From bcb1d96553e06bf2a0d44d06f40ac79350696465 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:35:51 +0800 Subject: [PATCH 01/23] fix(tui): erase viewport redraws without saving stale history (#309) --- docs/tui-capabilities.md | 4 +- .../tui/src/tui/engine/LOCAL_CHANGES.json | 6 +- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 8 ++ .../tui/src/tui/engine/tui-main-screen.ts | 13 ++- .../test/unit/tui-engine-local-deltas.test.ts | 85 ++++++++++++++++++- 5 files changed, 108 insertions(+), 8 deletions(-) diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 8fd5dbb6..92baff9e 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -90,7 +90,9 @@ screen, the renderer keeps native scrollback and the Composer position stable. Freed rows temporarily remain blank at the top of the active screen and subsequent output reuses them. This avoids resetting the host's scroll position when a turn finishes. Redundant resize notifications with unchanged dimensions do not rebuild -history. +history. Viewport-only redraws erase rows in place so terminals that save a cleared +screen to scrollback, including Apple Terminal, do not retain the old Composer, +status line or duplicate transcript rows. When a change removes or replaces text already in scrollback, the renderer still reconstructs the current session to avoid stale or duplicate history. Real resizes diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index 231cb753..cfae2635 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -163,11 +163,11 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "73789fb955252262ad03907446e4f4a6052bfcb86bf2a4fdc77ddaa1dd7ee9ee", - "changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038"], + "currentSha256": "5d499306f4b92d443c350dd944fbdc11b6ed7c74a030eb1e7e020890bc30fdb7", + "changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038", "L039"], "upstreamCommit": "6c4f360264397c59801f6da2bdac13e3b1fcbe91", "reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。", - "behaviorImpact": "Regular updates preserve native scrollback. Visible text-only shrink with unchanged historical text temporarily pads the active screen to preserve host scrolling and the input position; later output reuses this space. Historical text replacement or removal still reconstructs the session to avoid stale or duplicate history. Rebuilding clears pre-launch shell scrollback. Genuine resize retains delayed history replay; redundant same-size notifications are ignored." + "behaviorImpact": "Regular viewport redraws erase rows in place so hosts that save an erased screen to scrollback do not retain stale transcript or footer rows. Visible text-only shrink with unchanged historical text temporarily pads the active screen to preserve host scrolling and the input position; later output reuses this space. Historical text replacement or removal still reconstructs the session to avoid stale or duplicate history. Rebuilding clears pre-launch shell scrollback. Genuine resize retains delayed history replay; redundant same-size notifications are ignored." }, { "path": "tui.ts", diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index d920a633..88f5ab2f 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -146,3 +146,11 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Boundary: padding is confined to the active screen. Historical text replacement/removal, real resize, overlays and image reflow retain the structural reconstruction path. Blank rows can temporarily separate native history from the visible tail; this is preferable to clearing and replaying the terminal's scrollback during ordinary completion. No mouse capture is enabled in regular mode. - Evidence: local-delta tests use xterm's host scroll API independently of the hardware cursor, reproduce the pre-fix jump to line zero, and verify stable scrolling, Composer position, unique history, reclaimed space, corrected-history reconstruction and resize behavior. The product queue/feature tests continue to cover canonical history replacement. Native Windows Terminal and UU Remote acceptance remain separate. - Removal condition: the selected Pi baseline preserves host scrolling and unique history through visible shrink. + +## L039: Erase regular viewport redraws in place + +- Product contract: repainting the visible regular-mode screen must not append the previous transcript, Composer or status line to native history. +- Minimal difference: viewport-only full redraws home the cursor, erase each screen row with EL 2 using cursor-down movement, and return home before painting. This avoids ED 2, which saves the old screen to scrollback in Apple Terminal. Full structural reconstruction still clears and rebuilds history. +- Evidence: local-delta tests exercise xterm and a clear-to-scrollback host model, covering historical style changes, simultaneous growth, short-document shrink, subsequent differential output, host scrolling and resize preview/replay. Native Apple Terminal replay of synthetic renderer output reproduces duplicate rows before the fix and preserves the exact document afterward. +- Boundary: native replay covers synthetic output, not every live-model interaction or other terminal emulator. +- Removal condition: the selected Pi baseline supplies equivalent in-place viewport erasure without retaining stale rows in native history. diff --git a/packages/tui/src/tui/engine/tui-main-screen.ts b/packages/tui/src/tui/engine/tui-main-screen.ts index a5239708..44901c93 100644 --- a/packages/tui/src/tui/engine/tui-main-screen.ts +++ b/packages/tui/src/tui/engine/tui-main-screen.ts @@ -348,7 +348,18 @@ export class TuiMainScreen extends TuiBase implements TUI { output.append("\x1b[?2026h"); // Begin synchronized output if (clear) { output.append(this.deleteKittyImages(this.previousKittyImageIds)); - output.append(viewportOnly ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J"); + if (viewportOnly) { + // ED 2 saves the old screen to scrollback in Apple Terminal. Erase + // each row in place so old transcript/footer rows cannot survive there. + output.append("\x1b[H"); + for (let row = 0; row < height; row++) { + if (row > 0) output.append("\x1b[1B"); + output.append("\x1b[2K"); + } + output.append("\x1b[H"); + } else { + output.append("\x1b[2J\x1b[H\x1b[3J"); + } } for (let i = start; i < newLines.length; i++) { if (i > start) output.append("\r\n"); diff --git a/packages/tui/test/unit/tui-engine-local-deltas.test.ts b/packages/tui/test/unit/tui-engine-local-deltas.test.ts index 1d8f8ff1..d61cf233 100644 --- a/packages/tui/test/unit/tui-engine-local-deltas.test.ts +++ b/packages/tui/test/unit/tui-engine-local-deltas.test.ts @@ -37,6 +37,14 @@ class RecordingVirtualTerminal extends VirtualTerminal { } } +// Apple Terminal preserves the old screen in scrollback on ED 2. Model that +// behavior explicitly: xterm's default erase implementation does not expose it. +class ClearToScrollbackTerminal extends RecordingVirtualTerminal { + override write(data: string): void { + super.write(data.replaceAll('\x1b[2J', `\x1b[${this.rows};1H${'\r\n'.repeat(this.rows)}\x1b[2J`)); + } +} + class MutableLines implements Component { lines: string[] = []; @@ -48,6 +56,68 @@ class MutableLines implements Component { } describe('MCode Pi Engine local deltas', () => { + describe.each([ + ['xterm', RecordingVirtualTerminal], + ['clear-to-scrollback host', ClearToScrollbackTerminal], + ] as const)('%s viewport repaint', (_name, Terminal) => { + it.each([0, 30])('keeps unique history after a style update and %i new rows', async (growth) => { + const terminal = new Terminal(60, 12); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + const answer = Array.from({ length: 40 }, (_, index) => `Answer ${index}`); + component.lines = [...answer, `old composer${CURSOR_MARKER}`, 'running']; + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + terminal.scrollLines(-10); + const before = terminal.getScrollPosition(); + terminal.takeWrites(); + + const more = Array.from({ length: growth }, (_, index) => `More ${index}`); + component.lines = [...answer, ...more, `composer${CURSOR_MARKER}`, 'idle']; + component.lines[0] = '\x1b[1mAnswer 0\x1b[0m'; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual([...answer, ...more, 'composer', 'idle']); + expect(terminal.getScrollPosition().viewport).toBe(before.viewport); + expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 10 }); + const writes = terminal.takeWrites(); + expect(writes).not.toContain('\x1b[2J'); + expect(writes).not.toContain('\x1b[3J'); + + // Subsequent differential output must still overwrite the current footer. + component.lines.splice(-2, 0, 'Next response'); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getScrollBuffer()).toEqual([...answer, ...more, 'Next response', 'composer', 'idle']); + terminal.scrollLines(1000); + expect(terminal.getViewport().slice(-3)).toEqual(['Next response', 'composer', 'idle']); + }); + + it('erases stale rows when a short document shrinks', async () => { + const terminal = new Terminal(60, 12); + const tui = new TuiMainScreen(terminal); + tui.setClearOnShrink(true); + const component = new MutableLines(); + component.lines = ['answer', 'activity 1', 'activity 2', `old composer${CURSOR_MARKER}`, 'running']; + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + + component.lines = ['answer', `composer${CURSOR_MARKER}`, 'idle']; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual(['answer', 'composer', 'idle', ...Array(9).fill('')]); + expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 1 }); + const writes = terminal.takeWrites(); + expect(writes).not.toContain('\x1b[2J'); + expect(writes).not.toContain('\x1b[3J'); + }); + }); + it.each([1, 8, 30])('preserves a scrolled host viewport when %i visible activity rows settle', async (activityRows) => { const terminal = new RecordingVirtualTerminal(60, 44); const tui = new TuiMainScreen(terminal); @@ -330,8 +400,11 @@ describe('MCode Pi Engine local deltas', () => { expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 1 }); }); - it('still previews the resized tail and restores ordered scrollback after resize settles', async () => { - const terminal = new RecordingVirtualTerminal(67, 44); + it.each([ + ['xterm', RecordingVirtualTerminal], + ['clear-to-scrollback host', ClearToScrollbackTerminal], + ] as const)('previews the resized tail and restores ordered scrollback on %s', async (_name, Terminal) => { + const terminal = new Terminal(67, 44); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); component.lines = [ @@ -348,7 +421,13 @@ describe('MCode Pi Engine local deltas', () => { terminal.resize(60, 30); tui.renderNow(); await terminal.flush(); - expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + const previewWrites = terminal.takeWrites(); + expect(previewWrites).not.toContain('\x1b[2J'); + expect(previewWrites).not.toContain('\x1b[3J'); + expect(terminal.getScrollBuffer()).toEqual([ + ...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`), + 'composer', + ]); expect(terminal.getViewport()).toEqual([ ...Array.from({ length: 29 }, (_, index) => `Answer line ${index + 51}`), 'composer', From 00a7e7df1a1596cf1da63a6334b0c9f6d1f32618 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:49:20 +0800 Subject: [PATCH 02/23] feat(tui): add selectable themes and custom theme files (#311) * feat(tui): add selectable themes and custom theme files MCode TUI colors are now driven by named themes, each shipping a dark and a light palette. `/theme` opens a panel with live preview so users can switch themes and lock light/dark on top of terminal detection. Custom theme files under `tui/themes/*.json` support partial overrides, color aliases and syntax palettes, and hot reload so editing a theme in use takes effect without restarting. The selected theme persists in `tui/tui-settings.json` alongside `tuiMode`; unrecognized values fall back to the default theme instead of blocking startup. Assisted-by: mavis reason:port-theme-system * test: register theme system tests and source inventory The public source gate requires every first-party test to be listed in test/vitest-suites.json and every shipped source path to be recorded in release/public-source.json. Register the new theme system tests and the 11 files this change adds. Assisted-by: mavis reason:register-theme-tests --- packages/tui/docs/theme-config.md | 141 ++++ packages/tui/src/host/tui-settings.ts | 74 +- packages/tui/src/tui/app-composition.ts | 2 + packages/tui/src/tui/app.ts | 7 + packages/tui/src/tui/commands/catalog.ts | 9 + .../tui/controller/product/command-flow.ts | 2 + .../src/tui/controller/product/theme-setup.ts | 44 ++ .../src/tui/features/settings/theme-picker.ts | 248 +++++++ packages/tui/src/tui/launcher.ts | 14 +- packages/tui/src/tui/theme/contracts.ts | 85 +++ packages/tui/src/tui/theme/controller.ts | 160 +++- packages/tui/src/tui/theme/custom-themes.ts | 300 ++++++++ packages/tui/src/tui/theme/palettes.ts | 358 ++++++++- packages/tui/src/tui/theme/registry.ts | 92 +++ packages/tui/src/tui/theme/runtime.ts | 38 +- packages/tui/src/tui/theme/syntax.ts | 105 ++- packages/tui/src/types/tui-app.ts | 3 + packages/tui/test/helpers/theme-contrast.ts | 57 ++ .../tui/test/unit/host-tui-settings.test.ts | 157 ++++ .../features/settings/theme-picker.test.ts | 161 ++++ .../test/unit/tui/theme/custom-themes.test.ts | 275 +++++++ .../tui/test/unit/tui/theme/palettes.test.ts | 113 +++ .../tui/test/unit/tui/theme/runtime.test.ts | 695 ++++++++++++++++++ release/public-source.json | 11 + test/vitest-suites.json | 7 +- 25 files changed, 3051 insertions(+), 107 deletions(-) create mode 100644 packages/tui/docs/theme-config.md create mode 100644 packages/tui/src/tui/controller/product/theme-setup.ts create mode 100644 packages/tui/src/tui/features/settings/theme-picker.ts create mode 100644 packages/tui/src/tui/theme/custom-themes.ts create mode 100644 packages/tui/src/tui/theme/registry.ts create mode 100644 packages/tui/test/helpers/theme-contrast.ts create mode 100644 packages/tui/test/unit/host-tui-settings.test.ts create mode 100644 packages/tui/test/unit/tui/features/settings/theme-picker.test.ts create mode 100644 packages/tui/test/unit/tui/theme/custom-themes.test.ts create mode 100644 packages/tui/test/unit/tui/theme/palettes.test.ts create mode 100644 packages/tui/test/unit/tui/theme/runtime.test.ts diff --git a/packages/tui/docs/theme-config.md b/packages/tui/docs/theme-config.md new file mode 100644 index 00000000..3ce15c59 --- /dev/null +++ b/packages/tui/docs/theme-config.md @@ -0,0 +1,141 @@ +# 主题配置指南 + +mcode TUI 的配色由**具名主题**决定,每个主题同时提供深色和浅色两套调色板。终端深浅背景默认由 +终端证据自动探测,`/theme` 在此之上让用户切换主题、锁定明暗,并支持自己编写主题文件。 + +## TL;DR + +输入 `/theme` 打开主题面板: + +- `↑` / `↓` 移动光标,**实时预览**对应配色;`Enter` 保存并立即生效,`Esc` / `Ctrl+C` 取消并还原打开面板时的主题。 +- `a` 在「自动 / 浅色 / 深色」之间循环。自动模式跟随终端探测结果,锁定后不再被终端证据覆盖。 +- 每行右侧是配色条,面板底部显示当前外观、主题来源和说明。 +- 主题选择写入运行时数据目录下的 `tui/tui-settings.json`;写入失败时面板保持打开并提示,不会丢失当前选择。 +- `/theme` 是 `search-only` 命令:可以直接输入 `/theme` 使用,也能在命令搜索中找到,但不会出现在默认 slash 列表里。 + +## 内置主题 + +| 主题 ID | 名称 | 特点 | +| --- | --- | --- | +| `minimax` | MCode | 默认主题。MiniMax 蓝 + Catppuccin 语法高亮 | +| `midnight` | Midnight | 更深的蓝黑背景,抬高了前景对比度 | +| `graphite` | Graphite | 中性低彩度表面,长输出更安静 | +| `aurora` | Aurora | 偏青绿的次级色阶 | + +每个主题都定义了完整的深色和浅色版本,因此终端切换到浅色时不会出现缺色或错配。`minimax` +的取值与主题系统引入前完全一致,现有用户不会看到任何视觉变化。 + +内置主题的正文色与 `line` 非文本色需要满足 +[WCAG AA 对比度](./tui-foundation.md#主题与终端能力) 基线(正文 4.5:1、非文本 3:1),由 +`packages/tui/test/unit/tui/theme/palettes.test.ts` 对全部主题、全部外观做回归校验。 + +## 配置落点 + +- 文件:`~/.minimax/tui/tui-settings.json`(即运行时数据目录下的 `tui/tui-settings.json`)。 +- 键:`theme`,值是主题 ID,或 `主题ID/light`、`主题ID/dark` 锁定外观。 + +```json +{ + "tuiMode": "regular", + "theme": "midnight" +} +``` + +`theme` 与 `tuiMode` 写在同一份文件里,写入其中一个不会覆盖另一个;文件中的未知键也会原样保留。 +无法识别的 `theme` 值会回退到默认主题,不会阻断启动。 + +## 自定义主题文件 + +MCode 从运行时数据目录下的 `tui/themes/*.json` 读取用户主题。 + +- 一个文件提供一种外观。`aurora.json` 提供深色,`aurora-light.json` 提供浅色;两个文件的 + `name` 相同即组成一个可选主题。 +- 没有提供的外观会回退到默认主题对应外观的调色板,因此只写深色文件也能正常使用。 +- 文件名可以是任意 `.json`,主题 ID 取文件里的 `name`;`name` 不能包含 `/`,也不能与内置主题 + ID 相同(内置主题优先,冲突文件会被忽略并在加载问题里报告)。 +- **热重载**:编辑正在使用的自定义主题文件后,保存即生效,无需重启。 + +### 文件格式 + +```json +{ + "name": "my-theme", + "label": "My Theme", + "description": "A custom MCode palette", + "appearance": "dark", + "vars": { + "brand": "#68c0ff", + "gray": "#949494" + }, + "colors": { + "brand": "brand", + "signal": "brand", + "accent": "brand", + "text": "#d6d6d6", + "muted": "gray", + "line": "gray" + }, + "syntax": { + "text": "#cdd6f4", + "mauve": "#cba6f7", + "overlay2": "#9399b2" + } +} +``` + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `name` | 是 | 主题 ID,`[a-z0-9][a-z0-9._-]{0,63}` | +| `appearance` | 是 | `dark` 或 `light` | +| `label` / `description` | 否 | 面板中显示的名称与说明 | +| `vars` | 否 | 可复用的颜色别名 | +| `colors` | 是 | UI 颜色对象;可以只写要覆盖的字段 | +| `syntax` | 否 | 语法高亮色板;未写的色调沿用默认主题 | + +`colors` 和 `syntax` 的值可以是: + +- **hex 字面量**:`"#68c0ff"` 或三位简写 `"#6cf"`。 +- **`vars` 引用**:在 `colors` / `syntax` 里写 `vars` 中定义的别名名。 +- **空字符串** `""`:使用终端默认色。 + +`vars` 的值只能是 hex 字面量或空字符串——**不支持嵌套引用**(`vars.a` 不能再指向另一个 `vars` 键), +这类写法会在加载时被拒绝并给出定位到具体路径的错误。 + +只写部分 `colors` 字段即可,其余沿用默认主题对应外观的值——这样新增主题只需描述差异。 + +### 可用字段 + +`colors` 支持以下 21 个键: + +`brand`、`wordmarkHighlight`、`wordmarkShadow`、`signal`、`orbit`、`accent`、`markdownHeading`、 +`markdownCode`、`markdownLink`、`userMessageBg`、`diffAddedBg`、`diffRemovedBg`、`text`、`muted`、 +`dim`、`border`、`line`、`success`、`warning`、`error`。 + +`syntax` 支持以下 13 个色调: + +`blue`、`flamingo`、`green`、`mauve`、`overlay2`、`peach`、`pink`、`red`、`sapphire`、`subtext0`、 +`teal`、`text`、`yellow`。 + +自定义主题不参与内置主题的对比度回归校验。终端只支持 16 色时,UI 颜色会映射到终端语义色, +语法色使用固定的 ANSI16 映射(与内置主题一致)。 + +## 终端能力与降级 + +主题只消费探测到的 terminal capability,不改变任何业务语义: + +- 深浅背景优先采用 OSC 11 查询结果,其次是终端的 DEC 2031 上报,最后回退到 `COLORFGBG`; +- 锁定外观后(`/theme` 按 `a`,或配置里写 `主题ID/dark`),终端证据不再改变外观; +- ANSI16 / 256 / truecolor 逐级降级,不支持颜色时仍保持文本层级。 + +## 相关文件 + +| 路径 | 职责 | +| --- | --- | +| `src/tui/theme/contracts.ts` | 主题、调色板、语法色板的类型契约 | +| `src/tui/theme/palettes.ts` | 内置主题定义与默认主题 | +| `src/tui/theme/custom-themes.ts` | 自定义主题文件的发现、校验、加载与热重载 | +| `src/tui/theme/registry.ts` | 内置与自定义主题的合并、解析与回退 | +| `src/tui/theme/controller.ts` | 主题选择、外观锁定、终端明暗探测 | +| `src/tui/theme/runtime.ts` | 颜色与语法色板的活绑定 | +| `src/tui/features/settings/theme-picker.ts` | `/theme` 面板 | +| `src/host/tui-settings.ts` | `tui-settings.json` 读写 | diff --git a/packages/tui/src/host/tui-settings.ts b/packages/tui/src/host/tui-settings.ts index ae91b3b8..57582e73 100644 --- a/packages/tui/src/host/tui-settings.ts +++ b/packages/tui/src/host/tui-settings.ts @@ -1,32 +1,82 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import type { TuiMode } from '../tui/engine/public.js'; const TUI_SETTINGS_FILE = path.join('tui', 'tui-settings.json'); const LEGACY_TUI_SETTINGS_FILE = 'tui-settings.json'; +const THEME_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/iu; interface TuiSettingsDocument { readonly tuiMode?: unknown; + readonly theme?: unknown; } -export function readTuiModeSetting(dataDir: string): TuiMode { +function settingsPath(dataDir: string): string { + return path.join(dataDir, TUI_SETTINGS_FILE); +} + +function readDocument(dataDir: string): TuiSettingsDocument { for (const file of [TUI_SETTINGS_FILE, LEGACY_TUI_SETTINGS_FILE]) { try { const content = readFileSync(path.join(dataDir, file), 'utf8').replace(/^\uFEFF/u, ''); - const document = JSON.parse(content) as TuiSettingsDocument; - return document.tuiMode === 'fullscreen' ? 'fullscreen' : 'regular'; + const parsed = JSON.parse(content) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as TuiSettingsDocument; + } } catch { - // Try the legacy root location before falling back to the default mode. + // Try the legacy root location before falling back to defaults. } } - return 'regular'; + return {}; +} + +function writeDocument(dataDir: string, patch: TuiSettingsDocument): void { + const filePath = settingsPath(dataDir); + const directory = path.dirname(filePath); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + // Merge instead of replacing so writing one preference never drops the other. + const next: Record = { ...readDocument(dataDir), ...patch }; + if (next.tuiMode === undefined) delete next.tuiMode; + if (next.theme === undefined) delete next.theme; + const temporaryPath = path.join(directory, `.tui-settings.json.${process.pid}.${Date.now()}.tmp`); + writeFileSync(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + renameSync(temporaryPath, filePath); +} + +export function readTuiModeSetting(dataDir: string): TuiMode { + return readDocument(dataDir).tuiMode === 'fullscreen' ? 'fullscreen' : 'regular'; } export function writeTuiModeSetting(dataDir: string, mode: TuiMode): void { - mkdirSync(path.dirname(path.join(dataDir, TUI_SETTINGS_FILE)), { recursive: true }); - writeFileSync( - path.join(dataDir, TUI_SETTINGS_FILE), - `${JSON.stringify({ tuiMode: mode }, null, 2)}\n`, - { encoding: 'utf8', mode: 0o600 }, - ); + writeDocument(dataDir, { tuiMode: mode }); +} + +/** + * Saved theme selection. Accepts `id` or `id/light|dark` so a user can pin an + * appearance for terminals that report their background unreliably. Unknown or + * malformed values resolve to the default theme in the controller, never here. + */ +export function readTuiThemeSetting(dataDir: string): string | undefined { + const value = readDocument(dataDir).theme; + if (typeof value !== 'string') return undefined; + const raw = value.trim(); + if (!raw) return undefined; + const pair = /^([a-z0-9][a-z0-9._-]{0,63})\/(light|dark)$/iu.exec(raw); + const pinnedId = pair?.[1]; + const pinnedAppearance = pair?.[2]; + if (pinnedId && pinnedAppearance) { + return `${pinnedId}/${pinnedAppearance.toLowerCase()}`; + } + return THEME_ID.test(raw) ? raw : undefined; +} + +export function writeTuiThemeSetting(dataDir: string, theme: string): void { + const raw = theme.trim(); + if (!THEME_ID.test(raw) && !/^[a-z0-9][a-z0-9._-]{0,63}\/(light|dark)$/iu.test(raw)) { + throw new Error(`"${theme}" is not a valid theme id`); + } + writeDocument(dataDir, { theme: raw }); } diff --git a/packages/tui/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index 81491cb6..6273fc7d 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -174,6 +174,8 @@ export function createTuiApplicationRenderer(options: CreateTuiAppOptions) { themeController = new TuiThemeController({ ui: tui, colorLevel: capabilities.colorLevel, + ...(options.dataDir ? { dataDir: options.dataDir } : {}), + ...(options.theme ? { theme: options.theme } : {}), onDetection: (snapshot) => options.observability?.recordTheme?.(snapshot), }); diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index 38c665b2..c70b0cee 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -48,6 +48,7 @@ import type { TuiGoalFlow } from './controller/product/goal-flow.js'; import { createTuiSessionLifecycleBridge } from './controller/session-lifecycle-bridge.js'; import { parseTuiStatusLineItems as parseStatusItems } from './shell/status-line-items.js'; import { showTuiStatusLineSetup } from './controller/product/status-line-setup.js'; +import { showTuiThemeSetup } from './controller/product/theme-setup.js'; import { TuiCodexHandoffFlow } from './controller/product/codex-handoff-flow.js'; export type { CreateTuiAppOptions, TuiApp, TuiStopOptions }; @@ -511,6 +512,12 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { surface: interactionSurface, persist: options.persistStatusLineItems, }), + showTheme: () => + showTuiThemeSetup({ + controller: themeController, + surface: interactionSurface, + persist: options.persistTheme, + }), keybindings: options.keybindings, getTuiKeybindingOverrides: options.getTuiKeybindingOverrides, saveTuiKeybindingOverrides: options.saveTuiKeybindingOverrides, diff --git a/packages/tui/src/tui/commands/catalog.ts b/packages/tui/src/tui/commands/catalog.ts index 233c7fba..3da70ea0 100644 --- a/packages/tui/src/tui/commands/catalog.ts +++ b/packages/tui/src/tui/commands/catalog.ts @@ -370,6 +370,15 @@ const COMMAND_SOURCES: readonly TuiCommandSource[] = [ visibleWhen: (context) => !context.hasPendingInteraction, unavailableReason: 'Finish the pending interaction before configuring the status line.', }, + { + name: 'theme', + description: 'Choose the MCode color theme and terminal appearance', + category: 'Application', + // Reachable by typing, but kept out of the default slash list: theme + // selection is a preference, not a primary verb. + discoverability: 'search-only', + readiness: 'immediate', + }, { name: 'hotkeys', description: 'View and customize TUI keyboard shortcuts', diff --git a/packages/tui/src/tui/controller/product/command-flow.ts b/packages/tui/src/tui/controller/product/command-flow.ts index 4bdb3af9..695d9bcf 100644 --- a/packages/tui/src/tui/controller/product/command-flow.ts +++ b/packages/tui/src/tui/controller/product/command-flow.ts @@ -89,6 +89,7 @@ export interface TuiCommandFlowOptions { readonly surfaceHost: TuiSurfaceHost; readonly showTasks?: () => void | Promise; readonly showStatusLine?: () => void; + readonly showTheme?: () => void; readonly persistTuiMode?: (mode: TuiMode) => void; readonly queueEnabled: boolean; readonly liveRunId: () => string | undefined; @@ -1192,6 +1193,7 @@ export class TuiCommandFlow { checkin: async () => this.runDailyCheckinCommand(), settings: () => this.showSettingsPicker(), statusline: () => this.options.showStatusLine?.(), + theme: () => this.options.showTheme?.(), hotkeys: () => this.showHotkeysPicker(), reload: async () => { if (!this.options.reloadTui) { diff --git a/packages/tui/src/tui/controller/product/theme-setup.ts b/packages/tui/src/tui/controller/product/theme-setup.ts new file mode 100644 index 00000000..168e9238 --- /dev/null +++ b/packages/tui/src/tui/controller/product/theme-setup.ts @@ -0,0 +1,44 @@ +import { + TuiThemePicker, + type TuiThemeAppearanceChoice, +} from '../../features/settings/theme-picker.js'; +import type { TuiInteractionSurface } from '../../shell/interaction-surface.js'; +import type { TuiThemeController } from '../../theme/controller.js'; + +/** Persist an appearance pin as `id/light` so the next launch restores it. */ +export function themeSettingValue(themeId: string, appearance: TuiThemeAppearanceChoice): string { + return appearance === 'auto' ? themeId : `${themeId}/${appearance}`; +} + +export function showTuiThemeSetup(options: { + readonly controller: TuiThemeController; + readonly surface: Pick; + readonly persist?: (theme: string) => void; +}): void { + const { controller, surface } = options; + const picker = new TuiThemePicker({ + themes: controller.listThemes(), + currentThemeId: controller.selectedThemeId(), + currentAppearance: controller.snapshot().appearance, + appearanceOverride: controller.appearanceOverrideValue(), + preview: (themeId) => { + controller.previewTheme(themeId); + }, + setAppearance: (choice) => { + controller.setAppearanceOverride(choice === 'auto' ? undefined : choice); + }, + save: (themeId, appearance) => { + // Commit before persisting so a write failure leaves the previous theme + // active and the picker can show the error without losing the selection. + if (!controller.setTheme(themeId)) { + throw new Error(`Theme "${themeId}" is no longer available`); + } + options.persist?.(themeSettingValue(themeId, appearance)); + }, + onClose: () => { + surface.close(picker); + }, + requestRender: () => surface.request(), + }); + surface.show(picker); +} diff --git a/packages/tui/src/tui/features/settings/theme-picker.ts b/packages/tui/src/tui/features/settings/theme-picker.ts new file mode 100644 index 00000000..723f874b --- /dev/null +++ b/packages/tui/src/tui/features/settings/theme-picker.ts @@ -0,0 +1,248 @@ +import { panelLayout, renderPanelFrame } from '../../widgets/panel-frame.js'; +import { getKeybindings, matchesKey } from '../../engine/public.js'; +import type { Component } from '../../rendering/component.js'; +import { visibleWidth } from '../../rendering/text.js'; +import { tuiChalk as chalk, tuiColors as colors } from '../../theme/runtime.js'; +import type { + TuiResolvedAppearance, + TuiThemeColors, + TuiThemeDefinition, +} from '../../theme/contracts.js'; + +export type TuiThemeAppearanceChoice = 'auto' | TuiResolvedAppearance; + +export interface TuiThemePickerOptions { + readonly themes: readonly TuiThemeDefinition[]; + readonly currentThemeId: string; + readonly currentAppearance: TuiResolvedAppearance; + readonly appearanceOverride: TuiResolvedAppearance | undefined; + /** Apply without persisting, so moving the cursor previews live. */ + readonly preview: (themeId: string) => void; + readonly setAppearance: (choice: TuiThemeAppearanceChoice) => void; + readonly save: (themeId: string, appearance: TuiThemeAppearanceChoice) => Promise | void; + readonly onClose: () => void; + readonly requestRender: () => void; +} + +/** + * Theme chooser. Cursor movement previews each palette immediately so the user + * can judge a theme in the real transcript behind the panel; `Enter` persists + * and `Esc` restores the theme that was active when the picker opened. + */ +export class TuiThemePicker implements Component { + private selectedIndex: number; + private originalThemeId: string; + private originalAppearance: TuiThemeAppearanceChoice; + private appearance: TuiThemeAppearanceChoice; + private busy = false; + private error = false; + private disposed = false; + + constructor(private readonly options: TuiThemePickerOptions) { + this.originalThemeId = options.currentThemeId; + this.originalAppearance = options.appearanceOverride ?? 'auto'; + this.appearance = this.originalAppearance; + const index = options.themes.findIndex((theme) => theme.id === options.currentThemeId); + this.selectedIndex = index >= 0 ? index : 0; + } + + handleInput(data: string): void { + if (this.busy || this.disposed) return; + const keys = getKeybindings(); + if (keys.matches(data, 'tui.select.cancel') || matchesKey(data, 'ctrl+c')) { + this.restore(); + this.options.onClose(); + return; + } + if (keys.matches(data, 'tui.select.confirm')) { + this.commit(); + return; + } + const themes = this.options.themes; + if (themes.length === 0) return; + if (keys.matches(data, 'tui.select.up')) { + this.selectedIndex = (this.selectedIndex - 1 + themes.length) % themes.length; + } else if (keys.matches(data, 'tui.select.down')) { + this.selectedIndex = (this.selectedIndex + 1) % themes.length; + } else if (matchesKey(data, 'a')) { + this.appearance = cycleAppearance(this.appearance); + this.options.setAppearance(this.appearance); + } else { + return; + } + const focused = themes[this.selectedIndex]; + if (focused) this.options.preview(focused.id); + this.options.requestRender(); + } + + dispose(): void { + this.disposed = true; + } + + invalidate(): void {} + + render(width: number): string[] { + return this.renderViewport(width, 20); + } + + renderViewport(width: number, height: number): string[] { + const rows = Math.max(0, Math.floor(height)); + if (width <= 0 || rows === 0) return []; + const themes = this.options.themes; + const layout = panelLayout(width, rows, footerText(width, this.busy)); + if (themes.length === 0) { + return renderPanelFrame( + { title: 'Theme', body: ['No themes available.'], footer: footerText(width, false) }, + width, + rows, + ); + } + const focused = themes[Math.min(this.selectedIndex, themes.length - 1)]; + if (!focused) return []; + const active = focused.id === this.originalThemeId; + const nameWidth = Math.max( + ...themes.map((theme) => visibleWidth(theme.label)), + visibleWidth('Appearance'), + ); + const swatch = renderSwatch(focused, this.resolvedAppearance()); + const detail = [ + `${chalk.hex(colors.muted)('Appearance')} ${appearanceLabel(this.appearance, this.options.currentAppearance)}`, + `${chalk.hex(colors.muted)('Source')} ${focused.source === 'custom' ? 'custom file' : 'built-in'}`, + ...(focused.description + ? [`${chalk.hex(colors.muted)('About')} ${focused.description}`] + : []), + `${chalk.hex(colors.muted)('Palette')} ${swatch}`, + ]; + if (this.error) detail.push(chalk.hex(colors.error)('Could not save the theme selection.')); + + if (rows < 10) { + return layout.render({ + title: 'Theme', + body: [ + renderRow( + focused.label, + active, + this.options.currentThemeId === focused.id, + nameWidth, + this.resolvedAppearance(), + focused, + ), + ...(layout.bodyHeight >= 4 ? detail.slice(0, 2) : []), + ], + }); + } + + const listRows = Math.max(1, Math.min(themes.length, layout.bodyHeight - detail.length - 1)); + const start = Math.max( + 0, + Math.min(this.selectedIndex - listRows + 1, themes.length - listRows), + ); + return layout.render({ + title: 'Theme', + meta: `${themes.length} available`, + body: [ + ...themes + .slice(start, start + listRows) + .map((theme) => + renderRow( + theme.label, + theme.id === this.originalThemeId, + theme.id === focused.id, + nameWidth, + this.resolvedAppearance(), + theme, + ), + ), + ...detail, + ], + }); + } + + private resolvedAppearance(): TuiResolvedAppearance { + return this.appearance === 'auto' ? this.options.currentAppearance : this.appearance; + } + + private restore(): void { + const theme = this.options.themes.find((candidate) => candidate.id === this.originalThemeId); + if (theme) this.options.preview(this.originalThemeId); + this.appearance = this.originalAppearance; + this.options.setAppearance(this.originalAppearance); + } + + private async commit(): Promise { + const theme = this.options.themes[this.selectedIndex]; + if (!theme) return; + if (theme.id === this.originalThemeId && this.appearance === this.originalAppearance) { + this.options.onClose(); + return; + } + this.busy = true; + this.error = false; + this.options.requestRender(); + try { + await this.options.save(theme.id, this.appearance); + this.originalThemeId = theme.id; + this.originalAppearance = this.appearance; + if (!this.disposed) this.options.onClose(); + } catch { + this.error = true; + } finally { + this.busy = false; + if (!this.disposed) this.options.requestRender(); + } + } +} + +function cycleAppearance(current: TuiThemeAppearanceChoice): TuiThemeAppearanceChoice { + if (current === 'auto') return 'light'; + if (current === 'light') return 'dark'; + return 'auto'; +} + +function appearanceLabel( + choice: TuiThemeAppearanceChoice, + detected: TuiResolvedAppearance, +): string { + if (choice === 'auto') return `auto (${detected})`; + return choice === 'light' ? 'light (pinned)' : 'dark (pinned)'; +} + +function renderSwatch(theme: TuiThemeDefinition, appearance: TuiResolvedAppearance): string { + const palette = appearance === 'light' ? theme.light : theme.dark; + const roles = [ + 'brand', + 'accent', + 'markdownHeading', + 'markdownCode', + 'success', + 'warning', + 'error', + ] as const satisfies readonly (keyof TuiThemeColors)[]; + return roles.map((role) => chalk.bgHex(palette.colors[role])(' ')).join(''); +} + +function renderRow( + label: string, + saved: boolean, + focused: boolean, + nameWidth: number, + appearance: TuiResolvedAppearance, + theme: TuiThemeDefinition, +): string { + const marker = focused ? '›' : ' '; + const name = focused ? chalk.bold.hex(colors.signal)(label) : chalk.hex(colors.text)(label); + // Pad both columns so the swatches line up regardless of which row is current. + const pad = ' '.repeat(Math.max(0, nameWidth - visibleWidth(label))); + const badge = saved ? chalk.hex(colors.success)(CURRENT_BADGE.trim()) : ''; + const badgePad = ' '.repeat(Math.max(0, visibleWidth(CURRENT_BADGE) - visibleWidth(badge))); + return `${marker} ${name}${pad} ${badge}${badgePad} ${renderSwatch(theme, appearance)}`; +} + +const CURRENT_BADGE = ' current '; + +function footerText(width: number, busy: boolean): string { + if (busy) return 'Saving…'; + return width >= 64 + ? '↑↓ preview · a appearance · Enter save · Esc cancel' + : '↑↓ · a · Enter · Esc'; +} diff --git a/packages/tui/src/tui/launcher.ts b/packages/tui/src/tui/launcher.ts index 890d721c..19dd6e0b 100644 --- a/packages/tui/src/tui/launcher.ts +++ b/packages/tui/src/tui/launcher.ts @@ -58,7 +58,12 @@ import type { McodeUpdateApplication } from '../update/application.js'; import { tuiErrorDiagnostic } from '../user-facing-failure.js'; import { getConfig, resetConfig, writeTuiStatusLineSetting, type MavisRegion } from '@mavis/config'; import { markLoginRestartHandoff } from './login-restart-handoff.js'; -import { readTuiModeSetting, writeTuiModeSetting } from '../host/tui-settings.js'; +import { + readTuiModeSetting, + readTuiThemeSetting, + writeTuiModeSetting, + writeTuiThemeSetting, +} from '../host/tui-settings.js'; import { schedulePendingMcodePrefixUpdate } from '../update/prefix-update.js'; import { MCODE_TUI_RESULT_PATH_ENV } from './automation/result-writer.js'; import { startTuiStartupStatus, type TuiStartupStatus } from './startup-status.js'; @@ -77,6 +82,7 @@ export interface LaunchTuiOptions { dataDir?: string; terminal?: Terminal; tuiMode?: TuiMode; + theme?: string; externalEditorCommand?: string; resumeDraftAfterLogin?: boolean; lane?: string; @@ -131,6 +137,8 @@ export interface LaunchTuiDependencies { createIncidentReporter?: typeof createTuiIncidentReporter; readTuiMode?: typeof readTuiModeSetting; writeTuiMode?: typeof writeTuiModeSetting; + readTuiTheme?: typeof readTuiThemeSetting; + writeTuiTheme?: typeof writeTuiThemeSetting; createSharedAuthSession?: typeof createMcodeSharedAuthSession; createAuthApplication?: typeof createDefaultMcodeAuthApplication; } @@ -168,6 +176,7 @@ export async function launchTui( const dataDir = options.dataDir ?? (await (dependencies.prepareDataDir ?? prepareTuiDataDir)()); const baseTerminal = options.terminal ?? new ProcessTerminal(); const tuiMode = options.tuiMode ?? (dependencies.readTuiMode ?? readTuiModeSetting)(dataDir); + const theme = options.theme ?? (dependencies.readTuiTheme ?? readTuiThemeSetting)(dataDir); const terminalCapabilities = detectProcessTerminalCapabilities(); const authEnvironment = resolveMcodeAuthEnvironment({ runtimeRegion: process.env.MAVIS_REGION === 'en' ? 'en' : 'cn', @@ -398,6 +407,9 @@ export async function launchTui( terminal, tuiMode, persistTuiMode: (mode) => (dependencies.writeTuiMode ?? writeTuiModeSetting)(dataDir, mode), + ...(theme ? { theme } : {}), + persistTheme: (value) => + (dependencies.writeTuiTheme ?? writeTuiThemeSetting)(dataDir, value), persistStatusLineItems: (items) => writeTuiStatusLineSetting(dataDir, items), externalEditorCommand: options.externalEditorCommand, observability, diff --git a/packages/tui/src/tui/theme/contracts.ts b/packages/tui/src/tui/theme/contracts.ts index 862bd89a..f8067c61 100644 --- a/packages/tui/src/tui/theme/contracts.ts +++ b/packages/tui/src/tui/theme/contracts.ts @@ -32,12 +32,97 @@ export interface TuiThemeColors { readonly error: string; } +/** + * Syntax tones drive `cli-highlight` token mapping. They are kept beside — not + * inside — {@link TuiThemeColors} so terminal ANSI16 role resolution keeps + * iterating a flat list of paintable colors. + */ +export interface TuiThemeSyntaxTones { + readonly blue: string; + readonly flamingo: string; + readonly green: string; + readonly mauve: string; + readonly overlay2: string; + readonly peach: string; + readonly pink: string; + readonly red: string; + readonly sapphire: string; + readonly subtext0: string; + readonly teal: string; + readonly text: string; + readonly yellow: string; +} + +export const TUI_SYNTAX_TONE_NAMES = [ + 'blue', + 'flamingo', + 'green', + 'mauve', + 'overlay2', + 'peach', + 'pink', + 'red', + 'sapphire', + 'subtext0', + 'teal', + 'text', + 'yellow', +] as const satisfies readonly (keyof TuiThemeSyntaxTones)[]; + +export type TuiThemeSyntaxTone = (typeof TUI_SYNTAX_TONE_NAMES)[number]; + +export const TUI_THEME_COLOR_NAMES = [ + 'brand', + 'wordmarkHighlight', + 'wordmarkShadow', + 'signal', + 'orbit', + 'accent', + 'markdownHeading', + 'markdownCode', + 'markdownLink', + 'userMessageBg', + 'diffAddedBg', + 'diffRemovedBg', + 'text', + 'muted', + 'dim', + 'border', + 'line', + 'success', + 'warning', + 'error', +] as const satisfies readonly (keyof TuiThemeColors)[]; + +export type TuiThemeColorName = (typeof TUI_THEME_COLOR_NAMES)[number]; + export interface TuiThemePalette { readonly id: string; readonly appearance: TuiResolvedAppearance; readonly colors: TuiThemeColors; + readonly syntax: TuiThemeSyntaxTones; +} + +export type TuiThemeSource = 'builtin' | 'custom'; + +/** + * A user-selectable named theme. Every theme ships both appearances so a + * terminal that switches to light never falls back to a foreign palette. + */ +export interface TuiThemeDefinition { + readonly id: string; + readonly label: string; + readonly description?: string; + readonly source: TuiThemeSource; + readonly dark: TuiThemePalette; + readonly light: TuiThemePalette; + /** Absolute path for custom themes, used to offer file-level feedback. */ + readonly filePath?: string; + /** Validation diagnostics for partially loaded custom themes. */ + readonly issues?: readonly string[]; } export interface TuiThemeSnapshot extends TuiThemeDetection { readonly colorLevel: TuiColorLevel; + readonly themeId: string; } diff --git a/packages/tui/src/tui/theme/controller.ts b/packages/tui/src/tui/theme/controller.ts index c7063140..ca7d3f3c 100644 --- a/packages/tui/src/tui/theme/controller.ts +++ b/packages/tui/src/tui/theme/controller.ts @@ -1,12 +1,20 @@ -import { applyTuiRenderTheme } from './runtime.js'; +import { applyTuiRenderTheme, paletteSignature } from './runtime.js'; import type { TuiColorLevel, TuiResolvedAppearance, + TuiThemeDefinition, TuiThemeDetection, TuiThemeSnapshot, } from './contracts.js'; import { type RgbColor, appearanceFromRgb, resolveEnvironmentAppearance } from './detection.js'; -import { MINIMAX_CODE_DARK_THEME, MINIMAX_CODE_LIGHT_THEME } from './palettes.js'; +import { DEFAULT_THEME_ID } from './palettes.js'; +import { + loadCustomThemes, + watchCustomThemes, + type TuiThemeLoadIssue, + type TuiThemeLoadResult, +} from './custom-themes.js'; +import { TuiThemeRegistry } from './registry.js'; import type { TUI } from '../engine/public.js'; export type TuiThemeUi = Pick< @@ -21,7 +29,12 @@ export interface TuiThemeControllerOptions { readonly colorLevel: TuiColorLevel; readonly env?: Readonly>; readonly queryTimeoutMs?: number; + /** dataDir enables custom theme discovery and hot reload. */ + readonly dataDir?: string; + /** Persisted or CLI-supplied selection, e.g. `aurora` or `aurora/dark`. */ + readonly theme?: string; readonly onDetection?: (detection: TuiThemeSnapshot) => void; + readonly onThemesChanged?: (themes: readonly TuiThemeDefinition[]) => void; } export class TuiThemeController { @@ -30,9 +43,12 @@ export class TuiThemeController { private readonly env: Readonly>; private readonly queryTimeoutMs: number; private readonly onDetection: ((detection: TuiThemeSnapshot) => void) | undefined; + private readonly onThemesChanged: ((themes: readonly TuiThemeDefinition[]) => void) | undefined; + private readonly registry = new TuiThemeRegistry(); private state: TuiThemeSnapshot; private readonly listeners = new Set<(snapshot: TuiThemeSnapshot) => void>(); private stopTracking: (() => void) | undefined; + private stopThemeWatch: (() => void) | undefined; private refreshSequence = 0; private started = false; /** @@ -40,6 +56,12 @@ export class TuiThemeController { * Session, so it outranks `COLORFGBG`, which is only a process-start snapshot. */ private reportedAppearance: TuiResolvedAppearance | undefined; + /** When set, auto-detection stops overriding the appearance. */ + private appearanceOverride: TuiResolvedAppearance | undefined; + /** Latest terminal/env verdict, never replaced by {@link appearanceOverride}. */ + private latestDetection: TuiThemeDetection; + /** Signature of the palette currently bound to the render layer. */ + private activeSignature = ''; constructor(options: TuiThemeControllerOptions) { this.ui = options.ui; @@ -47,10 +69,29 @@ export class TuiThemeController { this.env = options.env ?? process.env; this.queryTimeoutMs = options.queryTimeoutMs ?? 250; this.onDetection = options.onDetection; + this.onThemesChanged = options.onThemesChanged; + + if (options.dataDir) { + this.applyLoadResult(loadCustomThemes(options.dataDir)); + this.startThemeWatch(options.dataDir); + } + const selection = this.registry.resolveSelection(options.theme); + this.appearanceOverride = selection.appearanceOverride; + const theme = this.registry.select(selection.themeId) ?? this.registry.selected(); + + const detected = resolveEnvironmentAppearance(this.env); + // Keep the terminal's own verdict separate from the pinned appearance so + // clearing the pin can restore it without waiting for another query. + this.latestDetection = detected; this.state = { - ...resolveEnvironmentAppearance(this.env), + ...detected, + // A pinned appearance must win on the very first frame, not only after the + // first terminal query resolves. + appearance: this.appearanceOverride ?? detected.appearance, colorLevel: this.colorLevel, + themeId: theme.id, }; + this.activeSignature = paletteSignature(this.registry.paletteFor(this.state.appearance)); this.onDetection?.(this.snapshot()); this.applyRenderTheme(); } @@ -64,6 +105,53 @@ export class TuiThemeController { return () => this.listeners.delete(listener); } + listThemes(): readonly TuiThemeDefinition[] { + return this.registry.list(); + } + + themeIssues(): readonly TuiThemeLoadIssue[] { + return this.registry.issuesList(); + } + + selectedThemeId(): string { + return this.registry.selectedIdValue(); + } + + /** + * Apply a theme by id without persisting it. Returns the resolved definition, + * or `undefined` when the id is unknown so the caller can keep the previous + * theme and report the failure. + */ + setTheme(id: string): TuiThemeDefinition | undefined { + const resolved = this.registry.select(id); + if (!resolved) return undefined; + this.commitTheme(); + return resolved; + } + + /** Switch the active palette without touching the saved selection. */ + previewTheme(id: string): TuiThemeDefinition | undefined { + const resolved = this.registry.select(id); + if (!resolved) return undefined; + this.applyRenderTheme(); + this.emit(); + return resolved; + } + + /** + * Pin the appearance (`light` / `dark`) or return to terminal-driven + * detection when `value` is undefined. + */ + setAppearanceOverride(value: string | undefined): TuiResolvedAppearance | undefined { + this.appearanceOverride = value === 'light' || value === 'dark' ? value : undefined; + this.commitDetection(); + return this.appearanceOverride; + } + + appearanceOverrideValue(): TuiResolvedAppearance | undefined { + return this.appearanceOverride; + } + async start(): Promise { if (!this.started) { this.started = true; @@ -89,7 +177,44 @@ export class TuiThemeController { .queryTerminalBackgroundColor({ timeoutMs: this.queryTimeoutMs }) .catch(() => undefined); if (sequence !== this.refreshSequence) return; - this.applyDetection(this.resolveDetection(background)); + this.commitDetection(this.resolveDetection(background)); + } + + private commitDetection(detection?: TuiThemeDetection): void { + if (detection) this.latestDetection = detection; + const base = this.latestDetection; + const appearance = this.appearanceOverride ?? base.appearance; + const next: TuiThemeSnapshot = { + ...base, + // A pinned appearance wins over terminal evidence. + appearance, + colorLevel: this.colorLevel, + themeId: this.registry.selectedIdValue(), + }; + + // Compare the palette content, not just its id: reloading a custom theme + // file in place keeps the same id while every color may have changed. + const signature = paletteSignature(this.registry.paletteFor(appearance)); + const renderingChanged = + signature !== this.activeSignature || + next.colorLevel !== this.state.colorLevel || + next.themeId !== this.state.themeId; + this.state = next; + this.onDetection?.(this.snapshot()); + if (!renderingChanged) return; + this.activeSignature = signature; + this.applyRenderTheme(); + this.emit(); + } + + private commitTheme(): void { + this.commitDetection(); + } + + private applyLoadResult(result: TuiThemeLoadResult): void { + this.registry.applyLoadResult(result); + this.stopThemeWatch?.(); + this.onThemesChanged?.(this.registry.list()); } /** @@ -121,29 +246,22 @@ export class TuiThemeController { this.refreshSequence += 1; this.stopTracking?.(); this.stopTracking = undefined; + this.stopThemeWatch?.(); + this.stopThemeWatch = undefined; this.ui.setTerminalColorSchemeNotifications(false); this.listeners.clear(); } - private applyDetection(detection: TuiThemeDetection): void { - const next: TuiThemeSnapshot = { - ...detection, - colorLevel: this.colorLevel, - }; - const renderingChanged = - next.appearance !== this.state.appearance || next.colorLevel !== this.state.colorLevel; - this.state = next; - this.onDetection?.(this.snapshot()); - if (!renderingChanged) return; - this.applyRenderTheme(); - this.emit(); + private startThemeWatch(dataDir: string): void { + this.stopThemeWatch = watchCustomThemes(dataDir, (result) => { + this.registry.applyLoadResult(result); + this.onThemesChanged?.(this.registry.list()); + this.commitDetection(); + }); } private applyRenderTheme(): void { - applyTuiRenderTheme( - this.state.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME, - this.state.colorLevel, - ); + applyTuiRenderTheme(this.registry.paletteFor(this.state.appearance), this.state.colorLevel); } private emit(): void { @@ -158,3 +276,5 @@ export class TuiThemeController { }); } } + +export { DEFAULT_THEME_ID }; diff --git a/packages/tui/src/tui/theme/custom-themes.ts b/packages/tui/src/tui/theme/custom-themes.ts new file mode 100644 index 00000000..453a8c72 --- /dev/null +++ b/packages/tui/src/tui/theme/custom-themes.ts @@ -0,0 +1,300 @@ +import { readdirSync, readFileSync, statSync, watch, type FSWatcher } from 'node:fs'; +import path from 'node:path'; +import { Ajv, type ValidateFunction } from 'ajv'; +import { + TUI_SYNTAX_TONE_NAMES, + TUI_THEME_COLOR_NAMES, + type TuiResolvedAppearance, + type TuiThemeColors, + type TuiThemeDefinition, + type TuiThemePalette, + type TuiThemeSyntaxTones, +} from './contracts.js'; +import { BUILT_IN_THEMES, DEFAULT_THEME_ID, defaultPalette } from './palettes.js'; + +const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/iu; +const THEME_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/iu; + +/** + * A color entry is a hex literal, the empty string for "terminal default", or + * a `vars` name. References are resolved after schema validation, so the schema + * only has to accept the identifier shape; an unresolvable name fails with a + * readable message from {@link resolveValue}. + */ +const COLOR_SCHEMA = { + anyOf: [ + { type: 'string', pattern: '^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$' }, + { type: 'string', maxLength: 0 }, + { type: 'string', pattern: '^[A-Za-z_][A-Za-z0-9_-]*$' }, + ], +} as const; + +/** + * `vars` holds literal colors only. Allowing an identifier here would let a + * nested reference pass validation and then fail during resolution with a + * confusing message, so reject it up front instead. + */ +const VAR_SCHEMA = { + anyOf: [ + { type: 'string', pattern: '^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$' }, + { type: 'string', maxLength: 0 }, + ], +} as const; + +const THEME_FILE_SCHEMA = { + type: 'object', + required: ['name', 'appearance', 'colors'], + additionalProperties: false, + properties: { + $schema: { type: 'string' }, + name: { type: 'string', pattern: THEME_ID.source }, + label: { type: 'string', minLength: 1, maxLength: 40 }, + description: { type: 'string', maxLength: 120 }, + appearance: { enum: ['dark', 'light'] }, + vars: { + type: 'object', + additionalProperties: VAR_SCHEMA, + }, + colors: { + type: 'object', + additionalProperties: false, + properties: Object.fromEntries(TUI_THEME_COLOR_NAMES.map((name) => [name, COLOR_SCHEMA])), + }, + syntax: { + type: 'object', + additionalProperties: false, + properties: Object.fromEntries(TUI_SYNTAX_TONE_NAMES.map((name) => [name, COLOR_SCHEMA])), + }, + }, +} as const; + +interface CustomThemeDocument { + readonly name: string; + readonly label?: string; + readonly description?: string; + readonly appearance: TuiResolvedAppearance; + readonly vars?: Readonly>; + readonly colors: Readonly>>; + readonly syntax?: Readonly>; +} + +export interface TuiThemeLoadIssue { + readonly filePath: string; + readonly message: string; +} + +export interface TuiThemeLoadResult { + readonly themes: readonly TuiThemeDefinition[]; + readonly issues: readonly TuiThemeLoadIssue[]; +} + +/** Directory MCode scans for user-authored theme files. */ +export function customThemesDirectory(dataDir: string): string { + return path.join(dataDir, 'tui', 'themes'); +} + +let cachedValidator: ValidateFunction | undefined; + +function themeFileValidator(): ValidateFunction { + if (cachedValidator) return cachedValidator; + const validator = new Ajv({ allErrors: true, strict: false }).compile(THEME_FILE_SCHEMA); + cachedValidator = validator; + return validator; +} + +interface StagedVariant extends TuiThemePalette { + readonly filePath: string; +} + +/** + * Resolve one `colors` / `syntax` value: an explicit hex, a `vars` reference, + * or the empty string meaning "terminal default". + */ +function resolveValue( + value: string, + vars: Readonly>, + fallback: string, + filePath: string, +): string { + if (value === '') return ''; + if (HEX_COLOR.test(value)) return value.toUpperCase(); + const referenced = vars[value]; + if (referenced === undefined) { + throw new Error(`"${value}" is not a hex color or a name defined in "vars"`); + } + if (referenced === '') return ''; + if (!HEX_COLOR.test(referenced)) { + throw new Error(`vars.${value} = "${referenced}" is not a hex color`); + } + void filePath; + return referenced.toUpperCase(); +} + +function buildVariant(document: CustomThemeDocument, filePath: string): StagedVariant { + const vars = document.vars ?? {}; + const base = defaultPalette(document.appearance); + const colors = { ...base.colors } as Record; + for (const [key, value] of Object.entries(document.colors)) { + const role = key as keyof TuiThemeColors; + const raw = value as string; + colors[role] = resolveValue(raw, vars, colors[role], filePath) || colors[role]; + } + const syntax = { ...base.syntax } as Record; + for (const [key, value] of Object.entries(document.syntax ?? {})) { + const tone = key as keyof TuiThemeSyntaxTones; + syntax[tone] = resolveValue(value as string, vars, syntax[tone], filePath) || syntax[tone]; + } + return { + id: document.name, + appearance: document.appearance, + colors: Object.freeze(colors) as TuiThemeColors, + syntax: Object.freeze(syntax) as TuiThemeSyntaxTones, + filePath, + }; +} + +function readThemeFile(filePath: string): CustomThemeDocument | Error { + try { + const raw = JSON.parse(readFileSync(filePath, 'utf8').replace(/^\uFEFF/u, '')) as unknown; + const validate = themeFileValidator(); + if (!validate(raw)) { + const detail = (validate.errors ?? []) + .slice(0, 4) + .map((error) => `${error.instancePath || '/'} ${error.message ?? 'is invalid'}`.trim()) + .join('; '); + return new Error(detail || 'does not match the theme schema'); + } + return raw as CustomThemeDocument; + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } +} + +/** + * Load every `*.json` under `/tui/themes`. + * + * A theme is assembled from one file per appearance: `aurora.json` plus + * `aurora-light.json` form a single selectable `aurora` theme. Any appearance a + * custom theme does not provide falls back to the default palette so selecting + * the theme never breaks rendering on a terminal of the other appearance. + * Built-in ids always win over a same-named file so a stray file cannot shadow + * a shipped theme. + */ +export function loadCustomThemes(dataDir: string): TuiThemeLoadResult { + const directory = customThemesDirectory(dataDir); + let entries: string[]; + try { + entries = readdirSync(directory); + } catch { + return { themes: [], issues: [] }; + } + + const builtinIds = new Set(BUILT_IN_THEMES.map((theme) => theme.id)); + const staged = new Map< + string, + { label?: string; description?: string; variants: StagedVariant[] } + >(); + const issues: TuiThemeLoadIssue[] = []; + + for (const entry of entries.sort()) { + if (!entry.toLowerCase().endsWith('.json')) continue; + const filePath = path.join(directory, entry); + try { + if (!statSync(filePath).isFile()) continue; + } catch { + continue; + } + const document = readThemeFile(filePath); + if (document instanceof Error) { + issues.push({ filePath, message: document.message }); + continue; + } + if (builtinIds.has(document.name)) { + issues.push({ + filePath, + message: `"${document.name}" is a built-in theme id and is ignored`, + }); + continue; + } + let variant: StagedVariant; + try { + variant = buildVariant(document, filePath); + } catch (error) { + issues.push({ filePath, message: error instanceof Error ? error.message : String(error) }); + continue; + } + const group = staged.get(document.name) ?? { variants: [] }; + if (document.label) group.label = document.label; + if (document.description) group.description = document.description; + const existing = group.variants.findIndex( + (candidate) => candidate.appearance === variant.appearance, + ); + if (existing >= 0) group.variants.splice(existing, 1, variant); + else group.variants.push(variant); + staged.set(document.name, group); + } + + const themes: TuiThemeDefinition[] = []; + for (const [id, group] of staged) { + const supplied = new Map(group.variants.map((variant) => [variant.appearance, variant])); + const pick = (appearance: TuiResolvedAppearance): TuiThemePalette => { + const variant = supplied.get(appearance); + if (variant) return variant; + // The file did not supply this appearance; borrow the default palette so + // selecting the theme still renders correctly on a terminal of that kind. + return { ...defaultPalette(appearance), id }; + }; + themes.push( + Object.freeze({ + id, + label: group.label ?? id, + ...(group.description ? { description: group.description } : {}), + source: 'custom', + dark: pick('dark'), + light: pick('light'), + filePath: group.variants[0]?.filePath, + }), + ); + } + + return { themes, issues }; +} + +/** + * Watch the custom theme directory and re-run the loader on change so editing a + * theme file updates the TUI without a restart. The watcher is a convenience: + * a failure to watch (missing directory, inotify limit) must never block the + * TUI, so errors collapse into a no-op. + */ +export function watchCustomThemes( + dataDir: string, + onChange: (result: TuiThemeLoadResult) => void, +): () => void { + const directory = customThemesDirectory(dataDir); + let watcher: FSWatcher | undefined; + let timer: NodeJS.Timeout | undefined; + const fire = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = undefined; + try { + onChange(loadCustomThemes(dataDir)); + } catch { + // A reload failure must never break the running session. + } + }, 150); + timer.unref?.(); + }; + try { + watcher = watch(directory, { persistent: false }, fire); + watcher.on('error', () => undefined); + } catch { + watcher = undefined; + } + return () => { + if (timer) clearTimeout(timer); + watcher?.close(); + }; +} + +export { DEFAULT_THEME_ID }; diff --git a/packages/tui/src/tui/theme/palettes.ts b/packages/tui/src/tui/theme/palettes.ts index bcdcfeaf..506be33e 100644 --- a/packages/tui/src/tui/theme/palettes.ts +++ b/packages/tui/src/tui/theme/palettes.ts @@ -1,9 +1,33 @@ -import type { TuiThemePalette } from './contracts.js'; +import type { + TuiResolvedAppearance, + TuiThemeColors, + TuiThemeDefinition, + TuiThemeSyntaxTones, +} from './contracts.js'; +import { CATPPUCCIN_SYNTAX_TONES } from './syntax.js'; -export const MINIMAX_CODE_DARK_THEME: TuiThemePalette = Object.freeze({ - id: 'minimax', - appearance: 'dark', - colors: Object.freeze({ +function palette( + id: string, + appearance: TuiResolvedAppearance, + colors: TuiThemeColors, + syntax: TuiThemeSyntaxTones, +) { + return Object.freeze({ + id, + appearance, + colors: Object.freeze(colors), + syntax: Object.freeze(syntax), + }); +} + +/** + * Default MCode palette. The values are frozen in place so a theme switch can + * never mutate a palette another theme still references. + */ +export const MINIMAX_CODE_DARK_THEME = palette( + 'minimax', + 'dark', + { brand: '#68C0FF', wordmarkHighlight: '#93D2FF', wordmarkShadow: '#3DAEFF', @@ -24,13 +48,14 @@ export const MINIMAX_CODE_DARK_THEME: TuiThemePalette = Object.freeze({ success: '#28C567', warning: '#FFC340', error: '#FF5E6C', - }), -}); + }, + CATPPUCCIN_SYNTAX_TONES.dark, +); -export const MINIMAX_CODE_LIGHT_THEME: TuiThemePalette = Object.freeze({ - id: 'minimax', - appearance: 'light', - colors: Object.freeze({ +export const MINIMAX_CODE_LIGHT_THEME = palette( + 'minimax', + 'light', + { brand: '#0094FC', wordmarkHighlight: '#3DAEFF', wordmarkShadow: '#0077D9', @@ -51,5 +76,314 @@ export const MINIMAX_CODE_LIGHT_THEME: TuiThemePalette = Object.freeze({ success: '#008635', warning: '#916300', error: '#E31937', - }), + }, + CATPPUCCIN_SYNTAX_TONES.light, +); + +/** Default palette for the active appearance. */ +export function defaultPalette(appearance: TuiResolvedAppearance) { + return appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME; +} + +// --------------------------------------------------------------------------- +// Built-in named themes +// --------------------------------------------------------------------------- + +/** + * Midnight keeps the MCode blue but deepens the background toward a blue-black + * and lifts foreground contrast, which suits high-DPI and OLED terminals. + */ +const MIDNIGHT: TuiThemeDefinition = Object.freeze({ + id: 'midnight', + label: 'Midnight', + description: 'Deep blue-black with lifted contrast', + source: 'builtin', + dark: palette( + 'midnight', + 'dark', + { + brand: '#5AB9FF', + wordmarkHighlight: '#8FD0FF', + wordmarkShadow: '#3A9BE0', + signal: '#5AB9FF', + orbit: '#2AD4DE', + accent: '#5AB9FF', + markdownHeading: '#B79CFF', + markdownCode: '#7EE787', + markdownLink: '#5AB9FF', + userMessageBg: '#141A22', + diffAddedBg: '#12301F', + diffRemovedBg: '#3D1A1A', + text: '#E6EDF3', + muted: '#9AA7B4', + dim: '#6B7785', + border: '#232C36', + line: '#7D8B99', + success: '#3FB950', + warning: '#E3B341', + error: '#F85149', + }, + { + blue: '#79C0FF', + flamingo: '#F0B7B0', + green: '#7EE787', + mauve: '#D2A8FF', + overlay2: '#8B949E', + peach: '#FFA657', + pink: '#F778BA', + red: '#FF7B72', + sapphire: '#A5D6FF', + subtext0: '#B1BAC4', + teal: '#39C5CF', + text: '#C9D1D9', + yellow: '#D29922', + }, + ), + light: palette( + 'midnight', + 'light', + { + brand: '#0A6FCE', + wordmarkHighlight: '#3DAEFF', + wordmarkShadow: '#07599F', + signal: '#0A6FCE', + orbit: '#0B7A82', + accent: '#0A6FCE', + markdownHeading: '#7A3FD1', + markdownCode: '#1A7F37', + markdownLink: '#0550AE', + userMessageBg: '#F2F5F9', + diffAddedBg: '#DDFBE4', + diffRemovedBg: '#FFE7E5', + text: '#1F2933', + muted: '#52606D', + dim: '#7B8794', + border: '#E4E9EF', + line: '#6B7785', + success: '#0F7B33', + warning: '#8A6100', + error: '#C21F39', + }, + { + blue: '#0550AE', + flamingo: '#B3594F', + green: '#116329', + mauve: '#8250DF', + overlay2: '#6E7781', + peach: '#953800', + pink: '#BF3989', + red: '#CF222E', + sapphire: '#1F6FEB', + subtext0: '#57606A', + teal: '#137C8B', + text: '#24292F', + yellow: '#9A6700', + }, + ), +}); + +/** Graphite strips most chroma from surfaces so dense logs stay calm. */ +const GRAPHITE: TuiThemeDefinition = Object.freeze({ + id: 'graphite', + label: 'Graphite', + description: 'Neutral low-chroma surfaces for dense output', + source: 'builtin', + dark: palette( + 'graphite', + 'dark', + { + brand: '#68C0FF', + wordmarkHighlight: '#9BD8FF', + wordmarkShadow: '#3D9AD6', + signal: '#68C0FF', + orbit: '#5AC8D2', + accent: '#68C0FF', + markdownHeading: '#C9D1D9', + markdownCode: '#A5D6A7', + markdownLink: '#68C0FF', + userMessageBg: '#22262B', + diffAddedBg: '#1B3324', + diffRemovedBg: '#3B2426', + text: '#DDE1E6', + muted: '#A8B0B8', + dim: '#71797F', + border: '#2E3338', + line: '#848C94', + success: '#3FB463', + warning: '#E0A92E', + error: '#F2606B', + }, + { + blue: '#7FB8E8', + flamingo: '#D9A0A8', + green: '#9CCFA5', + mauve: '#B5AEDA', + overlay2: '#8A9199', + peach: '#D9A97E', + pink: '#D5A8C4', + red: '#E08087', + sapphire: '#8FC9E0', + subtext0: '#AEB5BC', + teal: '#7FC5CB', + text: '#D7DBDF', + yellow: '#D6C07A', + }, + ), + light: palette( + 'graphite', + 'light', + { + brand: '#0B6FB8', + wordmarkHighlight: '#3DAEFF', + wordmarkShadow: '#07558F', + signal: '#0B6FB8', + orbit: '#0C7680', + accent: '#0B6FB8', + markdownHeading: '#3D444D', + markdownCode: '#1F7A3D', + markdownLink: '#0A5C9E', + userMessageBg: '#F4F5F6', + diffAddedBg: '#E2F3E6', + diffRemovedBg: '#FBE6E7', + text: '#2B3036', + muted: '#565E66', + dim: '#7C848C', + border: '#E6E8EA', + line: '#767E86', + success: '#0F7033', + warning: '#835A00', + error: '#B5202E', + }, + { + blue: '#2C6FAF', + flamingo: '#9E5A5F', + green: '#2F7A42', + mauve: '#6A5AA8', + overlay2: '#6D747B', + peach: '#96602A', + pink: '#9A4E80', + red: '#B23A44', + sapphire: '#2A7F9E', + subtext0: '#5B636B', + teal: '#237A82', + text: '#2B3036', + yellow: '#8A6D1F', + }, + ), }); + +/** Aurora pushes the secondary ramp toward cyan and mint. */ +const AURORA: TuiThemeDefinition = Object.freeze({ + id: 'aurora', + label: 'Aurora', + description: 'Cool cyan and mint secondary ramp', + source: 'builtin', + dark: palette( + 'aurora', + 'dark', + { + brand: '#5CC8E8', + wordmarkHighlight: '#8FE0F5', + wordmarkShadow: '#38A5C4', + signal: '#5CC8E8', + orbit: '#5FE3B0', + accent: '#5CC8E8', + markdownHeading: '#8FD9C0', + markdownCode: '#7BE0B4', + markdownLink: '#5CC8E8', + userMessageBg: '#16232A', + diffAddedBg: '#113028', + diffRemovedBg: '#3A1F26', + text: '#DCE9EE', + muted: '#93AAB3', + dim: '#647D86', + border: '#22333B', + line: '#7E99A3', + success: '#4FD18B', + warning: '#E8C15A', + error: '#F2788A', + }, + { + blue: '#6FC7E8', + flamingo: '#E8A9A0', + green: '#6FE0AE', + mauve: '#8FC9D9', + overlay2: '#7E99A3', + peach: '#E8B88C', + pink: '#E5A8CE', + red: '#F2788A', + sapphire: '#5AD4D4', + subtext0: '#A8C0C9', + teal: '#5FE3B0', + text: '#DCE9EE', + yellow: '#E8D08A', + }, + ), + light: palette( + 'aurora', + 'light', + { + brand: '#0A6E8C', + wordmarkHighlight: '#2FA8C9', + wordmarkShadow: '#07536A', + signal: '#0A6E8C', + orbit: '#0C7A5C', + accent: '#0A6E8C', + markdownHeading: '#0F6B57', + markdownCode: '#0F7350', + markdownLink: '#075C78', + userMessageBg: '#F1F6F8', + diffAddedBg: '#DDF3E9', + diffRemovedBg: '#FBE7EC', + text: '#1E2B31', + muted: '#4E646D', + dim: '#758D96', + border: '#E2EBEE', + line: '#6B8590', + success: '#0E7A52', + warning: '#856000', + error: '#B32B45', + }, + { + blue: '#0E6E8C', + flamingo: '#9E5A56', + green: '#0F7350', + mauve: '#0F6A72', + overlay2: '#6B8590', + peach: '#8A5A28', + pink: '#8E4470', + red: '#B32B45', + sapphire: '#0A7A7D', + subtext0: '#5A737D', + teal: '#0C7A5C', + text: '#1E2B31', + yellow: '#7A6418', + }, + ), +}); + +export const DEFAULT_THEME_ID = 'minimax'; + +/** + * The theme every lookup falls back to. Exported as a concrete value so callers + * never have to assert a built-in exists. + */ +export const DEFAULT_THEME: TuiThemeDefinition = Object.freeze({ + id: MINIMAX_CODE_DARK_THEME.id, + label: 'MCode', + description: 'The default MCode blue palette', + source: 'builtin', + dark: MINIMAX_CODE_DARK_THEME, + light: MINIMAX_CODE_LIGHT_THEME, +}); + +export const BUILT_IN_THEMES: readonly TuiThemeDefinition[] = Object.freeze([ + DEFAULT_THEME, + MIDNIGHT, + GRAPHITE, + AURORA, +]); + +export function builtinTheme(id: string): TuiThemeDefinition | undefined { + return BUILT_IN_THEMES.find((theme) => theme.id === id); +} diff --git a/packages/tui/src/tui/theme/registry.ts b/packages/tui/src/tui/theme/registry.ts new file mode 100644 index 00000000..49b587fa --- /dev/null +++ b/packages/tui/src/tui/theme/registry.ts @@ -0,0 +1,92 @@ +import type { TuiResolvedAppearance, TuiThemeDefinition, TuiThemePalette } from './contracts.js'; +import type { TuiThemeLoadIssue, TuiThemeLoadResult } from './custom-themes.js'; +import { BUILT_IN_THEMES, DEFAULT_THEME, DEFAULT_THEME_ID } from './palettes.js'; + +/** + * Owns the set of selectable themes for one TUI process. + * + * Built-ins are always present; custom files are layered on top and may be + * replaced at runtime when a watched theme file changes. A selected id that no + * longer resolves (file deleted, renamed, invalid) degrades to the default + * theme instead of leaving the TUI unpainted. + */ +export class TuiThemeRegistry { + private customThemes: readonly TuiThemeDefinition[] = []; + private issues: readonly TuiThemeLoadIssue[] = []; + private selectedId: string = DEFAULT_THEME_ID; + + applyLoadResult(result: TuiThemeLoadResult): void { + this.customThemes = result.themes; + this.issues = result.issues; + if (!this.resolve(this.selectedId)) this.selectedId = DEFAULT_THEME_ID; + } + + setIssues(issues: readonly TuiThemeLoadIssue[]): void { + this.issues = issues; + } + + list(): readonly TuiThemeDefinition[] { + return [...BUILT_IN_THEMES, ...this.customThemes]; + } + + issuesList(): readonly TuiThemeLoadIssue[] { + return this.issues; + } + + resolve(id: string): TuiThemeDefinition | undefined { + const normalized = id.trim().toLowerCase(); + return this.list().find((theme) => theme.id === normalized); + } + + selected(): TuiThemeDefinition { + return this.resolve(this.selectedId) ?? DEFAULT_THEME; + } + + selectedIdValue(): string { + return this.selected().id; + } + + /** + * Select a theme by id. Returns the resolved definition, or `undefined` when + * the id is unknown so the caller can surface the failure without mutating + * the current selection. + */ + select(id: string): TuiThemeDefinition | undefined { + const resolved = this.resolve(id); + if (!resolved) return undefined; + this.selectedId = resolved.id; + return resolved; + } + + /** + * Normalize a persisted or CLI-supplied value. `light/dark` selects an + * explicit appearance while keeping the current theme, which is the syntax + * MCode uses to pin a terminal that reports its background unreliably. + */ + resolveSelection(value: string | undefined): { + readonly themeId: string; + readonly appearanceOverride?: TuiResolvedAppearance; + } { + const raw = value?.trim(); + if (!raw) return { themeId: DEFAULT_THEME_ID }; + const pair = /^([a-z0-9._-]+)\/(light|dark)$/iu.exec(raw); + if (pair?.[1] && pair[2]) { + const theme = this.resolve(pair[1]); + if (theme) + return { + themeId: theme.id, + appearanceOverride: pair[2].toLowerCase() as TuiResolvedAppearance, + }; + } + return { themeId: this.resolve(raw)?.id ?? DEFAULT_THEME_ID }; + } + + palette(themeId: string, appearance: TuiResolvedAppearance): TuiThemePalette { + const theme = this.resolve(themeId) ?? DEFAULT_THEME; + return appearance === 'light' ? theme.light : theme.dark; + } + + paletteFor(appearance: TuiResolvedAppearance): TuiThemePalette { + return this.palette(this.selectedIdValue(), appearance); + } +} diff --git a/packages/tui/src/tui/theme/runtime.ts b/packages/tui/src/tui/theme/runtime.ts index 0d212787..cfa5b17f 100644 --- a/packages/tui/src/tui/theme/runtime.ts +++ b/packages/tui/src/tui/theme/runtime.ts @@ -4,16 +4,22 @@ import type { MarkdownTheme } from '../engine/public.js'; import { detectProcessTerminalCapabilities } from '../platform/terminal-capabilities.js'; import type { EditorTheme } from '../widgets/editor/editor.js'; import type { SelectListTheme } from '../widgets/select-list.js'; -import type { TuiColorLevel, TuiThemeColors, TuiThemePalette } from './contracts.js'; +import type { + TuiColorLevel, + TuiThemeColors, + TuiThemePalette, + TuiThemeSyntaxTones, +} from './contracts.js'; import { resolveTuiAnsi16Foreground, shouldSuppressTuiAnsi16Background } from './ansi16.js'; import { resolveEnvironmentAppearance } from './detection.js'; import { MINIMAX_CODE_DARK_THEME, MINIMAX_CODE_LIGHT_THEME } from './palettes.js'; -import { createCatppuccinHighlightTheme } from './syntax.js'; +import { createSyntaxHighlightTheme } from './syntax.js'; export interface TuiRenderThemeSnapshot { readonly name: string; readonly appearance: 'light' | 'dark'; readonly colorLevel: TuiColorLevel; + readonly signature: string; } const initialCapabilities = detectProcessTerminalCapabilities(); @@ -22,9 +28,18 @@ let renderThemeSnapshot: TuiRenderThemeSnapshot = { name: 'minimax', appearance: initialAppearance, colorLevel: initialCapabilities.colorLevel, + signature: '', }; let activeColors: TuiThemeColors = initialAppearance === 'light' ? MINIMAX_CODE_LIGHT_THEME.colors : MINIMAX_CODE_DARK_THEME.colors; +let activeSyntax: TuiThemeSyntaxTones = + initialAppearance === 'light' ? MINIMAX_CODE_LIGHT_THEME.syntax : MINIMAX_CODE_DARK_THEME.syntax; +renderThemeSnapshot = { + ...renderThemeSnapshot, + signature: paletteSignature( + initialAppearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME, + ), +}; export const tuiColors: TuiThemeColors = Object.freeze({ get brand() { @@ -210,19 +225,29 @@ export function renderTuiActionHint(value: string): string { .join(''); } +/** + * Identity of a palette's *content*, not just its name. Editing a custom theme + * file in place keeps the same id and appearance, so comparing ids alone would + * swallow the repaint and leave stale colors on screen. + */ +export function paletteSignature(palette: TuiThemePalette): string { + return JSON.stringify([palette.id, palette.appearance, palette.colors, palette.syntax]); +} + export function applyTuiRenderTheme(palette: TuiThemePalette, colorLevel: TuiColorLevel): boolean { + const signature = paletteSignature(palette); const changed = - renderThemeSnapshot.name !== palette.id || - renderThemeSnapshot.appearance !== palette.appearance || - renderThemeSnapshot.colorLevel !== colorLevel; + renderThemeSnapshot.signature !== signature || renderThemeSnapshot.colorLevel !== colorLevel; if (!changed) return false; const colorLevelChanged = renderThemeSnapshot.colorLevel !== colorLevel; renderThemeSnapshot = { name: palette.id, appearance: palette.appearance, colorLevel, + signature, }; activeColors = palette.colors; + activeSyntax = palette.syntax; if (colorLevelChanged) activeChalk = createTuiChalk({ colorLevel }); return true; } @@ -231,8 +256,9 @@ export function getTuiThemeSnapshot(): TuiRenderThemeSnapshot { return { ...renderThemeSnapshot }; } -const tuiHighlightTheme = createCatppuccinHighlightTheme( +const tuiHighlightTheme = createSyntaxHighlightTheme( tuiChalk, + () => activeSyntax, () => renderThemeSnapshot.appearance, ); diff --git a/packages/tui/src/tui/theme/syntax.ts b/packages/tui/src/tui/theme/syntax.ts index 278fdf2b..6bcc2719 100644 --- a/packages/tui/src/tui/theme/syntax.ts +++ b/packages/tui/src/tui/theme/syntax.ts @@ -1,58 +1,47 @@ import type { ChalkInstance } from 'chalk'; import type { Theme } from 'cli-highlight'; +import { type TuiThemeSyntaxTones, type TuiResolvedAppearance } from './contracts.js'; -type SyntaxTone = - | 'blue' - | 'flamingo' - | 'green' - | 'mauve' - | 'overlay2' - | 'peach' - | 'pink' - | 'red' - | 'sapphire' - | 'subtext0' - | 'teal' - | 'text' - | 'yellow'; +export type SyntaxTone = keyof TuiThemeSyntaxTones; -const CATPPUCCIN_SYNTAX_COLORS: Readonly< - Record<'dark' | 'light', Readonly>> -> = { - dark: { - blue: '#89B4FA', - flamingo: '#F2CDCD', - green: '#A6E3A1', - mauve: '#CBA6F7', - overlay2: '#9399B2', - peach: '#FAB387', - pink: '#F5C2E7', - red: '#F38BA8', - sapphire: '#74C7EC', - subtext0: '#A6ADC8', - teal: '#94E2D5', - text: '#CDD6F4', - yellow: '#F9E2AF', - }, - light: { - blue: '#1E66F5', - flamingo: '#DD7878', - green: '#40A02B', - mauve: '#8839EF', - overlay2: '#7C7F93', - peach: '#FE640B', - pink: '#EA76CB', - red: '#D20F39', - sapphire: '#209FB5', - subtext0: '#6C6F85', - teal: '#179299', - text: '#4C4F69', - yellow: '#DF8E1D', - }, -}; +export const CATPPUCCIN_SYNTAX_TONES: Readonly> = + Object.freeze({ + dark: Object.freeze({ + blue: '#89B4FA', + flamingo: '#F2CDCD', + green: '#A6E3A1', + mauve: '#CBA6F7', + overlay2: '#9399B2', + peach: '#FAB387', + pink: '#F5C2E7', + red: '#F38BA8', + sapphire: '#74C7EC', + subtext0: '#A6ADC8', + teal: '#94E2D5', + text: '#CDD6F4', + yellow: '#F9E2AF', + }), + light: Object.freeze({ + blue: '#1E66F5', + flamingo: '#DD7878', + green: '#40A02B', + mauve: '#8839EF', + overlay2: '#7C7F93', + peach: '#FE640B', + pink: '#EA76CB', + red: '#D20F39', + sapphire: '#209FB5', + subtext0: '#6C6F85', + teal: '#179299', + text: '#4C4F69', + yellow: '#DF8E1D', + }), + }); -// Pastel RGB colors collapse to white when Chalk approximates them to ANSI16. -// Keep token families distinct using the terminal's own semantic palette. +/** + * Pastel RGB colors collapse to white when Chalk approximates them to ANSI16. + * Keep token families distinct using the terminal's own semantic palette. + */ const ANSI16_SYNTAX_STYLES = { dark: { blue: 'blueBright', @@ -84,22 +73,28 @@ const ANSI16_SYNTAX_STYLES = { text: 'black', yellow: 'yellow', }, -} as const satisfies Record<'dark' | 'light', Record>; +} as const satisfies Record>; -export function createCatppuccinHighlightTheme( +/** + * Builds the Highlight.js token map for whichever theme is currently active. + * `tones` is read on every call so a theme switch is picked up without + * re-creating the highlight theme object. + */ +export function createSyntaxHighlightTheme( chalk: ChalkInstance, - appearance: () => 'light' | 'dark', + tones: () => TuiThemeSyntaxTones, + appearance: () => TuiResolvedAppearance, ): Theme { const color = (tone: SyntaxTone) => (text: string) => { const mode = appearance(); - if (chalk.level !== 1) return chalk.hex(CATPPUCCIN_SYNTAX_COLORS[mode][tone])(text); + if (chalk.level !== 1) return chalk.hex(tones()[tone])(text); const styled = chalk[ANSI16_SYNTAX_STYLES[mode][tone]](text); // Bright white + dim remains legible where ANSI bright-black is very dark. return mode === 'dark' && tone === 'overlay2' ? chalk.dim(styled) : styled; }; const strong = (text: string) => chalk.bold(color('red')(text)); - // Exact Catppuccin Highlight.js token mapping, adapted to ANSI output. As in Codex, + // Highlight.js token mapping, adapted to ANSI output. As in Codex, // terminal-hostile italic, underline, and syntax-theme backgrounds are intentionally omitted. return { default: color('text'), diff --git a/packages/tui/src/types/tui-app.ts b/packages/tui/src/types/tui-app.ts index b4c8da4b..2228931f 100644 --- a/packages/tui/src/types/tui-app.ts +++ b/packages/tui/src/types/tui-app.ts @@ -35,6 +35,9 @@ export interface CreateTuiAppOptions extends TuiUpdateOptions { terminal?: Terminal; tuiMode?: TuiMode; persistTuiMode?: (mode: TuiMode) => void; + /** Saved theme selection, e.g. `aurora` or `aurora/dark`. */ + theme?: string; + persistTheme?: (theme: string) => void; runtimeLogDirectory?: string; resolveAttachment?: ( reference: string, diff --git a/packages/tui/test/helpers/theme-contrast.ts b/packages/tui/test/helpers/theme-contrast.ts new file mode 100644 index 00000000..0f31d10f --- /dev/null +++ b/packages/tui/test/helpers/theme-contrast.ts @@ -0,0 +1,57 @@ +import type { TuiThemeColors } from '../../src/tui/theme/contracts.js'; + +type TuiThemeColorRole = keyof TuiThemeColors; + +export const MINIMAX_CODE_THEME_CONTRAST_POLICY = Object.freeze({ + backgrounds: Object.freeze({ + dark: '#000000', + light: '#FFFFFF', + }), + normalText: Object.freeze({ + minimum: 4.5, + roles: Object.freeze([ + 'signal', + 'orbit', + 'accent', + 'markdownHeading', + 'markdownCode', + 'markdownLink', + 'text', + 'muted', + 'success', + 'warning', + 'error', + ] satisfies readonly TuiThemeColorRole[]), + exceptions: Object.freeze([ + Object.freeze({ appearance: 'light', role: 'signal', minimum: 3 }), + Object.freeze({ appearance: 'light', role: 'accent', minimum: 3 }), + ] satisfies readonly { + readonly appearance: 'light' | 'dark'; + readonly role: TuiThemeColorRole; + readonly minimum: number; + }[]), + }), + nonText: Object.freeze({ + minimum: 3, + roles: Object.freeze(['line'] satisfies readonly TuiThemeColorRole[]), + }), +}); + +export function contrastRatio(first: string, second: string): number { + const firstLuminance = relativeLuminance(first); + const secondLuminance = relativeLuminance(second); + const lighter = Math.max(firstLuminance, secondLuminance); + const darker = Math.min(firstLuminance, secondLuminance); + return (lighter + 0.05) / (darker + 0.05); +} + +function relativeLuminance(hex: string): number { + const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/iu.exec(hex); + if (!match) throw new Error(`Expected a six-digit hex color, received ${hex}`); + const [, red = '00', green = '00', blue = '00'] = match; + const linearize = (channel: string): number => { + const value = Number.parseInt(channel, 16) / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * linearize(red) + 0.7152 * linearize(green) + 0.0722 * linearize(blue); +} diff --git a/packages/tui/test/unit/host-tui-settings.test.ts b/packages/tui/test/unit/host-tui-settings.test.ts new file mode 100644 index 00000000..3e995aee --- /dev/null +++ b/packages/tui/test/unit/host-tui-settings.test.ts @@ -0,0 +1,157 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + readTuiModeSetting, + readTuiThemeSetting, + writeTuiModeSetting, + writeTuiThemeSetting, +} from '../../src/host/tui-settings.js'; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +async function temporaryDataDir(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'mcode-tui-settings-')); + directories.push(directory); + return directory; +} + +describe('TUI mode settings', () => { + it('uses regular mode when no valid explicit setting exists', async () => { + const dataDir = await temporaryDataDir(); + + expect(readTuiModeSetting(dataDir)).toBe('regular'); + await mkdir(join(dataDir, 'tui'), { recursive: true }); + await writeFile(join(dataDir, 'tui', 'tui-settings.json'), '{broken', 'utf8'); + expect(readTuiModeSetting(dataDir)).toBe('regular'); + await writeFile(join(dataDir, 'tui', 'tui-settings.json'), '{"tuiMode":"other"}', 'utf8'); + expect(readTuiModeSetting(dataDir)).toBe('regular'); + }); + + it('persists the selected fullscreen mode in the MCode data directory', async () => { + const dataDir = await temporaryDataDir(); + + writeTuiModeSetting(dataDir, 'fullscreen'); + + expect(readTuiModeSetting(dataDir)).toBe('fullscreen'); + expect(JSON.parse(await readFile(join(dataDir, 'tui', 'tui-settings.json'), 'utf8'))).toEqual({ + tuiMode: 'fullscreen', + }); + }); + + it('accepts a UTF-8 BOM written by Windows editors', async () => { + const dataDir = await temporaryDataDir(); + await mkdir(join(dataDir, 'tui'), { recursive: true }); + await writeFile( + join(dataDir, 'tui', 'tui-settings.json'), + '\uFEFF{"tuiMode":"fullscreen"}', + 'utf8', + ); + + expect(readTuiModeSetting(dataDir)).toBe('fullscreen'); + }); + + it('reads the legacy root file without writing back to the root', async () => { + const dataDir = await temporaryDataDir(); + await writeFile(join(dataDir, 'tui-settings.json'), '{"tuiMode":"fullscreen"}', 'utf8'); + + expect(readTuiModeSetting(dataDir)).toBe('fullscreen'); + writeTuiModeSetting(dataDir, 'regular'); + expect(JSON.parse(await readFile(join(dataDir, 'tui', 'tui-settings.json'), 'utf8'))).toEqual({ + tuiMode: 'regular', + }); + }); +}); + +describe('TUI theme settings', () => { + it('uses no theme override when nothing is saved', async () => { + const dataDir = await temporaryDataDir(); + + expect(readTuiThemeSetting(dataDir)).toBeUndefined(); + }); + + it('round-trips a theme and a pinned appearance', async () => { + const dataDir = await temporaryDataDir(); + + writeTuiThemeSetting(dataDir, 'aurora'); + expect(readTuiThemeSetting(dataDir)).toBe('aurora'); + + writeTuiThemeSetting(dataDir, 'aurora/dark'); + expect(readTuiThemeSetting(dataDir)).toBe('aurora/dark'); + }); + + it('does not drop the theme when the TUI mode is written afterwards', async () => { + const dataDir = await temporaryDataDir(); + + writeTuiThemeSetting(dataDir, 'midnight'); + writeTuiModeSetting(dataDir, 'fullscreen'); + + expect(readTuiThemeSetting(dataDir)).toBe('midnight'); + expect(readTuiModeSetting(dataDir)).toBe('fullscreen'); + }); + + it('does not drop the TUI mode when the theme is written afterwards', async () => { + const dataDir = await temporaryDataDir(); + + writeTuiModeSetting(dataDir, 'fullscreen'); + writeTuiThemeSetting(dataDir, 'graphite'); + + expect(readTuiModeSetting(dataDir)).toBe('fullscreen'); + expect(readTuiThemeSetting(dataDir)).toBe('graphite'); + }); + + it('rejects a theme id that is not a safe identifier', async () => { + const dataDir = await temporaryDataDir(); + + expect(() => writeTuiThemeSetting(dataDir, '../escape')).toThrow(); + expect(() => writeTuiThemeSetting(dataDir, 'a/b/c')).toThrow(); + }); + + it('ignores a malformed saved theme instead of failing startup', async () => { + const dataDir = await temporaryDataDir(); + await mkdir(join(dataDir, 'tui'), { recursive: true }); + await writeFile( + join(dataDir, 'tui', 'tui-settings.json'), + JSON.stringify({ theme: { nested: true } }), + 'utf8', + ); + + expect(readTuiThemeSetting(dataDir)).toBeUndefined(); + expect(readTuiModeSetting(dataDir)).toBe('regular'); + }); + + it('ignores a saved theme that tries to traverse a path', async () => { + const dataDir = await temporaryDataDir(); + await mkdir(join(dataDir, 'tui'), { recursive: true }); + await writeFile( + join(dataDir, 'tui', 'tui-settings.json'), + JSON.stringify({ theme: '../../etc/passwd' }), + 'utf8', + ); + + expect(readTuiThemeSetting(dataDir)).toBeUndefined(); + }); + + it('keeps unknown settings keys when rewriting a known one', async () => { + const dataDir = await temporaryDataDir(); + await mkdir(join(dataDir, 'tui'), { recursive: true }); + await writeFile( + join(dataDir, 'tui', 'tui-settings.json'), + JSON.stringify({ futureSetting: 'keep-me' }), + 'utf8', + ); + + writeTuiThemeSetting(dataDir, 'aurora'); + + const document = JSON.parse( + await readFile(join(dataDir, 'tui', 'tui-settings.json'), 'utf8'), + ) as Record; + expect(document.futureSetting).toBe('keep-me'); + expect(document.theme).toBe('aurora'); + }); +}); diff --git a/packages/tui/test/unit/tui/features/settings/theme-picker.test.ts b/packages/tui/test/unit/tui/features/settings/theme-picker.test.ts new file mode 100644 index 00000000..e7a2f07f --- /dev/null +++ b/packages/tui/test/unit/tui/features/settings/theme-picker.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TuiThemePicker } from '../../../../../src/tui/features/settings/theme-picker.js'; +import { stripAnsi } from '../../../../../src/tui/rendering/text.js'; +import { BUILT_IN_THEMES } from '../../../../../src/tui/theme/palettes.js'; +import type { TuiThemeDefinition } from '../../../../../src/tui/theme/contracts.js'; + +const CUSTOM: TuiThemeDefinition = { + id: 'mine', + label: 'Mine', + description: 'Loaded from disk', + source: 'custom', + dark: BUILT_IN_THEMES[0]!.dark, + light: BUILT_IN_THEMES[0]!.light, +}; + +const THEMES = [...BUILT_IN_THEMES, CUSTOM]; + +function build(overrides: Partial[0]> = {}) { + const preview = vi.fn(); + const setAppearance = vi.fn(); + const save = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + const requestRender = vi.fn(); + const picker = new TuiThemePicker({ + themes: THEMES, + currentThemeId: 'minimax', + currentAppearance: 'dark', + appearanceOverride: undefined, + preview, + setAppearance, + save, + onClose, + requestRender, + ...overrides, + }); + return { picker, preview, setAppearance, save, onClose, requestRender }; +} + +describe('TuiThemePicker', () => { + it('focuses the current theme and shows each theme with a color swatch', () => { + const { picker } = build(); + + const rendered = stripAnsi(picker.render(100).join('\n')); + + expect(rendered).toContain('Theme'); + expect(rendered).toContain('MCode'); + expect(rendered).toContain('Midnight'); + expect(rendered).toContain('Aurora'); + expect(rendered).toContain('Mine'); + expect(rendered).toContain('current'); + // The cursor starts on the theme that is already active. + expect(rendered).toContain('› MCode'); + }); + + it('previews the focused theme while navigating', () => { + const { picker, preview, requestRender } = build(); + + picker.handleInput('\u001b[B'); + expect(preview).toHaveBeenLastCalledWith(BUILT_IN_THEMES[1]!.id); + expect(requestRender).toHaveBeenCalled(); + + picker.handleInput('\u001b[A'); + expect(preview).toHaveBeenLastCalledWith('minimax'); + }); + + it('wraps around at both ends of the list', () => { + const { picker, preview } = build(); + + picker.handleInput('\u001b[A'); + expect(preview).toHaveBeenLastCalledWith(CUSTOM.id); + + picker.handleInput('\u001b[B'); + expect(preview).toHaveBeenLastCalledWith('minimax'); + }); + + it('cycles the appearance with the a key', () => { + const { picker, setAppearance } = build(); + + picker.handleInput('a'); + expect(setAppearance).toHaveBeenLastCalledWith('light'); + picker.handleInput('a'); + expect(setAppearance).toHaveBeenLastCalledWith('dark'); + picker.handleInput('a'); + expect(setAppearance).toHaveBeenLastCalledWith('auto'); + }); + + it('saves the focused theme and closes', async () => { + const { picker, save, onClose } = build(); + + picker.handleInput('\u001b[B'); + picker.handleInput('\r'); + await vi.waitFor(() => expect(onClose).toHaveBeenCalled()); + + expect(save).toHaveBeenCalledWith(BUILT_IN_THEMES[1]!.id, 'auto'); + }); + + it('saves a pinned appearance alongside the theme', async () => { + const { picker, save, onClose } = build(); + + picker.handleInput('\u001b[B'); + picker.handleInput('a'); + picker.handleInput('\r'); + await vi.waitFor(() => expect(onClose).toHaveBeenCalled()); + + expect(save).toHaveBeenCalledWith(BUILT_IN_THEMES[1]!.id, 'light'); + }); + + it('closes without saving when nothing changed', async () => { + const { picker, save, onClose } = build(); + + picker.handleInput('\r'); + await vi.waitFor(() => expect(onClose).toHaveBeenCalled()); + + expect(save).not.toHaveBeenCalled(); + }); + + it('restores the original theme and appearance on cancel', () => { + const { picker, preview, setAppearance, save, onClose } = build(); + + picker.handleInput('\u001b[B'); + picker.handleInput('a'); + preview.mockClear(); + picker.handleInput('\u001b'); + + expect(preview).toHaveBeenCalledWith('minimax'); + expect(setAppearance).toHaveBeenLastCalledWith('auto'); + expect(save).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it('keeps the picker open and reports a failed save', async () => { + const save = vi.fn().mockRejectedValue(new Error('read-only')); + const { picker, onClose, requestRender } = build({ save }); + + picker.handleInput('\u001b[B'); + picker.handleInput('\r'); + await vi.waitFor(() => expect(requestRender).toHaveBeenCalled()); + + expect(onClose).not.toHaveBeenCalled(); + expect(stripAnsi(picker.render(100).join('\n'))).toContain( + 'Could not save the theme selection.', + ); + }); + + it('renders without a theme list', () => { + const { picker } = build({ themes: [] }); + + expect(stripAnsi(picker.render(80).join('\n'))).toContain('No themes available.'); + }); + + it('fits a narrow terminal without overflowing', () => { + const { picker } = build(); + + for (const width of [40, 60, 100, 160]) { + for (const line of picker.render(width)) { + expect(stripAnsi(line).length).toBeLessThanOrEqual(width); + } + } + }); +}); diff --git a/packages/tui/test/unit/tui/theme/custom-themes.test.ts b/packages/tui/test/unit/tui/theme/custom-themes.test.ts new file mode 100644 index 00000000..23020c02 --- /dev/null +++ b/packages/tui/test/unit/tui/theme/custom-themes.test.ts @@ -0,0 +1,275 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + customThemesDirectory, + loadCustomThemes, +} from '../../../../src/tui/theme/custom-themes.js'; +import { TuiThemeRegistry } from '../../../../src/tui/theme/registry.js'; +import type { TuiThemeDefinition } from '../../../../src/tui/theme/contracts.js'; +import { + DEFAULT_THEME_ID, + MINIMAX_CODE_DARK_THEME, + MINIMAX_CODE_LIGHT_THEME, +} from '../../../../src/tui/theme/palettes.js'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +async function themesDataDir(files: Readonly>): Promise { + const dataDir = await mkdtemp(join(tmpdir(), 'mcode-tui-themes-')); + temporaryDirectories.push(dataDir); + const directory = customThemesDirectory(dataDir); + await mkdir(directory, { recursive: true }); + for (const [name, content] of Object.entries(files)) { + await writeFile(join(directory, name), JSON.stringify(content, null, 2)); + } + return dataDir; +} + +/** Fail loudly instead of reaching for a non-null assertion on a test subject. */ +function onlyTheme(themes: readonly TuiThemeDefinition[]): TuiThemeDefinition { + expect(themes).toHaveLength(1); + const [theme] = themes; + if (!theme) throw new Error('expected exactly one loaded theme'); + return theme; +} + +describe('custom TUI theme files', () => { + it('returns no themes when the directory is absent', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'mcode-tui-themes-empty-')); + temporaryDirectories.push(dataDir); + + expect(loadCustomThemes(dataDir)).toEqual({ themes: [], issues: [] }); + }); + + it('loads a partial file on top of the default palette', async () => { + const dataDir = await themesDataDir({ + 'mine.json': { name: 'mine', appearance: 'dark', colors: { brand: '#112233' } }, + }); + + const { themes, issues } = loadCustomThemes(dataDir); + + expect(issues).toEqual([]); + const theme = onlyTheme(themes); + expect(theme.id).toBe('mine'); + expect(theme.source).toBe('custom'); + expect(theme.label).toBe('mine'); + expect(theme.dark.colors.brand).toBe('#112233'); + // Untouched roles keep the default palette so the theme is usable immediately. + expect(theme.dark.colors.error).toBe(MINIMAX_CODE_DARK_THEME.colors.error); + }); + + it('resolves vars references and normalizes case', async () => { + const dataDir = await themesDataDir({ + 'vars.json': { + name: 'vars', + appearance: 'dark', + vars: { brand: '#aabbcc' }, + colors: { brand: 'brand', accent: 'brand' }, + }, + }); + + const { themes, issues } = loadCustomThemes(dataDir); + + expect(issues).toEqual([]); + const theme = onlyTheme(themes); + expect(theme.dark.colors.brand).toBe('#AABBCC'); + expect(theme.dark.colors.accent).toBe('#AABBCC'); + }); + + it('pairs a dark and a light file into one selectable theme', async () => { + const dataDir = await themesDataDir({ + 'dual.json': { name: 'dual', appearance: 'dark', colors: { brand: '#010101' } }, + 'dual-light.json': { name: 'dual', appearance: 'light', colors: { brand: '#fefefe' } }, + }); + + const { themes } = loadCustomThemes(dataDir); + + const theme = onlyTheme(themes); + expect(theme.dark.colors.brand).toBe('#010101'); + expect(theme.light.colors.brand).toBe('#FEFEFE'); + }); + + it('falls back to the default palette for an appearance the files do not supply', async () => { + const dataDir = await themesDataDir({ + 'dark-only.json': { name: 'dark-only', appearance: 'dark', colors: { brand: '#010101' } }, + }); + + const { themes } = loadCustomThemes(dataDir); + + const theme = onlyTheme(themes); + expect(theme.dark.colors.brand).toBe('#010101'); + // The light variant is a complete, usable palette borrowed from the default + // light theme — not a blank object and not the dark value. + expect(theme.light.colors.brand).toBe(MINIMAX_CODE_LIGHT_THEME.colors.brand); + expect(theme.light.colors.text).toBe(MINIMAX_CODE_LIGHT_THEME.colors.text); + expect(theme.light.id).toBe('dark-only'); + }); + + it('reports a malformed file without dropping valid themes', async () => { + const dataDir = await themesDataDir({ + 'good.json': { name: 'good', appearance: 'dark', colors: { brand: '#010101' } }, + 'bad.json': { name: 'bad' }, + 'badvar.json': { + name: 'badvar', + appearance: 'dark', + colors: { brand: 'nope' }, + }, + }); + + const { themes, issues } = loadCustomThemes(dataDir); + + expect(themes.map((theme) => theme.id)).toEqual(['good']); + expect(issues).toHaveLength(2); + expect(issues.map((issue) => issue.message).join(' ')).toMatch(/brand|appearance/u); + }); + + it('rejects a nested vars reference during validation', async () => { + const dataDir = await themesDataDir({ + 'nested.json': { + name: 'nested', + appearance: 'dark', + // vars must hold literal colors; an identifier here would otherwise + // pass validation and fail later with a misleading message. + vars: { outer: 'inner', inner: '#112233' }, + colors: { brand: 'outer' }, + }, + }); + + const { themes, issues } = loadCustomThemes(dataDir); + + expect(themes).toEqual([]); + expect(issues).toHaveLength(1); + expect(issues[0]?.message).toMatch(/vars\/outer/u); + }); + + it('refuses to shadow a built-in theme id', async () => { + const dataDir = await themesDataDir({ + 'minimax.json': { name: 'minimax', appearance: 'dark', colors: { brand: '#010101' } }, + }); + + const { themes, issues } = loadCustomThemes(dataDir); + + expect(themes).toEqual([]); + expect(issues[0]?.message).toMatch(/built-in/u); + }); + + it('rejects ids that could escape the themes directory', async () => { + const dataDir = await themesDataDir({ + 'escape.json': { name: '../escape', appearance: 'dark', colors: { brand: '#010101' } }, + }); + + const { themes, issues } = loadCustomThemes(dataDir); + + expect(themes).toEqual([]); + expect(issues).toHaveLength(1); + }); +}); + +describe('TuiThemeRegistry', () => { + it('lists built-ins first and layers custom themes after them', async () => { + const dataDir = await themesDataDir({ + 'mine.json': { name: 'mine', appearance: 'dark', colors: { brand: '#010101' } }, + }); + const registry = new TuiThemeRegistry(); + + registry.applyLoadResult(loadCustomThemes(dataDir)); + + const ids = registry.list().map((theme) => theme.id); + expect(ids[0]).toBe(DEFAULT_THEME_ID); + expect(ids).toContain('mine'); + expect(ids.filter((id) => id === 'minimax')).toHaveLength(1); + }); + + it('keeps a valid selection when unrelated custom themes reload', async () => { + const dataDir = await themesDataDir({ + 'mine.json': { name: 'mine', appearance: 'dark', colors: { brand: '#010101' } }, + }); + const registry = new TuiThemeRegistry(); + registry.applyLoadResult(loadCustomThemes(dataDir)); + registry.select('mine'); + + registry.applyLoadResult(loadCustomThemes(dataDir)); + + expect(registry.selectedIdValue()).toBe('mine'); + }); + + it('falls back to the default theme when the selected theme disappears', async () => { + const dataDir = await themesDataDir({ + 'mine.json': { name: 'mine', appearance: 'dark', colors: { brand: '#010101' } }, + }); + const registry = new TuiThemeRegistry(); + registry.applyLoadResult(loadCustomThemes(dataDir)); + registry.select('mine'); + + registry.applyLoadResult({ themes: [], issues: [] }); + + expect(registry.selectedIdValue()).toBe(DEFAULT_THEME_ID); + }); + + it('rejects an unknown selection instead of clearing the current theme', async () => { + const registry = new TuiThemeRegistry(); + registry.select('midnight'); + + expect(registry.select('does-not-exist')).toBeUndefined(); + expect(registry.selectedIdValue()).toBe('midnight'); + }); + + it('resolves a pinned light/dark selection against the current theme', () => { + const registry = new TuiThemeRegistry(); + + expect(registry.resolveSelection('aurora/dark')).toEqual({ + themeId: 'aurora', + appearanceOverride: 'dark', + }); + expect(registry.resolveSelection('AURORA/LIGHT')).toEqual({ + themeId: 'aurora', + appearanceOverride: 'light', + }); + // A pinned appearance for an unknown theme still resolves to the default. + expect(registry.resolveSelection('nope/dark')).toEqual({ themeId: DEFAULT_THEME_ID }); + expect(registry.resolveSelection(undefined)).toEqual({ themeId: DEFAULT_THEME_ID }); + }); + + it('picks the palette matching the requested appearance', () => { + const registry = new TuiThemeRegistry(); + registry.select('midnight'); + + expect(registry.paletteFor('dark').id).toBe('midnight'); + expect(registry.paletteFor('light').id).toBe('midnight'); + expect(registry.paletteFor('dark').colors.brand).not.toBe( + registry.paletteFor('light').colors.brand, + ); + }); +}); + +describe('custom theme documentation', () => { + const docPath = new URL('../../../../docs/theme-config.md', import.meta.url); + + it('ships a JSON example the loader actually accepts', async () => { + const doc = await readFile(docPath, 'utf8'); + // The guide also shows tui-settings.json; only theme files carry `appearance`. + const blocks = [...doc.matchAll(/```json\n([\s\S]*?)```/gu)] + .map((match) => match[1] ?? '') + .filter((block) => block.includes('"appearance"')); + expect(blocks.length).toBeGreaterThan(0); + + const dataDir = await themesDataDir( + Object.fromEntries(blocks.map((block, index) => [`doc-${index}.json`, JSON.parse(block)])), + ); + + const { themes, issues } = loadCustomThemes(dataDir); + + // A documented example that the schema rejects is worse than no example: + // users copy it verbatim and the theme silently disappears from /theme. + expect(issues).toEqual([]); + expect(themes.length).toBe(blocks.length); + }); +}); diff --git a/packages/tui/test/unit/tui/theme/palettes.test.ts b/packages/tui/test/unit/tui/theme/palettes.test.ts new file mode 100644 index 00000000..434cf8f0 --- /dev/null +++ b/packages/tui/test/unit/tui/theme/palettes.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { + BUILT_IN_THEMES, + DEFAULT_THEME_ID, + MINIMAX_CODE_DARK_THEME, + MINIMAX_CODE_LIGHT_THEME, +} from '../../../../src/tui/theme/palettes.js'; +import { + TUI_SYNTAX_TONE_NAMES, + TUI_THEME_COLOR_NAMES, +} from '../../../../src/tui/theme/contracts.js'; +import { + MINIMAX_CODE_THEME_CONTRAST_POLICY, + contrastRatio, +} from '../../../helpers/theme-contrast.js'; + +/** Every palette in both appearances of every built-in theme. */ +const ALL_PALETTES = BUILT_IN_THEMES.flatMap((theme) => [ + { theme, palette: theme.dark }, + { theme, palette: theme.light }, +]); + +describe('built-in TUI themes', () => { + it('keeps the default MCode palette byte-identical to the pre-theme implementation', () => { + // The default theme is what every existing user sees, so it must not move. + expect(MINIMAX_CODE_DARK_THEME.colors).toEqual({ + brand: '#68C0FF', + wordmarkHighlight: '#93D2FF', + wordmarkShadow: '#3DAEFF', + signal: '#68C0FF', + orbit: '#1CCDD2', + accent: '#68C0FF', + markdownHeading: '#CBA6F7', + markdownCode: '#A6E3A1', + markdownLink: '#68C0FF', + userMessageBg: '#262626', + diffAddedBg: '#213A2B', + diffRemovedBg: '#4A221D', + text: '#D6D6D6', + muted: '#ADADAD', + dim: '#666666', + border: '#303030', + line: '#666666', + success: '#28C567', + warning: '#FFC340', + error: '#FF5E6C', + }); + expect(MINIMAX_CODE_LIGHT_THEME.colors.text).toBe('#303030'); + expect(DEFAULT_THEME_ID).toBe('minimax'); + }); + + it.each(ALL_PALETTES)( + 'enforces readable semantic colors for $theme.id $palette.appearance', + ({ palette }) => { + const background = MINIMAX_CODE_THEME_CONTRAST_POLICY.backgrounds[palette.appearance]; + + for (const role of MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.roles) { + const exception = MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.exceptions.find( + (candidate) => candidate.appearance === palette.appearance && candidate.role === role, + ); + expect( + contrastRatio(palette.colors[role], background), + `${palette.id}.${palette.appearance}.${role} must remain readable against ${background}`, + ).toBeGreaterThanOrEqual( + exception?.minimum ?? MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.minimum, + ); + } + for (const role of MINIMAX_CODE_THEME_CONTRAST_POLICY.nonText.roles) { + expect( + contrastRatio(palette.colors[role], background), + `${palette.id}.${palette.appearance}.${role} must stay distinguishable against ${background}`, + ).toBeGreaterThanOrEqual(MINIMAX_CODE_THEME_CONTRAST_POLICY.nonText.minimum); + } + }, + ); + + it.each(ALL_PALETTES)( + 'defines every color and syntax tone for $theme.id $palette.appearance', + ({ palette }) => { + for (const name of TUI_THEME_COLOR_NAMES) { + expect(palette.colors[name], `${palette.id}.${name}`).toMatch( + /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/iu, + ); + } + for (const tone of TUI_SYNTAX_TONE_NAMES) { + expect(palette.syntax[tone], `${palette.id}.syntax.${tone}`).toMatch( + /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/iu, + ); + } + }, + ); + + it.each(ALL_PALETTES)( + 'keeps syntax text legible for $theme.id $palette.appearance', + ({ palette }) => { + const background = MINIMAX_CODE_THEME_CONTRAST_POLICY.backgrounds[palette.appearance]; + // Comments are the lowest-emphasis token but still have to be readable. + expect(contrastRatio(palette.syntax.overlay2, background)).toBeGreaterThanOrEqual(3); + expect(contrastRatio(palette.syntax.text, background)).toBeGreaterThanOrEqual(4.5); + }, + ); + + it('gives every built-in theme a matching id across both appearances', () => { + for (const theme of BUILT_IN_THEMES) { + expect(theme.dark.id).toBe(theme.id); + expect(theme.light.id).toBe(theme.id); + expect(theme.source).toBe('builtin'); + expect(theme.label.length).toBeGreaterThan(0); + } + const ids = BUILT_IN_THEMES.map((theme) => theme.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/packages/tui/test/unit/tui/theme/runtime.test.ts b/packages/tui/test/unit/tui/theme/runtime.test.ts new file mode 100644 index 00000000..d0da7544 --- /dev/null +++ b/packages/tui/test/unit/tui/theme/runtime.test.ts @@ -0,0 +1,695 @@ +import { stripVTControlCharacters } from 'node:util'; +import { describe, expect, it, vi } from 'vitest'; +import { + MINIMAX_CODE_DARK_THEME, + MINIMAX_CODE_LIGHT_THEME, +} from '../../../../src/tui/theme/palettes.js'; +import { + MINIMAX_CODE_THEME_CONTRAST_POLICY, + contrastRatio, +} from '../../../helpers/theme-contrast.js'; +import { + appearanceFromRgb, + parseColorFgBgAppearance, + resolveEnvironmentAppearance, +} from '../../../../src/tui/theme/detection.js'; +import { TuiThemeController, type TuiThemeUi } from '../../../../src/tui/theme/controller.js'; +import { bindThemeRendering } from '../../../../src/tui/theme/render-binding.js'; +import type { TUI } from '../../../../src/tui/engine/public.js'; +import { + applyTuiRenderTheme, + createTuiMarkdownTheme, + getTuiThemeSnapshot, + renderTuiActionHint, + tuiChalk, + tuiColors, + tuiStreamingMarkdownTheme, +} from '../../../../src/tui/theme/runtime.js'; + +describe('MCode terminal theme palettes', () => { + it.each([MINIMAX_CODE_DARK_THEME, MINIMAX_CODE_LIGHT_THEME])( + 'enforces readable semantic colors for the $appearance palette', + (palette) => { + const background = MINIMAX_CODE_THEME_CONTRAST_POLICY.backgrounds[palette.appearance]; + + for (const role of MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.roles) { + const exception = MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.exceptions.find( + (candidate) => candidate.appearance === palette.appearance && candidate.role === role, + ); + expect( + contrastRatio(palette.colors[role], background), + `${palette.appearance}.${role} must remain readable against ${background}`, + ).toBeGreaterThanOrEqual( + exception?.minimum ?? MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.minimum, + ); + } + for (const role of MINIMAX_CODE_THEME_CONTRAST_POLICY.nonText.roles) { + expect( + contrastRatio(palette.colors[role], background), + `${palette.appearance}.${role} must remain distinguishable against ${background}`, + ).toBeGreaterThanOrEqual(MINIMAX_CODE_THEME_CONTRAST_POLICY.nonText.minimum); + } + }, + ); + + it('uses explicit ANSI16 semantics instead of nearest-RGB collisions', () => { + const original = getTuiThemeSnapshot(); + const originalPalette = + original.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME; + + try { + applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 1); + expect(tuiChalk.hex(tuiColors.text)('text')).toBe('text'); + expect(tuiChalk.bold.hex(tuiColors.text)('strong')).toContain('\u001B[1m'); + expect(tuiChalk.hex(tuiColors.muted)('muted')).toContain('\u001B[2m'); + expect(tuiChalk.hex(tuiColors.brand)('brand')).toContain('\u001B[36m'); + expect(tuiChalk.hex(tuiColors.signal)('signal')).toContain('\u001B[36m'); + expect(tuiChalk.hex(tuiColors.accent)('accent')).toContain('\u001B[36m'); + expect(tuiChalk.hex(tuiColors.line)('line')).toContain('\u001B[90m'); + expect(tuiChalk.hex(tuiColors.warning)('warning')).toContain('\u001B[93m'); + expect(tuiChalk.hex(tuiColors.error)('error')).toContain('\u001B[91m'); + expect(tuiChalk.bgHex(tuiColors.userMessageBg)('message')).toBe('message'); + expect(tuiChalk.bgHex(tuiColors.diffAddedBg)('addition')).toBe('addition'); + expect(tuiChalk.bgHex(tuiColors.diffRemovedBg)('deletion')).toBe('deletion'); + + applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 2); + expect(tuiChalk.hex(tuiColors.text)('text')).toContain('\u001B[38;5;'); + expect(tuiChalk.hex(tuiColors.signal)('signal')).toContain('\u001B[38;5;'); + + applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 3); + expect(tuiChalk.hex(tuiColors.text)('text')).toContain('\u001B[38;2;'); + expect(tuiChalk.hex(tuiColors.signal)('signal')).toContain('\u001B[38;2;'); + + applyTuiRenderTheme(MINIMAX_CODE_LIGHT_THEME, 1); + expect(tuiChalk.bold.hex(tuiColors.signal)('signal')).toContain('\u001B[94m'); + expect(tuiChalk.hex(tuiColors.line)('line')).toContain('\u001B[90m'); + expect(tuiChalk.hex(tuiColors.warning)('warning')).toContain('\u001B[33m'); + expect(tuiChalk.hex(tuiColors.error)('error')).toContain('\u001B[31m'); + expect(tuiChalk.bgHex(tuiColors.userMessageBg)('message')).toBe('message'); + expect(tuiChalk.bgHex(tuiColors.diffAddedBg)('addition')).toBe('addition'); + expect(tuiChalk.bgHex(tuiColors.diffRemovedBg)('deletion')).toBe('deletion'); + + applyTuiRenderTheme(MINIMAX_CODE_LIGHT_THEME, 2); + expect(tuiChalk.hex(tuiColors.signal)('signal')).toContain('\u001B[38;5;'); + expect(tuiChalk.bgHex(tuiColors.userMessageBg)('message')).toContain('\u001B[48;5;'); + expect(tuiChalk.bgHex(tuiColors.diffAddedBg)('addition')).toContain('\u001B[48;5;'); + } finally { + applyTuiRenderTheme(originalPalette, original.colorLevel); + } + }); + + it('keeps action guidance readable and makes keyboard controls prominent', () => { + const original = getTuiThemeSnapshot(); + const originalPalette = + original.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME; + + try { + applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 3); + const rendered = renderTuiActionHint( + '↑↓ select · 1-9 choose · Enter confirm · d details · Ctrl+C stop · Option+M mode · /status refresh · Esc cancel', + ); + + expect(stripVTControlCharacters(rendered)).toBe( + '↑↓ select · 1-9 choose · Enter confirm · d details · Ctrl+C stop · Option+M mode · /status refresh · Esc cancel', + ); + for (const key of ['↑', '↓', '1-9', 'Enter', 'd', 'Ctrl+C', 'Option+M', '/status', 'Esc']) { + expect(rendered).toContain(tuiChalk.bold.hex(tuiColors.text)(key)); + } + } finally { + applyTuiRenderTheme(originalPalette, original.colorLevel); + } + }); + + it('uses adaptive Catppuccin syntax colors for settled and streaming code', () => { + const original = getTuiThemeSnapshot(); + const originalPalette = + original.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME; + const code = "const retries = 3; // keep streaming\nreturn 'ready';"; + + try { + applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 3); + const settled = createTuiMarkdownTheme().highlightCode?.(code, 'ts').join('\n'); + const streaming = tuiStreamingMarkdownTheme.highlightCode?.(code, 'ts').join('\n'); + + expect(settled).toContain('\u001B[38;2;'); + expect(settled).not.toContain('\u001B[3m'); + expect(settled).not.toContain('\u001B[4m'); + expect(stripVTControlCharacters(settled ?? '')).toBe(code); + expect(streaming).toBe(settled); + + applyTuiRenderTheme(MINIMAX_CODE_LIGHT_THEME, 3); + const light = createTuiMarkdownTheme().highlightCode?.(code, 'ts').join('\n'); + + expect(light).toContain('\u001B[38;2;'); + expect(light).not.toBe(settled); + expect(stripVTControlCharacters(light ?? '')).toBe(code); + } finally { + applyTuiRenderTheme(originalPalette, original.colorLevel); + } + }); +}); + +describe('MCode terminal appearance detection', () => { + it('uses the final COLORFGBG component, including the ANSI 256 grayscale ramp', () => { + expect(parseColorFgBgAppearance('15;0')).toBe('dark'); + expect(parseColorFgBgAppearance('0;15')).toBe('light'); + expect(parseColorFgBgAppearance('7;232')).toBe('dark'); + expect(parseColorFgBgAppearance('0;255')).toBe('light'); + expect(parseColorFgBgAppearance('broken')).toBeUndefined(); + }); + + it('computes appearance from relative luminance', () => { + expect(appearanceFromRgb({ r: 23, g: 23, b: 23 })).toBe('dark'); + expect(appearanceFromRgb({ r: 250, g: 250, b: 250 })).toBe('light'); + }); + + it('falls back without treating the operating-system theme as terminal evidence', () => { + expect(resolveEnvironmentAppearance({ COLORFGBG: '15;0' })).toMatchObject({ + appearance: 'dark', + source: 'colorfgbg', + }); + expect(resolveEnvironmentAppearance({})).toEqual({ + appearance: 'dark', + source: 'fallback', + detail: 'no terminal background hint', + }); + }); +}); + +describe('TuiThemeController', () => { + it('uses Pi scheduled rendering when a theme change invalidates the component tree', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 255, g: 255, b: 255 }; + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + }); + const invalidate = vi.fn(); + const requestRender = vi.fn(); + const rendering = bindThemeRendering( + controller, + { invalidate, requestRender } as unknown as TUI, + () => true, + ); + + rendering.start(); + await vi.waitFor(() => expect(requestRender).toHaveBeenCalledOnce()); + + expect(invalidate).toHaveBeenCalledOnce(); + expect(requestRender).toHaveBeenCalledWith(); + controller.dispose(); + }); + + it('applies passive auto detection immediately through live theme bindings', () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '0;15' }, + }); + + expect(controller.snapshot()).toMatchObject({ appearance: 'light', source: 'colorfgbg' }); + expect(getTuiThemeSnapshot()).toMatchObject({ appearance: 'light' }); + expect(tuiColors.text).toBe(MINIMAX_CODE_LIGHT_THEME.colors.text); + expect(tuiChalk.hex(tuiColors.text)('body')).toContain('38;2;48;48;48'); + controller.dispose(); + }); + + it('uses passive evidence immediately and upgrades to terminal evidence asynchronously', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 255, g: 255, b: 255 }; + const onChange = vi.fn(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + }); + controller.onChange(onChange); + + expect(controller.snapshot()).toMatchObject({ appearance: 'dark', source: 'colorfgbg' }); + await controller.start(); + + expect(controller.snapshot()).toMatchObject({ appearance: 'light', source: 'osc11' }); + expect(onChange).toHaveBeenCalled(); + }); + + it('uses the actual OSC 11 background when a terminal reports a conflicting appearance', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 40, g: 44, b: 52 }; + const queryBackground = vi.spyOn(ui, 'queryTerminalBackgroundColor'); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '0;15' }, + }); + + await controller.start(); + ui.emit('light'); + await vi.waitFor(() => expect(queryBackground).toHaveBeenCalledTimes(2)); + + expect(controller.snapshot()).toMatchObject({ appearance: 'dark', source: 'osc11' }); + controller.dispose(); + }); + + it('does not request a redraw when only the detection source changes', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 0, g: 0, b: 0 }; + const onChange = vi.fn(); + const onDetection = vi.fn(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + onDetection, + }); + controller.onChange(onChange); + + await controller.start(); + + expect(controller.snapshot()).toMatchObject({ appearance: 'dark', source: 'osc11' }); + expect(onChange).not.toHaveBeenCalled(); + expect(onDetection).toHaveBeenLastCalledWith( + expect.objectContaining({ appearance: 'dark', source: 'osc11' }), + ); + controller.dispose(); + }); + + it('falls back to environment evidence when later terminal queries time out', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 255, g: 255, b: 255 }; + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + }); + + await controller.start(); + expect(controller.snapshot()).toMatchObject({ appearance: 'light', source: 'osc11' }); + + ui.nextBackground = undefined; + await controller.start(); + + expect(controller.snapshot()).toMatchObject({ appearance: 'dark', source: 'colorfgbg' }); + controller.dispose(); + }); + + it('always tracks supported terminal appearance changes', async () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ ui, colorLevel: 3, env: {} }); + await controller.start(); + + ui.nextBackground = { r: 255, g: 255, b: 255 }; + ui.emit('light'); + await vi.waitFor(() => expect(controller.snapshot().appearance).toBe('light')); + controller.dispose(); + }); + + it('follows a reported appearance change when the background query stops answering', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 255, g: 255, b: 255 }; + const onChange = vi.fn(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '0;15' }, + }); + controller.onChange(onChange); + + await controller.start(); + expect(controller.snapshot()).toMatchObject({ appearance: 'light', source: 'osc11' }); + + ui.nextBackground = undefined; + ui.emit('dark'); + + await vi.waitFor(() => + expect(controller.snapshot()).toMatchObject({ + appearance: 'dark', + source: 'terminal-report', + }), + ); + expect(onChange).toHaveBeenCalled(); + controller.dispose(); + }); + + it('never lets the process-start COLORFGBG snapshot override a later terminal report', async () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '0;15' }, + }); + + await controller.start(); + ui.emit('dark'); + await vi.waitFor(() => expect(controller.snapshot().appearance).toBe('dark')); + + // A renderer rebind re-queries without a fresh report; the stale light COLORFGBG must not win. + controller.rebindUi(); + await vi.waitFor(() => expect(controller.snapshot().source).toBe('terminal-report')); + expect(controller.snapshot().appearance).toBe('dark'); + controller.dispose(); + }); + + it('invalidates an in-flight terminal query when the active Pi renderer changes', async () => { + const ui = new ThemeUi(); + let resolveOldQuery: ((value: { r: number; g: number; b: number }) => void) | undefined; + const oldQuery = new Promise<{ r: number; g: number; b: number }>((resolve) => { + resolveOldQuery = resolve; + }); + const queryBackground = vi + .spyOn(ui, 'queryTerminalBackgroundColor') + .mockImplementationOnce(() => oldQuery) + .mockResolvedValue({ r: 0, g: 0, b: 0 }); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '0;15' }, + }); + + const starting = controller.start(); + await vi.waitFor(() => expect(queryBackground).toHaveBeenCalledOnce()); + controller.rebindUi(); + await vi.waitFor(() => expect(queryBackground).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => + expect(controller.snapshot()).toMatchObject({ appearance: 'dark', source: 'osc11' }), + ); + + resolveOldQuery?.({ r: 255, g: 255, b: 255 }); + await starting; + expect(controller.snapshot()).toMatchObject({ appearance: 'dark', source: 'osc11' }); + controller.dispose(); + }); +}); + +class ThemeUi implements TuiThemeUi { + nextBackground: { r: number; g: number; b: number } | undefined; + notificationsEnabled = false; + private listener: ((scheme: 'light' | 'dark') => void) | undefined; + + async queryTerminalBackgroundColor() { + return this.nextBackground; + } + + setTerminalColorSchemeNotifications(enabled: boolean): void { + this.notificationsEnabled = enabled; + } + + onTerminalColorSchemeChange(listener: NonNullable): () => void { + this.listener = listener; + return () => { + if (this.listener === listener) this.listener = undefined; + }; + } + + emit(scheme: Parameters>[0]): void { + this.listener?.(scheme); + } +} + +describe('TuiThemeController theme selection', () => { + const restoreTheme = () => { + const snapshot = getTuiThemeSnapshot(); + applyTuiRenderTheme( + snapshot.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME, + snapshot.colorLevel, + ); + }; + + it('applies the saved theme selection at construction', () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + theme: 'aurora', + }); + + expect(controller.selectedThemeId()).toBe('aurora'); + expect(controller.snapshot().themeId).toBe('aurora'); + expect(getTuiThemeSnapshot()).toMatchObject({ name: 'aurora', appearance: 'dark' }); + expect(tuiColors.brand).toBe('#5CC8E8'); + controller.dispose(); + restoreTheme(); + }); + + it('falls back to the default theme when the saved theme is unknown', () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + theme: 'not-a-real-theme', + }); + + expect(controller.selectedThemeId()).toBe('minimax'); + controller.dispose(); + restoreTheme(); + }); + + it('switches the live palette and notifies listeners on setTheme', () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' } }); + const onChange = vi.fn(); + controller.onChange(onChange); + const before = tuiColors.brand; + + const applied = controller.setTheme('midnight'); + + expect(applied?.id).toBe('midnight'); + expect(controller.selectedThemeId()).toBe('midnight'); + expect(tuiColors.brand).not.toBe(before); + expect(tuiColors.brand).toBe('#5AB9FF'); + expect(onChange).toHaveBeenCalled(); + controller.dispose(); + restoreTheme(); + }); + + it('keeps the current theme when an unknown id is requested', () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' } }); + controller.setTheme('graphite'); + const brand = tuiColors.brand; + + expect(controller.setTheme('nope')).toBeUndefined(); + + expect(controller.selectedThemeId()).toBe('graphite'); + expect(tuiColors.brand).toBe(brand); + controller.dispose(); + restoreTheme(); + }); + + it('previews a theme without losing the previous selection on restore', () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' } }); + const original = tuiColors.brand; + + controller.previewTheme('aurora'); + expect(getTuiThemeSnapshot().name).toBe('aurora'); + + controller.previewTheme('minimax'); + expect(tuiColors.brand).toBe(original); + controller.dispose(); + restoreTheme(); + }); + + it('repaints code blocks with the active theme syntax tones', () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' } }); + const code = "const retries = 3; // keep streaming\nreturn 'ready';"; + const minimaxCode = createTuiMarkdownTheme().highlightCode?.(code, 'ts').join('\n'); + + controller.setTheme('midnight'); + const midnightCode = createTuiMarkdownTheme().highlightCode?.(code, 'ts').join('\n'); + + expect(midnightCode).not.toBe(minimaxCode); + expect(stripVTControlCharacters(midnightCode ?? '')).toBe(code); + controller.dispose(); + restoreTheme(); + }); + + it('pins the appearance so terminal evidence cannot override it', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 0, g: 0, b: 0 }; + const controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' } }); + + controller.setAppearanceOverride('light'); + await controller.start(); + ui.emit('dark'); + await vi.waitFor(() => expect(controller.snapshot().source).toBe('osc11')); + + expect(controller.snapshot().appearance).toBe('light'); + expect(getTuiThemeSnapshot().appearance).toBe('light'); + controller.dispose(); + restoreTheme(); + }); + + it('restores terminal-driven appearance when the pin is cleared', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 255, g: 255, b: 255 }; + const controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' } }); + await controller.start(); + + controller.setAppearanceOverride('dark'); + expect(controller.snapshot().appearance).toBe('dark'); + + // No extra query: clearing the pin must restore the last terminal verdict + // immediately, which is what /theme's Esc and the auto cycle actually do. + controller.setAppearanceOverride(undefined); + + expect(controller.appearanceOverrideValue()).toBeUndefined(); + expect(controller.snapshot().appearance).toBe('light'); + expect(getTuiThemeSnapshot().appearance).toBe('light'); + expect(tuiColors.text).toBe(MINIMAX_CODE_LIGHT_THEME.colors.text); + controller.dispose(); + restoreTheme(); + }); + + it('restores a dark terminal verdict when clearing a light pin', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 0, g: 0, b: 0 }; + const controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' } }); + await controller.start(); + expect(controller.snapshot().appearance).toBe('dark'); + + controller.setAppearanceOverride('light'); + expect(controller.snapshot().appearance).toBe('light'); + + controller.setAppearanceOverride(undefined); + + expect(controller.snapshot().appearance).toBe('dark'); + expect(tuiColors.text).toBe(MINIMAX_CODE_DARK_THEME.colors.text); + controller.dispose(); + restoreTheme(); + }); + + it('repaints live colors when a custom theme file changes in place', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const dataDir = await mkdtemp(join(tmpdir(), 'mcode-theme-reload-')); + try { + const themesDir = join(dataDir, 'tui', 'themes'); + await mkdir(themesDir, { recursive: true }); + const file = join(themesDir, 'mine.json'); + const write = (brand: string) => + writeFile(file, JSON.stringify({ name: 'mine', appearance: 'dark', colors: { brand } })); + + await write('#112233'); + const ui = new ThemeUi(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + dataDir, + }); + expect(controller.setTheme('mine')).toBeDefined(); + expect(tuiColors.brand).toBe('#112233'); + const onChange = vi.fn(); + controller.onChange(onChange); + + // Same id, same appearance — only the color values move. + await write('#AABBCC'); + await vi.waitFor(() => expect(tuiColors.brand).toBe('#AABBCC'), { timeout: 4000 }); + + expect(controller.selectedThemeId()).toBe('mine'); + expect(onChange).toHaveBeenCalled(); + controller.dispose(); + } finally { + await rm(dataDir, { recursive: true, force: true }); + restoreTheme(); + } + }); + + it('does not repaint when a custom theme reload changes nothing', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const dataDir = await mkdtemp(join(tmpdir(), 'mcode-theme-noop-')); + try { + const themesDir = join(dataDir, 'tui', 'themes'); + await mkdir(themesDir, { recursive: true }); + const file = join(themesDir, 'mine.json'); + const body = JSON.stringify({ + name: 'mine', + appearance: 'dark', + colors: { brand: '#112233' }, + }); + await writeFile(file, body); + + const ui = new ThemeUi(); + const onThemesChanged = vi.fn(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + dataDir, + onThemesChanged, + }); + controller.setTheme('mine'); + const onChange = vi.fn(); + controller.onChange(onChange); + // The constructor load already fired once; only a watcher-driven reload + // may satisfy the wait below. + onThemesChanged.mockClear(); + + await writeFile(file, body); + // Wait for the reload to actually land. A bare sleep would also pass when + // the watcher never fired, which is exactly what this test must rule out. + await vi.waitFor(() => expect(onThemesChanged).toHaveBeenCalled(), { timeout: 4000 }); + + expect(tuiColors.brand).toBe('#112233'); + expect(onChange).not.toHaveBeenCalled(); + controller.dispose(); + } finally { + await rm(dataDir, { recursive: true, force: true }); + restoreTheme(); + } + }); + + it('applies a pinned appearance from the saved selection', () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 0, g: 0, b: 0 }; + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + theme: 'aurora/light', + }); + + expect(controller.selectedThemeId()).toBe('aurora'); + expect(controller.appearanceOverrideValue()).toBe('light'); + expect(controller.snapshot().appearance).toBe('light'); + expect(tuiColors.text).toBe('#1E2B31'); + controller.dispose(); + restoreTheme(); + }); + + it('lists built-in themes and surfaces custom load problems', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const dataDir = await mkdtemp(join(tmpdir(), 'mcode-theme-controller-')); + try { + await mkdir(join(dataDir, 'tui', 'themes'), { recursive: true }); + await writeFile( + join(dataDir, 'tui', 'themes', 'broken.json'), + JSON.stringify({ name: 'broken' }), + ); + const ui = new ThemeUi(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + dataDir, + }); + + expect(controller.listThemes().map((theme) => theme.id)).toContain('minimax'); + expect(controller.themeIssues()).toHaveLength(1); + controller.dispose(); + } finally { + await rm(dataDir, { recursive: true, force: true }); + restoreTheme(); + } + }); +}); diff --git a/release/public-source.json b/release/public-source.json index addd3d8c..058758e3 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -2791,6 +2791,7 @@ "packages/tui/THIRD_PARTY_NOTICES.md", "packages/tui/configs/data-minimal.yaml", "packages/tui/docs/status-line-config.md", + "packages/tui/docs/theme-config.md", "packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node", "packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node", "packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node", @@ -2970,6 +2971,7 @@ "packages/tui/src/tui/controller/product/model-state.ts", "packages/tui/src/tui/controller/product/session-mutation-flow.ts", "packages/tui/src/tui/controller/product/status-line-setup.ts", + "packages/tui/src/tui/controller/product/theme-setup.ts", "packages/tui/src/tui/controller/product/update-admission.ts", "packages/tui/src/tui/controller/product/update-flow.ts", "packages/tui/src/tui/controller/projection/status-metrics-flow.ts", @@ -3100,6 +3102,7 @@ "packages/tui/src/tui/features/settings/picker.ts", "packages/tui/src/tui/features/settings/status-line-copy.ts", "packages/tui/src/tui/features/settings/status-line-picker.ts", + "packages/tui/src/tui/features/settings/theme-picker.ts", "packages/tui/src/tui/features/transcript/panel.ts", "packages/tui/src/tui/features/update/panel.ts", "packages/tui/src/tui/foundation/bordered-table.ts", @@ -3162,8 +3165,10 @@ "packages/tui/src/tui/theme/ansi16.ts", "packages/tui/src/tui/theme/contracts.ts", "packages/tui/src/tui/theme/controller.ts", + "packages/tui/src/tui/theme/custom-themes.ts", "packages/tui/src/tui/theme/detection.ts", "packages/tui/src/tui/theme/palettes.ts", + "packages/tui/src/tui/theme/registry.ts", "packages/tui/src/tui/theme/render-binding.ts", "packages/tui/src/tui/theme/runtime.ts", "packages/tui/src/tui/theme/shell-command.ts", @@ -3211,6 +3216,7 @@ "packages/tui/src/update/startup-notice.ts", "packages/tui/src/update/versioned-prefix.ts", "packages/tui/src/user-facing-failure.ts", + "packages/tui/test/helpers/theme-contrast.ts", "packages/tui/test/helpers/virtual-terminal.ts", "packages/tui/test/pi-084-upstream/virtual-terminal.ts", "packages/tui/test/unit/acp-agent.test.ts", @@ -3227,6 +3233,7 @@ "packages/tui/test/unit/headless-model-selection.test.ts", "packages/tui/test/unit/headless-preparation-cancellation.test.ts", "packages/tui/test/unit/headless-runner.test.ts", + "packages/tui/test/unit/host-tui-settings.test.ts", "packages/tui/test/unit/incident-reporter-privacy.test.ts", "packages/tui/test/unit/mcode-tools-integration.test.ts", "packages/tui/test/unit/model-effort.test.ts", @@ -3287,6 +3294,10 @@ "packages/tui/test/unit/tui/features/composer/attachments.test.ts", "packages/tui/test/unit/tui/features/settings/hotkeys-picker.test.ts", "packages/tui/test/unit/tui/features/settings/status-line-picker.test.ts", + "packages/tui/test/unit/tui/features/settings/theme-picker.test.ts", + "packages/tui/test/unit/tui/theme/custom-themes.test.ts", + "packages/tui/test/unit/tui/theme/palettes.test.ts", + "packages/tui/test/unit/tui/theme/runtime.test.ts", "packages/tui/test/unit/tui/widgets/editor/editor-behavior.test.ts", "packages/tui/test/unit/update-application.test.ts", "packages/tui/test/unit/update-service.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 0601656f..b1c80517 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -165,7 +165,12 @@ "packages/local-runtime-v2/test/integration/btw-settled-history.integration.test.ts", "packages/tui/test/unit/tui/controller/side-session-flow.test.ts", "packages/tui/test/unit/observability.test.ts", - "packages/local-runtime-v2/src/service/session-system/fork/side-history-boundary.test.ts" + "packages/local-runtime-v2/src/service/session-system/fork/side-history-boundary.test.ts", + "packages/tui/test/unit/host-tui-settings.test.ts", + "packages/tui/test/unit/tui/features/settings/theme-picker.test.ts", + "packages/tui/test/unit/tui/theme/custom-themes.test.ts", + "packages/tui/test/unit/tui/theme/palettes.test.ts", + "packages/tui/test/unit/tui/theme/runtime.test.ts" ], "status-contract": [ "packages/tui/test/unit/tui-build-mode-contract.test.ts" From a346908903d66757dbff385a3e26a02c232f9c3c Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:57:53 +0800 Subject: [PATCH 03/23] chore: release MiniMax Code 0.5.2 (#313) --- package.json | 2 +- packages/tui/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b231e65f..0accb098 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minimax-code", - "version": "0.5.1", + "version": "0.5.2", "private": true, "type": "module", "description": "Standalone MiniMax Code TUI with managed accounts, BYOK models, cloud tools, plugins and ACP.", diff --git a/packages/tui/package.json b/packages/tui/package.json index 8342bcb7..95e9a476 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@minimax/code", - "version": "0.5.1", + "version": "0.5.2", "private": true, "description": "Minimax Code CLI and TUI product entry.", "type": "module", From 44b13d381e6f6494357d48e8f2473e0e65753379 Mon Sep 17 00:00:00 2001 From: TrentChou <49196512+TrentChou@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:40:49 +0800 Subject: [PATCH 04/23] perf(edit): bound the post-edit diff and unified patch (#284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(edit): bound the post-edit diff and unified patch `edit` computed its display diff and its unified patch with unbounded Myers, which costs O((N+M)*D) in the edit-script length D. A whole-file rewrite therefore scaled quadratically in the number of changed lines: rewriting every line of a 20 000-line file blocked the tool for over two minutes and produced a multi-megabyte patch that no renderer displays. The sibling `write` path already caps the same work in `packages/agent-tools/src/shared/write-capture.ts`; `edit` had no cap. Pass jsdiff's `maxEditLength` (1000 edits) and `timeout` (5 s) to `diffLines` and `createTwoFilesPatch`. `maxEditLength` is the primary bound because it is deterministic and therefore unit-testable; `timeout` only backstops slow machines. When a bound trips, `details.diff` carries a one-line notice so every renderer still has something to show, `details.patch` is omitted, and the new `details.diffOmitted` names the reason. The unified patch is skipped once the display diff was abandoned rather than repeating a second Myers run that aborts on the same bound. The file is written before any diff runs, so a dropped diff never changes what lands on disk. Measured end to end through `createEditTool` on a 20 000-line whole-file rewrite, three runs each on the same machine: 120 s / 138 s / 139 s before, 60 ms / 60 ms / 58 ms after. Assisted-by: minimax-code reason:bound-edit-diff-cost * fix(tui): render an omitted edit diff as a summary block When the edit tool hits its diff bounds it replaces the diff body with a one-line notice, so the body carries no `+`/`-` lines. Both preview consumers misread that notice as a real diff: - The TUI counted changed lines from the body and rendered `subject.ts · +0 -0 · Applied` for a 20k-line rewrite. - The ACP projector ran the notice through `parseUnifiedDiff`, emitting a diff block with `oldText === newText === "(diff omitted: ...)"`, which a client renders as "no changes". Read `details.diffOmitted` alongside `details.diff` and emit the existing summary block instead, which renders as a header plus the notice and needs no line counts. `timeout` maps to `unavailable`, bounds hits to `too-large`. E2E: PASS (tmux TUI against dist/cli.js, 20000-line replace_all edit: header is `subject.ts · Applied` + `(diff omitted: more than 1000 lines changed)`; a 1-line edit still renders `+1 -1 · Applied`) * fix(agent-tools): keep Compatible hooks working when the edit patch is bounded out Bounding the post-edit diff drops `details.patch` on exactly the edits the bound targets, and `withCompatibleEditToolResponse` returned the result untouched in that case. The Compatible PostToolUse payload then carried no `tool_response`, so `needsPostToolAdapter` found no usable CLAUDE handler and the runner rejected the hook with HOOK_INVALID_INPUT — every Compatible PostToolUse hook was silently skipped on large edits that used to run fine. There is no fallback for `edit` in `buildCompatiblePostToolResponse`, unlike bash, read and mcp. Synthesize the structured patch instead of reparsing one that does not exist: a bounded-out edit is by definition a wholesale rewrite, so emit a single hunk that removes every old line and adds every new one. This costs no Myers run, keeps the payload the same order as the `originalFile` already in it, and stays a correct — if imprecise — description of what landed on disk. The `\ No newline at end of file` marker is reproduced on whichever side lacks the trailing newline, in jsdiff's position. `EditCapture` now also carries the written content, and BOM stripping is shared between the two sides so the synthesized hunk matches what jsdiff would have produced. Assisted-by: minimax-code reason:bound-edit-diff-cost * fix(edit): report why the unified patch was omitted The unified patch is a second, independently timed Myers run over the same input as the display diff. When only that run ran out of time, `details` held a diff, no patch, and no `diffOmitted`, so a consumer could not tell an omitted patch apart from a tool that never produces one. Add `details.patchOmitted`: it mirrors `diffOmitted` when the display diff was abandoned, and reports `timeout` when only the patch was, so `details.patch` is absent exactly when `patchOmitted` is set. jsdiff routes `createTwoFilesPatch` through the same bounded `diffLines`, so `maxEditLength` is deterministic across both runs and can never trip for the patch alone; only its separately measured wall clock can. `diffOmitted` keeps its meaning and is unchanged. The patch-only timeout is not unit-testable — it needs the two runs to land on opposite sides of a 5 s wall clock, which no deterministic input can force — so the tests pin the invariant on both reachable paths instead. Assisted-by: minimax-code reason:bound-edit-diff-cost * fix(edit): raise the diff bound so ordinary rewrites keep their diff `maxEditLength` bounds D, the length of the Myers edit script, not the number of changed lines: replacing a line costs one deletion plus one insertion. A bound of 1000 therefore gave up on any full rewrite past 500 lines, and a reported 501-line whole-file replacement lost its diff even though the whole tool call, diff included, finishes in under 55 ms. That is well inside what the tool should still render. Raise the bound to 2000, which admits a full rewrite of any file up to 1000 lines. Measured end to end through `createEditTool`, three runs each on the same machine: 53 / 49 / 47 ms for a 501-line rewrite, 177 / 173 / 177 ms for 1000 lines, and 200 / 192 / 193 ms for a 20 000-line rewrite that still gives up — against 167 620 / 173 523 / 167 547 ms unbounded, with peak heap dropping from 47-60 MB to 14-15 MB. Doubling the bound roughly doubles the worst case the bound itself admits, which stays near 200 ms. Also correct the notice the bound emits. It read "more than N lines changed", which misreports a rewrite by a factor of two; D is exactly the number of added plus removed lines, so say that instead. Assisted-by: minimax-code reason:bound-edit-diff-cost * fix(agent-tools): send an empty structured patch when the edit patch is bounded out Synthesizing a whole-file hunk kept Compatible PostToolUse handlers selectable when the bound dropped `details.patch`, but pushed the hook payload past the runner's 1 MiB input limit on exactly the large files the bound targets. A 20 000-line file with 1001 changed lines serialized to 1 679 234 B and failed `serializeHookInput`, so the handler still never ran — it now failed one step later than before. Trimming the common prefix and suffix does not fix that either: the same shape still reaches 1 154 543 B on a 960 KB file. `structuredPatch` is a required array in the Compatible file-edit output schema, and the upstream tool already sends `[]` when its own diff times out, so the empty array is that schema's own way to say "no diff information". It keeps the handler selectable, keeps `edit` renamed to `Edit` for matchers, and passes the write-back `hasCompatibleJsonShape` check, which compares array-ness only and so still accepts a hook that returns real hunks. The same payload now serializes to 563 600 B. `EditCapture.updatedFile` existed only to feed the synthesized hunk and goes away with it, restoring the capture to its previous shape. A malformed `details.patch` now also degrades to `[]` instead of dropping the whole compatible response, which is strictly better: the handler runs rather than being skipped with HOOK_INVALID_INPUT. This does not make every hook payload fit. The response separately carries `originalFile`, `oldString` and `newString` in full, so a whole-file rewrite of a large file still exceeds the limit on those fields alone, both before and after this change. That bound predates this branch and is left alone. Also correct the stale timings in the test header, which predated the remeasurement at the current bound. Assisted-by: minimax-code reason:bound-edit-diff-cost --- .../src/desktop/edit-diff-bounds.test.ts | 179 ++++++++++++++++++ .../src/plugin-hooks/vendor-tool-response.ts | 54 ++++-- packages/tui/src/runtime/tool-preview.ts | 36 +++- .../tui-tool-preview-diff-omitted.test.ts | 106 +++++++++++ release/public-source.json | 2 + test/vitest-suites.json | 2 + third_party/pi-mono/MINIMAX_CHANGES.md | 16 ++ .../coding-agent/src/core/tools/edit-diff.ts | 66 ++++++- .../coding-agent/src/core/tools/edit.ts | 29 ++- 9 files changed, 456 insertions(+), 34 deletions(-) create mode 100644 packages/agent-tools/src/desktop/edit-diff-bounds.test.ts create mode 100644 packages/tui/test/unit/tui-tool-preview-diff-omitted.test.ts diff --git a/packages/agent-tools/src/desktop/edit-diff-bounds.test.ts b/packages/agent-tools/src/desktop/edit-diff-bounds.test.ts new file mode 100644 index 00000000..9877decd --- /dev/null +++ b/packages/agent-tools/src/desktop/edit-diff-bounds.test.ts @@ -0,0 +1,179 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { readPluginHookCompatibleToolResponse } from '../plugin-hooks/vendor-tool-response.js'; +import { LocalEditTool } from './local-pi-tools.js'; + +interface CompatibleHunk { + readonly oldStart: number; + readonly oldLines: number; + readonly newStart: number; + readonly newLines: number; + readonly lines: readonly string[]; +} + +const context = { sessionId: 'edit-diff-bounds-session', turnId: 'edit-diff-bounds-turn' }; + +// Myers costs O((N+M)·D) in the length D of the edit script, so a whole-file +// rewrite is quadratic in the number of changed lines. Rewriting every line of +// this file took 167.5-173.5 s unbounded (the tool diffed the same input twice) +// and produced a diff no renderer displays; bounded it settles in 192-200 ms. +// Vitest's default 5 s timeout therefore also guards the bound: if it is ever +// removed, the whole-file case stops finishing in time. +const wholeFileRewriteLines = 20_000; + +// Replacing a line costs 2 edits, so DIFF_MAX_EDIT_LENGTH (2000) admits a full +// rewrite of a file this long and omits anything longer. +const boundedRewriteLines = 1_000; + +function body(lines: number, render: (index: number) => string): string { + return `${Array.from({ length: lines }, (_, index) => render(index)).join('\n')}\n`; +} + +describe('edit diff bounds', () => { + let directory: string; + let file: string; + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'edit-diff-bounds-')); + file = join(directory, 'subject.ts'); + }); + + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it('keeps the diff and patch for an ordinary edit', async () => { + await writeFile(file, 'const value = 1;\nconst other = 2;\n'); + + const result = await new LocalEditTool(directory).execute(context, { + file_path: 'subject.ts', + old_string: 'const value = 1;', + new_string: 'const value = 42;', + }); + + expect(await readFile(file, 'utf8')).toBe('const value = 42;\nconst other = 2;\n'); + expect(result.details?.diffOmitted).toBeUndefined(); + expect(result.details?.patchOmitted).toBeUndefined(); + expect(result.details?.diff).toContain('const value = 42;'); + expect(result.details?.patch).toContain('@@'); + expect(result.details?.patch).toContain('+const value = 42;'); + }); + + it('keeps the diff for a large but bounded block replacement', async () => { + const original = body(wholeFileRewriteLines, (index) => `const value${index} = ${index};`); + await writeFile(file, original); + // 400 replaced lines cost 800 edits, which stays under the bound even + // though the surrounding file is large: the bound tracks changed lines, + // not file size. + const oldBlock = body(400, (index) => `const value${index} = ${index};`); + const newBlock = body(400, (index) => `const value${index} = ${index + 1};`); + + const result = await new LocalEditTool(directory).execute(context, { + file_path: 'subject.ts', + old_string: oldBlock, + new_string: newBlock, + }); + + expect(await readFile(file, 'utf8')).toBe(`${newBlock}${original.slice(oldBlock.length)}`); + expect(result.details?.diffOmitted).toBeUndefined(); + expect(result.details?.patch).toContain('@@'); + // The Compatible hook keeps the real hunks whenever the patch survives. + const hunks = readPluginHookCompatibleToolResponse(result)?.structuredPatch as + | readonly CompatibleHunk[] + | undefined; + expect(hunks?.length).toBeGreaterThan(0); + expect(hunks?.some((hunk) => hunk.lines.includes('+const value0 = 1;'))).toBe(true); + }); + + // Rewriting a few hundred lines is an ordinary edit, not the pathological + // case this bound targets: a reported 501-line whole-file replacement lost + // its diff under the first bound this test pins. + it('keeps the diff for a whole-file rewrite that fits the bound', async () => { + const original = body(boundedRewriteLines, (index) => `const value${index} = ${index};`); + const rewritten = body(boundedRewriteLines, (index) => `let renamed${index} = ${index * 2};`); + await writeFile(file, original); + + const result = await new LocalEditTool(directory).execute(context, { + file_path: 'subject.ts', + old_string: original, + new_string: rewritten, + }); + + expect(await readFile(file, 'utf8')).toBe(rewritten); + expect(result.details?.diffOmitted).toBeUndefined(); + expect(result.details?.patchOmitted).toBeUndefined(); + expect(result.details?.patch).toContain('+let renamed0 = 0;'); + }); + + it('omits the diff for a whole-file rewrite but still writes the file', async () => { + const original = body(wholeFileRewriteLines, (index) => `const value${index} = ${index};`); + const rewritten = body(wholeFileRewriteLines, (index) => `let renamed${index} = ${index * 2};`); + await writeFile(file, original); + + const result = await new LocalEditTool(directory).execute(context, { + file_path: 'subject.ts', + old_string: original, + new_string: rewritten, + }); + + // The edit itself must be unaffected: the diff is a receipt computed after + // the write, so giving it up never changes what lands on disk. + expect(await readFile(file, 'utf8')).toBe(rewritten); + expect(result.isError).toBeFalsy(); + + expect(result.details?.diffOmitted).toBe('too_many_changes'); + expect(result.details?.patchOmitted).toBe('too_many_changes'); + expect(result.details?.patch).toBeUndefined(); + expect(result.details?.diff).toContain('diff omitted'); + }); + + // A Compatible PostToolUse handler is skipped with HOOK_INVALID_INPUT when + // `structuredPatch` is missing, so dropping `details.patch` silently disabled + // every such hook on exactly the edits this bound targets. + it('sends the Compatible hook an empty structured patch when the patch is omitted', async () => { + const original = body(wholeFileRewriteLines, (index) => `const value${index} = ${index};`); + const rewritten = body(wholeFileRewriteLines, (index) => `let renamed${index} = ${index * 2};`); + await writeFile(file, original); + + const result = await new LocalEditTool(directory).execute(context, { + file_path: 'subject.ts', + old_string: original, + new_string: rewritten, + }); + + expect(result.details?.patch).toBeUndefined(); + const response = readPluginHookCompatibleToolResponse(result); + expect(response).toBeDefined(); + expect(response?.originalFile).toBe(original); + expect(response?.structuredPatch).toEqual([]); + }); + + // The runner rejects a serialized hook input over MAX_INPUT_BYTES (1 MiB), so + // an unavailable patch has to degrade to something bounded: describing the + // edit as one whole-file hunk grew this payload to 1 679 234 B against + // 563 600 B here, and failed the same handler a second way. The response + // separately carries `originalFile`, `oldString` and `newString` in full, so + // this case keeps the edit strings small to isolate the patch field. + it('keeps the Compatible hook payload under the runner input limit', async () => { + const original = body(wholeFileRewriteLines, (index) => `const value${index} = ${index};`); + await writeFile(file, original); + // 1001 replaced lines cost 2002 edits, one past the bound. + const oldBlock = body(1_001, (index) => `const value${index} = ${index};`); + const newBlock = body(1_001, (index) => `const value${index} = ${index + 1};`); + + const result = await new LocalEditTool(directory).execute(context, { + file_path: 'subject.ts', + old_string: oldBlock, + new_string: newBlock, + }); + + expect(result.details?.patchOmitted).toBe('too_many_changes'); + const response = readPluginHookCompatibleToolResponse(result); + expect(response?.structuredPatch).toEqual([]); + expect(Buffer.byteLength(JSON.stringify(response), 'utf8')).toBeLessThan(1024 * 1024); + }); +}); diff --git a/packages/agent-tools/src/plugin-hooks/vendor-tool-response.ts b/packages/agent-tools/src/plugin-hooks/vendor-tool-response.ts index 69ca7c8d..09e4a4c9 100644 --- a/packages/agent-tools/src/plugin-hooks/vendor-tool-response.ts +++ b/packages/agent-tools/src/plugin-hooks/vendor-tool-response.ts @@ -195,6 +195,14 @@ export function withCompatibleGrepToolResponse( }); } +interface CompatibleStructuredPatchHunk { + readonly oldStart: number; + readonly oldLines: number; + readonly newStart: number; + readonly newLines: number; + readonly lines: readonly string[]; +} + export function withCompatibleEditToolResponse( result: ToolResult, input: { @@ -206,30 +214,40 @@ export function withCompatibleEditToolResponse( readonly userModified: boolean; }, ): ToolResult { - const patch = typeof result.details?.patch === 'string' ? result.details.patch : undefined; - if (!patch) return result; - let parsed: ReturnType[number] | undefined; - try { - parsed = parsePatch(patch)[0]; - } catch { - // Hook compatibility is an enhancement; malformed vendor metadata must - // never turn a successful edit into a failed tool call. - return result; - } - if (!parsed) return result; return withPluginHookCompatibleToolResponse(result, { filePath: input.filePath, oldString: input.oldString, newString: input.newString, originalFile: input.originalFile, - structuredPatch: parsed.hunks.map((hunk) => ({ - oldStart: hunk.oldStart, - oldLines: hunk.oldLines, - newStart: hunk.newStart, - newLines: hunk.newLines, - lines: hunk.lines, - })), + structuredPatch: parseStructuredPatch(result.details?.patch), userModified: input.userModified, replaceAll: input.replaceAll, }); } + +/** + * `structuredPatch` is a required array in the Compatible file-edit response, + * and the upstream tool already sends `[]` when its own diff gives up, so an + * unavailable patch degrades to the empty array. Dropping the field instead + * makes the runner skip the handler before it starts, and describing the edit + * as one whole-file hunk pushes the payload past the runner's input limit. + */ +function parseStructuredPatch(patch: unknown): CompatibleStructuredPatchHunk[] { + if (typeof patch !== 'string') return []; + let parsed: ReturnType[number] | undefined; + try { + parsed = parsePatch(patch)[0]; + } catch { + // Hook compatibility is an enhancement; malformed vendor metadata must + // never turn a successful edit into a failed tool call. + return []; + } + if (!parsed) return []; + return parsed.hunks.map((hunk) => ({ + oldStart: hunk.oldStart, + oldLines: hunk.oldLines, + newStart: hunk.newStart, + newLines: hunk.newLines, + lines: hunk.lines, + })); +} diff --git a/packages/tui/src/runtime/tool-preview.ts b/packages/tui/src/runtime/tool-preview.ts index f1dd3190..9f04dbeb 100644 --- a/packages/tui/src/runtime/tool-preview.ts +++ b/packages/tui/src/runtime/tool-preview.ts @@ -40,7 +40,13 @@ function buildEditBlocks( ): TuiStructuredPreviewBlock[] { const path = readString(input, ['path', 'filePath', 'file_path']); const resultDiff = readToolResultDiff(output); - if (resultDiff) return [createDiffBlock(path, resultDiff)]; + if (resultDiff) { + return [ + resultDiff.omitted === undefined + ? createDiffBlock(path, resultDiff.diff) + : createOmittedDiffBlock(path, resultDiff.diff, resultDiff.omitted), + ]; + } return readEditPairs(input).map(({ oldText, newText }) => { const diff = [ @@ -108,7 +114,28 @@ function createDiffBlock(path: string | undefined, rawDiff: string): TuiStructur }; } -function readToolResultDiff(output: unknown): string | undefined { +// The edit tool replaces the diff body with a one-line notice once it hits its own +// bounds, so the body carries no `+`/`-` lines. Rendering that as a diff block reports +// `+0 -0`, which reads as "nothing changed" on exactly the largest edits. +function createOmittedDiffBlock( + path: string | undefined, + message: string, + omittedReason: string, +): TuiStructuredPreviewBlock { + return { + kind: 'summary', + ...(path ? { path } : {}), + message, + reason: omittedReason === 'timeout' ? 'unavailable' : 'too-large', + }; +} + +interface ToolResultDiff { + readonly diff: string; + readonly omitted?: string; +} + +function readToolResultDiff(output: unknown): ToolResultDiff | undefined { const root = parseRecord(output); if (!root) return undefined; const candidates = [root, parseRecord(root.result), parseRecord(root.output)].filter( @@ -117,7 +144,10 @@ function readToolResultDiff(output: unknown): string | undefined { for (const candidate of candidates) { const details = parseRecord(candidate.details); const diff = details ? readString(details, ['diff', 'previewDiff', 'preview_diff']) : undefined; - if (diff) return diff; + if (diff) { + const omitted = details ? readString(details, ['diffOmitted', 'diff_omitted']) : undefined; + return omitted === undefined ? { diff } : { diff, omitted }; + } } return undefined; } diff --git a/packages/tui/test/unit/tui-tool-preview-diff-omitted.test.ts b/packages/tui/test/unit/tui-tool-preview-diff-omitted.test.ts new file mode 100644 index 00000000..4e886441 --- /dev/null +++ b/packages/tui/test/unit/tui-tool-preview-diff-omitted.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { TuiAcpUpdateProjector } from '../../src/acp/updates.js'; +import { buildTuiToolPreview } from '../../src/runtime/tool-preview.js'; + +const PATH = '/tmp/subject.ts'; + +const INPUT = { + file_path: PATH, + old_string: 'oldToken', + new_string: 'newToken', + replace_all: true, +}; + +function editPreview(details: Record) { + return buildTuiToolPreview({ + toolName: 'edit', + status: 'completed', + input: INPUT, + output: { details }, + }); +} + +function acpToolContent(details: Record) { + const update = new TuiAcpUpdateProjector().project({ + type: 'delta', + toolCalls: [ + { + id: 'tool-1', + name: 'edit', + status: 'completed', + input: INPUT, + output: { details }, + structuredPreview: editPreview(details), + }, + ], + })[0]; + if (update?.sessionUpdate !== 'tool_call') { + throw new Error(`expected a tool_call update, got ${update?.sessionUpdate}`); + } + return update.content; +} + +describe('buildTuiToolPreview edit diff bounds', () => { + it('keeps a diff block with real counts when the edit tool returns a diff body', () => { + const preview = editPreview({ + diff: ['-1 export const a = oldToken(1);', '+1 export const a = newToken(1);'].join('\n'), + patch: 'irrelevant', + }); + + const block = preview?.blocks[0]; + if (block?.kind !== 'diff') throw new Error(`expected a diff block, got ${block?.kind}`); + expect(block.addedLines).toBe(1); + expect(block.removedLines).toBe(1); + expect(block.path).toBe(PATH); + }); + + it('renders a summary block instead of a misleading +0 -0 diff when the diff is omitted', () => { + const preview = editPreview({ + diff: '(diff omitted: more than 2000 added or removed lines)', + diffOmitted: 'too_many_changes', + }); + + const block = preview?.blocks[0]; + if (block?.kind !== 'summary') throw new Error(`expected a summary block, got ${block?.kind}`); + expect(block.reason).toBe('too-large'); + expect(block.message).toBe('(diff omitted: more than 2000 added or removed lines)'); + expect(block.path).toBe(PATH); + expect(block).not.toHaveProperty('addedLines'); + expect(block).not.toHaveProperty('removedLines'); + }); + + it('maps a timed-out diff to the unavailable summary reason', () => { + const preview = editPreview({ + diff: '(diff omitted: computing it exceeded 5000 ms)', + diffOmitted: 'timeout', + }); + + const block = preview?.blocks[0]; + if (block?.kind !== 'summary') throw new Error(`expected a summary block, got ${block?.kind}`); + expect(block.reason).toBe('unavailable'); + }); +}); + +describe('TuiAcpUpdateProjector edit diff bounds', () => { + it('still projects a real diff body as an ACP diff block', () => { + const content = acpToolContent({ + diff: ['-1 export const a = oldToken(1);', '+1 export const a = newToken(1);'].join('\n'), + }); + + expect(content?.[0]).toMatchObject({ type: 'diff', path: PATH }); + }); + + it('projects an omitted diff as text instead of a diff block with a parsed-notice body', () => { + const content = acpToolContent({ + diff: '(diff omitted: more than 2000 added or removed lines)', + diffOmitted: 'too_many_changes', + }); + + expect(content).toEqual([ + { + type: 'content', + content: { type: 'text', text: '(diff omitted: more than 2000 added or removed lines)' }, + }, + ]); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 058758e3..b5fd4d50 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -331,6 +331,7 @@ "packages/agent-tools/src/desktop/builtin-defs.ts", "packages/agent-tools/src/desktop/canonical-tool-policy.ts", "packages/agent-tools/src/desktop/cloud-session-reader.ts", + "packages/agent-tools/src/desktop/edit-diff-bounds.test.ts", "packages/agent-tools/src/desktop/host-trash-executor.ts", "packages/agent-tools/src/desktop/index.ts", "packages/agent-tools/src/desktop/local-ask-user.ts", @@ -3281,6 +3282,7 @@ "packages/tui/test/unit/tui-terminal-image-paste.test.ts", "packages/tui/test/unit/tui-terminal-text-paste.test.ts", "packages/tui/test/unit/tui-thinking-preview.test.ts", + "packages/tui/test/unit/tui-tool-preview-diff-omitted.test.ts", "packages/tui/test/unit/tui-transcript-presentation.test.ts", "packages/tui/test/unit/tui-transcript-view.test.ts", "packages/tui/test/unit/tui-workspace-status-line.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index b1c80517..c8a8090f 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -8,6 +8,8 @@ "packages/agent-core/test/unit/bash-subprocess-env.test.ts", "packages/agent-modules/permission/test/unit/permission/bash-policy-regressions.test.ts", "packages/agent-tools/src/shared/replace-all-edit.test.ts", + "packages/agent-tools/src/desktop/edit-diff-bounds.test.ts", + "packages/tui/test/unit/tui-tool-preview-diff-omitted.test.ts", "packages/local-runtime-v2/test/unit/agent/agent-import.test.ts", "packages/tui/test/unit/auth-application.test.ts", "packages/tui/test/unit/tui-assistant-content.test.ts", diff --git a/third_party/pi-mono/MINIMAX_CHANGES.md b/third_party/pi-mono/MINIMAX_CHANGES.md index fb2a7d86..d81c2b27 100644 --- a/third_party/pi-mono/MINIMAX_CHANGES.md +++ b/third_party/pi-mono/MINIMAX_CHANGES.md @@ -13,6 +13,22 @@ This directory vendors `pi-mono` as source so MiniMax can patch, validate, and s No upstream source files are changed in the baseline import. +### 2026-09-21 — report why the edit unified patch was omitted + +- Reason: the unified patch is a second, independently timed Myers run over the same input as the display diff. When only that run ran out of time, `details` held a diff, no patch, and no `diffOmitted` — consumers could not tell an omitted patch apart from a tool that never produces one. +- Affected package: `packages/coding-agent` (`@earendil-works/pi-coding-agent`), edit tool details assembly. +- Change type: generic, upstreamable correctness fix. Add `details.patchOmitted`, set to `diffOmitted` when the display diff was abandoned and to `timeout` when only the patch was, so `details.patch` is absent exactly when `patchOmitted` is set. jsdiff routes `createTwoFilesPatch` through the same bounded `diffLines`, so `maxEditLength` is deterministic across both runs and can never trip for the patch alone; only its separately measured wall clock can. `diffOmitted` keeps its meaning and is unchanged. +- Upstream PR: not opened. +- Validation: `packages/agent-tools/src/desktop/edit-diff-bounds.test.ts` pins the invariant on both reachable paths (an ordinary edit and a bounded-out whole-file rewrite). The patch-only timeout is not unit-testable: it needs the two runs to land on opposite sides of a 5 s wall clock, which no deterministic input can force. + +### 2026-09-21 — bound the post-edit diff of the edit tool + +- Reason: `edit` computed its display diff and its unified patch with unbounded Myers, which costs O((N+M)·D) in the length D of the edit script. A whole-file rewrite therefore scaled quadratically in the number of changed lines: rewriting every line of a 20 000-line file blocked the tool for over two minutes and produced a multi-megabyte patch that no renderer displays. The sibling `write` path already caps the same work (`packages/agent-tools/src/shared/write-capture.ts`); `edit` had no cap. +- Affected package: `packages/coding-agent` (`@earendil-works/pi-coding-agent`), edit tool diff generation. +- Change type: generic, upstreamable performance fix. Pass jsdiff's `maxEditLength` (2000 edits) and `timeout` (5 s) to `diffLines` and `createTwoFilesPatch`. `maxEditLength` is the primary bound because it is deterministic and therefore testable; `timeout` only backstops slow machines. Replacing a line costs 2 edits, so the bound admits a full rewrite of any file up to 1000 lines and only gives up past that. When a bound trips, `details.diff` carries a one-line notice so every renderer still has something to show, `details.patch` is omitted, and the new `details.diffOmitted` names the reason. The unified patch is skipped once the display diff was abandoned rather than repeating a second Myers run that aborts on the same bound. The file is written before any diff runs, so a dropped diff never changes what lands on disk. +- Upstream PR: not opened. +- Validation: `packages/agent-tools/src/desktop/edit-diff-bounds.test.ts` (registered in the `capability` suite) covers an ordinary edit, a large block replacement that stays under the bound, a full rewrite at the bound that keeps its diff, and a whole-file rewrite that keeps the write while dropping the diff; `pnpm verify --profile platform` on macOS. Measured end to end through `createEditTool`, three runs each on the same machine: a 20 000-line whole-file rewrite took 167 620 / 173 523 / 167 547 ms unbounded and 200 / 192 / 193 ms bounded, with peak heap dropping from 47–60 MB to 14–15 MB; a 501-line rewrite takes 53 / 49 / 47 ms and a 1000-line rewrite 177 / 173 / 177 ms, both keeping their diff. + ### 2026-09-19 — preserve the system role for Mistral Chat Completions - Reason: thinking-enabled custom OpenAI-compatible connections to `api.mistral.ai` emitted `developer`, which is absent from the [Mistral Chat Completions message contract](https://docs.mistral.ai/api/endpoint/chat). [OpenClaw's compatibility defaults](https://github.com/openclaw/openclaw/blob/e2bcb1614de060927121bd72de850cee3a08d308/packages/ai/src/transports/openai-completions-compat.ts#L184-L210) also disable this role for the Mistral public endpoint. diff --git a/third_party/pi-mono/packages/coding-agent/src/core/tools/edit-diff.ts b/third_party/pi-mono/packages/coding-agent/src/core/tools/edit-diff.ts index b2851d6f..39e2db2b 100644 --- a/third_party/pi-mono/packages/coding-agent/src/core/tools/edit-diff.ts +++ b/third_party/pi-mono/packages/coding-agent/src/core/tools/edit-diff.ts @@ -394,24 +394,72 @@ export function applyEditsToNormalizedContent( return { baseContent: normalizedContent, newContent }; } -/** Generate a standard unified patch. */ -export function generateUnifiedPatch(path: string, oldContent: string, newContent: string, contextLines = 4): string { +/** + * Bounds for the post-edit diff. The diff is a receipt computed after the file + * has already been written (and, for the TUI preview, a courtesy rendering), so + * giving it up never changes what lands on disk. + * + * Myers runs in O((N+M)·D) where D is the edit-script length. A whole-file + * replacement that touches tens of thousands of lines therefore takes minutes + * and hundreds of MB unbounded, and produces a multi-MB diff nothing can + * render. `maxEditLength` caps D deterministically (the same input always + * aborts at the same point, so the behavior is unit-testable); `timeout` is a + * wall-clock safety net for slow machines and pathological inputs. + * + * D is sized so that a full rewrite of an ordinary source file keeps its diff: + * replacing every line costs 2 edits per line, so 2000 covers files up to 1000 + * lines. Measured end to end on an M-series laptop, rewriting every line costs + * 49 ms at 500 lines and 177 ms at 1000; a 20 000-line rewrite gives up after + * 193 ms, against about 170 s unbounded. + */ +export const DIFF_MAX_EDIT_LENGTH = 2000; +export const DIFF_TIMEOUT_MS = 5_000; + +/** Why no diff was produced: the edit script exceeded DIFF_MAX_EDIT_LENGTH, or DIFF_TIMEOUT_MS elapsed first. */ +export type DiffOmittedReason = "too_many_changes" | "timeout"; + +const DIFF_BOUNDS = { maxEditLength: DIFF_MAX_EDIT_LENGTH, timeout: DIFF_TIMEOUT_MS } as const; + +/** + * Generate a standard unified patch. + * Returns `undefined` when the diff exceeds DIFF_MAX_EDIT_LENGTH or DIFF_TIMEOUT_MS. + */ +export function generateUnifiedPatch( + path: string, + oldContent: string, + newContent: string, + contextLines = 4, +): string | undefined { return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, { context: contextLines, headerOptions: Diff.FILE_HEADERS_ONLY, + ...DIFF_BOUNDS, }); } /** * Generate a display-oriented diff string with line numbers and context. * Returns both the diff string and the first changed line number (in the new file). + * + * When the diff exceeds DIFF_MAX_EDIT_LENGTH or DIFF_TIMEOUT_MS, `diff` is a + * single notice line (so every renderer still has something to show) and + * `omitted` names the reason. */ -export function generateDiffString( - oldContent: string, - newContent: string, - contextLines = 4, -): { diff: string; firstChangedLine: number | undefined } { - const parts = Diff.diffLines(oldContent, newContent); +export function generateDiffString(oldContent: string, newContent: string, contextLines = 4): EditDiffResult { + const startedAt = Date.now(); + const parts = Diff.diffLines(oldContent, newContent, DIFF_BOUNDS); + if (parts === undefined) { + // jsdiff stops on whichever bound trips first; only the clock tells them apart. + const omitted: DiffOmittedReason = Date.now() - startedAt >= DIFF_TIMEOUT_MS ? "timeout" : "too_many_changes"; + return { + diff: + omitted === "timeout" + ? `(diff omitted: computing it exceeded ${DIFF_TIMEOUT_MS} ms)` + : `(diff omitted: more than ${DIFF_MAX_EDIT_LENGTH} added or removed lines)`, + firstChangedLine: undefined, + omitted, + }; + } const output: string[] = []; const oldLines = oldContent.split("\n"); @@ -534,6 +582,8 @@ export function generateDiffString( export interface EditDiffResult { diff: string; firstChangedLine: number | undefined; + /** Set when the diff was not computed; `diff` then holds a one-line notice. */ + omitted?: DiffOmittedReason; } export interface EditDiffError { diff --git a/third_party/pi-mono/packages/coding-agent/src/core/tools/edit.ts b/third_party/pi-mono/packages/coding-agent/src/core/tools/edit.ts index 25aa1e17..7a5a3190 100644 --- a/third_party/pi-mono/packages/coding-agent/src/core/tools/edit.ts +++ b/third_party/pi-mono/packages/coding-agent/src/core/tools/edit.ts @@ -10,6 +10,7 @@ import { applyEditsToNormalizedContent, computeEditsDiff, detectLineEnding, + type DiffOmittedReason, type Edit, type EditDiffError, type EditDiffResult, @@ -59,12 +60,16 @@ type LegacyEditToolInput = EditToolInput & { }; export interface EditToolDetails { - /** Display-oriented diff of the changes made */ + /** Display-oriented diff of the changes made (a one-line notice when `diffOmitted` is set) */ diff: string; - /** Standard unified patch of the changes made */ - patch: string; + /** Standard unified patch of the changes made; absent exactly when `patchOmitted` is set */ + patch?: string; /** Line number of the first change in the new file (for editor navigation) */ firstChangedLine?: number; + /** Set when the diff exceeded DIFF_MAX_EDIT_LENGTH / DIFF_TIMEOUT_MS. The file was still written. */ + diffOmitted?: DiffOmittedReason; + /** Why `patch` is absent. Mirrors `diffOmitted`, and also covers the patch timing out on its own. */ + patchOmitted?: DiffOmittedReason; } /** @@ -347,8 +352,16 @@ export function createEditToolDefinition( await ops.writeFile(absolutePath, finalContent); throwIfAborted(); + // The file is already on disk, so both diffs below are receipts. When the + // display diff was abandoned the patch would abort on the same bounds, so + // skip it rather than paying for a second Myers run that cannot finish. const diffResult = generateDiffString(baseContent, newContent); - const patch = generateUnifiedPatch(path, baseContent, newContent); + const patch = diffResult.omitted ? undefined : generateUnifiedPatch(path, baseContent, newContent); + // jsdiff routes createTwoFilesPatch through the same bounded diffLines, so + // maxEditLength is deterministic across both runs and can never trip for + // the patch alone; only its separately measured wall clock can. + const patchOmitted: DiffOmittedReason | undefined = + diffResult.omitted ?? (patch === undefined ? "timeout" : undefined); return { content: [ { @@ -356,7 +369,13 @@ export function createEditToolDefinition( text: `Successfully replaced ${edits.length} block(s) in ${path}.`, }, ], - details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, + details: { + diff: diffResult.diff, + patch, + firstChangedLine: diffResult.firstChangedLine, + diffOmitted: diffResult.omitted, + patchOmitted, + }, }; }); }, From 2ede8e419170056f12e2959ce403f43cdfc49d1f Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:23:27 +0800 Subject: [PATCH 05/23] docs: document BYOK session-affinity compat key (#323) An Anthropic-compatible relay that routes prompt-cache hits per session had no documented way to learn the session identity. The request builder forwards metadata.user_id only when a caller sets metadata explicitly, because it is an abuse-detection and attribution field rather than a cache key, so nothing in the public documentation explained how to preserve session identity instead. Document the supported sendSessionAffinityHeaders compat override for custom providers, and pin the resulting wire-level request: no session identity without the override, x-session-affinity once a provider entry opts in, and no session header when the turn runs without prompt caching. Closes #318 --- docs/examples.md | 20 +++++ .../resolution/local-model-resolver.test.ts | 88 +++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/docs/examples.md b/docs/examples.md index 677cbb75..83e4147d 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -92,6 +92,26 @@ custom_provider: [Live acceptance](verification.md) separately verified MiniMax Token Plan and one configured BYOK provider. This is not a guarantee for every compatible service. +### Prompt-cache session affinity + +A relay that routes prompt-cache hits per session needs to recognize which session a request belongs to. MCode never derives that from the Anthropic `metadata.user_id` field: `metadata` is forwarded only when a caller sets it explicitly, because it is an abuse-detection and attribution field rather than a cache key. The supported mechanism is the `sendSessionAffinityHeaders` compatibility override, declared per model under `compat`: + +```yaml +custom_provider: + my-relay: + options: + apiKey: sk-relay-key + baseURL: https://relay.example.com + models: + MiniMax-M2: + compat: + sendSessionAffinityHeaders: true +``` + +With the override enabled, every request carries the current session id as `x-session-affinity`. An `openai-completions` provider additionally sends the same value as `session_id` and `x-client-request-id`. Key the relay's cache routing on those headers. Requests made with cache retention disabled send no session headers at all; leave the routing fallback in place rather than treating a missing header as a new session. + +The override defaults to `false`, so a provider that ignores these headers is unaffected; it is enabled automatically only for endpoints known to require it, such as Fireworks and the Anthropic route of Cloudflare AI Gateway. `compat` accepts further per-model capability overrides, and each value is applied only when it has the declared type, so a quoted `"false"` is discarded rather than read as true. Restart MCode after editing configuration. + ## 3. Search and image input For a custom BYOK model, declare image input support explicitly when adding the diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts index b8e4c32f..92bbd389 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts @@ -1530,3 +1530,91 @@ describe('LocalModelResolver custom provider compat overrides', () => { ).toBe('developer'); }); }); + +// A relay keyed on session identity only sees it when the provider entry opts in, so these +// cases pin the wire-level request for an Anthropic-compatible custom provider. +describe('LocalModelResolver custom provider session affinity', () => { + const readHeaders = (source: unknown): Record => { + if (!source) return {}; + const entries = + typeof Headers !== 'undefined' && source instanceof Headers + ? [...source.entries()] + : Object.entries(source as Record); + return Object.fromEntries( + entries.flatMap(([key, value]) => + typeof value === 'string' ? [[key.toLowerCase(), value]] : [], + ), + ); + }; + + const requestFor = async ( + compat: LocalModelConfig['compat'], + cacheRetention?: 'none', + ) => { + const modelConfig: LocalModelConfig = compat ? { compat } : {}; + const resolver = new LocalModelResolver({ + byokConfigGetter: () => ({ + custom_provider: { + relay: { + api: 'anthropic-messages', + options: { apiKey: 'relay-key', baseURL: 'https://relay.example' }, + models: { 'MiniMax-M2': modelConfig }, + }, + }, + }), + }); + const resolved = await resolver.resolveModel({ + sessionId: 'session-affinity-wire', + turnId: 'turn-affinity-wire', + agentConfig: { + ...AGENT_CONFIG, + model: modelRefForModel('custom_provider:relay', 'MiniMax-M2', modelConfig), + }, + }); + + let headers: Record = {}; + let payload: unknown; + await streamSimple( + resolved.model, + { + systemPrompt: 'Follow instructions.', + messages: [{ role: 'user', content: 'Hi', timestamp: Date.now() }], + }, + { + apiKey: 'relay-key', + sessionId: 'session-affinity-wire', + ...(cacheRetention ? { cacheRetention } : {}), + onPayload: (params: unknown) => { + payload = params; + }, + // Headers are captured before transport, so the request never leaves the test. + fetch: ((_url: unknown, init?: { headers?: unknown }) => { + headers = readHeaders(init?.headers); + return Promise.reject(new Error('offline')); + }) as unknown as typeof globalThis.fetch, + }, + ).result(); + return { headers, payload: payload as { metadata?: { user_id?: string } } }; + }; + + it('omits session identity when the provider entry declares no compat', async () => { + const { headers, payload } = await requestFor(undefined); + + expect(headers['x-session-affinity']).toBeUndefined(); + expect(payload.metadata).toBeUndefined(); + }); + + it('sends x-session-affinity once the provider entry opts in', async () => { + const { headers, payload } = await requestFor({ sendSessionAffinityHeaders: true }); + + expect(headers['x-session-affinity']).toBe('session-affinity-wire'); + // The session id stays out of `metadata.user_id`, which is an attribution field. + expect(payload.metadata).toBeUndefined(); + }); + + it('withholds the header when the turn runs without prompt caching', async () => { + const { headers } = await requestFor({ sendSessionAffinityHeaders: true }, 'none'); + + expect(headers['x-session-affinity']).toBeUndefined(); + }); +}); From b47eb90fc6c4777798a365d572afe4fe0ecfe210 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:44:14 +0800 Subject: [PATCH 06/23] fix(tui): preserve transcript position while reading history (#324) * docs(tui): design follow-tail preservation * docs(tui): add follow-tail implementation plan * fix(tui): preserve detached transcript position * fix(tui): re-arm follow-tail on explicit navigation * test(tui): cover follow-tail intent boundaries * fix(tui): re-arm follow-tail for queued messages * fix(tui): ignore stale queue admissions --- .../plans/2026-09-23-tui-follow-tail.md | 379 ++++++++++++++++++ .../2026-09-23-tui-follow-tail-design.md | 103 +++++ packages/tui/src/tui/app.ts | 8 +- .../tui/controller/product/command-flow.ts | 50 ++- packages/tui/src/tui/shell/chat-layout.ts | 6 + packages/tui/test/unit/tui-app.test.ts | 97 +++++ .../unit/tui-scrollbar-interaction.test.ts | 49 +++ .../controller/product/command-flow.test.ts | 15 +- release/public-source.json | 2 + 9 files changed, 691 insertions(+), 18 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-23-tui-follow-tail.md create mode 100644 docs/superpowers/specs/2026-09-23-tui-follow-tail-design.md diff --git a/docs/superpowers/plans/2026-09-23-tui-follow-tail.md b/docs/superpowers/plans/2026-09-23-tui-follow-tail.md new file mode 100644 index 00000000..225ed1d5 --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-tui-follow-tail.md @@ -0,0 +1,379 @@ +# TUI Follow-Tail Preservation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve a detached TUI transcript position during streaming while re-arming follow-tail for explicit submission and Session navigation. + +**Architecture:** Keep `ScrollView.isFollowingEnd` as the single follow-tail state. Make `TuiChatLayout.followBottom()` conditional on that state, expose `forceFollowBottom()` for explicit user/Session intent, and wire only submission and Session transitions to the force path. + +**Tech Stack:** TypeScript, Vitest, MiniMax Code TUI, `ScrollView`, `TuiChatLayout`, `TuiAltScreen`. + +## Global Constraints + +- Support Node.js `>=22.19 <23 || >=24.2 <27` and use the repository-pinned `pnpm@9.12.0` workflow. +- Reuse `ScrollView.isFollowingEnd`; do not add a second follow-tail state. +- Do not add a `followTail` configuration key or a "new output below" indicator. +- Guard ordinary assistant, bash, tool, and interaction content updates. +- Force-follow only for user submission, `/new`, and Session activation/switching. +- Preserve the existing `End` / `tui.altScreen.bottom` behavior. +- Use synthetic transcripts and temporary runtime data in tests. +- Keep new documentation and commit messages in English. +- Treat this as a streaming/rendering-path change and add the `perf:full` label to the pull request. + +--- + +## File Structure + +- Modify `packages/tui/src/tui/shell/chat-layout.ts`: own guarded content-follow and explicit force-follow behavior. +- Modify `packages/tui/src/tui/app.ts`: route user submission and Session-transition callbacks to force-follow; leave content callbacks guarded. +- Modify `packages/tui/test/unit/tui-scrollbar-interaction.test.ts`: verify observable detached-position, re-arm, and force-follow behavior through the real layout. +- Modify `packages/tui/test/unit/tui-app.test.ts`: verify application wiring invokes force-follow for submission and Session transitions. +- Modify `release/public-source.json`: regenerate after adding this public implementation-plan document. + +### Task 1: Preserve detached transcript position + +**Files:** +- Modify: `packages/tui/test/unit/tui-scrollbar-interaction.test.ts:22-36,279-304` +- Modify: `packages/tui/src/tui/shell/chat-layout.ts:142-144` + +**Interfaces:** +- Consumes: `ScrollView.isFollowingEnd: boolean` and `ScrollView.scrollToEnd(): void` from `packages/tui/src/tui/engine/components/scroll-view.ts`. +- Produces: `TuiChatLayout.followBottom(): void` for conditional content updates and `TuiChatLayout.forceFollowBottom(): void` for explicit intent. + +- [ ] **Step 1: Make the synthetic transcript appendable** + +Add this method to `ScrollableLines`: + +```ts + appendLine(line: string): void { + this.lines.push(line); + } +``` + +- [ ] **Step 2: Write the failing observable behavior test** + +Add the test beside the existing actual-chat-layout scrollbar test: + +```ts + it("preserves a detached transcript position until follow-tail is explicitly re-armed", async () => { + const terminal = new VirtualTerminal(40, 15); + const empty = { render: () => [], invalidate() {} }; + const content = new ScrollableLines(CONTENT_LINES); + const layout = new TuiChatLayout(terminal, { + surface: () => "conversation", + transcript: content, + welcome: empty, + interaction: { ...empty, isActive: () => false }, + activity: empty, + followUp: empty, + composer: { render: () => ["composer"], invalidate() {} }, + status: empty, + }); + const tui = new TuiAltScreen(terminal); + screens.push(tui); + tui.setLayoutRoot(layout.fullscreenLayoutRoot); + tui.start(); + await terminal.waitForRender(); + + expect(viewportText(terminal)).toContain("line-59"); + + terminal.sendInput(sgrPress(37, 0)); + terminal.sendInput(sgrRelease(37, 0)); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-00"); + + content.appendLine("line-60"); + layout.followBottom(); + tui.requestRender(); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-00"); + expect(viewportText(terminal)).not.toContain("line-60"); + + layout.forceFollowBottom(); + tui.requestRender(); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-60"); + + content.appendLine("line-61"); + layout.followBottom(); + tui.requestRender(); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-61"); + }); +``` + +- [ ] **Step 3: Install the isolated worktree dependencies** + +Run: + +```bash +pnpm install --frozen-lockfile +``` + +Expected: install exits successfully and creates `node_modules` in the worktree without changing the lockfile. + +- [ ] **Step 4: Run the focused test and verify the current failure** + +Run: + +```bash +pnpm exec vitest run --config vitest.oss.config.mjs packages/tui/test/unit/tui-scrollbar-interaction.test.ts +``` + +Expected: FAIL because the current unconditional `followBottom()` moves the detached viewport to `line-60`; the new `forceFollowBottom()` method is not yet present. + +- [ ] **Step 5: Implement the minimal layout behavior** + +Replace the current method with: + +```ts + followBottom(): void { + if (this.fullscreenBodyViewport.isFollowingEnd) { + this.fullscreenBodyViewport.scrollToEnd(); + } + } + + forceFollowBottom(): void { + this.fullscreenBodyViewport.scrollToEnd(); + } +``` + +- [ ] **Step 6: Run the focused test and verify the fix** + +Run: + +```bash +pnpm exec vitest run --config vitest.oss.config.mjs packages/tui/test/unit/tui-scrollbar-interaction.test.ts +``` + +Expected: PASS, including the existing mouse, wheel, scrollbar, and fullscreen-layout tests. + +- [ ] **Step 7: Commit the behavioral fix** + +```bash +git add packages/tui/src/tui/shell/chat-layout.ts packages/tui/test/unit/tui-scrollbar-interaction.test.ts +git -c user.name='hetaoBackend' -c user.email='hetao7@pku.edu.cn' commit -m 'fix(tui): preserve detached transcript position' +``` + +### Task 2: Force-follow explicit submission and Session intent + +**Files:** +- Modify: `packages/tui/test/unit/tui-app.test.ts:15,501-548` (imports and application lifecycle tests) +- Modify: `packages/tui/src/tui/app.ts:128-131,445-450` + +**Interfaces:** +- Consumes: `TuiChatLayout.forceFollowBottom(): void` from Task 1. +- Produces: application wiring where `onUserSubmissionProjected` and `TuiSessionFlow.followBottom` call `forceFollowBottom()`, while `followChatBottom` and interaction refreshes continue calling `followBottom()`. + +- [ ] **Step 1: Add the failing application-wiring test** + +Import the layout class: + +```ts +import { TuiChatLayout } from "../../src/tui/shell/chat-layout.js"; +``` + +Add an application lifecycle test: + +```ts + it("force-follows user submissions and Session transitions", async () => { + const forceFollowBottom = vi.spyOn(TuiChatLayout.prototype, "forceFollowBottom"); + const app = createTuiApp({ + runtime: createRuntime(), + terminal: new FakeTerminal(), + version: "test", + workspaceDir: "/workspace", + }); + app.start(); + + try { + await app.ready; + await app.openSession("session-1"); + + forceFollowBottom.mockClear(); + await app.submit("Continue with the next step"); + await vi.waitFor(() => expect(forceFollowBottom).toHaveBeenCalled()); + + forceFollowBottom.mockClear(); + await app.openSession("session-2"); + expect(forceFollowBottom).toHaveBeenCalled(); + } finally { + await app.stop(); + forceFollowBottom.mockRestore(); + } + }); +``` + +- [ ] **Step 2: Run the app test and verify the wiring failure** + +Run: + +```bash +pnpm exec vitest run --config vitest.oss.config.mjs packages/tui/test/unit/tui-app.test.ts +``` + +Expected: FAIL because the application still calls `layout.followBottom()` for both explicit-intent paths. + +- [ ] **Step 3: Route user submission to force-follow** + +In `onUserSubmissionProjected`, replace: + +```ts + followChatBottom(); +``` + +with: + +```ts + layout.forceFollowBottom(); +``` + +Leave the bash-flow callback unchanged so ordinary tool output remains guarded. + +- [ ] **Step 4: Route Session transitions to force-follow** + +In the `TuiSessionFlow` options, replace: + +```ts + followBottom: () => layout.followBottom(), +``` + +with: + +```ts + followBottom: () => layout.forceFollowBottom(), +``` + +This callback is used by both `startNewSession()` and `activateSessionById()`. + +- [ ] **Step 5: Run focused app and viewport tests** + +Run: + +```bash +pnpm exec vitest run --config vitest.oss.config.mjs packages/tui/test/unit/tui-app.test.ts packages/tui/test/unit/tui-scrollbar-interaction.test.ts +``` + +Expected: PASS with no snapshot, lifecycle, submission, or scroll regression. + +- [ ] **Step 6: Run type checking and source checks** + +Run: + +```bash +pnpm typecheck +pnpm check:source +git diff --check +``` + +Expected: all commands exit successfully; source inventory reports the committed spec and plan; no whitespace errors. + +- [ ] **Step 7: Commit the application wiring** + +```bash +git add packages/tui/src/tui/app.ts packages/tui/test/unit/tui-app.test.ts +git -c user.name='hetaoBackend' -c user.email='hetao7@pku.edu.cn' commit -m 'fix(tui): re-arm follow-tail on explicit navigation' +``` + +### Task 3: Verify and prepare the pull request + +**Files:** +- Verify: all changed files +- Verify: `release/public-source.json` + +**Interfaces:** +- Consumes: the complete implementation from Tasks 1 and 2. +- Produces: a verified `fix/tui-follow-tail` branch and a pull request that fixes issue #320. + +- [ ] **Step 1: Verify the committed public source inventory** + +Run: + +```bash +pnpm check:source +git status --short +``` + +Expected: the approved spec and implementation-plan paths are recorded; no untracked or modified deliverable file remains before full verification. + +- [ ] **Step 2: Run the full applicable verification profile** + +Run: + +```bash +pnpm verify +``` + +Expected: PASS for every macOS-applicable gate. Report any intentional platform skips and any live-service or Windows boundary that was not exercised. + +- [ ] **Step 3: Verify commit identity and branch state** + +Run: + +```bash +git log --format=fuller origin/main..HEAD +git status --short --branch +``` + +Expected: every new commit uses `hetaoBackend ` as both author and committer; the tracked working tree is clean. + +- [ ] **Step 4: Obtain confirmation immediately before publishing** + +State that the next actions push `fix/tui-follow-tail` to `origin` and create a public GitHub pull request containing the issue link, user-visible behavior, tests actually run, untested platform boundaries, and the `perf:full` label. + +- [ ] **Step 5: Push the feature branch** + +After explicit confirmation: + +```bash +git push -u origin fix/tui-follow-tail +``` + +Expected: the remote branch is created without modifying `main`. + +- [ ] **Step 6: Create the pull request** + +After the push succeeds: + +```bash +PR_BODY=$(cat <<'EOF' +## Summary + +- preserve a user-scrolled transcript position while assistant and tool output continues +- keep automatic follow-tail active when the viewport is already at the bottom +- force-follow after an explicit user submission or Session transition +- reuse the existing `ScrollView` follow-end state without adding configuration or UI + +## Validation + +- `pnpm exec vitest run --config vitest.oss.config.mjs packages/tui/test/unit/tui-app.test.ts packages/tui/test/unit/tui-scrollbar-interaction.test.ts` +- `pnpm typecheck` +- `pnpm check:source` +- `pnpm verify` + +## Boundaries + +- report the exact `pnpm verify` result, including intentional skips +- report that live Windows acceptance was not run unless it was actually run + +Closes #320 +EOF +) +gh pr create \ + --base main \ + --head fix/tui-follow-tail \ + --title 'fix(tui): preserve transcript position while reading history' \ + --body "$PR_BODY" \ + --label 'perf:full' +``` + +- [ ] **Step 7: Verify the created pull request** + +Run: + +```bash +gh pr view --json number,title,url,baseRefName,headRefName,labels,state +gh pr checks +``` + +Expected: the pull request targets `main`, comes from `fix/tui-follow-tail`, has the `perf:full` label, and its checks are queued or passing without an unexpected skip. diff --git a/docs/superpowers/specs/2026-09-23-tui-follow-tail-design.md b/docs/superpowers/specs/2026-09-23-tui-follow-tail-design.md new file mode 100644 index 00000000..d6b99674 --- /dev/null +++ b/docs/superpowers/specs/2026-09-23-tui-follow-tail-design.md @@ -0,0 +1,103 @@ +# TUI Follow-Tail Preservation Design + +Date: 2026-09-23 +Issue: https://github.com/MiniMax-AI/minimax-code/issues/320 + +## Summary + +The chat TUI currently scrolls to the transcript bottom whenever assistant output, tool output, or an interaction update changes. This overrides a user's deliberate attempt to read earlier output. The change makes automatic scrolling conditional on the existing `ScrollView` follow-end state, while preserving explicit bottom navigation and intentionally re-arming follow-tail after a submission or Session transition. + +## Problem + +`TuiChatLayout.followBottom()` currently calls `ScrollView.scrollToEnd()` unconditionally. `ScrollView` already tracks whether it is following the end, but the chat layout bypasses that state for every update. The result is that a user who scrolls upward is pulled back to the newest output as soon as the next token or tool result arrives. + +## Goals + +- Preserve the user's reading position while output continues to stream or tool output changes. +- Continue following new output while the viewport is at the bottom. +- Re-arm follow-tail when the user explicitly navigates to the bottom. +- Re-arm follow-tail after the user submits a message. +- Re-arm follow-tail when creating or activating a Session. +- Reuse the existing `ScrollView.isFollowingEnd` state without introducing a second source of truth. + +## Non-goals + +- Do not add a `followTail` configuration setting in this change. +- Do not add a "new output below" indicator or overlay. +- Do not change `ScrollView` scrollbar behavior, search behavior, or general keyboard bindings. +- Do not change prompt history or queued-message restoration behavior. + +## Design + +### Conditional content follow + +`TuiChatLayout.followBottom()` will inspect the primary transcript viewport's existing `isFollowingEnd` state. It will call `scrollToEnd()` only when the viewport is still following the end. This keeps the method safe for repeated content-update callbacks without duplicating user-scroll state in `TuiChatLayout`. + +The ordinary guarded path covers: + +- assistant streaming and transcript updates; +- bash and tool output; +- interaction or composition refreshes that can change visible content; +- any future content-update caller that uses the default method. + +### Explicit force-follow operations + +`TuiChatLayout` exposes an explicit `forceFollowBottom()` method. It calls `scrollToEnd()` regardless of the prior follow-end state. + +Force-follow applies only to user or Session intent: + +1. the user submits a new message; +2. `/new` resets the current Session; +3. the user activates or switches Sessions. + +The application will pass the force-follow callback only to these explicit intent paths. Session-flow callbacks used for reset and activation will always force the new Session's transcript to the bottom. + +### Re-arming through existing navigation + +Scrolling back to the bottom, including the existing `tui.altScreen.bottom` / `End` path, continues to call `ScrollView.scrollToEnd()`. That operation already sets `isFollowingEnd` to true, so no separate toggle state or synchronization callback is required. + +When the content is shorter than the viewport, `ScrollView` continues to consider itself at the end, preserving the current compact-transcript behavior. + +## Data flow + +1. The user scrolls upward; `ScrollView.isFollowingEnd` becomes false. +2. Assistant, bash, tool, or interaction content changes. +3. The existing update callback invokes the guarded `followBottom()` path. +4. `TuiChatLayout` sees `isFollowingEnd === false` and leaves `scrollTop` unchanged. +5. The next layout pass updates the content height while preserving the current position. +6. The user presses `End` or navigates to the bottom; `ScrollView.scrollToEnd()` sets `isFollowingEnd` to true. +7. Subsequent content changes once again follow the end. + +Submission and Session-transition flows instead invoke the force-follow callback before rendering the new state, so the viewport is always at the bottom for the new user or Session context. + +## Edge cases + +- A user who scrolls up during streaming remains detached until returning to the bottom. +- Content growth cannot re-arm follow-tail by itself. +- Content shrink continues to use the existing `ScrollView` clamping behavior. +- Empty or short transcripts remain logically at the end. +- Multiple updates before a render remain safe because the follow decision reads the current viewport state. +- A Session transition is not treated as ordinary streaming; it always shows the new Session's bottom. + +## Testing + +Focused tests will cover the `TuiChatLayout` and existing scroll/viewport regression surfaces: + +- while following the end, content growth continues to move to the newest output; +- after scrolling upward, content growth preserves `scrollTop`; +- after returning to the bottom, later content growth follows again; +- the force-follow path moves to the end after the user has scrolled upward; +- force-follow is used for user submission, Session reset, and Session activation call sites; +- ordinary bash, tool, and interaction update call sites use the guarded path; +- existing `End` / `tui.altScreen.bottom` behavior remains unchanged. + +Tests will use synthetic transcripts and temporary runtime data only. + +## Acceptance criteria + +- Scrolling upward to read earlier output is not overridden by new assistant or tool output. +- A viewport already at the bottom continues to follow streaming output. +- `End` or the existing bottom-navigation binding re-arms follow-tail. +- Submitting a message returns to the bottom and resumes following. +- Creating, resetting, or switching Sessions shows the bottom of the selected Session. +- No new configuration key, status indicator, or duplicate follow state is introduced. diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index c70b0cee..c8812f83 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -127,7 +127,6 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { onTodoChange: (items) => tasks.setItems(items), onUserSubmissionProjected: () => { codexHandoffFlow?.dismiss(); - followChatBottom(); if (started && !stopped) tui.requestImmediateRender(); }, onSessionLifecycle: (sessionId) => { @@ -446,7 +445,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { updateChrome(controller.snapshot()); tui.requestRender(); }, - followBottom: () => layout.followBottom(), + followBottom: () => layout.forceFollowBottom(), requestWelcomeRebuild: () => tui.requestImmediateRender(), switchComposerDraft: (sessionKey) => draftLifecycle?.switchSession(sessionKey) ?? Promise.resolve(), @@ -541,7 +540,10 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { tui.requestRender(); }, userMessageCount: () => transcript.snapshot().filter((cell) => cell.kind === 'user').length, - onMessageAdmitted: (input) => businessEventTracker?.trackChatSend(input), + onMessageAdmitted: (input) => { + businessEventTracker?.trackChatSend(input); + layout.forceFollowBottom(); + }, }); activeRunFlow.setCommandCatalog(commandFlow.catalog); runtimeEventFlow = new TuiRuntimeEventFlow({ diff --git a/packages/tui/src/tui/controller/product/command-flow.ts b/packages/tui/src/tui/controller/product/command-flow.ts index 695d9bcf..b78157c0 100644 --- a/packages/tui/src/tui/controller/product/command-flow.ts +++ b/packages/tui/src/tui/controller/product/command-flow.ts @@ -579,6 +579,7 @@ export class TuiCommandFlow { submission.attachments.map((attachment) => attachment.fileName), ); } + let queuedItemId: string | undefined; try { await this.options.controller.requireLoginForAgentAction(); const draft = { @@ -590,9 +591,19 @@ export class TuiCommandFlow { submission.transportContent || submission.reviewRequest ) { - await this.options.queueFlow.enqueue(command, draft, submission, activeAdmissionId); + queuedItemId = await this.options.queueFlow.enqueue( + command, + draft, + submission, + activeAdmissionId, + ); } else { - await this.options.queueFlow.enqueue(command, draft, undefined, activeAdmissionId); + queuedItemId = await this.options.queueFlow.enqueue( + command, + draft, + undefined, + activeAdmissionId, + ); } } catch (error) { this.options.queueFlow.cancelAdmission(activeAdmissionId); @@ -602,10 +613,12 @@ export class TuiCommandFlow { disposition: await this.restorePreparedSubmission(seed, submission, input), }; } - this.options.onMessageAdmitted?.({ - attachmentCount: submission.attachments.length, - isFirstMessage, - }); + if (queuedItemId) { + this.options.onMessageAdmitted?.({ + attachmentCount: submission.attachments.length, + isFirstMessage, + }); + } return { disposition: 'consumed' }; } this.options.setHint('Wait for the current response or press Esc to interrupt'); @@ -746,19 +759,32 @@ export class TuiCommandFlow { command, submission.attachments.map((attachment) => attachment.fileName), ); + let fallbackItemId: string | undefined; try { const draft = { attachments: submission.attachments, }; if (prepared.atomic || submission.clientIntent || submission.reviewRequest) { - await this.options.queueFlow.enqueue(command, draft, submission, fallbackAdmissionId); + fallbackItemId = await this.options.queueFlow.enqueue( + command, + draft, + submission, + fallbackAdmissionId, + ); } else { - await this.options.queueFlow.enqueue(command, draft, undefined, fallbackAdmissionId); + fallbackItemId = await this.options.queueFlow.enqueue( + command, + draft, + undefined, + fallbackAdmissionId, + ); + } + if (fallbackItemId) { + this.options.onMessageAdmitted?.({ + attachmentCount: submission.attachments.length, + isFirstMessage, + }); } - this.options.onMessageAdmitted?.({ - attachmentCount: submission.attachments.length, - isFirstMessage, - }); return 'consumed'; } catch (error) { this.options.queueFlow.cancelAdmission(fallbackAdmissionId); diff --git a/packages/tui/src/tui/shell/chat-layout.ts b/packages/tui/src/tui/shell/chat-layout.ts index e3e2a62e..79a74870 100644 --- a/packages/tui/src/tui/shell/chat-layout.ts +++ b/packages/tui/src/tui/shell/chat-layout.ts @@ -140,6 +140,12 @@ export class TuiChatLayout implements Component { } followBottom(): void { + if (this.fullscreenBodyViewport.isFollowingEnd) { + this.fullscreenBodyViewport.scrollToEnd(); + } + } + + forceFollowBottom(): void { this.fullscreenBodyViewport.scrollToEnd(); } diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 0dd3b9cd..147f85d9 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -13,6 +13,7 @@ import { resolveTuiStartupEnvironmentOption } from "../../src/cli/environment.js import { runTuiCli } from "../../src/cli/main.js"; import { launchTui } from "../../src/tui/launcher.js"; import { createTuiApp } from "../../src/tui/app.js"; +import { TuiChatLayout } from "../../src/tui/shell/chat-layout.js"; import type { TuiModel, TuiQuestionnaireRequest, @@ -517,6 +518,91 @@ function createRuntime(): TuiRuntime { } describe("createTuiApp", () => { + it("force-follows user submissions, /new, and Session transitions", async () => { + const forceFollowBottom = vi.spyOn(TuiChatLayout.prototype, "forceFollowBottom"); + const app = createTuiApp({ + runtime: createRuntime(), + terminal: new FakeTerminal(), + version: "test", + workspaceDir: "/workspace", + }); + app.start(); + + try { + await app.ready; + await app.openSession("session-1"); + + forceFollowBottom.mockClear(); + await app.submit("Continue with the next step"); + await vi.waitFor(() => expect(forceFollowBottom).toHaveBeenCalled()); + + forceFollowBottom.mockClear(); + await app.submit("/new"); + expect(forceFollowBottom).toHaveBeenCalled(); + + forceFollowBottom.mockClear(); + await app.openSession("session-2"); + expect(forceFollowBottom).toHaveBeenCalled(); + } finally { + await app.stop(); + forceFollowBottom.mockRestore(); + } + }); + + it("re-arms follow-tail when a follow-up is accepted into the queue", async () => { + let releaseRun: (() => void) | undefined; + const runGate = new Promise((resolve) => { + releaseRun = resolve; + }); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* pendingRun() { + yield { + type: "delta", + turnId: "turn-pending", + role: "assistant", + content: "Working", + }; + await runGate; + yield { type: "done", turnId: "turn-pending" }; + }, + ); + const forceFollowBottom = vi.spyOn(TuiChatLayout.prototype, "forceFollowBottom"); + const app = createTuiApp({ + runtime, + terminal: new FakeTerminal(), + version: "test", + workspaceDir: "/workspace", + productFeatures: { queue: true }, + }); + app.start(); + + try { + await app.ready; + const firstSubmission = app.submit("Start the live run"); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + + forceFollowBottom.mockClear(); + await app.submit("Queue the follow-up"); + + expect(runtime.enqueueMessage).toHaveBeenCalledWith( + "session-1", + "Queue the follow-up", + expect.objectContaining({ attachments: [] }), + ); + expect(forceFollowBottom).toHaveBeenCalled(); + + releaseRun?.(); + await firstSubmission; + } finally { + releaseRun?.(); + await app.stop(); + forceFollowBottom.mockRestore(); + } + }); + it.each(["regular", "fullscreen"] as const)( "configures /statusline from terminal input and reopens the saved selection in %s mode", async (tuiMode) => { @@ -689,6 +775,11 @@ describe("createTuiApp", () => { it("runs an editor ! command locally and carries its result into the next model message", async () => { const directory = await mkdtemp(join(tmpdir(), "tui-bash-app-")); const runtime = createRuntime(); + const followBottom = vi.spyOn(TuiChatLayout.prototype, "followBottom"); + const forceFollowBottom = vi.spyOn( + TuiChatLayout.prototype, + "forceFollowBottom", + ); const app = createTuiApp({ runtime, terminal: new FakeTerminal(), @@ -697,6 +788,8 @@ describe("createTuiApp", () => { }); try { await app.ready; + followBottom.mockClear(); + forceFollowBottom.mockClear(); app.editor.setText( process.platform === "win32" ? "!Write-Output shell-result" @@ -716,6 +809,8 @@ describe("createTuiApp", () => { ).toBe(true), { timeout: 5000 }, ); + expect(followBottom).toHaveBeenCalled(); + expect(forceFollowBottom).not.toHaveBeenCalled(); expect(runtime.sendMessage).not.toHaveBeenCalled(); expect( app.transcript @@ -734,6 +829,8 @@ describe("createTuiApp", () => { ); } finally { await app.stop(); + followBottom.mockRestore(); + forceFollowBottom.mockRestore(); await rm(directory, { recursive: true, force: true }); } }); diff --git a/packages/tui/test/unit/tui-scrollbar-interaction.test.ts b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts index 10d95b16..09342865 100644 --- a/packages/tui/test/unit/tui-scrollbar-interaction.test.ts +++ b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts @@ -32,6 +32,10 @@ class ScrollableLines implements Component { return [...this.lines]; } + appendLine(line: string): void { + this.lines.push(line); + } + invalidate(): void {} } @@ -302,4 +306,49 @@ describe("Scrollbar interaction boundaries", () => { expect(viewportText(terminal)).not.toContain("line-59"); expect(viewportText(terminal)).toContain("composer"); }); + + it("preserves a detached transcript position until follow-tail is explicitly re-armed", async () => { + const terminal = new VirtualTerminal(40, 15); + const empty = { render: () => [], invalidate() {} }; + const content = new ScrollableLines(CONTENT_LINES); + const layout = new TuiChatLayout(terminal, { + surface: () => "conversation", + transcript: content, + welcome: empty, + interaction: { ...empty, isActive: () => false }, + activity: empty, + followUp: empty, + composer: { render: () => ["composer"], invalidate() {} }, + status: empty, + }); + const tui = new TuiAltScreen(terminal); + screens.push(tui); + tui.setLayoutRoot(layout.fullscreenLayoutRoot); + tui.start(); + await terminal.waitForRender(); + + expect(viewportText(terminal)).toContain("line-59"); + + terminal.sendInput(sgrPress(37, 0)); + terminal.sendInput(sgrRelease(37, 0)); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-00"); + + content.appendLine("line-60"); + layout.followBottom(); + tui.requestRender(); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-00"); + expect(viewportText(terminal)).not.toContain("line-60"); + + terminal.sendInput("\x1b[F"); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-60"); + + content.appendLine("line-61"); + layout.followBottom(); + tui.requestRender(); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-61"); + }); }); diff --git a/packages/tui/test/unit/tui/controller/product/command-flow.test.ts b/packages/tui/test/unit/tui/controller/product/command-flow.test.ts index d101bf8e..d77c98e5 100644 --- a/packages/tui/test/unit/tui/controller/product/command-flow.test.ts +++ b/packages/tui/test/unit/tui/controller/product/command-flow.test.ts @@ -1058,13 +1058,15 @@ describe("TuiCommandFlow", () => { }); it.each([ - ["accepts the handoff", false], - ["rejects the handoff", true], + ["accepts the handoff", false, "queue-item-1"], + ["rejects the handoff", true, undefined], + ["drops a stale Session result", false, undefined], ] as const)( "preserves direct-admission semantics when the Runtime queue %s", - async (_scenario, enqueueFails) => { + async (_scenario, enqueueFails, queuedItemId) => { const draft = { attachments: [] }; const append = vi.fn(); + const onMessageAdmitted = vi.fn(); const controller = { snapshot: vi.fn(() => ({ status: "idle" as const, @@ -1086,6 +1088,7 @@ describe("TuiCommandFlow", () => { cancelAdmission: vi.fn(), enqueue: vi.fn(async () => { if (enqueueFails) throw new Error("queue unavailable"); + return queuedItemId; }), }; const flow = new TuiCommandFlow({ @@ -1116,6 +1119,7 @@ describe("TuiCommandFlow", () => { append, setHint: vi.fn(), onChanged: vi.fn(), + onMessageAdmitted, }); await expect(flow.submit("follow-up")).resolves.toBe( @@ -1134,6 +1138,11 @@ describe("TuiCommandFlow", () => { [], ); expect(composerDraft.completeSubmission).not.toHaveBeenCalled(); + if (queuedItemId) { + expect(onMessageAdmitted).toHaveBeenCalledOnce(); + } else { + expect(onMessageAdmitted).not.toHaveBeenCalled(); + } if (enqueueFails) { expect(queueFlow.cancelAdmission).toHaveBeenCalledWith( expect.stringMatching(/^\d+-\d+$/u), diff --git a/release/public-source.json b/release/public-source.json index b5fd4d50..63804a38 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -59,6 +59,8 @@ "docs/release-audit.md", "docs/releasing.md", "docs/source-sync.md", + "docs/superpowers/plans/2026-09-23-tui-follow-tail.md", + "docs/superpowers/specs/2026-09-23-tui-follow-tail-design.md", "docs/telemetry.md", "docs/tui-capabilities.md", "docs/verification.md", From 25518db09cca248d925d3ea1fbbeba3b4fbecbc1 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:54:16 +0800 Subject: [PATCH 07/23] fix(tui): restore early-aborted prompt to composer (#325) * fix(tui): restore early-aborted prompt to composer * fix(tui): preserve restored submission edits and metadata --- README.md | 2 +- README_ZH.md | 2 +- .../tui/src/application/run-coordinator.ts | 2 +- packages/tui/src/tui/app.ts | 197 ++++- .../tui/controller/chat-controller-types.ts | 6 + .../tui/src/tui/controller/chat-controller.ts | 3 + .../tui/controller/product/command-flow.ts | 112 ++- .../src/tui/controller/run/abort-live-turn.ts | 90 ++- .../run/turn-submission-retainer.ts | 43 + .../tui/features/composer/draft-lifecycle.ts | 59 +- .../tui/features/composer/draft-recovery.ts | 17 +- packages/tui/src/tui/transcript/view.ts | 13 +- packages/tui/src/tui/widgets/editor/editor.ts | 8 +- packages/tui/src/types/tui-app.ts | 3 + packages/tui/test/unit/tui-app.test.ts | 739 +++++++++++++++++- .../run/turn-submission-retainer.test.ts | 47 ++ release/public-source.json | 2 + test/vitest-suites.json | 1 + 18 files changed, 1291 insertions(+), 55 deletions(-) create mode 100644 packages/tui/src/tui/controller/run/turn-submission-retainer.ts create mode 100644 packages/tui/test/unit/tui/controller/run/turn-submission-retainer.test.ts diff --git a/README.md b/README.md index df7353d5..195b9acd 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Inside the TUI, use `/sessions` to find previous sessions and `/help` to see all | Reference a workspace file or directory | `@` | | Toggle Plan Mode | `Shift+Tab` | | Switch permission modes | `Alt+M` | -| Close a panel or interrupt a running task | `Esc` | +| Close a panel or interrupt a running task; interrupting before the model replies returns the message to the composer | `Esc` | ## Uninstall diff --git a/README_ZH.md b/README_ZH.md index 5800a879..ab35a080 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -145,7 +145,7 @@ mcode --session | 引用工作区文件或目录 | `@` | | 切换 Plan Mode | `Shift+Tab` | | 切换权限模式 | `Alt+M` | -| 关闭面板或中断正在运行的任务 | `Esc` | +| 关闭面板或中断正在运行的任务;在模型回复之前中断会把消息放回输入框 | `Esc` | ## 卸载 diff --git a/packages/tui/src/application/run-coordinator.ts b/packages/tui/src/application/run-coordinator.ts index 7e643f20..bfdcda4e 100644 --- a/packages/tui/src/application/run-coordinator.ts +++ b/packages/tui/src/application/run-coordinator.ts @@ -87,7 +87,7 @@ export interface TuiRunCoordinatorOptions { nowMs?: () => number; } -const DEFAULT_CANCELLATION_SETTLEMENT_TIMEOUT_MS = 1_000; +export const DEFAULT_CANCELLATION_SETTLEMENT_TIMEOUT_MS = 1_000; export class TuiRunCoordinator { private activeRun?: ActiveRun; diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index c8812f83..914d96e9 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -17,6 +17,10 @@ import { TranscriptView } from './transcript/view.js'; import { TuiChatController, type TuiChatSnapshot } from './controller/chat-controller.js'; import { TuiChromeFlow } from './controller/product/chrome-flow.js'; import type { TuiDraftLifecycle } from './features/composer/draft-lifecycle.js'; +import { retainAvailableAttachmentPlaceholders } from './features/composer/draft-recovery.js'; +import type { TuiSubmissionSnapshot } from './features/composer/submission.js'; +import { statSync } from 'node:fs'; +import type { TuiTransportAttachment } from '../types/invocation.js'; import { TuiRunProjection } from './state/run-projection.js'; import { createTuiState, TuiEffectRunner, TuiStateStore } from './state/index.js'; import { FeedbackFlow as Feedback } from './controller/product/feedback-flow.js'; @@ -358,6 +362,9 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { keybindings: options.keybindings, isStopped: () => stopped, }); + // Turns whose prompt was already returned to the composer by the abort + // restore; keys are runtime turn ids, unique for the app's lifetime. + const restoredAbortTurnIds = new Set(); abortLiveTurn = createTuiAbortLiveTurn({ controller, runProjection, @@ -371,6 +378,125 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { if (!stopped) tui.requestRender(); }, append: appendLocalCell, + onLiveTurnAborted: ({ turnId }) => { + // Return the aborted prompt to the composer only when the turn produced + // no user-visible output: any assistant text, tool call, or steer + // message means the user was interacting with a live response, not + // regretting a fresh submission. Thinking is internal model process and + // does not disqualify: it is the most common regret moment, and nothing + // of value is discarded by resending. + if (sessionFlow.isSideModeActive()) return; + const cells = transcript.snapshot().filter((cell) => cell.turnId === turnId); + const userCell = transcript.get(`user:${turnId}`); + if (!userCell) return; // runtime-owned or retry-continuation turns + // A second Esc in the settle window can reach the runtime-owned branch + // with the same turnId; track restored turns so a repeat concatenates + // nothing. (Cell status cannot key this: abort-time markTurn also + // cancels still-pending user cells before the first restore.) + if (restoredAbortTurnIds.has(turnId)) return; + const hasIrreversibleActivity = cells.some((cell) => { + if (cell.id === `user:${turnId}`) return false; + if (cell.kind === 'thinking' || cell.kind === 'turn-duration') return false; + // markTurn('cancelled') synthesizes an empty assistant placeholder for + // turns with no assistant output before this callback can run. + if ((cell.kind === 'assistant' || cell.kind === 'assistant-preamble') && !cell.content) { + return false; + } + return true; + }); + if (hasIrreversibleActivity) return; + // Preferred path: replay the ORIGINAL submission retained for this turn + // (complete attachment set, transport content, client intent, editor + // state) instead of reconstructing a lossy copy from the display cell. + const retained = commandFlow.getRetainedSubmission(turnId); + const retainedFiltered = retained + ? filterRetainedSubmissionForRestore(retained) + : undefined; + if (retained && retainedFiltered) { + commandFlow.restoreSubmission(retainedFiltered); + commandFlow.markAbortSubmissionRestored(retained.submissionId); + commandFlow.dropRetainedSubmission(turnId); + restoredAbortTurnIds.add(turnId); + markAbortedUserRowCancelled(transcript, turnId); + draftLifecycle?.recordRestoredSubmission(retainedFiltered); + chromeFlow?.setHint('Stopped · message restored to the Composer.'); + updateChrome(controller.snapshot()); + tui.requestRender(); + return; + } + const text = userCell.content; + const draftAttachments = (userCell.attachments ?? []).flatMap((attachment) => + attachment.filePath && typeof attachment.sizeBytes === 'number' + ? [ + { + type: attachment.type, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + filePath: attachment.filePath, + }, + ] + : [], + ); + // Fallback path: reconstruct from the display cell. The transport list + // must carry BOTH halves of a mixed row (asset-backed AND file-backed + // entries): submission-time selection is `transportAttachments ?? + // attachments`, so a partial transport list would silently drop the + // file-backed attachments on resubmit. + const transportAttachments: TuiTransportAttachment[] = (userCell.attachments ?? []).flatMap( + (attachment): TuiTransportAttachment[] => + attachment.assetId + ? [ + { + type: attachment.type, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + ...(attachment.filePath ? { filePath: attachment.filePath } : {}), + assetId: attachment.assetId, + }, + ] + : attachment.filePath && typeof attachment.sizeBytes === 'number' + ? [ + { + type: attachment.type, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + filePath: attachment.filePath, + }, + ] + : [], + ); + if (!text.trim() && draftAttachments.length === 0 && transportAttachments.length === 0) { + return; + } + const restoredSubmission: TuiSubmissionSnapshot = { + submissionId: `abort-restore:${turnId}`, + sessionId: controller.snapshot().session?.sessionId, + content: text, + attachments: draftAttachments, + ...(transportAttachments.length > 0 ? { transportAttachments } : {}), + createdAtMs: Date.now(), + editor: { + schemaVersion: 1, + text, + cursor: text.length, + pastes: [], + pasteCounter: 0, + }, + }; + commandFlow.restoreSubmission(restoredSubmission); + restoredAbortTurnIds.add(turnId); + markAbortedUserRowCancelled(transcript, turnId); + // The restored text already carries the submission's content; a pending + // retry for it would merge the same text again on the next hydrate. + draftLifecycle?.recordRestoredSubmission(restoredSubmission); + chromeFlow?.setHint('Stopped · message restored to the Composer.'); + // The funnel's updateChrome tail ran during the settle wait, before this + // hint was set; push the chrome once more so the hint is not left + // unpresented. + updateChrome(controller.snapshot()); + tui.requestRender(); + }, }); const planModeFlow = new TuiPlanModeFlow({ currentSession: () => controller.snapshot().session, @@ -447,8 +573,11 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { }, followBottom: () => layout.forceFollowBottom(), requestWelcomeRebuild: () => tui.requestImmediateRender(), - switchComposerDraft: (sessionKey) => - draftLifecycle?.switchSession(sessionKey) ?? Promise.resolve(), + switchComposerDraft: (sessionKey) => { + // Retained turn submissions belong to the previous session's turns. + commandFlow.clearRetainedSubmissions(); + return draftLifecycle?.switchSession(sessionKey) ?? Promise.resolve(); + }, detachForegroundObserver: () => runtimeEventFlow?.detachForegroundObserver(), adoptForegroundRun: () => sessionLifecycle.adoptForegroundRun( @@ -730,8 +859,8 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { activity.dispose(); widgets.imagePreview.dispose(); renderer.prepareTranscriptExit(); - disposeComponents(surfaceHost, editor, transcriptView, status, goal); draftStopPromise ??= draftLifecycle?.stop() ?? Promise.resolve(); + disposeComponents(surfaceHost, editor, transcriptView, status, goal); if (stopOptions.abortActiveTurn !== false) { const snapshot = controller.snapshot(); if (snapshot.activeTurnId) void controller.abort().catch(() => undefined); @@ -808,8 +937,70 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { updateChromeAndRequestRender(); }, submit: (input) => commandFlow.submit(input).then(() => undefined), + /** Exposed for integration tests that submit with an explicit seed. */ + commandFlow, abortTurn: abortLiveTurn, leaveUi, stop, }; } + +function fileExists(filePath: string): boolean { + try { + return statSync(filePath).isFile(); + } catch { + return false; + } +} + +/** + * Filters a retained submission snapshot for abort-time restore: drops + * attachments whose backing file disappeared (asset-backed entries stay), + * and prunes editor placeholders accordingly. Returns undefined when nothing + * restorable remains. + */ +function filterRetainedSubmissionForRestore( + snapshot: TuiSubmissionSnapshot, +): TuiSubmissionSnapshot | undefined { + const attachments = snapshot.attachments.filter((attachment) => + fileExists(attachment.filePath), + ); + const transportAttachments = (snapshot.transportAttachments ?? []).filter((attachment) => + attachment.assetId ? true : attachment.filePath ? fileExists(attachment.filePath) : false, + ); + if (!snapshot.content.trim() && attachments.length === 0 && transportAttachments.length === 0) { + return undefined; + } + return { + ...snapshot, + submissionId: `abort-restore:${snapshot.submissionId}`, + attachments, + ...(snapshot.transportAttachments ? { transportAttachments } : {}), + editor: retainAvailableAttachmentPlaceholders( + // Some submission paths (automation results, test seeds) submit with a + // payload but an empty editor draft; restoring that verbatim would + // leave the composer blank, so fall back to the visible content. + snapshot.editor.text.trim() + ? snapshot.editor + : { ...snapshot.editor, text: snapshot.content, cursor: snapshot.content.length }, + attachments, + ), + }; +} + +/** Marks the aborted turn's user row cancelled so it stays visible in history. */ +function markAbortedUserRowCancelled(transcript: TranscriptStore, turnId: string): void { + const userCell = transcript.get(`user:${turnId}`); + if (!userCell) return; + // markTurn does not rewrite the user cell once the runtime echo flipped it + // to 'succeeded', so upsert explicitly. createdAtMs is force-preserved by + // the store's merge for existing cells and is intentionally omitted. + transcript.upsert({ + id: `user:${turnId}`, + kind: 'user', + status: 'cancelled', + ...(userCell.content ? { content: userCell.content } : {}), + updatedAtMs: Date.now(), + ...(userCell.attachments ? { attachments: userCell.attachments } : {}), + }); +} diff --git a/packages/tui/src/tui/controller/chat-controller-types.ts b/packages/tui/src/tui/controller/chat-controller-types.ts index 992399b4..02c9d4a9 100644 --- a/packages/tui/src/tui/controller/chat-controller-types.ts +++ b/packages/tui/src/tui/controller/chat-controller-types.ts @@ -70,4 +70,10 @@ export interface TuiSubmitOptions { beforeTurnAdmission?: (sessionId: string) => void | Promise; onRuntimeAccepted?: (sessionId: string) => void; optimisticRequestId?: string; + /** + * Fired once a new turn has been minted and projected (before the first + * await), so the caller can retain per-turn state such as the original + * submission snapshot. Not fired for steer paths or retry continuations. + */ + onTurnStarted?: (turnId: string) => void; } diff --git a/packages/tui/src/tui/controller/chat-controller.ts b/packages/tui/src/tui/controller/chat-controller.ts index be98e4c6..0d970508 100644 --- a/packages/tui/src/tui/controller/chat-controller.ts +++ b/packages/tui/src/tui/controller/chat-controller.ts @@ -389,6 +389,9 @@ export class TuiChatController { errorRetryable: undefined, }); if (!optimisticCell && !isRetryContinuation) this.onUserSubmissionProjected?.(); + // Before the first await: the turn id is final, so the caller can retain + // the original submission snapshot for this turn. + if (!isRetryContinuation) options.onTurnStarted?.(turnId); try { await this.requireLoginForAgentAction(); diff --git a/packages/tui/src/tui/controller/product/command-flow.ts b/packages/tui/src/tui/controller/product/command-flow.ts index b78157c0..43b93fdc 100644 --- a/packages/tui/src/tui/controller/product/command-flow.ts +++ b/packages/tui/src/tui/controller/product/command-flow.ts @@ -12,7 +12,7 @@ import { TuiLoginRegionPicker } from '../../features/auth/login-region-picker.js import { TuiPermissionModePicker } from '../../features/interaction/permission-mode-picker.js'; import { TuiSettingsPicker } from '../../features/settings/picker.js'; import { TuiHotkeysPicker } from '../../features/settings/hotkeys-picker.js'; -import type { Editor } from '../../widgets/editor/editor.js'; +import { submittedEditorContent, type Editor } from '../../widgets/editor/editor.js'; import type { TuiInteractionSurface } from '../../shell/interaction-surface.js'; import type { TuiSurfaceHost } from '../../shell/surface-host.js'; import type { TuiRunProjection } from '../../state/run-projection.js'; @@ -23,6 +23,7 @@ import type { FeedbackFlow } from './feedback-flow.js'; import type { TuiSessionMutationFlow } from './session-mutation-flow.js'; import type { TuiInteractionFlow } from '../interaction/interaction-flow.js'; import type { TuiQueueFlow } from '../run/queue-flow.js'; +import { TuiTurnSubmissionRetainer } from '../run/turn-submission-retainer.js'; import type { TuiSessionFlow } from '../session-flow.js'; import type { TuiUpdateFlow } from './update-flow.js'; import type { TuiGoalFlow } from './goal-flow.js'; @@ -49,7 +50,7 @@ import { rebuildSessionMutationTransport } from '../../features/session-mutation import { toTuiTranscriptAttachments } from '../../features/composer/attachments.js'; import { resolveTuiRuntimeFailure } from '../runtime/runtime-error-presentation.js'; import type { TuiEditMessageAttachment, TuiSession } from '../../../runtime/port.js'; -import type { TuiTransportAttachment } from '../../../types/invocation.js'; +import type { TuiAttachment, TuiTransportAttachment } from '../../../types/invocation.js'; import { sessionMutationText } from '../../features/session-mutation/copy.js'; import { parseTuiBashInput } from '../../commands/bash-input.js'; import type { TuiBashFlow } from './bash-flow.js'; @@ -171,6 +172,8 @@ export class TuiCommandFlow { private readonly recoverableSubmissions = new Map(); private readonly sessionRetryability = new Map(); private readonly failedSubmissions = new Map(); + private readonly turnSubmissionRetainer = new TuiTurnSubmissionRetainer(); + private readonly restoredAbortSubmissionIds = new Set(); private readonly openExternalTarget: TuiExternalTargetOpener; constructor(private readonly options: TuiCommandFlowOptions) { @@ -181,6 +184,30 @@ export class TuiCommandFlow { ); } + /** The original submission retained for a still-unoutput turn, if any. */ + getRetainedSubmission(turnId: string): TuiSubmissionSnapshot | undefined { + return this.turnSubmissionRetainer.get(turnId); + } + + dropRetainedSubmission(turnId: string): void { + this.turnSubmissionRetainer.drop(turnId); + } + + /** Clears per-turn retained submissions on a session switch. */ + clearRetainedSubmissions(): void { + this.turnSubmissionRetainer.clear(); + this.restoredAbortSubmissionIds.clear(); + } + + /** Keep a restored draft's attachment lease when its original send finishes. */ + markAbortSubmissionRestored(submissionId: string): void { + this.restoredAbortSubmissionIds.add(submissionId); + if (this.restoredAbortSubmissionIds.size > 8) { + const oldest = this.restoredAbortSubmissionIds.values().next().value; + if (oldest) this.restoredAbortSubmissionIds.delete(oldest); + } + } + captureSubmissionSeed(editorDraft?: ReturnType): TuiSubmissionSeed { const sessionId = this.options.controller.snapshot().session?.sessionId; const editor = editorDraft ?? this.options.editor.captureDraft(); @@ -194,15 +221,32 @@ export class TuiCommandFlow { const recoveryKey = sessionId ?? 'new-session'; const recoverable = this.recoverableSubmissions.get(recoveryKey); this.recoverableSubmissions.delete(recoveryKey); + const visibleContent = submittedEditorContent(editor); + const unchangedText = recoverable?.content === visibleContent; + // Hidden context may be rebuilt for an edited session-mutation message. + // Other opaque transport (including /review) belongs to the old text and + // must not override the user's correction. + const transportContent = recoverable?.transportContent && + (unchangedText || rebuildSessionMutationTransport(recoverable.transportContent, visibleContent)) + ? recoverable.transportContent + : undefined; + const transportAttachments = recoverable?.transportAttachments + ? reconcileRecoveredTransportAttachments(recoverable, resources.attachments) + : undefined; return { ...(sessionId ? { sessionId } : {}), editor, resources, - ...(recoverable?.transportContent ? { transportContent: recoverable.transportContent } : {}), - ...(recoverable?.transportAttachments - ? { transportAttachments: recoverable.transportAttachments } + ...(transportContent ? { transportContent } : {}), + ...(transportAttachments + ? { transportAttachments } + : {}), + ...(unchangedText && recoverable?.clientIntent + ? { clientIntent: recoverable.clientIntent } + : {}), + ...(unchangedText && recoverable?.reviewRequest + ? { reviewRequest: recoverable.reviewRequest } : {}), - ...(recoverable?.reviewRequest ? { reviewRequest: recoverable.reviewRequest } : {}), }; } @@ -552,9 +596,7 @@ export class TuiCommandFlow { isFirstMessage, }); this.options.planModeFlow?.rejectSubmission(submission.submissionId); - await this.options.composerDraft.completeSubmission({ - attachments: submission.attachments, - }); + await this.completeSubmittedResources(submission); return { disposition: 'consumed' }; } catch (error) { this.options.planModeFlow?.rejectSubmission(submission.submissionId); @@ -635,6 +677,7 @@ export class TuiCommandFlow { }); } await this.options.featureFlow.waitForWelcomeModelSelection(); + let retainedTurnId: string | undefined; try { return { primary: this.options.controller.submit(submission.transportContent ?? command, { @@ -649,9 +692,20 @@ export class TuiCommandFlow { ...(optimisticRequestId ? { optimisticRequestId } : {}), ...(submission.clientIntent ? { clientIntent: submission.clientIntent } : {}), ...(submission.reviewRequest ? { reviewRequest: submission.reviewRequest } : {}), + onTurnStarted: (turnId) => { + // Retain the original submission for this turn so an early + // abort can replay it in full instead of reconstructing a + // lossy copy from the transcript cell. + retainedTurnId = turnId; + this.turnSubmissionRetainer.remember(turnId, submission); + }, onSessionResolved: (sessionId) => { submission = { ...submission, sessionId }; preparingSubmission = submission; + // Re-remember with the resolved session id attached. + if (retainedTurnId !== undefined) { + this.turnSubmissionRetainer.remember(retainedTurnId, submission); + } options.onSubmissionPrepared?.(submission); if (submission.clientIntent) { this.options.planModeFlow?.bindSubmissionToSession( @@ -744,9 +798,7 @@ export class TuiCommandFlow { } if (this.options.isStopped?.()) { await this.options.whenStopping?.(); - await this.options.composerDraft.completeSubmission({ - attachments: submission.attachments, - }); + await this.completeSubmittedResources(submission); return 'consumed'; } if (submitStatus === 'queue-required' && this.options.queueEnabled) { @@ -824,9 +876,7 @@ export class TuiCommandFlow { submitStatus === 'cancelled' || submitStatus === 'ignored' ) { - await this.options.composerDraft.completeSubmission({ - attachments: submission.attachments, - }); + await this.completeSubmittedResources(submission); } else if (prepared.atomic) { this.restoreSubmission(submission); } else { @@ -875,16 +925,26 @@ export class TuiCommandFlow { !attachment.filePath || !submission.attachments.some((draft) => draft.filePath === attachment.filePath), ); - if (submission.transportContent || submission.reviewRequest || hasTransportOnlyAttachment) { - this.restoreRecoverableSubmission(submission); - } this.options.editor.restoreSubmittedDraft(submission.editor); this.options.composerDraft.restoreSubmission({ attachments: submission.attachments }); + if ( + submission.transportContent || + submission.clientIntent || + submission.reviewRequest || + hasTransportOnlyAttachment + ) { + this.restoreRecoverableSubmission(submission); + } this.options.surfaceHost.setChatFocus(this.options.editor); this.options.onChanged(); return 'retained'; } + private async completeSubmittedResources(submission: TuiSubmissionSnapshot): Promise { + if (this.restoredAbortSubmissionIds.delete(submission.submissionId)) return; + await this.options.composerDraft.completeSubmission({ attachments: submission.attachments }); + } + private async completeStoppedSubmission(seed: TuiSubmissionSeed): Promise { await this.options.whenStopping?.(); await this.options.composerDraft.completeSubmission(seed.resources); @@ -1754,6 +1814,22 @@ function toRecoveredEditTransportAttachments( }); } +function reconcileRecoveredTransportAttachments( + submission: TuiSubmissionSnapshot, + currentAttachments: readonly TuiAttachment[], +): TuiTransportAttachment[] { + const originalLocalPaths = new Set(submission.attachments.map((item) => item.filePath)); + const currentPaths = new Set(currentAttachments.map((item) => item.filePath)); + const retained = (submission.transportAttachments ?? []).filter( + (item) => !item.filePath || !originalLocalPaths.has(item.filePath) || currentPaths.has(item.filePath), + ); + const retainedPaths = new Set(retained.flatMap((item) => item.filePath ? [item.filePath] : [])); + return [ + ...retained, + ...currentAttachments.filter((item) => !retainedPaths.has(item.filePath)), + ]; +} + function prepareSubmissionFailureMessage(error: unknown): string { if (error instanceof TuiLoginRequiredError) return error.message; return formatTuiActionFailure(error, { diff --git a/packages/tui/src/tui/controller/run/abort-live-turn.ts b/packages/tui/src/tui/controller/run/abort-live-turn.ts index 5b82ac1e..636572f0 100644 --- a/packages/tui/src/tui/controller/run/abort-live-turn.ts +++ b/packages/tui/src/tui/controller/run/abort-live-turn.ts @@ -3,6 +3,7 @@ import { isTuiDelegatedSession } from '../../../runtime/delegation.js'; import type { TuiRunProjection } from '../../state/run-projection.js'; import type { TuiChatController } from '../chat-controller.js'; import { formatTuiActionFailure } from '../../../user-facing-failure.js'; +import { DEFAULT_CANCELLATION_SETTLEMENT_TIMEOUT_MS } from '../../../application/run-coordinator.js'; export function createTuiAbortLiveTurn(options: { controller: TuiChatController; @@ -15,6 +16,15 @@ export function createTuiAbortLiveTurn(options: { updateChrome(): void; requestRender(): void; append(message: string, kind: 'warning' | 'error'): void; + /** + * Invoked at most once per abort attempt after a confirmed stop of the + * aborted turn, so the caller can return that turn's prompt to the + * composer when the turn produced no user-visible output. Only a confirmed + * root-turn stop fires this; a delegated-only stop may leave the root turn + * running. A second abort attempt for the same turn may fire it again — + * the caller owns cross-attempt dedupe. + */ + onLiveTurnAborted?: (info: { turnId: string; sessionId?: string }) => void; }): () => Promise { return async () => { const snapshot = options.controller.snapshot(); @@ -47,19 +57,50 @@ export function createTuiAbortLiveTurn(options: { return receipt.rootStopped || receipt.stoppedSessionIds.length > 0; }; if (snapshot.activeTurnId) { + const turnId = snapshot.activeTurnId; + let abortedRoot = false; + let delegatedStopped = false; try { const stoppingDelegation = stopDelegatedAgents(); - const aborted = await options.controller.abort(); - const delegatedStopped = await stoppingDelegation; - if (!aborted && !delegatedStopped) { + abortedRoot = await options.controller.abort(); + delegatedStopped = await stoppingDelegation; + if (!abortedRoot && !delegatedStopped) { options.append('Runtime did not confirm that the active response stopped.', 'warning'); } - return delegatedStopped || aborted; } finally { options.setTransientHint(undefined); options.updateChrome(); options.requestRender(); } + // Restore only after the run is CONFIRMED settled, not merely accepted: + // coordinator.abort() can return true after the settlement timeout while + // the run still drains, and a late stream event would defeat the gate. + // Bounded wait: if retirement does not settle within the cancellation + // settlement window, skip the restore entirely (an unbounded await + // would hang the funnel — nothing resolves idle waiters in that state). + // An unconfirmed stop skips the wait: no restore can fire, so the + // funnel (and leaveUi behind it) should not pay the bound. + let settleTimer: ReturnType | undefined; + const settledInTime = abortedRoot + ? await Promise.race([ + options.controller.whenIdle().then(() => true), + new Promise((resolve) => { + settleTimer = setTimeout( + () => resolve(false), + DEFAULT_CANCELLATION_SETTLEMENT_TIMEOUT_MS, + ); + }), + ]) + : false; + // Match the settleWithin house pattern: never leave the losing timer + // holding the event loop (embedded hosts drain on exit). + if (settleTimer !== undefined) clearTimeout(settleTimer); + // Fire after the finally block: it clears the transient hint and would + // otherwise erase the restore hint set by the callback. + if (abortedRoot && settledInTime && options.onLiveTurnAborted) { + fireRestore(options, { turnId, sessionId: session?.sessionId }); + } + return delegatedStopped || abortedRoot; } const runtimeTurnId = options.latestRuntimeTurnId(); if (!runtimeTurnId || !snapshot.session?.sessionId) return false; @@ -80,17 +121,23 @@ export function createTuiAbortLiveTurn(options: { options.append('Runtime did not confirm that the active response stopped.', 'warning'); } const active = await options.getActiveRun(sessionId).catch(() => undefined); - if ( - active && + const settled = + !!active && (active.state === 'idle' || active.state === 'terminal' || - (active.turnId && active.turnId !== runtimeTurnId)) - ) { + (active.turnId && active.turnId !== runtimeTurnId)); + if (settled) { options.runProjection.clearRuntimeTurn(runtimeTurnId); options.setTransientHint(undefined); } options.updateChrome(); options.requestRender(); + // Restore only once the run is confirmed settled: runtime-owned turns + // settle on the terminal runtime event, which can trail the abort + // response, and late cells would otherwise defeat the gate. + if (rootStopped && settled && options.onLiveTurnAborted) { + fireRestore(options, { turnId: runtimeTurnId, sessionId }); + } return rootStopped || delegatedStopped; } catch (error) { options.runProjection.clearRuntimeTurnStopping(runtimeTurnId); @@ -109,3 +156,30 @@ export function createTuiAbortLiveTurn(options: { } }; } + +/** + * Fires the restore callback with Esc-handling isolation: a restore failure + * is reported as a warning instead of escaping the funnel. + */ +function fireRestore( + options: { + onLiveTurnAborted?: (info: { turnId: string; sessionId?: string }) => void; + append: (message: string, kind: 'warning' | 'error') => void; + }, + info: { turnId: string; sessionId?: string }, +): void { + if (!options.onLiveTurnAborted) return; + try { + options.onLiveTurnAborted(info); + } catch (error) { + // Esc handling must survive a restore failure; report it instead. + options.append( + formatTuiActionFailure(error, { + summary: "Couldn't return the aborted prompt to the composer.", + nextStep: 'Retry Esc, or recall the prompt with Up.', + preservation: 'The session transcript is unchanged.', + }), + 'warning', + ); + } +} diff --git a/packages/tui/src/tui/controller/run/turn-submission-retainer.ts b/packages/tui/src/tui/controller/run/turn-submission-retainer.ts new file mode 100644 index 00000000..950dc321 --- /dev/null +++ b/packages/tui/src/tui/controller/run/turn-submission-retainer.ts @@ -0,0 +1,43 @@ +import type { TuiSubmissionSnapshot } from '../../features/composer/submission.js'; + +interface RetainedTurnSubmission { + readonly snapshot: TuiSubmissionSnapshot; + readonly createdAtMs: number; +} + +const RETAINED_TURNS_LIMIT = 8; + +/** + * Retains the most recent submission snapshots keyed by turn id so an early + * abort can replay the ORIGINAL submission (complete attachment set, + * transport content, client intent, editor state) instead of reconstructing + * a lossy copy from the transcript display cell. Entries are dropped once + * restored; the map is bounded and cleared on session switch. + */ +export class TuiTurnSubmissionRetainer { + private readonly entries = new Map(); + + remember(turnId: string, snapshot: TuiSubmissionSnapshot, now = Date.now()): void { + // Delete-then-set keeps iteration order aligned with recency so the + // oldest entry is always the first to evict. + this.entries.delete(turnId); + this.entries.set(turnId, { snapshot, createdAtMs: now }); + while (this.entries.size > RETAINED_TURNS_LIMIT) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + } + + get(turnId: string): TuiSubmissionSnapshot | undefined { + return this.entries.get(turnId)?.snapshot; + } + + drop(turnId: string): void { + this.entries.delete(turnId); + } + + clear(): void { + this.entries.clear(); + } +} diff --git a/packages/tui/src/tui/features/composer/draft-lifecycle.ts b/packages/tui/src/tui/features/composer/draft-lifecycle.ts index 5ce7513f..c9bd52b6 100644 --- a/packages/tui/src/tui/features/composer/draft-lifecycle.ts +++ b/packages/tui/src/tui/features/composer/draft-lifecycle.ts @@ -13,6 +13,7 @@ import { } from './submission.js'; const ADOPTION_RETRY_DELAY_MS = 30_000; +const RESTORED_SUBMISSION_CODE = 'submission.restored'; export class TuiDraftLifecycle { private recovery?: TuiDraftRecovery; @@ -122,6 +123,39 @@ export class TuiDraftLifecycle { void this.recovery.flush(this.capture()).catch((error: unknown) => this.report(error)); } + /** Persist an aborted submission's opaque metadata without replaying its text on hydrate. */ + recordRestoredSubmission(snapshot: TuiSubmissionSnapshot): void { + if (this.stopped) return; + for (const [submissionToken, pending] of this.pendingSubmissions) { + if (pending.sessionKey !== this.sessionKey) continue; + this.pendingSubmissions.delete(submissionToken); + } + const hasTransportOnlyAttachment = snapshot.transportAttachments?.some( + (attachment) => + !attachment.filePath || + !snapshot.attachments.some((draft) => draft.filePath === attachment.filePath), + ); + if ( + snapshot.transportContent || + snapshot.clientIntent || + snapshot.reviewRequest || + hasTransportOnlyAttachment + ) { + const retryId = `retry:restored:${snapshot.submissionId}`; + this.pendingSubmissions.set(retryId, { + sessionKey: this.sessionKey, + retry: { + retryId, + failureCode: RESTORED_SUBMISSION_CODE, + failedReason: 'Aborted submission returned to the composer.', + snapshot, + }, + }); + } + // This also removes the matching interrupted retry from the recovery file. + void this.recovery?.flush(this.capture()).catch((error: unknown) => this.report(error)); + } + switchSession(sessionKey: string): Promise { return this.enqueueTransition(async () => { if (this.stopped || sessionKey === this.sessionKey) return; @@ -202,12 +236,15 @@ export class TuiDraftLifecycle { async stop(): Promise { if (this.stopped) return; + // The app disposes the editor immediately after calling stop(); capture + // placeholder metadata before its dispose() clears that state. + const finalDraft = this.capture(); this.stopped = true; await this.transitionTail.catch(() => undefined); let releaseDraft = true; if (this.recovery) { try { - await this.recovery.flush(this.capture(), { materializeVolatileAttachments: true }); + await this.recovery.flush(finalDraft, { materializeVolatileAttachments: true }); } catch (error) { releaseDraft = false; this.report(error); @@ -251,8 +288,21 @@ export class TuiDraftLifecycle { private capture() { const draft = this.options.composerDraft.snapshot(); + const editor = this.options.editor.captureDraft(); + // Clearing a visible restored draft also discards its hidden metadata. + if (!editor.text && draft.attachments.length === 0) { + for (const [token, pending] of this.pendingSubmissions) { + if ( + pending.sessionKey === this.sessionKey && + pending.retry.failureCode === RESTORED_SUBMISSION_CODE && + (pending.retry.snapshot.editor.text || pending.retry.snapshot.content) + ) { + this.pendingSubmissions.delete(token); + } + } + } return { - editor: this.options.editor.captureDraft(), + editor, attachments: [...draft.attachments], retrySubmissions: this.captureRetrySubmissions(this.sessionKey), }; @@ -262,6 +312,11 @@ export class TuiDraftLifecycle { let restoredEditor = this.options.editor.restoreDraft(recovered.editor); this.options.composerDraft.restoreAttachments(recovered.attachments); for (const retry of recovered.retrySubmissions ?? []) { + if (retry.failureCode === RESTORED_SUBMISSION_CODE) { + this.pendingSubmissions.set(retry.retryId, { sessionKey: this.sessionKey, retry }); + this.options.onRetryRestored?.(retry.snapshot); + continue; + } if (retry.failureCode !== 'submission.interrupted') continue; restoredEditor = this.options.editor.restoreSubmittedDraft(retry.snapshot.editor) || restoredEditor; diff --git a/packages/tui/src/tui/features/composer/draft-recovery.ts b/packages/tui/src/tui/features/composer/draft-recovery.ts index 8f7b94f5..0e8a829e 100644 --- a/packages/tui/src/tui/features/composer/draft-recovery.ts +++ b/packages/tui/src/tui/features/composer/draft-recovery.ts @@ -437,6 +437,7 @@ export class TuiDraftRecovery { : {}), createdAtMs: snapshot.createdAtMs, ...(snapshot.clientIntent ? { clientIntent: snapshot.clientIntent } : {}), + ...(snapshot.reviewRequest ? { reviewRequest: snapshot.reviewRequest } : {}), }; } @@ -547,6 +548,7 @@ function cloneRetrySubmission(retry: TuiRetrySubmission): TuiRetrySubmission { : {}), createdAtMs: retry.snapshot.createdAtMs, ...(retry.snapshot.clientIntent ? { clientIntent: retry.snapshot.clientIntent } : {}), + ...(retry.snapshot.reviewRequest ? { reviewRequest: retry.snapshot.reviewRequest } : {}), }, }; } @@ -579,7 +581,8 @@ function relocateEditorAttachmentPlaceholders( }; } -function retainAvailableAttachmentPlaceholders( +/** Drops editor attachment placeholders whose backing file no longer exists. */ +export function retainAvailableAttachmentPlaceholders( editor: EditorDraftSnapshot, attachments: readonly TuiAttachment[], ): EditorDraftSnapshot { @@ -620,6 +623,16 @@ function relocateStoredDraftAssets( ...retry.snapshot, attachments: retryAttachments, editor: relocateEditorAttachmentPlaceholders(retry.snapshot.editor, relocatedPaths), + ...(retry.snapshot.transportAttachments + ? { + transportAttachments: retry.snapshot.transportAttachments.map((attachment) => ({ + ...attachment, + ...(attachment.filePath && relocatedPaths.has(attachment.filePath) + ? { filePath: relocatedPaths.get(attachment.filePath) } + : {}), + })), + } + : {}), }, }; }); @@ -711,6 +724,8 @@ function isSubmissionSnapshot(value: unknown): value is TuiSubmissionSnapshot { (value.clientIntent !== undefined && value.clientIntent !== 'plan-entry' && value.clientIntent !== 'plan-exit') || + (value.reviewRequest !== undefined && + (!isRecord(value.reviewRequest) || value.reviewRequest.scope !== 'local_changes')) || !Number.isFinite(value.createdAtMs) ) { return false; diff --git a/packages/tui/src/tui/transcript/view.ts b/packages/tui/src/tui/transcript/view.ts index 86986735..25a17f8f 100644 --- a/packages/tui/src/tui/transcript/view.ts +++ b/packages/tui/src/tui/transcript/view.ts @@ -821,9 +821,16 @@ function renderUserIntent(content: string, width: number): string[] { function renderUserMessage(cell: TranscriptCell, width: number): string[] { const attachments = renderUserAttachments(cell, width); const body = cell.content.trim() ? renderUserIntent(cell.content, width) : []; - return attachments.length > 0 && body.length > 0 - ? [...attachments, ' ', ...body] - : [...attachments, ...body]; + const rows = + attachments.length > 0 && body.length > 0 + ? [...attachments, ' ', ...body] + : [...attachments, ...body]; + // A cancelled user row (prompt restored to the composer on abort) stays in + // the history with a muted marker, matching the cancelled-todo precedent. + if (cell.status === 'cancelled') { + return [chalk.hex(colors.muted)('× Cancelled'), ...rows]; + } + return rows; } function renderPendingSteerMessage(cell: TranscriptCell, width: number): string[] { diff --git a/packages/tui/src/tui/widgets/editor/editor.ts b/packages/tui/src/tui/widgets/editor/editor.ts index eeca2a0f..f81e3eff 100644 --- a/packages/tui/src/tui/widgets/editor/editor.ts +++ b/packages/tui/src/tui/widgets/editor/editor.ts @@ -450,8 +450,7 @@ export class Editor implements Component, Focusable { ), }; this.pendingSubmission = undefined; - const visibleText = removeAttachmentElements(draft.text, draft.attachmentPlaceholders ?? []); - const content = expandDraftPastes(visibleText, draft.pastes).trim(); + const content = submittedEditorContent(draft); if (draft.attachmentPlaceholders?.length) this.lastAttachmentSubmission = { content, draft }; this.onSubmit?.(content, draft); } @@ -808,6 +807,11 @@ function appendAttachmentElements( return { text: output, cursor, elements: appended }; } +export function submittedEditorContent(draft: EditorDraftSnapshot): string { + const visibleText = removeAttachmentElements(draft.text, draft.attachmentPlaceholders ?? []); + return expandDraftPastes(visibleText, draft.pastes).trim(); +} + function removeAttachmentElements( text: string, elements: readonly EditorAttachmentElement[], diff --git a/packages/tui/src/types/tui-app.ts b/packages/tui/src/types/tui-app.ts index 2228931f..32c30d2d 100644 --- a/packages/tui/src/types/tui-app.ts +++ b/packages/tui/src/types/tui-app.ts @@ -1,6 +1,7 @@ import type { TuiIncidentSink, TuiObservability } from '../observability/index.js'; import type { McodeAuthPort } from '../auth/application.js'; import type { TuiRuntime, TuiWorkspaceRoot } from '../runtime/port.js'; +import type { TuiCommandFlow } from '../tui/controller/product/command-flow.js'; import type { TuiAttachment, ResolveTuiAttachmentOptions, @@ -106,6 +107,8 @@ export interface TuiApp { /** Internal startup chrome shown while the embedded Runtime is initializing. */ setStartupStatus(status?: string): void; submit(input: string): Promise; + /** Exposed for integration tests that submit with an explicit seed. */ + commandFlow: TuiCommandFlow; abortTurn(): Promise; leaveUi(): Promise; stop(options?: TuiStopOptions): Promise; diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 147f85d9..ab081afd 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -11326,12 +11326,15 @@ describe("createTuiApp", () => { app.start(); await app.ready; const submitting = app.submit("Wait for the session"); - await vi.waitFor(() => - expect(app.controller.snapshot()).toMatchObject({ + let turnIdAtAbort: string | undefined; + await vi.waitFor(() => { + const snapshot = app.controller.snapshot(); + turnIdAtAbort = snapshot.activeTurnId; + expect(snapshot).toMatchObject({ status: "starting", activeTurnId: expect.any(String), - }), - ); + }); + }); expect(app.tui.render(80).join("\n")).toContain("Loading"); expect(app.tui.render(80).join("\n")).not.toContain("Loading · 0s"); expect(app.tui.render(80).join("\n")).not.toContain("MCode ·"); @@ -11346,11 +11349,15 @@ describe("createTuiApp", () => { await submitting; expect(runtime.sendMessage).not.toHaveBeenCalled(); - expect(app.transcript.snapshot()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: "assistant", status: "cancelled" }), - ]), - ); + // The turn had produced no output, so its prompt returned to the composer; + // the user row stays in the transcript, marked cancelled. + expect(app.editor.getText()).toBe("Wait for the session"); + expect(app.transcript.get(`user:${turnIdAtAbort}`)?.status).toBe("cancelled"); + expect( + app.transcript + .snapshot() + .some((cell) => cell.kind === "assistant" && cell.status === "cancelled"), + ).toBe(true); await app.stop(); }); @@ -11400,7 +11407,9 @@ describe("createTuiApp", () => { ); terminal.input?.("Second request"); terminal.input?.("\r"); - await vi.waitFor(() => expect(app.editor.getText()).toBe("Second request")); + await vi.waitFor(() => + expect(app.editor.getText()).toContain("Second request"), + ); expect(runtime.sendMessage).toHaveBeenCalledTimes(1); await vi.waitFor(() => @@ -11410,6 +11419,11 @@ describe("createTuiApp", () => { expect(app.controller.snapshot().retiringTurnId).toBeUndefined(), ); expect(app.controller.snapshot().cancelling).toBe(false); + // The aborted first prompt (still without output) returns to the composer + // and merges with the text typed while the fence was active. + await vi.waitFor(() => + expect(app.editor.getText()).toBe("First request\nSecond request"), + ); expect(app.tui.render(80).join("\n")).not.toContain( "Stopping the current response", ); @@ -11419,11 +11433,12 @@ describe("createTuiApp", () => { ); expect(runtime.sendMessage).toHaveBeenCalledTimes(2); expect(runtime.enqueueMessage).not.toHaveBeenCalled(); - expect(app.transcript.snapshot()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: "assistant", status: "cancelled" }), - ]), - ); + // Restoring the aborted prompt keeps that turn's rows, marked cancelled. + expect( + app.transcript + .snapshot() + .some((cell) => cell.kind === "assistant" && cell.status === "cancelled"), + ).toBe(true); await app.stop(); }); @@ -11436,6 +11451,9 @@ describe("createTuiApp", () => { }); vi.mocked(runtime.sendMessage).mockImplementation( async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + // One delta keeps this turn outside the abort-prompt-restore window so + // this test stays focused on the failed-submission restore below. + yield { type: "delta", content: "First response" }; await new Promise((resolve) => { signal?.addEventListener("abort", resolve, { once: true }); }); @@ -11491,6 +11509,697 @@ describe("createTuiApp", () => { await app.stop(); }); + it("returns the prompt to the composer when a turn is aborted before any output", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + terminal.input?.("Fix the typo"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + const turnId = app.controller.snapshot().activeTurnId; + expect(turnId).toBeTruthy(); + + terminal.input?.("\x1b"); + await vi.waitFor(() => expect(app.editor.getText()).toBe("Fix the typo")); + + // The user row stays in the transcript, marked cancelled and rendered + // with the muted marker. + const cancelledUserCell = app.transcript.get(`user:${turnId}`); + expect(cancelledUserCell).toBeDefined(); + expect(cancelledUserCell?.status).toBe("cancelled"); + expect( + app.transcript.snapshot().some((cell) => cell.turnId === turnId), + ).toBe(true); + await vi.waitFor(() => + expect(app.tui.render(160).join("\n")).toContain("× Cancelled"), + ); + await vi.waitFor(() => + expect(app.tui.render(160).join("\n")).toContain( + "Stopped · message restored to the Composer.", + ), + ); + await app.stop(); + }); + + it("returns a thinking-only turn's prompt to the composer on abort", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + yield { type: "delta", thinking: "let me consider" }; + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + terminal.input?.("Wrong question"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + const turnId = app.controller.snapshot().activeTurnId; + await vi.waitFor(() => + expect( + app.transcript.snapshot().some((cell) => cell.kind === "thinking"), + ).toBe(true), + ); + + terminal.input?.("\x1b"); + await vi.waitFor(() => expect(app.editor.getText()).toBe("Wrong question")); + expect( + app.transcript.snapshot().some((cell) => cell.turnId === turnId), + ).toBe(true); + await app.stop(); + }); + + it("keeps the transcript and leaves the composer empty when aborting after output", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + yield { type: "delta", content: "partial answer" }; + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + terminal.input?.("Keep this prompt"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + const turnId = app.controller.snapshot().activeTurnId; + await vi.waitFor(() => + expect( + app.transcript.snapshot().some( + (cell) => cell.kind === "assistant" && cell.content.length > 0, + ), + ).toBe(true), + ); + + terminal.input?.("\x1b"); + await vi.waitFor(() => + expect(app.controller.snapshot().activeTurnId).toBeUndefined(), + ); + await vi.waitFor(() => + expect(app.controller.snapshot().retiringTurnId).toBeUndefined(), + ); + + expect(app.editor.getText()).toBe(""); + expect(app.transcript.get(`user:${turnId}`)).toBeDefined(); + expect(app.tui.render(160).join("\n")).not.toContain( + "Stopped · message restored to the Composer.", + ); + await app.stop(); + }); + + it.each([false, true])( + "restores a mixed submission after abort (local attachment removed: %s)", + async (removeLocal) => { + const directory = await mkdtemp(join(tmpdir(), "mcode-mixed-abort-")); + try { + const localPath = join(directory, "local.png"); + await writeFile(localPath, "png"); + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + // Mixed submission: a file-backed attachment (composer chip) plus an + // asset-only transport attachment. Submit through the seed path so the + // snapshot carries the full transport list, exactly as a paste flow + // would produce. + const seed = { + editor: { + schemaVersion: 1 as const, + text: "Mixed restore", + cursor: 13, + pastes: [], + pasteCounter: 0, + }, + resources: { + attachments: [ + { + type: "image" as const, + fileName: "local.png", + mimeType: "image/png", + sizeBytes: 3, + filePath: localPath, + }, + ], + }, + transportAttachments: [ + { + type: "image" as const, + fileName: "local.png", + mimeType: "image/png", + filePath: localPath, + }, + { + type: "image" as const, + fileName: "asset.png", + mimeType: "image/png", + assetId: "asset-mixed-1", + }, + ], + }; + void app.commandFlow.submit("Mixed restore", seed).catch(() => undefined); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + const turnIdAtAbort = app.controller.snapshot().activeTurnId; + + terminal.input?.("\x1b"); + await vi.waitFor(() => + expect(app.editor.getText()).toBe("Mixed restore [Image #1] "), + ); + // The retained snapshot was consulted and dropped after the restore. + await vi.waitFor(() => + expect(app.commandFlow.getRetainedSubmission(String(turnIdAtAbort))).toBeUndefined(), + ); + + if (removeLocal) { + app.editor.handleInput("\x05"); + app.editor.handleInput("\x7f"); + await vi.waitFor(() => + expect(app.editor.getText()).not.toContain("[Image #1]"), + ); + } + + // Resubmit the restored draft with its current attachment selection. + app.editor.handleInput("\r"); + await vi.waitFor(() => + expect(vi.mocked(runtime.sendMessage).mock.calls.length).toBe(2), + ); + + const request = vi.mocked(runtime.sendMessage).mock.calls[1][0]; + expect(request.attachments).toHaveLength(removeLocal ? 1 : 2); + const locals = request.attachments?.map((attachment) => attachment.local) ?? []; + expect(locals).toEqual( + removeLocal + ? [expect.objectContaining({ assetId: "asset-mixed-1" })] + : expect.arrayContaining([ + expect.objectContaining({ filePath: localPath }), + expect.objectContaining({ assetId: "asset-mixed-1" }), + ]), + ); + await app.stop(); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); + + it.each([false, true])( + "restores hidden transport only for unchanged text (edited: %s)", + async (edited) => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + void app.commandFlow.submit("Visible prompt", { + editor: { schemaVersion: 1, text: "", cursor: 0, pastes: [], pasteCounter: 0 }, + resources: { attachments: [] }, + transportContent: "Original hidden transport", + clientIntent: "plan-entry", + }); + await vi.waitFor(() => expect(app.controller.snapshot().status).toBe("running")); + + terminal.input?.("\x1b"); + await vi.waitFor(() => expect(app.editor.getText()).toBe("Visible prompt")); + if (edited) app.editor.setText("Corrected prompt"); + app.editor.handleInput("\r"); + await vi.waitFor(() => expect(vi.mocked(runtime.sendMessage)).toHaveBeenCalledTimes(2)); + const request = vi.mocked(runtime.sendMessage).mock.calls[1][0]; + expect(request.content).toBe(edited ? "Corrected prompt" : "Original hidden transport"); + expect(request.clientIntent).toBe(edited ? undefined : "plan-entry"); + await app.stop(); + }, + ); + + it("sends edited visible text instead of stale hidden review transport after abort", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + void app.commandFlow.submit( + "/review", + { + editor: { schemaVersion: 1, text: "/review", cursor: 7, pastes: [], pasteCounter: 0 }, + resources: { attachments: [] }, + }, + { + forceMessage: true, + transportContent: "Please review my uncommitted changes.", + reviewRequest: { scope: "local_changes" }, + }, + ); + await vi.waitFor(() => expect(app.controller.snapshot().status).toBe("running")); + + terminal.input?.("\x1b"); + await vi.waitFor(() => expect(app.editor.getText()).toBe("/review")); + app.editor.setText("Review only src/foo.ts"); + app.editor.handleInput("\r"); + await vi.waitFor(() => expect(vi.mocked(runtime.sendMessage)).toHaveBeenCalledTimes(2)); + expect(vi.mocked(runtime.sendMessage).mock.calls[1][0]).toMatchObject({ + content: "Review only src/foo.ts", + }); + expect(vi.mocked(runtime.sendMessage).mock.calls[1][0].reviewRequest).toBeUndefined(); + await app.stop(); + }); + + it("does not restore the prompt when the runtime does not confirm the stop", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(false); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + terminal.input?.("Unconfirmed stop"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + const turnId = app.controller.snapshot().activeTurnId; + + terminal.input?.("\x1b"); + await vi.waitFor(() => + expect(app.controller.snapshot().cancelling).toBe(true), + ); + // Let the abort funnel (settle race included) finish before asserting; an + // immediate assert could pass before a regressed restore ever fired. + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(app.editor.getText()).toBe(""); + expect(app.transcript.get(`user:${turnId}`)).toBeDefined(); + await app.stop(); + }); + + it("does not restore the prompt when only delegated agents stopped", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(false); + vi.mocked(runtime.stopDelegation).mockResolvedValue({ + rootStopped: false, + stoppedSessionIds: ["child-1"], + failedSessionIds: [], + activeSessionIds: [], + }); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + terminal.input?.("Root keeps running"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + const turnId = app.controller.snapshot().activeTurnId; + + terminal.input?.("\x1b"); + await vi.waitFor(() => + expect(app.controller.snapshot().cancelling).toBe(true), + ); + // Let the abort funnel settle; the root turn was never confirmed stopped. + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(app.editor.getText()).toBe(""); + expect(app.transcript.get(`user:${turnId}`)).toBeDefined(); + await app.stop(); + }); + + it("does not restore when retirement does not settle within the settlement window", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + terminal.input?.("Hanging retirement"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + const turnId = app.controller.snapshot().activeTurnId; + + // Simulate a retirement that never settles: whenIdle parks its waiter + // forever, so the bounded settle race must skip the restore instead of + // hanging the funnel. + vi.spyOn(app.controller, "whenIdle").mockReturnValue(new Promise(() => undefined)); + + terminal.input?.("\x1b"); + // Both the coordinator settle wait and the restore's settle race use the + // 1s cancellation-settlement window; dwell past both before asserting. + await new Promise((resolve) => setTimeout(resolve, 2_500)); + + expect(app.editor.getText()).toBe(""); + expect(app.tui.render(160).join("\n")).not.toContain( + "Stopped · message restored to the Composer.", + ); + // The restore never fired; the user row is untouched by it. (Its status + // may legitimately read 'cancelled' from abort-time markTurn when Esc + // lands before the user echo, which is unrelated to the restore.) + expect(app.transcript.get(`user:${turnId}`)).toBeDefined(); + + await app.stop(); + }); + + it("does not duplicate the restored prompt after relaunch", async () => { + const dataDir = await mkdtemp(join(tmpdir(), "mcode-abort-draft-")); + try { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const first = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + dataDir, + workspaceDir: "/workspace", + }); + first.start(); + await first.ready; + await first.openSession("session-1"); + terminal.input?.("Only once"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(first.controller.snapshot().status).toBe("running"), + ); + terminal.input?.("\x1b"); + await vi.waitFor(() => expect(first.editor.getText()).toBe("Only once")); + await first.stop(); + + const restored = createTuiApp({ + runtime: createRuntime(), + terminal: new FakeTerminal(), + version: "0.1.0", + dataDir, + workspaceDir: "/workspace", + }); + restored.start(); + await restored.ready; + await restored.openSession("session-1"); + await vi.waitFor(() => expect(restored.editor.getText()).toBe("Only once")); + await restored.stop(); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it("preserves restored asset and hidden submission metadata after relaunch", async () => { + const dataDir = await mkdtemp(join(tmpdir(), "mcode-abort-metadata-")); + try { + const localPath = join(dataDir, "local.png"); + await writeFile(localPath, "png"); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const first = createTuiApp({ + runtime, + terminal: new FakeTerminal(), + version: "0.1.0", + dataDir, + workspaceDir: "/workspace", + }); + first.start(); + await first.ready; + await first.openSession("session-1"); + void first.commandFlow.submit("Mixed restore", { + sessionId: "session-1", + editor: { + schemaVersion: 1, + text: "Mixed restore", + cursor: 13, + pastes: [], + pasteCounter: 0, + }, + resources: { + attachments: [{ + type: "image", + fileName: "local.png", + mimeType: "image/png", + sizeBytes: 3, + filePath: localPath, + }], + }, + transportAttachments: [ + { type: "image", fileName: "local.png", mimeType: "image/png", filePath: localPath }, + { type: "image", fileName: "asset.png", mimeType: "image/png", assetId: "asset-1" }, + ], + transportContent: "Original hidden transport", + clientIntent: "plan-entry", + }); + await vi.waitFor(() => expect(first.controller.snapshot().status).toBe("running")); + await first.abortTurn(); + await vi.waitFor(() => expect(first.editor.getText()).toContain("Mixed restore")); + await first.stop(); + + const nextRuntime = createRuntime(); + const restored = createTuiApp({ + runtime: nextRuntime, + terminal: new FakeTerminal(), + version: "0.1.0", + dataDir, + workspaceDir: "/workspace", + }); + restored.start(); + await restored.ready; + await restored.openSession("session-1"); + await vi.waitFor(() => expect(restored.editor.getText()).toContain("Mixed restore")); + expect(restored.editor.captureDraft().attachmentPlaceholders).toHaveLength(1); + restored.editor.handleInput("\r"); + await vi.waitFor(() => expect(nextRuntime.sendMessage).toHaveBeenCalledTimes(1)); + const request = vi.mocked(nextRuntime.sendMessage).mock.calls[0][0]; + expect(request.content).toBe("Original hidden transport"); + expect(request.clientIntent).toBe("plan-entry"); + expect(request.attachments?.map((attachment) => attachment.local)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ filePath: localPath }), + expect.objectContaining({ assetId: "asset-1" }), + ]), + ); + await restored.stop(); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it.each([false, true])( + "preserves review metadata after relaunch only when unchanged (edited: %s)", + async (edited) => { + const dataDir = await mkdtemp(join(tmpdir(), "mcode-abort-review-")); + try { + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const first = createTuiApp({ + runtime, + terminal: new FakeTerminal(), + version: "0.1.0", + dataDir, + workspaceDir: "/workspace", + }); + first.start(); + await first.ready; + await first.openSession("session-1"); + void first.commandFlow.submit( + "/review", + { + sessionId: "session-1", + editor: { schemaVersion: 1, text: "/review", cursor: 7, pastes: [], pasteCounter: 0 }, + resources: { attachments: [] }, + }, + { + forceMessage: true, + transportContent: "Please review my uncommitted changes.", + reviewRequest: { scope: "local_changes" }, + }, + ); + await vi.waitFor(() => expect(first.controller.snapshot().status).toBe("running")); + await first.abortTurn(); + await vi.waitFor(() => expect(first.editor.getText()).toBe("/review")); + await first.stop(); + + const nextRuntime = createRuntime(); + const restored = createTuiApp({ + runtime: nextRuntime, + terminal: new FakeTerminal(), + version: "0.1.0", + dataDir, + workspaceDir: "/workspace", + }); + restored.start(); + await restored.ready; + await restored.openSession("session-1"); + await vi.waitFor(() => expect(restored.editor.getText()).toBe("/review")); + if (edited) restored.editor.setText("Review src/foo.ts"); + restored.editor.handleInput("\r"); + await vi.waitFor(() => expect(nextRuntime.sendMessage).toHaveBeenCalledOnce()); + const request = vi.mocked(nextRuntime.sendMessage).mock.calls[0][0]; + expect(request.content).toBe( + edited ? "Review src/foo.ts" : "Please review my uncommitted changes.", + ); + expect(request.reviewRequest).toEqual( + edited ? undefined : { scope: "local_changes" }, + ); + await restored.stop(); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }, + ); + it("aborts session creation when the TUI stops during its first turn", async () => { const terminal = new FakeTerminal(); const runtime = createRuntime(); diff --git a/packages/tui/test/unit/tui/controller/run/turn-submission-retainer.test.ts b/packages/tui/test/unit/tui/controller/run/turn-submission-retainer.test.ts new file mode 100644 index 00000000..f3afceaf --- /dev/null +++ b/packages/tui/test/unit/tui/controller/run/turn-submission-retainer.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { TuiTurnSubmissionRetainer } from '../../../../../src/tui/controller/run/turn-submission-retainer.js'; +import type { TuiSubmissionSnapshot } from '../../../../../src/tui/features/composer/submission.js'; + +function snapshot(id: string): TuiSubmissionSnapshot { + return { + submissionId: id, + editor: { schemaVersion: 1, text: `text-${id}`, cursor: 6, pastes: [], pasteCounter: 0 }, + content: `text-${id}`, + attachments: [], + transportAttachments: [], + createdAtMs: 0, + }; +} + +describe('TuiTurnSubmissionRetainer', () => { + it('remembers and returns snapshots by turn id', () => { + const retainer = new TuiTurnSubmissionRetainer(); + retainer.remember('turn-1', snapshot('sub-1')); + expect(retainer.get('turn-1')?.submissionId).toBe('sub-1'); + expect(retainer.get('turn-2')).toBeUndefined(); + }); + + it('evicts the oldest entry beyond the cap and re-remembering bumps recency', () => { + const retainer = new TuiTurnSubmissionRetainer(); + for (let index = 0; index < 8; index += 1) { + retainer.remember(`turn-${index}`, snapshot(`sub-${index}`), index); + } + // Re-touch turn-3 so turn-1 becomes the oldest. + retainer.remember('turn-3', snapshot('sub-3'), 99); + retainer.remember('turn-new', snapshot('sub-new'), 100); + expect(retainer.get('turn-0')).toBeUndefined(); + expect(retainer.get('turn-1')).toBeDefined(); + expect(retainer.get('turn-3')?.submissionId).toBe('sub-3'); + expect(retainer.get('turn-new')).toBeDefined(); + }); + + it('drops individual entries and clears everything', () => { + const retainer = new TuiTurnSubmissionRetainer(); + retainer.remember('turn-1', snapshot('sub-1')); + retainer.remember('turn-2', snapshot('sub-2')); + retainer.drop('turn-1'); + expect(retainer.get('turn-1')).toBeUndefined(); + retainer.clear(); + expect(retainer.get('turn-2')).toBeUndefined(); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 63804a38..d795f506 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -2994,6 +2994,7 @@ "packages/tui/src/tui/controller/run/queue-flow.ts", "packages/tui/src/tui/controller/run/runtime-turn-settlement.ts", "packages/tui/src/tui/controller/run/turn-settlement.ts", + "packages/tui/src/tui/controller/run/turn-submission-retainer.ts", "packages/tui/src/tui/controller/runtime/event-stream-reconciliation.ts", "packages/tui/src/tui/controller/runtime/runtime-error-presentation.ts", "packages/tui/src/tui/controller/runtime/runtime-event-flow.ts", @@ -3294,6 +3295,7 @@ "packages/tui/test/unit/tui/controller/product/feature-flow.test.ts", "packages/tui/test/unit/tui/controller/product/model-state.test.ts", "packages/tui/test/unit/tui/controller/projection/visible-presentation.test.ts", + "packages/tui/test/unit/tui/controller/run/turn-submission-retainer.test.ts", "packages/tui/test/unit/tui/controller/side-session-flow.test.ts", "packages/tui/test/unit/tui/features/composer/attachments.test.ts", "packages/tui/test/unit/tui/features/settings/hotkeys-picker.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index c8a8090f..40ae8d55 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -149,6 +149,7 @@ "packages/tui/test/unit/tui/controller/product/chrome-flow.test.ts", "packages/tui/test/unit/tui/controller/product/command-flow.test.ts", "packages/tui/test/unit/tui/controller/product/feature-flow.test.ts", + "packages/tui/test/unit/tui/controller/run/turn-submission-retainer.test.ts", "packages/tui/test/unit/tui/features/settings/hotkeys-picker.test.ts", "packages/tui/test/unit/incident-reporter-privacy.test.ts", "packages/config/test/config-file-permissions.test.ts", From b7456eb92b7dfefdf54378b64d21c7363fdab4c9 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:17:49 +0800 Subject: [PATCH 08/23] docs: define pull request label usage (#326) --- .github/PULL_REQUEST_TEMPLATE.md | 2 ++ CONTRIBUTING.md | 2 ++ docs/maintainers.md | 14 ++++++++++++++ 3 files changed, 18 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c2c5eccd..d66d282d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,6 +4,8 @@ Describe the user-visible problem and resulting behavior. Link a public issue when applicable. +- PR labels: change type (`bug`, `enhancement`, `documentation` or `dependencies`) and affected product (`cli`, `tui` together with `cli`, or `desktop`) where applicable; see the [label guide](https://github.com/MiniMax-AI/minimax-code/blob/main/docs/maintainers.md#pull-request-labels). + ## Validation - Checks run and results (include the revision/profile where relevant): diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90e07f37..cddfe258 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,8 @@ Thanks for your interest in contributing. For now, we only accept code and docum Repository collaborators should submit pull requests from feature branches; do not push directly to the default branch. Describe user-visible changes, checks you ran, live-service or platform validation you did not run, and documentation impact. Preserve real author identities and existing copyright notices. +Apply the relevant change-type and product labels using the [pull request label guide](docs/maintainers.md#pull-request-labels). Add `perf:full` separately when the [performance rules](#performance-checks) require the full suite. + ## Maintainers and review See [Maintainers](docs/maintainers.md) for review ownership, independent approval, security/release routing and the public-to-internal contribution flow. The [PR template](.github/PULL_REQUEST_TEMPLATE.md) records checks, untested boundaries and permission to contribute under the existing applicable licenses. CODEOWNERS routes reviews; required checks and approvals must also be enabled in repository settings. diff --git a/docs/maintainers.md b/docs/maintainers.md index 23b70982..11098787 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -34,6 +34,20 @@ Route security reports through [Security](../SECURITY.md). Do not ask for secret This small label set draws on the type and information-request handling in [VS Code's triage guide](https://github.com/microsoft/vscode/wiki/Issues-Triaging) and the `needs-triage`/`needs-info` states used by [Ruff](https://github.com/astral-sh/ruff/labels). Contribution eligibility follows this repository's policy. +## Pull request labels + +Use the existing labels to describe a PR on three independent axes: + +| Axis | Labels and meaning | +| --- | --- | +| Change type | `bug`: fixes a malfunction; `enhancement`: adds or improves behavior, including performance; `documentation`: primarily changes documentation; `dependencies`: updates dependencies | +| Product | `cli`: changes the standalone CLI, its runtime or its build; `tui`: changes interactive-terminal behavior and is used together with `cli`; `desktop`: changes the Desktop app | +| Verification | `perf:full`: selects the full performance suite for changes covered by the [performance rules](../CONTRIBUTING.md#performance-checks) | + +Add one primary change-type label when a PR fits one of these types. Leave it unset for repository maintenance that fits none of them. Add each affected product label; leave product labels unset for repository-wide policy or tooling changes with no specific product impact. Classify the behavior and scope of the PR, not just its title or changed paths. The author proposes the labels and the reviewer checks them when the scope changes. + +Keep `perf:full` until merge when the performance rules require it, including for dependency updates that affect the covered runtime paths. It is a verification trigger, not a change type or a statement that the PR is ready to merge. Use GitHub's draft state, reviewers, approvals and checks for review progress. `needs-triage` and `needs-info` remain issue-triage labels. + ## Review and merge 1. Repository collaborators open an issue for substantial scope or submit a focused feature-branch PR. Other users may open issues to discuss ideas and proposals. Security details follow the private reporting process. From bb3972068399c67d6dab56efac6aa0597cfc271a Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:14:56 +0800 Subject: [PATCH 09/23] fix(tui): rebuild chat after closing full viewport interactions (#328) --- docs/tui-capabilities.md | 6 +- packages/tui/src/tui/app-composition.ts | 2 +- packages/tui/src/tui/app.ts | 6 +- .../tui/src/tui/shell/interaction-surface.ts | 6 +- packages/tui/test/unit/tui-app.test.ts | 94 +++++++++++++++++++ .../tui/test/unit/tui-surface-host.test.ts | 25 +++++ 6 files changed, 132 insertions(+), 7 deletions(-) diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 92baff9e..61348fe7 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -85,8 +85,10 @@ and initial prompt are not applied. In regular mode, independent feature panels occupy the complete visible terminal area, including short Rewind previews and scope pickers. Closing a panel restores -the current conversation. When running content shrinks entirely within the current -screen, the renderer keeps native scrollback and the Composer position stable. +the current conversation. Closing a full-viewport interaction rebuilds the chat +screen so its temporary rows do not leave a large blank area above the conversation. +When running content shrinks entirely within the current screen, the renderer +keeps native scrollback and the Composer position stable. Freed rows temporarily remain blank at the top of the active screen and subsequent output reuses them. This avoids resetting the host's scroll position when a turn finishes. Redundant resize notifications with unchanged dimensions do not rebuild diff --git a/packages/tui/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index 6273fc7d..2a823ca6 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -425,7 +425,7 @@ export function createTuiApplicationSurface(options: { readonly liveRunId: (snapshot?: TuiChatSnapshot) => string | undefined; readonly shouldResumeDraftAfterLogin: () => boolean; readonly isActive: () => boolean; - readonly requestInteractionRender: () => void; + readonly requestInteractionRender: (rebuild?: boolean) => void; readonly mode: () => TuiMode; readonly switchMode: (mode: TuiMode) => boolean; readonly chatMode: TuiMode; diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index 914d96e9..7a8b1601 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -820,8 +820,10 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { activity, composer, }); - function requestInteractionRender(): void { - if (started && !stopped) tui.requestImmediateRender(); + function requestInteractionRender(rebuild = false): void { + if (!started || stopped) return; + if (rebuild) tui.requestRender(true); + else tui.requestImmediateRender(); } const controllerReady = controller.initialize(); const ready = controllerReady.then(async () => { diff --git a/packages/tui/src/tui/shell/interaction-surface.ts b/packages/tui/src/tui/shell/interaction-surface.ts index 288340bb..334ce475 100644 --- a/packages/tui/src/tui/shell/interaction-surface.ts +++ b/packages/tui/src/tui/shell/interaction-surface.ts @@ -8,7 +8,7 @@ export class TuiInteractionSurface { constructor( private readonly host: TuiInlinePanelHost, private readonly surfaces: TuiSurfaceHost, - private readonly requestRender: () => void, + private readonly requestRender: (rebuild?: boolean) => void, private readonly onActiveChanged?: (active: boolean) => void, private readonly followFullscreenBottom?: () => void, ) {} @@ -38,11 +38,13 @@ export class TuiInteractionSurface { close(panel?: Component): boolean { if (!this.host.isActive(panel)) return false; + const rebuild = this.surfaces.getChatMode() === 'regular' && this.host.fullscreenViewport; if (this.layer && !this.layer.close()) return false; this.layer = undefined; if (!this.host.close(panel)) return false; this.onActiveChanged?.(false); - this.requestRender(); + if (rebuild) this.requestRender(true); + else this.requestRender(); return true; } diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index ab081afd..8b92d10c 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -189,6 +189,13 @@ class FakeTerminal implements Terminal { } } +// Apple Terminal can save ED 2 clears into scrollback before the renderer clears history. +class ClearToScrollbackTerminal extends VirtualTerminal { + override write(data: string): void { + super.write(data.replaceAll("\x1b[2J", `\x1b[${this.rows};1H${"\r\n".repeat(this.rows)}\x1b[2J`)); + } +} + function renderTerminalViewport( app: ReturnType, terminal: FakeTerminal, @@ -6041,6 +6048,93 @@ describe("createTuiApp", () => { await app.stop(); }); + it("does not push the conversation downward after closing /usage", async () => { + const terminal = new VirtualTerminal(80, 50); + const runtime = createRuntime(); + vi.mocked(runtime.getAccountStatus).mockResolvedValue({ + status: "ready", + authMode: "managed-login", + managedTokenPresent: true, + modelSource: "token-plan", + tokenPlanQuotaState: "available", + tokenPlanSummary: { tier: "Ultra Plan", creditBalance: "100" }, + tokenPlanQuota: { + fiveHour: { remainingPercent: 75, unlimited: false }, + weekly: { remainingPercent: 50, unlimited: false }, + video: { remainingCount: 5, totalCount: 5, unlimited: false }, + }, + warnings: [], + }); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + try { + await app.ready; + for (let index = 0; index < 2; index++) await app.submit(`Message ${index}`); + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).toContain("Message 1"); + const firstContentRowBefore = terminal.getViewport().findIndex((line) => line.trim().length > 0); + + await app.submit("/usage"); + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).toContain("Usage"); + + terminal.sendInput("\x1b"); + app.tui.renderNow(); + await terminal.flush(); + const viewport = terminal.getViewport().join("\n"); + expect(viewport).toContain("Message 1"); + expect(viewport).toContain("Message"); + expect(app.tui.render(80).findIndex((line) => line.trim().length > 0)).toBe(firstContentRowBefore); + expect(terminal.getViewport().findIndex((line) => line.trim().length > 0)).toBe(firstContentRowBefore); + } finally { + await app.stop(); + } + }); + + it.each([ + ['xterm', VirtualTerminal], + ['clear-to-scrollback terminal', ClearToScrollbackTerminal], + ] as const)('restores unique long history after /usage on %s', async (_name, Terminal) => { + const terminal = new Terminal(80, 24); + const app = createTuiApp({ + runtime: createRuntime(), + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + try { + await app.ready; + for (let index = 0; index < 12; index++) await app.submit(`Message ${index}`); + await app.submit("/usage"); + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).toContain("Usage"); + + terminal.sendInput("\x1b"); + app.tui.renderNow(); + await terminal.flush(); + const history = terminal.getScrollBuffer(); + for (let index = 0; index < 12; index++) { + expect(history.filter((line) => line.trimEnd().endsWith(`› Message ${index}`))).toHaveLength(1); + } + expect(history.join("\n")).not.toContain("Session usage"); + expect(terminal.getViewport().join("\n")).toContain("Ask Mcode to do anything"); + expect(terminal.getViewport().join("\n")).toContain("/workspace"); + } finally { + await app.stop(); + } + }); + it("uses public Runtime owners for Session inspection commands", async () => { const terminal = new FakeTerminal(); terminal.rows = 40; diff --git a/packages/tui/test/unit/tui-surface-host.test.ts b/packages/tui/test/unit/tui-surface-host.test.ts index da7e6925..fbfc2b54 100644 --- a/packages/tui/test/unit/tui-surface-host.test.ts +++ b/packages/tui/test/unit/tui-surface-host.test.ts @@ -70,6 +70,31 @@ function createRecordingPresenter(): { } describe('TuiSurfaceHost', () => { + it.each([ + { mode: 'regular', fullscreenViewport: true, rebuild: true }, + { mode: 'regular', fullscreenViewport: false, rebuild: false }, + { mode: 'fullscreen', fullscreenViewport: true, rebuild: false }, + ] as const)('requests rebuild on interaction close only for a regular full-viewport panel ($mode, $fullscreenViewport)', ({ mode, fullscreenViewport, rebuild }) => { + const inline = new TuiInlinePanelHost(); + const host = createSurfaceHost({ + chat: { component: inline, focus: component([]) }, + chatMode: mode, + mode: () => mode, + setFocus: vi.fn(), + requestRender: vi.fn(), + }); + const requestRender = vi.fn(); + const interaction = new TuiInteractionSurface(inline, host, requestRender); + const panel = { ...component(['inspection']), fullscreenViewport }; + + interaction.show(panel); + requestRender.mockClear(); + expect(interaction.close(panel)).toBe(true); + expect(requestRender).toHaveBeenCalledOnce(); + if (rebuild) expect(requestRender).toHaveBeenCalledWith(true); + else expect(requestRender).toHaveBeenCalledWith(); + }); + it.each(['regular', 'fullscreen'] as const)( 'passes current terminal size to leaf features in %s', (mode) => { From 7f2fe5230a30cb840e12d863e56d61032508f89f Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:15:13 +0800 Subject: [PATCH 10/23] fix(tui): clear previous run duration on new turn (#327) * fix(tui): clear previous run duration on new turn * fix(tui): dismiss stale duration when a message appears --- .../tui/src/tui/controller/chat-controller.ts | 5 +++ .../controller/projection/turn-projection.ts | 13 +++++- .../tui/test/unit/tui-chat-controller.test.ts | 41 +++++++++++++++++++ .../tui/test/unit/tui-shell-block.test.ts | 25 +++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/tui/controller/chat-controller.ts b/packages/tui/src/tui/controller/chat-controller.ts index 0d970508..4420801a 100644 --- a/packages/tui/src/tui/controller/chat-controller.ts +++ b/packages/tui/src/tui/controller/chat-controller.ts @@ -380,6 +380,8 @@ export class TuiChatController { updatedAtMs: timestamp, }); } + // The user turn is already visible while the asynchronous login preflight runs. + this.turnProjection.removePreviousTerminalDuration(turnId); this.updateState({ activeTurnId: turnId, lastSettledTurn: undefined, @@ -667,6 +669,9 @@ export class TuiChatController { attachments: readonly TranscriptAttachment[] = [], userPresentation?: TranscriptUserPresentation, ): void { + if (userPresentation !== 'pending-steer') { + this.turnProjection.removePreviousTerminalDuration(); + } this.turnProjection.projectOptimisticUserMessage( requestId, content, diff --git a/packages/tui/src/tui/controller/projection/turn-projection.ts b/packages/tui/src/tui/controller/projection/turn-projection.ts index 2b4a6096..b036d5db 100644 --- a/packages/tui/src/tui/controller/projection/turn-projection.ts +++ b/packages/tui/src/tui/controller/projection/turn-projection.ts @@ -59,7 +59,8 @@ export class TuiTurnProjection { beginTurn(turnId: string, timestamp: number): void { let changed = this.todoProjection.clearSettled(); - // Shell results are one-time local feedback; history refreshes retain ephemeral cells. + changed = this.removePreviousTerminalDuration(turnId) || changed; + // One-time feedback survives history refreshes, so dismiss it when a new run starts. for (const cell of this.transcript.snapshot()) { if ( cell.kind === 'shell' && @@ -73,6 +74,16 @@ export class TuiTurnProjection { this.liveProjection.beginTurn(turnId, timestamp); } + removePreviousTerminalDuration(turnId?: string): boolean { + let changed = false; + for (const cell of this.transcript.snapshot()) { + if (cell.kind === 'turn-duration' && (turnId === undefined || cell.turnId !== turnId)) { + changed = this.transcript.remove(cell.id) || changed; + } + } + return changed; + } + projectOptimisticUserMessage( requestId: string, content: string, diff --git a/packages/tui/test/unit/tui-chat-controller.test.ts b/packages/tui/test/unit/tui-chat-controller.test.ts index 524369b9..922c4e5f 100644 --- a/packages/tui/test/unit/tui-chat-controller.test.ts +++ b/packages/tui/test/unit/tui-chat-controller.test.ts @@ -266,7 +266,22 @@ describe('TuiChatController', () => { onUserSubmissionProjected, }); + transcript.upsert({ + id: 'turn-duration:previous-turn', + kind: 'turn-duration', + status: 'cancelled', + content: '', + durationMs: 12_000, + turnId: 'previous-turn', + ephemeral: true, + createdAtMs: 80, + updatedAtMs: 80, + }); + const view = new TranscriptView(transcript); + expect(view.render(80).join('\n')).toContain('Interrupted after 12s'); controller.projectOptimisticUserMessage('submission-1', 'Show this immediately', 90); + expect(view.render(80).join('\n')).not.toContain('Interrupted after 12s'); + expect(transcript.get('turn-duration:previous-turn')).toBeUndefined(); const sending = controller.submit('Show this immediately', { optimisticRequestId: 'submission-1', }); @@ -279,6 +294,8 @@ describe('TuiChatController', () => { }); expect(transcript.get('optimistic:user:submission-1')).toBeUndefined(); expect(transcript.snapshot().filter((cell) => cell.kind === 'user')).toHaveLength(1); + expect(view.render(80).join('\n')).not.toContain('Interrupted after 12s'); + expect(transcript.get('turn-duration:previous-turn')).toBeUndefined(); expect(onUserSubmissionProjected).toHaveBeenCalledOnce(); expect(runtime.createSession).not.toHaveBeenCalled(); expect(runtime.sendMessage).not.toHaveBeenCalled(); @@ -287,6 +304,30 @@ describe('TuiChatController', () => { await expect(sending).resolves.toBe('succeeded'); }); + it('keeps the previous duration while projecting a steer for the current turn', () => { + const transcript = new TranscriptStore(); + transcript.upsert({ + id: 'turn-duration:previous-turn', + kind: 'turn-duration', + status: 'cancelled', + content: '', + durationMs: 12_000, + turnId: 'previous-turn', + ephemeral: true, + createdAtMs: 80, + updatedAtMs: 80, + }); + const controller = new ProductionTuiChatController({ + runtime: {} as never, + transcript, + workspaceDir: '/workspace', + }); + + controller.projectOptimisticUserMessage('steer-1', 'Continue this turn', 90, [], 'pending-steer'); + + expect(transcript.get('turn-duration:previous-turn')).toBeDefined(); + }); + it('detaches the foreground observer when switching Sessions without aborting the Runtime turn', async () => { let releaseTurn: (() => void) | undefined; const runtime = { diff --git a/packages/tui/test/unit/tui-shell-block.test.ts b/packages/tui/test/unit/tui-shell-block.test.ts index 3e1d30be..4fe2e18c 100644 --- a/packages/tui/test/unit/tui-shell-block.test.ts +++ b/packages/tui/test/unit/tui-shell-block.test.ts @@ -22,6 +22,31 @@ function shellCell() { } describe('Shell transcript block', () => { + it.each(['succeeded', 'cancelled'] as const)( + 'dismisses the previous %s turn duration when the next turn starts', + (status) => { + const transcript = new TranscriptStore(); + const view = new TranscriptView(transcript); + const onChange = vi.fn(); + const projection = new TuiTurnProjection({ transcript, now: () => 10, onChange }); + projection.recordTerminalDuration('previous-turn', status, 12_000); + const label = status === 'cancelled' ? 'Interrupted after 12s' : 'Completed in 12s'; + expect(stripAnsi(view.render(80).join('\n'))).toContain(label); + + transcript.replaceDurableProjection(() => projection.hydrateHistory([])); + expect(stripAnsi(view.render(80).join('\n'))).toContain(label); + + onChange.mockClear(); + projection.beginTurn('next-turn', 20); + expect(transcript.get('turn-duration:previous-turn')).toBeUndefined(); + expect(stripAnsi(view.render(80).join('\n'))).not.toContain(label); + expect(onChange).toHaveBeenCalledOnce(); + + projection.recordTerminalDuration('next-turn', 'succeeded', 3_000); + expect(stripAnsi(view.render(80).join('\n'))).toContain('Completed in 3s'); + }, + ); + it.each([ { exitCode: 0, cancelled: false, status: 'succeeded' }, { exitCode: 7, cancelled: false, status: 'failed' }, From cf66c7fdece9d49b3f7943cbcb29f7d31fb203de Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:19:46 +0800 Subject: [PATCH 11/23] fix(tui): dismiss interrupted duration on submit frame (#329) Render the projected submission synchronously so the prior interrupted footer is erased before input returns. Queue the normal input render before dispatch so a synchronous render coalesces instead of doing a second pass. --- packages/tui/src/tui/app.ts | 2 +- .../tui/src/tui/engine/LOCAL_CHANGES.json | 8 +- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 8 ++ packages/tui/src/tui/engine/tui.ts | 5 +- packages/tui/test/unit/tui-app.test.ts | 91 +++++++++++++++++++ .../test/unit/tui-engine-local-deltas.test.ts | 46 ++++++++++ 6 files changed, 154 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index 7a8b1601..ab9207c9 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -131,7 +131,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { onTodoChange: (items) => tasks.setItems(items), onUserSubmissionProjected: () => { codexHandoffFlow?.dismiss(); - if (started && !stopped) tui.requestImmediateRender(); + if (started && !stopped) tui.renderNow(); }, onSessionLifecycle: (sessionId) => { if (!sessionId) bashFlow.clearUnboundContext(); diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index cfae2635..5c9eedcd 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -171,10 +171,10 @@ }, { "path": "tui.ts", - "currentSha256": "0dee17d791b8f02fc26b28fe32506b036eb82761572dbcd378f2cac782a01798", - "changeIds": ["L005", "L015", "L023", "L027", "L028", "L031"], - "reason": "Keep strict TypeScript fixes, expose Pi's existing immediate scheduler as a non-destructive product interaction contract, and dispatch the input left over after terminal color sequences are removed. 提供 resize hook 及焦点输入过滤 独立面板声明键盘分页归属。", - "behaviorImpact": "Urgent product interactions render immediately without resetting differential state or clearing native scrollback, and a coalesced color answer no longer discards the keystrokes sharing its chunk. 焦点先交给 viewport listener,不进入编辑器。 handlesViewportKeys 为 true 时,fullscreen 分页交给焦点面板;默认仍由外层视口处理。" + "currentSha256": "cea30262a99f7e22dc03c51d4f6dfb9a949aee183c7da1d6138c5eeff77a7ea3", + "changeIds": ["L005", "L015", "L023", "L027", "L028", "L031", "L040"], + "reason": "Keep strict TypeScript fixes, expose Pi's existing immediate scheduler as a non-destructive product interaction contract, dispatch the input left over after terminal color sequences are removed, and coalesce synchronous submission renders. 提供 resize hook 及焦点输入过滤 独立面板声明键盘分页归属。", + "behaviorImpact": "Urgent product interactions render immediately without resetting differential state or clearing native scrollback, and a coalesced color answer no longer discards the keystrokes sharing its chunk. A synchronous submission frame dismisses the previous interrupted footer without a second input render. 焦点先交给 viewport listener,不进入编辑器。 handlesViewportKeys 为 true 时,fullscreen 分页交给焦点面板;默认仍由外层视口处理。" }, { "path": "utils.ts", diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index 88f5ab2f..e9c7d159 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -154,3 +154,11 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Evidence: local-delta tests exercise xterm and a clear-to-scrollback host model, covering historical style changes, simultaneous growth, short-document shrink, subsequent differential output, host scrolling and resize preview/replay. Native Apple Terminal replay of synthetic renderer output reproduces duplicate rows before the fix and preserves the exact document afterward. - Boundary: native replay covers synthetic output, not every live-model interaction or other terminal emulator. - Removal condition: the selected Pi baseline supplies equivalent in-place viewport erasure without retaining stale rows in native history. + +## L040: Coalesce synchronous submission renders + +- Product contract: sending the next message removes the previous interrupted duration from the physical terminal before the input callback returns, without rendering the same frame twice. +- Minimal difference: queue the normal immediate input render before dispatching a focused component's key. A synchronous `renderNow()` during submission clears that queued request; ordinary keys still render on the next tick. +- User impact: the previous interrupted footer disappears with the submitted message, and the extra no-op render after Enter is avoided. +- Evidence: `tui-app.test.ts` checks every presented frame across interrupt and resend; `tui-engine-local-deltas.test.ts` checks that a synchronous input render has no second pass. +- Removal condition: the selected Pi baseline coalesces synchronous input renders while preserving immediate key rendering. diff --git a/packages/tui/src/tui/engine/tui.ts b/packages/tui/src/tui/engine/tui.ts index f3fbe574..89cd0bb3 100644 --- a/packages/tui/src/tui/engine/tui.ts +++ b/packages/tui/src/tui/engine/tui.ts @@ -904,10 +904,13 @@ export abstract class TuiBase extends Container implements TUI { if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) { return; } - this.focusedComponent.handleInput(data); // Keyboard input is latency-sensitive. Avoid the throttled timer path, // where even setTimeout(0) can take a full 16 ms tick on Windows. this.requestImmediateRender(); + // A submission may render synchronously to dismiss a previous turn's + // footer. In that case renderNow clears this pending request, avoiding + // a second full render on the next tick. + this.focusedComponent.handleInput(data); } } diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 8b92d10c..9a922ceb 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -11654,6 +11654,97 @@ describe("createTuiApp", () => { await app.stop(); }); + it("clears the interrupted duration before the next message input returns", async () => { + const terminal = new FakeTerminal(); + terminal.rows = 10; + const screen = new VirtualTerminalScreen(terminal.columns, terminal.rows); + const frames: string[] = []; + const write = terminal.write.bind(terminal); + terminal.write = (data) => { + write(data); + // Model terminals that preserve an erased screen in native scrollback. + screen.feed( + data.replaceAll( + "\x1b[2J", + `\x1b[${terminal.rows};1H${"\r\n".repeat(terminal.rows)}\x1b[2J`, + ), + ); + if (data.includes("\x1b[?2026l")) frames.push(screen.text()); + }; + const runtime = createRuntime(); + let finishResend: (() => void) | undefined; + let sendCount = 0; + vi.mocked(runtime.sendMessage).mockImplementation( + async function* (_request, signal) { + sendCount += 1; + if (sendCount <= 10) { + yield { type: "delta", content: `Preload reply ${sendCount}` }; + yield { type: "done" }; + return; + } + if (sendCount === 11) { + yield { type: "delta", content: "Partial response" }; + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + } else { + await new Promise((resolve) => { + finishResend = resolve; + }); + } + if (sendCount === 12) yield { type: "delta", content: "Agent reply" }; + yield { type: "done" }; + }, + ); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + try { + await app.ready; + for (let i = 0; i < 10; i++) { + await app.submit(`Preload ${i}`); + app.tui.renderNow(); + } + terminal.input?.("First request"); + terminal.input?.("\r"); + await vi.waitFor(() => expect(app.controller.snapshot().status).toBe("running")); + terminal.input?.("\x1b"); + await vi.waitFor(() => + expect(app.tui.render(80).join("\n")).toContain("Partial response"), + ); + await vi.waitFor(() => + expect( + app.transcript + .snapshot() + .some((cell) => cell.kind === "turn-duration" && cell.status === "cancelled"), + ).toBe(true), + ); + app.tui.renderNow(); + expect(screen.text()).toContain("Interrupted after"); + + app.tui.renderNow(); + frames.length = 0; + terminal.input?.("Second request"); + terminal.input?.("\r"); + expect(screen.text()).not.toContain("Interrupted after"); + expect(app.tui.render(80).join("\n")).not.toContain("Interrupted after"); + await vi.waitFor(() => expect(runtime.sendMessage).toHaveBeenCalledTimes(12)); + app.tui.renderNow(); + + expect(frames.length).toBeGreaterThan(0); + expect(frames.filter((frame) => frame.includes("Interrupted after"))).toEqual([]); + } finally { + finishResend?.(); + await app.stop(); + screen.dispose(); + } + }); + it("returns a thinking-only turn's prompt to the composer on abort", async () => { const terminal = new FakeTerminal(); const runtime = createRuntime(); diff --git a/packages/tui/test/unit/tui-engine-local-deltas.test.ts b/packages/tui/test/unit/tui-engine-local-deltas.test.ts index d61cf233..da018735 100644 --- a/packages/tui/test/unit/tui-engine-local-deltas.test.ts +++ b/packages/tui/test/unit/tui-engine-local-deltas.test.ts @@ -56,6 +56,52 @@ class MutableLines implements Component { } describe('MCode Pi Engine local deltas', () => { + it('does not replay a submitted input frame after a synchronous render', async () => { + const terminal = new RecordingVirtualTerminal(60, 12); + const tui = new TuiMainScreen(terminal); + let renderCount = 0; + let content = 'old footer'; + let updateAfterSyncRender = false; + const component: Component = { + render: () => { + renderCount += 1; + return [content]; + }, + invalidate: () => undefined, + handleInput: () => { + content = 'new message'; + tui.renderNow(); + if (updateAfterSyncRender) { + content = 'later status'; + tui.requestRender(); + } + }, + }; + tui.addChild(component); + tui.setFocus(component); + tui.start(); + tui.renderNow(); + await new Promise((resolve) => setImmediate(resolve)); + renderCount = 0; + + terminal.sendInput('\r'); + expect(renderCount).toBe(1); + await new Promise((resolve) => setImmediate(resolve)); + expect(renderCount).toBe(1); + await terminal.flush(); + expect(terminal.getViewport().join('\n')).toContain('new message'); + + updateAfterSyncRender = true; + renderCount = 0; + terminal.sendInput('\r'); + expect(renderCount).toBe(1); + await new Promise((resolve) => setImmediate(resolve)); + expect(renderCount).toBe(2); + await terminal.flush(); + expect(terminal.getViewport().join('\n')).toContain('later status'); + tui.stop(); + }); + describe.each([ ['xterm', RecordingVirtualTerminal], ['clear-to-scrollback host', ClearToScrollbackTerminal], From 944e8741c33fda5a0b7924937ea6d0e833f0d50f Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:29:34 +0800 Subject: [PATCH 12/23] fix(tui): switch theme appearance with arrow keys (#330) --- .../src/tui/features/settings/theme-picker.ts | 28 +++++++++++-------- .../features/settings/theme-picker.test.ts | 27 ++++++++++++++---- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/packages/tui/src/tui/features/settings/theme-picker.ts b/packages/tui/src/tui/features/settings/theme-picker.ts index 723f874b..1f24db4f 100644 --- a/packages/tui/src/tui/features/settings/theme-picker.ts +++ b/packages/tui/src/tui/features/settings/theme-picker.ts @@ -11,6 +11,9 @@ import type { export type TuiThemeAppearanceChoice = 'auto' | TuiResolvedAppearance; +const APPEARANCE_CHOICES = ['light', 'auto', 'dark'] as const satisfies + readonly TuiThemeAppearanceChoice[]; + export interface TuiThemePickerOptions { readonly themes: readonly TuiThemeDefinition[]; readonly currentThemeId: string; @@ -64,9 +67,10 @@ export class TuiThemePicker implements Component { this.selectedIndex = (this.selectedIndex - 1 + themes.length) % themes.length; } else if (keys.matches(data, 'tui.select.down')) { this.selectedIndex = (this.selectedIndex + 1) % themes.length; - } else if (matchesKey(data, 'a')) { - this.appearance = cycleAppearance(this.appearance); - this.options.setAppearance(this.appearance); + } else if (matchesKey(data, 'left')) { + this.shiftAppearance(-1); + } else if (matchesKey(data, 'right')) { + this.shiftAppearance(1); } else { return; } @@ -162,6 +166,14 @@ export class TuiThemePicker implements Component { return this.appearance === 'auto' ? this.options.currentAppearance : this.appearance; } + private shiftAppearance(step: -1 | 1): void { + const index = APPEARANCE_CHOICES.indexOf(this.appearance); + const next = APPEARANCE_CHOICES[Math.max(0, Math.min(index + step, APPEARANCE_CHOICES.length - 1))]!; + if (next === this.appearance) return; + this.appearance = next; + this.options.setAppearance(next); + } + private restore(): void { const theme = this.options.themes.find((candidate) => candidate.id === this.originalThemeId); if (theme) this.options.preview(this.originalThemeId); @@ -193,12 +205,6 @@ export class TuiThemePicker implements Component { } } -function cycleAppearance(current: TuiThemeAppearanceChoice): TuiThemeAppearanceChoice { - if (current === 'auto') return 'light'; - if (current === 'light') return 'dark'; - return 'auto'; -} - function appearanceLabel( choice: TuiThemeAppearanceChoice, detected: TuiResolvedAppearance, @@ -243,6 +249,6 @@ const CURRENT_BADGE = ' current '; function footerText(width: number, busy: boolean): string { if (busy) return 'Saving…'; return width >= 64 - ? '↑↓ preview · a appearance · Enter save · Esc cancel' - : '↑↓ · a · Enter · Esc'; + ? '↑↓ theme · ←→ appearance · Enter save · Esc cancel' + : '↑↓ ←→ · Enter · Esc'; } diff --git a/packages/tui/test/unit/tui/features/settings/theme-picker.test.ts b/packages/tui/test/unit/tui/features/settings/theme-picker.test.ts index e7a2f07f..072e540d 100644 --- a/packages/tui/test/unit/tui/features/settings/theme-picker.test.ts +++ b/packages/tui/test/unit/tui/features/settings/theme-picker.test.ts @@ -74,15 +74,30 @@ describe('TuiThemePicker', () => { expect(preview).toHaveBeenLastCalledWith('minimax'); }); - it('cycles the appearance with the a key', () => { + it('selects light, auto, and dark with the left and right arrows', () => { const { picker, setAppearance } = build(); - picker.handleInput('a'); + picker.handleInput('\u001b[D'); expect(setAppearance).toHaveBeenLastCalledWith('light'); - picker.handleInput('a'); + picker.handleInput('\u001b[D'); + expect(setAppearance).toHaveBeenCalledTimes(1); + + picker.handleInput('\u001b[C'); + expect(setAppearance).toHaveBeenLastCalledWith('auto'); + picker.handleInput('\u001b[C'); expect(setAppearance).toHaveBeenLastCalledWith('dark'); + picker.handleInput('\u001b[C'); + expect(setAppearance).toHaveBeenCalledTimes(3); + }); + + it('ignores a as an appearance shortcut', () => { + const { picker, preview, setAppearance, requestRender } = build(); + picker.handleInput('a'); - expect(setAppearance).toHaveBeenLastCalledWith('auto'); + + expect(setAppearance).not.toHaveBeenCalled(); + expect(preview).not.toHaveBeenCalled(); + expect(requestRender).not.toHaveBeenCalled(); }); it('saves the focused theme and closes', async () => { @@ -99,7 +114,7 @@ describe('TuiThemePicker', () => { const { picker, save, onClose } = build(); picker.handleInput('\u001b[B'); - picker.handleInput('a'); + picker.handleInput('\u001b[D'); picker.handleInput('\r'); await vi.waitFor(() => expect(onClose).toHaveBeenCalled()); @@ -119,7 +134,7 @@ describe('TuiThemePicker', () => { const { picker, preview, setAppearance, save, onClose } = build(); picker.handleInput('\u001b[B'); - picker.handleInput('a'); + picker.handleInput('\u001b[D'); preview.mockClear(); picker.handleInput('\u001b'); From b2f146a1b63415b286240011b7ec789edb5fc6e5 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:48:57 +0800 Subject: [PATCH 13/23] fix(tui): keep interrupted footer hidden during edit resubmission (#332) --- .../tui/src/tui/controller/chat-controller.ts | 9 + .../product/session-mutation-flow.ts | 13 ++ .../tui/transcript/presentation/visibility.ts | 18 ++ packages/tui/test/unit/tui-app.test.ts | 203 ++++++++++++++++++ 4 files changed, 243 insertions(+) diff --git a/packages/tui/src/tui/controller/chat-controller.ts b/packages/tui/src/tui/controller/chat-controller.ts index 4420801a..abd1b851 100644 --- a/packages/tui/src/tui/controller/chat-controller.ts +++ b/packages/tui/src/tui/controller/chat-controller.ts @@ -646,6 +646,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); 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 1c788cb0..10f95a6e 100644 --- a/packages/tui/src/tui/controller/product/session-mutation-flow.ts +++ b/packages/tui/src/tui/controller/product/session-mutation-flow.ts @@ -427,6 +427,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(); @@ -446,6 +447,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, @@ -481,6 +489,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 { 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/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 9a922ceb..6e298b00 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -11654,6 +11654,209 @@ describe("createTuiApp", () => { 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; From ffbfff6c097a45e6c89c090329ec99026aebc8da Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:49:02 +0800 Subject: [PATCH 14/23] fix(tui): restore chat viewport after autocomplete shrinks (#331) --- docs/tui-capabilities.md | 3 + packages/tui/src/tui/app-composition.ts | 3 + .../tui/src/tui/engine/LOCAL_CHANGES.json | 12 +-- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 8 ++ .../tui/src/tui/engine/components/editor.ts | 23 ++++++ .../tui/src/tui/engine/tui-main-screen.ts | 11 ++- packages/tui/src/tui/engine/tui.ts | 6 ++ packages/tui/src/tui/widgets/editor/editor.ts | 8 ++ packages/tui/test/unit/tui-app.test.ts | 79 +++++++++++++++++++ 9 files changed, 146 insertions(+), 7 deletions(-) diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 61348fe7..dfd23df2 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -87,6 +87,9 @@ 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. Closing a full-viewport interaction rebuilds the chat screen so its temporary rows do not leave a large blank area above the conversation. +Closing or shrinking a Composer completion menu also restores the exposed chat +rows. Short documents refresh in place; history is reconstructed only when the +smaller layout needs to bring scrolled rows back into view. When running content shrinks entirely within the current screen, the renderer keeps native scrollback and the Composer position stable. Freed rows temporarily remain blank at the top of the active screen and subsequent diff --git a/packages/tui/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index 2a823ca6..4db84b77 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -208,6 +208,9 @@ export function createTuiApplicationEditor( autocompleteMaxVisible: 8, placeholder: composerText('placeholder'), }); + editor.onAutocompleteResize = (previousRows, rows) => { + if (tui.mode === 'regular' && rows < previousRows) tui.requestLayoutRender(); + }; editor.setAutocompleteProvider(createTuiInitialAutocomplete(workspaceRoots.list(), runtime)); return editor; } diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index 5c9eedcd..f29800f9 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -21,8 +21,8 @@ }, { "path": "components/editor.ts", - "currentSha256": "0775a490df136d1083738cce649e026d6153ecb5480715b2c1104d51051968c5", - "changeIds": ["L005", "L009", "L022", "L030", "L032"], + "currentSha256": "0c257eb6225d4847a78152b14bd12fe299223541b193a5f4bc0c5667680c7b52", + "changeIds": ["L005", "L009", "L022", "L030", "L032", "L041"], "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 定位标记。 命令参数候选标记阶段,接受命令后续查参数,并隔离参数回调失败。", "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,16 +163,16 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "5d499306f4b92d443c350dd944fbdc11b6ed7c74a030eb1e7e020890bc30fdb7", - "changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038", "L039"], + "currentSha256": "f0127d16377ed9f159df5e2e78aeae11f9b05bbb857b0ea31ff944dea6869226", + "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 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": "cea30262a99f7e22dc03c51d4f6dfb9a949aee183c7da1d6138c5eeff77a7ea3", - "changeIds": ["L005", "L015", "L023", "L027", "L028", "L031", "L040"], + "currentSha256": "27c80c6c58f0eb35a4f3e199123aca47c95296abd4dd46b922df770effa9d42a", + "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 分页交给焦点面板;默认仍由外层视口处理。" }, diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index e9c7d159..31e2bbb8 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -162,3 +162,11 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - 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 inline completion shrink + +- Product contract: closing or filtering an inline completion menu restores the conversation instead of leaving the released rows blank above it. Ordinary activity shrink retains L038's native scrolling behavior. +- Minimal difference: Editor reports completion row-count changes relative to its last rendered menu, including asynchronous results and grouped-list navigation. The product requests a layout render on shrink in regular mode. Main-screen layout renders skip L038 padding for one frame, allowing L034 reconstruction only when scrolled rows must return; short documents retain differential rendering. Disposal disables the callback before cancellation. +- Evidence: product VirtualTerminal tests exercise Escape, Tab, Backspace, asynchronous empty results and filtering, compare the complete visible frame, and check unique long history under xterm and an ED 2 clear-to-scrollback model. Short-document and fullscreen cases avoid unnecessary reconstruction; existing activity-shrink regressions remain intact. +- 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 inline completion layout shrink from ordinary visible activity shrink. diff --git a/packages/tui/src/tui/engine/components/editor.ts b/packages/tui/src/tui/engine/components/editor.ts index 999096c7..38bae221 100644 --- a/packages/tui/src/tui/engine/components/editor.ts +++ b/packages/tui/src/tui/engine/components/editor.ts @@ -331,6 +331,7 @@ export class Editor implements Component, Focusable { private autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters); private autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters); private autocompleteList?: SelectList; + private renderedAutocompleteRows = 0; private autocompleteSuggestions?: AutocompleteSuggestions; private autocompleteState: 'regular' | 'force' | null = null; private autocompletePrefix: string = ''; @@ -381,6 +382,8 @@ export class Editor implements Component, Focusable { public onChange?: (text: string) => void; public onPaste?: (text: string) => boolean; public onAutocompleteView?: (suggestions: AutocompleteSuggestions) => void; + /** Lets hosts restore space occupied by a shrinking inline completion menu. */ + public onAutocompleteResize?: (previousRows: number, rows: number) => void; public onAutocompleteSelect?: ( suggestions: AutocompleteSuggestions, item: AutocompleteSuggestions['items'][number], @@ -695,8 +698,10 @@ export class Editor implements Component, Focusable { } // Add autocomplete list if active + this.renderedAutocompleteRows = 0; if (this.autocompleteState && this.autocompleteList) { const autocompleteResult = this.autocompleteList.render(contentWidth); + this.renderedAutocompleteRows = autocompleteResult.length; for (const line of autocompleteResult) { const lineWidth = visibleWidth(line); const linePadding = ' '.repeat(Math.max(0, contentWidth - lineWidth)); @@ -781,6 +786,7 @@ export class Editor implements Component, Focusable { if (kb.matches(data, 'tui.select.up') || kb.matches(data, 'tui.select.down')) { this.autocompleteList.handleInput(data); + this.notifyAutocompleteResize(); return; } @@ -1214,6 +1220,7 @@ export class Editor implements Component, Focusable { } dispose(): void { + this.onAutocompleteResize = undefined; this.cancelAutocomplete(); this.autocompleteProvider = undefined; this.onPaste = undefined; @@ -2632,6 +2639,7 @@ export class Editor implements Component, Focusable { } this.autocompleteState = state; + this.notifyAutocompleteResize(); const kind = suggestions.prefix.startsWith('/') ? '/' : suggestions.prefix.startsWith('@') @@ -2659,6 +2667,21 @@ export class Editor implements Component, Focusable { this.autocompleteSuggestions = undefined; this.autocompletePrefix = ''; this.reportedAutocompleteKind = undefined; + this.notifyAutocompleteResize(); + } + + private autocompleteRows(): number { + if (!this.onAutocompleteResize || !this.autocompleteState || !this.autocompleteList) return 0; + // SelectList owns group headings and pagination rows; reuse its layout rather + // than assuming one row per candidate. Its rows do not wrap with terminal width. + return this.autocompleteList.render(this.tui.terminal.columns).length; + } + + private notifyAutocompleteResize(): void { + const rows = this.autocompleteRows(); + if (rows !== this.renderedAutocompleteRows) { + this.onAutocompleteResize?.(this.renderedAutocompleteRows, rows); + } } private cancelAutocomplete(): void { diff --git a/packages/tui/src/tui/engine/tui-main-screen.ts b/packages/tui/src/tui/engine/tui-main-screen.ts index 44901c93..bfe68eb7 100644 --- a/packages/tui/src/tui/engine/tui-main-screen.ts +++ b/packages/tui/src/tui/engine/tui-main-screen.ts @@ -129,6 +129,12 @@ export class TuiMainScreen extends TuiBase implements TUI { private previousViewportTop = 0; private resizeTimer: ReturnType | undefined; private historyReplayPending = false; + private layoutRenderPending = false; + + override requestLayoutRender(): void { + this.layoutRenderPending = true; + super.requestLayoutRender(); + } protected override onTerminalResize(): void { // Some hosts repeat resize notifications while scrolling or reconnecting. @@ -184,6 +190,7 @@ export class TuiMainScreen extends TuiBase implements TUI { } protected override resetRenderState(): void { + this.layoutRenderPending = false; this.cancelResize(); this.historyReplayPending = false; this.previousLines = []; @@ -290,6 +297,8 @@ export class TuiMainScreen extends TuiBase implements TUI { return targetScreenRow - currentScreenRow; }; + const layoutRender = this.layoutRenderPending; + this.layoutRenderPending = false; // 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, "")); @@ -304,7 +313,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 && + !layoutRender && !widthChanged && !heightChanged && !this.historyReplayPending && !this.hasOverlayEntries && prevViewportTop > 0 && newLines.length > prevViewportTop && newLines.length < prevViewportTop + height && this.previousKittyImageIds.size === 0 && !newLines.some(isImageLine) diff --git a/packages/tui/src/tui/engine/tui.ts b/packages/tui/src/tui/engine/tui.ts index 89cd0bb3..7e3749a1 100644 --- a/packages/tui/src/tui/engine/tui.ts +++ b/packages/tui/src/tui/engine/tui.ts @@ -312,6 +312,8 @@ export interface TUI extends Component { renderNow(force?: boolean): void; requestRender(force?: boolean): void; requestImmediateRender(): void; + /** Restore rows exposed by a shrinking interactive layout instead of padding them. */ + requestLayoutRender(): void; addInputListener(listener: TuiInputListener): () => void; removeInputListener(listener: TuiInputListener): void; onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void; @@ -785,6 +787,10 @@ export abstract class TuiBase extends Container implements TUI { process.nextTick(() => this.scheduleRender()); } + requestLayoutRender(): void { + this.requestImmediateRender(); + } + requestImmediateRender(): void { this.cancelRenderTimer(); this.renderRequested = true; diff --git a/packages/tui/src/tui/widgets/editor/editor.ts b/packages/tui/src/tui/widgets/editor/editor.ts index f81e3eff..8cc6ac7a 100644 --- a/packages/tui/src/tui/widgets/editor/editor.ts +++ b/packages/tui/src/tui/widgets/editor/editor.ts @@ -80,6 +80,14 @@ export class Editor implements Component, Focusable { return this.engine.focused; } + get onAutocompleteResize(): PiEditor['onAutocompleteResize'] { + return this.engine.onAutocompleteResize; + } + + set onAutocompleteResize(callback: PiEditor['onAutocompleteResize']) { + this.engine.onAutocompleteResize = callback; + } + set focused(value: boolean) { this.engine.focused = value; } diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 6e298b00..670d8cdb 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -2968,6 +2968,7 @@ describe("createTuiApp", () => { app.start(); await app.ready; + const requestLayoutRender = vi.spyOn(app.tui, "requestLayoutRender"); terminal.input?.("/"); await vi.waitFor(() => @@ -2983,6 +2984,7 @@ describe("createTuiApp", () => { terminal.input?.("\u001B"); expect(app.editor.getText()).toBe("/"); expect(app.editor.focused).toBe(true); + expect(requestLayoutRender).not.toHaveBeenCalled(); await app.stop(); }); @@ -3026,6 +3028,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; From 76262afff95962f68d793a5e85a4b23b7a5ff8fa Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:11:48 +0800 Subject: [PATCH 15/23] fix(tui): restore chat after transient layout shrink (#333) --- docs/tui-capabilities.md | 17 +- packages/tui/src/tui/app-composition.ts | 5 +- packages/tui/src/tui/app.ts | 5 +- .../tui/src/tui/engine/LOCAL_CHANGES.json | 8 +- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 12 +- .../tui/src/tui/engine/components/editor.ts | 23 -- .../tui/src/tui/engine/tui-main-screen.ts | 35 ++- packages/tui/src/tui/engine/tui.ts | 13 +- packages/tui/src/tui/shell/chat-layout.ts | 14 ++ .../tui/src/tui/shell/interaction-surface.ts | 6 +- packages/tui/src/tui/shell/surface-host.ts | 5 + packages/tui/src/tui/widgets/editor/editor.ts | 8 - packages/tui/test/unit/tui-app.test.ts | 64 +++++- .../unit/tui-background-work-panel.test.ts | 7 +- .../test/unit/tui-engine-local-deltas.test.ts | 217 ++++++++++++++++++ .../tui/test/unit/tui-surface-host.test.ts | 11 +- 16 files changed, 365 insertions(+), 85 deletions(-) diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index dfd23df2..a67fb9f2 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -85,13 +85,16 @@ 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. Closing a full-viewport interaction rebuilds the chat -screen so its temporary rows do not leave a large blank area above the conversation. -Closing or shrinking a Composer completion menu also restores the exposed chat -rows. Short documents refresh in place; history is reconstructed only when the -smaller layout needs to bring scrolled rows back into view. -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 diff --git a/packages/tui/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index 4db84b77..6273fc7d 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -208,9 +208,6 @@ export function createTuiApplicationEditor( autocompleteMaxVisible: 8, placeholder: composerText('placeholder'), }); - editor.onAutocompleteResize = (previousRows, rows) => { - if (tui.mode === 'regular' && rows < previousRows) tui.requestLayoutRender(); - }; editor.setAutocompleteProvider(createTuiInitialAutocomplete(workspaceRoots.list(), runtime)); return editor; } @@ -428,7 +425,7 @@ export function createTuiApplicationSurface(options: { readonly liveRunId: (snapshot?: TuiChatSnapshot) => string | undefined; readonly shouldResumeDraftAfterLogin: () => boolean; readonly isActive: () => boolean; - readonly requestInteractionRender: (rebuild?: boolean) => void; + readonly requestInteractionRender: () => void; readonly mode: () => TuiMode; readonly switchMode: (mode: TuiMode) => boolean; readonly chatMode: TuiMode; diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index ab9207c9..6d556331 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -820,10 +820,9 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { activity, composer, }); - function requestInteractionRender(rebuild = false): void { + function requestInteractionRender(): void { if (!started || stopped) return; - if (rebuild) tui.requestRender(true); - else tui.requestImmediateRender(); + tui.requestImmediateRender(); } const controllerReady = controller.initialize(); const ready = controllerReady.then(async () => { diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index f29800f9..f39b20b7 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -21,8 +21,8 @@ }, { "path": "components/editor.ts", - "currentSha256": "0c257eb6225d4847a78152b14bd12fe299223541b193a5f4bc0c5667680c7b52", - "changeIds": ["L005", "L009", "L022", "L030", "L032", "L041"], + "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 定位标记。 命令参数候选标记阶段,接受命令后续查参数,并隔离参数回调失败。", "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,7 +163,7 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "f0127d16377ed9f159df5e2e78aeae11f9b05bbb857b0ea31ff944dea6869226", + "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 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。", @@ -171,7 +171,7 @@ }, { "path": "tui.ts", - "currentSha256": "27c80c6c58f0eb35a4f3e199123aca47c95296abd4dd46b922df770effa9d42a", + "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 分页交给焦点面板;默认仍由外层视口处理。" diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index 31e2bbb8..50b72941 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -142,7 +142,7 @@ 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. @@ -163,10 +163,10 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - 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 inline completion shrink +## L041: Restore chat rows after transient layout shrink -- Product contract: closing or filtering an inline completion menu restores the conversation instead of leaving the released rows blank above it. Ordinary activity shrink retains L038's native scrolling behavior. -- Minimal difference: Editor reports completion row-count changes relative to its last rendered menu, including asynchronous results and grouped-list navigation. The product requests a layout render on shrink in regular mode. Main-screen layout renders skip L038 padding for one frame, allowing L034 reconstruction only when scrolled rows must return; short documents retain differential rendering. Disposal disables the callback before cancellation. -- Evidence: product VirtualTerminal tests exercise Escape, Tab, Backspace, asynchronous empty results and filtering, compare the complete visible frame, and check unique long history under xterm and an ED 2 clear-to-scrollback model. Short-document and fullscreen cases avoid unnecessary reconstruction; existing activity-shrink regressions remain intact. +- 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 inline completion layout shrink from ordinary visible activity shrink. +- Removal condition: the selected Pi baseline distinguishes transient UI layout shrink from ordinary background content shrink. diff --git a/packages/tui/src/tui/engine/components/editor.ts b/packages/tui/src/tui/engine/components/editor.ts index 38bae221..999096c7 100644 --- a/packages/tui/src/tui/engine/components/editor.ts +++ b/packages/tui/src/tui/engine/components/editor.ts @@ -331,7 +331,6 @@ export class Editor implements Component, Focusable { private autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters); private autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters); private autocompleteList?: SelectList; - private renderedAutocompleteRows = 0; private autocompleteSuggestions?: AutocompleteSuggestions; private autocompleteState: 'regular' | 'force' | null = null; private autocompletePrefix: string = ''; @@ -382,8 +381,6 @@ export class Editor implements Component, Focusable { public onChange?: (text: string) => void; public onPaste?: (text: string) => boolean; public onAutocompleteView?: (suggestions: AutocompleteSuggestions) => void; - /** Lets hosts restore space occupied by a shrinking inline completion menu. */ - public onAutocompleteResize?: (previousRows: number, rows: number) => void; public onAutocompleteSelect?: ( suggestions: AutocompleteSuggestions, item: AutocompleteSuggestions['items'][number], @@ -698,10 +695,8 @@ export class Editor implements Component, Focusable { } // Add autocomplete list if active - this.renderedAutocompleteRows = 0; if (this.autocompleteState && this.autocompleteList) { const autocompleteResult = this.autocompleteList.render(contentWidth); - this.renderedAutocompleteRows = autocompleteResult.length; for (const line of autocompleteResult) { const lineWidth = visibleWidth(line); const linePadding = ' '.repeat(Math.max(0, contentWidth - lineWidth)); @@ -786,7 +781,6 @@ export class Editor implements Component, Focusable { if (kb.matches(data, 'tui.select.up') || kb.matches(data, 'tui.select.down')) { this.autocompleteList.handleInput(data); - this.notifyAutocompleteResize(); return; } @@ -1220,7 +1214,6 @@ export class Editor implements Component, Focusable { } dispose(): void { - this.onAutocompleteResize = undefined; this.cancelAutocomplete(); this.autocompleteProvider = undefined; this.onPaste = undefined; @@ -2639,7 +2632,6 @@ export class Editor implements Component, Focusable { } this.autocompleteState = state; - this.notifyAutocompleteResize(); const kind = suggestions.prefix.startsWith('/') ? '/' : suggestions.prefix.startsWith('@') @@ -2667,21 +2659,6 @@ export class Editor implements Component, Focusable { this.autocompleteSuggestions = undefined; this.autocompletePrefix = ''; this.reportedAutocompleteKind = undefined; - this.notifyAutocompleteResize(); - } - - private autocompleteRows(): number { - if (!this.onAutocompleteResize || !this.autocompleteState || !this.autocompleteList) return 0; - // SelectList owns group headings and pagination rows; reuse its layout rather - // than assuming one row per candidate. Its rows do not wrap with terminal width. - return this.autocompleteList.render(this.tui.terminal.columns).length; - } - - private notifyAutocompleteResize(): void { - const rows = this.autocompleteRows(); - if (rows !== this.renderedAutocompleteRows) { - this.onAutocompleteResize?.(this.renderedAutocompleteRows, rows); - } } private cancelAutocomplete(): void { diff --git a/packages/tui/src/tui/engine/tui-main-screen.ts b/packages/tui/src/tui/engine/tui-main-screen.ts index bfe68eb7..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,12 +131,8 @@ export class TuiMainScreen extends TuiBase implements TUI { private previousViewportTop = 0; private resizeTimer: ReturnType | undefined; private historyReplayPending = false; - private layoutRenderPending = false; - - override requestLayoutRender(): void { - this.layoutRenderPending = true; - super.requestLayoutRender(); - } + private viewportLayouts: TuiMainScreenRenderState['viewportLayouts'] = []; + private hadOverlays = false; protected override onTerminalResize(): void { // Some hosts repeat resize notifications while scrolling or reconnecting. @@ -173,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, }; } @@ -187,10 +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.layoutRenderPending = false; + this.viewportLayouts = []; + this.hadOverlays = false; this.cancelResize(); this.historyReplayPending = false; this.previousLines = []; @@ -297,11 +300,21 @@ export class TuiMainScreen extends TuiBase implements TUI { return targetScreenRow - currentScreenRow; }; - const layoutRender = this.layoutRenderPending; - this.layoutRenderPending = false; // 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) { @@ -313,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 ( - !layoutRender && !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) diff --git a/packages/tui/src/tui/engine/tui.ts b/packages/tui/src/tui/engine/tui.ts index 7e3749a1..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 */ @@ -312,8 +319,6 @@ export interface TUI extends Component { renderNow(force?: boolean): void; requestRender(force?: boolean): void; requestImmediateRender(): void; - /** Restore rows exposed by a shrinking interactive layout instead of padding them. */ - requestLayoutRender(): void; addInputListener(listener: TuiInputListener): () => void; removeInputListener(listener: TuiInputListener): void; onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void; @@ -787,10 +792,6 @@ export abstract class TuiBase extends Container implements TUI { process.nextTick(() => this.scheduleRender()); } - requestLayoutRender(): void { - this.requestImmediateRender(); - } - requestImmediateRender(): void { this.cancelRenderTimer(); this.renderRequested = true; diff --git a/packages/tui/src/tui/shell/chat-layout.ts b/packages/tui/src/tui/shell/chat-layout.ts index 79a74870..a8c4df22 100644 --- a/packages/tui/src/tui/shell/chat-layout.ts +++ b/packages/tui/src/tui/shell/chat-layout.ts @@ -41,6 +41,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: @@ -233,8 +238,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] @@ -259,6 +271,7 @@ export class TuiChatLayout implements Component { frame.horizontalPadding, ) : renderPart(this.parts.welcome); + this.viewportLayoutKey = JSON.stringify([...viewportLayout, welcome.length]); return this.fitDocumentFrame([welcome, ...tailEntries]); } @@ -278,6 +291,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/interaction-surface.ts b/packages/tui/src/tui/shell/interaction-surface.ts index 334ce475..288340bb 100644 --- a/packages/tui/src/tui/shell/interaction-surface.ts +++ b/packages/tui/src/tui/shell/interaction-surface.ts @@ -8,7 +8,7 @@ export class TuiInteractionSurface { constructor( private readonly host: TuiInlinePanelHost, private readonly surfaces: TuiSurfaceHost, - private readonly requestRender: (rebuild?: boolean) => void, + private readonly requestRender: () => void, private readonly onActiveChanged?: (active: boolean) => void, private readonly followFullscreenBottom?: () => void, ) {} @@ -38,13 +38,11 @@ export class TuiInteractionSurface { close(panel?: Component): boolean { if (!this.host.isActive(panel)) return false; - const rebuild = this.surfaces.getChatMode() === 'regular' && this.host.fullscreenViewport; if (this.layer && !this.layer.close()) return false; this.layer = undefined; if (!this.host.close(panel)) return false; this.onActiveChanged?.(false); - if (rebuild) this.requestRender(true); - else this.requestRender(); + this.requestRender(); return true; } 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/widgets/editor/editor.ts b/packages/tui/src/tui/widgets/editor/editor.ts index 8cc6ac7a..f81e3eff 100644 --- a/packages/tui/src/tui/widgets/editor/editor.ts +++ b/packages/tui/src/tui/widgets/editor/editor.ts @@ -80,14 +80,6 @@ export class Editor implements Component, Focusable { return this.engine.focused; } - get onAutocompleteResize(): PiEditor['onAutocompleteResize'] { - return this.engine.onAutocompleteResize; - } - - set onAutocompleteResize(callback: PiEditor['onAutocompleteResize']) { - this.engine.onAutocompleteResize = callback; - } - set focused(value: boolean) { this.engine.focused = value; } diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 670d8cdb..fac9a117 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -2968,7 +2968,7 @@ describe("createTuiApp", () => { app.start(); await app.ready; - const requestLayoutRender = vi.spyOn(app.tui, "requestLayoutRender"); + const writesBefore = terminal.writes.length; terminal.input?.("/"); await vi.waitFor(() => @@ -2984,7 +2984,7 @@ describe("createTuiApp", () => { terminal.input?.("\u001B"); expect(app.editor.getText()).toBe("/"); expect(app.editor.focused).toBe(true); - expect(requestLayoutRender).not.toHaveBeenCalled(); + expect(terminal.writes.slice(writesBefore).join("")).not.toContain("\x1b[3J"); await app.stop(); }); @@ -6127,6 +6127,66 @@ 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(); 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-engine-local-deltas.test.ts b/packages/tui/test/unit/tui-engine-local-deltas.test.ts index da018735..8f5e95ce 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; @@ -47,6 +48,11 @@ class ClearToScrollbackTerminal extends RecordingVirtualTerminal { class MutableLines implements Component { lines: string[] = []; + viewportLayoutKey: string | undefined; + + getViewportLayoutKey(): string | undefined { + return this.viewportLayoutKey; + } render(): string[] { return [...this.lines]; @@ -55,6 +61,25 @@ 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('MCode Pi Engine local deltas', () => { it('does not replay a submitted input frame after a synchronous render', async () => { const terminal = new RecordingVirtualTerminal(60, 12); @@ -168,6 +193,7 @@ describe('MCode 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, @@ -203,6 +229,194 @@ describe('MCode 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); @@ -232,6 +446,7 @@ describe('MCode 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); @@ -296,6 +511,7 @@ describe('MCode 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); @@ -338,6 +554,7 @@ describe('MCode 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, diff --git a/packages/tui/test/unit/tui-surface-host.test.ts b/packages/tui/test/unit/tui-surface-host.test.ts index fbfc2b54..4ed7414b 100644 --- a/packages/tui/test/unit/tui-surface-host.test.ts +++ b/packages/tui/test/unit/tui-surface-host.test.ts @@ -71,10 +71,10 @@ function createRecordingPresenter(): { describe('TuiSurfaceHost', () => { it.each([ - { mode: 'regular', fullscreenViewport: true, rebuild: true }, - { mode: 'regular', fullscreenViewport: false, rebuild: false }, - { mode: 'fullscreen', fullscreenViewport: true, rebuild: false }, - ] as const)('requests rebuild on interaction close only for a regular full-viewport panel ($mode, $fullscreenViewport)', ({ mode, fullscreenViewport, rebuild }) => { + { 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([]) }, @@ -91,8 +91,7 @@ describe('TuiSurfaceHost', () => { requestRender.mockClear(); expect(interaction.close(panel)).toBe(true); expect(requestRender).toHaveBeenCalledOnce(); - if (rebuild) expect(requestRender).toHaveBeenCalledWith(true); - else expect(requestRender).toHaveBeenCalledWith(); + expect(requestRender).toHaveBeenCalledWith(); }); it.each(['regular', 'fullscreen'] as const)( From 47d14243da298e5cbe2cf0f4423a947faa3109f5 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:54:21 +0800 Subject: [PATCH 16/23] fix(skills): discover linked workspace skill directories (#337) --- docs/tui-capabilities.md | 9 + packages/agent-modules/skills/src/registry.ts | 21 +- packages/agent-modules/skills/src/types.ts | 2 + packages/local-runtime/src/skills/roots.ts | 1 + .../unit/skill-directory-symlinks.test.ts | 233 ++++++++++++++++++ release/public-source.json | 1 + test/vitest-suites.json | 1 + 7 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 packages/local-runtime/test/unit/skill-directory-symlinks.test.ts diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index a67fb9f2..622039f2 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -21,6 +21,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 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/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/release/public-source.json b/release/public-source.json index d795f506..a07638a9 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -2656,6 +2656,7 @@ "packages/local-runtime/test/unit/local-thread-goal-wiring.test.ts", "packages/local-runtime/test/unit/metrics-telemetry-privacy.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", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 40ae8d55..9ddb86ca 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -5,6 +5,7 @@ "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", From 782f64dade70fe75c18e28cbf5233e860a51d1d8 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:55:59 +0800 Subject: [PATCH 17/23] fix(tui): reset rewound todos and preserve local commands during edits (#336) --- .../tui/src/tui/controller/chat-controller.ts | 1 + .../tui/controller/product/command-flow.ts | 8 +- packages/tui/test/unit/tui-app.test.ts | 87 +++++++++++++++++++ .../tui/test/unit/tui-chat-controller.test.ts | 43 +++++++++ 4 files changed, 138 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/tui/controller/chat-controller.ts b/packages/tui/src/tui/controller/chat-controller.ts index abd1b851..e058b1ff 100644 --- a/packages/tui/src/tui/controller/chat-controller.ts +++ b/packages/tui/src/tui/controller/chat-controller.ts @@ -254,6 +254,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; diff --git a/packages/tui/src/tui/controller/product/command-flow.ts b/packages/tui/src/tui/controller/product/command-flow.ts index 43b93fdc..601e2997 100644 --- a/packages/tui/src/tui/controller/product/command-flow.ts +++ b/packages/tui/src/tui/controller/product/command-flow.ts @@ -281,7 +281,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, diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index fac9a117..9461c9d6 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -3557,6 +3557,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({ diff --git a/packages/tui/test/unit/tui-chat-controller.test.ts b/packages/tui/test/unit/tui-chat-controller.test.ts index 922c4e5f..a0102b16 100644 --- a/packages/tui/test/unit/tui-chat-controller.test.ts +++ b/packages/tui/test/unit/tui-chat-controller.test.ts @@ -2745,6 +2745,49 @@ describe('TuiChatController', () => { ); }); + 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', + 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.reconcileOwnerHistory(true); + + expect(onTodoChange).toHaveBeenLastCalledWith(keepEarlier ? [earlierTodo] : []); + }, + ); + it('clears input-adjacent tasks when starting a new session', () => { const onTodoChange = vi.fn(); const controller = new ProductionTuiChatController({ From 67afb6134d5fa391148d002b89174a28d115e15e Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:41:14 +0800 Subject: [PATCH 18/23] feat(tui): select plugins with durable identity mentions (#334) * feat(tui): select plugins with durable identity mentions * test(tui): update plugin mention discovery hints * fix(tui): preserve plugin bindings across draft and message edits * fix(tui): retain unique plugin labels across multiple external edits * fix: reconcile plugin mention display and simplify completion rows * fix(tui): reconcile theme edits missed during watcher startup --- docs/tui-capabilities.md | 23 + .../system-reminder/package.json | 3 +- .../system-reminder/src/plugin-reference.ts | 72 +- .../conversation/conversation-application.ts | 5 + .../conversation/direct-send-delivery.ts | 7 +- .../user-message-turn-delivery.ts | 8 + .../session/content-application.ts | 1 + .../session-system/messages/repo/contract.ts | 2 + .../messages/user-message-commit-service.ts | 3 + .../local-turn-plugin-capabilities.test.ts | 169 +++++ .../local-turn-plugin-capabilities.ts | 105 ++- packages/protocol/src/local.ts | 2 + packages/shared/package.json | 4 + packages/shared/src/plugin-mention.ts | 40 ++ .../tui/src/application/run-coordinator.ts | 2 + .../tui/src/runtime/adapters/plugin-access.ts | 3 +- packages/tui/src/runtime/stream-events.ts | 2 + .../src/tui/commands/plugin-autocomplete.ts | 113 +++ .../tui/src/tui/controller/chat-controller.ts | 1 + .../tui/controller/interaction/input-flow.ts | 2 + .../tui/controller/product/command-flow.ts | 19 +- .../product/session-mutation-flow.ts | 11 +- .../projection/turn-user-projection.ts | 14 +- .../src/tui/controller/run/active-run-flow.ts | 38 +- .../tui/src/tui/controller/run/queue-flow.ts | 23 +- .../tui/src/tui/engine/LOCAL_CHANGES.json | 6 +- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 7 + .../tui/src/tui/engine/components/editor.ts | 11 +- .../tui/features/composer/draft-recovery.ts | 17 +- .../src/tui/features/composer/submission.ts | 5 +- .../src/tui/features/history/search-panel.ts | 7 +- packages/tui/src/tui/shell/composer.ts | 2 +- packages/tui/src/tui/theme/custom-themes.ts | 3 + packages/tui/src/tui/widgets/editor/editor.ts | 214 +++++- .../src/tui/widgets/editor/plugin-mentions.ts | 134 ++++ packages/tui/test/unit/tui-app.test.ts | 4 +- .../tui/test/unit/tui-chat-controller.test.ts | 46 +- packages/tui/test/unit/tui-composer.test.ts | 6 +- .../tui/test/unit/tui-plugin-mentions.test.ts | 677 ++++++++++++++++++ .../test/unit/tui/theme/custom-themes.test.ts | 59 +- .../tui/test/unit/tui/theme/runtime.test.ts | 17 +- pnpm-lock.yaml | 3 + release/public-source.json | 5 + test/vitest-suites.json | 4 +- tsconfig.standalone.json | 3 + 45 files changed, 1783 insertions(+), 119 deletions(-) create mode 100644 packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts create mode 100644 packages/shared/src/plugin-mention.ts create mode 100644 packages/tui/src/tui/commands/plugin-autocomplete.ts create mode 100644 packages/tui/src/tui/widgets/editor/plugin-mentions.ts create mode 100644 packages/tui/test/unit/tui-plugin-mentions.test.ts diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 622039f2..9ced4ea3 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -141,3 +141,26 @@ existing diagnostic-counts projection; raw error text, stacks and session IDs are not added to the uploaded ZIP. Offline tests cover persisted tool histories, archives, concurrent parent output, side-session cleanup and local diagnostics; this does not establish native-terminal or live-model acceptance. + +## Select a plugin for a message + +Type `@` in the Composer to search files and installed, enabled plugins. Plugin +candidates show their source so packages with the same display name can be +selected independently. Choose a plugin with Tab or Enter, then describe the task. +The Composer shows `@Name` and retains the plugin identity through editing, undo, +prompt history, queued-message recovery, saved drafts, and `/edit` after a +message is sent. Ctrl+C clearing/restoration and external-editor edits retain +unchanged plugin bindings. If external edits make duplicate labels ambiguous, +reselect those plugins in the Composer. Displayed messages remain readable; Runtime retains +the original input separately when needed to recover the plugin identity for editing. + +Selection applies to that message. Runtime checks the plugin's effective Skills, +MCP tools, and App tools again for the turn and asks the Agent to prefer relevant +capabilities. Selecting a plugin does not install or enable it. An unavailable +selection is reported to the Agent rather than redirected to a same-named package. + +Exec and ACP text prompts can use the durable linked form, for example +`[@Notes](plugin://notes%40local) summarize these files`. The ID is the package +name plus its `local` or `official` source; display labels do not determine the +selection. Legacy whitespace-delimited `@package-name` text remains supported +when it identifies exactly one effective plugin. 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/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/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/protocol/src/local.ts b/packages/protocol/src/local.ts index c313e961..10c8268f 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 57e7636a..456b4e3a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -191,6 +191,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/src/application/run-coordinator.ts b/packages/tui/src/application/run-coordinator.ts index bfdcda4e..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[]; @@ -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/runtime/adapters/plugin-access.ts b/packages/tui/src/runtime/adapters/plugin-access.ts index 3b857017..035ec738 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/tui/commands/plugin-autocomplete.ts b/packages/tui/src/tui/commands/plugin-autocomplete.ts new file mode 100644 index 00000000..abe63065 --- /dev/null +++ b/packages/tui/src/tui/commands/plugin-autocomplete.ts @@ -0,0 +1,113 @@ +import type { McodePluginRuntimeAccess } 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.ts b/packages/tui/src/tui/controller/chat-controller.ts index e058b1ff..520ca372 100644 --- a/packages/tui/src/tui/controller/chat-controller.ts +++ b/packages/tui/src/tui/controller/chat-controller.ts @@ -436,6 +436,7 @@ export class TuiChatController { turnId, session, content, + ...(options.displayContent !== undefined ? { displayContent } : {}), workspace: this.workspaceDir, version: this.version, ...(attachments.length > 0 ? { attachments } : {}), diff --git a/packages/tui/src/tui/controller/interaction/input-flow.ts b/packages/tui/src/tui/controller/interaction/input-flow.ts index 75509bb3..612196e7 100644 --- a/packages/tui/src/tui/controller/interaction/input-flow.ts +++ b/packages/tui/src/tui/controller/interaction/input-flow.ts @@ -109,6 +109,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) => @@ -505,6 +506,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 601e2997..b75a012b 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, @@ -12,7 +13,7 @@ import { TuiLoginRegionPicker } from '../../features/auth/login-region-picker.js import { TuiPermissionModePicker } from '../../features/interaction/permission-mode-picker.js'; import { TuiSettingsPicker } from '../../features/settings/picker.js'; import { TuiHotkeysPicker } from '../../features/settings/hotkeys-picker.js'; -import { submittedEditorContent, 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'; @@ -226,10 +227,14 @@ export class TuiCommandFlow { // Hidden context may be rebuilt for an edited session-mutation message. // Other opaque transport (including /review) belongs to the old text and // must not override the user's correction. - const transportContent = recoverable?.transportContent && + 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; + : undefined); const transportAttachments = recoverable?.transportAttachments ? reconcileRecoveredTransportAttachments(recoverable, resources.attachments) : undefined; @@ -273,7 +278,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 } @@ -513,9 +518,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, 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 10f95a6e..9ffc66d3 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'; @@ -415,8 +416,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')); @@ -658,7 +663,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/projection/turn-user-projection.ts b/packages/tui/src/tui/controller/projection/turn-user-projection.ts index 7bf3910c..957d0c1c 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/active-run-flow.ts b/packages/tui/src/tui/controller/run/active-run-flow.ts index 2604786d..333083bb 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 { McodePluginRuntimeAccess } from '../../../plugin/contract.js'; import type { Component } from '../../rendering/component.js'; import type { Terminal } from '../../engine/public.js'; import { @@ -57,7 +59,9 @@ export interface TuiSteerOptions { export function createTuiInitialAutocomplete( workspace: string | readonly TuiWorkspaceRoot[], - workspaceFiles?: Partial, + workspaceFiles?: Partial< + TuiWorkspaceFilePort & Pick + >, builtInCommands: readonly TuiCommand[] = MINIMAX_CODE_DISCOVERABLE_COMMANDS, ) { return createTuiAutocomplete(builtInCommands, [], workspace, workspaceFiles); @@ -67,15 +71,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 +141,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 +576,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 +741,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/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index f39b20b7..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 保留草稿;无参数命令保持原行为。" }, { diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index 50b72941..b240a9f3 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -170,3 +170,10 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - 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/features/composer/draft-recovery.ts b/packages/tui/src/tui/features/composer/draft-recovery.ts index 0e8a829e..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,7 +432,10 @@ export class TuiDraftRecovery { const relocatedPath = attachment.filePath ? relocatedPaths.get(attachment.filePath) : undefined; - return { ...attachment, ...(relocatedPath ? { filePath: relocatedPath } : {}) }; + return { + ...attachment, + ...(relocatedPath ? { filePath: relocatedPath } : {}), + }; }), } : {}), @@ -450,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; @@ -509,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 ? { @@ -538,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) => ({ @@ -556,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 ? { @@ -749,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/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/theme/custom-themes.ts b/packages/tui/src/tui/theme/custom-themes.ts index 453a8c72..afb59ae6 100644 --- a/packages/tui/src/tui/theme/custom-themes.ts +++ b/packages/tui/src/tui/theme/custom-themes.ts @@ -288,6 +288,9 @@ export function watchCustomThemes( 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; } diff --git a/packages/tui/src/tui/widgets/editor/editor.ts b/packages/tui/src/tui/widgets/editor/editor.ts index f81e3eff..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,12 +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 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); } @@ -474,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), @@ -525,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'; @@ -541,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) { @@ -555,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; @@ -623,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 { @@ -652,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 || @@ -698,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; }); } @@ -728,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; @@ -754,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), @@ -787,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(); @@ -812,6 +961,29 @@ export function submittedEditorContent(draft: EditorDraftSnapshot): string { 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/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 9461c9d6..30d84699 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -1982,7 +1982,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?.(); @@ -1990,7 +1990,7 @@ describe("createTuiApp", () => { app.setStartupStatus(undefined); expect(app.tui.render(80).join("\n")).toContain( - "Start · @ file · / autocomplete", + "Start · @ file or Plugin · / autocomplete", ); } finally { releaseSessions?.(); diff --git a/packages/tui/test/unit/tui-chat-controller.test.ts b/packages/tui/test/unit/tui-chat-controller.test.ts index a0102b16..e13994f9 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, @@ -20,6 +20,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); 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-plugin-mentions.test.ts b/packages/tui/test/unit/tui-plugin-mentions.test.ts new file mode 100644 index 00000000..6ddf09f0 --- /dev/null +++ b/packages/tui/test/unit/tui-plugin-mentions.test.ts @@ -0,0 +1,677 @@ +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 } 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'; +import { stripTerminalSequences } from '../../src/tui/engine/public.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/theme/custom-themes.test.ts b/packages/tui/test/unit/tui/theme/custom-themes.test.ts index 23020c02..9b48b725 100644 --- a/packages/tui/test/unit/tui/theme/custom-themes.test.ts +++ b/packages/tui/test/unit/tui/theme/custom-themes.test.ts @@ -1,10 +1,12 @@ +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 } from 'vitest'; +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'; @@ -14,9 +16,15 @@ import { MINIMAX_CODE_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 })), ); @@ -42,6 +50,55 @@ function onlyTheme(themes: readonly TuiThemeDefinition[]): TuiThemeDefinition { } 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); diff --git a/packages/tui/test/unit/tui/theme/runtime.test.ts b/packages/tui/test/unit/tui/theme/runtime.test.ts index d0da7544..ab9e80a1 100644 --- a/packages/tui/test/unit/tui/theme/runtime.test.ts +++ b/packages/tui/test/unit/tui/theme/runtime.test.ts @@ -568,6 +568,7 @@ describe('TuiThemeController theme selection', () => { 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 }); @@ -577,7 +578,7 @@ describe('TuiThemeController theme selection', () => { await write('#112233'); const ui = new ThemeUi(); - const controller = new TuiThemeController({ + controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' }, @@ -594,8 +595,8 @@ describe('TuiThemeController theme selection', () => { expect(controller.selectedThemeId()).toBe('mine'); expect(onChange).toHaveBeenCalled(); - controller.dispose(); } finally { + controller?.dispose(); await rm(dataDir, { recursive: true, force: true }); restoreTheme(); } @@ -606,6 +607,7 @@ describe('TuiThemeController theme selection', () => { 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 }); @@ -619,7 +621,7 @@ describe('TuiThemeController theme selection', () => { const ui = new ThemeUi(); const onThemesChanged = vi.fn(); - const controller = new TuiThemeController({ + controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' }, @@ -629,8 +631,11 @@ describe('TuiThemeController theme selection', () => { controller.setTheme('mine'); const onChange = vi.fn(); controller.onChange(onChange); - // The constructor load already fired once; only a watcher-driven reload - // may satisfy the wait below. + // 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); @@ -640,8 +645,8 @@ describe('TuiThemeController theme selection', () => { expect(tuiColors.brand).toBe('#112233'); expect(onChange).not.toHaveBeenCalled(); - controller.dispose(); } finally { + controller?.dispose(); await rm(dataDir, { recursive: true, force: true }); restoreTheme(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8014dab7..00246441 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -335,6 +335,9 @@ importers: packages/agent-modules/system-reminder: devDependencies: + '@mavis/shared': + specifier: workspace:^ + version: link:../../shared '@mavis/config': specifier: workspace:^ version: link:../../config diff --git a/release/public-source.json b/release/public-source.json index a07638a9..9748e819 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1278,6 +1278,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", @@ -2764,6 +2765,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", @@ -2952,6 +2954,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", @@ -3203,6 +3206,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", @@ -3272,6 +3276,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", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 9ddb86ca..5bc27008 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -174,7 +174,9 @@ "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/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" ], "status-contract": [ "packages/tui/test/unit/tui-build-mode-contract.test.ts" diff --git a/tsconfig.standalone.json b/tsconfig.standalone.json index 1ad29d2b..df4c912a 100644 --- a/tsconfig.standalone.json +++ b/tsconfig.standalone.json @@ -328,6 +328,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" ], From a914a306c9aa84a71e8c49f03e1d539416287397 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:21:52 +0800 Subject: [PATCH 19/23] chore(tui): restore source ESLint rules and verification gate (#341) --- .agents/skills/testing-workflow/SKILL.md | 1 + .eslintrc.cjs | 129 ++ CONTRIBUTING.md | 2 + package.json | 13 +- .../tui/src/application/assistant-content.ts | 4 +- .../src/observability/incident-reporter.ts | 4 +- .../src/tui/commands/plugin-autocomplete.ts | 2 +- .../unit/incident-reporter-privacy.test.ts | 2 +- .../tui/test/unit/tui-image-preview.test.ts | 3 +- .../tui/test/unit/tui-plugin-mentions.test.ts | 11 +- .../unit/tui-scrollbar-interaction.test.ts | 2 +- pnpm-lock.yaml | 1847 ++++++++++++++++- release/dependency-licenses.json | 1371 +++++++++++- release/public-source.json | 1 + scripts/verify.mjs | 1 + test/source-sync.test.mjs | 38 + 16 files changed, 3381 insertions(+), 50 deletions(-) create mode 100644 .eslintrc.cjs diff --git a/.agents/skills/testing-workflow/SKILL.md b/.agents/skills/testing-workflow/SKILL.md index b37177ab..e082ffd3 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/CONTRIBUTING.md b/CONTRIBUTING.md index cddfe258..0e2cec3a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,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/package.json b/package.json index 0accb098..017e59c6 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,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", @@ -40,7 +42,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/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/observability/incident-reporter.ts b/packages/tui/src/observability/incident-reporter.ts index dd0ff95b..8d46ced9 100644 --- a/packages/tui/src/observability/incident-reporter.ts +++ b/packages/tui/src/observability/incident-reporter.ts @@ -661,8 +661,8 @@ function minimizeEventLog(value: unknown, eventType: string): string { // An already minimized unknown thrown value stays unknown after disk replay. const breadcrumbs = (Array.isArray(log.breadcrumbs) ? log.breadcrumbs : []) .slice(-MAX_BREADCRUMBS) - .flatMap((value) => { - const crumb = record(value); + .flatMap((breadcrumb) => { + const crumb = record(breadcrumb); const name = allowedString(crumb.name, BREADCRUMB_NAMES); const phase = allowedString(record(crumb.details).phase, PHASES); return name && timestamp(crumb.occurredAtMs) diff --git a/packages/tui/src/tui/commands/plugin-autocomplete.ts b/packages/tui/src/tui/commands/plugin-autocomplete.ts index abe63065..6fe8d5fd 100644 --- a/packages/tui/src/tui/commands/plugin-autocomplete.ts +++ b/packages/tui/src/tui/commands/plugin-autocomplete.ts @@ -57,7 +57,7 @@ export class TuiPluginAutocomplete implements AutocompleteProvider { .map((plugin) => { const name = sanitizeTerminalText(plugin.displayName) - .replace(/[\[\]\\]/gu, ' ') + .replace(/[[\]\\]/gu, ' ') .replace(/\s+/gu, ' ') .trim() .slice(0, 256) || plugin.name; diff --git a/packages/tui/test/unit/incident-reporter-privacy.test.ts b/packages/tui/test/unit/incident-reporter-privacy.test.ts index ebe9cc6b..2e2371a3 100644 --- a/packages/tui/test/unit/incident-reporter-privacy.test.ts +++ b/packages/tui/test/unit/incident-reporter-privacy.test.ts @@ -307,7 +307,7 @@ describe('TUI automatic incident HTTP privacy boundary', () => { reporter.capture(input(new Error(privateText))); await reporter.drain(); expect(requests).toEqual([]); - const name = readdirSync(directory).find((name) => name.startsWith('pending-'))!; + const name = readdirSync(directory).find((filename) => filename.startsWith('pending-'))!; const path = join(directory, name); const stored = JSON.parse(readFileSync(path, 'utf8')); const log = JSON.parse(stored.eventLog); 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 index 6ddf09f0..96004de9 100644 --- a/packages/tui/test/unit/tui-plugin-mentions.test.ts +++ b/packages/tui/test/unit/tui-plugin-mentions.test.ts @@ -2,7 +2,7 @@ import { ConversationApplication } from '../../../local-runtime-v2/src/applicati 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 } from '../../src/tui/engine/public.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'; @@ -31,7 +31,6 @@ import { } 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'; -import { stripTerminalSequences } from '../../src/tui/engine/public.js'; const identity = (value: string) => value; const makePlugin = (name = 'notes', marketplace: 'local' | 'official' = 'local') => ({ @@ -525,14 +524,14 @@ describe('Plugin mention review regressions', () => { workspaceDir: '/workspace', configuredCommand: 'synthetic-editor', editDraft: async ({ draft }) => - rename ? draft.replace('@My Notes', '@Other Notes') : draft + ' tomorrow', + 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'); + expect(submittedEditorTransport(editor.captureDraft())).toBe(`${transport} tomorrow`); editor.handleInput('\x1f'); expect(editor.captureDraft()).toEqual(before); rename = true; @@ -550,7 +549,7 @@ describe('Plugin mention review regressions', () => { tui: { start: vi.fn(), stop: vi.fn(), requestRender: vi.fn() }, workspaceDir: '/workspace', configuredCommand: 'synthetic-editor', - editDraft: async ({ draft }) => 'Please ' + draft + ' tomorrow', + editDraft: async ({ draft }) => `Please ${draft} tomorrow`, isAppStopped: () => false, append: vi.fn(), setHint: vi.fn(), @@ -670,7 +669,7 @@ describe('Plugin mention review regressions', () => { editor.handleInput(' tomorrow'); await flow.submitEdit(editor.getText(), [], editor.captureDraft()); expect(editSessionMessage).toHaveBeenCalledWith( - expect.objectContaining({ content: canonical + ' tomorrow' }), + 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 09342865..06c8394f 100644 --- a/packages/tui/test/unit/tui-scrollbar-interaction.test.ts +++ b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts @@ -187,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() {}, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00246441..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 @@ -335,12 +356,12 @@ importers: packages/agent-modules/system-reminder: devDependencies: - '@mavis/shared': - specifier: workspace:^ - version: link:../../shared '@mavis/config': specifier: workspace:^ version: link:../../config + '@mavis/shared': + specifier: workspace:^ + version: link:../../shared '@types/node': specifier: ^20 version: 20.19.43 @@ -1381,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'} @@ -1396,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'} @@ -1751,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==} @@ -1906,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==} @@ -1979,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==} @@ -2015,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'} @@ -2062,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: @@ -2211,6 +2334,9 @@ packages: ajv: optional: true + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -2228,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'} @@ -2235,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==} @@ -2242,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} @@ -2275,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} @@ -2300,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} @@ -2357,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'} @@ -2403,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'} @@ -2423,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'} @@ -2444,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: @@ -2560,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'} @@ -2579,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'} @@ -2591,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'} @@ -2645,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==} @@ -2674,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==} @@ -2685,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'} @@ -2694,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'} @@ -2718,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} @@ -2726,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'} @@ -2734,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.*} @@ -2754,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==} @@ -2767,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'} @@ -2793,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'} @@ -2854,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'} @@ -2865,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==} @@ -2875,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'} @@ -2891,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'} @@ -2911,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'} @@ -2922,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==} @@ -3013,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'} @@ -3037,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'} @@ -3050,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==} @@ -3128,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==} @@ -3156,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'} @@ -3178,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} @@ -3201,11 +3777,35 @@ 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: + 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'} @@ -3228,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} @@ -3239,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'} @@ -3249,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==} @@ -3265,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'} @@ -3283,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==} @@ -3322,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} @@ -3332,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'} @@ -3369,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'} @@ -3417,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'} @@ -3429,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==} @@ -3437,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'} @@ -3449,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'} @@ -3461,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==} @@ -3488,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'} @@ -3501,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==} @@ -3568,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'} @@ -3601,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==} @@ -3643,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'} @@ -3658,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'} @@ -3695,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'} @@ -3714,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'} @@ -3721,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'} @@ -3735,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==} @@ -3758,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==} @@ -3859,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 @@ -3873,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==} @@ -3919,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: @@ -4267,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 @@ -4284,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 @@ -4561,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': {} @@ -4658,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': {} @@ -4741,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 @@ -4782,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 @@ -4813,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 @@ -4967,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 @@ -4984,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: @@ -4992,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 @@ -5008,6 +5986,8 @@ snapshots: - supports-color optional: true + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} base64-js@1.5.1: {} @@ -5047,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 @@ -5074,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 @@ -5129,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: {} @@ -5166,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 @@ -5178,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 @@ -5208,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 @@ -5238,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: {} @@ -5254,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: @@ -5289,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: {} @@ -5366,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 @@ -5374,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: {} @@ -5395,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: @@ -5412,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 @@ -5439,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 @@ -5460,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: {} @@ -5486,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 @@ -5498,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 @@ -5527,6 +6874,8 @@ snapshots: graceful-fs@4.2.11: {} + graphemer@1.4.0: {} + handlebars@4.7.9: dependencies: minimist: 1.2.8 @@ -5536,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: @@ -5597,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: {} @@ -5617,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 @@ -5634,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: {} @@ -5712,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: @@ -5741,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: @@ -5763,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 @@ -5829,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: {} @@ -5851,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 @@ -5867,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 @@ -5887,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: {} @@ -5904,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 @@ -5952,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 @@ -5961,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 @@ -5973,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: {} @@ -5986,6 +7577,8 @@ snapshots: path-to-regexp@8.4.2: {} + path-type@4.0.0: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -6039,6 +7632,8 @@ snapshots: pngjs@6.0.0: {} + possible-typed-array-names@1.1.0: {} + postcss@8.5.28: dependencies: nanoid: 3.3.19 @@ -6060,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: {} @@ -6106,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 @@ -6167,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: @@ -6182,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 @@ -6234,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 @@ -6254,6 +7911,8 @@ snapshots: semver@5.7.2: {} + semver@6.3.1: {} + semver@7.8.5: {} send@1.2.1: @@ -6281,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: {} @@ -6353,6 +8034,8 @@ snapshots: sisteransi@1.0.5: {} + slash@3.0.0: {} + smol-toml@1.7.1: {} smol-toml@1.8.0: {} @@ -6373,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 @@ -6407,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: @@ -6432,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 @@ -6463,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 @@ -6470,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 @@ -6488,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 @@ -6496,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: @@ -6503,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: {} @@ -6513,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: {} @@ -6611,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 @@ -6624,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: @@ -6656,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 9748e819..0154e4bc 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", diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 18dbd74d..0ca4761d 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 a6a39d01..17806964 100644 --- a/test/source-sync.test.mjs +++ b/test/source-sync.test.mjs @@ -927,6 +927,7 @@ test('Windows contract profile selects focused gates', () => { 'check:tsconfig', 'export source preview', 'test:release-tools', + 'lint:tui', 'build', 'check:standalone', 'test:artifact', @@ -1116,3 +1117,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'); + } +}); From 823d024a357411db9b5a0358ef6054fab88ee2a1 Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Wed, 23 Sep 2026 23:48:04 +0200 Subject: [PATCH 20/23] chore: reconcile generated source inventory after upstream merge --- release/public-source.json | 2 +- test/vitest-suites.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/release/public-source.json b/release/public-source.json index 13647d7e..ad784316 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3317,10 +3317,10 @@ "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/session-tabs-flow.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/test/vitest-suites.json b/test/vitest-suites.json index cc6625c6..5f95e573 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -181,7 +181,7 @@ "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/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", From 2c4c584600dc8bbe3abae72425646bc76922a0dc Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Wed, 23 Sep 2026 23:52:21 +0200 Subject: [PATCH 21/23] fix: adapt upstream theme/launcher/plugin changes to fork Kcode identity --- packages/tui/src/tui/app.ts | 1 - packages/tui/src/tui/commands/plugin-autocomplete.ts | 4 ++-- .../tui/src/tui/controller/run/active-run-flow.ts | 6 +++--- packages/tui/src/tui/launcher.ts | 12 ++++++++---- packages/tui/src/tui/theme/runtime.ts | 4 ++-- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index c2b2065b..3bc23c7f 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -601,7 +601,6 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { commandFlow.clearRetainedSubmissions(); return draftLifecycle?.switchSession(sessionKey) ?? Promise.resolve(); }, - detachForegroundObserver: () => runtimeEventFlow?.detachForegroundObserver(), adoptForegroundRun: () => sessionLifecycle.adoptForegroundRun( controller.snapshot().session?.sessionId, diff --git a/packages/tui/src/tui/commands/plugin-autocomplete.ts b/packages/tui/src/tui/commands/plugin-autocomplete.ts index 6fe8d5fd..e08cf2af 100644 --- a/packages/tui/src/tui/commands/plugin-autocomplete.ts +++ b/packages/tui/src/tui/commands/plugin-autocomplete.ts @@ -1,4 +1,4 @@ -import type { McodePluginRuntimeAccess } from '../../plugin/contract.js'; +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'; @@ -14,7 +14,7 @@ export class TuiPluginAutocomplete implements AutocompleteProvider { constructor( private readonly base: AutocompleteProvider, - private readonly plugins?: Partial>, + private readonly plugins?: Partial>, ) {} async getSuggestions( 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 287f2fb5..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,5 +1,5 @@ import { TuiPluginAutocomplete } from '../../commands/plugin-autocomplete.js'; -import type { McodePluginRuntimeAccess } from '../../../plugin/contract.js'; +import type { KcodePluginRuntimeAccess } from '../../../plugin/contract.js'; import type { Component } from '../../rendering/component.js'; import type { Terminal } from '../../engine/public.js'; import { @@ -70,7 +70,7 @@ export function createTuiAutocomplete( skillCommands: readonly TuiCommand[], workspace: string | readonly TuiWorkspaceRoot[], workspaceFiles?: Partial< - TuiWorkspaceFilePort & Pick + TuiWorkspaceFilePort & Pick >, shellCwd?: () => string, ): AutocompleteProvider { @@ -575,7 +575,7 @@ class TuiAutocompleteProvider implements AutocompleteProvider { skillCommands: readonly TuiCommand[], workspace: string | readonly TuiWorkspaceRoot[], private readonly workspaceFiles?: Partial< - TuiWorkspaceFilePort & Pick + TuiWorkspaceFilePort & Pick >, shellCwd?: () => string, ) { diff --git a/packages/tui/src/tui/launcher.ts b/packages/tui/src/tui/launcher.ts index 6ef70ffa..37797996 100644 --- a/packages/tui/src/tui/launcher.ts +++ b/packages/tui/src/tui/launcher.ts @@ -51,10 +51,14 @@ 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'; - readTuiTheme?: typeof readTuiThemeSetting; - writeTuiTheme?: typeof writeTuiThemeSetting; - createSharedAuthSession?: typeof createKcodeSharedAuthSession; - createAuthApplication?: typeof createDefaultKcodeAuthApplication; +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'; const KCODE_EXIT_SLOGAN = 'Intelligence with everyone, bye~'; diff --git a/packages/tui/src/tui/theme/runtime.ts b/packages/tui/src/tui/theme/runtime.ts index a8dc0834..7728607e 100644 --- a/packages/tui/src/tui/theme/runtime.ts +++ b/packages/tui/src/tui/theme/runtime.ts @@ -13,7 +13,7 @@ import type { 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; @@ -256,7 +256,7 @@ export function getTuiThemeSnapshot(): TuiRenderThemeSnapshot { return { ...renderThemeSnapshot }; } -const tuiHighlightTheme = createCatppuccinHighlightTheme( +const tuiHighlightTheme = createSyntaxHighlightTheme( tuiChalk, () => activeSyntax, () => renderThemeSnapshot.appearance, From 0164245fe9e77db413c61a354942f34c0ba392ab Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Thu, 24 Sep 2026 00:32:22 +0200 Subject: [PATCH 22/23] fix: satisfy the lint:tui gate on the fork (upstream #341 follow-up) - auth/application.ts: drop the no-useless-catch try/catch wrapper in login() - transcript/usage-visualization.ts, update/application.ts: prefer-template - update-release.test.ts: prefer-template in fixture URLs - package-identity.test.ts: import the scripts/lib/package-identity.mjs via import.meta.resolve so the cross-package reference satisfies import/no-relative-packages while keeping the vitest resolution path --- packages/tui/src/auth/application.ts | 66 +++++++++---------- .../src/tui/transcript/usage-visualization.ts | 11 ++-- packages/tui/src/update/application.ts | 3 +- .../tui/test/unit/package-identity.test.ts | 4 +- packages/tui/test/unit/update-release.test.ts | 4 +- 5 files changed, 43 insertions(+), 45 deletions(-) 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/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/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/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/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', }, From 1451ca2ad72b03d08f14322df25f7321312a0acc Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Thu, 24 Sep 2026 00:42:11 +0200 Subject: [PATCH 23/23] fix: adapt upstream theme tests and TUI copy assertions to the Kcode identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tui-app.test.ts: /usage placeholder assertion 'Ask Mcode…' -> 'Ask Kcode…' - theme tests (palettes/custom-themes/runtime): MINIMAX_CODE_DARK/LIGHT_THEME and MINIMAX_CODE_THEME_CONTRAST_POLICY renamed to the KCODE_* exports the fork's theme/palettes.ts ships; theme-contrast helper gains the KCODE_THEME_CONTRAST_POLICY alias with the same palette roles --- packages/tui/test/helpers/theme-contrast.ts | 3 +- packages/tui/test/unit/tui-app.test.ts | 2 +- .../test/unit/tui/theme/custom-themes.test.ts | 10 ++-- .../tui/test/unit/tui/theme/palettes.test.ts | 24 ++++----- .../tui/test/unit/tui/theme/runtime.test.ts | 50 +++++++++---------- 5 files changed, 45 insertions(+), 44 deletions(-) diff --git a/packages/tui/test/helpers/theme-contrast.ts b/packages/tui/test/helpers/theme-contrast.ts index 0f31d10f..9edd5074 100644 --- a/packages/tui/test/helpers/theme-contrast.ts +++ b/packages/tui/test/helpers/theme-contrast.ts @@ -2,7 +2,8 @@ import type { TuiThemeColors } from '../../src/tui/theme/contracts.js'; type TuiThemeColorRole = keyof TuiThemeColors; -export const MINIMAX_CODE_THEME_CONTRAST_POLICY = Object.freeze({ +/** 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', diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index b32eb800..9cdbe1e0 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -6072,7 +6072,7 @@ describe("createTuiApp", () => { expect(history.filter((line) => line.trimEnd().endsWith(`› Message ${index}`))).toHaveLength(1); } expect(history.join("\n")).not.toContain("Session usage"); - expect(terminal.getViewport().join("\n")).toContain("Ask Mcode to do anything"); + expect(terminal.getViewport().join("\n")).toContain("Ask Kcode to do anything"); expect(terminal.getViewport().join("\n")).toContain("/workspace"); } finally { await app.stop(); diff --git a/packages/tui/test/unit/tui/theme/custom-themes.test.ts b/packages/tui/test/unit/tui/theme/custom-themes.test.ts index 9b48b725..d9d49852 100644 --- a/packages/tui/test/unit/tui/theme/custom-themes.test.ts +++ b/packages/tui/test/unit/tui/theme/custom-themes.test.ts @@ -12,8 +12,8 @@ import { TuiThemeRegistry } from '../../../../src/tui/theme/registry.js'; import type { TuiThemeDefinition } from '../../../../src/tui/theme/contracts.js'; import { DEFAULT_THEME_ID, - MINIMAX_CODE_DARK_THEME, - MINIMAX_CODE_LIGHT_THEME, + KCODE_DARK_THEME, + KCODE_LIGHT_THEME, } from '../../../../src/tui/theme/palettes.js'; vi.mock('node:fs', async (importOriginal) => { @@ -120,7 +120,7 @@ describe('custom TUI theme files', () => { expect(theme.label).toBe('mine'); expect(theme.dark.colors.brand).toBe('#112233'); // Untouched roles keep the default palette so the theme is usable immediately. - expect(theme.dark.colors.error).toBe(MINIMAX_CODE_DARK_THEME.colors.error); + expect(theme.dark.colors.error).toBe(KCODE_DARK_THEME.colors.error); }); it('resolves vars references and normalizes case', async () => { @@ -165,8 +165,8 @@ describe('custom TUI theme files', () => { expect(theme.dark.colors.brand).toBe('#010101'); // The light variant is a complete, usable palette borrowed from the default // light theme — not a blank object and not the dark value. - expect(theme.light.colors.brand).toBe(MINIMAX_CODE_LIGHT_THEME.colors.brand); - expect(theme.light.colors.text).toBe(MINIMAX_CODE_LIGHT_THEME.colors.text); + expect(theme.light.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'); }); diff --git a/packages/tui/test/unit/tui/theme/palettes.test.ts b/packages/tui/test/unit/tui/theme/palettes.test.ts index 434cf8f0..c01bf5e6 100644 --- a/packages/tui/test/unit/tui/theme/palettes.test.ts +++ b/packages/tui/test/unit/tui/theme/palettes.test.ts @@ -2,15 +2,15 @@ import { describe, expect, it } from 'vitest'; import { BUILT_IN_THEMES, DEFAULT_THEME_ID, - MINIMAX_CODE_DARK_THEME, - MINIMAX_CODE_LIGHT_THEME, + 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 { - MINIMAX_CODE_THEME_CONTRAST_POLICY, + KCODE_THEME_CONTRAST_POLICY, contrastRatio, } from '../../../helpers/theme-contrast.js'; @@ -23,7 +23,7 @@ const ALL_PALETTES = BUILT_IN_THEMES.flatMap((theme) => [ describe('built-in TUI themes', () => { it('keeps the default MCode palette byte-identical to the pre-theme implementation', () => { // The default theme is what every existing user sees, so it must not move. - expect(MINIMAX_CODE_DARK_THEME.colors).toEqual({ + expect(KCODE_DARK_THEME.colors).toEqual({ brand: '#68C0FF', wordmarkHighlight: '#93D2FF', wordmarkShadow: '#3DAEFF', @@ -45,31 +45,31 @@ describe('built-in TUI themes', () => { warning: '#FFC340', error: '#FF5E6C', }); - expect(MINIMAX_CODE_LIGHT_THEME.colors.text).toBe('#303030'); + 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 = MINIMAX_CODE_THEME_CONTRAST_POLICY.backgrounds[palette.appearance]; + const background = KCODE_THEME_CONTRAST_POLICY.backgrounds[palette.appearance]; - for (const role of MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.roles) { - const exception = MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.exceptions.find( + 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 ?? MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.minimum, + exception?.minimum ?? KCODE_THEME_CONTRAST_POLICY.normalText.minimum, ); } - for (const role of MINIMAX_CODE_THEME_CONTRAST_POLICY.nonText.roles) { + 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(MINIMAX_CODE_THEME_CONTRAST_POLICY.nonText.minimum); + ).toBeGreaterThanOrEqual(KCODE_THEME_CONTRAST_POLICY.nonText.minimum); } }, ); @@ -93,7 +93,7 @@ describe('built-in TUI themes', () => { it.each(ALL_PALETTES)( 'keeps syntax text legible for $theme.id $palette.appearance', ({ palette }) => { - const background = MINIMAX_CODE_THEME_CONTRAST_POLICY.backgrounds[palette.appearance]; + 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); diff --git a/packages/tui/test/unit/tui/theme/runtime.test.ts b/packages/tui/test/unit/tui/theme/runtime.test.ts index ab9e80a1..070704d3 100644 --- a/packages/tui/test/unit/tui/theme/runtime.test.ts +++ b/packages/tui/test/unit/tui/theme/runtime.test.ts @@ -1,11 +1,11 @@ import { stripVTControlCharacters } from 'node:util'; import { describe, expect, it, vi } from 'vitest'; import { - MINIMAX_CODE_DARK_THEME, - MINIMAX_CODE_LIGHT_THEME, + KCODE_DARK_THEME, + KCODE_LIGHT_THEME, } from '../../../../src/tui/theme/palettes.js'; import { - MINIMAX_CODE_THEME_CONTRAST_POLICY, + KCODE_THEME_CONTRAST_POLICY, contrastRatio, } from '../../../helpers/theme-contrast.js'; import { @@ -27,27 +27,27 @@ import { } from '../../../../src/tui/theme/runtime.js'; describe('MCode terminal theme palettes', () => { - it.each([MINIMAX_CODE_DARK_THEME, MINIMAX_CODE_LIGHT_THEME])( + it.each([KCODE_DARK_THEME, KCODE_LIGHT_THEME])( 'enforces readable semantic colors for the $appearance palette', (palette) => { - const background = MINIMAX_CODE_THEME_CONTRAST_POLICY.backgrounds[palette.appearance]; + const background = KCODE_THEME_CONTRAST_POLICY.backgrounds[palette.appearance]; - for (const role of MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.roles) { - const exception = MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.exceptions.find( + 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 ?? MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.minimum, + exception?.minimum ?? KCODE_THEME_CONTRAST_POLICY.normalText.minimum, ); } - for (const role of MINIMAX_CODE_THEME_CONTRAST_POLICY.nonText.roles) { + 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(MINIMAX_CODE_THEME_CONTRAST_POLICY.nonText.minimum); + ).toBeGreaterThanOrEqual(KCODE_THEME_CONTRAST_POLICY.nonText.minimum); } }, ); @@ -55,10 +55,10 @@ describe('MCode terminal theme palettes', () => { it('uses explicit ANSI16 semantics instead of nearest-RGB collisions', () => { const original = getTuiThemeSnapshot(); const originalPalette = - original.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME; + original.appearance === 'light' ? KCODE_LIGHT_THEME : KCODE_DARK_THEME; try { - applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 1); + 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'); @@ -72,15 +72,15 @@ describe('MCode terminal theme palettes', () => { expect(tuiChalk.bgHex(tuiColors.diffAddedBg)('addition')).toBe('addition'); expect(tuiChalk.bgHex(tuiColors.diffRemovedBg)('deletion')).toBe('deletion'); - applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 2); + 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(MINIMAX_CODE_DARK_THEME, 3); + 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(MINIMAX_CODE_LIGHT_THEME, 1); + 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'); @@ -89,7 +89,7 @@ describe('MCode terminal theme palettes', () => { expect(tuiChalk.bgHex(tuiColors.diffAddedBg)('addition')).toBe('addition'); expect(tuiChalk.bgHex(tuiColors.diffRemovedBg)('deletion')).toBe('deletion'); - applyTuiRenderTheme(MINIMAX_CODE_LIGHT_THEME, 2); + 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;'); @@ -101,10 +101,10 @@ describe('MCode terminal theme palettes', () => { it('keeps action guidance readable and makes keyboard controls prominent', () => { const original = getTuiThemeSnapshot(); const originalPalette = - original.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME; + original.appearance === 'light' ? KCODE_LIGHT_THEME : KCODE_DARK_THEME; try { - applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 3); + 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', ); @@ -123,11 +123,11 @@ describe('MCode terminal theme palettes', () => { it('uses adaptive Catppuccin syntax colors for settled and streaming code', () => { const original = getTuiThemeSnapshot(); const originalPalette = - original.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME; + original.appearance === 'light' ? KCODE_LIGHT_THEME : KCODE_DARK_THEME; const code = "const retries = 3; // keep streaming\nreturn 'ready';"; try { - applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 3); + applyTuiRenderTheme(KCODE_DARK_THEME, 3); const settled = createTuiMarkdownTheme().highlightCode?.(code, 'ts').join('\n'); const streaming = tuiStreamingMarkdownTheme.highlightCode?.(code, 'ts').join('\n'); @@ -137,7 +137,7 @@ describe('MCode terminal theme palettes', () => { expect(stripVTControlCharacters(settled ?? '')).toBe(code); expect(streaming).toBe(settled); - applyTuiRenderTheme(MINIMAX_CODE_LIGHT_THEME, 3); + applyTuiRenderTheme(KCODE_LIGHT_THEME, 3); const light = createTuiMarkdownTheme().highlightCode?.(code, 'ts').join('\n'); expect(light).toContain('\u001B[38;2;'); @@ -211,7 +211,7 @@ describe('TuiThemeController', () => { expect(controller.snapshot()).toMatchObject({ appearance: 'light', source: 'colorfgbg' }); expect(getTuiThemeSnapshot()).toMatchObject({ appearance: 'light' }); - expect(tuiColors.text).toBe(MINIMAX_CODE_LIGHT_THEME.colors.text); + expect(tuiColors.text).toBe(KCODE_LIGHT_THEME.colors.text); expect(tuiChalk.hex(tuiColors.text)('body')).toContain('38;2;48;48;48'); controller.dispose(); }); @@ -411,7 +411,7 @@ describe('TuiThemeController theme selection', () => { const restoreTheme = () => { const snapshot = getTuiThemeSnapshot(); applyTuiRenderTheme( - snapshot.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME, + snapshot.appearance === 'light' ? KCODE_LIGHT_THEME : KCODE_DARK_THEME, snapshot.colorLevel, ); }; @@ -540,7 +540,7 @@ describe('TuiThemeController theme selection', () => { expect(controller.appearanceOverrideValue()).toBeUndefined(); expect(controller.snapshot().appearance).toBe('light'); expect(getTuiThemeSnapshot().appearance).toBe('light'); - expect(tuiColors.text).toBe(MINIMAX_CODE_LIGHT_THEME.colors.text); + expect(tuiColors.text).toBe(KCODE_LIGHT_THEME.colors.text); controller.dispose(); restoreTheme(); }); @@ -558,7 +558,7 @@ describe('TuiThemeController theme selection', () => { controller.setAppearanceOverride(undefined); expect(controller.snapshot().appearance).toBe('dark'); - expect(tuiColors.text).toBe(MINIMAX_CODE_DARK_THEME.colors.text); + expect(tuiColors.text).toBe(KCODE_DARK_THEME.colors.text); controller.dispose(); restoreTheme(); });