diff --git a/.agents/skills/testing-workflow/SKILL.md b/.agents/skills/testing-workflow/SKILL.md index 5a581b13..5c108e3c 100644 --- a/.agents/skills/testing-workflow/SKILL.md +++ b/.agents/skills/testing-workflow/SKILL.md @@ -34,6 +34,7 @@ suites are outside this distribution's verification. | Sandbox on macOS | `pnpm test:sandbox` | | Source-sync, workflow and release tools | `pnpm test:release-tools` | | npm release archive installation | `MCODE_RELEASE_TAG=vX.Y.Z MCODE_RELEASE_ARCHIVE=/path/to/package.tar.gz pnpm verify --profile package` | +| TUI source and test lint | `pnpm lint:tui` | | Types and standalone build boundary | `pnpm typecheck`, `pnpm build`, `pnpm check:standalone` | | Published files and generated paths | `pnpm check:source`, `pnpm check:tsconfig` | diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 00000000..a6759cd0 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,129 @@ +// TUI rules adapted from the source repository, without browser/React rules. +// Keep Pi engine style exceptions aligned with the audited fork. +module.exports = { + root: true, + env: { + es2022: true, + node: true, + }, + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + }, + plugins: ["@typescript-eslint", "import", "prettier"], + extends: [ + "airbnb-base", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended", + ], + ignorePatterns: [ + "dist/", + "node_modules/", + "third_party/", + "packages/tui/test/pi-084-upstream/", + ], + rules: { + "prettier/prettier": [ + "warn", + { + trailingComma: "all", + tabWidth: 2, + semi: true, + singleQuote: true, + useTabs: false, + bracketSpacing: true, + arrowParens: "always", + jsxSingleQuote: false, + printWidth: 100, + endOfLine: "auto", + embeddedLanguageFormatting: "auto", + proseWrap: "always", + }, + ], + "import/extensions": "off", + "import/prefer-default-export": "off", + "import/no-extraneous-dependencies": "off", + "import/no-unresolved": "off", + "import/order": "off", + "no-shadow": "off", + "@typescript-eslint/no-shadow": "error", + "no-use-before-define": "off", + "@typescript-eslint/no-use-before-define": "off", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + }, + ], + "consistent-return": "off", + "no-restricted-syntax": "off", + "class-methods-use-this": "off", + "no-underscore-dangle": "off", + "no-continue": "off", + "no-await-in-loop": "off", + "no-void": "off", + "no-nested-ternary": "off", + "no-plusplus": "off", + "no-bitwise": "off", + "no-cond-assign": "off", + "no-promise-executor-return": "off", + "no-restricted-globals": "off", + "no-duplicate-imports": "off", + "no-multi-assign": "off", + "default-case": "off", + "max-classes-per-file": "off", + radix: "off", + "no-param-reassign": [ + "error", + { + props: false, + }, + ], + "no-control-regex": "off", + "@typescript-eslint/naming-convention": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-non-null-assertion": "warn", + "@typescript-eslint/no-loop-func": "warn", + camelcase: "off", + "lines-between-class-members": "off", + "no-unused-expressions": "off", + "dot-notation": "off", + "no-useless-constructor": "off", + "no-empty-function": "off", + "no-return-await": "off", + "no-throw-literal": "off", + "default-param-last": "off", + "no-loop-func": "off", + eqeqeq: "error", + "no-var": "error", + "prefer-const": "error", + "no-console": [ + "warn", + { + allow: ["warn", "error"], + }, + ], + "no-alert": "warn", + "no-empty": "warn", + "no-unsafe-finally": "warn", + "no-constant-condition": "warn", + "prefer-destructuring": "off", + }, + overrides: [ + { + files: ["packages/tui/src/tui/engine/**/*.ts"], + excludedFiles: ["packages/tui/src/tui/engine/public.ts"], + rules: { + "@typescript-eslint/no-shadow": "off", + "no-else-return": "off", + "no-lonely-if": "off", + "no-param-reassign": "off", + "one-var": "off", + "prefer-regex-literals": "off", + }, + }, + ], +}; diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index bcbb650a..3446bf64 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 a61c856d..6567cc6d 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. @@ -39,6 +41,8 @@ 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. 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. +`pnpm lint` (or `pnpm lint:tui`) checks TUI source and tests with the source repository's Airbnb, TypeScript, import and Prettier rules. Errors fail verification; formatting, unused variables and explicit `any` remain warnings, matching the source policy. The scripts use `--quiet` to keep existing warning debt out of CI logs; omit it when invoking ESLint directly to inspect warnings. The audited Pi engine retains its narrow style exceptions; its public facade uses the normal rules. Vendored Pi test copies are excluded. Full, platform, archive and Windows verification include this gate; docs and package-installation profiles do not. + 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. diff --git a/README.md b/README.md index 98f26d8d..4077d1ce 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,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 80546d24..bdd07e7f 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -183,7 +183,7 @@ kcode --session | 引用工作区文件或目录 | `@` | | 切换 Plan Mode | `Shift+Tab` | | 切换权限模式 | `Alt+M` | -| 关闭面板或中断正在运行的任务 | `Esc` | +| 关闭面板或中断正在运行的任务;在模型回复之前中断会把消息放回输入框 | `Esc` | ## 卸载 diff --git a/docs/examples.md b/docs/examples.md index 32c2d6a6..cc3cd289 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/docs/maintainers.md b/docs/maintainers.md index 2d1c8847..e1b4a125 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. 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/docs/tui-capabilities.md b/docs/tui-capabilities.md index de7621f8..1fbfe42a 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -22,6 +22,15 @@ The evidence column summarizes the historical TUI 0.3.11 restoration record from | Files, shell, subagents, sessions, headless, ACP | Actual runtime retained | BYOK, file reads, session resume, ACP, sandbox, and status protocol tests | | Built-in skills, MCP, plugin tools | Original TUI assets and activation conditions retained | Asset build, plugin, and MCP tests; no claim that every skill has passed a real task | +## Skill directory links + +Workspace `.agents/skills`, `.claude/skills`, and `.minimax/skills` support +directory symlinks, both for the entire skill root and for individual skill +directories. Targets may live outside the workspace. Existing external-source +enable settings and duplicate-name priority still apply. Linked directories are +watched for `SKILL.md` creation and edits; broken links are skipped. `SKILL.md` +itself must remain a regular file. + ## ACP Skill commands ACP clients receive enabled Skills alongside built-in slash commands when a session @@ -85,12 +94,22 @@ 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, replacing or shrinking a transient region +restores the exposed chat rows. This includes inline selectors such as `/theme`, +completion menus, multi-line drafts, image previews, queued messages, task and +Goal summaries, welcome notices and status rows. Short documents refresh in place; +history is reconstructed only when the smaller layout needs to bring scrolled +rows back into view. This rule follows the rendered layout, including asynchronous +updates, rather than requiring each close handler to request a special redraw. +When background running content shrinks entirely within the current screen and +the transient layout stays unchanged, 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/package.json b/package.json index afe63510..5c5d8cdd 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "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" + "test:release-tools": "node --test test/source-sync.test.mjs", + "lint": "pnpm run lint:tui", + "lint:tui": "eslint packages/tui/src packages/tui/test --ext .ts,.mjs --quiet" }, "devDependencies": { "@types/node": "^20.19.0", @@ -41,7 +43,14 @@ "vitest": "4.1.11", "yaml": "2.9.0", "tar": "7.5.22", - "vite": "7.3.6" + "vite": "7.3.6", + "eslint": "8.57.1", + "@typescript-eslint/eslint-plugin": "7.18.0", + "@typescript-eslint/parser": "7.18.0", + "eslint-config-airbnb-base": "15.0.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-import": "2.32.0", + "eslint-plugin-prettier": "5.5.5" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/packages/agent-modules/skills/src/registry.ts b/packages/agent-modules/skills/src/registry.ts index 50c469b4..97277ce6 100644 --- a/packages/agent-modules/skills/src/registry.ts +++ b/packages/agent-modules/skills/src/registry.ts @@ -226,7 +226,7 @@ export class SkillRegistry { }; for (const root of this.roots) { collectPath(root.rootPath, root); - for (const skillDir of listWatchableSkillDirs(root.rootPath)) { + for (const skillDir of listWatchableSkillDirs(root)) { collectPath(skillDir, root); } } @@ -472,7 +472,10 @@ function existingWatchTarget(targetPath: string): SkillWatchTarget | undefined { } function allowsDirectorySymlinkOutsideRoot(root: SkillSourceRoot): boolean { - return ['agent', 'global', 'user', 'builtin'].includes(root.kind); + return ( + root.allowDirectorySymlinksOutsideRoot === true || + ['agent', 'global', 'user', 'builtin'].includes(root.kind) + ); } async function listSkillFiles( @@ -811,10 +814,20 @@ function sameSkillFileStat(expected: SkillFileStat, actual: SkillFileStat): bool ); } -function listWatchableSkillDirs(rootPath: string): string[] { +function listWatchableSkillDirs(root: SkillSourceRoot): string[] { + const { rootPath } = root; try { return readdirSync(rootPath, { withFileTypes: true }) - .filter((child) => child.isDirectory()) + .filter((child) => { + if (child.isDirectory()) return true; + if (!child.isSymbolicLink() || !allowsDirectorySymlinkOutsideRoot(root)) return false; + try { + // Watch linked directories even before they contain a SKILL.md. + return statSync(path.join(rootPath, child.name)).isDirectory(); + } catch { + return false; + } + }) .map((child) => path.join(rootPath, child.name)) .sort(); } catch { diff --git a/packages/agent-modules/skills/src/types.ts b/packages/agent-modules/skills/src/types.ts index 150617e0..841c158e 100644 --- a/packages/agent-modules/skills/src/types.ts +++ b/packages/agent-modules/skills/src/types.ts @@ -7,6 +7,8 @@ export interface SkillSourceRoot { rootPath: string; priority?: number; external?: boolean; + /** Follow directory links outside the root for configured compatibility sources. */ + allowDirectorySymlinksOutsideRoot?: boolean; } export type SkillDiagnosticLevel = 'warning' | 'error'; diff --git a/packages/agent-modules/system-reminder/package.json b/packages/agent-modules/system-reminder/package.json index 1ee0840c..0ddefcd9 100644 --- a/packages/agent-modules/system-reminder/package.json +++ b/packages/agent-modules/system-reminder/package.json @@ -14,7 +14,8 @@ "@mavis/config": "workspace:^", "@types/node": "^20", "typescript": "^5", - "vitest": "^2" + "vitest": "^2", + "@mavis/shared": "workspace:^" }, "private": true, "license": "MIT" diff --git a/packages/agent-modules/system-reminder/src/plugin-reference.ts b/packages/agent-modules/system-reminder/src/plugin-reference.ts index 5878a928..2ad38865 100644 --- a/packages/agent-modules/system-reminder/src/plugin-reference.ts +++ b/packages/agent-modules/system-reminder/src/plugin-reference.ts @@ -1,12 +1,15 @@ +import { parsePluginMentions } from '@mavis/shared/plugin-mention'; + export interface EffectivePluginToolGroup { readonly source: string; readonly tools: readonly string[]; - /** Deferred App tools must be discovered before they can be invoked. */ + /** Deferred tools must be discovered before they can be invoked. */ readonly access?: 'direct' | 'tool_search'; } export interface EffectivePluginCapabilityInventory { readonly name: string; + readonly pluginId?: string; readonly appTools: readonly EffectivePluginToolGroup[]; readonly mcpTools: readonly EffectivePluginToolGroup[]; readonly skills: readonly string[]; @@ -33,7 +36,7 @@ export function detectPluginReferencesForMessages { + if (plugins.length === 0 && unavailablePluginIds.length === 0) return undefined; + const blocks = plugins.slice(0, 8).map((plugin) => { const name = inlineCode(plugin.name); - return [ + const detail = [ ``, `Referenced as \`@${name}\`.`, '', @@ -66,20 +70,46 @@ export function buildPluginReferenceReminder( '', '', ].join('\n'); + return detail.length <= 4096 + ? detail + : `\nCapability details omitted to fit the context limit. Use this Plugin's tool provenance and Skill namespace in the available catalogs.\n`; }); + const bounded: string[] = []; + let remaining = 12_288; + let omitted = plugins.length > 8 || unavailablePluginIds.length > 8; + for (const block of [ + ...blocks, + ...unavailablePluginIds + .slice(0, 8) + .map( + (id) => + `Selected Plugin \`${inlineCode(id)}\` is unavailable for this turn. Tell the user it could not be used; do not substitute a same-named Plugin or enable/install it automatically.`, + ), + ]) { + if (block.length + 2 > remaining) { + omitted = true; + continue; + } + bounded.push(block); + remaining -= block.length + 2; + } + return [ '', 'The user explicitly selected the following Plugin capabilities for this request.', 'Prefer them when relevant; other tools remain available if needed.', '', - blocks.join('\n\n'), + bounded.join('\n\n'), + ...(omitted ? ['Additional selected capabilities omitted to fit the context limit.'] : []), '', - 'Only the capabilities listed above are effective for this turn.', + 'The listed capabilities are drawn from the effective inventory for this turn; lists may be truncated.', 'Do not invent or claim unavailable Plugin capabilities.', - ...(plugins.some((plugin) => plugin.appTools.some((group) => group.access === 'tool_search')) + ...(plugins.some((plugin) => + [...plugin.appTools, ...plugin.mcpTools].some((group) => group.access === 'tool_search'), + ) ? [ - 'For App tools marked `via tool_search + mcp_invoke`, discover the exact tool with `tool_search` before calling it through `mcp_invoke`.', + 'For tools marked `via tool_search + mcp_invoke`, discover the exact tool with `tool_search` before calling it through `mcp_invoke`.', ] : []), 'Before following a listed Skill, call the `skill` tool with its exact name.', @@ -91,6 +121,7 @@ export function buildPluginReferenceReminder( function formatAppToolGroups(groups: readonly EffectivePluginToolGroup[]): string { if (groups.length === 0) return 'none'; return [...groups] + .slice(0, 16) .sort((left, right) => { const sourceOrder = normalizedName(left.source).localeCompare(normalizedName(right.source)); if (sourceOrder !== 0) return sourceOrder; @@ -108,11 +139,24 @@ function detectReferencesInText( text: string, plugins: readonly T[], ): T[] { - const normalizedText = text.normalize('NFKC'); + const linked = parsePluginMentions(text); + let plainText = text; + for (const mention of [...linked].reverse()) + plainText = `${plainText.slice(0, mention.start)}${' '.repeat(mention.end - mention.start)}${plainText.slice(mention.end)}`; + const normalizedText = plainText.normalize('NFKC'); const matches: Array<{ index: number; plugin: T }> = []; for (const plugin of plugins) { + const explicit = linked.find((mention) => mention.pluginId === plugin.pluginId); + if (explicit) { + matches.push({ index: explicit.start, plugin }); + continue; + } const name = normalizedName(plugin.name); - if (!name) continue; + if ( + !name || + plugins.filter((candidate) => normalizedName(candidate.name) === name).length !== 1 + ) + continue; const pattern = new RegExp(`(^|\\s)@${escapeRegExp(name)}(?=\\s|$)`, 'giu'); const match = pattern.exec(normalizedText); if (!match) continue; @@ -128,16 +172,19 @@ function formatToolGroups( ): string { if (groups.length === 0) return 'none'; return [...groups] + .slice(0, 16) .sort((left, right) => normalizedName(left.source).localeCompare(normalizedName(right.source))) .map((group) => { const tools = formatToolNames(group.tools); - return `- ${label} \`${inlineCode(group.source)}\`: ${tools || 'none'}`; + const access = group.access === 'tool_search' ? ' via `tool_search` + `mcp_invoke`' : ''; + return `- ${label} \`${inlineCode(group.source)}\`${access}: ${tools || 'none'}`; }) .join('\n'); } function formatToolNames(tools: readonly string[]): string { return [...new Set(tools)] + .slice(0, 32) .sort((left, right) => normalizedName(left).localeCompare(normalizedName(right))) .map((tool) => `\`${inlineCode(tool)}\``) .join(', '); @@ -146,6 +193,7 @@ function formatToolNames(tools: readonly string[]): string { function formatSkills(skills: readonly string[]): string { if (skills.length === 0) return 'none'; return [...new Set(skills)] + .slice(0, 32) .sort((left, right) => normalizedName(left).localeCompare(normalizedName(right))) .map((skill) => `- \`${inlineCode(skill)}\``) .join('\n'); 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/local-runtime-v2/src/application/conversation/conversation-application.ts b/packages/local-runtime-v2/src/application/conversation/conversation-application.ts index c7be0ea1..1f7c92f8 100644 --- a/packages/local-runtime-v2/src/application/conversation/conversation-application.ts +++ b/packages/local-runtime-v2/src/application/conversation/conversation-application.ts @@ -70,6 +70,8 @@ import { } from "./session-stream-delivery.js"; export interface ConversationSendMessageRequest extends SendMessageReq { + /** Readable projection supplied by the process-local composer; content retains transport identity. */ + readonly displayContent?: string; /** Process-local caller's Unix-ms deadline; its cancellation owner still enforces it. */ readonly executionDeadlineAtMs?: number; /** Process-local opt-in; emits metadata only, never provider payloads. */ @@ -575,6 +577,9 @@ async function toDirectSendInput( input: toAgentHostUserInput(req, text, attachments), ...executionOptions, provenance: messageProvenance(req), + ...(req.displayContent !== undefined + ? { displayContent: req.displayContent } + : {}), ...(materialized.displayAttachments.length > 0 ? { displayAttachments: materialized.displayAttachments } : {}), diff --git a/packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts b/packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts index 28f3e872..3c2a6e91 100644 --- a/packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts +++ b/packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts @@ -26,6 +26,7 @@ export interface DirectSendInput { readonly outputContract?: SubmitTurnSubmission['outputContract']; readonly executionDeadlineAtMs?: SubmitTurnSubmission['executionDeadlineAtMs']; readonly provenance: SubmitTurnSubmission['provenance']; + readonly displayContent?: string; readonly displayAttachments?: readonly UserMessageAttachment[]; readonly requestedTurnId?: string; readonly hideUserMessage?: boolean; @@ -388,8 +389,12 @@ async function completeAfterSettlement( function directSendDisplayOptions( input: DirectSendInput, -): Pick, 'displayAttachments' | 'hideUserMessage'> { +): Pick< + NonNullable, + 'displayContent' | 'displayAttachments' | 'hideUserMessage' +> { return { + ...(input.displayContent !== undefined ? { displayContent: input.displayContent } : {}), ...(input.displayAttachments ? { displayAttachments: input.displayAttachments.map((attachment) => ({ ...attachment })) } : {}), diff --git a/packages/local-runtime-v2/src/application/conversation/user-message-turn-delivery.ts b/packages/local-runtime-v2/src/application/conversation/user-message-turn-delivery.ts index 22fb114c..c4aa266b 100644 --- a/packages/local-runtime-v2/src/application/conversation/user-message-turn-delivery.ts +++ b/packages/local-runtime-v2/src/application/conversation/user-message-turn-delivery.ts @@ -1,3 +1,4 @@ +import { parsePluginMentions } from '@mavis/shared/plugin-mention'; import { randomUUID } from 'node:crypto'; import { @@ -126,6 +127,7 @@ export class UserMessageTurnDeliveryService implements UserMessageTurnDelivery { ...(input.message.userMessageId ? { userMessageId: input.message.userMessageId } : {}), unstartedFromTurnIds: input.message.unstartedFromTurnIds, content: projectedMessageContent(delivery, input.message.message.text), + inputContent: input.message.message.text, ...(attachments ? { attachments } : {}), ...(input.message.sourceMessageId ? { sourceMessageId: input.message.sourceMessageId } : {}), provenance: input.message.provenance, @@ -295,6 +297,7 @@ async function commitTurnMessages( messageKey: member.messageKey, userMessageId: member.userMessageId, content: projectedMessageContent(member.message, member.message.content), + inputContent: member.message.content, timestamp: member.createdAt, ...(member.unconsumedFromTurnIds ? { unconsumedFromTurnIds: member.unconsumedFromTurnIds } @@ -323,6 +326,7 @@ async function commitTurnMessage( messageKey: input.messageKey ?? `turn:${turnId}`, ...(input.userMessageId ? { userMessageId: input.userMessageId } : {}), content: projectedMessageContent(input, input.input.text), + inputContent: input.input.text, ...(queryKey ? { queryKey } : {}), ...(attachments ? { attachments } : {}), ...(input.sourceMessageId ? { sourceMessageId: input.sourceMessageId } : {}), @@ -340,6 +344,7 @@ function commitMessage( readonly messageKey: string; readonly userMessageId?: UserMessageId; readonly content: string; + readonly inputContent: string; readonly timestamp?: number; readonly unconsumedFromTurnIds?: readonly string[]; readonly unstartedFromTurnIds?: readonly string[]; @@ -360,6 +365,9 @@ function commitMessage( messageKey: input.messageKey, ...(input.userMessageId ? { userMessageId: input.userMessageId } : {}), content: input.content, + ...(input.inputContent !== input.content && parsePluginMentions(input.inputContent).length > 0 + ? { editContent: input.inputContent } + : {}), ...(input.timestamp !== undefined ? { timestamp: input.timestamp } : {}), ...(input.unconsumedFromTurnIds ? { unconsumedFromTurnIds: input.unconsumedFromTurnIds } diff --git a/packages/local-runtime-v2/src/application/session/content-application.ts b/packages/local-runtime-v2/src/application/session/content-application.ts index 81e295e4..27b69097 100644 --- a/packages/local-runtime-v2/src/application/session/content-application.ts +++ b/packages/local-runtime-v2/src/application/session/content-application.ts @@ -481,6 +481,7 @@ export function toSessionMessageView( ); return { msgId: maybeString(firstDefined([message.msgId, message.msg_id])) ?? "", + ...(typeof message.editContent === "string" ? { editContent: message.editContent } : {}), parentMsgId: maybeString( firstDefined([message.parentMsgId, message.parent_msg_id]), ), 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 d36c15fa..d19e8114 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(); + }); +}); diff --git a/packages/local-runtime-v2/src/service/session-system/messages/repo/contract.ts b/packages/local-runtime-v2/src/service/session-system/messages/repo/contract.ts index 197c3f35..f2b38fc5 100644 --- a/packages/local-runtime-v2/src/service/session-system/messages/repo/contract.ts +++ b/packages/local-runtime-v2/src/service/session-system/messages/repo/contract.ts @@ -2,6 +2,8 @@ import type { AppDb } from '../../../../infra/db/client.js'; export interface DisplayMessageRecord extends Record { readonly msg_id?: string; + /** Canonical user input preserved when display text hides plugin identities. */ + readonly editContent?: string; /** Stable canonical identity of this completed assistant message, when known. */ readonly canonical_message_id?: string; readonly role?: string; diff --git a/packages/local-runtime-v2/src/service/session-system/messages/user-message-commit-service.ts b/packages/local-runtime-v2/src/service/session-system/messages/user-message-commit-service.ts index d6c54d10..589f0796 100644 --- a/packages/local-runtime-v2/src/service/session-system/messages/user-message-commit-service.ts +++ b/packages/local-runtime-v2/src/service/session-system/messages/user-message-commit-service.ts @@ -47,6 +47,8 @@ export interface CommitUserMessageInput { /** Pre-generated identity shared by Queue, Display and canonical history. */ readonly userMessageId?: UserMessageId; readonly content?: string; + /** Canonical input retained when readable display text hides explicit plugin identities. */ + readonly editContent?: string; /** Display classification independent from Turn/query identity. */ readonly kind?: string; /** Query sidecar identity; absent only for legacy or non-query user Messages. */ @@ -118,6 +120,7 @@ export class UserMessageCommitService { msg_id: messageId, role: 'user', msg_content: input.content ?? '', + ...(input.editContent !== undefined ? { editContent: input.editContent } : {}), msg_type: 1, timestamp: input.timestamp ?? this.nowMs(), kind: input.kind, diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts new file mode 100644 index 00000000..28432121 --- /dev/null +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts @@ -0,0 +1,169 @@ +import { buildLocalTurnToolCatalog } from './local-turn-tool-catalog.js'; +import { Type } from '@sinclair/typebox'; +import { describe, expect, it, vi } from 'vitest'; +import type { RuntimeTool } from '@mavis/agent-core/tools'; +import { + detectPluginReferencesForMessages, + buildPluginReferenceReminder, +} from '@mavis/system-reminder'; +import { + mergeAgentHostTurnCapabilities, + type MergeAgentHostTurnCapabilitiesInput, +} from './local-turn-plugin-capabilities.js'; + +function tool(name: string): RuntimeTool { + return { + def: { name, description: 'Synthetic test tool', schema: Type.Object({}) }, + impl: { + execute: vi.fn(async () => ({ + tool_name: name, + text: 'ok', + content: [{ type: 'text' as const, text: 'ok' }], + })), + }, + }; +} +function input( + userText = '[@My Notes](plugin://notes%40local) summarize', +): MergeAgentHostTurnCapabilitiesInput { + const mcp = tool('mcp__notes__read'); + const app = tool('app_notes_search'); + return { + tools: [], + plan: { deferred: false, inlineTools: [] }, + model: { provider: 'synthetic', id: 'offline', contextWindow: 128000 }, + options: { + enabled: true, + modelWhitelist: [], + thresholdPct: 10, + minDeferCount: 1, + topKDefault: 5, + topKMax: 10, + systemHint: true, + maxSchemaTextLen: 1000, + }, + effectivePluginSkills: [{ pluginName: 'notes', name: 'notes:organize' }], + capabilities: { + revision: 'r1', + plugins: [{ name: 'notes', source: 'local', appProviders: ['notes-app'] }], + skills: [], + runtimeTools: [mcp, app], + runtimeToolBindings: [ + { kind: 'mcp', source: 'notes-server', pluginName: 'notes', tool: mcp }, + { kind: 'app', source: 'notes-app', toolMode: 'tool_search', tool: app }, + ], + }, + userText, + }; +} + +describe('Plugin identity selection at the final Runtime capability boundary', () => { + it('selects exact IDs independently of aliases and never substitutes a same-named source', () => { + const local = { + name: 'notes', + pluginId: 'notes@local', + skills: ['notes:organize'], + appTools: [], + mcpTools: [], + }; + const official = { ...local, pluginId: 'notes@official' }; + expect( + detectPluginReferencesForMessages([], '[@Alias](plugin://notes%40local)', [official, local]), + ).toEqual([local]); + expect( + detectPluginReferencesForMessages([], '[@notes](plugin://notes%40missing)', [local]), + ).toEqual([]); + expect(detectPluginReferencesForMessages([], '@notes summarize', [official, local])).toEqual( + [], + ); + expect(detectPluginReferencesForMessages([], '@notes summarize', [local])).toEqual([local]); + }); + + it('advertises only effective skills, direct MCP and searchable App tools, preserving callable implementations', async () => { + const request = input(); + const result = mergeAgentHostTurnCapabilities(request); + expect(result.reminder).toContain('notes:organize'); + expect(result.reminder).toContain('mcp__notes__read'); + expect(result.reminder).toContain('app_notes_search'); + expect(result.reminder).toContain('tool_search'); + expect(result.plan.deferred).toBe(true); + const mcp = result.tools.find((tool) => tool.def.name === 'mcp__notes__read')!; + await expect(mcp.impl.execute({ sessionId: 's', turnId: 't' }, {})).resolves.toMatchObject({ + text: 'ok', + }); + expect(mcp.toolCallProvenanceResolver?.({ phase: 'start', toolName: mcp.def.name })).toEqual([ + expect.objectContaining({ plugin_name: 'notes' }), + ]); + }); + + it('discovers and invokes deferred MCP and App tools through the final catalog', async () => { + const request = input(); + const mcp = request.capabilities!.runtimeTools[0]!; + const result = buildLocalTurnToolCatalog({ + sessionId: 's', + llmModel: request.model, + sources: { + nativeTools: [], + mcpEntries: [{ tool: mcp, source: 'configured', serverName: 'notes-server' }], + threadGoalTools: [], + cuRuntimeAvailable: false, + }, + config: { ...request.options, modelWhitelist: ['*'], thresholdPct: 0 }, + desktopCapabilities: request.capabilities, + effectivePluginSkills: request.effectivePluginSkills, + userText: request.userText, + }); + expect(result.tools.map((tool) => tool.def.name)).not.toContain(mcp.def.name); + expect(result.userPromptPrefix).toContain( + 'server `notes-server` via `tool_search` + `mcp_invoke`', + ); + const search = result.tools.find((tool) => tool.def.name === 'tool_search')!; + const invoke = result.tools.find((tool) => tool.def.name === 'mcp_invoke')!; + for (const name of ['mcp__notes__read', 'app_notes_search']) { + const found = await search.impl.execute({ sessionId: 's', turnId: 't' }, { query: name }); + expect(JSON.stringify(found)).toContain(name); + await expect( + invoke.impl.execute({ sessionId: 's', turnId: 't' }, { tool_name: name, arguments: {} }), + ).resolves.toMatchObject({ text: 'ok' }); + } + }); + + it('reports a plugin disabled before this turn and does not activate it or use another source', () => { + const request = input(); + const result = mergeAgentHostTurnCapabilities({ + ...request, + effectivePluginSkills: [], + capabilities: { + revision: 'r2', + plugins: [], + runtimeTools: [], + runtimeToolBindings: [], + skills: [], + }, + }); + expect(result.reminder).toContain('notes@local'); + expect(result.reminder).toContain('unavailable for this turn'); + expect(result.tools).toEqual([]); + expect( + mergeAgentHostTurnCapabilities({ ...request, capabilities: undefined }).reminder, + ).toContain('unavailable'); + expect( + mergeAgentHostTurnCapabilities(input('[@notes](plugin://notes%40official)')).reminder, + ).toContain('notes@official` is unavailable'); + }); + + it('bounds capability instructions even for large inventories', () => { + const result = buildPluginReferenceReminder( + Array.from({ length: 40 }, (_, index) => ({ + name: `plugin-${index}`, + pluginId: `plugin-${index}@local`, + skills: Array.from({ length: 200 }, (_, i) => `plugin-${index}:skill-${i}`), + appTools: [], + mcpTools: [], + })), + ); + expect(result!.length).toBeLessThan(16384); + expect(result).toContain('omitted'); + expect(result).toContain(''); + }); +}); diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts index 6d7656a1..0f956f1c 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts @@ -1,3 +1,4 @@ +import { buildPluginId, parsePluginMentions } from '@mavis/shared/plugin-mention'; import type { RuntimeTool } from '@mavis/agent-core/tools'; import { attachPluginCapabilityAttribution, @@ -66,7 +67,10 @@ export interface MergeAgentHostTurnCapabilitiesInput { readonly capabilities?: AgentHostTurnCapabilityView; readonly model: McpModelIdentity; readonly options: McpDisclosureOptions; - readonly effectivePluginSkills: readonly { readonly pluginName: string; readonly name: string }[]; + readonly effectivePluginSkills: readonly { + readonly pluginName: string; + readonly name: string; + }[]; readonly hostCapabilityRegistry?: HostCapabilityResolver; readonly surface?: HostCapabilitySurface; readonly userText?: string; @@ -84,7 +88,13 @@ export function mergeAgentHostTurnCapabilities( ): MergedAgentHostTurnCapabilities { const merged = mergeTools(input); const capabilities = input.capabilities; - if (!capabilities) return merged; + if (!capabilities) { + const reminder = buildPluginReferenceReminder( + [], + parsePluginMentions(input.userText ?? '').map((mention) => mention.pluginId), + ); + return { ...merged, ...(reminder ? { reminder } : {}) }; + } const attributed = { ...merged, tools: attachPluginCapabilityAttribution( @@ -98,29 +108,38 @@ export function mergeAgentHostTurnCapabilities( ), }; if (input.userText === undefined) return attributed; - const reminder = buildPluginReferenceReminder( - detectPluginReferencesForMessages( - [{ content: input.userText }], - input.userText, - buildCapabilityInventory({ - capabilities, - effectivePluginSkills: input.effectivePluginSkills, - // Attribution decorates relevant tools with cloned wrapper objects. - // Inventory filtering must retain the pre-decoration identities used - // by runtimeToolBindings and the deferred registry. - finalTools: merged.tools, - deferredToolNames: new Set( - attributed.plan.deferred ? attributed.plan.deferredRegistry.keys() : [], - ), - }), + const inventory = buildCapabilityInventory({ + capabilities, + effectivePluginSkills: input.effectivePluginSkills, + // Inventory filtering uses the pre-attribution tool identities. + finalTools: merged.tools, + deferredToolNames: new Set( + attributed.plan.deferred ? attributed.plan.deferredRegistry.keys() : [], ), + }); + const unavailable = [ + ...new Set(parsePluginMentions(input.userText).map((mention) => mention.pluginId)), + ].filter( + (id) => + !inventory.some( + (plugin) => + plugin.pluginId === id && + (plugin.skills.length > 0 || plugin.appTools.length > 0 || plugin.mcpTools.length > 0), + ), + ); + const reminder = buildPluginReferenceReminder( + detectPluginReferencesForMessages([{ content: input.userText }], input.userText, inventory), + unavailable, ); return { ...attributed, ...(reminder ? { reminder } : {}) }; } function buildAttributionIndex(input: { readonly capabilities: AgentHostTurnCapabilityView; - readonly effectivePluginSkills: readonly { readonly pluginName: string; readonly name: string }[]; + readonly effectivePluginSkills: readonly { + readonly pluginName: string; + readonly name: string; + }[]; readonly finalTools: readonly RuntimeTool[]; readonly plan: McpDisclosurePlan; }): PluginCapabilityAttributionIndex { @@ -273,7 +292,10 @@ function mergeSearchableTools( ); for (const tool of selected.searchable) deferredRegistry.set(tool.def.name, tool); const index = buildOrReuseIndex( - [...deferredRegistry.values()].map((tool) => ({ tool, source: 'configured' as const })), + [...deferredRegistry.values()].map((tool) => ({ + tool, + source: 'configured' as const, + })), { maxSchemaTextLen: input.options.maxSchemaTextLen }, ); const estimate = input.options.estimateTokens ?? estimateToolTokens; @@ -312,16 +334,20 @@ function mergeSearchableTools( function buildCapabilityInventory(input: { readonly capabilities: AgentHostTurnCapabilityView; - readonly effectivePluginSkills: readonly { readonly pluginName: string; readonly name: string }[]; + readonly effectivePluginSkills: readonly { + readonly pluginName: string; + readonly name: string; + }[]; readonly finalTools: readonly RuntimeTool[]; readonly deferredToolNames: ReadonlySet; }): EffectivePluginCapabilityInventory[] { const finalTools = new Set(input.finalTools); - const searchableAppToolsAvailable = + const searchableToolsAvailable = input.finalTools.some((tool) => tool.def.name === 'tool_search') && input.finalTools.some((tool) => tool.def.name === 'mcp_invoke'); return input.capabilities.plugins.map((plugin) => ({ name: plugin.name, + pluginId: buildPluginId(plugin.name, plugin.source), appTools: plugin.appProviders.flatMap((provider) => { const bindings = input.capabilities.runtimeToolBindings.filter( (binding) => binding.kind === 'app' && binding.source === provider, @@ -333,7 +359,7 @@ function buildCapabilityInventory(input: { ? [binding.tool.def.name] : [], ); - const searchable = searchableAppToolsAvailable + const searchable = searchableToolsAvailable ? bindings.flatMap((binding) => binding.toolMode === 'tool_search' && input.deferredToolNames.has(binding.tool.def.name) ? [binding.tool.def.name] @@ -343,18 +369,37 @@ function buildCapabilityInventory(input: { return [ ...(direct.length > 0 ? [{ source: provider, tools: direct }] : []), ...(searchable.length > 0 - ? [{ source: provider, tools: searchable, access: 'tool_search' as const }] + ? [ + { + source: provider, + tools: searchable, + access: 'tool_search' as const, + }, + ] : []), ]; }), - mcpTools: groupMcpTools( - input.capabilities.runtimeToolBindings.filter( - (binding) => - binding.kind === 'mcp' && - binding.pluginName === plugin.name && - finalTools.has(binding.tool), + mcpTools: [ + ...groupMcpTools( + input.capabilities.runtimeToolBindings.filter( + (binding) => + binding.kind === 'mcp' && + binding.pluginName === plugin.name && + finalTools.has(binding.tool), + ), ), - ), + ...(searchableToolsAvailable + ? groupMcpTools( + input.capabilities.runtimeToolBindings.filter( + (binding) => + binding.kind === 'mcp' && + binding.pluginName === plugin.name && + !finalTools.has(binding.tool) && + input.deferredToolNames.has(binding.tool.def.name), + ), + ).map((group) => ({ ...group, access: 'tool_search' as const })) + : []), + ], skills: input.effectivePluginSkills.flatMap((skill) => skill.pluginName === plugin.name ? [skill.name] : [], ), diff --git a/packages/local-runtime/src/skills/roots.ts b/packages/local-runtime/src/skills/roots.ts index 27dec24d..6b3afd7c 100644 --- a/packages/local-runtime/src/skills/roots.ts +++ b/packages/local-runtime/src/skills/roots.ts @@ -134,6 +134,7 @@ function externalSkillRoot( rootPath, priority: sourceConfig.priority + priorityBonus, external: true, + allowDirectorySymlinksOutsideRoot: true, }; } diff --git a/packages/local-runtime/test/unit/skill-directory-symlinks.test.ts b/packages/local-runtime/test/unit/skill-directory-symlinks.test.ts new file mode 100644 index 00000000..68067f42 --- /dev/null +++ b/packages/local-runtime/test/unit/skill-directory-symlinks.test.ts @@ -0,0 +1,233 @@ +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_SKILLS_CONFIG } from '@mavis/config'; +import { createSkillRegistry, type SkillRegistryWatcher } from '@mavis/skills'; +import { readConfiguredSkillRoots } from '../../src/skills/roots.js'; + +let fixture: string; +let workspace: string; +let watcher: SkillRegistryWatcher | undefined; + +beforeEach(async () => { + fixture = await realpath(await mkdtemp(join(tmpdir(), 'mcode-skill-links-'))); + workspace = join(fixture, 'workspace'); + await mkdir(join(workspace, '.git'), { recursive: true }); +}); + +afterEach(async () => { + watcher?.close(); + watcher = undefined; + await rm(fixture, { recursive: true, force: true }); +}); + +function workspaceRoots() { + return readConfiguredSkillRoots( + { dataDir: join(fixture, 'data'), provider: {} }, + 'mavis', + workspace, + ).filter((root) => root.kind === 'workspace'); +} + +async function writeSkill(dir: string, name = 'linked', body = 'Initial instructions') { + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, 'SKILL.md'), + `---\nname: ${name}\ndescription: Linked skill\n---\n# Linked skill\n${body}\n`, + ); +} + +async function linkDirectory(target: string, link: string, relativeTarget = false) { + await mkdir(dirname(link), { recursive: true }); + await symlink( + relativeTarget && process.platform !== 'win32' ? relative(dirname(link), target) : target, + link, + process.platform === 'win32' ? 'junction' : 'dir', + ); +} + +describe('configured workspace skill directory links', () => { + it.each(['.agents', '.claude', '.minimax'])( + 'discovers a linked %s/skills root', + async (source) => { + const target = join(workspace, 'skills', 'linked'); + await writeSkill(target); + await linkDirectory(dirname(target), join(workspace, source, 'skills'), true); + + const registry = await createSkillRegistry(workspaceRoots()); + const [skill] = registry.getAvailableSkills(); + expect(skill?.name).toBe('linked'); + expect(skill?.skillDir).toBe(target); + expect(registry.readByLocationUri(skill!.locationUri)).toContain('Initial instructions'); + expect(registry.getSnapshot()?.diagnostics).not.toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'skill_outside_root' })]), + ); + }, + ); + + it.each([false, true])( + 'discovers child directory links (relative: %s) outside the workspace', + async (relativeTarget) => { + const target = join(fixture, 'shared', 'linked'); + const link = join(workspace, '.agents', 'skills', 'linked'); + await writeSkill(target); + await linkDirectory(target, link, relativeTarget); + + const registry = await createSkillRegistry(workspaceRoots()); + const [skill] = registry.getAvailableSkills(); + expect(skill).toMatchObject({ + name: 'linked', + skillDir: target, + entryDir: link, + }); + expect(fileURLToPath(skill!.locationUri.replace(/^files:/, 'file:'))).toBe( + join(target, 'SKILL.md'), + ); + }, + ); + + it('deduplicates shared root aliases using existing source priority', async () => { + const target = join(workspace, 'skills', 'linked'); + await writeSkill(target); + await linkDirectory(dirname(target), join(workspace, '.agents', 'skills'), true); + await linkDirectory(dirname(target), join(workspace, '.claude', 'skills'), true); + + const registry = await createSkillRegistry(workspaceRoots()); + expect(registry.getAvailableSkills()).toHaveLength(1); + expect(registry.getAvailableSkills()[0]?.rootId).toContain('workspace-cc:'); + expect(registry.getSnapshot()?.entries).toHaveLength(2); + }); + + it('retains external-source disable controls', () => { + const config = { + dataDir: join(fixture, 'data'), + provider: {}, + skills: { + external: { ...DEFAULT_SKILLS_CONFIG.external, enabled: false }, + }, + }; + expect( + readConfiguredSkillRoots(config, 'mavis', workspace).filter((root) => root.external), + ).toEqual([]); + config.skills.external.enabled = true; + config.skills.external.sources = { + ...DEFAULT_SKILLS_CONFIG.external.sources, + 'workspace-agents': { enabled: false, priority: 55 }, + }; + expect( + readConfiguredSkillRoots(config, 'mavis', workspace).some((root) => + root.id.startsWith('external:workspace-agents:'), + ), + ).toBe(false); + }); + + it('refreshes a retargeted child link without retaining cached content', async () => { + const first = join(fixture, 'first'); + const second = join(fixture, 'second'); + const link = join(workspace, '.agents', 'skills', 'linked'); + await writeSkill(first, 'linked', 'First instructions'); + await writeSkill(second, 'linked', 'Second instructions'); + await linkDirectory(first, link); + const registry = await createSkillRegistry(workspaceRoots()); + expect(registry.getAvailableSkills()[0]?.content).toContain('First instructions'); + await rm(link); + await linkDirectory(second, link); + await registry.refresh(); + expect(registry.getAvailableSkills()[0]).toMatchObject({ + skillDir: second, + entryDir: link, + }); + expect(registry.getAvailableSkills()[0]?.content).toContain('Second instructions'); + }); + + it('watches linked directories before SKILL.md exists and reloads later edits', async () => { + const target = join(fixture, 'shared', 'linked'); + await mkdir(target, { recursive: true }); + await linkDirectory(target, join(workspace, '.agents', 'skills', 'linked')); + const registry = await createSkillRegistry(workspaceRoots()); + expect(registry.getAvailableSkills()).toEqual([]); + const onChange = vi.fn(); + watcher = registry.watch({ onChange }); + + // Native watchers may finish arming after watch() returns. Observe a real + // event in the linked target before making the one-shot SKILL.md write. + await vi.waitFor( + async () => { + if (onChange.mock.calls.length === 0) { + await writeFile(join(target, '.watch-ready'), String(Date.now())); + } + expect(onChange).toHaveBeenCalled(); + }, + { timeout: 4000, interval: 250 }, + ); + expect(registry.getAvailableSkills()).toEqual([]); + onChange.mockClear(); + await writeSkill(target); + await vi.waitFor( + () => { + expect(onChange).toHaveBeenCalled(); + expect(registry.getAvailableSkills()[0]?.content).toContain('Initial instructions'); + }, + { timeout: 4000 }, + ); + onChange.mockClear(); + await writeSkill(target, 'linked', 'Updated instructions'); + await vi.waitFor( + () => { + expect(onChange).toHaveBeenCalled(); + expect(registry.getAvailableSkills()[0]?.content).toContain('Updated instructions'); + }, + { timeout: 4000 }, + ); + }, 15000); + + it.skipIf(process.platform === 'win32')( + 'skips broken, cyclic and non-directory links without losing valid skills', + async () => { + const root = join(workspace, '.agents', 'skills'); + await writeSkill(join(root, 'valid'), 'valid'); + await symlink(join(fixture, 'missing'), join(root, 'broken')); + await symlink('cycle', join(root, 'cycle')); + await writeFile(join(fixture, 'plain.txt'), 'Not a directory'); + await symlink(join(fixture, 'plain.txt'), join(root, 'file')); + const registry = await createSkillRegistry(workspaceRoots()); + expect(registry.getAvailableSkills().map((skill) => skill.name)).toEqual(['valid']); + expect(registry.getSnapshot()?.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'skill_directory_link_unreadable' }), + expect.objectContaining({ + code: 'skill_directory_link_not_directory', + }), + ]), + ); + }, + ); + + it.skipIf(process.platform === 'win32')('continues rejecting SKILL.md file links', async () => { + const target = join(fixture, 'shared'); + await writeSkill(target); + const skillDir = join(workspace, '.agents', 'skills', 'linked'); + await mkdir(skillDir, { recursive: true }); + await symlink(join(target, 'SKILL.md'), join(skillDir, 'SKILL.md')); + const registry = await createSkillRegistry(workspaceRoots()); + expect(registry.getAvailableSkills()).toEqual([]); + expect(registry.getSnapshot()?.diagnostics).toContainEqual( + expect.objectContaining({ code: 'skill_symlink_rejected' }), + ); + }); + + it.each(['project', 'workspace'] as const)('keeps unopted %s roots bounded', async (kind) => { + const target = join(fixture, 'shared', 'linked'); + const root = join(workspace, 'bounded'); + await writeSkill(target); + await linkDirectory(target, join(root, 'linked')); + const registry = await createSkillRegistry([{ id: 'bounded', kind, rootPath: root }]); + expect(registry.getAvailableSkills()).toEqual([]); + expect(registry.getSnapshot()?.diagnostics).toContainEqual( + expect.objectContaining({ code: 'skill_outside_root' }), + ); + }); +}); diff --git a/packages/protocol/src/local.ts b/packages/protocol/src/local.ts index e42bc9de..c23b4605 100644 --- a/packages/protocol/src/local.ts +++ b/packages/protocol/src/local.ts @@ -339,6 +339,8 @@ export interface MemoryReferenceView { export interface SessionMessageView { msgId: string; + /** Canonical user text for editing when display text omits plugin identities. */ + editContent?: string; parentMsgId?: string; timestamp?: number; msgContent?: string; diff --git a/packages/shared/package.json b/packages/shared/package.json index c0b35a74..27707196 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -195,6 +195,10 @@ "./safety-check-v2": { "types": "./src/safety-check-v2.ts", "import": "./dist/safety-check-v2.js" + }, + "./plugin-mention": { + "types": "./src/plugin-mention.ts", + "import": "./dist/plugin-mention.js" } }, "types": "./src/index.ts", diff --git a/packages/shared/src/plugin-mention.ts b/packages/shared/src/plugin-mention.ts new file mode 100644 index 00000000..d738380b --- /dev/null +++ b/packages/shared/src/plugin-mention.ts @@ -0,0 +1,40 @@ +/** Durable plugin identity shared by Composer history and Runtime selection. */ +export function buildPluginId(name: string, source: 'official' | 'local'): string { + return `${name.trim()}@${source}`; +} + +export interface PluginMention { + readonly pluginId: string; + readonly label: string; + readonly start: number; + readonly end: number; +} + +export function serializePluginMention(pluginId: string, label: string): string { + const name = label + .replace(/^@/u, '') + .replace(/[\[\]\r\n\\]/gu, ' ') + .slice(0, 256); + return `[@${name}](plugin://${encodeURIComponent(pluginId)})`; +} + +/** Linked references also work in exec and ACP text inputs. Display names never select identity. */ +export function parsePluginMentions(text: string): PluginMention[] { + const result: PluginMention[] = []; + for (const match of text.matchAll(/\[@([^\]\r\n]{1,256})\]\(plugin:\/\/([^\s()]{1,768})\)/gu)) { + let pluginId: string; + try { + pluginId = decodeURIComponent(match[2]!); + } catch { + continue; + } + if (!pluginId || pluginId.length > 512 || /[\u0000-\u001f\u007f]/u.test(pluginId)) continue; + result.push({ + pluginId, + label: `@${match[1]}`, + start: match.index, + end: match.index + match[0].length, + }); + } + return result; +} 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/application/assistant-content.ts b/packages/tui/src/application/assistant-content.ts index c868b702..fa79e124 100644 --- a/packages/tui/src/application/assistant-content.ts +++ b/packages/tui/src/application/assistant-content.ts @@ -25,9 +25,9 @@ export function simplifyAssistantContentForTerminal(content: string): string { return [presentation.text, formatAssetGroup(presentation.assets)].filter(Boolean).join('\n\n'); } -export function projectAssistantContentForTerminal(content: string): TerminalAssistantContent { +export function projectAssistantContentForTerminal(rawContent: string): TerminalAssistantContent { // Model content is data. Only the renderer may introduce terminal controls. - content = sanitizeTerminalText(content); + const content = sanitizeTerminalText(rawContent); if (!content || !RICH_CONTENT_RE.test(content)) return { text: content, assets: [] }; const segments = splitMarkdownProtectedSegments(content); diff --git a/packages/tui/src/application/run-coordinator.ts b/packages/tui/src/application/run-coordinator.ts index 7e643f20..211a1eb2 100644 --- a/packages/tui/src/application/run-coordinator.ts +++ b/packages/tui/src/application/run-coordinator.ts @@ -28,6 +28,7 @@ export interface TuiRunRequest { turnId: string; session: Promise; content: string; + displayContent?: string; workspace: string; version: string; attachments?: readonly TuiTransportAttachment[]; @@ -87,7 +88,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; @@ -406,6 +407,7 @@ function toSendMessageRequest( id: session.sessionId, turnId: request.turnId, content: request.content, + ...(request.displayContent !== undefined ? { displayContent: request.displayContent } : {}), ...(executionDeadlineAtMs !== undefined ? { executionDeadlineAtMs } : {}), ...(request.executionDiagnostics ? { executionDiagnostics: true } : {}), ...(request.clientIntent ? { clientIntent: request.clientIntent } : {}), diff --git a/packages/tui/src/auth/application.ts b/packages/tui/src/auth/application.ts index 3b91da53..2ad2e991 100644 --- a/packages/tui/src/auth/application.ts +++ b/packages/tui/src/auth/application.ts @@ -64,43 +64,39 @@ export class KcodeAuthApplication implements KcodeAuthPort { onProgress?: (progress: KcodeAuthProgress) => void, region: MavisRegion = this.scope.region, ): Promise { - try { - const requestedScope = { ...this.scope, region }; - const switchesRegion = !isSameScope(requestedScope, this.scope); - let sharedAuthCore = this.options.sharedAuthCore; - if (switchesRegion) { - if (!this.options.resolveSharedAuthCore) { - throw new Error(formatEnvironmentConflict(this.scope, requestedScope)); - } - sharedAuthCore = this.options.resolveSharedAuthCore(region); + const requestedScope = { ...this.scope, region }; + const switchesRegion = !isSameScope(requestedScope, this.scope); + let sharedAuthCore = this.options.sharedAuthCore; + if (switchesRegion) { + if (!this.options.resolveSharedAuthCore) { + throw new Error(formatEnvironmentConflict(this.scope, requestedScope)); } - const wasAuthenticated = (await sharedAuthCore.getStatus()).status === 'authenticated'; - let deviceFlowStarted = false; - await sharedAuthCore.login({ - onDeviceAuthorization: (authorization: DeviceAuthorizationPrompt) => { - deviceFlowStarted = true; - onProgress?.({ state: 'device-authorization', ...authorization }); - }, - }); - this.persistRegionPreference(requestedScope); - const alreadyAuthenticated = wasAuthenticated && !deviceFlowStarted; - const result = { - state: alreadyAuthenticated - ? ('already-authenticated' as const) - : ('authenticated' as const), - message: alreadyAuthenticated - ? switchesRegion - ? `Already signed in with ${formatRegion(requestedScope.region)}.` - : 'Already signed in with MiniMax.' - : switchesRegion - ? `Signed in with ${formatRegion(requestedScope.region)}.` - : 'Signed in with MiniMax.', - ...(switchesRegion ? { restartRequired: true as const } : {}), - }; - return result; - } catch (error) { - throw error; + sharedAuthCore = this.options.resolveSharedAuthCore(region); } + const wasAuthenticated = (await sharedAuthCore.getStatus()).status === 'authenticated'; + let deviceFlowStarted = false; + await sharedAuthCore.login({ + onDeviceAuthorization: (authorization: DeviceAuthorizationPrompt) => { + deviceFlowStarted = true; + onProgress?.({ state: 'device-authorization', ...authorization }); + }, + }); + this.persistRegionPreference(requestedScope); + const alreadyAuthenticated = wasAuthenticated && !deviceFlowStarted; + const result = { + state: alreadyAuthenticated + ? ('already-authenticated' as const) + : ('authenticated' as const), + message: alreadyAuthenticated + ? switchesRegion + ? `Already signed in with ${formatRegion(requestedScope.region)}.` + : 'Already signed in with MiniMax.' + : switchesRegion + ? `Signed in with ${formatRegion(requestedScope.region)}.` + : 'Signed in with MiniMax.', + ...(switchesRegion ? { restartRequired: true as const } : {}), + }; + return result; } async logout(): Promise { 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/runtime/adapters/plugin-access.ts b/packages/tui/src/runtime/adapters/plugin-access.ts index 4d57acb3..69edc8af 100644 --- a/packages/tui/src/runtime/adapters/plugin-access.ts +++ b/packages/tui/src/runtime/adapters/plugin-access.ts @@ -1,3 +1,4 @@ +import { buildPluginId } from '@mavis/shared/plugin-mention'; import type { CliService, InstalledPluginSummary, @@ -124,7 +125,7 @@ function toPluginView( const name = plugin.name.trim(); if (!name) return undefined; return { - pluginId: `${name}@${marketplace}`, + pluginId: buildPluginId(name, marketplace), name, displayName: plugin.displayName?.trim() || name, marketplace, diff --git a/packages/tui/src/runtime/stream-events.ts b/packages/tui/src/runtime/stream-events.ts index 5644b107..55ca8996 100644 --- a/packages/tui/src/runtime/stream-events.ts +++ b/packages/tui/src/runtime/stream-events.ts @@ -44,6 +44,7 @@ export type TuiMessagePart = }; export interface TuiMessage { + editContent?: string; id?: string; turnId?: string; role: TuiMessageRole; @@ -333,6 +334,7 @@ function normalizeMessage(message: Record, fallbackTurnId?: str tokensBefore: readNumber(message, ['tokensBefore', 'tokens_before']), tokensAfter: readNumber(message, ['tokensAfter', 'tokens_after']), content: readString(message, ['msg_content', 'msgContent', 'content']), + editContent: readString(message, ['editContent']), thinking: readString(message, ['thinking_content', 'thinkingContent', 'thinking']), thinkingDurationMs: readNumber(message, ['thinking_duration_ms', 'thinkingDurationMs']), toolCalls: normalizeToolCalls(readArray(message, ['tool_calls', 'toolCalls'])), 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/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index f9d957ca..de5f7d17 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -172,6 +172,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 a371867e..3bc23c7f 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -16,6 +16,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'; @@ -47,6 +51,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'; import { resolveTuiSessionTabGroupRefs, @@ -134,8 +139,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { onTodoChange: (items) => tasks.setItems(items), onUserSubmissionProjected: () => { codexHandoffFlow?.dismiss(); - followChatBottom(); - if (started && !stopped) tui.requestImmediateRender(); + if (started && !stopped) tui.renderNow(); }, onSessionLifecycle: (sessionId) => { if (!sessionId) bashFlow.clearUnboundContext(); @@ -381,6 +385,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, @@ -394,6 +401,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, @@ -468,10 +594,13 @@ 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(), + switchComposerDraft: (sessionKey) => { + // Retained turn submissions belong to the previous session's turns. + commandFlow.clearRetainedSubmissions(); + return draftLifecycle?.switchSession(sessionKey) ?? Promise.resolve(); + }, adoptForegroundRun: () => sessionLifecycle.adoptForegroundRun( controller.snapshot().session?.sessionId, @@ -531,6 +660,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, @@ -554,6 +689,9 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { tui.requestRender(); }, userMessageCount: () => transcript.snapshot().filter((cell) => cell.kind === 'user').length, + onMessageAdmitted: () => { + layout.forceFollowBottom(); + }, }); activeRunFlow.setCommandCatalog(commandFlow.catalog); runtimeEventFlow = new TuiRuntimeEventFlow({ @@ -710,7 +848,8 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { composer, }); function requestInteractionRender(): void { - if (started && !stopped) tui.requestImmediateRender(); + if (!started || stopped) return; + tui.requestImmediateRender(); } const controllerReady = controller.initialize(); const ready = controllerReady.then(async () => { @@ -748,8 +887,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); @@ -826,8 +965,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/commands/catalog.ts b/packages/tui/src/tui/commands/catalog.ts index 55336ef4..5ab0f305 100644 --- a/packages/tui/src/tui/commands/catalog.ts +++ b/packages/tui/src/tui/commands/catalog.ts @@ -405,6 +405,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/commands/plugin-autocomplete.ts b/packages/tui/src/tui/commands/plugin-autocomplete.ts new file mode 100644 index 00000000..e08cf2af --- /dev/null +++ b/packages/tui/src/tui/commands/plugin-autocomplete.ts @@ -0,0 +1,113 @@ +import type { KcodePluginRuntimeAccess } from '../../plugin/contract.js'; +import type { AutocompleteItem, AutocompleteProvider } from '../widgets/autocomplete.js'; +import { truncateToWidth } from '../engine/public.js'; +import { sanitizeTerminalText } from '../rendering/terminal-text.js'; + +export interface PluginAutocompleteItem extends AutocompleteItem { + readonly pluginId: string; + readonly groupLabel: string; +} + +/** Product-level plugin candidates share @ with files; plugin state stays in Runtime. */ +export class TuiPluginAutocomplete implements AutocompleteProvider { + readonly triggerCharacters = ['@']; + + constructor( + private readonly base: AutocompleteProvider, + private readonly plugins?: Partial>, + ) {} + + async getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: { signal: AbortSignal; force?: boolean }, + ) { + const before = (lines[cursorLine] ?? '').slice(0, cursorCol); + const prefix = /(?:^|\s)(@[^\s"/]*)$/u.exec(before)?.[1]; + if ( + !prefix || + lines.join('\n').trimStart().startsWith('!') || + !this.plugins?.listInstalledPlugins + ) { + return this.base.getSuggestions(lines, cursorLine, cursorCol, options); + } + const [files, plugins] = await Promise.allSettled([ + this.base.getSuggestions(lines, cursorLine, cursorCol, options), + this.plugins.listInstalledPlugins(), + ]); + if (options.signal.aborted) return null; + const fileSuggestions = files.status === 'fulfilled' ? files.value : null; + const query = prefix.slice(1).normalize('NFKC').toLocaleLowerCase(); + const items: PluginAutocompleteItem[] = + plugins.status === 'fulfilled' + ? plugins.value + .filter( + (plugin) => + plugin.installed && + plugin.enabled && + [plugin.name, plugin.displayName, plugin.pluginId].some((text) => + text.normalize('NFKC').toLocaleLowerCase().includes(query), + ), + ) + .sort( + (a, b) => + a.displayName.localeCompare(b.displayName) || a.pluginId.localeCompare(b.pluginId), + ) + .map((plugin) => { + const name = + sanitizeTerminalText(plugin.displayName) + .replace(/[[\]\\]/gu, ' ') + .replace(/\s+/gu, ' ') + .trim() + .slice(0, 256) || plugin.name; + return { + value: `@${name}`, + label: `@${name}`, + pluginId: plugin.pluginId, + groupLabel: ' Plugins', + description: `${plugin.marketplace} · ${truncateToWidth( + sanitizeTerminalText(plugin.description ?? plugin.name) + .replace(/\s+/gu, ' ') + .trim(), + 64, + '…', + )}`, + }; + }) + : []; + const combined = [ + ...items, + ...(fileSuggestions?.items ?? []).map((item) => ({ + ...item, + ...(items.length > 0 ? { groupLabel: ' Files' } : {}), + })), + ]; + return combined.length ? { prefix, items: combined } : null; + } + + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ) { + if (!('pluginId' in item)) + return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + const line = lines[cursorLine] ?? ''; + const start = cursorCol - prefix.length; + const insertion = `${item.value} `; + const next = [...lines]; + next[cursorLine] = line.slice(0, start) + insertion + line.slice(cursorCol); + return { lines: next, cursorLine, cursorCol: start + insertion.length }; + } + + shouldAutoTriggerCompletion(lines: string[], cursorLine: number, cursorCol: number) { + return this.base.shouldAutoTriggerCompletion?.(lines, cursorLine, cursorCol); + } + + shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number) { + return this.base.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? false; + } +} diff --git a/packages/tui/src/tui/controller/chat-controller-types.ts b/packages/tui/src/tui/controller/chat-controller-types.ts index 813c980e..e20f4ef7 100644 --- a/packages/tui/src/tui/controller/chat-controller-types.ts +++ b/packages/tui/src/tui/controller/chat-controller-types.ts @@ -73,4 +73,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 c9a24bc8..dd59da7f 100644 --- a/packages/tui/src/tui/controller/chat-controller.ts +++ b/packages/tui/src/tui/controller/chat-controller.ts @@ -430,6 +430,7 @@ export class TuiChatController { getMessages(current.sessionId), ]); if (projectionSequence !== this.sessionProjectionSequence) return; + this.turnProjection.clearTodos(); this.transcript.replaceDurableProjection(() => this.turnProjection.hydrateHistory(messages)); this.durableMessageAnchor = latestHistoryMessageId(messages); if (projectionSequence !== this.sessionProjectionSequence) return; @@ -607,6 +608,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, @@ -616,6 +619,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(); @@ -657,6 +663,7 @@ export class TuiChatController { turnId, session, content, + ...(options.displayContent !== undefined ? { displayContent } : {}), workspace: this.workspaceDir, version: this.version, ...(attachments.length > 0 ? { attachments } : {}), @@ -868,6 +875,15 @@ export class TuiChatController { return Boolean(this.activeTurn || this.runCoordinator.activeTurnId()); } + getTerminalDurationId(): string | undefined { + return this.transcript.snapshot().find((cell) => cell.kind === 'turn-duration')?.id; + } + + dismissTerminalDuration(id: string | undefined): void { + if (!id || this.transcript.get(id)?.kind !== 'turn-duration') return; + if (this.transcript.remove(id)) this.notify(); + } + beginRuntimeTurn(turnId: string, timestamp: number): void { this.outputRate.beginTurn(turnId); this.turnProjection.beginTurn(turnId, timestamp); @@ -891,6 +907,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/interaction/input-flow.ts b/packages/tui/src/tui/controller/interaction/input-flow.ts index b59b6ad2..57af07eb 100644 --- a/packages/tui/src/tui/controller/interaction/input-flow.ts +++ b/packages/tui/src/tui/controller/interaction/input-flow.ts @@ -123,6 +123,7 @@ export class TuiInputFlow { item, ) => { previousAutocompleteSelect?.(suggestions, item); + if ('pluginId' in item) return; const imageReference = selectedImageMentionReference(item.value, suggestions.prefix); if (!imageReference) return; void this.options.composerDraft.queueAttachment(imageReference).catch((error: unknown) => @@ -558,6 +559,7 @@ export class TuiInputFlow { this.options.editor.restoreDraft({ ...this.clearedEditorDraft, text: '', + pluginMentions: [], cursor: 0, pastes: [], attachmentPlaceholders: [], diff --git a/packages/tui/src/tui/controller/product/command-flow.ts b/packages/tui/src/tui/controller/product/command-flow.ts index f564ab7b..1dda1177 100644 --- a/packages/tui/src/tui/controller/product/command-flow.ts +++ b/packages/tui/src/tui/controller/product/command-flow.ts @@ -1,3 +1,4 @@ +import { parsePluginMentions } from '@mavis/shared/plugin-mention'; import { createTuiCommandCatalog, matchTuiCommandInput, @@ -13,7 +14,7 @@ import { KCODE_LOGIN_PROVIDERS, isKcodeLoginProviderId } from '../../../provider 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, submittedEditorTransport, 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'; @@ -25,6 +26,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'; @@ -51,7 +53,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'; @@ -92,6 +94,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; @@ -172,6 +175,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) { @@ -182,6 +187,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(); @@ -195,15 +224,36 @@ 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 pluginTransport = recoverable?.transportContent && parsePluginMentions(recoverable.transportContent).length > 0 + ? rebuildSessionMutationTransport(recoverable.transportContent, submittedEditorTransport(editor) ?? visibleContent) + : undefined; + const transportContent = pluginTransport ?? (recoverable?.transportContent && + parsePluginMentions(recoverable.transportContent).length === 0 && + (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 } : {}), }; } @@ -230,7 +280,7 @@ export class TuiCommandFlow { requireRuntimeAcceptance: true, transportContent: options.transportContent ?? - rebuildSessionMutationTransport(recoveryTransport, input) ?? + rebuildSessionMutationTransport(recoveryTransport, seed ? submittedEditorTransport(seed.editor) ?? input : input) ?? recoveryTransport, ...(seed?.reviewRequest && !options.reviewRequest ? { reviewRequest: seed.reviewRequest } @@ -238,7 +288,13 @@ export class TuiCommandFlow { } : options; const command = input.trim(); - if (this.options.sessionMutationFlow?.isEditing?.()) { + // Local commands remain commands while editing a historical message. + // Skills and explicit message submissions still belong to the edited prompt. + const localCommand = !submitOptions.forceMessage && this.catalog.resolve(command); + if ( + this.options.sessionMutationFlow?.isEditing?.() && + (!localCommand || localCommand.invocationKind === 'skill') + ) { const editDisposition = await this.options.sessionMutationFlow.submitEdit( input, seed?.resources.attachments, @@ -464,9 +520,11 @@ export class TuiCommandFlow { const bashContext = this.options.bashFlow?.takeContext( seed ? seed.sessionId : this.options.controller.snapshot().session?.sessionId, ); + const boundContent = seed ? submittedEditorTransport(seed.editor) : undefined; + const messageContent = options.transportContent ?? boundContent; const transportContent = bashContext - ? `${bashContext}\n\n${options.transportContent ?? command}` - : options.transportContent; + ? `${bashContext}\n\n${messageContent ?? command}` + : messageContent; let submission = createTuiSubmissionSnapshot({ submissionId, sessionId: seed ? seed.sessionId : this.options.controller.snapshot().session?.sessionId, @@ -553,9 +611,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); @@ -580,6 +636,7 @@ export class TuiCommandFlow { submission.attachments.map((attachment) => attachment.fileName), ); } + let queuedItemId: string | undefined; try { await this.options.controller.requireLoginForAgentAction(); const draft = { @@ -591,9 +648,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); @@ -603,10 +670,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'); @@ -623,6 +692,7 @@ export class TuiCommandFlow { }); } await this.options.featureFlow.waitForWelcomeModelSelection(); + let retainedTurnId: string | undefined; try { return { primary: this.options.controller.submit(submission.transportContent ?? command, { @@ -637,9 +707,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( @@ -732,9 +813,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) { @@ -747,19 +826,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); @@ -799,9 +891,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 { @@ -850,16 +940,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); @@ -1295,6 +1395,7 @@ export class TuiCommandFlow { feedback: async ({ args }) => this.options.feedbackFlow.show(args), settings: () => this.showSettingsPicker(), statusline: () => this.options.showStatusLine?.(), + theme: () => this.options.showTheme?.(), hotkeys: () => this.showHotkeysPicker(), reload: async () => { if (!this.options.reloadTui) { @@ -1802,6 +1903,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/product/session-mutation-flow.ts b/packages/tui/src/tui/controller/product/session-mutation-flow.ts index a79f6d26..f4485c30 100644 --- a/packages/tui/src/tui/controller/product/session-mutation-flow.ts +++ b/packages/tui/src/tui/controller/product/session-mutation-flow.ts @@ -1,3 +1,4 @@ +import { submittedEditorTransport } from '../../widgets/editor/editor.js'; import type { TuiChatController } from '../chat-controller.js'; import type { Component, Focusable } from '../../rendering/component.js'; import { matchesKey } from '../../engine/public.js'; @@ -417,8 +418,12 @@ export class TuiSessionMutationFlow { this.options.onChanged(); return 'retained'; } + const boundContent = draft ? submittedEditorTransport(draft) ?? content : content; const transportContent = - rebuildSessionMutationTransport(invocation.targetMessage?.content, content) ?? content; + rebuildSessionMutationTransport( + invocation.targetMessage?.editContent ?? invocation.targetMessage?.content, + boundContent, + ) ?? boundContent; if (invocation.phase === 'resubmit') { this.invocations.set(invocation.sequence, { ...invocation, phase: 'resubmitting' }); this.options.setHint(sessionMutationText('sessionMutation.hint.editSubmitting')); @@ -429,6 +434,7 @@ export class TuiSessionMutationFlow { ...(mergedAttachments ? { attachments: mergedAttachments } : {}), }; } + const previousDurationId = this.options.controller.getTerminalDurationId(); this.invocations.set(invocation.sequence, { ...invocation, phase: 'submitting' }); this.options.setHint(sessionMutationText('sessionMutation.hint.editSubmitting')); this.options.onChanged(); @@ -448,6 +454,13 @@ export class TuiSessionMutationFlow { return 'retained'; } const code = rewindErrorCode(error); + if ( + code === 'EDIT_RESTART_NEEDS_RESUBMIT' || + code === 'EDIT_SUBMIT_FAILED_AFTER_REWIND' || + code === 'REWIND_DISPLAY_COMMIT_FAILED' + ) { + this.options.controller.dismissTerminalDuration(previousDurationId); + } if (code === 'EDIT_RESTART_NEEDS_NEW_OPERATION') { this.invocations.set(invocation.sequence, { ...current, @@ -483,6 +496,11 @@ export class TuiSessionMutationFlow { ); return 'retained'; } + // Edit bypasses normal submit. Discard only the captured footer on success, + // before history refresh can expose it; a new run may already have finished. + if (this.isCurrentMutation(invocation)) { + this.options.controller.dismissTerminalDuration(previousDurationId); + } try { await this.options.refreshProjection?.(invocation.sourceSessionId); } catch { @@ -647,7 +665,9 @@ export class TuiSessionMutationFlow { this.closeHistoryScreen(); this.options.setEditTranscriptBoundary?.(targetMessage.id); this.invocations.set(sequence, { ...invocation, targetMessage, phase: 'editing' }); - const content = visibleSessionMutationContent(targetMessage.content); + const content = visibleSessionMutationContent( + targetMessage.editContent ?? targetMessage.content, + ); const placeholders = this.editAttachmentEntries(); if (placeholders.length > 0) this.options.editor.restoreMessageDraft(content, placeholders); else this.options.editor.setText(content); 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/controller/projection/turn-projection.ts b/packages/tui/src/tui/controller/projection/turn-projection.ts index a1848afc..2c280c12 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/src/tui/controller/projection/turn-user-projection.ts b/packages/tui/src/tui/controller/projection/turn-user-projection.ts index 7e01fed4..867c6dc0 100644 --- a/packages/tui/src/tui/controller/projection/turn-user-projection.ts +++ b/packages/tui/src/tui/controller/projection/turn-user-projection.ts @@ -1,3 +1,4 @@ +import { parsePluginMentions } from '@mavis/shared/plugin-mention'; import type { TuiMessage } from '../../../runtime/port.js'; import { formatTuiHistorySubmission, @@ -34,7 +35,7 @@ export class TuiUserProjection { return; } const visibleContent = - message.source === 'code_review' ? '/review' : visibleSessionMutationContent(content); + message.source === 'code_review' ? '/review' : visibleUserContent(content); this.transcript.upsert({ id: `history:user:${messageId}`, kind: 'user', @@ -69,7 +70,7 @@ export class TuiUserProjection { return false; } const formatted = formatTuiHistorySubmission( - message.source === 'code_review' ? '/review' : visibleSessionMutationContent(content), + message.source === 'code_review' ? '/review' : visibleUserContent(content), message.attachments ?? [], ); const attachments = toTuiTranscriptAttachments(message.attachments ?? []); @@ -187,3 +188,12 @@ function mergeTranscriptAttachments( ...attachment, })); } + +/** Older persisted rows may still contain the durable plugin transport links. */ +function visibleUserContent(content: string): string { + let visible = visibleSessionMutationContent(content); + for (const mention of parsePluginMentions(visible).reverse()) { + visible = visible.slice(0, mention.start) + mention.label + visible.slice(mention.end); + } + return visible; +} 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/active-run-flow.ts b/packages/tui/src/tui/controller/run/active-run-flow.ts index bb8ffec7..f42ce340 100644 --- a/packages/tui/src/tui/controller/run/active-run-flow.ts +++ b/packages/tui/src/tui/controller/run/active-run-flow.ts @@ -1,3 +1,5 @@ +import { TuiPluginAutocomplete } from '../../commands/plugin-autocomplete.js'; +import type { KcodePluginRuntimeAccess } from '../../../plugin/contract.js'; import type { Component } from '../../rendering/component.js'; import type { Terminal } from '../../engine/public.js'; import { @@ -67,15 +69,20 @@ export function createTuiAutocomplete( builtInCommands: readonly TuiCommand[], skillCommands: readonly TuiCommand[], workspace: string | readonly TuiWorkspaceRoot[], - workspaceFiles?: Partial, + workspaceFiles?: Partial< + TuiWorkspaceFilePort & Pick + >, shellCwd?: () => string, ): AutocompleteProvider { - return new TuiAutocompleteProvider( - builtInCommands, - skillCommands, - workspace, + return new TuiPluginAutocomplete( + new TuiAutocompleteProvider( + builtInCommands, + skillCommands, + workspace, + workspaceFiles, + shellCwd, + ), workspaceFiles, - shellCwd, ); } @@ -132,11 +139,9 @@ export class TuiActiveRunFlow { private autocompleteHasLiveRun: boolean | undefined; private autocompleteSignature: string | undefined; private pendingAutocomplete: - | { readonly provider: AutocompleteProvider; readonly signature: string } - | undefined; + { readonly provider: AutocompleteProvider; readonly signature: string } | undefined; private contextInspection: - | { readonly sessionId: string; readonly panel: TuiReportInspectionPanel } - | undefined; + { readonly sessionId: string; readonly panel: TuiReportInspectionPanel } | undefined; constructor( private readonly options: { @@ -569,7 +574,9 @@ class TuiAutocompleteProvider implements AutocompleteProvider { builtInCommands: readonly TuiCommand[], skillCommands: readonly TuiCommand[], workspace: string | readonly TuiWorkspaceRoot[], - private readonly workspaceFiles?: Partial, + private readonly workspaceFiles?: Partial< + TuiWorkspaceFilePort & Pick + >, shellCwd?: () => string, ) { this.workspaceRoots = normalizeAutocompleteRoots(workspace); @@ -732,7 +739,10 @@ class TuiAutocompleteProvider implements AutocompleteProvider { const directory = query ? query.slice(0, -1) : undefined; if (this.workspaceFiles?.listWorkspaceFileTreeCandidates) { const entries = await this.workspaceFiles.listWorkspaceFileTreeCandidates( - { roots: this.workspaceRoots, ...(directory ? { path: directory } : {}) }, + { + roots: this.workspaceRoots, + ...(directory ? { path: directory } : {}), + }, signal, ); return entries.flatMap((entry) => this.toMultiRootTreeItem(entry)); diff --git a/packages/tui/src/tui/controller/run/queue-flow.ts b/packages/tui/src/tui/controller/run/queue-flow.ts index 7632f22b..3a87c34f 100644 --- a/packages/tui/src/tui/controller/run/queue-flow.ts +++ b/packages/tui/src/tui/controller/run/queue-flow.ts @@ -1,3 +1,4 @@ +import { decodePluginMentions, encodePluginMentions, transformPluginMentions } from '../../widgets/editor/plugin-mentions.js'; import type { TuiModelSelection, TuiQueuePort, @@ -301,6 +302,12 @@ export class TuiQueueFlow { ? (submission?.transportContent ?? rememberedTransport) : (item.content ?? submission?.transportContent ?? rememberedTransport); if (!transportContent) return item; + const decoded = decodePluginMentions(visibleSessionMutationContent(transportContent)); + if (decoded.mentions.length) { + this.queuedTransportContents.set(item.itemId, transportContent); + return { ...item, content: decoded.text }; + } + if (rememberedTransport && decodePluginMentions(visibleSessionMutationContent(rememberedTransport)).text === item.content) return item; if (item.reviewRequest) { this.queuedTransportContents.set(item.itemId, transportContent); return { ...item, content: '/review' }; @@ -575,8 +582,11 @@ export class TuiQueueFlow { } const sessionId = this.options.controller.snapshot().session?.sessionId; if (!sessionId) return false; - const transportContent = - rebuildSessionMutationTransport(this.queuedTransportContents.get(itemId), content) ?? content; + const previousTransport = this.queuedTransportContents.get(itemId); + const previous = decodePluginMentions(visibleSessionMutationContent(previousTransport ?? cached?.content)); + const mentions = transformPluginMentions(previous.text, content, previous.mentions); + const boundContent = encodePluginMentions(content, mentions); + const transportContent = rebuildSessionMutationTransport(previousTransport, boundContent) ?? boundContent; const updated = await this.options.runtime.updateQueuedMessageContent( sessionId, itemId, @@ -588,6 +598,8 @@ export class TuiQueueFlow { await this.refresh(sessionId); return false; } + if (transportContent !== content) this.queuedTransportContents.set(itemId, transportContent); + else this.queuedTransportContents.delete(itemId); const captured = this.queuedSubmissions.get(itemId); if (captured) { this.queuedSubmissions.set(itemId, { @@ -596,6 +608,7 @@ export class TuiQueueFlow { schemaVersion: 1, text: content, cursor: content.length, + pluginMentions: mentions, pastes: [], pasteCounter: 0, }, @@ -792,7 +805,8 @@ function createSubmissionFromQueuedMessage( submissionId: string, transportContent?: string, ): TuiSubmissionSnapshot { - const content = item.content ?? ''; + const decoded = decodePluginMentions(visibleSessionMutationContent(transportContent ?? item.content)); + const content = decoded.text; const transportAttachments = (item.attachments ?? []).flatMap( (attachment) => { const filePath = attachment.local?.filePath; @@ -816,12 +830,13 @@ function createSubmissionFromQueuedMessage( editor: { schemaVersion: 1, text: content, + pluginMentions: decoded.mentions, cursor: content.length, pastes: [], pasteCounter: 0, }, content, - ...(transportContent ? { transportContent } : {}), + ...(transportContent || decoded.mentions.length ? { transportContent: transportContent ?? item.content } : {}), attachments: (item.attachments ?? []).flatMap((attachment) => { const filePath = attachment.local?.filePath; if (!filePath) return []; 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/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index 231cb753..690550ab 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -21,9 +21,9 @@ }, { "path": "components/editor.ts", - "currentSha256": "0775a490df136d1083738cce649e026d6153ecb5480715b2c1104d51051968c5", - "changeIds": ["L005", "L009", "L022", "L030", "L032"], - "reason": "Keep strict TypeScript fixes and add the smallest generic snapshot, paste, telemetry, programmatic submission, undo-extension and empty-placeholder hooks needed by the MCode product wrapper. 提供方通过上下文回调控制自动触发,并通过 applyOnEnter 控制 Enter 是否填入候选。 强制补全上下文失效时立即取消请求并清空菜单。 支持单帧 placeholder 覆盖,由 Editor 统一保留 padding、可见光标和 IME 定位标记。 命令参数候选标记阶段,接受命令后续查参数,并隔离参数回调失败。", + "currentSha256": "55d313e4f7f5760212e0ff397cb7a599c52d42a90df8c0014f25cf4652897d20", + "changeIds": ["L005", "L009", "L022", "L030", "L032", "L042"], + "reason": "Preserve product history metadata through a generic text transform and draft extension state. Keep strict TypeScript fixes and add the smallest generic snapshot, paste, telemetry, programmatic submission, undo-extension and empty-placeholder hooks needed by the MCode product wrapper. 提供方通过上下文回调控制自动触发,并通过 applyOnEnter 控制 Enter 是否填入候选。 强制补全上下文失效时立即取消请求并清空菜单。 支持单帧 placeholder 覆盖,由 Editor 统一保留 padding、可见光标和 IME 定位标记。 命令参数候选标记阶段,接受命令后续查参数,并隔离参数回调失败。", "behaviorImpact": "MCode Draft, attachment, product-level submission-intent and empty-input guidance semantics use the canonical Pi Editor cursor, width, padding, history, undo, paste-marker and autocomplete behavior. Shell 补全只由 Tab 打开,菜单内输入继续过滤;Enter 执行当前输入,Tab 填入选中项。其他提供方沿用默认行为。 删除 Shell 标记或命令名前缀后立即关闭候选;已取消请求的延迟结果无法恢复旧菜单。 带参数候选的命令支持连续补全,参数 Enter 仅填入,Esc 保留草稿;无参数命令保持原行为。" }, { @@ -163,18 +163,18 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "73789fb955252262ad03907446e4f4a6052bfcb86bf2a4fdc77ddaa1dd7ee9ee", - "changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038"], + "currentSha256": "9d5c2b83b6b3f0b614f9746a398025eb2c9d324a83959138e77728a08679939c", + "changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038", "L039", "L041"], "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", - "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": "96591553bc244dbe431de5bff46f57a874d83cd5fe623c94b47b637f83afcec0", + "changeIds": ["L005", "L015", "L023", "L027", "L028", "L031", "L040", "L041"], + "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 d920a633..b240a9f3 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -142,7 +142,38 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct ## 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. +- Minimal difference: when terminal geometry, the text already in scrollback and the declared transient layout keys are unchanged, absorb visible text-only shrink with blank rows at the current screen boundary before cursor extraction and differential rendering. L041 makes this an explicit background-content policy; unclassified layouts restore exposed rows. 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. + +## 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. + +## 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. + +## L041: Restore chat rows after transient layout shrink + +- Product contract: shrinking a transient UI region restores the conversation instead of leaving released rows blank above it. Background activity shrink with unchanged transient layout retains L038's native scrolling behavior. +- Minimal difference: components may expose the layout key of their last rendered frame. MainScreen permits L038 padding only when every root explicitly supplies the same key and no overlay was present. ChatLayout includes every transient section's height and interaction state, while SurfaceHost includes the active feature. Unknown or changed layouts use L034 reconstruction only when scrolled rows must return. Keys are captured with native render state and cleared on reset. This replaces the earlier completion-specific resize callback and full-viewport close exception. +- Evidence: application tests replay `/theme`, `/settings`, prompt-history search, image-preview dismissal, multi-line draft clearing and completion filtering. Engine tests repeatedly expand/shrink each transient section under xterm and an ED 2 clear-to-scrollback model, compare the complete viewport, verify unique history, and retain positive background-activity scroll preservation. Short documents avoid unnecessary clearing. +- Boundary: full history reconstruction retains L034's shell-scrollback tradeoff. Emulator tests do not establish native terminal or live-service acceptance. +- Removal condition: the selected Pi baseline distinguishes transient UI layout shrink from ordinary background content shrink. + +## L042: Preserve product mention bindings in prompt history + +- Product contract: plugin labels retain their exact identities while browsing history, including restoration of the working draft. +- Minimal difference: expose a generic history-text decoder and capture/restore the existing undo extension state alongside the history draft. Plugin parsing and identity ownership stay in the product Editor. +- Evidence: `tui-plugin-mentions.test.ts` covers repeated history navigation, identical display labels with different IDs, working-draft restoration, atomic deletion and undo. +- Removal condition: the selected Pi baseline supports durable history decoding and draft extension state. diff --git a/packages/tui/src/tui/engine/components/editor.ts b/packages/tui/src/tui/engine/components/editor.ts index 999096c7..5c8a5657 100644 --- a/packages/tui/src/tui/engine/components/editor.ts +++ b/packages/tui/src/tui/engine/components/editor.ts @@ -356,6 +356,7 @@ export class Editor implements Component, Focusable { private history: string[] = []; private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. private historyDraft: EditorState | null = null; + private historyDraftExtensionState: unknown; // Kill ring for Emacs-style kill/yank operations private killRing = new KillRing(); @@ -377,6 +378,8 @@ export class Editor implements Component, Focusable { // Undo support private undoStack = new UndoStack(); + /** Decode a durable history entry before restoring its editable text. */ + public transformHistoryText?: (text: string) => string; public onSubmit?: (text: string, snapshot: EditorStateSnapshot) => void; public onChange?: (text: string) => void; public onPaste?: (text: string) => boolean; @@ -496,6 +499,7 @@ export class Editor implements Component, Focusable { if (this.historyIndex === -1 && newIndex >= 0) { this.pushUndoSnapshot(); this.historyDraft = structuredClone(this.state); + this.historyDraftExtensionState = this.captureUndoExtensionState?.(); } this.historyIndex = newIndex; @@ -505,6 +509,8 @@ export class Editor implements Component, Focusable { this.historyDraft = null; if (draft) { this.state = draft; + this.restoreUndoExtensionState?.(this.historyDraftExtensionState); + this.historyDraftExtensionState = undefined; this.preferredVisualCol = null; this.snappedFromCursorCol = null; this.scrollOffset = 0; @@ -514,7 +520,7 @@ export class Editor implements Component, Focusable { } } else { this.setTextInternal( - this.history[this.historyIndex] || '', + this.transformHistoryText?.(this.history[this.historyIndex] || '') ?? this.history[this.historyIndex] ?? '', direction === -1 ? 'start' : 'end', ); } @@ -523,6 +529,7 @@ export class Editor implements Component, Focusable { private exitHistoryBrowsing(): void { this.historyIndex = -1; this.historyDraft = null; + this.historyDraftExtensionState = undefined; } /** Internal setText that doesn't reset history state - used by navigateHistory */ @@ -1221,6 +1228,8 @@ export class Editor implements Component, Focusable { this.onAutocompleteSelect = undefined; this.captureUndoExtensionState = undefined; this.restoreUndoExtensionState = undefined; + this.transformHistoryText = undefined; + this.historyDraftExtensionState = undefined; } /** diff --git a/packages/tui/src/tui/engine/tui-main-screen.ts b/packages/tui/src/tui/engine/tui-main-screen.ts index a5239708..8d4d1741 100644 --- a/packages/tui/src/tui/engine/tui-main-screen.ts +++ b/packages/tui/src/tui/engine/tui-main-screen.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { deleteKittyImage, isImageLine } from "./terminal-image.js"; -import { type TUI, TuiBase, type TuiStopOptions } from "./tui.js"; +import { type Component, type TUI, TuiBase, type TuiStopOptions } from "./tui.js"; import { stripTerminalSequences, visibleWidth } from "./utils.js"; const KITTY_SEQUENCE_PREFIX = "\x1b_G"; @@ -114,6 +114,8 @@ export interface TuiMainScreenRenderState { hardwareCursorRow: number; maxLinesRendered: number; previousViewportTop: number; + viewportLayouts: { component: Component; key: string | undefined }[]; + hadOverlays: boolean; } /** TUI implementation that renders into the terminal's main screen and scrollback. */ @@ -129,6 +131,8 @@ export class TuiMainScreen extends TuiBase implements TUI { private previousViewportTop = 0; private resizeTimer: ReturnType | undefined; private historyReplayPending = false; + private viewportLayouts: TuiMainScreenRenderState['viewportLayouts'] = []; + private hadOverlays = false; protected override onTerminalResize(): void { // Some hosts repeat resize notifications while scrolling or reconnecting. @@ -167,6 +171,8 @@ export class TuiMainScreen extends TuiBase implements TUI { hardwareCursorRow: this.hardwareCursorRow, maxLinesRendered: this.maxLinesRendered, previousViewportTop: this.previousViewportTop, + viewportLayouts: this.viewportLayouts.map((layout) => ({ ...layout })), + hadOverlays: this.hadOverlays, }; } @@ -181,9 +187,13 @@ export class TuiMainScreen extends TuiBase implements TUI { this.hardwareCursorRow = state.hardwareCursorRow; this.maxLinesRendered = state.maxLinesRendered; this.previousViewportTop = state.previousViewportTop; + this.viewportLayouts = state.viewportLayouts.map((layout) => ({ ...layout })); + this.hadOverlays = state.hadOverlays; } protected override resetRenderState(): void { + this.viewportLayouts = []; + this.hadOverlays = false; this.cancelResize(); this.historyReplayPending = false; this.previousLines = []; @@ -293,6 +303,18 @@ export class TuiMainScreen extends TuiBase implements TUI { // Render all components to get new lines. Strip OSC 133 zone sentinels before the // differential compare so they never enter previousLines or any terminal write. let newLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + const viewportLayouts = this.children.map((component) => ({ + component, + key: component.getViewportLayoutKey?.(), + })); + const stableLayout = viewportLayouts.length > 0 && + viewportLayouts.length === this.viewportLayouts.length && + viewportLayouts.every(({ component, key }, index) => + key !== undefined && component === this.viewportLayouts[index]?.component && + key === this.viewportLayouts[index]?.key); + this.viewportLayouts = viewportLayouts; + const hadOverlays = this.hadOverlays; + this.hadOverlays = this.hasOverlayEntries; // Composite overlays into the rendered lines (before differential compare) if (this.hasOverlayEntries) { @@ -304,7 +326,7 @@ export class TuiMainScreen extends TuiBase implements TUI { // 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 && + stableLayout && !hadOverlays && !widthChanged && !heightChanged && !this.historyReplayPending && !this.hasOverlayEntries && prevViewportTop > 0 && newLines.length > prevViewportTop && newLines.length < prevViewportTop + height && this.previousKittyImageIds.size === 0 && !newLines.some(isImageLine) @@ -348,7 +370,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/src/tui/engine/tui.ts b/packages/tui/src/tui/engine/tui.ts index f3fbe574..babec471 100644 --- a/packages/tui/src/tui/engine/tui.ts +++ b/packages/tui/src/tui/engine/tui.ts @@ -27,6 +27,13 @@ export interface Component { */ render(width: number): string[]; + /** + * Opt into preserving native scrolling when only background content shrinks. + * Return a key for the last rendered transient layout (menus, editor, banners). + * A changed or missing key restores exposed document rows instead of padding. + */ + getViewportLayoutKey?(): string | undefined; + /** * Optional handler for keyboard input when component has focus */ @@ -904,10 +911,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/src/tui/features/composer/draft-lifecycle.ts b/packages/tui/src/tui/features/composer/draft-lifecycle.ts index f439baa0..de7f0f21 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..505198dd 100644 --- a/packages/tui/src/tui/features/composer/draft-recovery.ts +++ b/packages/tui/src/tui/features/composer/draft-recovery.ts @@ -1,3 +1,4 @@ +import { validPluginMentions } from '../../widgets/editor/plugin-mentions.js'; import { createHash } from 'node:crypto'; import { chmod, @@ -431,12 +432,16 @@ export class TuiDraftRecovery { const relocatedPath = attachment.filePath ? relocatedPaths.get(attachment.filePath) : undefined; - return { ...attachment, ...(relocatedPath ? { filePath: relocatedPath } : {}) }; + return { + ...attachment, + ...(relocatedPath ? { filePath: relocatedPath } : {}), + }; }), } : {}), createdAtMs: snapshot.createdAtMs, ...(snapshot.clientIntent ? { clientIntent: snapshot.clientIntent } : {}), + ...(snapshot.reviewRequest ? { reviewRequest: snapshot.reviewRequest } : {}), }; } @@ -449,7 +454,9 @@ export class TuiDraftRecovery { .map((attachment) => resolve(attachment.filePath)) .filter((filePath) => dirname(filePath) === resolve(this.assetsDirectory)), ); - const entries = await readdir(this.assetsDirectory, { withFileTypes: true }).catch(() => []); + const entries = await readdir(this.assetsDirectory, { + withFileTypes: true, + }).catch(() => []); await Promise.all( entries.map(async (entry) => { if (!entry.isFile()) return; @@ -508,6 +515,7 @@ function cloneDraft(draft: TuiRecoverableDraft): TuiRecoverableDraft { return { editor: { ...draft.editor, + pluginMentions: draft.editor.pluginMentions?.map((mention) => ({ ...mention })), pastes: draft.editor.pastes.map((paste) => ({ ...paste })), ...(draft.editor.attachmentPlaceholders ? { @@ -537,7 +545,9 @@ function cloneRetrySubmission(retry: TuiRetrySubmission): TuiRetrySubmission { ...(retry.snapshot.transportContent ? { transportContent: retry.snapshot.transportContent } : {}), - attachments: retry.snapshot.attachments.map((attachment) => ({ ...attachment })), + attachments: retry.snapshot.attachments.map((attachment) => ({ + ...attachment, + })), ...(retry.snapshot.transportAttachments ? { transportAttachments: retry.snapshot.transportAttachments.map((attachment) => ({ @@ -547,6 +557,7 @@ function cloneRetrySubmission(retry: TuiRetrySubmission): TuiRetrySubmission { : {}), createdAtMs: retry.snapshot.createdAtMs, ...(retry.snapshot.clientIntent ? { clientIntent: retry.snapshot.clientIntent } : {}), + ...(retry.snapshot.reviewRequest ? { reviewRequest: retry.snapshot.reviewRequest } : {}), }, }; } @@ -554,6 +565,7 @@ function cloneRetrySubmission(retry: TuiRetrySubmission): TuiRetrySubmission { function cloneEditorDraft(editor: EditorDraftSnapshot): EditorDraftSnapshot { return { ...editor, + pluginMentions: editor.pluginMentions?.map((mention) => ({ ...mention })), pastes: editor.pastes.map((paste) => ({ ...paste })), ...(editor.attachmentPlaceholders ? { @@ -579,7 +591,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 +633,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 +734,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; @@ -734,6 +759,7 @@ function isEditorDraft(value: unknown): value is EditorDraftSnapshot { !isRecord(value) || value.schemaVersion !== 1 || typeof value.text !== 'string' || + !validPluginMentions(value.text, value.pluginMentions) || !Number.isInteger(value.cursor) || (value.cursor as number) < 0 || (value.cursor as number) > value.text.length || diff --git a/packages/tui/src/tui/features/composer/submission.ts b/packages/tui/src/tui/features/composer/submission.ts index 85075427..5f904fb8 100644 --- a/packages/tui/src/tui/features/composer/submission.ts +++ b/packages/tui/src/tui/features/composer/submission.ts @@ -51,7 +51,9 @@ export function createTuiSubmissionSnapshot(options: { editor: cloneEditorDraft(options.editor), content: options.content, ...(options.transportContent ? { transportContent: options.transportContent } : {}), - attachments: options.resources.attachments.map((attachment) => ({ ...attachment })), + attachments: options.resources.attachments.map((attachment) => ({ + ...attachment, + })), transportAttachments: (options.transportAttachments ?? options.resources.attachments).map( (attachment) => ({ ...attachment }), ), @@ -64,6 +66,7 @@ export function createTuiSubmissionSnapshot(options: { function cloneEditorDraft(editor: EditorDraftSnapshot): EditorDraftSnapshot { return { ...editor, + pluginMentions: editor.pluginMentions?.map((mention) => ({ ...mention })), pastes: editor.pastes.map((paste) => ({ ...paste })), ...(editor.attachmentPlaceholders ? { diff --git a/packages/tui/src/tui/features/history/search-panel.ts b/packages/tui/src/tui/features/history/search-panel.ts index 0a568d69..cc2aad2e 100644 --- a/packages/tui/src/tui/features/history/search-panel.ts +++ b/packages/tui/src/tui/features/history/search-panel.ts @@ -1,3 +1,4 @@ +import { decodePluginMentions } from '../../widgets/editor/plugin-mentions.js'; import { Input, matchesKey } from '../../engine/public.js'; import type { Component, Focusable } from '../../rendering/component.js'; import { truncateToWidth, visibleWidth } from '../../rendering/text.js'; @@ -67,8 +68,8 @@ export class TuiHistorySearchPanel implements Component, Focusable { const selected = index === this.selectedIndex; const prefix = selected ? chalk.bold.hex(colors.signal)('› ') : ' '; const value = selected - ? chalk.bold.hex(colors.text)(sanitizeTerminalText(entry)) - : chalk.hex(colors.muted)(sanitizeTerminalText(entry)); + ? chalk.bold.hex(colors.text)(sanitizeTerminalText(decodePluginMentions(entry).text)) + : chalk.hex(colors.muted)(sanitizeTerminalText(decodePluginMentions(entry).text)); return fit(`${prefix}${value}`, safeWidth); }) : [fit(chalk.hex(colors.muted)(' No matching prompts'), safeWidth)]; @@ -82,7 +83,7 @@ export class TuiHistorySearchPanel implements Component, Focusable { private matches(): string[] { const query = this.searchInput.getValue().trim().toLocaleLowerCase(); if (!query) return [...this.options.entries]; - return this.options.entries.filter((entry) => entry.toLocaleLowerCase().includes(query)); + return this.options.entries.filter((entry) => decodePluginMentions(entry).text.toLocaleLowerCase().includes(query)); } private move(direction: -1 | 1): void { 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..1f24db4f --- /dev/null +++ b/packages/tui/src/tui/features/settings/theme-picker.ts @@ -0,0 +1,254 @@ +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; + +const APPEARANCE_CHOICES = ['light', 'auto', 'dark'] as const satisfies + readonly TuiThemeAppearanceChoice[]; + +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, 'left')) { + this.shiftAppearance(-1); + } else if (matchesKey(data, 'right')) { + this.shiftAppearance(1); + } 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 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); + 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 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 + ? '↑↓ theme · ←→ appearance · Enter save · Esc cancel' + : '↑↓ ←→ · Enter · Esc'; +} diff --git a/packages/tui/src/tui/launcher.ts b/packages/tui/src/tui/launcher.ts index 2a8c6943..37797996 100644 --- a/packages/tui/src/tui/launcher.ts +++ b/packages/tui/src/tui/launcher.ts @@ -51,7 +51,12 @@ import type { KcodeUpdateApplication } 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 { schedulePendingKcodePrefixUpdate } from '../update/prefix-update.js'; import { KCODE_TUI_RESULT_PATH_ENV } from './automation/result-writer.js'; import { startTuiStartupStatus, type TuiStartupStatus } from './startup-status.js'; @@ -70,6 +75,7 @@ export interface LaunchTuiOptions { dataDir?: string; terminal?: Terminal; tuiMode?: TuiMode; + theme?: string; externalEditorCommand?: string; resumeDraftAfterLogin?: boolean; lane?: string; @@ -122,6 +128,8 @@ export interface LaunchTuiDependencies { createIncidentReporter?: () => TuiIncidentReporter; readTuiMode?: typeof readTuiModeSetting; writeTuiMode?: typeof writeTuiModeSetting; + readTuiTheme?: typeof readTuiThemeSetting; + writeTuiTheme?: typeof writeTuiThemeSetting; createSharedAuthSession?: typeof createKcodeSharedAuthSession; createAuthApplication?: typeof createDefaultKcodeAuthApplication; } @@ -145,6 +153,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 = resolveKcodeAuthEnvironment({ runtimeRegion: process.env.MAVIS_REGION === 'en' ? 'en' : 'cn', @@ -325,6 +334,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/shell/chat-layout.ts b/packages/tui/src/tui/shell/chat-layout.ts index 2740cf56..8390c506 100644 --- a/packages/tui/src/tui/shell/chat-layout.ts +++ b/packages/tui/src/tui/shell/chat-layout.ts @@ -46,6 +46,11 @@ interface MouseAwareComponent extends Component { } export class TuiChatLayout implements Component { + private viewportLayoutKey: string | undefined; + + getViewportLayoutKey(): string | undefined { + return this.viewportLayoutKey; + } readonly fullscreenLayoutRoot: Component; private readonly fullscreenBodyViewport: ScrollView; private pendingFullscreenFrame: @@ -154,6 +159,12 @@ export class TuiChatLayout implements Component { } followBottom(): void { + if (this.fullscreenBodyViewport.isFollowingEnd) { + this.fullscreenBodyViewport.scrollToEnd(); + } + } + + forceFollowBottom(): void { this.fullscreenBodyViewport.scrollToEnd(); } @@ -244,8 +255,15 @@ export class TuiChatLayout implements Component { goal.length + followUp.length + activity.length + composer.length + status.length, surface === 'conversation' ? 2 : Math.max(1, notice.length + 1), ); + // Activity/transcript updates may preserve host scrolling. Every transient + // section participates here so new controls cannot silently leave blank rows. + const viewportLayout = [ + surface, interactionActive, interaction.length, composer.length, followUp.length, + goal.length, notice.length, tasks.length, status.length, + ]; if (surface === 'welcome') { if (interactionActive) { + this.viewportLayoutKey = JSON.stringify([...viewportLayout, 0]); return this.fitDocumentFrame( this.viewport() === 'fixed' ? [interaction, activity, composer, status] @@ -271,6 +289,7 @@ export class TuiChatLayout implements Component { frame.horizontalPadding, ) : renderPart(this.parts.welcome); + this.viewportLayoutKey = JSON.stringify([...viewportLayout, welcome.length]); return this.fitDocumentFrame([welcome, ...tailEntries]); } @@ -291,6 +310,7 @@ export class TuiChatLayout implements Component { ]; const transcript = renderPart(this.parts.transcript); const welcome = renderPart(this.parts.welcome); + this.viewportLayoutKey = JSON.stringify([...viewportLayout, welcome.length]); const prelude = joinWelcomeAndTranscript(welcome, transcript); const bodyEntries = [prelude, transcript.length > 0 ? [''] : []]; return this.fitDocumentFrame([...bodyEntries, ...footerEntries]); diff --git a/packages/tui/src/tui/shell/composer.ts b/packages/tui/src/tui/shell/composer.ts index 4cb207c2..f6e1fbe3 100644 --- a/packages/tui/src/tui/shell/composer.ts +++ b/packages/tui/src/tui/shell/composer.ts @@ -260,7 +260,7 @@ function composerLabels( return [`${attachment} · ${submit} send`, attachment, 'Message']; } if (state.surface === 'welcome') { - return ['Start · @ file · / autocomplete', 'Start below']; + return ['Start · @ file or Plugin · / autocomplete', 'Start below']; } const newline = composerNewlineKeybinding(supportsShiftEnter, keybindings); return [`Message · ${submit} send · ${newline} newline`, `Message · ${submit} send`, 'Message']; diff --git a/packages/tui/src/tui/shell/surface-host.ts b/packages/tui/src/tui/shell/surface-host.ts index 7510050f..a13f18df 100644 --- a/packages/tui/src/tui/shell/surface-host.ts +++ b/packages/tui/src/tui/shell/surface-host.ts @@ -220,6 +220,11 @@ export class TuiSurfaceHost implements Component { else this.chatComponent.invalidate(); } + getViewportLayoutKey(): string | undefined { + const key = this.chatComponent.getViewportLayoutKey?.(); + return key === undefined ? undefined : JSON.stringify([this.activeFeature()?.screen.id, key]); + } + render(width: number): string[] { const active = this.activeFeature(); if (!active || this.currentMode() === 'regular') return this.chatComponent.render(width); 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 63efe891..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 { KCODE_DARK_THEME, KCODE_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' ? KCODE_LIGHT_THEME : KCODE_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..afb59ae6 --- /dev/null +++ b/packages/tui/src/tui/theme/custom-themes.ts @@ -0,0 +1,303 @@ +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); + // Native watching may miss edits made while it starts (notably on macOS). + // Reconcile once after registration, then use the same debounced event path. + fire(); + } 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 5ff101ca..ff835a89 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 KCODE_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 KCODE_DARK_THEME = palette( + 'minimax', + 'dark', + { brand: '#68C0FF', wordmarkHighlight: '#93D2FF', wordmarkShadow: '#3DAEFF', @@ -24,13 +48,14 @@ export const KCODE_DARK_THEME: TuiThemePalette = Object.freeze({ success: '#28C567', warning: '#FFC340', error: '#FF5E6C', - }), -}); + }, + CATPPUCCIN_SYNTAX_TONES.dark, +); -export const KCODE_LIGHT_THEME: TuiThemePalette = Object.freeze({ - id: 'minimax', - appearance: 'light', - colors: Object.freeze({ +export const KCODE_LIGHT_THEME = palette( + 'minimax', + 'light', + { brand: '#0094FC', wordmarkHighlight: '#3DAEFF', wordmarkShadow: '#0077D9', @@ -51,5 +76,314 @@ export const KCODE_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' ? KCODE_LIGHT_THEME : KCODE_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: KCODE_DARK_THEME.id, + label: 'MCode', + description: 'The default MCode blue palette', + source: 'builtin', + dark: KCODE_DARK_THEME, + light: KCODE_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 08dfb14b..7728607e 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 { KCODE_DARK_THEME, KCODE_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' ? KCODE_LIGHT_THEME.colors : KCODE_DARK_THEME.colors; +let activeSyntax: TuiThemeSyntaxTones = + initialAppearance === 'light' ? KCODE_LIGHT_THEME.syntax : KCODE_DARK_THEME.syntax; +renderThemeSnapshot = { + ...renderThemeSnapshot, + signature: paletteSignature( + initialAppearance === 'light' ? KCODE_LIGHT_THEME : KCODE_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/tui/transcript/presentation/visibility.ts b/packages/tui/src/tui/transcript/presentation/visibility.ts index 356acff9..62935d87 100644 --- a/packages/tui/src/tui/transcript/presentation/visibility.ts +++ b/packages/tui/src/tui/transcript/presentation/visibility.ts @@ -12,6 +12,7 @@ import type { */ export class TranscriptVisibilityProjection implements TranscriptProjectionSource { private hiddenFromSourceMessageId: string | undefined; + private hiddenTerminalDurationId: string | undefined; private visibilityRevision = 0; private visibleLengthCache: | { @@ -43,6 +44,18 @@ export class TranscriptVisibilityProjection implements TranscriptProjectionSourc readonly hideFromSourceMessage = (sourceMessageId: string | undefined): void => { if (this.hiddenFromSourceMessageId === sourceMessageId) return; this.hiddenFromSourceMessageId = sourceMessageId; + this.hiddenTerminalDurationId = undefined; + if (sourceMessageId) { + let durationId: string | undefined; + for (let index = this.source.length - 1; index >= 0; index -= 1) { + const cell = this.source.cellAt(index); + if (cell?.kind === 'turn-duration') durationId = cell.id; + if (cell?.sourceMessageId === sourceMessageId) { + this.hiddenTerminalDurationId = durationId; + break; + } + } + } this.visibilityRevision += 1; }; @@ -80,6 +93,11 @@ export class TranscriptVisibilityProjection implements TranscriptProjectionSourc length = index; break; } + // Runtime can refresh rewound history before the edit RPC settles. Keep + // the captured footer hidden if that refresh removes the message anchor. + if (length === this.source.length && this.hiddenTerminalDurationId) { + length = this.source.locateCell(this.hiddenTerminalDurationId)?.index ?? length; + } if (sourceRevision !== undefined) { this.visibleLengthCache = { sourceRevision, diff --git a/packages/tui/src/tui/transcript/usage-visualization.ts b/packages/tui/src/tui/transcript/usage-visualization.ts index 1eb4fde2..c01ec566 100644 --- a/packages/tui/src/tui/transcript/usage-visualization.ts +++ b/packages/tui/src/tui/transcript/usage-visualization.ts @@ -53,10 +53,11 @@ export class UsageVisualization implements Component { this.data.sessionRecorded === false ? chalk.hex(colors.dim)('Account only') : chalk.bold.hex(colors.signal)( - `${formatCompact(this.data.totalTokens)} total` + - (typeof this.data.costTotalUsd === 'number' - ? ` · ${this.data.costUnpriced === true ? '~' : ''}${formatTuiCostUsd(this.data.costTotalUsd)}` - : ''), + `${formatCompact(this.data.totalTokens)} total${ + typeof this.data.costTotalUsd === 'number' + ? ` · ${this.data.costUnpriced === true ? '~' : ''}${formatTuiCostUsd(this.data.costTotalUsd)}` + : '' + }`, ); return composeEdges(title, summary, width); } @@ -122,7 +123,7 @@ function renderCostModelRow( const detail = `${formatTuiCostUsd(row.costUsd)} · ${formatCompact(row.totalTokens)} tok` + ` · ${row.turns} call${row.turns === 1 ? '' : 's'} · ${cache}` + - (row.unpricedRows > 0 ? ` · ${row.unpricedRows} unpriced` : ''); + `${row.unpricedRows > 0 ? ` · ${row.unpricedRows} unpriced` : ''}`; const lead = `${label} ${chalk.hex(colors.dim)(`(${scope})`)}`; const line = visibleWidth(lead) + 2 + visibleWidth(detail) <= width 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..e7ee277f 100644 --- a/packages/tui/src/tui/widgets/editor/editor.ts +++ b/packages/tui/src/tui/widgets/editor/editor.ts @@ -1,3 +1,11 @@ +import { + decodePluginMentions, + encodePluginMentions, + transformPluginMentions, + transformExternalPluginMentions, + validPluginMentions, + type EditorPluginMention, +} from './plugin-mentions.js'; import type { AutocompleteProvider, AutocompleteSuggestions } from '../autocomplete.js'; import { decodePrintableKey, @@ -20,6 +28,7 @@ export type { EditorTheme }; export interface EditorDraftSnapshot extends EditorStateSnapshot { readonly attachmentPlaceholders?: readonly EditorAttachmentElement[]; + readonly pluginMentions?: readonly EditorPluginMention[]; } export interface EditorAttachmentPlaceholder { @@ -46,6 +55,11 @@ export class Editor implements Component, Focusable { ) => void; private readonly engine: PiEditor; + private pluginMentions: EditorPluginMention[] = []; + private pendingPluginMention: EditorPluginMention | undefined; + private pendingPluginState: EditorPluginMention[] | undefined; + private skipNextPluginTransform = false; + private lastPluginSubmission: { content: string; transport: string } | undefined; private attachmentPlaceholders = new Map(); private bindLegacyAttachmentPlaceholders = false; private lastText = ''; @@ -70,10 +84,39 @@ export class Editor implements Component, Focusable { this.engine.onSubmit = (_text, snapshot) => this.handleEngineSubmit(snapshot); this.engine.onPaste = (text) => this.onPaste?.(text) ?? false; this.engine.onAutocompleteView = (suggestions) => this.onAutocompleteView?.(suggestions); - this.engine.onAutocompleteSelect = (suggestions, item) => + this.engine.onAutocompleteSelect = (suggestions, item) => { + if ('pluginId' in item && typeof item.pluginId === 'string') { + const start = this.engine.captureState().cursor - suggestions.prefix.length; + this.pendingPluginMention = { + pluginId: item.pluginId, + label: item.value, + start, + end: start + item.value.length, + }; + } this.onAutocompleteSelect?.(suggestions, item); - this.engine.captureUndoExtensionState = () => [...this.attachmentPlaceholders.values()]; - this.engine.restoreUndoExtensionState = (state) => this.restoreAttachmentUndoState(state); + }; + this.engine.captureUndoExtensionState = () => ({ + attachments: [...this.attachmentPlaceholders.values()], + plugins: this.pluginMentions.map((mention) => ({ ...mention })), + }); + this.engine.restoreUndoExtensionState = (state) => { + if (!state || typeof state !== 'object' || !('attachments' in state) || !('plugins' in state)) + return; + this.restoreAttachmentUndoState(state.attachments); + if (validPluginMentions(this.getText(), state.plugins)) { + this.pluginMentions = (state.plugins ?? []).map((mention) => ({ + ...mention, + })); + this.skipNextPluginTransform = true; + } + }; + this.engine.transformHistoryText = (text) => { + const decoded = decodePluginMentions(text); + this.pluginMentions = decoded.mentions; + this.skipNextPluginTransform = true; + return decoded.text; + }; } get focused(): boolean { @@ -113,7 +156,15 @@ export class Editor implements Component, Focusable { } addToHistory(text: string): void { - this.engine.addToHistory(text); + const current = this.captureDraft(); + const transport = + this.lastPluginSubmission?.content === text.trim() + ? this.lastPluginSubmission.transport + : submittedEditorContent(current) === text.trim() + ? submittedEditorTransport(current) + : undefined; + this.engine.addToHistory(transport ?? text); + this.lastPluginSubmission = undefined; } getHistoryEntries(): readonly string[] { @@ -139,6 +190,7 @@ export class Editor implements Component, Focusable { captureDraft(): EditorDraftSnapshot { return { ...this.engine.captureState(), + pluginMentions: this.pluginMentions.map((mention) => ({ ...mention })), attachmentPlaceholders: [...this.attachmentPlaceholders.values()].map((element) => ({ ...element, })), @@ -148,6 +200,11 @@ export class Editor implements Component, Focusable { restoreDraft(snapshot: EditorDraftSnapshot): boolean { if (!isValidEditorDraftSnapshot(snapshot)) return false; const previous = this.attachmentPlaceholders; + const previousPlugins = this.pluginMentions; + this.pluginMentions = (snapshot.pluginMentions ?? []).map((mention) => ({ + ...mention, + })); + this.skipNextPluginTransform = true; this.attachmentPlaceholders = new Map( (snapshot.attachmentPlaceholders ?? []).map((element) => [element.id, { ...element }]), ); @@ -157,6 +214,8 @@ export class Editor implements Component, Focusable { const restored = this.engine.restoreState(snapshot); if (!restored) { this.attachmentPlaceholders = previous; + this.pluginMentions = previousPlugins; + this.skipNextPluginTransform = false; this.skipNextAttachmentTransform = false; } return restored; @@ -180,8 +239,18 @@ export class Editor implements Component, Focusable { cursor: currentOffset + remappedCurrent.cursor, pastes: [...snapshot.pastes.map((paste) => ({ ...paste })), ...remappedCurrent.pastes], pasteCounter: remappedCurrent.pasteCounter, + pluginMentions: [ + ...(snapshot.pluginMentions ?? []).map((mention) => ({ ...mention })), + ...(remappedCurrent.pluginMentions ?? []).map((mention) => ({ + ...mention, + start: mention.start + currentOffset, + end: mention.end + currentOffset, + })), + ], attachmentPlaceholders: [ - ...(snapshot.attachmentPlaceholders ?? []).map((element) => ({ ...element })), + ...(snapshot.attachmentPlaceholders ?? []).map((element) => ({ + ...element, + })), ...(remappedCurrent.attachmentPlaceholders ?? []) .filter(({ id }) => !submittedAttachmentIds.has(id)) .map((element) => ({ @@ -239,7 +308,10 @@ export class Editor implements Component, Focusable { dismissAttachmentPreview(): void { const preview = this.getAttachmentPreview(); if (!preview) return; - this.dismissedAttachmentPreview = { id: preview.id, cursor: this.engine.captureState().cursor }; + this.dismissedAttachmentPreview = { + id: preview.id, + cursor: this.engine.captureState().cursor, + }; this.freshAttachmentPreview = undefined; this.tui.requestRender(); } @@ -251,6 +323,7 @@ export class Editor implements Component, Focusable { let cursor = this.engine.captureState().cursor; let changed = false; const elements = cloneAttachmentElements(this.attachmentPlaceholders); + let pluginMentions = this.pluginMentions.map((mention) => ({ ...mention })); if (this.bindLegacyAttachmentPlaceholders) { const claimedRanges: Array<{ start: number; end: number }> = []; for (const [id, label] of next) { @@ -270,7 +343,9 @@ export class Editor implements Component, Focusable { replacement: string, target?: { id: string; label: string }, ): void => { - text = `${text.slice(0, start)}${replacement}${text.slice(end)}`; + const nextText = `${text.slice(0, start)}${replacement}${text.slice(end)}`; + pluginMentions = transformPluginMentions(text, nextText, pluginMentions); + text = nextText; if (cursor > end) cursor += replacement.length - (end - start); else if (cursor > start) cursor = start + replacement.length; const delta = replacement.length - (end - start); @@ -342,10 +417,14 @@ export class Editor implements Component, Focusable { return; } this.pendingAttachmentElements = elements; + this.pendingPluginState = pluginMentions; this.skipNextAttachmentTransform = true; this.engine.replaceRange(0, this.getText().length, text, cursor); if (added) { - this.freshAttachmentPreview = { id: added.id, cursor: this.engine.captureState().cursor }; + this.freshAttachmentPreview = { + id: added.id, + cursor: this.engine.captureState().cursor, + }; this.dismissedAttachmentPreview = undefined; } } @@ -359,16 +438,32 @@ export class Editor implements Component, Focusable { } setText(text: string): void { - const value = text.replace(/\r\n?/gu, '\n'); + const decoded = decodePluginMentions(text.replace(/\r\n?/gu, '\n')); + const value = decoded.text; const replaced = appendAttachmentElements(value, this.attachmentPlaceholders); - if (replaced.text === this.getText()) return; + if (replaced.text === this.getText()) { + if (text !== value) { + this.pluginMentions = decoded.mentions; + this.onChange?.(this.getText()); + } + return; + } + this.pendingPluginMention = undefined; + this.pendingPluginState = decoded.mentions; this.pendingAttachmentElements = replaced.elements; this.skipNextAttachmentTransform = true; this.engine.replaceRange(0, this.getText().length, replaced.text, replaced.cursor); } replaceTextUndoable(text: string): void { - this.setText(text); + // External editors edit expanded visible text, while setText loads a new draft. + const draft = this.captureDraft(); + const previous = decodePluginMentions( + expandDraftPastes(encodePluginMentions(draft.text, this.pluginMentions), draft.pastes), + ); + const normalized = text.replace(/\r\n?/gu, '\n'); + const mentions = transformExternalPluginMentions(previous.text, normalized, previous.mentions); + this.setText(encodePluginMentions(normalized, mentions)); } invalidate(): void { @@ -407,6 +502,7 @@ export class Editor implements Component, Focusable { dispose(): void { this.engine.dispose(); this.attachmentPlaceholders.clear(); + this.pluginMentions = []; this.pendingSubmission = undefined; this.lastAttachmentSubmission = undefined; this.onAttachmentPlaceholderDeleted = undefined; @@ -414,7 +510,21 @@ export class Editor implements Component, Focusable { } private handleEngineChange(text: string): void { + if (this.pendingPluginState) { + this.pluginMentions = this.pendingPluginState; + this.pendingPluginState = undefined; + } else if (this.skipNextPluginTransform) this.skipNextPluginTransform = false; + else this.pluginMentions = transformPluginMentions(this.lastText, text, this.pluginMentions); + if (this.pendingPluginMention) { + if ( + text.slice(this.pendingPluginMention.start, this.pendingPluginMention.end) === + this.pendingPluginMention.label + ) + this.pluginMentions.push(this.pendingPluginMention); + this.pendingPluginMention = undefined; + } if (this.pendingSubmission && text === '') { + this.pluginMentions = []; this.attachmentPlaceholders.clear(); this.lastText = ''; this.skipNextAttachmentTransform = false; @@ -445,13 +555,15 @@ export class Editor implements Component, Focusable { private handleEngineSubmit(snapshot: EditorStateSnapshot): void { const draft: EditorDraftSnapshot = { ...snapshot, + pluginMentions: this.pendingSubmission?.pluginMentions?.map((mention) => ({ ...mention })), attachmentPlaceholders: (this.pendingSubmission?.attachmentPlaceholders ?? []).map( (element) => ({ ...element }), ), }; this.pendingSubmission = undefined; - const visibleText = removeAttachmentElements(draft.text, draft.attachmentPlaceholders ?? []); - const content = expandDraftPastes(visibleText, draft.pastes).trim(); + const content = submittedEditorContent(draft); + const transport = submittedEditorTransport(draft); + this.lastPluginSubmission = transport ? { content, transport } : undefined; if (draft.attachmentPlaceholders?.length) this.lastAttachmentSubmission = { content, draft }; this.onSubmit?.(content, draft); } @@ -475,14 +587,24 @@ export class Editor implements Component, Focusable { } } + private atomicElements(): EditorAttachmentElement[] { + return [ + ...this.attachmentPlaceholders.values(), + ...this.pluginMentions.map((mention) => ({ + ...mention, + id: `plugin:${mention.start}`, + })), + ]; + } + private handleAtomicAttachmentInput(data: string): boolean { - if (this.attachmentPlaceholders.size === 0) return false; + if (this.atomicElements().length === 0) return false; const command = resolveAttachmentCommand(data); if (!command || command === 'jump-forward' || command === 'jump-backward') return false; const snapshot = this.engine.captureState(); const cursor = snapshot.cursor; const text = snapshot.text; - const elements = [...this.attachmentPlaceholders.values()]; + const elements = this.atomicElements(); if (command === 'left' || command === 'word-left') { const element = elements.find( (candidate) => cursor > candidate.start && cursor <= attachmentElementEnd(text, candidate), @@ -526,7 +648,7 @@ export class Editor implements Component, Focusable { this.jumpOutsideAttachments(printable, direction); return true; } - if (this.attachmentPlaceholders.size === 0) return false; + if (this.atomicElements().length === 0) return false; const command = resolveAttachmentCommand(data); if (command !== 'jump-forward' && command !== 'jump-backward') return false; this.attachmentJumpMode = command === 'jump-forward' ? 'forward' : 'backward'; @@ -542,7 +664,7 @@ export class Editor implements Component, Focusable { ? snapshot.text.indexOf(character, from) : snapshot.text.lastIndexOf(character, from); if (target < 0) return; - const element = [...this.attachmentPlaceholders.values()].find( + const element = this.atomicElements().find( (candidate) => target >= candidate.start && target < candidate.end, ); if (!element) { @@ -556,7 +678,7 @@ export class Editor implements Component, Focusable { private snapCursorOutsideAttachment(previousCursor: number): void { const snapshot = this.engine.captureState(); - const element = [...this.attachmentPlaceholders.values()].find( + const element = this.atomicElements().find( (candidate) => snapshot.cursor > candidate.start && snapshot.cursor < candidate.end, ); if (!element) return; @@ -624,7 +746,10 @@ function editorDeletionRange( }; } if (command === 'delete-line-start') { - return { start: text.lastIndexOf('\n', Math.max(0, cursor - 1)) + 1, end: cursor }; + return { + start: text.lastIndexOf('\n', Math.max(0, cursor - 1)) + 1, + end: cursor, + }; } const lineEnd = text.indexOf('\n', cursor); return { @@ -653,6 +778,7 @@ function isValidEditorDraftSnapshot(snapshot: EditorDraftSnapshot): boolean { !snapshot || snapshot.schemaVersion !== 1 || typeof snapshot.text !== 'string' || + !validPluginMentions(snapshot.text, snapshot.pluginMentions) || !Number.isInteger(snapshot.cursor) || snapshot.cursor < 0 || snapshot.cursor > snapshot.text.length || @@ -699,7 +825,10 @@ function isValidEditorDraftSnapshot(snapshot: EditorDraftSnapshot): boolean { return false; } attachmentIds.add(element.id); - ranges.push({ start: attachmentElementStart(snapshot.text, element), end: element.end }); + ranges.push({ + start: attachmentElementStart(snapshot.text, element), + end: element.end, + }); return true; }); } @@ -729,7 +858,14 @@ function remapEditorDraftPastes( const id = Number(match[1]); const paste = draft.pastes.find((candidate) => candidate.id === id); if (!paste || match.index === undefined) return []; - return [{ start: match.index, end: match.index + match[0].length, paste, marker: match[0] }]; + return [ + { + start: match.index, + end: match.index + match[0].length, + paste, + marker: match[0], + }, + ]; }) .map((replacement, index) => { const id = startingCounter + index + 1; @@ -755,8 +891,16 @@ function remapEditorDraftPastes( schemaVersion: 1, text, cursor: shiftPosition(draft.cursor), - pastes: replacements.map(({ id, paste }) => ({ id, content: paste.content })), + pastes: replacements.map(({ id, paste }) => ({ + id, + content: paste.content, + })), pasteCounter: startingCounter + replacements.length, + pluginMentions: draft.pluginMentions?.map((mention) => ({ + ...mention, + start: shiftPosition(mention.start), + end: shiftPosition(mention.end), + })), attachmentPlaceholders: (draft.attachmentPlaceholders ?? []).map((element) => ({ ...element, start: shiftPosition(element.start), @@ -788,7 +932,11 @@ function attachmentElementEnd(text: string, element: EditorAttachmentElement): n function appendAttachmentElements( text: string, elements: ReadonlyMap, -): { text: string; cursor: number; elements: Map } { +): { + text: string; + cursor: number; + elements: Map; +} { let output = text; let cursor = output.length; const appended = new Map(); @@ -808,6 +956,34 @@ 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(); +} + +/** Serialize identity bindings into the existing durable user-text transport. */ +export function submittedEditorTransport(draft: EditorDraftSnapshot): string | undefined { + if (!draft.pluginMentions?.length) return undefined; + let text = draft.text; + const replacements = [ + ...draft.pluginMentions.map((mention) => ({ + start: mention.start, + end: mention.end, + text: encodePluginMentions(mention.label, [ + { ...mention, start: 0, end: mention.label.length }, + ]), + })), + ...(draft.attachmentPlaceholders ?? []).map((element) => ({ + start: attachmentElementStart(text, element), + end: attachmentElementEnd(text, element), + text: '', + })), + ].sort((a, b) => b.start - a.start); + for (const replacement of replacements) + text = text.slice(0, replacement.start) + replacement.text + text.slice(replacement.end); + return expandDraftPastes(text, draft.pastes).trim(); +} + function removeAttachmentElements( text: string, elements: readonly EditorAttachmentElement[], diff --git a/packages/tui/src/tui/widgets/editor/plugin-mentions.ts b/packages/tui/src/tui/widgets/editor/plugin-mentions.ts new file mode 100644 index 00000000..ca882832 --- /dev/null +++ b/packages/tui/src/tui/widgets/editor/plugin-mentions.ts @@ -0,0 +1,134 @@ +import { + parsePluginMentions, + serializePluginMention, + type PluginMention, +} from '@mavis/shared/plugin-mention'; + +export type EditorPluginMention = PluginMention; + +/** Decode durable history links into visible labels with exact identity bindings. */ +export function decodePluginMentions(text: string): { + text: string; + mentions: EditorPluginMention[]; +} { + const mentions: EditorPluginMention[] = []; + let offset = 0; + let previousEnd = 0; + let output = ''; + for (const mention of parsePluginMentions(text)) { + output += text.slice(previousEnd, mention.start) + mention.label; + const start = mention.start + offset; + mentions.push({ ...mention, start, end: start + mention.label.length }); + offset += mention.label.length - (mention.end - mention.start); + previousEnd = mention.end; + } + return { text: output + text.slice(previousEnd), mentions }; +} + +export function encodePluginMentions( + text: string, + mentions: readonly EditorPluginMention[], +): string { + let output = text; + for (const mention of [...mentions].sort((a, b) => b.start - a.start)) { + if (text.slice(mention.start, mention.end) !== mention.label) continue; + output = + output.slice(0, mention.start) + + serializePluginMention(mention.pluginId, mention.label) + + output.slice(mention.end); + } + return output; +} + +export function validPluginMentions( + text: string, + value: unknown, +): value is readonly EditorPluginMention[] | undefined { + if (value === undefined) return true; + if (!Array.isArray(value)) return false; + const ranges: Array<{ start: number; end: number }> = []; + return value.every((item) => { + if ( + !item || + typeof item !== 'object' || + typeof item.pluginId !== 'string' || + !item.pluginId || + item.pluginId.length > 512 || + /[\u0000-\u001f\u007f]/u.test(item.pluginId) || + typeof item.label !== 'string' || + !item.label.startsWith('@') || + item.label.length > 257 || + !Number.isInteger(item.start) || + !Number.isInteger(item.end) || + item.start < 0 || + item.end <= item.start || + item.end > text.length || + text.slice(item.start, item.end) !== item.label || + ranges.some((range) => item.start < range.end && item.end > range.start) + ) + return false; + ranges.push(item); + return true; + }); +} + +/** Edits inside a mention remove its binding; edits outside only move its range. */ +export function transformPluginMentions( + previous: string, + next: string, + mentions: readonly EditorPluginMention[], +): EditorPluginMention[] { + let start = 0; + while (start < previous.length && start < next.length && previous[start] === next[start]) start++; + let oldEnd = previous.length; + let newEnd = next.length; + while (oldEnd > start && newEnd > start && previous[oldEnd - 1] === next[newEnd - 1]) { + oldEnd--; + newEnd--; + } + return mentions.flatMap((mention) => { + if (mention.end <= start) return [{ ...mention }]; + if (mention.start < oldEnd) return []; + const delta = newEnd - oldEnd; + return [{ ...mention, start: mention.start + delta, end: mention.end + delta }]; + }); +} + +/** External edits can change several ranges at once; only relocate unambiguous labels. */ +export function transformExternalPluginMentions( + previous: string, + next: string, + mentions: readonly EditorPluginMention[], +): EditorPluginMention[] { + const positions = new Map(); + const occurrences = (text: string, label: string): number[] => { + const result: number[] = []; + for ( + let start = text.indexOf(label); + start >= 0; + start = text.indexOf(label, start + label.length) + ) { + result.push(start); + } + return result; + }; + return mentions + .flatMap((mention) => { + let matches = positions.get(mention.label); + if (!matches) { + matches = { + before: occurrences(previous, mention.label), + after: occurrences(next, mention.label), + }; + positions.set(mention.label, matches); + } + // Inserting/removing a duplicate visible label makes its source identity ambiguous. + if (matches.before.length !== matches.after.length) return []; + if (matches.before.length === 1) { + const start = matches.after[0]!; + return [{ ...mention, start, end: start + mention.label.length }]; + } + return transformPluginMentions(previous, next, [mention]); + }) + .sort((a, b) => a.start - b.start); +} diff --git a/packages/tui/src/types/tui-app.ts b/packages/tui/src/types/tui-app.ts index 86445e55..29f628e5 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 { KcodeAuthPort } 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, @@ -34,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, @@ -101,6 +105,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/src/update/application.ts b/packages/tui/src/update/application.ts index 7551b100..9b6c8199 100644 --- a/packages/tui/src/update/application.ts +++ b/packages/tui/src/update/application.ts @@ -232,8 +232,7 @@ function unsupportedInstallationMessage(source: KcodeInstallSource): string { : 'This installation is an npm prefix carrying the upstream installer layout, which this ' + 'build does not replace in place. '; return ( - layout + - `Install a release archive from ${KCODE_RELEASES_URL} with npm install --global, ` + + `${layout}Install a release archive from ${KCODE_RELEASES_URL} with npm install --global, ` + 'or keep this installation on the distribution it came from.' ); } diff --git a/packages/tui/test/helpers/theme-contrast.ts b/packages/tui/test/helpers/theme-contrast.ts new file mode 100644 index 00000000..9edd5074 --- /dev/null +++ b/packages/tui/test/helpers/theme-contrast.ts @@ -0,0 +1,58 @@ +import type { TuiThemeColors } from '../../src/tui/theme/contracts.js'; + +type TuiThemeColorRole = keyof TuiThemeColors; + +/** KCode alias for the upstream MINIMAX_CODE contrast policy (same palette roles). */ +export const KCODE_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/package-identity.test.ts b/packages/tui/test/unit/package-identity.test.ts index 5808e887..b6863331 100644 --- a/packages/tui/test/unit/package-identity.test.ts +++ b/packages/tui/test/unit/package-identity.test.ts @@ -73,7 +73,9 @@ describe('KCode package identity', () => { }); it('uses the same name in the CLI and in the build and release scripts', async () => { - const scriptIdentity = (await import('../../../../scripts/lib/package-identity.mjs')) as { + // import.meta.resolve keeps the cross-package reference declarative: the + // import/no-relative-packages rule only sees a specifier, not a path. + const scriptIdentity = (await import(import.meta.resolve('../../../../scripts/lib/package-identity.mjs'))) as { PACKAGE_NAME: string; WORKSPACE_PACKAGE_NAME: string; LEGACY_PACKAGE_NAMES: readonly string[]; diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 64a53664..9cdbe1e0 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, @@ -184,6 +185,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, @@ -510,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) => { @@ -682,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(), @@ -690,6 +788,8 @@ describe("createTuiApp", () => { }); try { await app.ready; + followBottom.mockClear(); + forceFollowBottom.mockClear(); app.editor.setText( process.platform === "win32" ? "!Write-Output shell-result" @@ -709,6 +809,8 @@ describe("createTuiApp", () => { ).toBe(true), { timeout: 5000 }, ); + expect(followBottom).toHaveBeenCalled(); + expect(forceFollowBottom).not.toHaveBeenCalled(); expect(runtime.sendMessage).not.toHaveBeenCalled(); expect( app.transcript @@ -727,6 +829,8 @@ describe("createTuiApp", () => { ); } finally { await app.stop(); + followBottom.mockRestore(); + forceFollowBottom.mockRestore(); await rm(directory, { recursive: true, force: true }); } }); @@ -1823,7 +1927,7 @@ describe("createTuiApp", () => { const starting = app.tui.render(80).join("\n"); expect(starting).toContain("⠋ Starting server..."); - expect(starting).not.toContain("Start · @ file · / autocomplete"); + expect(starting).not.toContain("Start · @ file or Plugin · / autocomplete"); expect(starting).not.toContain("Loading session"); releaseSessions?.(); @@ -1831,7 +1935,7 @@ describe("createTuiApp", () => { app.setStartupStatus(undefined); expect(app.tui.render(80).join("\n")).toContain( - "Start · @ file · / autocomplete", + "Start · @ file or Plugin · / autocomplete", ); } finally { releaseSessions?.(); @@ -2573,6 +2677,7 @@ describe("createTuiApp", () => { app.start(); await app.ready; + const writesBefore = terminal.writes.length; terminal.input?.("/"); await vi.waitFor(() => @@ -2588,6 +2693,7 @@ describe("createTuiApp", () => { terminal.input?.("\u001B"); expect(app.editor.getText()).toBe("/"); expect(app.editor.focused).toBe(true); + expect(terminal.writes.slice(writesBefore).join("")).not.toContain("\x1b[3J"); await app.stop(); }); @@ -2631,6 +2737,83 @@ describe("createTuiApp", () => { await app.stop(); }); + describe.each([ + ["xterm", VirtualTerminal], + ["clear-to-scrollback terminal", ClearToScrollbackTerminal], + ] as const)("autocomplete viewport restoration on %s", (_terminalName, Terminal) => { + it.each([ + ["Escape", "\x1b", "/"], + ["Tab", "\t", "/help "], + ["Backspace", "\x7f", ""], + ["no matches", "zzzzzz", "/zzzzzz"], + ["filter to one command", "help", "/help"], + ] as const)("restores the conversation viewport when Slash Command autocomplete shrinks via %s", async (_name, input, draft) => { + const terminal = new Terminal(80, 24); + const app = createTuiApp({ + runtime: createRuntime(), + terminal, + version: "0.2.0", + workspaceDir: "/workspace", + tuiMode: "regular", + }); + + app.start(); + try { + await app.ready; + for (let index = 0; index < 12; index++) await app.submit(`Message ${index}`); + app.tui.renderNow(); + await terminal.flush(); + + terminal.sendInput("/"); + await vi.waitFor(async () => { + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).toContain("Show available commands"); + }); + const menuRows = app.editor.render(78).length; + terminal.sendInput(input); + await vi.waitFor(async () => { + app.tui.renderNow(); + await terminal.flush(); + expect(app.editor.getText()).toBe(draft); + expect(app.editor.render(78).length).toBeLessThan(menuRows); + const expected = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).slice(-24); + expect(terminal.getViewport().map((line) => line.trimEnd())).toEqual(expected); + }); + const history = terminal.getScrollBuffer(); + for (let index = 0; index < 12; index++) { + expect(history.filter((line) => line.trimEnd().endsWith(`› Message ${index}`))).toHaveLength(1); + } + } finally { + await app.stop(); + } + }); + }); + + it("dismisses autocomplete in a short document without clearing native history", async () => { + const terminal = new VirtualTerminal(80, 100); + const app = createTuiApp({ runtime: createRuntime(), terminal, version: "0.2.0", workspaceDir: "/workspace" }); + const write = vi.spyOn(terminal, "write"); + app.start(); + try { + await app.ready; + terminal.sendInput("/"); + await vi.waitFor(async () => { + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).toContain("Show available commands"); + }); + write.mockClear(); + terminal.sendInput("\x1b"); + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).not.toContain("Show available commands"); + expect(write.mock.calls.map(([data]) => data).join("")).not.toContain("\x1b[3J"); + } finally { + await app.stop(); + } + }); + it("resumes fullscreen tail following when submitting from scrolled history", async () => { const terminal = new FakeTerminal(); terminal.rows = 6; @@ -3083,6 +3266,93 @@ describe("createTuiApp", () => { await app.stop(); }); + it.each(["rewind", "edit"] as const)( + "removes discarded todos after /history %s", + async (action) => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.listSessionInputSummaries).mockResolvedValue([ + { userMessageId: "before-todo", timestamp: 1, fileChangeCount: 0 }, + ]); + vi.mocked(runtime.listMessagePage).mockResolvedValue({ + messages: [{ id: "before-todo", role: "user", content: "Original prompt" }], + hasMore: false, + }); + const app = createTuiApp({ runtime, terminal, version: "0.1.0", workspaceDir: "/workspace" }); + app.start(); + try { + await app.ready; + await app.submit("Seed todo session"); + app.controller.applyRuntimeTurnEvent("discarded-turn", { + type: "generic", + eventType: "todo_updated", + turnId: "discarded-turn", + data: { todos: [{ content: "Discarded todo", status: "in_progress" }] }, + }); + expect(stripAnsi(app.tui.render(100).join("\n"))).toContain("Discarded todo"); + await app.submit("/history"); + await vi.waitFor(() => + expect(app.surfaceHost.getActiveSurface().id).toBe("session-history:explorer"), + ); + terminal.input?.("\r"); + terminal.input?.("\x1b[B"); + if (action === "rewind") terminal.input?.("\x1b[B"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(app.surfaceHost.getActiveSurface().id).toBe( + action === "rewind" + ? "session-mutation:rewind-confirm" + : "session-mutation:rewind-preview", + ), + ); + terminal.input?.("\r"); + if (action === "edit") { + await vi.waitFor(() => expect(app.editor.getText()).toBe("Original prompt")); + await app.submit("Revised prompt"); + expect(runtime.editSessionMessage).toHaveBeenCalledOnce(); + } else { + await vi.waitFor(() => expect(runtime.rewindSession).toHaveBeenCalledOnce()); + } + await vi.waitFor(() => expect(app.surfaceHost.getActiveSurface().kind).toBe("chat")); + expect(stripAnsi(app.tui.render(100).join("\n"))).not.toContain("Discarded todo"); + } finally { + await app.stop(); + } + }, + ); + + it.each([false, true])("handles exact /rewind locally while editing=%s", async (editing) => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.listSessionInputSummaries).mockResolvedValue([ + { userMessageId: "before-todo", timestamp: 1, fileChangeCount: 0 }, + ]); + vi.mocked(runtime.listMessagePage).mockResolvedValue({ + messages: [{ id: "before-todo", role: "user", content: "Original prompt" }], + hasMore: false, + }); + const app = createTuiApp({ runtime, terminal, version: "0.1.0", workspaceDir: "/workspace" }); + app.start(); + try { + await app.ready; + await app.submit("Seed session"); + if (editing) { + await app.submit("/edit"); + await vi.waitFor(() => expect(app.editor.getText()).toBe("Original prompt")); + } + const sends = vi.mocked(runtime.sendMessage).mock.calls.length; + app.editor.setText("/rewind"); + app.editor.handleInput("\r"); + await vi.waitFor(() => + expect(app.surfaceHost.getActiveSurface().id).toBe("session-mutation:history"), + ); + expect(runtime.editSessionMessage).not.toHaveBeenCalled(); + expect(runtime.sendMessage).toHaveBeenCalledTimes(sends); + } finally { + await app.stop(); + } + }); + it("uses Ctrl+T for tasks while Ctrl+O remains scoped to transcript details", async () => { const terminal = new FakeTerminal(); const app = createTuiApp({ @@ -5662,6 +5932,153 @@ describe("createTuiApp", () => { await app.stop(); }); + it.each(["/theme", "/settings", "history search"])("restores the complete chat viewport after closing %s", async (entry) => { + const terminal = new VirtualTerminal(80, 24); + const app = createTuiApp({ runtime: createRuntime(), terminal, version: "0.2.0", workspaceDir: "/workspace" }); + app.start(); + try { + await app.ready; + for (let index = 0; index < 12; index++) await app.submit(`Message ${index}`); + if (entry === "history search") { + for (let index = 0; index < 12; index++) app.editor.addToHistory(`Message ${index}`); + terminal.sendInput("\x12"); + } + else await app.submit(entry); + app.tui.renderNow(); + await terminal.flush(); + expect(app.interaction.isActive()).toBe(true); + terminal.sendInput("\x1b"); + app.tui.renderNow(); + await terminal.flush(); + expect(app.interaction.isActive()).toBe(false); + const expected = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).slice(-24); + expect(terminal.getViewport().map((line) => line.trimEnd())).toEqual(expected); + for (let index = 0; index < 12; index++) { + expect(terminal.getScrollBuffer().filter((line) => line.trimEnd().endsWith(`› Message ${index}`))).toHaveLength(1); + } + } finally { + await app.stop(); + } + }); + + it.each(["multiline draft", "image preview"])("restores the chat viewport after dismissing %s", async (kind) => { + const terminal = new ClearToScrollbackTerminal(80, 24); + const app = createTuiApp({ + runtime: createRuntime(), terminal, version: "0.2.0", workspaceDir: "/workspace", + resolveAttachment: async () => ({ type: "image", filePath: "/tmp/preview-missing.png", fileName: "preview.png", mimeType: "image/png", sizeBytes: 32768 }), + }); + app.start(); + try { + await app.ready; + for (let index = 0; index < 12; index++) await app.submit(`Message ${index}`); + if (kind === "multiline draft") { + app.editor.setText(Array.from({ length: 8 }, (_, index) => `Draft ${index}`).join("\n")); + } else { + terminal.sendInput("\x1b[200~/tmp/preview-missing.png\x1b[201~"); + await vi.waitFor(() => expect(app.editor.getAttachmentPreview()).toBeDefined()); + } + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).toContain(kind === "multiline draft" ? "Draft 7" : "preview.png"); + terminal.sendInput(kind === "multiline draft" ? "\x03" : "\x1b"); + app.tui.renderNow(); + await terminal.flush(); + if (kind === "multiline draft") expect(app.editor.getText()).toBe(""); + else expect(app.editor.getAttachmentPreview()).toBeUndefined(); + const expected = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).slice(-24); + expect(terminal.getViewport().map((line) => line.trimEnd())).toEqual(expected); + } finally { + 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 Kcode 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; @@ -10947,12 +11364,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("KCode ·"); @@ -10967,11 +11387,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(); }); @@ -11021,7 +11445,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(() => @@ -11031,6 +11457,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", ); @@ -11040,11 +11471,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(); }); @@ -11057,6 +11489,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 }); }); @@ -11112,6 +11547,991 @@ 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.each([ + "resubmit", + "cancel", + "failed-submit-cancel", + "new-operation-cancel", + "rewound-submit-cancel", + "resubmit-required-cancel", + "fast-completion", + "early-rewind", + "display-commit-cancel", + ])( + "keeps the interrupted footer lifecycle correct after double Escape and %s", + async (action) => { + const terminal = new FakeTerminal(); + const screen = new VirtualTerminalScreen(terminal.columns, terminal.rows); + const frames: string[] = []; + const write = terminal.write.bind(terminal); + terminal.write = (data) => { + write(data); + screen.feed(data); + if (data.includes("\x1b[?2026l")) frames.push(screen.text()); + }; + const runtime = createRuntime(); + const busEvents: TuiRuntimeEvent[] = []; + let wakeBus: (() => void) | undefined; + let finishEdit: (() => void) | undefined; + vi.mocked(runtime.watchEvents).mockImplementation(async function* (signal) { + while (!signal.aborted) { + if (busEvents.length === 0) { + await new Promise((resolve) => { + wakeBus = resolve; + signal.addEventListener("abort", resolve, { once: true }); + }); + } + const event = busEvents.shift(); + if (event) yield event; + } + }); + const originalMessage = { + id: "msg-user-interrupted", + role: "user" as const, + content: "Original query", + timestamp: 1, + }; + vi.mocked(runtime.listSessionInputSummaries).mockResolvedValue([ + { userMessageId: originalMessage.id, timestamp: 1, fileChangeCount: 0 }, + ]); + vi.mocked(runtime.listMessagePage).mockResolvedValue({ + messages: [originalMessage], + hasMore: false, + }); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* (_request, signal) { + yield { type: "message", message: originalMessage }; + yield { type: "delta", content: "Partial response" }; + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + // The RPC/history refresh can finish before the replacement session.start event. + vi.mocked(runtime.editSessionMessage).mockImplementation(async () => { + if (action === "failed-submit-cancel") + throw new Error("Edit rejected before rewind"); + const failureCodes: Partial> = { + "new-operation-cancel": "EDIT_RESTART_NEEDS_NEW_OPERATION", + "rewound-submit-cancel": "EDIT_SUBMIT_FAILED_AFTER_REWIND", + "resubmit-required-cancel": "EDIT_RESTART_NEEDS_RESUBMIT", + "display-commit-cancel": "REWIND_DISPLAY_COMMIT_FAILED", + }; + const failureCode = failureCodes[action]; + if (failureCode) + throw Object.assign(new Error("Edit failed"), { key: failureCode }); + if (action === "fast-completion") { + app.controller.beginRuntimeTurn("turn-edited", 1); + app.controller.runtimeTurnSettlement.settleProjection( + "turn-edited", + "succeeded", + 2_000, + ); + } + vi.mocked(runtime.getMessages).mockResolvedValue([ + { ...originalMessage, id: "msg-user-edited", content: "Edited query" }, + ]); + if (action === "early-rewind") { + busEvents.push( + runtimeEvent({ + type: "message.rewind", + timestamp: 101, + source: "runtime-v2", + payload: { sessionId: "session-1", contextReset: true }, + }), + ); + wakeBus?.(); + await new Promise((resolve) => { + finishEdit = resolve; + }); + } + return { + rewound: true, + turnId: "turn-edited", + userMessageId: "msg-user-edited", + }; + }); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + app.start(); + try { + await app.ready; + terminal.input?.("Original query"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect( + app.transcript + .snapshot() + .some((cell) => cell.content === "Partial response"), + ).toBe(true), + ); + terminal.input?.("\x1b"); + await vi.waitFor(() => + expect( + app.transcript + .snapshot() + .some((cell) => cell.kind === "turn-duration"), + ).toBe(true), + ); + expect(app.editor.getText()).toBe(""); + app.tui.renderNow(); + expect(screen.text()).toContain("Interrupted after"); + + terminal.input?.("\x1b"); + terminal.input?.("\x1b"); + await vi.waitFor(() => + expect(app.editor.getText()).toBe("Original query"), + ); + app.tui.renderNow(); + expect(screen.text()).not.toContain("Interrupted after"); + + if (action === "cancel") { + terminal.input?.("\x1b"); + app.tui.renderNow(); + expect(screen.text()).toContain("Interrupted after"); + expect(runtime.editSessionMessage).not.toHaveBeenCalled(); + return; + } + + frames.length = 0; + app.editor.setText("Edited query"); + terminal.input?.("\r"); + if (action.endsWith("-cancel")) { + await vi.waitFor(() => + expect(app.editor.getText()).toBe("Edited query"), + ); + expect(runtime.editSessionMessage).toHaveBeenCalledOnce(); + terminal.input?.("\x1b"); + app.tui.renderNow(); + if ( + action === "failed-submit-cancel" || + action === "new-operation-cancel" + ) { + expect(screen.text()).toContain("Interrupted after"); + } else { + expect(screen.text()).not.toContain("Interrupted after"); + } + return; + } + await vi.waitFor(() => + expect( + app.transcript + .snapshot() + .some((cell) => cell.sourceMessageId === "msg-user-edited"), + ).toBe(true), + ); + app.tui.renderNow(); + if (action === "early-rewind") { + // Inspect the actual screen while the edit RPC is still pending. + expect(finishEdit).toBeDefined(); + expect(screen.text()).not.toContain("Interrupted after"); + finishEdit?.(); + await vi.waitFor(() => expect(app.editor.getText()).toBe("")); + app.tui.renderNow(); + } + expect(runtime.editSessionMessage).toHaveBeenCalledOnce(); + expect(screen.text()).toContain("Edited query"); + expect(screen.text()).not.toContain("Interrupted after"); + if (action === "fast-completion") + expect(screen.text()).toContain("Completed in 2s"); + expect( + frames.filter((frame) => frame.includes("Interrupted after")), + ).toEqual([]); + } finally { + finishEdit?.(); + await app.stop(); + screen.dispose(); + } + }, + ); + + 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(); + 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-background-work-panel.test.ts b/packages/tui/test/unit/tui-background-work-panel.test.ts index 2ccd563a..592553fe 100644 --- a/packages/tui/test/unit/tui-background-work-panel.test.ts +++ b/packages/tui/test/unit/tui-background-work-panel.test.ts @@ -342,7 +342,12 @@ describe('Tasks in the regular terminal viewport', () => { const terminal = new VirtualTerminal(80, 16); const tui = new TuiMainScreen(terminal); const chatLines = Array.from({ length: 40 }, (_, index) => `chat-line-${index}`); - tui.addChild({ render: () => [...chatLines, 'COMPOSER', 'STATUS'], invalidate() {} }); + tui.addChild({ + render: () => [...chatLines, 'COMPOSER', 'STATUS'], + // This fixture settles background content while the transient footer stays fixed. + getViewportLayoutKey: () => 'composer:1,status:1', + invalidate() {}, + }); const tasks = Array.from({ length: 30 }, (_, index) => ({ ...backgroundTasks()[0]!, taskId: `task-${index}`, diff --git a/packages/tui/test/unit/tui-chat-controller.test.ts b/packages/tui/test/unit/tui-chat-controller.test.ts index 37cb2c03..c6e23dd0 100644 --- a/packages/tui/test/unit/tui-chat-controller.test.ts +++ b/packages/tui/test/unit/tui-chat-controller.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { SendMessageReq } from '@mavis/local-runtime-v2/cli-service'; +import type { CliSendMessageReq, SendMessageReq } from '@mavis/local-runtime-v2/cli-service'; import { TuiFailure } from '../../src/failure.js'; import { TuiChatController as ProductionTuiChatController, @@ -22,6 +22,50 @@ class TuiChatController extends ProductionTuiChatController { } describe('TuiChatController', () => { + it.each(['display', 'legacy transport'])( + 'reconciles plugin mentions using %s without duplicate user cells', + async (echo) => { + const content = '[@Codex 插件](plugin://codex%40official) 这个插件可以干什么'; + const displayContent = '@Codex 插件 这个插件可以干什么'; + let turn = 0; + const runtime = { + createSession: vi.fn(async () => ({ sessionId: 'plugin-session' })), + sendMessage: vi.fn(async function* ( + request: SendMessageReq, + ): AsyncGenerator { + expect(request).toMatchObject({ content, displayContent }); + const message = { + id: `message-${request.turnId}`, + turnId: request.turnId, + role: 'user' as const, + content: echo === 'display' ? request.displayContent : request.content, + }; + yield { type: 'message', message }; + yield { type: 'message', message }; + yield { type: 'done', turnId: request.turnId }; + }), + abortSession: vi.fn(async () => true), + }; + const transcript = new TranscriptStore(); + const controller = new ProductionTuiChatController({ + runtime: runtime as never, + transcript, + workspaceDir: '/workspace', + createTurnId: () => `plugin-turn-${++turn}`, + }); + await controller.submit(content, { displayContent }); + expect(transcript.snapshot().filter((cell) => cell.kind === 'user')).toMatchObject([ + { content: displayContent, sourceMessageId: 'message-plugin-turn-1' }, + ]); + // An intentional repeat in another turn must remain a separate message. + await controller.submit(content, { displayContent }); + expect(transcript.snapshot().filter((cell) => cell.kind === 'user')).toHaveLength(2); + const rendered = new TranscriptView(transcript).render(100).join('\n'); + expect(rendered).not.toContain('plugin://'); + expect(rendered).not.toContain('\x1b[4m'); + }, + ); + it('hides plain and namespaced AskUser protocol tools from the transcript', () => { expect(isQuestionnaireTool('ask_user')).toBe(true); expect(isQuestionnaireTool('functions.AskUser')).toBe(true); @@ -268,7 +312,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', }); @@ -281,6 +340,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(); @@ -289,6 +350,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 = { @@ -3103,54 +3188,48 @@ describe('TuiChatController', () => { ); }); - it('deletes a Session and clears the projection when it was the visible one', async () => { - const deleteSession = vi.fn(async (_sessionId: string): Promise => undefined); - const runtime = { - listSessions: vi.fn(async () => [ - { - sessionId: 'session-1', - title: 'First', - workspaceDir: '/workspace', - updatedAt: 200, - }, - { - sessionId: 'session-2', - title: 'Second', - workspaceDir: '/workspace', - updatedAt: 100, - }, - ]), - getSession: vi.fn(async (sessionId: string) => ({ - sessionId, + it.each([false, true])( + 'rebuilds todos from rewound history with earlier todos=%s', + async (keepEarlier) => { + const onTodoChange = vi.fn(); + const earlierTodo = { content: 'Earlier task', status: 'pending' }; + const getMessages = vi.fn(async () => + keepEarlier + ? [ + { + id: 'earlier-todo', + turnId: 'earlier-turn', + role: 'system' as const, + content: JSON.stringify({ eventType: 'todo_updated', todos: [earlierTodo] }), + }, + ] + : [], + ); + const controller = new ProductionTuiChatController({ + runtime: { + getSession: vi.fn(async () => ({ sessionId: 'rewound-session' })), + getMessages, + } as never, + transcript: new TranscriptStore(), workspaceDir: '/workspace', - })), - getMessages: vi.fn(async () => []), - deleteSession, - sendMessage: vi.fn(), - abortSession: vi.fn(async () => true), - }; - const controller = new TuiChatController({ - runtime, - transcript: new TranscriptStore(), - workspaceDir: '/workspace', - }); - - await controller.initialize(); - await controller.loadSessionProjection('session-1'); + onTodoChange, + }); + await controller.loadSessionProjection('rewound-session'); + controller.applyRuntimeTurnEvent('discarded-turn', { + type: 'generic', + eventType: 'todo_updated', + turnId: 'discarded-turn', + data: { todos: [{ content: 'Discarded task', status: 'in_progress' }] }, + }); + expect(onTodoChange).toHaveBeenLastCalledWith([ + { content: 'Discarded task', status: 'in_progress' }, + ]); - await controller.deleteSession('session-2'); - expect(deleteSession).toHaveBeenCalledWith('session-2'); - expect(controller.snapshot().sessions.map((session) => session.sessionId)).not.toContain( - 'session-2', - ); - expect(controller.snapshot().session?.sessionId).toBe('session-1'); + await controller.reconcileOwnerHistory(true); - await controller.deleteSession('session-1'); - expect(controller.snapshot().session).toBeUndefined(); - expect( - controller.snapshot().sessions.map((session) => session.sessionId), - ).toEqual([]); - }); + expect(onTodoChange).toHaveBeenLastCalledWith(keepEarlier ? [earlierTodo] : []); + }, + ); it('clears input-adjacent tasks when starting a new session', () => { const onTodoChange = vi.fn(); diff --git a/packages/tui/test/unit/tui-composer.test.ts b/packages/tui/test/unit/tui-composer.test.ts index 5c6da276..eb7d980a 100644 --- a/packages/tui/test/unit/tui-composer.test.ts +++ b/packages/tui/test/unit/tui-composer.test.ts @@ -312,7 +312,7 @@ describe("TuiComposer", () => { ); const firstHeader = stripAnsi(composer.render(120)[0] ?? ""); - expect(firstHeader).toContain("Start · @ file · / autocomplete"); + expect(firstHeader).toContain("Start · @ file or Plugin · / autocomplete"); expect(firstHeader).toContain( "Tip: /goal keeps multi-step work focused on a finish line", ); @@ -329,7 +329,7 @@ describe("TuiComposer", () => { { now: () => 30_000, tips: ordinaryTip ? [ordinaryTip] : [] }, ); const nextHeader = stripAnsi(nextComposer.render(120)[0] ?? ""); - expect(nextHeader).toContain("Start · @ file · / autocomplete"); + expect(nextHeader).toContain("Start · @ file or Plugin · / autocomplete"); expect(nextHeader).toContain( "Tip: Ctrl+U resumes your recent Codex session", ); @@ -459,7 +459,7 @@ describe("TuiComposer", () => { ], [ { mode: "message", surface: "welcome" }, - "Start · @ file · / autocomplete", + "Start · @ file or Plugin · / autocomplete", ], ] satisfies Array<[TuiComposerState, string]>)( "explains the %s submission mode before the user presses Enter", 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 491682b2..f486eee8 100644 --- a/packages/tui/test/unit/tui-engine-local-deltas.test.ts +++ b/packages/tui/test/unit/tui-engine-local-deltas.test.ts @@ -11,6 +11,7 @@ import { type TUI, visibleWidth, } from '../../src/tui/engine/public.js'; +import { TuiChatLayout, type TuiChatLayoutParts } from '../../src/tui/shell/chat-layout.js'; import { VirtualTerminal } from '../pi-084-upstream/virtual-terminal.js'; const passthrough = (value: string): string => value; @@ -37,8 +38,21 @@ 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[] = []; + viewportLayoutKey: string | undefined; + + getViewportLayoutKey(): string | undefined { + return this.viewportLayoutKey; + } render(): string[] { return [...this.lines]; @@ -47,11 +61,139 @@ class MutableLines implements Component { invalidate(): void {} } +function createMutableChatParts(surface: 'welcome' | 'conversation') { + const parts = { + surface: () => surface, + welcome: new MutableLines(), + notice: new MutableLines(), + transcript: new MutableLines(), + interaction: new MutableLines(), + activity: new MutableLines(), + goal: new MutableLines(), + followUp: new MutableLines(), + tasks: new MutableLines(), + composer: new MutableLines(), + status: new MutableLines(), + } satisfies TuiChatLayoutParts; + parts.composer.lines = [`composer${CURSOR_MARKER}`]; + parts.status.lines = ['status']; + return parts; +} + describe('KCode 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], + ] 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); const component = new MutableLines(); + component.viewportLayoutKey = 'stable-footer'; const answer = Array.from({ length: 80 }, (_, index) => `Answer ${index}`); component.lines = [ ...answer, @@ -87,6 +229,194 @@ describe('KCode Pi Engine local deltas', () => { expect(terminal.getViewport().slice(-2)).toEqual(['composer', 'idle']); }); + describe.each([ + ['xterm', RecordingVirtualTerminal], + ['clear-to-scrollback host', ClearToScrollbackTerminal], + ] as const)('%s transient layout restoration', (_name, Terminal) => { + const transientParts = ['welcome', 'interaction', 'composer', 'notice', 'followUp', 'goal', 'tasks', 'status'] as const; + + it.each(transientParts)('restores the document tail after repeated %s collapses', async (part) => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const parts = createMutableChatParts(part === 'notice' || part === 'welcome' ? 'welcome' : 'conversation'); + const history = Array.from({ length: 40 }, (_, index) => `History ${index}`); + (part === 'notice' || part === 'welcome' ? parts.welcome : parts.transcript).lines = history; + const layout = new TuiChatLayout(terminal, parts); + tui.addChild(layout); + tui.renderNow(); + await terminal.flush(); + const expected = terminal.getScrollBuffer(); + const baseline = [...parts[part].lines]; + + for (let cycle = 0; cycle < 3; cycle++) { + parts[part].lines = [ + ...(part === 'welcome' ? baseline : []), + ...Array.from({ length: 8 }, (_, index) => `${part} ${cycle}-${index}`), + ]; + tui.renderNow(); + await terminal.flush(); + parts[part].lines = [...baseline]; + tui.renderNow(); + await terminal.flush(); + + const logicalDocument = layout.render(terminal.columns).map((line) => line.replace(CURSOR_MARKER, '')); + expect(terminal.getViewport()).toEqual(logicalDocument.slice(-terminal.rows)); + expect(terminal.getScrollBuffer()).toEqual(expected); + for (const line of history) { + expect(terminal.getScrollBuffer().filter((row) => row.trim() === line)).toHaveLength(1); + } + } + }); + + it.each(transientParts)('erases a short %s without clearing host history', async (part) => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const parts = createMutableChatParts(part === 'notice' || part === 'welcome' ? 'welcome' : 'conversation'); + (part === 'notice' || part === 'welcome' ? parts.welcome : parts.transcript).lines = ['Short answer']; + const layout = new TuiChatLayout(terminal, parts); + tui.addChild(layout); + tui.renderNow(); + await terminal.flush(); + const expected = terminal.getScrollBuffer(); + const baseline = [...parts[part].lines]; + parts[part].lines = [ + ...(part === 'welcome' ? baseline : []), + ...Array.from({ length: 8 }, (_, index) => `${part} ${index}`), + ]; + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + parts[part].lines = baseline; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + }); + + it.each([ + ['hide', 2], + ['hide', 40], + ['setHidden', 2], + ['setHidden', 40], + ] as const)('restores %s overlays after activity settles with %i history rows', async (close, historyRows) => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const parts = createMutableChatParts('conversation'); + parts.transcript.lines = Array.from({ length: historyRows }, (_, index) => `History ${index}`); + const layout = new TuiChatLayout(terminal, parts); + tui.addChild(layout); + tui.renderNow(); + await terminal.flush(); + const expected = terminal.getScrollBuffer(); + const overlay = new MutableLines(); + overlay.lines = ['Overlay contents']; + + for (let cycle = 0; cycle < 2; cycle++) { + // Background activity changes do not alter the chat's transient layout key. + // The previous overlay frame must still prevent padding after it disappears. + parts.activity.lines = Array.from({ length: 5 }, (_, index) => `Activity ${index}`); + const handle = tui.showOverlay(overlay, { width: 24 }); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join('\n')).toContain('Overlay contents'); + terminal.takeWrites(); + if (close === 'hide') handle.hide(); + else handle.setHidden(true); + parts.activity.lines = []; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.getViewport().join('\n')).not.toContain('Overlay contents'); + if (historyRows < terminal.rows) expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + // Hidden overlays remain registered; removing one must keep the restored frame. + handle.hide(); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getScrollBuffer()).toEqual(expected); + } + }); + + it.each([false, true])('reconstructs when any root lacks a layout key (mixed roots: %s)', async (mixedRoots) => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + const history = Array.from({ length: 40 }, (_, index) => `History ${index}`); + component.lines = [...history, 'composer', 'status']; + tui.addChild(component); + if (mixedRoots) { + component.viewportLayoutKey = 'stable-footer'; + tui.addChild(new MutableLines()); + } + tui.renderNow(); + await terminal.flush(); + const expected = terminal.getScrollBuffer(); + component.lines.splice(-2, 0, ...Array.from({ length: 8 }, (_, index) => `Transient ${index}`)); + tui.renderNow(); + await terminal.flush(); + component.lines = [...history, 'composer', 'status']; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.getViewport()).toEqual(component.lines.slice(-terminal.rows)); + }); + + it('tracks layout changes even when they produce an identical frame', async () => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + const history = Array.from({ length: 40 }, (_, index) => `History ${index}`); + component.viewportLayoutKey = 'closed'; + component.lines = [...history, 'composer', 'status']; + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + const expected = terminal.getScrollBuffer(); + component.lines.splice(-2, 0, ...Array.from({ length: 8 }, (_, index) => `Transient ${index}`)); + tui.renderNow(); + await terminal.flush(); + + // A selector can replace a same-height footer without changing any rows. + component.viewportLayoutKey = 'open'; + tui.renderNow(); + await terminal.flush(); + component.viewportLayoutKey = 'closed'; + component.lines = [...history, 'composer', 'status']; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.getViewport()).toEqual(component.lines.slice(-terminal.rows)); + }); + + it('preserves a scrolled host viewport when only chat activity settles', async () => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const parts = createMutableChatParts('conversation'); + parts.transcript.lines = Array.from({ length: 40 }, (_, index) => `History ${index}`); + parts.activity.lines = ['Activity 1', 'Activity 2', 'Activity 3']; + const layout = new TuiChatLayout(terminal, parts); + tui.addChild(layout); + tui.renderNow(); + await terminal.flush(); + terminal.scrollLines(-10); + const before = terminal.getScrollPosition(); + terminal.takeWrites(); + parts.activity.lines = []; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollPosition()).toEqual(before); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(terminal.getScrollBuffer().filter((line) => line.trim().startsWith('History '))) + .toHaveLength(40); + terminal.scrollLines(1000); + expect(terminal.getViewport().slice(-2).map((line) => line.trim())).toEqual(['composer', 'status']); + }); + }); + it('ignores same-size resize notifications while the host is scrolled up', async () => { const terminal = new RecordingVirtualTerminal(60, 12); const tui = new TuiMainScreen(terminal); @@ -116,6 +446,7 @@ describe('KCode Pi Engine local deltas', () => { const terminal = new RecordingVirtualTerminal(60, 12); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); + component.viewportLayoutKey = 'stable-footer'; const answer = Array.from({ length: 80 }, (_, index) => `Answer ${index}`); component.lines = [...answer, 'activity', `composer${CURSOR_MARKER}`, 'status']; tui.addChild(component); @@ -180,6 +511,7 @@ describe('KCode Pi Engine local deltas', () => { const terminal = new RecordingVirtualTerminal(67, 44); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); + component.viewportLayoutKey = 'stable-footer'; const answer = Array.from({ length: 80 }, (_, index) => `Answer line ${index}`); component.lines = [...answer, 'activity', `composer${CURSOR_MARKER}`, 'status']; tui.addChild(component); @@ -222,6 +554,7 @@ describe('KCode Pi Engine local deltas', () => { const terminal = new RecordingVirtualTerminal(60, 44); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); + component.viewportLayoutKey = 'stable-footer'; const answer = Array.from({ length: 80 }, (_, index) => `Answer ${index}`); component.lines = [ ...answer, @@ -330,8 +663,11 @@ describe('KCode 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 +684,13 @@ describe('KCode 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', diff --git a/packages/tui/test/unit/tui-image-preview.test.ts b/packages/tui/test/unit/tui-image-preview.test.ts index e6734cf5..a18a093e 100644 --- a/packages/tui/test/unit/tui-image-preview.test.ts +++ b/packages/tui/test/unit/tui-image-preview.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; // Test-only packaging infrastructure belongs to the repository root, not a runtime package. // eslint-disable-next-line import/no-relative-packages import { packageExportEntries } from '../../../../scripts/lib/package-exports.mjs'; +// eslint-disable-next-line import/no-relative-packages -- read the source-only bundle scope import { readExtraction } from '../../../../scripts/lib/release-metadata.mjs'; // eslint-disable-next-line import/no-relative-packages -- reuse the real bundle module-location contract import { createTuiBundleModuleLocationConfig } from '../../../../scripts/lib/tui-npm-bundle-profile.mjs'; @@ -196,7 +197,7 @@ describe('preview file loader', () => { name: 'preview-test-pi-source', setup(builder) { builder.onResolve({ filter: /^@earendil-works\/pi-/ }, ({ path }) => { - const entry = entries.find((entry) => entry.specifier === path); + const entry = entries.find((candidate) => candidate.specifier === path); if (!entry) return undefined; return { path: join(root, entry.file), diff --git a/packages/tui/test/unit/tui-plugin-mentions.test.ts b/packages/tui/test/unit/tui-plugin-mentions.test.ts new file mode 100644 index 00000000..96004de9 --- /dev/null +++ b/packages/tui/test/unit/tui-plugin-mentions.test.ts @@ -0,0 +1,676 @@ +import { ConversationApplication } from '../../../local-runtime-v2/src/application/conversation/conversation-application.js'; +import { DirectSendDeliveryService } from '../../../local-runtime-v2/src/application/conversation/direct-send-delivery.js'; +import { TuiUserProjection } from '../../src/tui/controller/projection/turn-user-projection.js'; +import { TranscriptStore } from '../../src/tui/transcript/store.js'; +import { visibleWidth, stripTerminalSequences } from '../../src/tui/engine/public.js'; +import { TuiInputFlow } from '../../src/tui/controller/interaction/input-flow.js'; +import { TuiExternalEditorFlow } from '../../src/tui/controller/interaction/external-editor-flow.js'; +import { TuiSessionMutationFlow } from '../../src/tui/controller/product/session-mutation-flow.js'; +import { UserMessageTurnDeliveryService } from '../../../local-runtime-v2/src/application/conversation/user-message-turn-delivery.js'; +import { UserMessageCommitService } from '../../../local-runtime-v2/src/service/session-system/messages/user-message-commit-service.js'; +import { + normalizeDisplayMessage, + decodeDisplayMessage, +} from '../../../local-runtime-v2/src/service/session-system/messages/repo/codec.js'; +import { toSessionMessageView } from '../../../local-runtime-v2/src/application/session/content-application.js'; +import { normalizeTuiMessage } from '../../src/runtime/stream-events.js'; +import { TuiHistorySearchPanel } from '../../src/tui/features/history/search-panel.js'; +import { TuiQueueFlow } from '../../src/tui/controller/run/queue-flow.js'; +import { TuiRunProjection } from '../../src/tui/state/run-projection.js'; +import { TuiCommandFlow } from '../../src/tui/controller/product/command-flow.js'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { serializePluginMention, parsePluginMentions } from '@mavis/shared/plugin-mention'; +import { createTuiAutocomplete } from '../../src/tui/controller/run/active-run-flow.js'; +import { + Editor, + submittedEditorTransport, + submittedEditorContent, +} from '../../src/tui/widgets/editor/editor.js'; +import { TuiDraftRecovery } from '../../src/tui/features/composer/draft-recovery.js'; +import { createTuiSubmissionSnapshot } from '../../src/tui/features/composer/submission.js'; + +const identity = (value: string) => value; +const makePlugin = (name = 'notes', marketplace: 'local' | 'official' = 'local') => ({ + pluginId: `${name}@${marketplace}`, + name, + displayName: 'My Notes', + marketplace, + installed: true, + enabled: true, + capabilities: { appCount: 0, mcpServerCount: 0, skillCount: 1 }, +}); +const editors: Editor[] = []; +afterEach(() => { + for (const editor of editors.splice(0)) editor.dispose(); +}); +function createEditor() { + const editor = new Editor( + { terminal: { rows: 24 }, requestRender: vi.fn() }, + { + borderColor: identity, + selectList: { + selectedPrefix: identity, + selectedText: identity, + description: identity, + scrollInfo: identity, + noMatch: identity, + }, + }, + ); + editors.push(editor); + editor.focused = true; + editor.setAutocompleteProvider( + createTuiAutocomplete([], [], '/workspace', { + listInstalledPlugins: async () => [makePlugin()], + listWorkspaceFileTree: async () => [], + searchWorkspaceFiles: async () => [], + }), + ); + return editor; +} +async function selectPlugin(editor: Editor) { + editor.handleInput('@'); + await vi.waitFor(() => + expect(stripTerminalSequences(editor.render(80).join('\n'))).toContain('local · notes'), + ); + editor.handleInput('\t'); + expect(editor.getText()).toBe('@My Notes '); + expect(editor.captureDraft().pluginMentions).toEqual([ + { pluginId: 'notes@local', label: '@My Notes', start: 0, end: 9 }, + ]); +} + +describe('Plugin mentions from Composer to durable text', () => { + it('carries the display label through Runtime direct-send admission while preserving execution identity', async () => { + const submit = vi.fn(async () => ({ accepted: false, reason: 'invalid-input' })); + const directSend = new DirectSendDeliveryService({ + turns: { submit }, + stream: { reserve: () => ({ discardIfEmpty: vi.fn() }) }, + } as never); + const application = new ConversationApplication({ directSend } as never); + const content = '[@Codex 插件](plugin://codex%40official) hello'; + const displayContent = '@Codex 插件 hello'; + await application.sendMessage({}, { id: 's1', content, displayContent }); + expect(submit).toHaveBeenCalledWith(expect.objectContaining({ + input: expect.objectContaining({ text: content }), + delivery: expect.objectContaining({ displayContent }), + })); + }); + + it('renders legacy plugin history as plain labels without losing editable transport', () => { + const transcript = new TranscriptStore(); + const message = { + role: 'user' as const, + content: '[@Codex 插件](plugin://codex%40official) hello', + }; + new TuiUserProjection(transcript).hydrate(message, 'message-1', 'turn-1', 1); + expect(transcript.snapshot()[0]?.content).toBe('@Codex 插件 hello'); + expect(message.content).toContain('plugin://'); + }); + + it('groups plugins and files, bounds wide descriptions, and keeps all matching plugins selectable', async () => { + const provider = createTuiAutocomplete([], [], '/workspace', { + listInstalledPlugins: async () => + Array.from({ length: 10 }, (_, index) => ({ + ...makePlugin(`notes-${index}`), + description: '读取、生成、重排、填写和处理文件。'.repeat(20), + })), + listWorkspaceFileTree: async () => [ + { name: 'notes.md', path: 'notes.md', type: 'file' as const }, + ], + }); + const suggestions = await provider.getSuggestions(['@'], 0, 1, { + signal: new AbortController().signal, + }); + expect(suggestions?.items).toHaveLength(11); + expect(suggestions?.items[0]).toMatchObject({ groupLabel: ' Plugins' }); + expect(suggestions?.items[10]).toMatchObject({ groupLabel: ' Files' }); + for (const item of suggestions!.items.slice(0, 10)) { + expect(visibleWidth(item.description!)).toBeLessThanOrEqual(72); + expect(item.description).toContain('…'); + } + const editor = createEditor(); + editor.setAutocompleteProvider(provider); + editor.handleInput('@'); + await vi.waitFor(() => expect(editor.render(180).join('\n')).toContain('Plugins')); + expect(editor.render(180).join('\n')).not.toContain('local · notes'); + for (const width of [40, 80, 180]) { + for (const line of editor.render(width)) + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + for (let index = 0; index < 10; index++) editor.handleInput('\x1b[B'); + expect(editor.render(80).join('\n')).toContain('Files'); + editor.handleInput('\t'); + expect(editor.getText()).toContain('notes.md'); + }); + + it('offers enabled plugins alongside files, refreshes state, and preserves file completion', async () => { + const listInstalledPlugins = vi.fn(async () => [ + makePlugin(), + { ...makePlugin('off'), enabled: false }, + ]); + const provider = createTuiAutocomplete([], [], '/workspace', { + listInstalledPlugins, + listWorkspaceFileTree: async () => [ + { name: 'notes.md', path: 'notes.md', type: 'file' as const }, + ], + }); + const request = { signal: new AbortController().signal }; + const result = await provider.getSuggestions(['@'], 0, 1, request); + expect(result?.items).toHaveLength(2); + expect(result?.items[0]).toMatchObject({ pluginId: 'notes@local' }); + expect(result?.items[1]?.value).toContain('notes.md'); + listInstalledPlugins.mockResolvedValue([{ ...makePlugin(), enabled: false }]); + expect((await provider.getSuggestions(['@'], 0, 1, request))?.items).toHaveLength(1); + expect(provider.applyCompletion(['see @ suffix'], 0, 5, result!.items[0]!, '@').lines).toEqual([ + 'see @My Notes suffix', + ]); + }); + + it('renders a label, submits stable identity, and preserves history through multiple recalls', async () => { + const editor = createEditor(); + await selectPlugin(editor); + editor.handleInput('整理笔记'); + const submitted = vi.fn(); + editor.onSubmit = submitted; + editor.handleInput('\r'); + const [content, draft] = submitted.mock.calls[0]!; + expect(content).toBe('@My Notes 整理笔记'); + const transport = submittedEditorTransport(draft); + expect(transport).toBe('[@My Notes](plugin://notes%40local) 整理笔记'); + editor.addToHistory(content); + editor.addToHistory('another prompt'); + editor.handleInput('\x1b[A'); + expect(editor.getText()).toBe('another prompt'); + editor.handleInput('\x1b[A'); + expect(editor.getText()).toBe(content); + expect(submittedEditorTransport(editor.captureDraft())).toBe(transport); + editor.handleInput('\x1b[B'); + expect(editor.getText()).toBe('another prompt'); + expect(editor.captureDraft().pluginMentions).toEqual([]); + }); + + it('deletes a selected mention atomically and restores its exact identity with undo', async () => { + const editor = createEditor(); + await selectPlugin(editor); + editor.handleInput('\x7f'); + expect(editor.getText()).toBe(''); + expect(editor.captureDraft().pluginMentions).toEqual([]); + editor.handleInput('\x1f'); + expect(editor.getText()).toBe('@My Notes '); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local)', + ); + }); + + it('restores the working draft identity after browsing history', async () => { + const editor = createEditor(); + await selectPlugin(editor); + editor.addToHistory('older'); + editor.handleInput('\x01'); + editor.handleInput('\x1b[A'); + expect(editor.getText()).toBe('older'); + editor.handleInput('\x1b[B'); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local)', + ); + }); + + it('keeps identities distinct when history labels are identical', () => { + const editor = createEditor(); + editor.addToHistory(serializePluginMention('notes@local', 'My Notes')); + editor.addToHistory(serializePluginMention('notes@official', 'My Notes')); + editor.handleInput('\x1b[A'); + expect(editor.captureDraft().pluginMentions?.[0]?.pluginId).toBe('notes@official'); + editor.handleInput('\x1b[A'); + expect(editor.captureDraft().pluginMentions?.[0]?.pluginId).toBe('notes@local'); + }); + + it('preserves mentions with attachments, merged retries and persisted drafts', async () => { + const editor = createEditor(); + await selectPlugin(editor); + editor.syncAttachmentPlaceholders([{ id: '/image.png', label: '[Image #1]' }]); + editor.handleInput('summarize'); + const snapshot = editor.captureDraft(); + expect(submittedEditorTransport(snapshot)).toBe( + '[@My Notes](plugin://notes%40local) summarize', + ); + const submission = createTuiSubmissionSnapshot({ + submissionId: 's1', + editor: snapshot, + content: submittedEditorContent(snapshot), + resources: { attachments: [] }, + }); + const directory = await mkdtemp(join(tmpdir(), 'plugin-mention-test-')); + try { + const store = new TuiDraftRecovery({ dataDir: directory, workspaceDir: '/workspace' }); + const plain = { + ...snapshot, + text: '@My Notes summarize', + cursor: 19, + attachmentPlaceholders: [], + }; + await store.flush({ + editor: plain, + attachments: [], + retrySubmissions: [ + { + retryId: 'retry:r1', + failedReason: 'offline', + snapshot: { ...submission, editor: plain }, + }, + ], + }); + const loaded = await new TuiDraftRecovery({ + dataDir: directory, + workspaceDir: '/workspace', + }).load(); + expect(loaded?.editor.pluginMentions).toEqual(snapshot.pluginMentions); + expect(loaded?.retrySubmissions?.[0]?.snapshot.editor.pluginMentions).toEqual( + snapshot.pluginMentions, + ); + editor.setText('new draft'); + expect(editor.restoreSubmittedDraft(loaded!.editor)).toBe(true); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local) summarize\nnew draft', + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('shows human-readable history search and restores the canonical identity', () => { + const entry = serializePluginMention('notes@official', 'My Notes'); + const editor = createEditor(); + const panel = new TuiHistorySearchPanel({ + entries: [entry], + initialQuery: 'My Notes', + onSelect: (text) => editor.setText(text), + onCancel: vi.fn(), + requestRender: vi.fn(), + }); + const rendered = stripTerminalSequences(panel.render(100).join('\n')); + expect(rendered).toContain('@My Notes'); + expect(rendered).not.toContain('plugin://'); + panel.handleInput('\r'); + expect(editor.captureDraft().pluginMentions?.[0]?.pluginId).toBe('notes@official'); + }); + + it('rejects corrupt restored ranges and malformed IDs without binding another plugin', () => { + const editor = createEditor(); + expect( + editor.restoreDraft({ + schemaVersion: 1, + text: '@Notes', + cursor: 6, + pastes: [], + pasteCounter: 0, + pluginMentions: [{ pluginId: 'notes@local', label: '@Wrong', start: 0, end: 6 }], + }), + ).toBe(false); + expect(parsePluginMentions('[@Notes](plugin://%ZZ)')).toEqual([]); + }); + it('submits bound text through the real command flow and restores the binding on failure', async () => { + const editor = createEditor(); + await selectPlugin(editor); + editor.handleInput('summarize'); + const draft = editor.captureDraft(); + const content = submittedEditorContent(draft); + const submit = vi.fn( + async ( + _text: string, + options: { + onSessionResolved?: (id: string) => void; + onRuntimeAccepted?: (id: string) => void; + }, + ) => { + options.onSessionResolved?.('session-a'); + options.onRuntimeAccepted?.('session-a'); + return 'succeeded' as const; + }, + ); + const flow = new TuiCommandFlow({ + workspaceDir: '/workspace', + controller: { + snapshot: () => ({ status: 'idle', sessions: [], session: { sessionId: 'session-a' } }), + submit, + refreshSessionMetadata: async () => undefined, + } as never, + activeRunFlow: {} as never, + featureFlow: { + skillCommands: () => [], + waitForWelcomeModelSelection: async () => undefined, + applyPendingModelSelection: async () => undefined, + } as never, + feedbackFlow: {} as never, + updateFlow: {} as never, + interactionFlow: { handleCommand: async () => false, hasPending: () => false } as never, + sessionFlow: {} as never, + queueFlow: {} as never, + composerDraft: { + hasContent: () => false, + capture: () => ({ attachments: [] }), + reserveSubmission: vi.fn(), + restoreSubmission: vi.fn(), + completeSubmission: async () => undefined, + } as never, + workspaceRoots: { additionalDirectories: () => [] } as never, + runProjection: new TuiRunProjection(), + editor, + surface: {} as never, + surfaceHost: { setChatFocus: vi.fn() } as never, + queueEnabled: true, + liveRunId: () => undefined, + runtimeStopping: () => false, + abortLiveTurn: async () => false, + leaveUi: async () => undefined, + whenReady: async () => undefined, + append: vi.fn(), + setHint: vi.fn(), + onChanged: vi.fn(), + }); + const seed = flow.captureSubmissionSeed(draft); + editor.setText(''); + await expect(flow.submit(content, seed)).resolves.toBe('consumed'); + expect(submit).toHaveBeenCalledWith( + '[@My Notes](plugin://notes%40local) summarize', + expect.objectContaining({ displayContent: content }), + ); + flow.restoreRecoverableSubmission({ + submissionId: 'recovered', + sessionId: 'session-a', + editor: draft, + content, + attachments: [], + createdAtMs: 1, + transportContent: + 'context\n\n[@My Notes](plugin://notes%40local) summarize', + }); + editor.restoreDraft(draft); + editor.handleInput(' tomorrow'); + const retrySeed = flow.captureSubmissionSeed(); + await flow.submit('@My Notes summarize tomorrow', retrySeed); + expect(submit).toHaveBeenLastCalledWith( + 'context\n\n[@My Notes](plugin://notes%40local) summarize tomorrow', + expect.objectContaining({ displayContent: '@My Notes summarize tomorrow' }), + ); + editor.setText(''); + await flow.restoreFailedSeed(content, seed); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local) summarize', + ); + }); + + it.each(['', 'synthetic context\n\n'])( + 'reconstructs queued bindings and context after reload: %s', + async (context) => { + let item = { + itemId: 'q1', + sessionId: 's1', + status: 'queued', + source: 'user', + content: `${context}[@My Notes](plugin://notes%40local) summarize`, + attachments: [], + }; + const projection = new TuiRunProjection(); + const updateQueuedMessageContent = vi.fn( + async (_session: string, _item: string, content: string) => { + item = { ...item, content }; + return item; + }, + ); + let removed = false; + const queue = new TuiQueueFlow({ + runtime: { + getQueueSnapshot: async () => ({ + items: removed ? [] : [item], + paused: true, + pendingCount: removed ? 0 : 1, + }), + deleteQueuedMessage: async () => { + removed = true; + return true; + }, + updateQueuedMessageContent, + } as never, + controller: { snapshot: () => ({ session: { sessionId: 's1' } }) } as never, + composerDraft: { restoreQueuedSubmission: vi.fn(), releaseQueueItem: vi.fn() } as never, + runProjection: projection, + transcript: { get: vi.fn(), remove: vi.fn(), upsert: vi.fn() } as never, + followUp: { setQueueSummary: vi.fn(), setItems: vi.fn() } as never, + surface: {} as never, + enabled: true, + setHint: vi.fn(), + onChanged: vi.fn(), + requestRender: vi.fn(), + }); + await queue.refresh('s1'); + expect(projection.snapshot().queuedItems[0]?.content).toBe('@My Notes summarize'); + await queue.updateContent('q1', '@My Notes summarize tomorrow'); + expect(updateQueuedMessageContent).toHaveBeenCalledWith( + 's1', + 'q1', + `${context}[@My Notes](plugin://notes%40local) summarize tomorrow`, + ); + const restored = await queue.restoreLatest(); + expect(restored?.content).toBe('@My Notes summarize tomorrow'); + expect(submittedEditorTransport(restored!.editor)).toBe( + '[@My Notes](plugin://notes%40local) summarize tomorrow', + ); + }, + ); +}); + +describe('Plugin mention review regressions', () => { + it('clears and restores the actual editor binding with Ctrl+C and Ctrl+-', () => { + const editor = createEditor(); + editor.setText('[@My Notes](plugin://notes%40local) summarize'); + const flow = new TuiInputFlow({ + editor, + tui: { requestRender: vi.fn() }, + interaction: { isActive: () => false }, + liveRunId: () => undefined, + hasWaitingMessage: () => false, + featureFlow: { isFeatureScreenActive: () => false }, + composerDraft: { + abortClipboardRead: () => false, + stashForClear: vi.fn(), + hasContent: () => false, + restoreClearedDraft: vi.fn(), + }, + setHint: vi.fn(), + onChanged: vi.fn(), + workspaceDir: '/workspace', + } as never); + flow.handle('\x03'); + expect(editor.getText()).toBe(''); + expect(editor.captureDraft().pluginMentions).toEqual([]); + flow.handle('\x1f'); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local) summarize', + ); + }); + + it.each([false, true])( + 'keeps external edits and undo bound, including expanded paste: %s', + async (withPaste) => { + const editor = createEditor(); + editor.setText('[@My Notes](plugin://notes%40local) summarize'); + if (withPaste) { + const draft = editor.captureDraft(); + const prefix = '[paste #1] '; + editor.restoreDraft({ + ...draft, + text: prefix + draft.text, + cursor: prefix.length + draft.text.length, + pastes: [{ id: 1, content: 'Expanded text before plugin' }], + pasteCounter: 1, + pluginMentions: draft.pluginMentions!.map((mention) => ({ + ...mention, + start: mention.start + prefix.length, + end: mention.end + prefix.length, + })), + }); + } + const before = editor.captureDraft(); + const transport = submittedEditorTransport(before); + let rename = false; + const flow = new TuiExternalEditorFlow({ + editor, + tui: { start: vi.fn(), stop: vi.fn(), requestRender: vi.fn() }, + workspaceDir: '/workspace', + configuredCommand: 'synthetic-editor', + editDraft: async ({ draft }) => + rename ? draft.replace('@My Notes', '@Other Notes') : `${draft} tomorrow`, + isAppStopped: () => false, + append: vi.fn(), + setHint: vi.fn(), + onChanged: vi.fn(), + }); + await flow.open(); + expect(submittedEditorTransport(editor.captureDraft())).toBe(`${transport} tomorrow`); + editor.handleInput('\x1f'); + expect(editor.captureDraft()).toEqual(before); + rename = true; + await flow.open(); + expect(editor.captureDraft().pluginMentions).toEqual([]); + expect(submittedEditorTransport(editor.captureDraft())).toBeUndefined(); + }, + ); + + it('retains an unchanged plugin across multiple external edits without guessing duplicate identities', async () => { + const editor = createEditor(); + editor.setText('[@My Notes](plugin://notes%40local) summarize'); + const flow = new TuiExternalEditorFlow({ + editor, + tui: { start: vi.fn(), stop: vi.fn(), requestRender: vi.fn() }, + workspaceDir: '/workspace', + configuredCommand: 'synthetic-editor', + editDraft: async ({ draft }) => `Please ${draft} tomorrow`, + isAppStopped: () => false, + append: vi.fn(), + setHint: vi.fn(), + onChanged: vi.fn(), + }); + await flow.open(); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + 'Please [@My Notes](plugin://notes%40local) summarize tomorrow', + ); + editor.setText( + '[@My Notes](plugin://notes%40local) and [@My Notes](plugin://notes%40official)', + ); + editor.replaceTextUndoable('@My Notes and @My Notes tomorrow'); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local) and [@My Notes](plugin://notes%40official) tomorrow', + ); + editor.replaceTextUndoable('@My Notes tomorrow'); + expect(editor.captureDraft().pluginMentions).toEqual([]); + }); + + it.each(['ordinary', 'batch', 'steering'] as const)( + 'preserves %s display-message identity through storage, projection and /edit', + async (mode) => { + const canonical = + 'synthetic context\n\n[@My Notes](plugin://notes%40local) summarize'; + const display = '@My Notes summarize'; + let stored: ReturnType | undefined; + const commits = new UserMessageCommitService({ + makeMessageId: () => 'u1', + messages: { + commitUserMessage: async (input) => { + const row = normalizeDisplayMessage(input.message, { + turnId: input.turnId, + nowMs: () => 1, + }); + stored = decodeDisplayMessage({ + sessionId: input.sessionId, + messageId: row.msgId, + dataJson: row.dataJson, + source: null, + sourceContextJson: null, + } as never); + return { message: stored, created: true, firstUserMessageForSession: true }; + }, + }, + }); + const delivery = new UserMessageTurnDeliveryService({ + messages: commits, + stream: { write: vi.fn() }, + queryCollapse: { + resolveQueryKey: async () => 'q1', + start: async () => { + throw new Error('Synthetic unavailable sidecar'); + }, + }, + }); + const message = { content: canonical, attachments: [], displayContent: display }; + if (mode === 'steering') { + await delivery.consumeSteering({ + sessionId: 's1', + turnId: 't1', + message: { + producerId: 'cli', + idempotencyKey: 'fixture', + provenance: { source: 'cli' }, + message: { text: canonical, attachments: [] }, + delivery: { displayContent: display }, + }, + } as never); + } else { + await delivery.deliver({ + sessionId: 's1', + input: { text: canonical, attachments: [] }, + provenance: { source: 'cli' }, + displayContent: display, + requestedTurnId: 't1', + submit: async () => ({ accepted: true, turnId: 't1' }), + ...(mode === 'batch' + ? { + immediateSendBatch: { + members: [ + { message, messageKey: 'batch-1', createdAt: 1, provenance: { source: 'cli' } }, + ], + }, + } + : {}), + } as never); + } + expect(stored?.msg_content).toBe(display); + expect(stored?.editContent).toBe(canonical); + const reloaded = normalizeTuiMessage(toSessionMessageView(stored!)); + expect(reloaded.content).toBe(display); + expect(reloaded.editContent).toBe(canonical); + const editor = createEditor(); + const editSessionMessage = vi.fn(async () => ({})); + const flow = new TuiSessionMutationFlow({ + editor, + controller: { + snapshot: () => ({ session: { sessionId: 's1' } }), + getTerminalDurationId: () => undefined, + dismissTerminalDuration: vi.fn(), + }, + runtime: { + listSessionInputSummaries: async () => [{ userMessageId: 'u1', timestamp: 1 }], + listMessagePage: async () => ({ messages: [reloaded], hasMore: false }), + editSessionMessage, + }, + surfaceHost: { setChatFocus: vi.fn() }, + setHint: vi.fn(), + append: vi.fn(), + onChanged: vi.fn(), + hasLiveRun: () => false, + } as never); + flow.startEdit(); + await vi.waitFor(() => expect(flow.isEditing()).toBe(true)); + expect(editor.getText()).toBe(display); + editor.handleInput(' tomorrow'); + await flow.submitEdit(editor.getText(), [], editor.captureDraft()); + expect(editSessionMessage).toHaveBeenCalledWith( + expect.objectContaining({ content: `${canonical} tomorrow` }), + ); + }, + ); +}); diff --git a/packages/tui/test/unit/tui-scrollbar-interaction.test.ts b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts index 10d95b16..06c8394f 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 {} } @@ -183,7 +187,7 @@ describe("Scrollbar interaction boundaries", () => { render: (width: number) => Array.from( { length: CONTENT_LINES }, - () => "x".repeat(width - 2) + "YZ", + () => `${"x".repeat(width - 2)}YZ`, ), invalidate() {}, }; @@ -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-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' }, diff --git a/packages/tui/test/unit/tui-surface-host.test.ts b/packages/tui/test/unit/tui-surface-host.test.ts index da7e6925..4ed7414b 100644 --- a/packages/tui/test/unit/tui-surface-host.test.ts +++ b/packages/tui/test/unit/tui-surface-host.test.ts @@ -70,6 +70,30 @@ function createRecordingPresenter(): { } describe('TuiSurfaceHost', () => { + it.each([ + { mode: 'regular', fullscreenViewport: true }, + { mode: 'regular', fullscreenViewport: false }, + { mode: 'fullscreen', fullscreenViewport: true }, + ] as const)('leaves layout restoration to the renderer on interaction close ($mode, $fullscreenViewport)', ({ mode, fullscreenViewport }) => { + 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(); + expect(requestRender).toHaveBeenCalledWith(); + }); + it.each(['regular', 'fullscreen'] as const)( 'passes current terminal size to leaf features in %s', (mode) => { 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/packages/tui/test/unit/tui/controller/product/command-flow.test.ts b/packages/tui/test/unit/tui/controller/product/command-flow.test.ts index d23701ca..d3c8079d 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 @@ -1113,13 +1113,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, @@ -1141,6 +1143,7 @@ describe("TuiCommandFlow", () => { cancelAdmission: vi.fn(), enqueue: vi.fn(async () => { if (enqueueFails) throw new Error("queue unavailable"); + return queuedItemId; }), }; const flow = new TuiCommandFlow({ @@ -1171,6 +1174,7 @@ describe("TuiCommandFlow", () => { append, setHint: vi.fn(), onChanged: vi.fn(), + onMessageAdmitted, }); await expect(flow.submit("follow-up")).resolves.toBe( @@ -1189,6 +1193,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/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/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..072e540d --- /dev/null +++ b/packages/tui/test/unit/tui/features/settings/theme-picker.test.ts @@ -0,0 +1,176 @@ +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('selects light, auto, and dark with the left and right arrows', () => { + const { picker, setAppearance } = build(); + + picker.handleInput('\u001b[D'); + expect(setAppearance).toHaveBeenLastCalledWith('light'); + 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).not.toHaveBeenCalled(); + expect(preview).not.toHaveBeenCalled(); + expect(requestRender).not.toHaveBeenCalled(); + }); + + 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('\u001b[D'); + 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('\u001b[D'); + 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..d9d49852 --- /dev/null +++ b/packages/tui/test/unit/tui/theme/custom-themes.test.ts @@ -0,0 +1,332 @@ +import { watch as watchFs, writeFileSync } from 'node:fs'; +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, vi } from 'vitest'; +import { + customThemesDirectory, + loadCustomThemes, + watchCustomThemes, +} 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, + KCODE_DARK_THEME, + KCODE_LIGHT_THEME, +} from '../../../../src/tui/theme/palettes.js'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, watch: vi.fn(actual.watch) }; +}); + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + vi.useRealTimers(); + 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('reconciles edits missed during watcher startup and continues to reload later events', async () => { + const dataDir = await themesDataDir({ + 'mine.json': { name: 'mine', appearance: 'dark', colors: { brand: '#112233' } }, + }); + const file = join(customThemesDirectory(dataDir), 'mine.json'); + expect(onlyTheme(loadCustomThemes(dataDir).themes).dark.colors.brand).toBe('#112233'); + const close = vi.fn(); + vi.mocked(watchFs).mockReturnValueOnce({ on: vi.fn(), close } as never); + vi.useFakeTimers(); + const onChange = vi.fn(); + const stop = watchCustomThemes(dataDir, onChange); + try { + // Model macOS dropping the first edit before native watching is ready. + writeFileSync( + file, + JSON.stringify({ name: 'mine', appearance: 'dark', colors: { brand: '#AABBCC' } }), + ); + await vi.advanceTimersByTimeAsync(150); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onlyTheme(onChange.mock.calls[0]![0].themes).dark.colors.brand).toBe('#AABBCC'); + + writeFileSync( + file, + JSON.stringify({ name: 'mine', appearance: 'dark', colors: { brand: '#DDEEFF' } }), + ); + const fire = vi.mocked(watchFs).mock.calls.at(-1)![2]!; + fire('change', 'mine.json'); + await vi.advanceTimersByTimeAsync(150); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onlyTheme(onChange.mock.calls[1]![0].themes).dark.colors.brand).toBe('#DDEEFF'); + } finally { + stop(); + } + expect(close).toHaveBeenCalledOnce(); + }); + + it('cancels the startup reconciliation when disposed', async () => { + const dataDir = await themesDataDir({}); + const close = vi.fn(); + vi.mocked(watchFs).mockReturnValueOnce({ on: vi.fn(), close } as never); + vi.useFakeTimers(); + const onChange = vi.fn(); + const stop = watchCustomThemes(dataDir, onChange); + stop(); + await vi.advanceTimersByTimeAsync(1000); + expect(onChange).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledOnce(); + }); + + 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(KCODE_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(KCODE_LIGHT_THEME.colors.brand); + expect(theme.light.colors.text).toBe(KCODE_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..c01bf5e6 --- /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, + KCODE_DARK_THEME, + KCODE_LIGHT_THEME, +} from '../../../../src/tui/theme/palettes.js'; +import { + TUI_SYNTAX_TONE_NAMES, + TUI_THEME_COLOR_NAMES, +} from '../../../../src/tui/theme/contracts.js'; +import { + KCODE_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(KCODE_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(KCODE_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 = KCODE_THEME_CONTRAST_POLICY.backgrounds[palette.appearance]; + + for (const role of KCODE_THEME_CONTRAST_POLICY.normalText.roles) { + const exception = KCODE_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 ?? KCODE_THEME_CONTRAST_POLICY.normalText.minimum, + ); + } + for (const role of KCODE_THEME_CONTRAST_POLICY.nonText.roles) { + expect( + contrastRatio(palette.colors[role], background), + `${palette.id}.${palette.appearance}.${role} must stay distinguishable against ${background}`, + ).toBeGreaterThanOrEqual(KCODE_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 = KCODE_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..070704d3 --- /dev/null +++ b/packages/tui/test/unit/tui/theme/runtime.test.ts @@ -0,0 +1,700 @@ +import { stripVTControlCharacters } from 'node:util'; +import { describe, expect, it, vi } from 'vitest'; +import { + KCODE_DARK_THEME, + KCODE_LIGHT_THEME, +} from '../../../../src/tui/theme/palettes.js'; +import { + KCODE_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([KCODE_DARK_THEME, KCODE_LIGHT_THEME])( + 'enforces readable semantic colors for the $appearance palette', + (palette) => { + const background = KCODE_THEME_CONTRAST_POLICY.backgrounds[palette.appearance]; + + for (const role of KCODE_THEME_CONTRAST_POLICY.normalText.roles) { + const exception = KCODE_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 ?? KCODE_THEME_CONTRAST_POLICY.normalText.minimum, + ); + } + for (const role of KCODE_THEME_CONTRAST_POLICY.nonText.roles) { + expect( + contrastRatio(palette.colors[role], background), + `${palette.appearance}.${role} must remain distinguishable against ${background}`, + ).toBeGreaterThanOrEqual(KCODE_THEME_CONTRAST_POLICY.nonText.minimum); + } + }, + ); + + it('uses explicit ANSI16 semantics instead of nearest-RGB collisions', () => { + const original = getTuiThemeSnapshot(); + const originalPalette = + original.appearance === 'light' ? KCODE_LIGHT_THEME : KCODE_DARK_THEME; + + try { + applyTuiRenderTheme(KCODE_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(KCODE_DARK_THEME, 2); + expect(tuiChalk.hex(tuiColors.text)('text')).toContain('\u001B[38;5;'); + expect(tuiChalk.hex(tuiColors.signal)('signal')).toContain('\u001B[38;5;'); + + applyTuiRenderTheme(KCODE_DARK_THEME, 3); + expect(tuiChalk.hex(tuiColors.text)('text')).toContain('\u001B[38;2;'); + expect(tuiChalk.hex(tuiColors.signal)('signal')).toContain('\u001B[38;2;'); + + applyTuiRenderTheme(KCODE_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(KCODE_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' ? KCODE_LIGHT_THEME : KCODE_DARK_THEME; + + try { + applyTuiRenderTheme(KCODE_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' ? KCODE_LIGHT_THEME : KCODE_DARK_THEME; + const code = "const retries = 3; // keep streaming\nreturn 'ready';"; + + try { + applyTuiRenderTheme(KCODE_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(KCODE_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(KCODE_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' ? KCODE_LIGHT_THEME : KCODE_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(KCODE_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(KCODE_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-')); + let controller: TuiThemeController | undefined; + 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(); + 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(); + } finally { + controller?.dispose(); + 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-')); + let controller: TuiThemeController | undefined; + 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(); + controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + dataDir, + onThemesChanged, + }); + controller.setTheme('mine'); + const onChange = vi.fn(); + controller.onChange(onChange); + // Let the constructor load and startup reconciliation finish before the + // no-op edit, so this assertion still requires a native watcher event. + await vi.waitFor(() => expect(onThemesChanged.mock.calls.length).toBeGreaterThanOrEqual(2), { + timeout: 4000, + }); + 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(); + } finally { + controller?.dispose(); + 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/packages/tui/test/unit/update-release.test.ts b/packages/tui/test/unit/update-release.test.ts index 626c5ed9..ccbe0036 100644 --- a/packages/tui/test/unit/update-release.test.ts +++ b/packages/tui/test/unit/update-release.test.ts @@ -48,7 +48,7 @@ function asset(name: string, size: number, tag = 'v0.0.0') { size, url: `https://api.github.com/repos/tournierjc/kinetick-code/releases/assets/${name}`, browser_download_url: - `https://github.com/tournierjc/kinetick-code/releases/download/${tag}/` + name, + `https://github.com/tournierjc/kinetick-code/releases/download/${tag}/${name}`, }; } @@ -244,7 +244,7 @@ describe('KCode release channel', () => { artifact: { name, url: 'https://api.github.com/repos/tournierjc/kinetick-code/releases/assets/archive', - downloadUrl: 'https://github.com/tournierjc/kinetick-code/releases/download/v0.5.3/' + name, + downloadUrl: `https://github.com/tournierjc/kinetick-code/releases/download/v0.5.3/${name}`, size: ARCHIVE_BYTES.length, checksumUrl: 'https://api.github.com/repos/tournierjc/kinetick-code/releases/assets/checksum', }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8014dab7..549de119 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,12 +23,33 @@ importers: '@types/node': specifier: ^20.19.0 version: 20.19.43 + '@typescript-eslint/eslint-plugin': + specifier: 7.18.0 + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: 7.18.0 + version: 7.18.0(eslint@8.57.1)(typescript@5.9.3) '@typescript/native-preview': specifier: 7.0.0-dev.20260707.2 version: 7.0.0-dev.20260707.2 esbuild: specifier: 0.28.2 version: 0.28.2 + eslint: + specifier: 8.57.1 + version: 8.57.1 + eslint-config-airbnb-base: + specifier: 15.0.0 + version: 15.0.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-config-prettier: + specifier: 10.1.8 + version: 10.1.8(eslint@8.57.1) + eslint-plugin-import: + specifier: 2.32.0 + version: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1) + eslint-plugin-prettier: + specifier: 5.5.5 + version: 5.5.5(eslint-config-prettier@10.1.8(eslint@8.57.1))(eslint@8.57.1)(prettier@3.9.6) prettier: specifier: 3.9.6 version: 3.9.6 @@ -338,6 +359,9 @@ importers: '@mavis/config': specifier: workspace:^ version: link:../../config + '@mavis/shared': + specifier: workspace:^ + version: link:../../shared '@types/node': specifier: ^20 version: 20.19.43 @@ -1378,6 +1402,24 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -1393,6 +1435,19 @@ packages: peerDependencies: hono: 4.13.5 + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -1748,6 +1803,10 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgr/core@0.3.6': + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} + '@pondwader/socks5-server@1.0.10': resolution: {integrity: sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==} @@ -1903,6 +1962,9 @@ packages: cpu: [x64] os: [win32] + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@silvia-odwyer/photon-node@0.3.4': resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} @@ -1976,6 +2038,9 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/lazystream@1.0.0': resolution: {integrity: sha512-FMcHyqpFP/OiKgYXBgMHKpiVi321PoKF2s1mSnvCLiDpJlNYC5cdcB78bwObjga2YBOeIEJ7DKx3blwZLTzQlw==} @@ -2012,6 +2077,64 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@7.18.0': + resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + '@typescript-eslint/parser': ^7.0.0 + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@7.18.0': + resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@7.18.0': + resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/type-utils@7.18.0': + resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@7.18.0': + resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/typescript-estree@7.18.0': + resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@7.18.0': + resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + + '@typescript-eslint/visitor-keys@7.18.0': + resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} + engines: {node: ^18.18.0 || >=20.0.0} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': resolution: {integrity: sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg==} engines: {node: '>=16.20.0'} @@ -2059,6 +2182,9 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@vitest/coverage-v8@4.1.11': resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: @@ -2208,6 +2334,9 @@ packages: ajv: optional: true + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -2225,6 +2354,34 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.2.0: + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==} + engines: {node: '>= 0.4'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -2232,6 +2389,10 @@ packages: ast-v8-to-istanbul@1.0.6: resolution: {integrity: sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==} + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2239,9 +2400,16 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + axios@1.20.0: resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -2272,6 +2440,12 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} + + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} @@ -2297,10 +2471,18 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + canvas@3.2.3: resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==} engines: {node: ^18.12.0 || >= 20.9.0} @@ -2354,6 +2536,12 @@ packages: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confusing-browser-globals@1.0.11: + resolution: {integrity: sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -2400,9 +2588,29 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2420,6 +2628,17 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2441,6 +2660,18 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + drizzle-orm@0.45.2: resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} peerDependencies: @@ -2557,6 +2788,14 @@ packages: resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==} engines: {node: '>=10.13.0'} + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -2576,6 +2815,14 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} + engines: {node: '>= 0.4'} + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -2588,9 +2835,108 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-airbnb-base@15.0.0: + resolution: {integrity: sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==} + engines: {node: ^10.12.0 || >=12.0.0} + peerDependencies: + eslint: ^7.32.0 || ^8.2.0 + eslint-plugin-import: ^2.25.2 + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-module-utils@2.14.0: + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-prettier@5.5.5: + resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -2642,10 +2988,19 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} @@ -2671,6 +3026,10 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -2682,6 +3041,17 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + follow-redirects@1.16.0: resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} @@ -2691,6 +3061,10 @@ packages: debug: optional: true + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -2715,6 +3089,9 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2723,6 +3100,13 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + gaxios@7.3.1: resolution: {integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==} engines: {node: '>=18'} @@ -2731,6 +3115,10 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -2751,6 +3139,10 @@ packages: resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} engines: {node: '>=6'} + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + get-tsconfig@4.14.3: resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} @@ -2764,14 +3156,34 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + google-auth-library@10.9.1: resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} engines: {node: '>=18'} @@ -2790,15 +3202,29 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + handlebars@4.7.9: resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} hasBin: true + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2851,6 +3277,10 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + ignore@7.0.5: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} @@ -2862,6 +3292,18 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2872,6 +3314,10 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + interpret@1.4.0: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} @@ -2888,18 +3334,58 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + is-core-module@2.17.0: resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} engines: {node: '>= 0.4'} + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2908,10 +3394,26 @@ packages: resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} engines: {node: '>=18'} + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + is-path-inside@4.0.0: resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} engines: {node: '>=12'} @@ -2919,13 +3421,52 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + is-stream@1.1.0: resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} engines: {node: '>=0.10.0'} + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3010,16 +3551,29 @@ packages: json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-to-ts@3.1.1: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -3034,6 +3588,9 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -3047,9 +3604,17 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash.identity@3.0.0: resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==} @@ -3125,6 +3690,13 @@ packages: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -3153,6 +3725,9 @@ packages: napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.1.0: resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} engines: {node: '>=18'} @@ -3175,6 +3750,10 @@ packages: engines: {node: '>=10.5.0'} deprecated: Use your platform's native DOMException instead + node-exports-info@1.6.2: + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} + engines: {node: '>= 0.4'} + node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3198,13 +3777,37 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} - obug@2.2.1: - resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} - engines: {node: '>=12.20.0'} + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} - on-exit-leak-free@2.1.2: - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} - engines: {node: '>=14.0.0'} + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} @@ -3225,6 +3828,14 @@ packages: zod: optional: true + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.2: + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} + engines: {node: '>= 0.4'} + oxc-parser@0.148.0: resolution: {integrity: sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3236,6 +3847,14 @@ packages: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-retry@4.6.2: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} @@ -3246,6 +3865,10 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -3262,6 +3885,14 @@ packages: partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@2.0.1: resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} engines: {node: '>=4'} @@ -3280,6 +3911,10 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3319,6 +3954,10 @@ packages: resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} engines: {node: '>=12.13.0'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -3329,6 +3968,14 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + engines: {node: '>=6.0.0'} + prettier@3.9.6: resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} @@ -3366,6 +4013,10 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qs@6.16.0: resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} @@ -3414,10 +4065,18 @@ packages: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + regexp-tree@0.1.27: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} hasBin: true + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3426,6 +4085,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -3434,6 +4097,11 @@ packages: engines: {node: '>= 0.4'} hasBin: true + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} + hasBin: true + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -3446,6 +4114,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rollup@4.63.1: resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -3458,12 +4131,24 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + safe-regex@2.1.1: resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} @@ -3485,6 +4170,10 @@ packages: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -3498,6 +4187,18 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} @@ -3565,6 +4266,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + smol-toml@1.7.1: resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} engines: {node: '>= 18'} @@ -3598,10 +4303,26 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -3640,6 +4361,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} + engines: {node: ^14.18.0 || >=16.0.0} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -3655,6 +4380,9 @@ packages: resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -3692,10 +4420,19 @@ packages: ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + tsconfig-paths-webpack-plugin@4.2.0: resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} engines: {node: '>=10.13.0'} + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tsconfig-paths@4.2.0: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} @@ -3711,6 +4448,14 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -3718,6 +4463,22 @@ packages: typebox@1.1.38: resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.5: + resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3732,6 +4493,10 @@ packages: resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==} engines: {node: '>=14'} + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -3755,6 +4520,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3856,6 +4624,22 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.24: + resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==} + engines: {node: '>= 0.4'} + which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -3870,6 +4654,10 @@ packages: engines: {node: '>=8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -3916,6 +4704,10 @@ packages: resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} engines: {node: '>=10'} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -4264,6 +5056,29 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.30.0(zod@3.25.76))': dependencies: google-auth-library: 10.9.1 @@ -4281,6 +5096,18 @@ snapshots: dependencies: hono: 4.13.5 + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -4558,6 +5385,8 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@pkgr/core@0.3.6': {} + '@pondwader/socks5-server@1.0.10': {} '@protobufjs/aspromise@1.1.2': {} @@ -4655,6 +5484,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.63.1': optional: true + '@rtsao/scc@1.1.0': {} + '@silvia-odwyer/photon-node@0.3.4': {} '@sinclair/typebox@0.34.52': {} @@ -4738,6 +5569,8 @@ snapshots: '@types/js-yaml@4.0.9': {} + '@types/json5@0.0.29': {} + '@types/lazystream@1.0.0': dependencies: '@types/node': 20.19.43 @@ -4779,6 +5612,87 @@ snapshots: dependencies: '@types/node': 20.19.43 + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 7.18.0 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.3 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + + '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@7.18.0': {} + + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.3 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.9 + semver: 7.8.5 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + eslint: 8.57.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + eslint-visitor-keys: 3.4.3 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': optional: true @@ -4810,6 +5724,8 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260707.2 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260707.2 + '@ungap/structured-clone@1.4.0': {} + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -4964,6 +5880,13 @@ snapshots: optionalDependencies: ajv: 8.18.0 + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -4981,6 +5904,58 @@ snapshots: argparse@2.0.1: {} + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array-union@2.1.0: {} + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.6: @@ -4989,11 +5964,17 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + async-function@1.0.0: {} + asynckit@0.4.0: optional: true atomic-sleep@1.0.0: {} + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + axios@1.20.0: dependencies: follow-redirects: 1.16.0 @@ -5005,6 +5986,8 @@ snapshots: - supports-color optional: true + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} base64-js@1.5.1: {} @@ -5044,6 +6027,15 @@ snapshots: bowser@2.14.1: {} + brace-expansion@1.1.21: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.7: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -5071,11 +6063,20 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + callsites@3.1.0: {} + canvas@3.2.3: dependencies: node-addon-api: 7.1.1 @@ -5126,6 +6127,10 @@ snapshots: commander@15.0.0: {} + concat-map@0.0.1: {} + + confusing-browser-globals@1.0.11: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -5163,8 +6168,30 @@ snapshots: data-uri-to-buffer@4.0.1: {} + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + dateformat@4.6.3: {} + debug@3.2.7: + dependencies: + ms: 2.1.3 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -5175,6 +6202,20 @@ snapshots: deep-extend@0.6.0: {} + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + delayed-stream@1.0.0: optional: true @@ -5205,6 +6246,18 @@ snapshots: diff@8.0.4: {} + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + drizzle-orm@0.45.2(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1): optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -5235,6 +6288,70 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.4 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.2 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.5 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.24 + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -5251,7 +6368,19 @@ snapshots: get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 hasown: 2.0.4 - optional: true + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.4 + + es-to-primitive@1.3.4: + dependencies: + es-abstract-get: 1.0.0 + es-define-property: 1.0.1 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 esbuild@0.28.2: optionalDependencies: @@ -5286,10 +6415,149 @@ snapshots: escape-html@1.0.3: {} - estree-walker@3.0.3: + escape-string-regexp@4.0.0: {} + + eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): + dependencies: + confusing-browser-globals: 1.0.11 + eslint: 8.57.1 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1) + object.assign: 4.1.7 + object.entries: 1.1.9 + semver: 6.3.1 + + eslint-config-prettier@10.1.8(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-import-resolver-node@0.3.10: + dependencies: + debug: 3.2.7 + is-core-module: 2.17.0 + resolve: 2.0.0-next.7 + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.14.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@8.57.1): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.10 + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.2.0 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@8.57.1) + hasown: 2.0.4 + is-core-module: 2.17.0 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.10 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@8.57.1))(eslint@8.57.1)(prettier@3.9.6): + dependencies: + eslint: 8.57.1 + prettier: 3.9.6 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.13 + optionalDependencies: + eslint-config-prettier: 10.1.8(eslint@8.57.1) + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.4.0 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.3.2 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + esutils@2.0.3: {} + etag@1.8.1: {} event-target-shim@5.0.1: {} @@ -5363,6 +6631,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-diff@1.3.0: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5371,6 +6641,10 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} fast-uri@3.1.6: {} @@ -5392,6 +6666,10 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + file-uri-to-path@1.0.0: {} fill-range@7.1.1: @@ -5409,9 +6687,26 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.4.4: {} + follow-redirects@1.16.0: optional: true + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -5436,11 +6731,27 @@ snapshots: fs-constants@1.0.0: {} + fs.realpath@1.0.0: {} + fsevents@2.3.3: optional: true function-bind@1.1.2: {} + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + gaxios@7.3.1: dependencies: extend: 3.0.2 @@ -5457,6 +6768,8 @@ snapshots: transitivePeerDependencies: - supports-color + generator-function@2.0.1: {} + get-caller-file@2.0.5: {} get-east-asian-width@1.6.0: {} @@ -5483,6 +6796,12 @@ snapshots: dependencies: pump: 3.0.4 + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + get-tsconfig@4.14.3: dependencies: resolve-pkg-maps: 1.0.0 @@ -5495,16 +6814,47 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + glob@13.0.6: dependencies: minimatch: 10.2.6 minipass: 7.1.3 path-scurry: 2.0.2 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + global-directory@4.0.1: dependencies: ini: 4.1.1 + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + google-auth-library@10.9.1: dependencies: base64-js: 1.5.1 @@ -5524,6 +6874,8 @@ snapshots: graceful-fs@4.2.11: {} + graphemer@1.4.0: {} + handlebars@4.7.9: dependencies: minimist: 1.2.8 @@ -5533,14 +6885,23 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + has-bigints@1.1.0: {} + has-flag@4.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + has-symbols@1.1.0: {} has-tostringtag@1.0.2: dependencies: has-symbols: 1.1.0 - optional: true hasown@2.0.4: dependencies: @@ -5594,18 +6955,38 @@ snapshots: ieee754@1.2.1: {} + ignore@5.3.2: {} + ignore@7.0.5: {} ignore@7.0.9: {} immediate@3.0.6: {} + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + inherits@2.0.4: {} ini@1.3.8: {} ini@4.1.1: {} + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + interpret@1.4.0: {} interpret@3.1.1: {} @@ -5614,14 +6995,66 @@ snapshots: ipaddr.js@1.9.1: {} + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + is-core-module@2.17.0: dependencies: hasown: 2.0.4 + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + is-extglob@2.1.1: {} + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -5631,16 +7064,68 @@ snapshots: global-directory: 4.0.1 is-path-inside: 4.0.0 + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-number@7.0.0: {} + is-path-inside@3.0.3: {} + is-path-inside@4.0.0: {} is-promise@4.0.0: {} + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + is-stream@1.1.0: {} + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.24 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + isarray@1.0.0: {} + isarray@2.0.5: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -5709,15 +7194,25 @@ snapshots: dependencies: bignumber.js: 9.3.1 + json-buffer@3.0.1: {} + json-schema-to-ts@3.1.1: dependencies: '@babel/runtime': 7.29.7 ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + json5@2.2.3: {} jszip@3.10.2: @@ -5738,6 +7233,10 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + kleur@3.0.3: {} knip@6.35.1: @@ -5760,15 +7259,23 @@ snapshots: dependencies: readable-stream: 2.3.8 + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lie@3.3.0: dependencies: immediate: 3.0.6 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash.identity@3.0.0: optional: true - lodash.merge@4.6.2: - optional: true + lodash.merge@4.6.2: {} lodash.pickby@4.6.0: optional: true @@ -5826,6 +7333,14 @@ snapshots: dependencies: brace-expansion: 5.0.9 + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.21 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.7 + minimist@1.2.8: {} minipass@7.1.3: {} @@ -5848,6 +7363,8 @@ snapshots: napi-build-utils@2.0.0: {} + natural-compare@1.4.0: {} + negotiator@1.1.0: dependencies: content-type: 2.1.0 @@ -5864,6 +7381,13 @@ snapshots: node-domexception@1.0.0: {} + node-exports-info@1.6.2: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + node-fetch@3.3.2: dependencies: data-uri-to-buffer: 4.0.1 @@ -5884,6 +7408,44 @@ snapshots: object-inspect@1.13.4: {} + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + obug@2.2.1: {} on-exit-leak-free@2.1.2: {} @@ -5901,6 +7463,22 @@ snapshots: ws: 8.21.3 zod: 3.25.76 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + oxc-parser@0.148.0: dependencies: '@oxc-project/types': 0.148.0 @@ -5949,6 +7527,14 @@ snapshots: p-finally@1.0.0: {} + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-retry@4.6.2: dependencies: '@types/retry': 0.12.0 @@ -5958,6 +7544,10 @@ snapshots: pako@1.0.11: {} + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -5970,6 +7560,10 @@ snapshots: partial-json@0.1.7: {} + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + path-key@2.0.1: {} path-key@3.1.1: {} @@ -5983,6 +7577,8 @@ snapshots: path-to-regexp@8.4.2: {} + path-type@4.0.0: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -6036,6 +7632,8 @@ snapshots: pngjs@6.0.0: {} + possible-typed-array-names@1.1.0: {} + postcss@8.5.28: dependencies: nanoid: 3.3.19 @@ -6057,6 +7655,12 @@ snapshots: tar-fs: 2.1.5 tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + prettier@3.9.6: {} process-nextick-args@2.0.1: {} @@ -6103,6 +7707,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + qs@6.16.0: dependencies: es-define-property: 1.0.1 @@ -6164,12 +7770,34 @@ snapshots: dependencies: resolve: 1.22.12 + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + regexp-tree@0.1.27: {} + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + require-directory@2.1.1: {} require-from-string@2.0.2: {} + resolve-from@4.0.0: {} + resolve-pkg-maps@1.0.0: {} resolve@1.22.12: @@ -6179,12 +7807,25 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + resolve@2.0.0-next.7: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.17.0 + node-exports-info: 1.6.2 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + retry@0.12.0: {} retry@0.13.1: {} reusify@1.1.0: {} + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + rollup@4.63.1: dependencies: '@types/estree': 1.0.9 @@ -6231,10 +7872,29 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + safe-regex@2.1.1: dependencies: regexp-tree: 0.1.27 @@ -6251,6 +7911,8 @@ snapshots: semver@5.7.2: {} + semver@6.3.1: {} + semver@7.8.5: {} send@1.2.1: @@ -6278,6 +7940,28 @@ snapshots: transitivePeerDependencies: - supports-color + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + setimmediate@1.0.5: {} setprototypeof@1.2.0: {} @@ -6350,6 +8034,8 @@ snapshots: sisteransi@1.0.5: {} + slash@3.0.0: {} + smol-toml@1.7.1: {} smol-toml@1.8.0: {} @@ -6370,12 +8056,41 @@ snapshots: std-env@4.2.0: {} + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -6404,6 +8119,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + synckit@0.11.13: + dependencies: + '@pkgr/core': 0.3.6 + tapable@2.3.3: {} tar-fs@2.1.5: @@ -6429,6 +8148,8 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + text-table@0.2.0: {} + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -6460,6 +8181,10 @@ snapshots: ts-algebra@2.0.0: {} + ts-api-utils@1.4.3(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 @@ -6467,6 +8192,13 @@ snapshots: tapable: 2.3.3 tsconfig-paths: 4.2.0 + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + tsconfig-paths@4.2.0: dependencies: json5: 2.2.3 @@ -6485,6 +8217,12 @@ snapshots: dependencies: safe-buffer: 5.2.1 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + type-is@2.1.0: dependencies: content-type: 2.1.0 @@ -6493,6 +8231,38 @@ snapshots: typebox@1.1.38: {} + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.5: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + typescript@5.9.3: {} uglify-js@3.19.3: @@ -6500,6 +8270,13 @@ snapshots: unbash@4.0.11: {} + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -6510,6 +8287,10 @@ snapshots: unpipe@1.0.0: {} + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + util-deprecate@1.0.2: {} vary@1.1.2: {} @@ -6608,6 +8389,47 @@ snapshots: web-streams-polyfill@3.3.3: {} + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.24 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.24: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@1.3.1: dependencies: isexe: 2.0.0 @@ -6621,6 +8443,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + word-wrap@1.2.5: {} + wordwrap@1.0.0: {} wrap-ansi@7.0.0: @@ -6653,6 +8477,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 20.2.9 + yocto-queue@0.1.0: {} + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/release/dependency-licenses.json b/release/dependency-licenses.json index 854c5200..95f6e0f9 100644 --- a/release/dependency-licenses.json +++ b/release/dependency-licenses.json @@ -266,6 +266,38 @@ "license": "MIT", "homepage": "https://github.com/evanw/esbuild#readme" }, + { + "name": "@eslint-community/eslint-utils", + "versions": [ + "4.10.1" + ], + "license": "MIT", + "homepage": "https://github.com/eslint-community/eslint-utils#readme" + }, + { + "name": "@eslint-community/regexpp", + "versions": [ + "4.12.2" + ], + "license": "MIT", + "homepage": "https://github.com/eslint-community/regexpp#readme" + }, + { + "name": "@eslint/eslintrc", + "versions": [ + "2.1.4" + ], + "license": "MIT", + "homepage": "https://github.com/eslint/eslintrc#readme" + }, + { + "name": "@eslint/js", + "versions": [ + "8.57.1" + ], + "license": "MIT", + "homepage": "https://eslint.org" + }, { "name": "@google/genai", "versions": [ @@ -282,6 +314,30 @@ "license": "MIT", "homepage": "https://github.com/honojs/node-server" }, + { + "name": "@humanwhocodes/config-array", + "versions": [ + "0.13.0" + ], + "license": "Apache-2.0", + "homepage": "https://github.com/humanwhocodes/config-array#readme" + }, + { + "name": "@humanwhocodes/module-importer", + "versions": [ + "1.0.1" + ], + "license": "Apache-2.0", + "homepage": "https://github.com/humanwhocodes/module-importer#readme" + }, + { + "name": "@humanwhocodes/object-schema", + "versions": [ + "2.0.3" + ], + "license": "BSD-3-Clause", + "homepage": "https://github.com/humanwhocodes/object-schema#readme" + }, { "name": "@isaacs/fs-minipass", "versions": [ @@ -434,6 +490,14 @@ "license": "MIT", "homepage": "https://github.com/pinojs/redact#readme" }, + { + "name": "@pkgr/core", + "versions": [ + "0.3.6" + ], + "license": "MIT", + "homepage": "https://github.com/un-ts/pkgr/blob/master/packages/core" + }, { "name": "@pondwader/socks5-server", "versions": [ @@ -522,6 +586,14 @@ "license": "MIT", "homepage": "https://rollupjs.org/" }, + { + "name": "@rtsao/scc", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/rtsao/scc#readme" + }, { "name": "@silvia-odwyer/photon-node", "versions": [ @@ -675,6 +747,14 @@ "license": "MIT", "homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/js-yaml" }, + { + "name": "@types/json5", + "versions": [ + "0.0.29" + ], + "license": "MIT", + "homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped#readme" + }, { "name": "@types/lazystream", "versions": [ @@ -757,6 +837,70 @@ "license": "MIT", "homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/ws" }, + { + "name": "@typescript-eslint/eslint-plugin", + "versions": [ + "7.18.0" + ], + "license": "MIT", + "homepage": "https://typescript-eslint.io/packages/eslint-plugin" + }, + { + "name": "@typescript-eslint/parser", + "versions": [ + "7.18.0" + ], + "license": "BSD-2-Clause", + "homepage": "https://typescript-eslint.io/packages/parser" + }, + { + "name": "@typescript-eslint/scope-manager", + "versions": [ + "7.18.0" + ], + "license": "MIT", + "homepage": "https://typescript-eslint.io/packages/scope-manager" + }, + { + "name": "@typescript-eslint/type-utils", + "versions": [ + "7.18.0" + ], + "license": "MIT", + "homepage": "https://typescript-eslint.io" + }, + { + "name": "@typescript-eslint/types", + "versions": [ + "7.18.0" + ], + "license": "MIT", + "homepage": "https://typescript-eslint.io" + }, + { + "name": "@typescript-eslint/typescript-estree", + "versions": [ + "7.18.0" + ], + "license": "BSD-2-Clause", + "homepage": "https://typescript-eslint.io/packages/typescript-estree" + }, + { + "name": "@typescript-eslint/utils", + "versions": [ + "7.18.0" + ], + "license": "MIT", + "homepage": "https://typescript-eslint.io/packages/utils" + }, + { + "name": "@typescript-eslint/visitor-keys", + "versions": [ + "7.18.0" + ], + "license": "MIT", + "homepage": "https://typescript-eslint.io" + }, { "name": "@typescript/native-preview", "versions": [ @@ -773,6 +917,14 @@ "license": "Apache-2.0", "homepage": "https://www.typescriptlang.org/" }, + { + "name": "@ungap/structured-clone", + "versions": [ + "1.4.0" + ], + "license": "ISC", + "homepage": "https://github.com/ungap/structured-clone#readme" + }, { "name": "@vitest/coverage-v8", "versions": [ @@ -929,6 +1081,7 @@ { "name": "ajv", "versions": [ + "6.15.0", "8.18.0" ], "license": "MIT", @@ -974,6 +1127,62 @@ "license": "Python-2.0", "homepage": "https://github.com/nodeca/argparse#readme" }, + { + "name": "array-buffer-byte-length", + "versions": [ + "1.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/array-buffer-byte-length#readme" + }, + { + "name": "array-includes", + "versions": [ + "3.2.0" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/array-includes#readme" + }, + { + "name": "array-union", + "versions": [ + "2.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/array-union#readme" + }, + { + "name": "array.prototype.findlastindex", + "versions": [ + "1.2.6" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Array.prototype.findLastIndex#readme" + }, + { + "name": "array.prototype.flat", + "versions": [ + "1.3.3" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Array.prototype.flat#readme" + }, + { + "name": "array.prototype.flatmap", + "versions": [ + "1.3.3" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Array.prototype.flatMap#readme" + }, + { + "name": "arraybuffer.prototype.slice", + "versions": [ + "1.0.4" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/ArrayBuffer.prototype.slice#readme" + }, { "name": "assertion-error", "versions": [ @@ -990,6 +1199,14 @@ "license": "MIT", "homepage": "https://github.com/AriPerkkio/ast-v8-to-istanbul" }, + { + "name": "async-function", + "versions": [ + "1.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/async-function#readme" + }, { "name": "asynckit", "versions": [ @@ -1006,6 +1223,14 @@ "license": "MIT", "homepage": "https://github.com/davidmarkclements/atomic-sleep#readme" }, + { + "name": "available-typed-arrays", + "versions": [ + "1.0.7" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/available-typed-arrays#readme" + }, { "name": "axios", "versions": [ @@ -1017,6 +1242,7 @@ { "name": "balanced-match", "versions": [ + "1.0.2", "4.0.4" ], "license": "MIT", @@ -1089,6 +1315,8 @@ { "name": "brace-expansion", "versions": [ + "1.1.21", + "2.1.7", "5.0.9" ], "license": "MIT", @@ -1127,6 +1355,14 @@ "license": "MIT", "homepage": "https://github.com/visionmedia/bytes.js#readme" }, + { + "name": "call-bind", + "versions": [ + "1.0.9" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/call-bind#readme" + }, { "name": "call-bind-apply-helpers", "versions": [ @@ -1143,6 +1379,14 @@ "license": "MIT", "homepage": "https://github.com/ljharb/call-bound#readme" }, + { + "name": "callsites", + "versions": [ + "3.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/callsites#readme" + }, { "name": "canvas", "versions": [ @@ -1241,6 +1485,22 @@ "license": "MIT", "homepage": "https://github.com/tj/commander.js#readme" }, + { + "name": "concat-map", + "versions": [ + "0.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/substack/node-concat-map#readme" + }, + { + "name": "confusing-browser-globals", + "versions": [ + "1.0.11" + ], + "license": "MIT", + "homepage": "https://github.com/facebook/create-react-app#readme" + }, { "name": "content-disposition", "versions": [ @@ -1323,6 +1583,30 @@ "license": "MIT", "homepage": "https://github.com/TooTallNate/node-data-uri-to-buffer" }, + { + "name": "data-view-buffer", + "versions": [ + "1.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/data-view-buffer#readme" + }, + { + "name": "data-view-byte-length", + "versions": [ + "1.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/data-view-byte-length#readme" + }, + { + "name": "data-view-byte-offset", + "versions": [ + "1.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/data-view-byte-offset#readme" + }, { "name": "dateformat", "versions": [ @@ -1334,6 +1618,7 @@ { "name": "debug", "versions": [ + "3.2.7", "4.4.3" ], "license": "MIT", @@ -1355,6 +1640,30 @@ "license": "MIT", "homepage": "https://github.com/unclechu/node-deep-extend" }, + { + "name": "deep-is", + "versions": [ + "0.1.4" + ], + "license": "MIT", + "homepage": "https://github.com/thlorenz/deep-is#readme" + }, + { + "name": "define-data-property", + "versions": [ + "1.1.4" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/define-data-property#readme" + }, + { + "name": "define-properties", + "versions": [ + "1.2.1" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/define-properties#readme" + }, { "name": "delayed-stream", "versions": [ @@ -1374,7 +1683,7 @@ { "name": "dependency-cruiser", "versions": [ - "18.2.0" + "18.3.1" ], "license": "MIT", "homepage": "https://github.com/sverweij/dependency-cruiser" @@ -1395,6 +1704,23 @@ "license": "BSD-3-Clause", "homepage": "https://github.com/kpdecker/jsdiff#readme" }, + { + "name": "dir-glob", + "versions": [ + "3.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/kevva/dir-glob#readme" + }, + { + "name": "doctrine", + "versions": [ + "2.1.0", + "3.0.0" + ], + "license": "Apache-2.0", + "homepage": "https://github.com/eslint/doctrine" + }, { "name": "drizzle-orm", "versions": [ @@ -1454,11 +1780,27 @@ { "name": "enhanced-resolve", "versions": [ - "5.24.5" + "5.25.1" ], "license": "MIT", "homepage": "https://github.com/webpack/enhanced-resolve#readme" }, + { + "name": "es-abstract", + "versions": [ + "1.24.2" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/es-abstract#readme" + }, + { + "name": "es-abstract-get", + "versions": [ + "1.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/es-abstract-get#readme" + }, { "name": "es-define-property", "versions": [ @@ -1499,6 +1841,22 @@ "license": "MIT", "homepage": "https://github.com/es-shims/es-set-tostringtag#readme" }, + { + "name": "es-shim-unscopables", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/es-shim-unscopables#readme" + }, + { + "name": "es-to-primitive", + "versions": [ + "1.3.4" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/es-to-primitive#readme" + }, { "name": "esbuild", "versions": [ @@ -1524,31 +1882,151 @@ "homepage": "https://github.com/component/escape-html#readme" }, { - "name": "estree-walker", + "name": "escape-string-regexp", "versions": [ - "3.0.3" + "4.0.0" ], "license": "MIT", - "homepage": "https://github.com/Rich-Harris/estree-walker#readme" + "homepage": "https://github.com/sindresorhus/escape-string-regexp#readme" }, { - "name": "etag", + "name": "eslint", "versions": [ - "1.8.1" + "8.57.1" ], "license": "MIT", - "homepage": "https://github.com/jshttp/etag#readme" + "homepage": "https://eslint.org" }, { - "name": "event-target-shim", + "name": "eslint-config-airbnb-base", "versions": [ - "5.0.1" + "15.0.0" ], "license": "MIT", - "homepage": "https://github.com/mysticatea/event-target-shim" + "homepage": "https://github.com/airbnb/javascript" }, { - "name": "events", + "name": "eslint-config-prettier", + "versions": [ + "10.1.8" + ], + "license": "MIT", + "homepage": "https://github.com/prettier/eslint-config-prettier#readme" + }, + { + "name": "eslint-import-resolver-node", + "versions": [ + "0.3.10" + ], + "license": "MIT", + "homepage": "https://github.com/import-js/eslint-plugin-import" + }, + { + "name": "eslint-module-utils", + "versions": [ + "2.14.0" + ], + "license": "MIT", + "homepage": "https://github.com/import-js/eslint-plugin-import#readme" + }, + { + "name": "eslint-plugin-import", + "versions": [ + "2.32.0" + ], + "license": "MIT", + "homepage": "https://github.com/import-js/eslint-plugin-import" + }, + { + "name": "eslint-plugin-prettier", + "versions": [ + "5.5.5" + ], + "license": "MIT", + "homepage": "https://github.com/prettier/eslint-plugin-prettier#readme" + }, + { + "name": "eslint-scope", + "versions": [ + "7.2.2" + ], + "license": "BSD-2-Clause", + "homepage": "http://github.com/eslint/eslint-scope" + }, + { + "name": "eslint-visitor-keys", + "versions": [ + "3.4.3" + ], + "license": "Apache-2.0", + "homepage": "https://github.com/eslint/eslint-visitor-keys#readme" + }, + { + "name": "espree", + "versions": [ + "9.6.1" + ], + "license": "BSD-2-Clause", + "homepage": "https://github.com/eslint/espree" + }, + { + "name": "esquery", + "versions": [ + "1.7.0" + ], + "license": "BSD-3-Clause", + "homepage": "https://github.com/estools/esquery/" + }, + { + "name": "esrecurse", + "versions": [ + "4.3.0" + ], + "license": "BSD-2-Clause", + "homepage": "https://github.com/estools/esrecurse" + }, + { + "name": "estraverse", + "versions": [ + "5.3.0" + ], + "license": "BSD-2-Clause", + "homepage": "https://github.com/estools/estraverse" + }, + { + "name": "estree-walker", + "versions": [ + "3.0.3" + ], + "license": "MIT", + "homepage": "https://github.com/Rich-Harris/estree-walker#readme" + }, + { + "name": "esutils", + "versions": [ + "2.0.3" + ], + "license": "BSD-2-Clause", + "homepage": "https://github.com/estools/esutils" + }, + { + "name": "etag", + "versions": [ + "1.8.1" + ], + "license": "MIT", + "homepage": "https://github.com/jshttp/etag#readme" + }, + { + "name": "event-target-shim", + "versions": [ + "5.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/mysticatea/event-target-shim" + }, + { + "name": "events", "versions": [ "3.3.0" ], @@ -1635,6 +2113,14 @@ "license": "MIT", "homepage": "https://github.com/epoberezkin/fast-deep-equal#readme" }, + { + "name": "fast-diff", + "versions": [ + "1.3.0" + ], + "license": "Apache-2.0", + "homepage": "https://github.com/jhchen/fast-diff#readme" + }, { "name": "fast-glob", "versions": [ @@ -1643,6 +2129,22 @@ "license": "MIT", "homepage": "https://github.com/mrmlnc/fast-glob#readme" }, + { + "name": "fast-json-stable-stringify", + "versions": [ + "2.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/epoberezkin/fast-json-stable-stringify" + }, + { + "name": "fast-levenshtein", + "versions": [ + "2.0.6" + ], + "license": "MIT", + "homepage": "https://github.com/hiddentao/fast-levenshtein#readme" + }, { "name": "fast-safe-stringify", "versions": [ @@ -1691,6 +2193,14 @@ "license": "MIT", "homepage": "https://github.com/node-fetch/fetch-blob#readme" }, + { + "name": "file-entry-cache", + "versions": [ + "6.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/royriojas/file-entry-cache#readme" + }, { "name": "file-uri-to-path", "versions": [ @@ -1715,6 +2225,30 @@ "license": "MIT", "homepage": "https://github.com/pillarjs/finalhandler#readme" }, + { + "name": "find-up", + "versions": [ + "5.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/find-up#readme" + }, + { + "name": "flat-cache", + "versions": [ + "3.2.0" + ], + "license": "MIT", + "homepage": "https://github.com/jaredwray/flat-cache#readme" + }, + { + "name": "flatted", + "versions": [ + "3.4.4" + ], + "license": "ISC", + "homepage": "https://github.com/WebReflection/flatted#readme" + }, { "name": "follow-redirects", "versions": [ @@ -1723,6 +2257,14 @@ "license": "MIT", "homepage": "https://github.com/follow-redirects/follow-redirects" }, + { + "name": "for-each", + "versions": [ + "0.3.5" + ], + "license": "MIT", + "homepage": "https://github.com/Raynos/for-each" + }, { "name": "form-data", "versions": [ @@ -1771,6 +2313,14 @@ "license": "MIT", "homepage": "https://github.com/mafintosh/fs-constants" }, + { + "name": "fs.realpath", + "versions": [ + "1.0.0" + ], + "license": "ISC", + "homepage": "https://github.com/isaacs/fs.realpath#readme" + }, { "name": "fsevents", "versions": [ @@ -1787,6 +2337,22 @@ "license": "MIT", "homepage": "https://github.com/Raynos/function-bind" }, + { + "name": "function.prototype.name", + "versions": [ + "1.2.0" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Function.prototype.name#readme" + }, + { + "name": "functions-have-names", + "versions": [ + "1.2.3" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/functions-have-names#readme" + }, { "name": "gaxios", "versions": [ @@ -1803,6 +2369,14 @@ "license": "Apache-2.0", "homepage": "https://github.com/googleapis/google-cloud-node-core/tree/main/packages/gcp-metadata" }, + { + "name": "generator-function", + "versions": [ + "2.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/TimothyGu/generator-function#readme" + }, { "name": "get-caller-file", "versions": [ @@ -1843,6 +2417,14 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/get-stream#readme" }, + { + "name": "get-symbol-description", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/get-symbol-description#readme" + }, { "name": "get-tsconfig", "versions": [ @@ -1867,6 +2449,14 @@ "license": "MIT", "homepage": "https://github.com/substack/github-from-package" }, + { + "name": "glob", + "versions": [ + "7.2.3" + ], + "license": "ISC", + "homepage": "https://github.com/isaacs/node-glob#readme" + }, { "name": "glob", "versions": [ @@ -1878,7 +2468,8 @@ { "name": "glob-parent", "versions": [ - "5.1.2" + "5.1.2", + "6.0.2" ], "license": "ISC", "homepage": "https://github.com/gulpjs/glob-parent#readme" @@ -1891,6 +2482,30 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/global-directory#readme" }, + { + "name": "globals", + "versions": [ + "13.24.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/globals#readme" + }, + { + "name": "globalthis", + "versions": [ + "1.0.4" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/System.global#readme" + }, + { + "name": "globby", + "versions": [ + "11.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/globby#readme" + }, { "name": "google-auth-library", "versions": [ @@ -1931,6 +2546,14 @@ "license": "ISC", "homepage": "https://github.com/isaacs/node-graceful-fs#readme" }, + { + "name": "graphemer", + "versions": [ + "1.4.0" + ], + "license": "MIT", + "homepage": "https://github.com/flmnt/graphemer" + }, { "name": "handlebars", "versions": [ @@ -1939,6 +2562,14 @@ "license": "MIT", "homepage": "https://handlebarsjs.com/" }, + { + "name": "has-bigints", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/has-bigints#readme" + }, { "name": "has-flag", "versions": [ @@ -1947,6 +2578,22 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/has-flag#readme" }, + { + "name": "has-property-descriptors", + "versions": [ + "1.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/has-property-descriptors#readme" + }, + { + "name": "has-proto", + "versions": [ + "1.2.0" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/has-proto#readme" + }, { "name": "has-symbols", "versions": [ @@ -2055,8 +2702,9 @@ { "name": "ignore", "versions": [ + "5.3.2", "7.0.5", - "7.0.6" + "7.0.9" ], "license": "MIT", "homepage": "https://github.com/kaelzhang/node-ignore#readme" @@ -2069,6 +2717,30 @@ "license": "MIT", "homepage": "https://github.com/calvinmetcalf/immediate#readme" }, + { + "name": "import-fresh", + "versions": [ + "3.3.1" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/import-fresh#readme" + }, + { + "name": "imurmurhash", + "versions": [ + "0.1.4" + ], + "license": "MIT", + "homepage": "https://github.com/jensyt/imurmurhash-js" + }, + { + "name": "inflight", + "versions": [ + "1.0.6" + ], + "license": "ISC", + "homepage": "https://github.com/isaacs/inflight" + }, { "name": "inherits", "versions": [ @@ -2086,6 +2758,14 @@ "license": "ISC", "homepage": "https://github.com/npm/ini#readme" }, + { + "name": "internal-slot", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/internal-slot#readme" + }, { "name": "interpret", "versions": [ @@ -2111,14 +2791,78 @@ "license": "MIT", "homepage": "https://github.com/whitequark/ipaddr.js#readme" }, + { + "name": "is-array-buffer", + "versions": [ + "3.0.5" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-array-buffer#readme" + }, + { + "name": "is-async-function", + "versions": [ + "2.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-async-function#readme" + }, + { + "name": "is-bigint", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-bigint#readme" + }, + { + "name": "is-boolean-object", + "versions": [ + "1.2.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-boolean-object#readme" + }, + { + "name": "is-callable", + "versions": [ + "1.2.7" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-callable#readme" + }, { "name": "is-core-module", "versions": [ - "2.16.2" + "2.17.0" ], "license": "MIT", "homepage": "https://github.com/inspect-js/is-core-module" }, + { + "name": "is-data-view", + "versions": [ + "1.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-data-view#readme" + }, + { + "name": "is-date-object", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-date-object#readme" + }, + { + "name": "is-document.all", + "versions": [ + "1.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-document.all#readme" + }, { "name": "is-extglob", "versions": [ @@ -2127,6 +2871,14 @@ "license": "MIT", "homepage": "https://github.com/jonschlinkert/is-extglob" }, + { + "name": "is-finalizationregistry", + "versions": [ + "1.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-finalizationregistry#readme" + }, { "name": "is-fullwidth-code-point", "versions": [ @@ -2135,6 +2887,14 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/is-fullwidth-code-point#readme" }, + { + "name": "is-generator-function", + "versions": [ + "1.1.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-generator-function#readme" + }, { "name": "is-glob", "versions": [ @@ -2151,6 +2911,22 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/is-installed-globally#readme" }, + { + "name": "is-map", + "versions": [ + "2.0.3" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-map#readme" + }, + { + "name": "is-negative-zero", + "versions": [ + "2.0.3" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-negative-zero" + }, { "name": "is-number", "versions": [ @@ -2159,9 +2935,18 @@ "license": "MIT", "homepage": "https://github.com/jonschlinkert/is-number" }, + { + "name": "is-number-object", + "versions": [ + "1.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-number-object#readme" + }, { "name": "is-path-inside", "versions": [ + "3.0.3", "4.0.0" ], "license": "MIT", @@ -2175,6 +2960,30 @@ "license": "MIT", "homepage": "https://github.com/then/is-promise#readme" }, + { + "name": "is-regex", + "versions": [ + "1.2.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-regex" + }, + { + "name": "is-set", + "versions": [ + "2.0.3" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-set#readme" + }, + { + "name": "is-shared-array-buffer", + "versions": [ + "1.0.4" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-shared-array-buffer#readme" + }, { "name": "is-stream", "versions": [ @@ -2183,10 +2992,59 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/is-stream#readme" }, + { + "name": "is-string", + "versions": [ + "1.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-string#readme" + }, + { + "name": "is-symbol", + "versions": [ + "1.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-symbol#readme" + }, + { + "name": "is-typed-array", + "versions": [ + "1.1.15" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-typed-array#readme" + }, + { + "name": "is-weakmap", + "versions": [ + "2.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-weakmap#readme" + }, + { + "name": "is-weakref", + "versions": [ + "1.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-weakref#readme" + }, + { + "name": "is-weakset", + "versions": [ + "2.0.4" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/is-weakset#readme" + }, { "name": "isarray", "versions": [ - "1.0.0" + "1.0.0", + "2.0.5" ], "license": "MIT", "homepage": "https://github.com/juliangruber/isarray" @@ -2274,7 +3132,7 @@ { "name": "jscpd", "versions": [ - "5.2.0" + "5.2.1" ], "license": "MIT", "homepage": "https://jscpd.dev" @@ -2282,7 +3140,7 @@ { "name": "jscpd-darwin-arm64", "versions": [ - "5.2.0" + "5.2.1" ], "license": "MIT", "homepage": "https://github.com/kucherenko/jscpd#readme" @@ -2295,6 +3153,14 @@ "license": "MIT", "homepage": "https://github.com/sidorares/json-bigint#readme" }, + { + "name": "json-buffer", + "versions": [ + "3.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/dominictarr/json-buffer" + }, { "name": "json-schema-to-ts", "versions": [ @@ -2306,6 +3172,7 @@ { "name": "json-schema-traverse", "versions": [ + "0.4.1", "1.0.0" ], "license": "MIT", @@ -2319,9 +3186,18 @@ "license": "BSD-2-Clause", "homepage": "https://github.com/RemyRylan/json-schema-typed/tree/main/dist/node" }, + { + "name": "json-stable-stringify-without-jsonify", + "versions": [ + "1.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/samn/json-stable-stringify" + }, { "name": "json5", "versions": [ + "1.0.2", "2.2.3" ], "license": "MIT", @@ -2351,6 +3227,14 @@ "license": "MIT", "homepage": "https://github.com/brianloveswords/node-jws#readme" }, + { + "name": "keyv", + "versions": [ + "4.5.4" + ], + "license": "MIT", + "homepage": "https://github.com/jaredwray/keyv" + }, { "name": "kleur", "versions": [ @@ -2375,6 +3259,14 @@ "license": "MIT", "homepage": "https://github.com/jpommerening/node-lazystream" }, + { + "name": "levn", + "versions": [ + "0.4.1" + ], + "license": "MIT", + "homepage": "https://github.com/gkz/levn" + }, { "name": "lie", "versions": [ @@ -2383,6 +3275,14 @@ "license": "MIT", "homepage": "https://github.com/calvinmetcalf/lie#readme" }, + { + "name": "locate-path", + "versions": [ + "6.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/locate-path#readme" + }, { "name": "lodash.identity", "versions": [ @@ -2521,6 +3421,15 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/mimic-response#readme" }, + { + "name": "minimatch", + "versions": [ + "3.1.5", + "9.0.9" + ], + "license": "ISC", + "homepage": "https://github.com/isaacs/minimatch#readme" + }, { "name": "minimatch", "versions": [ @@ -2593,6 +3502,14 @@ "license": "MIT", "homepage": "https://github.com/inspiredware/napi-build-utils#readme" }, + { + "name": "natural-compare", + "versions": [ + "1.4.0" + ], + "license": "MIT", + "homepage": "https://github.com/litejs/natural-compare-lite#readme" + }, { "name": "negotiator", "versions": [ @@ -2641,6 +3558,14 @@ "license": "MIT", "homepage": "https://github.com/jimmywarting/node-domexception#readme" }, + { + "name": "node-exports-info", + "versions": [ + "1.6.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/node-exports-info#readme" + }, { "name": "node-fetch", "versions": [ @@ -2684,10 +3609,58 @@ { "name": "object-inspect", "versions": [ - "1.13.4" + "1.13.4" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/object-inspect" + }, + { + "name": "object-keys", + "versions": [ + "1.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/object-keys#readme" + }, + { + "name": "object.assign", + "versions": [ + "4.1.7" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/object.assign#readme" + }, + { + "name": "object.entries", + "versions": [ + "1.1.9" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Object.entries#readme" + }, + { + "name": "object.fromentries", + "versions": [ + "2.0.8" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Object.fromEntries#readme" + }, + { + "name": "object.groupby", + "versions": [ + "1.0.3" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Object.groupBy#readme" + }, + { + "name": "object.values", + "versions": [ + "1.2.1" ], "license": "MIT", - "homepage": "https://github.com/inspect-js/object-inspect" + "homepage": "https://github.com/es-shims/Object.values#readme" }, { "name": "obug", @@ -2729,6 +3702,22 @@ "license": "Apache-2.0", "homepage": "https://github.com/openai/openai-node#readme" }, + { + "name": "optionator", + "versions": [ + "0.9.4" + ], + "license": "MIT", + "homepage": "https://github.com/gkz/optionator" + }, + { + "name": "own-keys", + "versions": [ + "1.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/own-keys#readme" + }, { "name": "oxc-parser", "versions": [ @@ -2753,6 +3742,22 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/p-finally#readme" }, + { + "name": "p-limit", + "versions": [ + "3.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/p-limit#readme" + }, + { + "name": "p-locate", + "versions": [ + "5.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/p-locate#readme" + }, { "name": "p-retry", "versions": [ @@ -2777,6 +3782,14 @@ "license": "(MIT AND Zlib)", "homepage": "https://github.com/nodeca/pako" }, + { + "name": "parent-module", + "versions": [ + "1.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/parent-module#readme" + }, { "name": "parse5", "versions": [ @@ -2810,6 +3823,22 @@ "license": "MIT", "homepage": "https://promplate.dev/partial-json-parser" }, + { + "name": "path-exists", + "versions": [ + "4.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/path-exists#readme" + }, + { + "name": "path-is-absolute", + "versions": [ + "1.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/path-is-absolute#readme" + }, { "name": "path-key", "versions": [ @@ -2843,6 +3872,14 @@ "license": "MIT", "homepage": "https://github.com/pillarjs/path-to-regexp#readme" }, + { + "name": "path-type", + "versions": [ + "4.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/path-type#readme" + }, { "name": "pathe", "versions": [ @@ -2863,7 +3900,6 @@ "name": "picomatch", "versions": [ "2.3.2", - "4.0.5", "4.0.7" ], "license": "MIT", @@ -2918,6 +3954,14 @@ "license": "MIT", "homepage": "https://github.com/lukeapage/pngjs" }, + { + "name": "possible-typed-array-names", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/possible-typed-array-names#readme" + }, { "name": "postcss", "versions": [ @@ -2934,6 +3978,14 @@ "license": "MIT", "homepage": "https://github.com/prebuild/prebuild-install" }, + { + "name": "prelude-ls", + "versions": [ + "1.2.1" + ], + "license": "MIT", + "homepage": "http://preludels.com" + }, { "name": "prettier", "versions": [ @@ -2942,6 +3994,14 @@ "license": "MIT", "homepage": "https://prettier.io" }, + { + "name": "prettier-linter-helpers", + "versions": [ + "1.0.1" + ], + "license": "MIT", + "homepage": "https://github.com/prettier/prettier-linter-helpers#readme" + }, { "name": "process", "versions": [ @@ -3014,6 +4074,14 @@ "license": "MIT", "homepage": "https://github.com/mafintosh/pump#readme" }, + { + "name": "punycode", + "versions": [ + "2.3.1" + ], + "license": "MIT", + "homepage": "https://mths.be/punycode" + }, { "name": "qs", "versions": [ @@ -3090,6 +4158,14 @@ "license": "MIT", "homepage": "https://github.com/gulpjs/rechoir#readme" }, + { + "name": "reflect.getprototypeof", + "versions": [ + "1.0.10" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/Reflect.getPrototypeOf" + }, { "name": "regexp-tree", "versions": [ @@ -3098,6 +4174,14 @@ "license": "MIT", "homepage": "https://github.com/DmitrySoshnikov/regexp-tree" }, + { + "name": "regexp.prototype.flags", + "versions": [ + "1.5.4" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/RegExp.prototype.flags#readme" + }, { "name": "require-directory", "versions": [ @@ -3117,11 +4201,20 @@ { "name": "resolve", "versions": [ - "1.22.12" + "1.22.12", + "2.0.0-next.7" ], "license": "MIT", "homepage": "https://github.com/browserify/resolve#readme" }, + { + "name": "resolve-from", + "versions": [ + "4.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/resolve-from#readme" + }, { "name": "resolve-pkg-maps", "versions": [ @@ -3147,6 +4240,14 @@ "license": "MIT", "homepage": "https://github.com/mcollina/reusify#readme" }, + { + "name": "rimraf", + "versions": [ + "3.0.2" + ], + "license": "ISC", + "homepage": "https://github.com/isaacs/rimraf#readme" + }, { "name": "rollup", "versions": [ @@ -3171,6 +4272,14 @@ "license": "MIT", "homepage": "https://github.com/feross/run-parallel" }, + { + "name": "safe-array-concat", + "versions": [ + "1.1.4" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/safe-array-concat#readme" + }, { "name": "safe-buffer", "versions": [ @@ -3180,6 +4289,14 @@ "license": "MIT", "homepage": "https://github.com/feross/safe-buffer" }, + { + "name": "safe-push-apply", + "versions": [ + "1.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/safe-push-apply#readme" + }, { "name": "safe-regex", "versions": [ @@ -3188,6 +4305,14 @@ "license": "MIT", "homepage": "https://github.com/davisjam/safe-regex" }, + { + "name": "safe-regex-test", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/safe-regex-test#readme" + }, { "name": "safe-stable-stringify", "versions": [ @@ -3224,6 +4349,7 @@ "name": "semver", "versions": [ "5.7.2", + "6.3.1", "7.8.5" ], "license": "ISC", @@ -3245,6 +4371,30 @@ "license": "MIT", "homepage": "https://github.com/expressjs/serve-static#readme" }, + { + "name": "set-function-length", + "versions": [ + "1.2.2" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/set-function-length#readme" + }, + { + "name": "set-function-name", + "versions": [ + "2.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/set-function-name#readme" + }, + { + "name": "set-proto", + "versions": [ + "1.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/set-proto#readme" + }, { "name": "setimmediate", "versions": [ @@ -3375,6 +4525,14 @@ "license": "MIT", "homepage": "https://github.com/terkelg/sisteransi#readme" }, + { + "name": "slash", + "versions": [ + "3.0.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/slash#readme" + }, { "name": "smol-toml", "versions": [ @@ -3440,6 +4598,14 @@ "license": "MIT", "homepage": "https://github.com/unjs/std-env#readme" }, + { + "name": "stop-iteration-iterator", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/stop-iteration-iterator#readme" + }, { "name": "string_decoder", "versions": [ @@ -3457,6 +4623,30 @@ "license": "MIT", "homepage": "https://github.com/sindresorhus/string-width#readme" }, + { + "name": "string.prototype.trim", + "versions": [ + "1.2.11" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/String.prototype.trim#readme" + }, + { + "name": "string.prototype.trimend", + "versions": [ + "1.0.10" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/String.prototype.trimEnd#readme" + }, + { + "name": "string.prototype.trimstart", + "versions": [ + "1.0.8" + ], + "license": "MIT", + "homepage": "https://github.com/es-shims/String.prototype.trimStart#readme" + }, { "name": "strip-ansi", "versions": [ @@ -3507,6 +4697,14 @@ "license": "MIT", "homepage": "https://github.com/inspect-js/node-supports-preserve-symlinks-flag#readme" }, + { + "name": "synckit", + "versions": [ + "0.11.13" + ], + "license": "MIT", + "homepage": "https://github.com/un-ts/synckit#readme" + }, { "name": "tapable", "versions": [ @@ -3539,6 +4737,14 @@ "license": "MIT", "homepage": "https://github.com/mafintosh/tar-stream" }, + { + "name": "text-table", + "versions": [ + "0.2.0" + ], + "license": "MIT", + "homepage": "https://github.com/substack/text-table" + }, { "name": "thenify", "versions": [ @@ -3619,9 +4825,18 @@ "license": "MIT", "homepage": "https://github.com/ThomasAribart/ts-algebra#readme" }, + { + "name": "ts-api-utils", + "versions": [ + "1.4.3" + ], + "license": "MIT", + "homepage": "https://github.com/JoshuaKGoldberg/ts-api-utils#readme" + }, { "name": "tsconfig-paths", "versions": [ + "3.15.0", "4.2.0" ], "license": "MIT", @@ -3659,6 +4874,22 @@ "license": "Apache-2.0", "homepage": "https://github.com/mikeal/tunnel-agent#readme" }, + { + "name": "type-check", + "versions": [ + "0.4.0" + ], + "license": "MIT", + "homepage": "https://github.com/gkz/type-check" + }, + { + "name": "type-fest", + "versions": [ + "0.20.2" + ], + "license": "(MIT OR CC0-1.0)", + "homepage": "https://github.com/sindresorhus/type-fest#readme" + }, { "name": "type-is", "versions": [ @@ -3675,6 +4906,38 @@ "license": "MIT", "homepage": "https://github.com/sinclairzx81/typebox#readme" }, + { + "name": "typed-array-buffer", + "versions": [ + "1.0.3" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/typed-array-buffer#readme" + }, + { + "name": "typed-array-byte-length", + "versions": [ + "1.0.3" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/typed-array-byte-length#readme" + }, + { + "name": "typed-array-byte-offset", + "versions": [ + "1.0.5" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/typed-array-byte-offset#readme" + }, + { + "name": "typed-array-length", + "versions": [ + "1.0.8" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/typed-array-length#readme" + }, { "name": "typescript", "versions": [ @@ -3699,6 +4962,14 @@ "license": "ISC", "homepage": "https://github.com/webpro-nl/unbash#readme" }, + { + "name": "unbox-primitive", + "versions": [ + "1.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/ljharb/unbox-primitive#readme" + }, { "name": "undici", "versions": [ @@ -3732,6 +5003,14 @@ "license": "MIT", "homepage": "https://github.com/stream-utils/unpipe#readme" }, + { + "name": "uri-js", + "versions": [ + "4.4.1" + ], + "license": "BSD-2-Clause", + "homepage": "https://github.com/garycourt/uri-js" + }, { "name": "util-deprecate", "versions": [ @@ -3797,6 +5076,38 @@ "license": "ISC", "homepage": "https://github.com/isaacs/node-which#readme" }, + { + "name": "which-boxed-primitive", + "versions": [ + "1.1.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/which-boxed-primitive#readme" + }, + { + "name": "which-builtin-type", + "versions": [ + "1.2.1" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/which-builtin-type#readme" + }, + { + "name": "which-collection", + "versions": [ + "1.0.2" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/which-collection#readme" + }, + { + "name": "which-typed-array", + "versions": [ + "1.1.24" + ], + "license": "MIT", + "homepage": "https://github.com/inspect-js/which-typed-array#readme" + }, { "name": "why-is-node-running", "versions": [ @@ -3805,6 +5116,14 @@ "license": "MIT", "homepage": "https://github.com/mafintosh/why-is-node-running" }, + { + "name": "word-wrap", + "versions": [ + "1.2.5" + ], + "license": "MIT", + "homepage": "https://github.com/jonschlinkert/word-wrap" + }, { "name": "wordwrap", "versions": [ @@ -3885,6 +5204,14 @@ "license": "ISC", "homepage": "https://github.com/yargs/yargs-parser#readme" }, + { + "name": "yocto-queue", + "versions": [ + "0.1.0" + ], + "license": "MIT", + "homepage": "https://github.com/sindresorhus/yocto-queue#readme" + }, { "name": "zod", "versions": [ diff --git a/release/public-source.json b/release/public-source.json index 86a4b277..ad784316 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -6,6 +6,7 @@ ".agents/skills/retro/SKILL.md", ".agents/skills/testing-workflow/SKILL.md", ".agents/skills/verify-all-runtime-sinks/SKILL.md", + ".eslintrc.cjs", ".gitattributes", ".github/CODEOWNERS", ".github/ISSUE_TEMPLATE/01-bug-report.yml", @@ -61,6 +62,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/tui-capabilities.md", "docs/verification.md", "examples/clamp/README.md", @@ -332,6 +335,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", @@ -1286,6 +1290,7 @@ "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-input-preparation.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts", + "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-hooks.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-tool-catalog-source.ts", @@ -2655,6 +2660,7 @@ "packages/local-runtime/test/unit/local-thread-goal-store.test.ts", "packages/local-runtime/test/unit/local-thread-goal-wiring.test.ts", "packages/local-runtime/test/unit/session-diff-fallback.test.ts", + "packages/local-runtime/test/unit/skill-directory-symlinks.test.ts", "packages/local-runtime/test/unit/thread-goal-kickoff-host.test.ts", "packages/local-runtime/test/unit/thread-goal-verifier-contract.test.ts", "packages/local-runtime/test/unit/thread-goal/contract.test.ts", @@ -2763,6 +2769,7 @@ "packages/shared/src/opencode-go-headers.ts", "packages/shared/src/openrouter-attribution.ts", "packages/shared/src/outbound-media.ts", + "packages/shared/src/plugin-mention.ts", "packages/shared/src/plugin-skill-name.ts", "packages/shared/src/product-build-identity.ts", "packages/shared/src/product-time.ts", @@ -2794,6 +2801,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", @@ -2947,6 +2955,7 @@ "packages/tui/src/tui/commands/bash-input.ts", "packages/tui/src/tui/commands/catalog.ts", "packages/tui/src/tui/commands/input-intent.ts", + "packages/tui/src/tui/commands/plugin-autocomplete.ts", "packages/tui/src/tui/commands/side-session.ts", "packages/tui/src/tui/controller/chat-controller-failure.ts", "packages/tui/src/tui/controller/chat-controller-support.ts", @@ -2970,6 +2979,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", @@ -2989,6 +2999,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", @@ -3102,6 +3113,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", @@ -3166,8 +3178,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", @@ -3197,6 +3211,7 @@ "packages/tui/src/tui/widgets/autocomplete.ts", "packages/tui/src/tui/widgets/editor/editor.ts", "packages/tui/src/tui/widgets/editor/paste.ts", + "packages/tui/src/tui/widgets/editor/plugin-mentions.ts", "packages/tui/src/tui/widgets/input.ts", "packages/tui/src/tui/widgets/panel-frame.ts", "packages/tui/src/tui/widgets/select-list.ts", @@ -3213,6 +3228,7 @@ "packages/tui/src/update/release.ts", "packages/tui/src/update/startup-notice.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", @@ -3229,6 +3245,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/mcode-tools-integration.test.ts", "packages/tui/test/unit/model-effort.test.ts", "packages/tui/test/unit/network-proxy.test.ts", @@ -3263,6 +3280,7 @@ "packages/tui/test/unit/tui-keybindings.test.ts", "packages/tui/test/unit/tui-model-picker.test.ts", "packages/tui/test/unit/tui-plugin-manager.test.ts", + "packages/tui/test/unit/tui-plugin-mentions.test.ts", "packages/tui/test/unit/tui-provider-editor.test.ts", "packages/tui/test/unit/tui-provider-manager.test.ts", "packages/tui/test/unit/tui-provider-onboarding.test.ts", @@ -3281,6 +3299,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-session-store.test.ts", "packages/tui/test/unit/tui-transcript-view.test.ts", @@ -3292,11 +3311,16 @@ "packages/tui/test/unit/tui/controller/product/model-state.test.ts", "packages/tui/test/unit/tui/controller/projection/status-metrics-flow.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", "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/session-tabs-flow.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-release.test.ts", diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 1e9c2c51..cf5b5326 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -48,6 +48,7 @@ const steps = [ command: ["scripts/export-source-preview.mjs", "--out", preview], }, { name: "test:release-tools", script: "test:release-tools", docs: true, windows: true }, + { name: "lint:tui", script: "lint:tui", 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 }, diff --git a/test/source-sync.test.mjs b/test/source-sync.test.mjs index d48a28d2..d76f7422 100644 --- a/test/source-sync.test.mjs +++ b/test/source-sync.test.mjs @@ -871,6 +871,48 @@ 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', + 'lint:tui', + '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'; @@ -1050,3 +1092,40 @@ test('source imports preserve vendored Office schema bytes through Git staging', assert.deepEqual(git('show', `:${schema}`), bytes); } }); + +test('TUI lint rejects semantic regressions in source and tests while preserving engine exceptions', async () => { + const { ESLint } = await import('eslint'); + const root = fileURLToPath(new URL('../', import.meta.url)); + const eslint = new ESLint({ cwd: root }); + const rulesFor = async (source, filePath) => { + const [result] = await eslint.lintText(source, { filePath }); + assert.equal(result.fatalErrorCount, 0, JSON.stringify(result.messages)); + return result.messages.filter(message => message.severity === 2).map(message => message.ruleId); + }; + const shadow = 'const value = 1; export function sample(value: number) { return value; }\n'; + for (const file of ['packages/tui/src/lint-probe.ts', 'packages/tui/test/unit/lint-probe.test.ts']) { + assert.ok((await rulesFor(shadow, file)).includes('@typescript-eslint/no-shadow'), file); + assert.ok((await rulesFor('export const compare = (value: number) => value == 1;\n', file)).includes('eqeqeq'), file); + } + assert.ok((await rulesFor('export const compare = (value) => value == 1;\n', 'packages/tui/test/lint-probe.mjs')).includes('eqeqeq')); + assert.ok(!(await rulesFor(shadow, 'packages/tui/src/tui/engine/lint-probe.ts')).includes('@typescript-eslint/no-shadow')); + assert.ok((await rulesFor(shadow, 'packages/tui/src/tui/engine/public.ts')).includes('@typescript-eslint/no-shadow')); + assert.ok((await rulesFor('export const compare = (value: number) => value == 1;\n', 'packages/tui/src/tui/engine/lint-probe.ts')).includes('eqeqeq')); + const [formatting] = await eslint.lintText('export const label = "synthetic";\n', { filePath: 'packages/tui/src/lint-probe.ts' }); + assert.ok(formatting.messages.some(message => message.ruleId === 'prettier/prettier' && message.severity === 1)); + assert.equal(await eslint.isPathIgnored('packages/tui/test/unit/lint-probe.test.ts'), false); + assert.equal(await eslint.isPathIgnored('packages/tui/test/pi-084-upstream/lint-probe.test.ts'), true); + assert.equal(await eslint.isPathIgnored('third_party/pi-mono/packages/tui/src/lint-probe.ts'), true); +}); + +test('TUI lint failure stops full and platform verification before compilation or build', t => { + const fixture = verificationFixture(t); + for (const profile of ['full', 'platform', 'archive']) { + const result = fixture.run(['--profile', profile], { VERIFY_FIXTURE_FAIL: 'lint:tui' }); + assert.equal(result.status, 1, result.stderr); + const report = fixture.report(); + assert.equal(report.gates.find(gate => gate.name === 'lint:tui').status, 'FAIL'); + assert.equal(report.gates.find(gate => gate.name === 'lint:tui').exitCode, 17); + assert.equal(report.gates.find(gate => gate.name === 'build').status, 'NOT_RUN'); + } +}); diff --git a/test/vitest-suites.json b/test/vitest-suites.json index e226eb17..5f95e573 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -5,9 +5,12 @@ "test/windows-contract.test.mjs" ], "capability": [ + "packages/local-runtime/test/unit/skill-directory-symlinks.test.ts", "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", @@ -148,6 +151,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/config/test/config-file-permissions.test.ts", "packages/config/test/private-config-file.test.ts", @@ -171,6 +175,13 @@ "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/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", + "packages/tui/test/unit/tui-plugin-mentions.test.ts", + "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts", "packages/tui/test/unit/tui-copilot-login.test.ts", "packages/local-runtime-v2/src/service/model-system/copilot-oauth.test.ts", "packages/local-runtime-v2/src/service/model-system/initialize.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, + }, }; }); }, diff --git a/tsconfig.standalone.json b/tsconfig.standalone.json index c150e1ff..c44b028f 100644 --- a/tsconfig.standalone.json +++ b/tsconfig.standalone.json @@ -334,6 +334,9 @@ "@mavis/shared/navigation-text": [ "./packages/shared/src/navigation-text.ts" ], + "@mavis/shared/plugin-mention": [ + "./packages/shared/src/plugin-mention.ts" + ], "@mavis/shared/product-build-identity": [ "./packages/shared/src/product-build-identity.ts" ],