diff --git a/.cargo/config.toml b/.cargo/config.toml index 5592118f..a13f206e 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,9 @@ [alias] -xtask = "run -p xtask --" +# Build and run xtask in a private `target/xtask` dir, isolated from the +# workspace `target/`. On Windows a running exe is locked, so sharing `target/` +# would let a concurrent `cargo build` or `cargo xtask` cause "Access is denied +# (os error 5)" relink failures. The isolation is set via this `--target-dir` +# flag (not the CARGO_TARGET_DIR env var) so it does NOT leak into the nested +# cargo builds xtask itself spawns (e.g. packaging), which must still use the +# workspace `target/`. +xtask = "run -p xtask --target-dir target/xtask --" diff --git a/.run/xtask_blog_build.run.xml b/.run/xtask_blog_build.run.xml index d6d54865..68d33199 100644 --- a/.run/xtask_blog_build.run.xml +++ b/.run/xtask_blog_build.run.xml @@ -1,6 +1,6 @@ - diff --git a/.run/xtask_blog_install.run.xml b/.run/xtask_blog_install.run.xml index 6f4c3d6f..ced81baa 100644 --- a/.run/xtask_blog_install.run.xml +++ b/.run/xtask_blog_install.run.xml @@ -1,6 +1,6 @@ - diff --git a/.run/xtask_blog_preview.run.xml b/.run/xtask_blog_preview.run.xml index 01473707..b11b24a8 100644 --- a/.run/xtask_blog_preview.run.xml +++ b/.run/xtask_blog_preview.run.xml @@ -1,7 +1,17 @@ - - diff --git a/.run/xtask_blog_serve.run.xml b/.run/xtask_blog_serve.run.xml index 5c63def9..38954b93 100644 --- a/.run/xtask_blog_serve.run.xml +++ b/.run/xtask_blog_serve.run.xml @@ -1,7 +1,17 @@ - - diff --git a/.run/xtask_blog_serve_en.run.xml b/.run/xtask_blog_serve_en.run.xml index 85f76cc7..87e96d68 100644 --- a/.run/xtask_blog_serve_en.run.xml +++ b/.run/xtask_blog_serve_en.run.xml @@ -1,7 +1,17 @@ - - diff --git a/.run/xtask_blog_typecheck.run.xml b/.run/xtask_blog_typecheck.run.xml index 018034b0..4e6e8088 100644 --- a/.run/xtask_blog_typecheck.run.xml +++ b/.run/xtask_blog_typecheck.run.xml @@ -1,6 +1,6 @@ - diff --git a/.run/xtask_fixture_prepare_react_complex.run.xml b/.run/xtask_fixture_prepare_react_complex.run.xml index 33788a36..0270f6e7 100644 --- a/.run/xtask_fixture_prepare_react_complex.run.xml +++ b/.run/xtask_fixture_prepare_react_complex.run.xml @@ -1,6 +1,6 @@ - diff --git a/.run/xtask_fixture_prepare_react_simple.run.xml b/.run/xtask_fixture_prepare_react_simple.run.xml index 7393c3b3..6cb82fbd 100644 --- a/.run/xtask_fixture_prepare_react_simple.run.xml +++ b/.run/xtask_fixture_prepare_react_simple.run.xml @@ -1,6 +1,6 @@ - diff --git a/.run/xtask_help.run.xml b/.run/xtask_help.run.xml index 33b8d282..ae7a2af2 100644 --- a/.run/xtask_help.run.xml +++ b/.run/xtask_help.run.xml @@ -1,6 +1,6 @@ - diff --git a/.run/xtask_package.run.xml b/.run/xtask_package.run.xml index 8fe864c2..3dfc6351 100644 --- a/.run/xtask_package.run.xml +++ b/.run/xtask_package.run.xml @@ -1,6 +1,6 @@ - diff --git a/.run/xtask_package_release.run.xml b/.run/xtask_package_release.run.xml index 4e64ced3..2427dd30 100644 --- a/.run/xtask_package_release.run.xml +++ b/.run/xtask_package_release.run.xml @@ -1,6 +1,6 @@ - diff --git a/.run/xtask_set-version.run.xml b/.run/xtask_set-version.run.xml index e26ef071..2ad551e1 100644 --- a/.run/xtask_set-version.run.xml +++ b/.run/xtask_set-version.run.xml @@ -1,6 +1,6 @@ - diff --git a/.run/xtask_tui_dev.run.xml b/.run/xtask_tui_dev.run.xml index c9b86d18..4beba548 100644 --- a/.run/xtask_tui_dev.run.xml +++ b/.run/xtask_tui_dev.run.xml @@ -1,18 +1,17 @@ - - \ No newline at end of file + diff --git a/.run/xtask_tui_dev_here.run.xml b/.run/xtask_tui_dev_here.run.xml new file mode 100644 index 00000000..b02724fc --- /dev/null +++ b/.run/xtask_tui_dev_here.run.xml @@ -0,0 +1,17 @@ + + + + diff --git a/.run/xtask_tui_install.run.xml b/.run/xtask_tui_install.run.xml index f1c52900..6afda73f 100644 --- a/.run/xtask_tui_install.run.xml +++ b/.run/xtask_tui_install.run.xml @@ -1,6 +1,6 @@ - diff --git a/.run/xtask_tui_prod.run.xml b/.run/xtask_tui_prod.run.xml index b25f809a..b83dbaaa 100644 --- a/.run/xtask_tui_prod.run.xml +++ b/.run/xtask_tui_prod.run.xml @@ -1,18 +1,17 @@ - - diff --git a/.run/xtask_tui_test.run.xml b/.run/xtask_tui_test.run.xml index 55ad1917..34198c35 100644 --- a/.run/xtask_tui_test.run.xml +++ b/.run/xtask_tui_test.run.xml @@ -1,6 +1,6 @@ - diff --git a/.run/xtask_tui_typecheck.run.xml b/.run/xtask_tui_typecheck.run.xml index b4515012..69b98f49 100644 --- a/.run/xtask_tui_typecheck.run.xml +++ b/.run/xtask_tui_typecheck.run.xml @@ -1,6 +1,6 @@ - diff --git a/AGENTS.md b/AGENTS.md index ceb16c19..05100374 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,11 +40,12 @@ For the nested TUI package, prefer Cargo-facing xtask commands instead of runnin cargo xtask tui-install cargo xtask tui-typecheck cargo xtask tui-test -cargo xtask tui-dev # run the TUI from TypeScript source against a workspace fixture -cargo xtask tui-prod # package the standalone kqode binary and run it from the workspace +cargo xtask tui-dev # run the TUI from TypeScript source against a workspace fixture +cargo xtask tui-dev-here # run the TUI from TypeScript source against the terminal cwd +cargo xtask tui-prod # package the standalone kqode binary and run it from the workspace ``` -`tui-dev` and `tui-prod` both run against a workspace fixture; seed one first with `cargo xtask fixture-prepare-react-simple` (or `fixture-prepare-react-complex`) to skip the interactive fixture prompt. +`tui-dev` and `tui-prod` both run against a workspace fixture; seed one first with `cargo xtask fixture-prepare-react-simple` (or `fixture-prepare-react-complex`) to skip the interactive fixture prompt. Use `tui-dev-here` through `scripts/xtask.sh`/`scripts/xtask.ps1` from another project directory when source-mode TUI should see that directory as its cwd. For the Docusaurus blog/docs site under `blog/`, prefer Cargo-facing xtask commands instead of running package-manager commands directly: @@ -57,7 +58,14 @@ cargo xtask blog-serve-en cargo xtask blog-preview ``` -Running two or more `cargo xtask` commands at once fails on Windows: `cargo xtask` expands to `cargo run -p xtask`, which relinks the shared `target\debug\xtask.exe` on each call, and a long-running command keeps that executable locked so the next call cannot replace it (os error 32, or os error 5 on the remove step). To run long-lived or multiple commands in parallel, use the launcher, which builds once then runs a per-invocation copy under `target/debug/xtask-run/`: +`cargo xtask` is parallel-safe on Windows: the alias builds and runs xtask in a private `target\xtask` directory (separate from the workspace `target\`), so ordinary fast commands never relink a binary another `cargo xtask` or a `cargo build --workspace` is holding. Run fast commands normally, including concurrently: + +```powershell +cargo xtask tui-typecheck +cargo xtask blog-build +``` + +The long-running servers (`blog-serve`, `blog-serve-en`, `blog-preview`, `tui-dev`, `tui-dev-here`, `tui-prod`) hold the binary for their whole session, so run those through the launcher — it builds once, then runs a per-invocation copy under `target\xtask\debug\xtask-run\`, leaving the canonical binary free to relink: ```powershell ./scripts/xtask.ps1 blog-serve # Windows (PowerShell) @@ -67,7 +75,7 @@ Running two or more `cargo xtask` commands at once fails on Windows: `cargo xtas ./scripts/xtask.sh blog-serve # macOS/Linux ``` -Keep xtask command modules as thin wrappers around reusable implementation modules. When adding or renaming an xtask command, add a matching checked-in IDE run profile under `.run/` using the `xtask: ` naming pattern. +Keep xtask command modules as thin wrappers around reusable implementation modules. When adding or renaming an xtask command, add a matching checked-in IDE run profile under `.run/` using the `xtask: ` naming pattern, with its Cargo command routed through the `xtask` alias (`xtask `) so IDE runs inherit the private-dir isolation. ## Architecture diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b1f8796..33dcc789 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,7 @@ cargo xtask tui-install # install nested TUI dependencies cargo xtask tui-typecheck # type-check the TUI (tsc --noEmit) cargo xtask tui-test # run TUI tests (vitest) cargo xtask tui-dev # run the TUI from a throwaway fixture workspace +cargo xtask tui-dev-here # run the TUI from source against the current cwd ``` ### Documentation site @@ -85,10 +86,14 @@ cargo xtask blog-preview ### Windows note -Running two or more `cargo xtask` commands at once fails on Windows, because each -call relinks the shared `target\debug\xtask.exe` and a long-running command keeps -it locked. To run long-lived or multiple commands in parallel, use the launcher, -which builds once and then runs a per-invocation copy: +`cargo xtask` is parallel-safe on Windows: the alias builds and runs xtask in a +private `target\xtask` directory, separate from the workspace `target\`, so a +fast command never relinks a binary another process is holding. Run fast +commands normally, including in parallel. The long-running servers +(`blog-serve`, `blog-serve-en`, `blog-preview`, `tui-dev`, `tui-dev-here`, +`tui-prod`) hold the binary for their whole session, so run those through the +launcher, which builds once and then runs a per-invocation copy that leaves the +canonical binary free: ```powershell ./scripts/xtask.ps1 blog-serve # Windows (PowerShell) diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md index 43cab953..4ace3c03 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -69,6 +69,7 @@ cargo xtask tui-install # 安装内嵌 TUI 的依赖 cargo xtask tui-typecheck # 对 TUI 做类型检查(tsc --noEmit) cargo xtask tui-test # 运行 TUI 测试(vitest) cargo xtask tui-dev # 在一次性 fixture 工作区中运行 TUI +cargo xtask tui-dev-here # 从源码运行 TUI,并使用当前终端目录作为 cwd ``` ### 文档站点 @@ -84,9 +85,13 @@ cargo xtask blog-preview ### Windows 注意事项 -在 Windows 上同时运行两个或更多 `cargo xtask` 命令会失败,因为每次调用都会重新 -链接共享的 `target\debug\xtask.exe`,而长时间运行的命令会一直锁定它。若要并行运行 -长时命令或多个命令,请使用启动器,它只构建一次,然后运行每次调用独立的副本: +在 Windows 上,`cargo xtask` 已可并行运行:别名会把 xtask 构建并运行在私有的 +`target\xtask` 目录中(与工作区的 `target\` 分开),因此快速命令不会去重新链接 +其他进程正在占用的可执行文件。快速命令可以照常运行,也可以并行运行。长时间运行的 +服务器命令(`blog-serve`、`blog-serve-en`、`blog-preview`、`tui-dev`、 +`tui-dev-here`、`tui-prod`)会在整个会话期间占用该可执行文件,因此请通过启动器 +运行它们——启动器只构建一次,然后运行每次调用独立的副本,从而让规范可执行文件 +保持可被重新链接: ```powershell ./scripts/xtask.ps1 blog-serve # Windows(PowerShell) diff --git a/README.md b/README.md index ce3c3699..5e971ea6 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,14 @@ cargo xtask tui-install # install nested TUI dependencies cargo xtask tui-typecheck # type-check the TUI (tsc --noEmit) cargo xtask tui-test # run TUI tests (vitest) cargo xtask tui-dev # run the TUI from a throwaway fixture workspace +cargo xtask tui-dev-here # run the TUI from source against the current cwd ``` `cargo xtask tui-dev` runs the Ink TUI against a copied fixture workspace, so the displayed working directory is a realistic project rather than the KQode repo. +When dogfooding against an existing checkout, run +`/path/to/KQode/scripts/xtask.sh tui-dev-here` from that checkout so source mode +uses the terminal's current directory, matching the packaged `kqode` executable. Today the TUI talks to a local Rust JSON-RPC backend that acknowledges each submitted prompt (`ACK: message received`); it does not yet call a model, run tools, or execute an agent loop, and the slash-command, mention, and model diff --git a/README.zh-CN.md b/README.zh-CN.md index 143b219b..cd517733 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -92,10 +92,13 @@ cargo xtask tui-install # 安装内嵌 TUI 的依赖 cargo xtask tui-typecheck # 对 TUI 做类型检查(tsc --noEmit) cargo xtask tui-test # 运行 TUI 测试(vitest) cargo xtask tui-dev # 在一次性 fixture 工作区中运行 TUI +cargo xtask tui-dev-here # 从源码运行 TUI,并使用当前终端目录作为 cwd ``` `cargo xtask tui-dev` 会针对一份复制出来的 fixture 工作区运行 Ink TUI,因此显示的 -工作目录是一个真实的项目,而不是 KQode 仓库本身。目前 TUI 与一个本地 Rust +工作目录是一个真实的项目,而不是 KQode 仓库本身。如果要在已有项目中 dogfood, +可以从该项目目录运行 `/path/to/KQode/scripts/xtask.sh tui-dev-here`,让源码模式 +使用当前终端目录,与打包后的 `kqode` 可执行文件保持一致。目前 TUI 与一个本地 Rust JSON-RPC 后端通信,该后端只会对每个提交的 prompt 做确认(`ACK: message received`);它尚未调用模型、运行工具或执行智能体循环,斜杠命令、提及(mention) 与模型相关的交互目前都只是无实际作用的占位符。 diff --git a/docs/research/2026-07-23-copilot-cli-right-edge-rendering.md b/docs/research/2026-07-23-copilot-cli-right-edge-rendering.md new file mode 100644 index 00000000..470ca583 --- /dev/null +++ b/docs/research/2026-07-23-copilot-cli-right-edge-rendering.md @@ -0,0 +1,71 @@ +--- +date: 2026-07-23 +topic: copilot-cli-right-edge-rendering +question: "How does GitHub Copilot CLI visually reach the terminal right edge without clipping its content?" +status: blocked +--- + +# Copilot CLI Right-Edge Rendering + +## Summary + +Source-level implementation research is blocked. The checked-in reference +catalog classifies GitHub Copilot CLI as a public product reference rather than +an approved open-source source-research target, so this run did not fetch or +inspect its implementation. + +The supplied screenshot is still useful as product evidence: the conversation +surface stops before a dedicated right-side scrollbar/gutter, so it does not +demonstrate meaningful text rendered in the physical final terminal cell. + +--- + +## Run Metadata + +| Repo | Requested URL | Resolved URL | Branch | SHA | Status | Notes | +|---|---|---|---|---|---|---| +| GitHub Copilot CLI | not fetched | not fetched | n/a | n/a | blocked | Product-only reference under the current approved catalog. | + +--- + +## Method + +- Question: How Copilot CLI handles right-edge rendering. +- Repo scope: GitHub Copilot CLI product reference. +- Safety posture: screenshot inspection only; no unsupported source fetch. +- Citation format: no source citations are available for implementation claims. + +--- + +## Per-Repo Findings + +### GitHub Copilot CLI + +**Status:** blocked + +**Observed product behavior** + +- The screenshot shows a dedicated vertical scrollbar and a visible gutter at + the far right. Conversation-row text and backgrounds terminate before that + chrome, so the UI can appear edge-to-edge without placing important text in + the terminal's final cell. + +**Evidence gaps** + +- No approved source evidence identifies the renderer, width calculation, cell + buffer, or terminal-specific final-column handling. + +--- + +## Evidence Gaps + +- GitHub Copilot CLI source: `policy_blocked` by the current KQode reference + catalog, which lists it as a product reference rather than a source target. +- Renderer implementation: `no_evidence`; screenshot behavior must not be + presented as proof of a specific rendering architecture. + +--- + +## References + +No commit-pinned source references were available for this blocked run. diff --git a/docs/solutions/architecture-patterns/terminal-edge-rendering-tradeoffs-in-the-ink-tui.md b/docs/solutions/architecture-patterns/terminal-edge-rendering-tradeoffs-in-the-ink-tui.md index ff4bc5c2..69aa1771 100644 --- a/docs/solutions/architecture-patterns/terminal-edge-rendering-tradeoffs-in-the-ink-tui.md +++ b/docs/solutions/architecture-patterns/terminal-edge-rendering-tradeoffs-in-the-ink-tui.md @@ -67,11 +67,12 @@ const isFullscreen = isTty && outputHeight >= viewportRows; const outputToRender = isFullscreen ? output : output + '\n'; ``` -The omitted newline shifts Ink's cursor baseline up one row. KQode compensates -with `INK_CURSOR_ROW_ORIGIN_OFFSET` (`tui/src/constants/ui.ts`), which **must -move in lockstep** with the guard row: fullscreen (`FULLSCREEN_GUARD_ROWS = 0`) -→ offset `1`; non-fullscreen (`FULLSCREEN_GUARD_ROWS = 1`) → offset `0`. Change -one without the other and the prompt cursor drifts one row off the composer. +The omitted newline means the physical bottom is the final visible row rather +than a row after the output. Ink 7.1's cursor helper assumed the latter for both +paths. A `+1` application offset corrected the initial render but poisoned the +stored previous position: every cursor-only Left/Right update then drifted one +row upward, and Up moved two rows. KQode now patches Ink's no-newline baseline +directly (`tui/patches/ink@7.1.0.patch`) and uses unadjusted cursor coordinates. ### On Windows, fullscreen frames force a full clear + repaint every frame @@ -128,11 +129,12 @@ On Windows you cannot have both on WezTerm: | Fullscreen (`FULLSCREEN_GUARD_ROWS = 0`) | WezTerm blinks per keystroke; WT fine | Yes (full repaint, no `ESC[K`) | | Non-fullscreen (guard row `= 1`) | No blink | No — incremental `ESC[K` clips the last column of rewritten rows on WezTerm | -**Decision: prioritize Windows Terminal.** Render edge-to-edge unconditionally -(`FULLSCREEN_GUARD_ROWS = 0`, `INK_CURSOR_ROW_ORIGIN_OFFSET = 1`, no per-terminal -branching) and accept WezTerm's blink/clip as a known limitation. Windows -Terminal, macOS, and Linux get a clean full-height, edge-to-edge UI. The revert -knobs are documented inline for anyone who later wants to re-optimize WezTerm. +**Updated decision:** keep the full-height canvas unconditionally +(`FULLSCREEN_GUARD_ROWS = 0`, patched Ink cursor baseline, no per-terminal +branching), but reserve the final cell from non-body chrome through the shared +`chromeColumnsAtom`. Composer wrapping, cwd layout, status text, clicks, and +caret scrolling all use that guarded width; only the body pane uses the physical +last column for scrollbar chrome. ## Why This Matters @@ -152,9 +154,8 @@ knobs are documented inline for anyone who later wants to re-optimize WezTerm. ## When to Apply -- Before changing `FULLSCREEN_GUARD_ROWS`, always change - `INK_CURSOR_ROW_ORIGIN_OFFSET` in lockstep and visually verify the cursor lands - on the composer row (no unit test covers this). +- When upgrading Ink, reapply or retire the checked-in cursor-baseline patch only + after `inkCursorPatch.test.ts` and live fullscreen cursor navigation pass. - When a full-width bar/bubble does not reach the right edge, prefer a `` over a bare ``. - When diagnosing per-keystroke flicker on Windows, check whether frames are @@ -168,21 +169,20 @@ Guard row and cursor offset move together (`tui/src/state/ui/dimensions.ts`, `tui/src/constants/ui.ts`): ```ts -// Edge-to-edge (current): fills full height, Ink renders fullscreen frames. -export const FULLSCREEN_GUARD_ROWS = 0; // dimensions.ts -export const INK_CURSOR_ROW_ORIGIN_OFFSET = 1; // constants/ui.ts +// Edge-to-edge (current): fills full height; the Ink patch handles the +// no-trailing-newline cursor baseline. +export const FULLSCREEN_GUARD_ROWS = 0; -// Revert knob: reserve one row to stay non-fullscreen (no Windows repaint blink). +// Revert knob: reserve one row to stay non-fullscreen. // export const FULLSCREEN_GUARD_ROWS = 1; -// export const INK_CURSOR_ROW_ORIGIN_OFFSET = 0; ``` -Status bar reaching the final column (`tui/src/components/StatusBar.tsx`): +Guarded non-body chrome (`tui/src/state/ui/dimensions.ts`): -```tsx -// paddingRight={1} reserved the last column to avoid WezTerm clipping the model -// label; removed to reach the edge on Windows Terminal (WezTerm may clip). -{/* ...hints... model label ... */} +```ts +export const chromeColumnsAtom = atom((get) => + resolveChromeColumns(get(columnsAtom)) +); ``` Observed on WezTerm-on-Windows with `FULLSCREEN_GUARD_ROWS = 1`: no flicker, but diff --git a/scripts/xtask.ps1 b/scripts/xtask.ps1 index e65ecf50..27b23af5 100644 --- a/scripts/xtask.ps1 +++ b/scripts/xtask.ps1 @@ -9,9 +9,12 @@ `blog-serve` or `tui-dev` keeps that executable locked, so a second `cargo xtask` fails to replace it ("cannot move xtask.exe", os error 32). - This launcher builds the xtask binary once, then runs a unique per-invocation - copy under `target\debug\xtask-run\`. The canonical `xtask.exe` is never held - by a running command, so any number of xtask commands can run in parallel. + This launcher builds the xtask binary once into the private `target\xtask` + directory shared with the `cargo xtask` alias, then runs a unique per-invocation + copy under `target\xtask\debug\xtask-run\`. The canonical `xtask.exe` is never + held by a running command, so any number of xtask commands can run in parallel. + The private dir is passed as a --target-dir flag, never exported as + CARGO_TARGET_DIR, so it cannot leak into child builds (e.g. tui-prod's backend). `repo_root()` inside xtask is baked in at compile time (CARGO_MANIFEST_DIR), so a relocated copy still resolves the real repository. @@ -19,23 +22,26 @@ .EXAMPLE ./scripts/xtask.ps1 blog-serve ./scripts/xtask.ps1 tui-dev + C:\path\to\KQode\scripts\xtask.ps1 tui-dev-here # from another project cwd #> $ErrorActionPreference = 'Stop' $repoRoot = Split-Path -Parent $PSScriptRoot +# Private build dir shared with the `cargo xtask` alias (see .cargo/config.toml). +# Passed as a --target-dir flag, never exported as CARGO_TARGET_DIR, so it cannot +# leak into child builds such as tui-prod's `cargo build --release --bin kqode`. +$targetDir = Join-Path $repoRoot 'target\xtask' + Push-Location $repoRoot try { - cargo build -p xtask + cargo build -p xtask --target-dir $targetDir if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } finally { Pop-Location } -$targetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $repoRoot 'target' } -# A relative CARGO_TARGET_DIR is resolved by cargo against the build cwd (repo root), so anchor it there too. -if (-not [System.IO.Path]::IsPathRooted($targetDir)) { $targetDir = Join-Path $repoRoot $targetDir } $sourceExe = Join-Path $targetDir 'debug\xtask.exe' if (-not (Test-Path $sourceExe)) { Write-Error "xtask binary not found at $sourceExe" diff --git a/scripts/xtask.sh b/scripts/xtask.sh old mode 100644 new mode 100755 index e31c50c5..d1b060a4 --- a/scripts/xtask.sh +++ b/scripts/xtask.sh @@ -4,9 +4,12 @@ # `cargo xtask ` expands to `cargo run -p xtask`, which relinks the single # shared `target/debug/xtask` binary on every call. A long-running command such # as `blog-serve` or `tui-dev` keeps that binary locked (notably on Windows), so -# a second invocation fails to replace it. This launcher builds xtask once, then -# runs a unique per-invocation copy under `target/debug/xtask-run/`, leaving the -# canonical binary free so any number of xtask commands can run in parallel. +# a second invocation fails to replace it. This launcher builds xtask once into +# the private `target/xtask` dir shared with the `cargo xtask` alias, then runs a +# unique per-invocation copy under `target/xtask/debug/xtask-run/`, leaving the +# canonical binary free so any number of xtask commands can run in parallel. The +# private dir is passed as a --target-dir flag, never exported as CARGO_TARGET_DIR, +# so it cannot leak into child builds (e.g. tui-prod's backend). # # `repo_root()` inside xtask is baked in at compile time (CARGO_MANIFEST_DIR), so # a relocated copy still resolves the real repository. @@ -14,16 +17,16 @@ # Usage: # ./scripts/xtask.sh blog-serve # ./scripts/xtask.sh tui-dev +# /path/to/KQode/scripts/xtask.sh tui-dev-here # from another project cwd set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(dirname "$script_dir")" -(cd "$repo_root" && cargo build -p xtask) +# Private build dir shared with the `cargo xtask` alias (see .cargo/config.toml). +target_dir="$repo_root/target/xtask" +(cd "$repo_root" && cargo build -p xtask --target-dir "$target_dir") -target_dir="${CARGO_TARGET_DIR:-$repo_root/target}" -# A relative CARGO_TARGET_DIR is resolved by cargo against the build cwd (repo root), so anchor it there too. -[[ "$target_dir" = /* ]] || target_dir="$repo_root/$target_dir" source_exe="$target_dir/debug/xtask" if [[ ! -x "$source_exe" ]]; then echo "xtask binary not found at $source_exe" >&2 diff --git a/src/backend.rs b/src/backend.rs index c489e029..13568e0d 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -1,10 +1,13 @@ -use std::{error::Error, fmt}; +use std::{error::Error, fmt, thread}; use lsp_server::{Connection, Message, Notification, Request, Response}; +use serde::Serialize; +use crate::git; use crate::protocol::{ - BACKEND_READY_METHOD, JSON_RPC_INVALID_PARAMS, JSON_RPC_METHOD_NOT_FOUND, MessageSubmitParams, - MessageSubmitResult, RpcMethod, + BACKEND_READY_METHOD, GitStatusResult, JSON_RPC_INVALID_PARAMS, JSON_RPC_METHOD_NOT_FOUND, + MessageSubmitParams, MessageSubmitResult, PullRequestResult, RpcMethod, + SUBMIT_STATUS_NEEDS_CONFIGURATION, }; #[derive(Debug)] @@ -28,6 +31,11 @@ impl Error for BackendError {} /// transport is established and before any request is handled, so clients bound /// startup on real JSON-RPC readiness rather than the OS process-spawn event. /// +/// After the receive loop ends (stdin closed), [`io_threads.join`] blocks until +/// every `connection.sender` clone is dropped. Deferred handlers such as +/// [`spawn_git_status`] hold such clones, so an in-flight response is flushed to +/// stdout before the process exits rather than being cut off. +/// /// # Errors /// /// Returns an error when the ready signal cannot be sent, the transport threads @@ -69,15 +77,9 @@ fn run_loop(connection: Connection) -> Result<(), BackendError> { while let Ok(message) = connection.receiver.recv() { match message { Message::Request(request) => { - let response = handle_request(request); - connection - .sender - .send(Message::Response(response)) - .map_err(|error| { - BackendError::Transport(format!( - "failed to write JSON-RPC response: {error}" - )) - })?; + if let Some(response) = handle_request(request, &connection) { + send_response(&connection, response)?; + } } Message::Notification(_) => {} Message::Response(_) => { @@ -91,21 +93,93 @@ fn run_loop(connection: Connection) -> Result<(), BackendError> { Ok(()) } -fn handle_request(request: Request) -> Response { - if request.method != RpcMethod::MessageSubmit.as_str() { - return Response::new_err( +/// Writes one response over the transport, mapping a closed writer to a +/// [`BackendError::Transport`]. +fn send_response(connection: &Connection, response: Response) -> Result<(), BackendError> { + connection + .sender + .send(Message::Response(response)) + .map_err(|error| { + BackendError::Transport(format!("failed to write JSON-RPC response: {error}")) + }) +} + +/// Dispatches one JSON-RPC request. +/// +/// Returns `Some(response)` to answer synchronously, or `None` when the handler +/// owns its response and will send it later. `kqode.message.submit` answers +/// immediately with a configuration-required ack in this bootstrap slice; +/// `kqode.git.status` and `kqode.git.pullRequest` each run on a spawned thread +/// and send their response deferred, so a slow `git`/`gh` never stalls the +/// receive loop. +fn handle_request(request: Request, connection: &Connection) -> Option { + match RpcMethod::from_method(&request.method) { + Some(RpcMethod::MessageSubmit) => Some(handle_message_submit(request, connection)), + Some(RpcMethod::GitStatus) => { + spawn_deferred(request, connection, || GitStatusResult { + label: git::status_label(), + }); + None + } + Some(RpcMethod::PullRequest) => { + spawn_deferred(request, connection, || { + let pull_request = git::pull_request(); + PullRequestResult { + label: pull_request.as_ref().map(|pr| pr.label.clone()), + url: pull_request.map(|pr| pr.url), + } + }); + None + } + None => Some(Response::new_err( request.id, JSON_RPC_METHOD_NOT_FOUND, format!("unsupported method `{}`", request.method), - ); + )), } +} - match serde_json::from_value::(request.params) { - Ok(params) => Response::new_ok(request.id, MessageSubmitResult::from(params)), - Err(error) => Response::new_err( +/// Handles `kqode.message.submit`. +/// +/// No provider is wired in this bootstrap slice, so every accepted submit is +/// acknowledged with [`SUBMIT_STATUS_NEEDS_CONFIGURATION`]; it never reads +/// plaintext credentials or contacts a model. Streaming lands with the provider +/// PR. +fn handle_message_submit(request: Request, _connection: &Connection) -> Response { + // Deserialize to validate the request shape (deny_unknown_fields); the + // bootstrap handler acks without reading the prompt text or a provider. + if let Err(error) = serde_json::from_value::(request.params) { + return Response::new_err( request.id, JSON_RPC_INVALID_PARAMS, format!("invalid message submit params: {error}"), - ), + ); } + + Response::new_ok( + request.id, + MessageSubmitResult { + status: SUBMIT_STATUS_NEEDS_CONFIGURATION, + }, + ) +} + +/// Spawns a detached thread that runs `compute` (a blocking git/GitHub query) +/// and sends its serialized result as the deferred response for `request`. +/// +/// Running these off the receive loop keeps a slow or hung command from stalling +/// other requests; the thread's `sender` clone also keeps the transport alive +/// until the response is flushed, so the answer is not lost if stdin closes +/// first (see the shutdown note in [`run_stdio`]). +fn spawn_deferred(request: Request, connection: &Connection, compute: F) +where + T: Serialize, + F: FnOnce() -> T + Send + 'static, +{ + let id = request.id; + let sender = connection.sender.clone(); + thread::spawn(move || { + let response = Response::new_ok(id, compute()); + let _ = sender.send(Message::Response(response)); + }); } diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 00000000..423a42e2 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,12 @@ +//! Git and GitHub status for the current workspace. +//! +//! The backend runs in the workspace directory (the TUI spawns it with +//! `cwd = workspaceCwd`), so git/GitHub commands are invoked in the inherited +//! process cwd — no path argument is threaded through the protocol. Parsing and +//! label formatting live here in the core runtime so the headless CLI and the +//! TUI stay consistent. + +mod command; +mod status; + +pub use status::{PullRequestStatus, pull_request, status_label}; diff --git a/src/git/command.rs b/src/git/command.rs new file mode 100644 index 00000000..2d2b40d9 --- /dev/null +++ b/src/git/command.rs @@ -0,0 +1,68 @@ +use std::{ + io::Read, + path::Path, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +/// Poll interval while waiting for a bounded status child. +const COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(10); + +pub(super) fn run_stdout(program: &str, args: &[&str], timeout: Duration) -> Option { + run_stdout_in(program, args, timeout, None) +} + +fn run_stdout_in( + program: &str, + args: &[&str], + timeout: Duration, + cwd: Option<&Path>, +) -> Option { + let mut command = Command::new(program); + command + .args(args.iter().copied()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + + let mut child = command.spawn().ok()?; + let mut stdout = child.stdout.take()?; + let reader = thread::spawn(move || { + let mut output = String::new(); + stdout.read_to_string(&mut output).ok()?; + Some(output) + }); + + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + let _ = reader.join(); + return None; + } + return reader.join().ok()?; + } + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + return None; + } + Ok(None) => thread::sleep(COMMAND_POLL_INTERVAL), + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + return None; + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/git/command/tests.rs b/src/git/command/tests.rs new file mode 100644 index 00000000..90467f1e --- /dev/null +++ b/src/git/command/tests.rs @@ -0,0 +1,47 @@ +use std::{ + fs, + path::Path, + process::Command, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use super::run_stdout_in; + +#[test] +fn drains_output_larger_than_a_pipe_buffer() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let repo = std::env::temp_dir().join(format!("kqode-git-output-{unique}")); + fs::create_dir_all(&repo).unwrap(); + run_git(&repo, &["init", "--quiet"]); + run_git(&repo, &["config", "user.email", "test@example.com"]); + run_git(&repo, &["config", "user.name", "KQode Test"]); + + let expected = "x".repeat(128 * 1024); + fs::write(repo.join("large.txt"), &expected).unwrap(); + run_git(&repo, &["add", "large.txt"]); + run_git(&repo, &["commit", "--quiet", "-m", "large fixture"]); + + let output = run_stdout_in( + "git", + &["show", "HEAD:large.txt"], + Duration::from_secs(2), + Some(&repo), + ); + + fs::remove_dir_all(&repo).unwrap(); + assert_eq!(output.as_deref(), Some(expected.as_str())); +} + +fn run_git(cwd: &Path, args: &[&str]) { + assert!( + Command::new("git") + .args(args) + .current_dir(cwd) + .status() + .unwrap() + .success() + ); +} diff --git a/src/git/status.rs b/src/git/status.rs new file mode 100644 index 00000000..dd8af00f --- /dev/null +++ b/src/git/status.rs @@ -0,0 +1,203 @@ +use std::time::Duration; + +use serde::Deserialize; + +use super::command::run_stdout; + +/// Command used for local repository status. +const GIT_COMMAND: &str = "git"; +/// Command used for GitHub pull-request lookup. +const GITHUB_COMMAND: &str = "gh"; +/// Arguments that ask git for stable branch/status porcelain. +const GIT_STATUS_ARGS: &[&str] = &["status", "--porcelain=v1", "--branch"]; +/// Arguments that ask GitHub CLI for the current branch's pull-request number +/// and URL as a JSON object (e.g. `{"number":3,"url":"https://.../pull/3"}`). +const PULL_REQUEST_ARGS: &[&str] = &["pr", "view", "--json", "number,url"]; +/// Branch glyph prefixing every status label. +const GIT_BRANCH_ICON: &str = "⎇"; +/// Flag appended when the worktree has unstaged changes. +const UNSTAGED_CHANGE_FLAG: &str = "*"; +/// Flag appended when the index has staged changes. +const STAGED_CHANGE_FLAG: &str = "+"; +/// Flag appended when the worktree has untracked files. +const UNTRACKED_CHANGE_FLAG: &str = "%"; +/// Prefix of the porcelain `--branch` header line. +const STATUS_BRANCH_PREFIX: &str = "## "; +/// Separator between the local branch and its upstream in the header line. +const STATUS_UPSTREAM_SEPARATOR: &str = "..."; +/// Header text for a repository with no commits yet (`## No commits yet on X`). +const NO_COMMITS_BRANCH_PREFIX: &str = "No commits yet on "; +/// Header text emitted for a detached HEAD. +const DETACHED_HEAD_STATUS: &str = "HEAD (no branch)"; +/// Prefix for a pull-request status segment. +const PULL_REQUEST_LABEL_PREFIX: &str = "#"; +/// Ceiling for the local `git status` call before it is treated as unavailable. +/// This is a fast, offline command, so a tight budget is fine. +const GIT_STATUS_TIMEOUT: Duration = Duration::from_secs(2); +/// Ceiling for the `gh pr view` call before it is treated as unavailable. +/// +/// Unlike `git status`, this performs a network round-trip to the GitHub API, +/// whose latency is variable and regularly exceeds the local-git budget. It is +/// therefore given a larger timeout so a slow-but-succeeding lookup is not +/// killed, which would drop the PR label from the status line. +const PULL_REQUEST_TIMEOUT: Duration = Duration::from_secs(8); + +/// The current branch's pull request, formatted for display: a `#N` `label` and +/// the PR's web `url`. Returned to clients so the TUI can render a hyperlink. +#[derive(Debug, Eq, PartialEq)] +pub struct PullRequestStatus { + pub label: String, + pub url: String, +} + +/// The current branch's GitHub pull request, as reported by `gh pr view`. +#[derive(Debug, Deserialize)] +struct PullRequest { + number: u32, + url: String, +} + +/// Parsed porcelain status of the workspace worktree. +#[derive(Debug, Eq, PartialEq)] +struct GitStatus { + branch: String, + has_unstaged_changes: bool, + has_staged_changes: bool, + has_untracked_changes: bool, +} + +/// Returns the formatted working-tree label (e.g. `⎇ main*`), or `None` when the +/// directory is not a git repository or `git` is unavailable. +/// +/// This is a fast, local query with no network I/O, so the TUI refreshes it +/// after every turn. Blocking; call it off the backend's request loop (e.g. on a +/// thread). +#[must_use] +pub fn status_label() -> Option { + Some(format_label(&read_status()?)) +} + +/// Returns the current branch's GitHub pull request as a display label and URL, +/// or `None` when there is no PR (or `gh` is unavailable). +/// +/// Unlike [`status_label`], this performs a network round-trip via `gh`, so the +/// TUI fetches it once at session bootstrap rather than on every turn. Blocking; +/// call it off the backend's request loop (e.g. on a thread). +#[must_use] +pub fn pull_request() -> Option { + let pull_request = read_pull_request()?; + Some(PullRequestStatus { + label: format_pull_request_label(pull_request.number), + url: pull_request.url, + }) +} + +fn read_status() -> Option { + let porcelain = run_stdout(GIT_COMMAND, GIT_STATUS_ARGS, GIT_STATUS_TIMEOUT)?; + parse_status(&porcelain) +} + +fn read_pull_request() -> Option { + let stdout = run_stdout(GITHUB_COMMAND, PULL_REQUEST_ARGS, PULL_REQUEST_TIMEOUT)?; + parse_pull_request(&stdout) +} + +fn parse_status(porcelain: &str) -> Option { + let lines: Vec<&str> = porcelain.lines().filter(|line| !line.is_empty()).collect(); + let branch_line = lines + .iter() + .find(|line| line.starts_with(STATUS_BRANCH_PREFIX))?; + let branch = parse_branch_name(branch_line); + + let mut status = GitStatus { + branch, + has_unstaged_changes: false, + has_staged_changes: false, + has_untracked_changes: false, + }; + + for line in &lines { + if line.starts_with(STATUS_BRANCH_PREFIX) { + continue; + } + if line.starts_with("??") { + status.has_untracked_changes = true; + continue; + } + + let code = line.as_bytes(); + if let Some(&staged) = code.first() + && is_change_flag(staged) + { + status.has_staged_changes = true; + } + if let Some(&unstaged) = code.get(1) + && is_change_flag(unstaged) + { + status.has_unstaged_changes = true; + } + } + + Some(status) +} + +/// Whether a porcelain XY status byte marks a tracked change — i.e. not clean +/// (` `), untracked (`?`), or ignored (`!`). +fn is_change_flag(code: u8) -> bool { + code != b' ' && code != b'?' && code != b'!' +} + +/// Extracts the branch name from the porcelain `## ...` header line. +fn parse_branch_name(branch_line: &str) -> String { + let branch_status = &branch_line[STATUS_BRANCH_PREFIX.len()..]; + + if let Some(rest) = branch_status.strip_prefix(NO_COMMITS_BRANCH_PREFIX) { + return rest.to_owned(); + } + if branch_status == DETACHED_HEAD_STATUS { + return "HEAD".to_owned(); + } + + let without_upstream = branch_status + .split(STATUS_UPSTREAM_SEPARATOR) + .next() + .unwrap_or(branch_status); + without_upstream + .split(" [") + .next() + .unwrap_or(without_upstream) + .to_owned() +} + +fn parse_pull_request(stdout: &str) -> Option { + serde_json::from_str(stdout.trim()).ok() +} + +fn format_label(status: &GitStatus) -> String { + format!( + "{GIT_BRANCH_ICON} {}{}", + status.branch, + format_flags(status) + ) +} + +fn format_flags(status: &GitStatus) -> String { + let mut flags = String::new(); + if status.has_unstaged_changes { + flags.push_str(UNSTAGED_CHANGE_FLAG); + } + if status.has_staged_changes { + flags.push_str(STAGED_CHANGE_FLAG); + } + if status.has_untracked_changes { + flags.push_str(UNTRACKED_CHANGE_FLAG); + } + flags +} + +fn format_pull_request_label(number: u32) -> String { + format!("{PULL_REQUEST_LABEL_PREFIX}{number}") +} + +#[cfg(test)] +mod tests; diff --git a/src/git/status/tests.rs b/src/git/status/tests.rs new file mode 100644 index 00000000..d1f95953 --- /dev/null +++ b/src/git/status/tests.rs @@ -0,0 +1,109 @@ +use super::{ + GIT_STATUS_TIMEOUT, PULL_REQUEST_TIMEOUT, format_label, format_pull_request_label, + parse_pull_request, parse_status, +}; + +fn parse_status_label(porcelain: &str) -> Option { + parse_status(porcelain).map(|status| format_label(&status)) +} + +#[test] +fn formats_branch_with_staged_unstaged_and_untracked_flags() { + let label = parse_status_label( + &[ + "## feat/first-ink-tui-jsonrpc-backend...origin/feat/first-ink-tui-jsonrpc-backend", + " M tui/src/App.tsx", + "A src/git.rs", + "?? src/git/tests.rs", + ] + .join("\n"), + ); + + assert_eq!( + label.as_deref(), + Some("⎇ feat/first-ink-tui-jsonrpc-backend*+%") + ); +} + +#[test] +fn formats_a_clean_branch_without_flags() { + assert_eq!( + parse_status_label("## main...origin/main\n").as_deref(), + Some("⎇ main") + ); +} + +#[test] +fn strips_upstream_and_ahead_behind_from_the_branch_name() { + assert_eq!( + parse_status_label("## main...origin/main [ahead 1, behind 2]\n").as_deref(), + Some("⎇ main") + ); +} + +#[test] +fn labels_a_detached_head() { + assert_eq!( + parse_status_label("## HEAD (no branch)\n").as_deref(), + Some("⎇ HEAD") + ); +} + +#[test] +fn labels_a_repository_with_no_commits_yet() { + assert_eq!( + parse_status_label("## No commits yet on main\n").as_deref(), + Some("⎇ main") + ); +} + +#[test] +fn separates_staged_from_unstaged_changes() { + let staged_only = parse_status("## main\nM src/git.rs\n").unwrap(); + assert!(staged_only.has_staged_changes); + assert!(!staged_only.has_unstaged_changes); + assert!(!staged_only.has_untracked_changes); + + let unstaged_only = parse_status("## main\n M src/git.rs\n").unwrap(); + assert!(unstaged_only.has_unstaged_changes); + assert!(!unstaged_only.has_staged_changes); +} + +#[test] +fn treats_untracked_entries_as_untracked_only() { + let status = parse_status("## main\n?? src/new.rs\n").unwrap(); + assert!(status.has_untracked_changes); + assert!(!status.has_staged_changes); + assert!(!status.has_unstaged_changes); + assert_eq!(format_label(&status), "⎇ main%"); +} + +#[test] +fn parses_pull_request_number_and_url_and_formats_the_label() { + let pull_request = + parse_pull_request("{\"number\":3,\"url\":\"https://github.com/o/r/pull/3\"}\n").unwrap(); + assert_eq!(pull_request.number, 3); + assert_eq!(pull_request.url, "https://github.com/o/r/pull/3"); + assert_eq!(format_pull_request_label(pull_request.number), "#3"); +} + +#[test] +fn ignores_unparseable_pull_request_output() { + assert!(parse_pull_request("").is_none()); + assert!(parse_pull_request("not json").is_none()); +} + +#[test] +fn gives_the_networked_pull_request_lookup_at_least_the_local_git_budget() { + // `gh pr view` performs a network round-trip to the GitHub API, so its + // timeout must not be smaller than the fast, offline `git status` budget; + // otherwise a slow-but-succeeding lookup is killed and the PR label and + // hyperlink silently disappear from the status line. + assert!(PULL_REQUEST_TIMEOUT >= GIT_STATUS_TIMEOUT); +} + +#[test] +fn returns_none_without_a_branch_header_line() { + assert!(parse_status_label(" M src/git.rs\n").is_none()); + assert!(parse_status_label("").is_none()); +} diff --git a/src/lib.rs b/src/lib.rs index 87fac3f6..49c47ffb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ pub mod backend; pub mod build_env; +pub mod git; pub mod protocol; diff --git a/src/protocol.rs b/src/protocol.rs index acdc186e..d9ca40cd 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -3,9 +3,6 @@ use serde::{Deserialize, Serialize}; /// Hidden argument that starts the internal JSON-RPC backend loop. pub const BACKEND_MODE_ARG: &str = "--__kqode-json-rpc-backend"; -/// ACK text returned by the first-slice backend proof. -pub const ACK_MESSAGE: &str = "ACK: message received"; - /// JSON-RPC notification the backend emits exactly once, immediately after its /// stdio transport is live and before it handles any request. /// @@ -16,6 +13,9 @@ pub const ACK_MESSAGE: &str = "ACK: message received"; /// `tui/src/contracts/backend/messages.ts` (`BACKEND_READY_METHOD`). pub const BACKEND_READY_METHOD: &str = "kqode.backend.ready"; +/// `status` value when a submit cannot run because no API key is configured. +pub const SUBMIT_STATUS_NEEDS_CONFIGURATION: &str = "needsConfiguration"; + /// JSON-RPC code for method lookup failures. pub const JSON_RPC_METHOD_NOT_FOUND: i32 = -32601; @@ -26,36 +26,74 @@ pub const JSON_RPC_INVALID_PARAMS: i32 = -32602; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum RpcMethod { MessageSubmit, + GitStatus, + PullRequest, } impl RpcMethod { pub const fn as_str(self) -> &'static str { match self { Self::MessageSubmit => "kqode.message.submit", + Self::GitStatus => "kqode.git.status", + Self::PullRequest => "kqode.git.pullRequest", } } + + /// Resolves a wire method name to its [`RpcMethod`], or `None` when the + /// backend does not implement it (yielding a method-not-found response). + #[must_use] + pub fn from_method(method: &str) -> Option { + [Self::MessageSubmit, Self::GitStatus, Self::PullRequest] + .into_iter() + .find(|candidate| candidate.as_str() == method) + } } /// Params for `kqode.message.submit`. +/// +/// Carries only the prompt `text` in this bootstrap slice. Kept in lockstep with +/// the TypeScript `MessageSubmitParams`. #[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct MessageSubmitParams { pub text: String, } -/// Result for `kqode.message.submit`. +/// Result for `kqode.message.submit`: an immediate ack. +/// +/// `status` is [`SUBMIT_STATUS_NEEDS_CONFIGURATION`] in this bootstrap slice +/// because no provider is wired yet. #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct MessageSubmitResult { - pub message: &'static str, - pub received_text: String, + pub status: &'static str, } -impl From for MessageSubmitResult { - fn from(params: MessageSubmitParams) -> Self { - Self { - message: ACK_MESSAGE, - received_text: params.text, - } - } +/// Result for `kqode.git.status`: the formatted working-tree label (e.g. +/// `⎇ main*`), or `null` when the workspace is not a git repository (or `git` +/// could not be queried). +/// +/// This is a fast, local query refreshed after every turn; the branch's pull +/// request is a separate, network-bound `kqode.git.pullRequest` call +/// ([`PullRequestResult`]) fetched once per session. Kept in lockstep with the +/// TypeScript `GitStatusResult`. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitStatusResult { + pub label: Option, +} + +/// Result for `kqode.git.pullRequest`: the current branch's GitHub PR as a +/// display `label` (e.g. `#3`) and web `url`, or both `null` when there is no PR +/// (or `gh` could not be queried). +/// +/// Fetched once at session bootstrap because a branch's PR is static for the +/// session, and kept separate from [`GitStatusResult`] so the per-turn status +/// refresh never incurs the `gh` network round-trip. Kept in lockstep with the +/// TypeScript `PullRequestResult`. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PullRequestResult { + pub label: Option, + pub url: Option, } diff --git a/tests/git_status.rs b/tests/git_status.rs new file mode 100644 index 00000000..de4f2024 --- /dev/null +++ b/tests/git_status.rs @@ -0,0 +1,68 @@ +#[path = "common/rpc.rs"] +mod rpc; + +use kqode::protocol::RpcMethod; +use serde_json::json; + +use rpc::{backend_output, request_frame, response_frames}; + +#[test] +fn git_status_returns_a_formatted_label_for_the_workspace() { + // The integration harness spawns the backend with the crate root as its cwd, + // which is the KQode git repository, so the status query resolves a branch. + // Branch name and dirty flags are non-deterministic, so assert only the + // stable shape: a non-null label beginning with the branch glyph. + let output = backend_output(&request_frame( + 1, + RpcMethod::GitStatus.as_str(), + json!(null), + )); + + assert!(output.status.success(), "{output:?}"); + + let frames = response_frames(&output.stdout); + assert_eq!( + frames.len(), + 1, + "expected one git status response after the ready notification: {frames:?}" + ); + assert_eq!(frames[0]["id"], 1); + + let label = frames[0]["result"]["label"] + .as_str() + .expect("git status label is a string inside the KQode repository"); + assert!( + label.starts_with("⎇ "), + "label should start with the branch glyph: {label}" + ); +} + +#[test] +fn pull_request_returns_a_nullable_label_and_url_for_the_workspace() { + // The branch/PR state of the test checkout is non-deterministic (and `gh` + // may be unavailable or offline in CI), so assert only the stable wire + // shape: `label` and `url` are each either null or a string. + let output = backend_output(&request_frame( + 1, + RpcMethod::PullRequest.as_str(), + json!(null), + )); + + assert!(output.status.success(), "{output:?}"); + + let frames = response_frames(&output.stdout); + assert_eq!( + frames.len(), + 1, + "expected one pull request response after the ready notification: {frames:?}" + ); + assert_eq!(frames[0]["id"], 1); + assert!( + frames[0]["result"]["label"].is_null() || frames[0]["result"]["label"].is_string(), + "pull request label should be null or a string" + ); + assert!( + frames[0]["result"]["url"].is_null() || frames[0]["result"]["url"].is_string(), + "pull request url should be null or a string" + ); +} diff --git a/tests/message_submit.rs b/tests/message_submit.rs index 018a6d72..51b698aa 100644 --- a/tests/message_submit.rs +++ b/tests/message_submit.rs @@ -1,7 +1,7 @@ #[path = "common/rpc.rs"] mod rpc; -use kqode::protocol::{ACK_MESSAGE, BACKEND_READY_METHOD, RpcMethod}; +use kqode::protocol::{BACKEND_READY_METHOD, RpcMethod, SUBMIT_STATUS_NEEDS_CONFIGURATION}; use serde_json::json; use rpc::{backend_output, parse_stdout_frames, request_frame, response_frames}; @@ -29,7 +29,9 @@ fn backend_announces_ready_before_handling_requests() { } #[test] -fn message_submit_returns_ack_with_received_text() { +fn message_submit_without_key_returns_needs_configuration() { + // With no API key (forced by the harness), submit must return an immediate + // ack routing the user to configuration and must not stream any tokens. let output = backend_output(&request_frame( 1, RpcMethod::MessageSubmit.as_str(), @@ -37,21 +39,30 @@ fn message_submit_returns_ack_with_received_text() { )); assert!(output.status.success(), "{output:?}"); + + let all_frames = parse_stdout_frames(&output.stdout); + assert_eq!( + all_frames.len(), + 2, + "expected only the ready notification and one ack response: {all_frames:?}" + ); + let frames = response_frames(&output.stdout); assert_eq!(frames[0]["id"], 1); - assert_eq!(frames[0]["result"]["message"], ACK_MESSAGE); - assert_eq!(frames[0]["result"]["receivedText"], "hello from tui"); + assert_eq!( + frames[0]["result"]["status"], + SUBMIT_STATUS_NEEDS_CONFIGURATION + ); } #[test] -fn message_submit_preserves_unicode_newlines_and_empty_text() { - let text = " hello\nfrom tui 🌱 "; +fn message_submit_answers_each_request_with_needs_configuration() { let output = backend_output( &[ request_frame( 1, RpcMethod::MessageSubmit.as_str(), - json!({ "text": text }), + json!({ "text": "first" }), ), request_frame(2, RpcMethod::MessageSubmit.as_str(), json!({ "text": "" })), ] @@ -61,7 +72,13 @@ fn message_submit_preserves_unicode_newlines_and_empty_text() { assert!(output.status.success(), "{output:?}"); let frames = response_frames(&output.stdout); assert_eq!(frames[0]["id"], 1); + assert_eq!( + frames[0]["result"]["status"], + SUBMIT_STATUS_NEEDS_CONFIGURATION + ); assert_eq!(frames[1]["id"], 2); - assert_eq!(frames[0]["result"]["receivedText"], text); - assert_eq!(frames[1]["result"]["receivedText"], ""); + assert_eq!( + frames[1]["result"]["status"], + SUBMIT_STATUS_NEEDS_CONFIGURATION + ); } diff --git a/tui/AGENTS.md b/tui/AGENTS.md index d8e2f7d3..68773be7 100644 --- a/tui/AGENTS.md +++ b/tui/AGENTS.md @@ -6,14 +6,36 @@ Split TUI source files into focused modules before they grow beyond 200 lines. P Prefer Jotai atoms/selectors over drilling HomeScreen config or shared TUI state through multiple component layers. When a leaf component is specific to the stateful TUI shell, read shared screen state from atoms near that leaf instead of threading config props through intermediate wrappers. +Keep dependencies flowing in one direction: + +```text +constants / contracts / theme + -> libs / backend + -> state / hooks + -> components + -> App / bootstrap / cli +``` + +Modules in the same layer may depend on one another only when the resulting graph stays acyclic. Keep architecture boundaries executable through the Vitest guard in `src/__tests__/architecture.test.ts`; existing exceptions belong in its explicit baselines and each migration should reduce those baselines. + +Files under `src/components/` may export React components only. Move hooks to `src/hooks/`, pure functions and shared types to domain-focused `src/libs/` modules, and stateful atoms/selectors to `src/state/`. Do not expose component internals only for tests: verify simple logic through component behavior, or move substantial pure logic to `src/libs/` so production code and tests share the same public function. + +Use `src/constants/` only for dependency-free immutable static data such as strings, numbers, readonly arrays/objects, and enum-like values. Constants modules must not import from other project layers, create mutable collections such as `Set` or `Map`, or contain runtime calculation functions. + +Organize `src/state/` by domain directories and split files within a domain by responsibility, not one atom per file. Keep cohesive base atoms, derived selectors, and write actions together until a file approaches roughly 200 lines or mixes distinct responsibilities; then split into focused files such as `state.ts`, `editing.ts`, `cursor.ts`, and `scroll.ts`. State modules may export atoms/selectors and state-specific types only. Move reusable deterministic calculations to `src/libs/` and exported static data to `src/constants/`; helpers that directly use Jotai `get`/`set` or reference atoms remain in the state domain. + ## Terminal layout Keep the cwd row, prompt composer, and command/status row stuck to the bottom of the terminal for every shell window size. Keep exactly one blank separator row between the body area and cwd row, but do not let body, preview, or header content push gaps between the composer and the command/status row. -The render canvas fills the full terminal height (`FULLSCREEN_GUARD_ROWS = 0` in `src/state/ui/dimensions.ts`) so no blank row sits at the bottom. Filling the terminal *exactly* makes Ink treat every frame as fullscreen: it clears and repaints the whole screen per keystroke on terminals that don't coalesce the clear (WezTerm blinks; Windows Terminal does not) and omits its trailing newline, shifting the cursor baseline up one row (compensated by `INK_CURSOR_ROW_ORIGIN_OFFSET = 1` in `src/constants/ui.ts`). Raise `FULLSCREEN_GUARD_ROWS` back to `1` to restore the incremental, non-fullscreen path. Keep `render()` on `incrementalRendering: true` (`src/cli/kqodeCli.tsx`), and read "bottom" above as the bottom of this canvas. +The render canvas fills the full terminal height except in WezTerm, where `WEZTERM_FULLSCREEN_GUARD_ROWS = 1` reserves one bottom row. Filling WezTerm *exactly* makes Ink treat every frame as fullscreen and visibly clear and repaint the whole screen per keystroke; the guard keeps it on Ink's incremental path while other terminals retain the edge-to-edge layout. The checked-in Ink patch under `tui/patches/` still makes cursor positioning use the actual final visible row for terminals that render fullscreen without a trailing newline; keep its regression test when upgrading Ink. Keep `render()` on `incrementalRendering: true` (`src/cli/kqodeCli.tsx`), and read "bottom" above as the bottom of this canvas. + +Meaningful non-body chrome must stay out of the terminal's final cell because several terminals clip it. Composer, cwd, status, click mapping, and caret/scroll calculations share `chromeColumnsAtom`; only `BodyPane` uses the physical final column for scrollbar chrome. + +The prompt composer reserves one background-only column of internal right padding in addition to the terminal final-cell gutter. Its rendering, soft wrapping, caret movement, clicks, and scrolling must all use `resolveComposerInputColumns()` so the visual width and interaction geometry cannot drift. The composer starts as one row and grows only when the current prompt text needs soft wrapping or validation feedback. When changing composer, body, header, or resize behavior, preserve this dynamic row shifting so long prompts expand the composer while short prompts keep the command/status row directly underneath. -The UI also fills the terminal's *final column* for a tight right edge. Ink erases to end-of-line after each row, and some terminals (e.g. WezTerm) drop a glyph rendered into the last column — Windows Terminal renders it fine, so the status bar reaches the edge (``, no `paddingRight`). If a terminal clips the right-aligned model label (e.g. `GPT-5.5` → `GPT-5.`), restore `paddingRight={1}` on the status bar box. +Terminal.app uses continuous `─` composer borders, lets composer text rows blend into the body background, and renders user messages as borderless filled rows because its font rasterizer leaves visible side-bearing gaps between block-element glyphs. Composer decorative lines may extend through the final-cell gutter while content geometry remains guarded. Other terminals retain inward `▄`/`▀` half-block borders and distinct surface backgrounds. Keep this terminal branch isolated in `src/libs/terminal/surfaceBorder.ts`. -The prompt composer starts as one row and grows only when the current prompt text needs soft wrapping or validation feedback. When changing composer, body, header, or resize behavior, preserve this dynamic row shifting so long prompts expand the composer while short prompts keep the command/status row directly underneath. +Ink keyboard handling is patched for xterm `modifyOtherKeys` Enter sequences and the TUI opts into Kitty keyboard auto-detection. Ghostty and other capable terminals should distinguish Shift/Alt/Ctrl+Enter; Terminal.app cannot distinguish Shift+Enter from plain Enter, so `\` then Enter remains the documented no-configuration newline fallback. Prompt cursor placement is manually resolved with Ink's cursor API, so it can drift when vertical layout math changes. When changing body height, spacer rows, wrapping rows, validation rows, background rows, cwd/composer/status placement, or `cursorTop` math, explicitly verify the cursor still lands on the active composer text row rather than one row above or on the cwd row. diff --git a/tui/bun.lock b/tui/bun.lock index 779c913a..968f2897 100644 --- a/tui/bun.lock +++ b/tui/bun.lock @@ -9,6 +9,7 @@ "ink": "^7.1.0", "jotai": "^2.20.1", "react": "^19.2.7", + "string-width": "^8.2.0", "tree-kill": "^1.2.2", "vscode-jsonrpc": "^9.0.0", }, @@ -22,307 +23,310 @@ }, }, }, + "patchedDependencies": { + "ink@7.1.0": "patches/ink@7.1.0.patch", + }, "packages": { - "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "https://packagefeedproxy.microsoft.io/npm/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], - "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "https://packagefeedproxy.microsoft.io/npm/@emnapi/core/-/core-1.11.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "https://packagefeedproxy.microsoft.io/npm/@emnapi/runtime/-/runtime-1.11.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "https://packagefeedproxy.microsoft.io/npm/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/android-arm/-/android-arm-0.28.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/android-x64/-/android-x64-0.28.1.tgz", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "https://packagefeedproxy.microsoft.io/npm/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://packagefeedproxy.microsoft.io/npm/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "https://packagefeedproxy.microsoft.io/npm/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="], + "@oxc-project/types": ["@oxc-project/types@0.137.0", "https://packagefeedproxy.microsoft.io/npm/@oxc-project/types/-/types-0.137.0.tgz", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.3", "", { "os": "android", "cpu": "arm64" }, "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.3", "https://packagefeedproxy.microsoft.io/npm/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", { "os": "android", "cpu": "arm64" }, "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.3", "https://packagefeedproxy.microsoft.io/npm/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.3", "https://packagefeedproxy.microsoft.io/npm/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.3", "https://packagefeedproxy.microsoft.io/npm/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.3", "", { "os": "linux", "cpu": "arm" }, "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.3", "https://packagefeedproxy.microsoft.io/npm/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", { "os": "linux", "cpu": "arm" }, "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.3", "https://packagefeedproxy.microsoft.io/npm/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.3", "https://packagefeedproxy.microsoft.io/npm/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.3", "https://packagefeedproxy.microsoft.io/npm/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.3", "https://packagefeedproxy.microsoft.io/npm/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.3", "https://packagefeedproxy.microsoft.io/npm/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.3", "https://packagefeedproxy.microsoft.io/npm/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.3", "", { "os": "none", "cpu": "arm64" }, "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.3", "https://packagefeedproxy.microsoft.io/npm/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", { "os": "none", "cpu": "arm64" }, "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.3", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.3", "https://packagefeedproxy.microsoft.io/npm/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.3", "https://packagefeedproxy.microsoft.io/npm/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.3", "https://packagefeedproxy.microsoft.io/npm/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", { "os": "win32", "cpu": "x64" }, "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "https://packagefeedproxy.microsoft.io/npm/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://packagefeedproxy.microsoft.io/npm/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://packagefeedproxy.microsoft.io/npm/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/chai": ["@types/chai@5.2.3", "https://packagefeedproxy.microsoft.io/npm/@types/chai/-/chai-5.2.3.tgz", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "https://packagefeedproxy.microsoft.io/npm/@types/deep-eql/-/deep-eql-4.0.2.tgz", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/estree": ["@types/estree@1.0.9", "https://packagefeedproxy.microsoft.io/npm/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - "@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], + "@types/node": ["@types/node@24.13.2", "https://packagefeedproxy.microsoft.io/npm/@types/node/-/node-24.13.2.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], - "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + "@types/react": ["@types/react@19.2.17", "https://packagefeedproxy.microsoft.io/npm/@types/react/-/react-19.2.17.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], - "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + "@vitest/expect": ["@vitest/expect@4.1.9", "https://packagefeedproxy.microsoft.io/npm/@vitest/expect/-/expect-4.1.9.tgz", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + "@vitest/mocker": ["@vitest/mocker@4.1.9", "https://packagefeedproxy.microsoft.io/npm/@vitest/mocker/-/mocker-4.1.9.tgz", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "https://packagefeedproxy.microsoft.io/npm/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - "@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + "@vitest/runner": ["@vitest/runner@4.1.9", "https://packagefeedproxy.microsoft.io/npm/@vitest/runner/-/runner-4.1.9.tgz", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "https://packagefeedproxy.microsoft.io/npm/@vitest/snapshot/-/snapshot-4.1.9.tgz", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + "@vitest/spy": ["@vitest/spy@4.1.9", "https://packagefeedproxy.microsoft.io/npm/@vitest/spy/-/spy-4.1.9.tgz", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + "@vitest/utils": ["@vitest/utils@4.1.9", "https://packagefeedproxy.microsoft.io/npm/@vitest/utils/-/utils-4.1.9.tgz", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "https://packagefeedproxy.microsoft.io/npm/ansi-escapes/-/ansi-escapes-7.3.0.tgz", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-regex": ["ansi-regex@6.2.2", "https://packagefeedproxy.microsoft.io/npm/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "ansi-styles": ["ansi-styles@6.2.3", "https://packagefeedproxy.microsoft.io/npm/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "assertion-error": ["assertion-error@2.0.1", "https://packagefeedproxy.microsoft.io/npm/assertion-error/-/assertion-error-2.0.1.tgz", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "auto-bind": ["auto-bind@5.0.1", "https://packagefeedproxy.microsoft.io/npm/auto-bind/-/auto-bind-5.0.1.tgz", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chai": ["chai@6.2.2", "https://packagefeedproxy.microsoft.io/npm/chai/-/chai-6.2.2.tgz", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chalk": ["chalk@5.6.2", "https://packagefeedproxy.microsoft.io/npm/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], + "citty": ["citty@0.2.2", "https://packagefeedproxy.microsoft.io/npm/citty/-/citty-0.2.2.tgz", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], - "cli-boxes": ["cli-boxes@4.0.1", "", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="], + "cli-boxes": ["cli-boxes@4.0.1", "https://packagefeedproxy.microsoft.io/npm/cli-boxes/-/cli-boxes-4.0.1.tgz", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="], - "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + "cli-cursor": ["cli-cursor@4.0.0", "https://packagefeedproxy.microsoft.io/npm/cli-cursor/-/cli-cursor-4.0.0.tgz", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], - "cli-truncate": ["cli-truncate@6.0.1", "", { "dependencies": { "slice-ansi": "^9.0.0", "string-width": "^8.2.0" } }, "sha512-2FPVnc3JxdRLONB/9edO1RwuUFFPJ3U2c6XvyccEhjqV5xw6mS22aH27OFdD1u4IYQOEUzXsT6ZU06d1VCSu+Q=="], + "cli-truncate": ["cli-truncate@6.0.1", "https://packagefeedproxy.microsoft.io/npm/cli-truncate/-/cli-truncate-6.0.1.tgz", { "dependencies": { "slice-ansi": "^9.0.0", "string-width": "^8.2.0" } }, "sha512-2FPVnc3JxdRLONB/9edO1RwuUFFPJ3U2c6XvyccEhjqV5xw6mS22aH27OFdD1u4IYQOEUzXsT6ZU06d1VCSu+Q=="], - "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "code-excerpt": ["code-excerpt@4.0.0", "https://packagefeedproxy.microsoft.io/npm/code-excerpt/-/code-excerpt-4.0.0.tgz", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "convert-source-map": ["convert-source-map@2.0.0", "https://packagefeedproxy.microsoft.io/npm/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + "convert-to-spaces": ["convert-to-spaces@2.0.1", "https://packagefeedproxy.microsoft.io/npm/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "csstype": ["csstype@3.2.3", "https://packagefeedproxy.microsoft.io/npm/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-libc": ["detect-libc@2.1.2", "https://packagefeedproxy.microsoft.io/npm/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "environment": ["environment@1.1.0", "https://packagefeedproxy.microsoft.io/npm/environment/-/environment-1.1.0.tgz", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], - "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + "es-module-lexer": ["es-module-lexer@2.1.0", "https://packagefeedproxy.microsoft.io/npm/es-module-lexer/-/es-module-lexer-2.1.0.tgz", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], - "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], + "es-toolkit": ["es-toolkit@1.49.0", "https://packagefeedproxy.microsoft.io/npm/es-toolkit/-/es-toolkit-1.49.0.tgz", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], - "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "esbuild": ["esbuild@0.28.1", "https://packagefeedproxy.microsoft.io/npm/esbuild/-/esbuild-0.28.1.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "escape-string-regexp": ["escape-string-regexp@2.0.0", "https://packagefeedproxy.microsoft.io/npm/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "estree-walker": ["estree-walker@3.0.3", "https://packagefeedproxy.microsoft.io/npm/estree-walker/-/estree-walker-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "expect-type": ["expect-type@1.4.0", "https://packagefeedproxy.microsoft.io/npm/expect-type/-/expect-type-1.4.0.tgz", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fdir": ["fdir@6.5.0", "https://packagefeedproxy.microsoft.io/npm/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.3", "https://packagefeedproxy.microsoft.io/npm/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "https://packagefeedproxy.microsoft.io/npm/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], - "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "indent-string": ["indent-string@5.0.0", "https://packagefeedproxy.microsoft.io/npm/indent-string/-/indent-string-5.0.0.tgz", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], - "ink": ["ink@7.1.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.3", "auto-bind": "^5.0.1", "chalk": "^5.6.2", "cli-boxes": "^4.0.1", "cli-cursor": "^4.0.0", "cli-truncate": "^6.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.45.1", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^9.0.0", "stack-utils": "^2.0.6", "string-width": "^8.2.0", "terminal-size": "^4.0.1", "type-fest": "^5.5.0", "widest-line": "^6.0.0", "wrap-ansi": "^10.0.0", "ws": "^8.20.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.2.0", "react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-VWE6/yeLtFCJBNLflyI2OSylyXK1Rc24LuXup8Qt+icwkmmycFNdbn8IkSp6Frc0h1iA0NOvvi1ajW44U/w3Qg=="], + "ink": ["ink@7.1.0", "https://packagefeedproxy.microsoft.io/npm/ink/-/ink-7.1.0.tgz", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.3", "auto-bind": "^5.0.1", "chalk": "^5.6.2", "cli-boxes": "^4.0.1", "cli-cursor": "^4.0.0", "cli-truncate": "^6.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.45.1", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^9.0.0", "stack-utils": "^2.0.6", "string-width": "^8.2.0", "terminal-size": "^4.0.1", "type-fest": "^5.5.0", "widest-line": "^6.0.0", "wrap-ansi": "^10.0.0", "ws": "^8.20.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.2.0", "react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-VWE6/yeLtFCJBNLflyI2OSylyXK1Rc24LuXup8Qt+icwkmmycFNdbn8IkSp6Frc0h1iA0NOvvi1ajW44U/w3Qg=="], - "ink-testing-library": ["ink-testing-library@4.0.0", "", { "peerDependencies": { "@types/react": ">=18.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q=="], + "ink-testing-library": ["ink-testing-library@4.0.0", "https://packagefeedproxy.microsoft.io/npm/ink-testing-library/-/ink-testing-library-4.0.0.tgz", { "peerDependencies": { "@types/react": ">=18.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "https://packagefeedproxy.microsoft.io/npm/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], + "is-in-ci": ["is-in-ci@2.0.0", "https://packagefeedproxy.microsoft.io/npm/is-in-ci/-/is-in-ci-2.0.0.tgz", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], - "jotai": ["jotai@2.20.1", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg=="], + "jotai": ["jotai@2.20.1", "https://packagefeedproxy.microsoft.io/npm/jotai/-/jotai-2.20.1.tgz", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg=="], - "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + "lightningcss": ["lightningcss@1.32.0", "https://packagefeedproxy.microsoft.io/npm/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://packagefeedproxy.microsoft.io/npm/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "https://packagefeedproxy.microsoft.io/npm/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "https://packagefeedproxy.microsoft.io/npm/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "https://packagefeedproxy.microsoft.io/npm/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "https://packagefeedproxy.microsoft.io/npm/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "https://packagefeedproxy.microsoft.io/npm/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "https://packagefeedproxy.microsoft.io/npm/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "https://packagefeedproxy.microsoft.io/npm/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "https://packagefeedproxy.microsoft.io/npm/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "https://packagefeedproxy.microsoft.io/npm/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://packagefeedproxy.microsoft.io/npm/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "magic-string": ["magic-string@0.30.21", "https://packagefeedproxy.microsoft.io/npm/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "mimic-fn": ["mimic-fn@2.1.0", "https://packagefeedproxy.microsoft.io/npm/mimic-fn/-/mimic-fn-2.1.0.tgz", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "nanoid": ["nanoid@3.3.15", "https://packagefeedproxy.microsoft.io/npm/nanoid/-/nanoid-3.3.15.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], - "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + "obug": ["obug@2.1.3", "https://packagefeedproxy.microsoft.io/npm/obug/-/obug-2.1.3.tgz", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "onetime": ["onetime@5.1.2", "https://packagefeedproxy.microsoft.io/npm/onetime/-/onetime-5.1.2.tgz", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + "patch-console": ["patch-console@2.0.0", "https://packagefeedproxy.microsoft.io/npm/patch-console/-/patch-console-2.0.0.tgz", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pathe": ["pathe@2.0.3", "https://packagefeedproxy.microsoft.io/npm/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picocolors": ["picocolors@1.1.1", "https://packagefeedproxy.microsoft.io/npm/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "picomatch": ["picomatch@4.0.4", "https://packagefeedproxy.microsoft.io/npm/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postcss": ["postcss@8.5.15", "https://packagefeedproxy.microsoft.io/npm/postcss/-/postcss-8.5.15.tgz", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], - "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + "react": ["react@19.2.7", "https://packagefeedproxy.microsoft.io/npm/react/-/react-19.2.7.tgz", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], - "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + "react-reconciler": ["react-reconciler@0.33.0", "https://packagefeedproxy.microsoft.io/npm/react-reconciler/-/react-reconciler-0.33.0.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], - "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + "restore-cursor": ["restore-cursor@4.0.0", "https://packagefeedproxy.microsoft.io/npm/restore-cursor/-/restore-cursor-4.0.0.tgz", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], - "rolldown": ["rolldown@1.1.3", "", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="], + "rolldown": ["rolldown@1.1.3", "https://packagefeedproxy.microsoft.io/npm/rolldown/-/rolldown-1.1.3.tgz", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "scheduler": ["scheduler@0.27.0", "https://packagefeedproxy.microsoft.io/npm/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "siginfo": ["siginfo@2.0.0", "https://packagefeedproxy.microsoft.io/npm/siginfo/-/siginfo-2.0.0.tgz", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "signal-exit": ["signal-exit@3.0.7", "https://packagefeedproxy.microsoft.io/npm/signal-exit/-/signal-exit-3.0.7.tgz", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="], + "slice-ansi": ["slice-ansi@9.0.0", "https://packagefeedproxy.microsoft.io/npm/slice-ansi/-/slice-ansi-9.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.1", "https://packagefeedproxy.microsoft.io/npm/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "stack-utils": ["stack-utils@2.0.6", "https://packagefeedproxy.microsoft.io/npm/stack-utils/-/stack-utils-2.0.6.tgz", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "stackback": ["stackback@0.0.2", "https://packagefeedproxy.microsoft.io/npm/stackback/-/stackback-0.0.2.tgz", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "std-env": ["std-env@4.1.0", "https://packagefeedproxy.microsoft.io/npm/std-env/-/std-env-4.1.0.tgz", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], - "string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + "string-width": ["string-width@8.2.1", "https://packagefeedproxy.microsoft.io/npm/string-width/-/string-width-8.2.1.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "strip-ansi": ["strip-ansi@7.2.0", "https://packagefeedproxy.microsoft.io/npm/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tagged-tag": ["tagged-tag@1.0.0", "https://packagefeedproxy.microsoft.io/npm/tagged-tag/-/tagged-tag-1.0.0.tgz", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], + "terminal-size": ["terminal-size@4.0.1", "https://packagefeedproxy.microsoft.io/npm/terminal-size/-/terminal-size-4.0.1.tgz", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], - "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinybench": ["tinybench@2.9.0", "https://packagefeedproxy.microsoft.io/npm/tinybench/-/tinybench-2.9.0.tgz", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + "tinyexec": ["tinyexec@1.2.4", "https://packagefeedproxy.microsoft.io/npm/tinyexec/-/tinyexec-1.2.4.tgz", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], - "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinyglobby": ["tinyglobby@0.2.17", "https://packagefeedproxy.microsoft.io/npm/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "https://packagefeedproxy.microsoft.io/npm/tinyrainbow/-/tinyrainbow-3.1.0.tgz", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], - "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + "tree-kill": ["tree-kill@1.2.2", "https://packagefeedproxy.microsoft.io/npm/tree-kill/-/tree-kill-1.2.2.tgz", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tslib": ["tslib@2.8.1", "https://packagefeedproxy.microsoft.io/npm/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], + "tsx": ["tsx@4.22.4", "https://packagefeedproxy.microsoft.io/npm/tsx/-/tsx-4.22.4.tgz", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], - "type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], + "type-fest": ["type-fest@5.7.0", "https://packagefeedproxy.microsoft.io/npm/type-fest/-/type-fest-5.7.0.tgz", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "typescript": ["typescript@6.0.3", "https://packagefeedproxy.microsoft.io/npm/typescript/-/typescript-6.0.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "undici-types": ["undici-types@7.18.2", "https://packagefeedproxy.microsoft.io/npm/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "vite": ["vite@8.1.0", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q=="], + "vite": ["vite@8.1.0", "https://packagefeedproxy.microsoft.io/npm/vite/-/vite-8.1.0.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q=="], - "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "vitest": ["vitest@4.1.9", "https://packagefeedproxy.microsoft.io/npm/vitest/-/vitest-4.1.9.tgz", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - "vscode-jsonrpc": ["vscode-jsonrpc@9.0.0", "", {}, "sha512-+VvMmQPJhtvJ+8O+zu2JKIRiLxXF8NW7krWgyMGeOHrp4Cn23T5hc0v2LknNeopDOB70wghHAds7mKtcZ0I4Sg=="], + "vscode-jsonrpc": ["vscode-jsonrpc@9.0.0", "https://packagefeedproxy.microsoft.io/npm/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0.tgz", {}, "sha512-+VvMmQPJhtvJ+8O+zu2JKIRiLxXF8NW7krWgyMGeOHrp4Cn23T5hc0v2LknNeopDOB70wghHAds7mKtcZ0I4Sg=="], - "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "https://packagefeedproxy.microsoft.io/npm/why-is-node-running/-/why-is-node-running-2.3.0.tgz", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], + "widest-line": ["widest-line@6.0.0", "https://packagefeedproxy.microsoft.io/npm/widest-line/-/widest-line-6.0.0.tgz", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], - "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], + "wrap-ansi": ["wrap-ansi@10.0.0", "https://packagefeedproxy.microsoft.io/npm/wrap-ansi/-/wrap-ansi-10.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], - "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "ws": ["ws@8.21.0", "https://packagefeedproxy.microsoft.io/npm/ws/-/ws-8.21.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "yoga-layout": ["yoga-layout@3.2.1", "https://packagefeedproxy.microsoft.io/npm/yoga-layout/-/yoga-layout-3.2.1.tgz", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], } } diff --git a/tui/package.json b/tui/package.json index f20b6b4d..4aec6095 100644 --- a/tui/package.json +++ b/tui/package.json @@ -21,6 +21,7 @@ "ink": "^7.1.0", "jotai": "^2.20.1", "react": "^19.2.7", + "string-width": "^8.2.0", "tree-kill": "^1.2.2", "vscode-jsonrpc": "^9.0.0" }, @@ -31,5 +32,8 @@ "tsx": "^4.22.4", "typescript": "^6.0.3", "vitest": "^4.1.9" + }, + "patchedDependencies": { + "ink@7.1.0": "patches/ink@7.1.0.patch" } } diff --git a/tui/patches/ink@7.1.0.patch b/tui/patches/ink@7.1.0.patch new file mode 100644 index 00000000..0d481a76 --- /dev/null +++ b/tui/patches/ink@7.1.0.patch @@ -0,0 +1,200 @@ +diff --git a/build/cursor-helpers.d.ts b/build/cursor-helpers.d.ts +index c6e94667f9aef310093d40520fd8611448d0f0b6..8b4d831f2acea9560ca66fab144e8ae1563990d6 100644 +--- a/build/cursor-helpers.d.ts ++++ b/build/cursor-helpers.d.ts +@@ -11,9 +11,10 @@ Compare two cursor positions. Returns true if they differ. + export declare const cursorPositionChanged: (a: CursorPosition | undefined, b: CursorPosition | undefined) => boolean; + /** + Build escape sequence to move cursor from bottom of output to the target position and show it. +-Assumes cursor is at (col 0, line visibleLineCount) — i.e. just after the last output line. ++The bottom is the row after output when it has a trailing newline, otherwise it ++is the final visible row. + */ +-export declare const buildCursorSuffix: (visibleLineCount: number, cursorPosition: CursorPosition | undefined) => string; ++export declare const buildCursorSuffix: (visibleLineCount: number, cursorPosition: CursorPosition | undefined, hasTrailingNewline: boolean) => string; + /** + Build escape sequence to move cursor from previousCursorPosition back to the bottom of output. + This must be done before eraseLines or any operation that assumes cursor is at the bottom. +@@ -25,6 +26,7 @@ export type CursorOnlyInput = { + previousCursorPosition: CursorPosition | undefined; + visibleLineCount: number; + cursorPosition: CursorPosition | undefined; ++ hasTrailingNewline: boolean; + }; + /** + Build the escape sequence for cursor-only updates (output unchanged, cursor moved). +diff --git a/build/cursor-helpers.js b/build/cursor-helpers.js +index be493545f0a22d2972a36ad858d2e4bbc4c9326a..632dac9131cc463988e526779c7b604753832e1e 100644 +--- a/build/cursor-helpers.js ++++ b/build/cursor-helpers.js +@@ -8,13 +8,15 @@ Compare two cursor positions. Returns true if they differ. + export const cursorPositionChanged = (a, b) => a?.x !== b?.x || a?.y !== b?.y; + /** + Build escape sequence to move cursor from bottom of output to the target position and show it. +-Assumes cursor is at (col 0, line visibleLineCount) — i.e. just after the last output line. ++The bottom is the row after output when it has a trailing newline, otherwise it ++is the final visible row. + */ +-export const buildCursorSuffix = (visibleLineCount, cursorPosition) => { ++export const buildCursorSuffix = (visibleLineCount, cursorPosition, hasTrailingNewline) => { + if (!cursorPosition) { + return ''; + } +- const moveUp = visibleLineCount - cursorPosition.y; ++ const bottomRow = visibleLineCount - (hasTrailingNewline ? 0 : 1); ++ const moveUp = bottomRow - cursorPosition.y; + return ((moveUp > 0 ? ansiEscapes.cursorUp(moveUp) : '') + + ansiEscapes.cursorTo(cursorPosition.x) + + showCursorEscape); +@@ -39,7 +41,7 @@ Hides cursor if it was previously shown, returns to bottom, then repositions. + export const buildCursorOnlySequence = (input) => { + const hidePrefix = input.cursorWasShown ? hideCursorEscape : ''; + const returnToBottom = buildReturnToBottom(input.previousLineCount, input.previousCursorPosition); +- const cursorSuffix = buildCursorSuffix(input.visibleLineCount, input.cursorPosition); ++ const cursorSuffix = buildCursorSuffix(input.visibleLineCount, input.cursorPosition, input.hasTrailingNewline); + return hidePrefix + returnToBottom + cursorSuffix; + }; + /** +diff --git a/build/ink.js b/build/ink.js +index 24d45ece46acbc2765c9da2f2d39c2b620f9a504..0082666b281ec2db77b875d6b8460bcc19a59ce7 100644 +--- a/build/ink.js ++++ b/build/ink.js +@@ -765,7 +765,7 @@ export default class Ink { + if (sync) { + this.options.stdout.write(bsu); + } +- this.options.stdout.write(ansiEscapes.clearTerminal + this.fullStaticOutput + output); ++ this.options.stdout.write(ansiEscapes.clearTerminal + this.fullStaticOutput + outputToRender); + this.lastOutput = output; + this.lastOutputToRender = outputToRender; + this.lastOutputHeight = outputHeight; +diff --git a/build/log-update.js b/build/log-update.js +index cdb5fc41f158304c4c4f230a765065ab1fe0a2f7..ece0ab329f7951f2110ebd4d80edb486cca25264 100644 +--- a/build/log-update.js ++++ b/build/log-update.js +@@ -32,7 +32,8 @@ const createStandard = (stream, { showCursor = false } = {}) => { + } + const lines = str.split('\n'); + const visibleCount = visibleLineCount(lines, str); +- const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor); ++ const hasTrailingNewline = str.endsWith('\n'); ++ const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor, hasTrailingNewline); + if (str === previousOutput && cursorChanged) { + stream.write(buildCursorOnlySequence({ + cursorWasShown, +@@ -40,6 +41,7 @@ const createStandard = (stream, { showCursor = false } = {}) => { + previousCursorPosition, + visibleLineCount: visibleCount, + cursorPosition: activeCursor, ++ hasTrailingNewline, + })); + } + else { +@@ -89,7 +91,7 @@ const createStandard = (stream, { showCursor = false } = {}) => { + stream.write(hideCursorEscape); + } + if (activeCursor) { +- stream.write(buildCursorSuffix(visibleLineCount(lines, str), activeCursor)); ++ stream.write(buildCursorSuffix(visibleLineCount(lines, str), activeCursor, str.endsWith('\n'))); + } + previousCursorPosition = activeCursor ? { ...activeCursor } : undefined; + cursorWasShown = activeCursor !== undefined; +@@ -131,6 +133,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => { + const nextLines = str.split('\n'); + const visibleCount = visibleLineCount(nextLines, str); + const previousVisible = visibleLineCount(previousLines, previousOutput); ++ const hasTrailingNewline = str.endsWith('\n'); + if (str === previousOutput && cursorChanged) { + stream.write(buildCursorOnlySequence({ + cursorWasShown, +@@ -138,6 +141,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => { + previousCursorPosition, + visibleLineCount: visibleCount, + cursorPosition: activeCursor, ++ hasTrailingNewline, + })); + previousCursorPosition = activeCursor ? { ...activeCursor } : undefined; + cursorWasShown = activeCursor !== undefined; +@@ -145,7 +149,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => { + } + const returnPrefix = buildReturnToBottomPrefix(cursorWasShown, previousLines.length, previousCursorPosition); + if (str === '\n' || previousOutput.length === 0) { +- const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor); ++ const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor, hasTrailingNewline); + stream.write(returnPrefix + + ansiEscapes.eraseLines(previousLines.length) + + str + +@@ -156,7 +160,6 @@ const createIncremental = (stream, { showCursor = false } = {}) => { + previousLines = nextLines; + return true; + } +- const hasTrailingNewline = str.endsWith('\n'); + // We aggregate all chunks for incremental rendering into a buffer, and then write them to stdout at the end. + const buffer = []; + buffer.push(returnPrefix); +@@ -187,7 +190,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => { + // has no trailing newline (fullscreen mode). + (isLastLine && !hasTrailingNewline ? '' : '\n')); + } +- const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor); ++ const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor, hasTrailingNewline); + buffer.push(cursorSuffix); + stream.write(buffer.join('')); + cursorWasShown = activeCursor !== undefined; +@@ -230,7 +233,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => { + stream.write(hideCursorEscape); + } + if (activeCursor) { +- stream.write(buildCursorSuffix(visibleLineCount(lines, str), activeCursor)); ++ stream.write(buildCursorSuffix(visibleLineCount(lines, str), activeCursor, str.endsWith('\n'))); + } + previousCursorPosition = activeCursor ? { ...activeCursor } : undefined; + cursorWasShown = activeCursor !== undefined; +diff --git a/build/parse-keypress.js b/build/parse-keypress.js +index e5129a11311e3fefff156aa22404ecfe52e15ba9..7092088e2d3638fe28fa35db70c9f21a07e76440 100644 +--- a/build/parse-keypress.js ++++ b/build/parse-keypress.js +@@ -127,6 +127,8 @@ const kittyKeyRe = /^\x1b\[(\d+)(?:;(\d+)(?::(\d+))?(?:;([\d:]+))?)?u$/; + // These are legacy CSI sequences enhanced with the :eventType field. + // Examples: \x1b[1;1:1A (up arrow press), \x1b[3;1:3~ (delete release) + const kittySpecialKeyRe = /^\x1b\[(\d+);(\d+):(\d+)([A-Za-z~])$/; ++// xterm modifyOtherKeys: CSI 27 ; modifiers ; codepoint ~ ++const modifyOtherKeysRe = /^\x1b\[27;(\d+);(\d+)~$/; + // Letter-terminated special key names (CSI 1 ; mods letter) + const kittySpecialLetterKeys = { + A: 'up', +@@ -277,6 +279,24 @@ function parseKittyModifiers(modifiers) { + numLock: !!(modifiers & kittyModifiers.numLock), + }; + } ++const parseModifyOtherKeys = (s) => { ++ const match = modifyOtherKeysRe.exec(s); ++ if (!match) ++ return null; ++ const modifiers = Math.max(0, parseInt(match[1], 10) - 1); ++ const codepoint = parseInt(match[2], 10); ++ if (codepoint !== 13) { ++ return null; ++ } ++ return { ++ name: 'return', ++ ...parseKittyModifiers(modifiers), ++ sequence: s, ++ raw: s, ++ isPrintable: true, ++ text: '\r', ++ }; ++}; + const parseKittyKeypress = (s) => { + const match = kittyKeyRe.exec(s); + if (!match) +@@ -380,6 +400,9 @@ const parseKeypress = (s = '') => { + else if (!s) { + s = ''; + } ++ const modifyOtherKeysResult = parseModifyOtherKeys(s); ++ if (modifyOtherKeysResult) ++ return modifyOtherKeysResult; + // Try kitty keyboard protocol parsers first + const kittyResult = parseKittyKeypress(s); + if (kittyResult) diff --git a/tui/src/App.tsx b/tui/src/App.tsx index 6c253ce3..7a28b998 100644 --- a/tui/src/App.tsx +++ b/tui/src/App.tsx @@ -4,7 +4,7 @@ import { useLayoutEffect } from 'react'; import { HelpScreen } from '@components/HelpScreen/index.tsx'; import { HomeScreen } from '@components/HomeScreen/index.tsx'; import { TerminalTooSmall } from '@components/TerminalTooSmall.tsx'; -import { useGlobalKeys } from '@/useGlobalKeys.ts'; +import { useGlobalKeys } from '@hooks/useGlobalKeys.ts'; import { terminalTooSmallAtom, windowColumnsAtom, windowRowsAtom } from '@state/ui/index.ts'; import { helpVisibleAtom } from '@state/ui/help/index.ts'; diff --git a/tui/src/__tests__/App.submit.test.tsx b/tui/src/__tests__/App.submit.test.tsx index b0bf26d2..7e0b17a0 100644 --- a/tui/src/__tests__/App.submit.test.tsx +++ b/tui/src/__tests__/App.submit.test.tsx @@ -1,35 +1,42 @@ +import os from 'node:os'; +import path from 'node:path'; import { createStore } from 'jotai'; import { describe, expect, it, vi } from 'vitest'; import { App } from '@/App.tsx'; import { BackendClientError, BackendErrorKind } from '@contracts/backend/index.ts'; -import type { BackendClient } from '@contracts/backend/index.ts'; -import { ACK_MESSAGE } from '@contracts/backend/index.ts'; -import type { MessageSubmitParams, MessageSubmitResult } from '@contracts/backend/index.ts'; +import type { BackendClient, SubmitOutcome, SubmitParams } from '@contracts/backend/index.ts'; import { columnsTestOverrideAtom, rowsTestOverrideAtom } from '@state/ui/index.ts'; import { backendClientAtom, productVersionAtom, workspaceCwdAtom } from '@state/global/index.ts'; import { promptQueueAtom } from '@state/promptQueue/index.ts'; +import { NEEDS_CONFIGURATION_MESSAGE } from '@libs/promptQueue/promptQueue.ts'; import { flushInput } from '@test/flushInput.ts'; import { renderWithJotai } from '@test/renderWithJotai.tsx'; -const workspaceCwd = 'C:\\Users\\kefeiqian\\Projects\\dummy-react-app'; +const workspaceCwd = path.join(os.homedir(), 'Projects', 'dummy-react-app'); +const needsConfigurationHeadline = NEEDS_CONFIGURATION_MESSAGE.split('.')[0]; -function renderApp(backendClient: BackendClient, columns = 80, rows = 40) { +function renderApp(backendClient: Partial, columns = 80, rows = 40) { const store = createStore(); store.set(productVersionAtom, '0.1.0'); store.set(workspaceCwdAtom, workspaceCwd); store.set(columnsTestOverrideAtom, columns); store.set(rowsTestOverrideAtom, rows); - store.set(backendClientAtom, backendClient); + const client: BackendClient = { + gitStatus: async () => null, + pullRequest: async () => null, + submit: async () => { + throw new Error('submit not provided'); + }, + ...backendClient + }; + store.set(backendClientAtom, client); return { store, ...renderWithJotai(, store) }; } -function echoBackend() { - return vi.fn( - async ({ text }: MessageSubmitParams): Promise => ({ - message: ACK_MESSAGE, - receivedText: text - }) - ); +// A fake backend that acks needsConfiguration, matching the bootstrap slice where +// no provider is wired yet. +function needsConfigurationBackend() { + return vi.fn(async (): Promise => ({ kind: 'needsConfiguration' })); } async function waitForFrame( @@ -53,39 +60,39 @@ async function submit(stdin: { write: (data: string) => void }, text: string): P await flushInput(); } -describe('App submit and ACK output', () => { - it('appends the prompt and the Rust backend ACK when Enter is pressed', async () => { - const submitMessage = echoBackend(); - const { lastFrame, stdin } = renderApp({ submitMessage }); +describe('App submit and configuration notice', () => { + it('appends the prompt and routes to the configuration notice when Enter is pressed', async () => { + const submitFn = needsConfigurationBackend(); + const { lastFrame, stdin } = renderApp({ submit: submitFn }); await submit(stdin, 'hello from tui'); const frame = await waitForFrame(lastFrame, (output) => - output.includes('Rust backend ACK - received: hello from tui') + output.includes(needsConfigurationHeadline) ); expect(frame).toContain('❯ hello from tui'); - expect(submitMessage).toHaveBeenCalledWith({ text: 'hello from tui' }); + expect(submitFn).toHaveBeenCalledWith({ text: 'hello from tui' }); }); - it('preserves Unicode and surrounding spaces in the backend result', async () => { - const submitMessage = echoBackend(); - const { lastFrame, stdin } = renderApp({ submitMessage }, 120); + it('preserves Unicode and surrounding spaces in the submitted text', async () => { + const submitFn = needsConfigurationBackend(); + const { lastFrame, stdin } = renderApp({ submit: submitFn }, 120); await submit(stdin, ' café ☕ '); await waitForFrame(lastFrame, (output) => output.includes('café ☕')); - expect(submitMessage).toHaveBeenCalledWith({ text: ' café ☕ ' }); + expect(submitFn).toHaveBeenCalledWith({ text: ' café ☕ ' }); }); it('queues consecutive submits, marking only the later prompts pending', async () => { - const pending: Array<{ text: string; resolve: (result: MessageSubmitResult) => void }> = []; - const submitMessage = vi.fn( - (params: MessageSubmitParams): Promise => + const pending: Array<{ text: string; resolve: (outcome: SubmitOutcome) => void }> = []; + const submitFn = vi.fn( + (params: SubmitParams): Promise => new Promise((resolve) => { pending.push({ text: params.text, resolve }); }) ); - const { lastFrame, stdin } = renderApp({ submitMessage }); + const { lastFrame, stdin } = renderApp({ submit: submitFn }); await submit(stdin, 'first'); await submit(stdin, 'second'); @@ -95,28 +102,28 @@ describe('App submit and ACK output', () => { lastFrame, (output) => output.includes('second (pending)') && output.includes('third (pending)') ); - expect(submitMessage).toHaveBeenCalledTimes(1); - expect(submitMessage).toHaveBeenCalledWith({ text: 'first' }); + expect(submitFn).toHaveBeenCalledTimes(1); + expect(submitFn).toHaveBeenNthCalledWith(1, { text: 'first' }); expect(queuedFrame).toContain('❯ first'); expect(queuedFrame).not.toContain('first (pending)'); - pending[0]?.resolve({ message: ACK_MESSAGE, receivedText: 'first' }); + pending[0]?.resolve({ kind: 'needsConfiguration' }); const drainedFrame = await waitForFrame( lastFrame, (output) => - output.includes('Rust backend ACK - received: first') && !output.includes('second (pending)') + output.includes(needsConfigurationHeadline) && !output.includes('second (pending)') ); - expect(submitMessage).toHaveBeenCalledTimes(2); - expect(submitMessage).toHaveBeenNthCalledWith(2, { text: 'second' }); + expect(submitFn).toHaveBeenCalledTimes(2); + expect(submitFn).toHaveBeenNthCalledWith(2, { text: 'second' }); expect(drainedFrame).toContain('third (pending)'); }); it('shows a red backend failure for the matching prompt', async () => { - const submitMessage = vi.fn(async (): Promise => { + const submitFn = vi.fn(async (): Promise => { throw new BackendClientError(BackendErrorKind.Transport, 'connection died'); }); - const { lastFrame, stdin } = renderApp({ submitMessage }); + const { lastFrame, stdin } = renderApp({ submit: submitFn }); await submit(stdin, 'will fail'); @@ -127,14 +134,11 @@ describe('App submit and ACK output', () => { expect(frame).toContain('connection died'); }); - it('escapes terminal-control characters in backend output before rendering', async () => { - const submitMessage = vi.fn( - async (): Promise => ({ - message: ACK_MESSAGE, - receivedText: 'evil\u001b[2Jcleared' - }) - ); - const { lastFrame, stdin } = renderApp({ submitMessage }, 120, 20); + it('escapes terminal-control characters in backend error output before rendering', async () => { + const submitFn = vi.fn(async (): Promise => { + throw new BackendClientError(BackendErrorKind.Transport, 'evil\u001b[2Jcleared'); + }); + const { lastFrame, stdin } = renderApp({ submit: submitFn }, 120, 20); await submit(stdin, 'trigger'); @@ -143,18 +147,18 @@ describe('App submit and ACK output', () => { }); it('does not call the backend for whitespace-only submits', async () => { - const submitMessage = echoBackend(); - const { stdin } = renderApp({ submitMessage }); + const submitFn = needsConfigurationBackend(); + const { stdin } = renderApp({ submit: submitFn }); await submit(stdin, ' '); await flushInput(); - expect(submitMessage).not.toHaveBeenCalled(); + expect(submitFn).not.toHaveBeenCalled(); }); it('posts an unknown slash command and its error into the body without a backend call', async () => { - const submitMessage = echoBackend(); - const { lastFrame, stdin, store } = renderApp({ submitMessage }); + const submitFn = needsConfigurationBackend(); + const { lastFrame, stdin, store } = renderApp({ submit: submitFn }); await submit(stdin, '/nope'); @@ -162,7 +166,7 @@ describe('App submit and ACK output', () => { output.includes('Unknown command: /nope') ); expect(frame).toContain('❯ /nope'); - expect(submitMessage).not.toHaveBeenCalled(); + expect(submitFn).not.toHaveBeenCalled(); expect(store.get(promptQueueAtom).some((item) => item.state === 'active')).toBe(false); }); }); diff --git a/tui/src/__tests__/App.test.tsx b/tui/src/__tests__/App.test.tsx index 183b3946..88dd8578 100644 --- a/tui/src/__tests__/App.test.tsx +++ b/tui/src/__tests__/App.test.tsx @@ -1,15 +1,15 @@ import { createStore } from 'jotai'; import os from 'node:os'; import path from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { App } from '@/App.tsx'; import { armedActionAtom, columnsTestOverrideAtom, - FULLSCREEN_GUARD_ROWS, rowsTestOverrideAtom } from '@state/ui/index.ts'; import { ArmedAction } from '@constants/ui.ts'; +import { WEZTERM_FULLSCREEN_GUARD_ROWS } from '@constants/terminal.ts'; import { productVersionAtom, workspaceCwdAtom } from '@state/global/index.ts'; import { helpVisibleAtom } from '@state/ui/help/index.ts'; import { flushInput } from '@test/flushInput.ts'; @@ -31,29 +31,15 @@ function renderApp({ columns, rows }: { columns?: number; rows?: number } = {}) return { store, ...renderWithJotai(, store) }; } -function deferredPromise() { - let resolve!: (value: T | PromiseLike) => void; - const promise = new Promise((innerResolve) => { - resolve = innerResolve; +describe('App', () => { + beforeEach(() => { + vi.stubEnv('TERM_PROGRAM', 'Windows_Terminal'); }); - return { promise, resolve }; -} -async function waitForFrame( - getFrame: () => string | undefined, - predicate: (frame: string) => boolean -): Promise { - for (let attempt = 0; attempt < 200; attempt += 1) { - const frame = getFrame() ?? ''; - if (predicate(frame)) { - return frame; - } - await new Promise((resolve) => setTimeout(resolve, 5)); - } - throw new Error(`timed out waiting for frame. Last frame:\n${getFrame() ?? ''}`); -} + afterEach(() => { + vi.unstubAllEnvs(); + }); -describe('App', () => { it('smoke renders product metadata and workspace cwd', () => { const { lastFrame } = renderApp({ columns: 100, rows: 20 }); @@ -75,11 +61,38 @@ describe('App', () => { await flushInput(); const outputRows = (lastFrame() ?? '').split('\n'); - // The UI fills the full terminal height (FULLSCREEN_GUARD_ROWS = 0). - expect(outputRows).toHaveLength(18 - FULLSCREEN_GUARD_ROWS); + expect(outputRows).toHaveLength(18); expect(outputRows.at(-1)).toContain('/ commands | @ mention | ? help'); }); + it('keeps WezTerm frames below Ink fullscreen rendering', async () => { + vi.stubEnv('TERM_PROGRAM', 'WezTerm'); + const { lastFrame, stdout } = renderApp(); + + await flushInput(); + Object.defineProperty(stdout, 'columns', { configurable: true, value: 80 }); + Object.defineProperty(stdout, 'rows', { configurable: true, value: 18 }); + stdout.emit('resize'); + await flushInput(); + + const outputRows = (lastFrame() ?? '').split('\n'); + expect(outputRows).toHaveLength(18 - WEZTERM_FULLSCREEN_GUARD_ROWS); + expect(outputRows.at(-1)).toContain('/ commands | @ mention | ? help'); + }); + + it('reports the guarded minimum height in WezTerm', async () => { + vi.stubEnv('TERM_PROGRAM', 'WezTerm'); + const { lastFrame, stdout } = renderApp(); + + await flushInput(); + Object.defineProperty(stdout, 'columns', { configurable: true, value: 80 }); + Object.defineProperty(stdout, 'rows', { configurable: true, value: 15 }); + stdout.emit('resize'); + await flushInput(); + + expect(lastFrame() ?? '').toContain('60 cols \u00D7 16 rows'); + }); + it('shows the enlarge notice when the terminal shrinks below the usable height', async () => { const { lastFrame, stdout } = renderApp(); diff --git a/tui/src/__tests__/architecture.test.ts b/tui/src/__tests__/architecture.test.ts new file mode 100644 index 00000000..bd488bff --- /dev/null +++ b/tui/src/__tests__/architecture.test.ts @@ -0,0 +1,45 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { analyzeArchitecture } from '@test/analyzeArchitecture.ts'; + +const srcRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const componentExportBaseline: string[] = []; + +const layerViolationBaseline: string[] = []; + +const unknownLayerBaseline: string[] = []; + +const stateExportBaseline: string[] = []; + +describe('TUI architecture boundaries', () => { + const report = analyzeArchitecture(srcRoot); + + it('keeps dependencies flowing toward lower layers', () => { + expect(report.fileCount).toBeGreaterThan(0); + expect(report.edgeCount).toBeGreaterThan(0); + expect(report.layerViolations.sort()).toEqual(layerViolationBaseline); + }); + + it('has no circular module dependencies', () => { + expect(report.cycles).toEqual([]); + }); + + it('keeps entrypoints at the composition layer and rejects unknown layers', () => { + expect(report.entryPointViolations).toEqual([]); + expect(report.unknownLayerFiles).toEqual(unknownLayerBaseline); + }); + + it('exports only React components from component modules', () => { + expect(report.componentExportViolations).toEqual(componentExportBaseline); + }); + + it('keeps constants dependency-free and statically initialized', () => { + expect(report.constantViolations).toEqual([]); + }); + + it('exports only atoms and types from state modules', () => { + expect(report.stateExportViolations).toEqual(stateExportBaseline); + }); +}); diff --git a/tui/src/__tests__/components/HomeScreen.paste.test.tsx b/tui/src/__tests__/components/HomeScreen.paste.test.tsx new file mode 100644 index 00000000..958eb702 --- /dev/null +++ b/tui/src/__tests__/components/HomeScreen.paste.test.tsx @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest'; +import { clipboardClientAtom } from '@state/global/index.ts'; +import { bodySelectionAtom } from '@state/ui/index.ts'; +import { composerStateAtom } from '@state/ui/composer/index.ts'; +import { flushInput } from '@test/flushInput.ts'; +import { renderSelectionScreen } from '@test/renderSelectionScreen.tsx'; +import { + bracketedPasteInput, + SgrMouseButton, + sgrMouseInput +} from '@test/terminalInput.ts'; + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('HomeScreen right-click paste', () => { + it('pastes once when no selection is active', async () => { + const { stdin, store } = renderSelectionScreen(); + const readText = vi.fn().mockResolvedValue('from clipboard'); + store.set(clipboardClientAtom, { readText, writeText: vi.fn() }); + await flushInput(); + + stdin.write(sgrMouseInput(SgrMouseButton.Right, 5, 14)); + await flushInput(); + await flushPromises(); + stdin.write(bracketedPasteInput('stale native clipboard')); + await flushInput(); + + expect(readText).toHaveBeenCalledTimes(1); + expect(store.get(composerStateAtom).text).toBe('from clipboard'); + }); + + it('uses native bracketed paste when the app clipboard read fails', async () => { + const { stdin, store } = renderSelectionScreen(); + store.set(clipboardClientAtom, { + readText: vi.fn().mockResolvedValue(null), + writeText: vi.fn() + }); + await flushInput(); + + stdin.write(sgrMouseInput(SgrMouseButton.Right, 5, 14)); + await flushInput(); + await flushPromises(); + stdin.write(bracketedPasteInput('native fallback')); + await flushInput(); + + expect(store.get(composerStateAtom).text).toBe('native fallback'); + }); + + it('expands pasted tabs so composer display columns stay stable', async () => { + const { stdin, store } = renderSelectionScreen(); + await flushInput(); + + stdin.write(bracketedPasteInput('a\tb')); + await flushInput(); + + expect(store.get(composerStateAtom)).toMatchObject({ + cursorIndex: 5, + text: 'a b' + }); + }); + + it('keeps only native paste when it wins the right-click race', async () => { + const { stdin, store } = renderSelectionScreen(); + let finishRead: (text: string) => void = () => undefined; + store.set(clipboardClientAtom, { + readText: vi.fn( + () => + new Promise((resolve) => { + finishRead = resolve; + }) + ), + writeText: vi.fn() + }); + await flushInput(); + + stdin.write(sgrMouseInput(SgrMouseButton.Right, 5, 14)); + await flushInput(); + stdin.write(bracketedPasteInput('native first')); + await flushInput(); + finishRead('app clipboard later'); + await flushPromises(); + + expect(store.get(composerStateAtom).text).toBe('native first'); + }); + + it('buffers stale native paste until a preceding copy finishes', async () => { + const { stdin, store } = renderSelectionScreen(); + let finishWrite: (success: boolean) => void = () => undefined; + const readText = vi.fn().mockResolvedValue('new clipboard'); + store.set(clipboardClientAtom, { + readText, + writeText: vi.fn( + () => + new Promise((resolve) => { + finishWrite = resolve; + }) + ) + }); + store.set(bodySelectionAtom, { + anchor: { rowIndex: 0, column: 0 }, + focus: { rowIndex: 0, column: 9 } + }); + await flushInput(); + + stdin.write(sgrMouseInput(SgrMouseButton.Right, 5, 14)); + stdin.write(sgrMouseInput(SgrMouseButton.Right, 5, 14)); + await flushInput(); + + expect(readText).not.toHaveBeenCalled(); + + stdin.write(bracketedPasteInput('old clipboard')); + await flushInput(); + + expect(store.get(composerStateAtom).text).toBe(''); + + finishWrite(true); + await flushPromises(); + await flushPromises(); + + expect(readText).toHaveBeenCalledTimes(1); + expect(store.get(composerStateAtom).text).toBe('new clipboard'); + }); + + it('cancels a pending read when keyboard input resumes', async () => { + const { stdin, store } = renderSelectionScreen(); + let finishRead: (text: string) => void = () => undefined; + store.set(clipboardClientAtom, { + readText: vi.fn( + () => + new Promise((resolve) => { + finishRead = resolve; + }) + ), + writeText: vi.fn() + }); + await flushInput(); + + stdin.write(sgrMouseInput(SgrMouseButton.Right, 5, 14)); + await flushInput(); + stdin.write('z'); + await flushInput(); + finishRead('stale clipboard'); + await flushPromises(); + stdin.write(bracketedPasteInput('late native clipboard')); + await flushInput(); + + expect(store.get(composerStateAtom).text).toBe('z'); + }); +}); diff --git a/tui/src/__tests__/components/HomeScreen.pasteLifecycle.test.tsx b/tui/src/__tests__/components/HomeScreen.pasteLifecycle.test.tsx new file mode 100644 index 00000000..bdd0bdb5 --- /dev/null +++ b/tui/src/__tests__/components/HomeScreen.pasteLifecycle.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + PASTE_FAILED_HINT, + RIGHT_CLICK_PASTE_DEDUP_MS +} from '@constants/ui.ts'; +import { composerTarget } from '@hooks/promptComposer/pasteFromClipboard.ts'; +import { clipboardClientAtom } from '@state/global/index.ts'; +import { + shouldInsertBracketedPasteAtom, + transientStatusHintAtom +} from '@state/ui/index.ts'; +import { rightClickPasteStateAtom } from '@state/ui/pasteState.ts'; +import { composerStateAtom } from '@state/ui/composer/index.ts'; +import { flushInput } from '@test/flushInput.ts'; +import { renderSelectionScreen } from '@test/renderSelectionScreen.tsx'; +import { + SgrMouseButton, + sgrMouseInput +} from '@test/terminalInput.ts'; + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('HomeScreen paste lifecycle cleanup', () => { + it('suppresses pending app and native paste after unmount', async () => { + const { stdin, store, unmount } = renderSelectionScreen(); + let finishRead: (text: string) => void = () => undefined; + store.set(clipboardClientAtom, { + readText: vi.fn( + () => + new Promise((resolve) => { + finishRead = resolve; + }) + ), + writeText: vi.fn() + }); + await flushInput(); + + stdin.write(sgrMouseInput(SgrMouseButton.Right, 5, 14)); + await flushInput(); + unmount(); + + expect(store.get(rightClickPasteStateAtom)).toMatchObject({ + kind: 'paste', + status: 'cancelled' + }); + + finishRead('late app clipboard'); + await flushPromises(); + + expect(store.get(composerStateAtom).text).toBe(''); + expect( + store.set(shouldInsertBracketedPasteAtom, { + target: composerTarget(store) + }) + ).toBe(false); + }); + + it('does not show a delayed paste failure after unmount', async () => { + const { stdin, store, unmount } = renderSelectionScreen(); + store.set(clipboardClientAtom, { + readText: vi.fn().mockResolvedValue(null), + writeText: vi.fn() + }); + await flushInput(); + + stdin.write(sgrMouseInput(SgrMouseButton.Right, 5, 14)); + await flushInput(); + await flushPromises(); + unmount(); + await new Promise((resolve) => + setTimeout(resolve, RIGHT_CLICK_PASTE_DEDUP_MS + 10) + ); + + expect(store.get(transientStatusHintAtom)?.text).not.toBe(PASTE_FAILED_HINT); + }); +}); diff --git a/tui/src/__tests__/components/HomeScreen.selection.test.tsx b/tui/src/__tests__/components/HomeScreen.selection.test.tsx new file mode 100644 index 00000000..46b10b02 --- /dev/null +++ b/tui/src/__tests__/components/HomeScreen.selection.test.tsx @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; +import { clipboardClientAtom } from '@state/global/index.ts'; +import { + bodySelectionAtom, + columnsTestOverrideAtom +} from '@state/ui/index.ts'; +import { flushInput } from '@test/flushInput.ts'; +import { renderSelectionScreen } from '@test/renderSelectionScreen.tsx'; +import { + SgrMouseButton, + sgrMouseInput +} from '@test/terminalInput.ts'; + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('HomeScreen transcript selection', () => { + it('highlights on drag, then copies and clears on right-click', async () => { + const { stdin, store } = renderSelectionScreen(); + const writeText = vi.fn().mockResolvedValue(true); + store.set(clipboardClientAtom, { readText: vi.fn(), writeText }); + await flushInput(); + + // Header occupies SGR row 1, so the first transcript row is row 2. + stdin.write(sgrMouseInput(SgrMouseButton.Left, 3, 2)); + stdin.write(sgrMouseInput(SgrMouseButton.LeftDrag, 13, 2)); + stdin.write(sgrMouseInput(SgrMouseButton.Left, 13, 2, 'm')); + await flushInput(); + + expect(store.get(bodySelectionAtom)).not.toBeNull(); + expect(writeText).not.toHaveBeenCalled(); + + stdin.write(sgrMouseInput(SgrMouseButton.Right, 13, 2)); + await flushInput(); + await flushPromises(); + + expect(writeText).toHaveBeenCalledWith('selectable'); + expect(store.get(bodySelectionAtom)).toBeNull(); + }); + + it('does not select the rendered blank row below a short transcript', async () => { + const { stdin, store } = renderSelectionScreen(); + await flushInput(); + + stdin.write(sgrMouseInput(SgrMouseButton.Left, 3, 3)); + stdin.write(sgrMouseInput(SgrMouseButton.LeftDrag, 13, 3)); + stdin.write(sgrMouseInput(SgrMouseButton.Left, 13, 3, 'm')); + await flushInput(); + + expect(store.get(bodySelectionAtom)).toBeNull(); + }); + + it('clears a selection when resize changes transcript wrapping geometry', async () => { + const { store } = renderSelectionScreen(); + store.set(bodySelectionAtom, { + anchor: { rowIndex: 0, column: 0 }, + focus: { rowIndex: 0, column: 5 } + }); + await flushInput(); + + store.set(columnsTestOverrideAtom, 70); + await flushInput(); + + expect(store.get(bodySelectionAtom)).toBeNull(); + }); + + it('clears a selection when the screen unmounts', async () => { + const { store, unmount } = renderSelectionScreen(); + store.set(bodySelectionAtom, { + anchor: { rowIndex: 0, column: 0 }, + focus: { rowIndex: 0, column: 5 } + }); + await flushInput(); + + unmount(); + + expect(store.get(bodySelectionAtom)).toBeNull(); + }); +}); diff --git a/tui/src/__tests__/components/HomeScreen.test.tsx b/tui/src/__tests__/components/HomeScreen.test.tsx index b8ef6f6a..35ab3af5 100644 --- a/tui/src/__tests__/components/HomeScreen.test.tsx +++ b/tui/src/__tests__/components/HomeScreen.test.tsx @@ -7,18 +7,23 @@ import { App } from '@/App.tsx'; import { BodyPane } from '@components/BodyPane.tsx'; import { formatDisplayCwd } from '@libs/tui/cwdLine.ts'; import type { BodyEntry } from '@libs/tui/bodyRows.ts'; +import type { GitStatus } from '@contracts/backend/index.ts'; import { PROMPT_MAX_BYTES } from '@libs/composer/promptText.ts'; import { bodyEntriesAtom, columnsTestOverrideAtom, - gitStatusLabelTestOverrideAtom, + gitStatusAtom, rowsTestOverrideAtom } from '@state/ui/index.ts'; import { productVersionAtom, workspaceCwdAtom } from '@state/global/index.ts'; +import { composerStateAtom } from '@state/ui/composer/index.ts'; import { flushInput } from '@test/flushInput.ts'; import { renderWithJotai } from '@test/renderWithJotai.tsx'; import { theme } from '@theme/themeConfig.ts'; +// Build the workspace under the real home dir (not a hard-coded C:\ string) so +// formatDisplayCwd collapses it to a `~`-relative path on every OS, keeping the +// cwd-row assertions valid on the Linux CI runner as well as Windows. const workspaceCwd = path.join(os.homedir(), 'Projects', 'KQode'); const displayCwd = `~${path.sep}${path.join('Projects', 'KQode')}`; const projectsKQode = path.join('Projects', 'KQode'); @@ -26,7 +31,7 @@ const projectsKQode = path.join('Projects', 'KQode'); type RenderHomeScreenOptions = { productVersion?: string; workspaceCwd?: string; - gitStatusLabel?: string; + gitStatus?: GitStatus; columns?: number; rows?: number; bodyEntries?: readonly BodyEntry[]; @@ -35,7 +40,7 @@ type RenderHomeScreenOptions = { function renderHomeScreen({ productVersion = '0.1.0', workspaceCwd: screenWorkspaceCwd = workspaceCwd, - gitStatusLabel, + gitStatus, columns, rows, bodyEntries @@ -43,8 +48,8 @@ function renderHomeScreen({ const store = createStore(); store.set(productVersionAtom, productVersion); store.set(workspaceCwdAtom, screenWorkspaceCwd); - if (gitStatusLabel !== undefined) { - store.set(gitStatusLabelTestOverrideAtom, gitStatusLabel); + if (gitStatus !== undefined) { + store.set(gitStatusAtom, gitStatus); } if (columns !== undefined) { store.set(columnsTestOverrideAtom, columns); @@ -55,7 +60,7 @@ function renderHomeScreen({ if (bodyEntries !== undefined) { store.set(bodyEntriesAtom, bodyEntries); } - return renderWithJotai(, store); + return { store, ...renderWithJotai(, store) }; } describe('HomeScreen', () => { @@ -211,9 +216,12 @@ describe('HomeScreen', () => { expect(outputRows.at(2)).toContain(' IJKLMNOPQRSTUVWXYZ'); }); - it('formats the cwd row with a home-relative path and bracketed git status', () => { + it('formats the cwd row with home-relative path and git status', () => { const { lastFrame } = renderHomeScreen({ - gitStatusLabel: '⎇ feat/first-ink-tui-jsonrpc-backend*+%', + gitStatus: { + label: '⎇ feat/first-ink-tui-jsonrpc-backend*+%', + pullRequestLabel: '#3' + }, columns: 100, rows: 20 }); @@ -221,10 +229,30 @@ describe('HomeScreen', () => { const output = lastFrame() ?? ''; expect(formatDisplayCwd(workspaceCwd)).toBe(displayCwd); - expect(output).toContain(`${displayCwd} [⎇ feat/first-ink-tui-jsonrpc-backend*+%]`); + expect(output).toContain(`${displayCwd} [⎇ feat/first-ink-tui-jsonrpc-backend*+%] [#3]`); expect(output).not.toContain('cwd '); }); + it('renders the PR label as a dotted-underline OSC 8 hyperlink when a PR url is present', () => { + const { lastFrame } = renderHomeScreen({ + gitStatus: { + label: '⎇ main*', + pullRequestLabel: '#3', + pullRequestUrl: 'https://github.com/o/r/pull/3' + }, + columns: 100, + rows: 20 + }); + + const output = lastFrame() ?? ''; + + // OSC 8 hyperlink pointing at the PR, wrapping a #3 that carries a primed + // dotted underline (4m then 4:3m) with its 24m reset intact. + expect(output).toContain( + '\u001B]8;;https://github.com/o/r/pull/3\u0007\u001B[4m\u001B[4:3m#3\u001B[24m' + ); + }); + it('soft-wraps a long cwd without truncating it', () => { const longWorkspace = path.join( workspaceCwd, @@ -284,7 +312,7 @@ describe('HomeScreen', () => { }); it('keeps cwd, composer, status hints, and model label visible at the minimum 60x15', async () => { - const { lastFrame, stdin } = renderHomeScreen({ columns: 60, rows: 15 }); + const { lastFrame, stdin, store } = renderHomeScreen({ columns: 60, rows: 15 }); const output = lastFrame() ?? ''; @@ -302,17 +330,10 @@ describe('HomeScreen', () => { const wrappedOutput = lastFrame() ?? ''; const wrappedRows = wrappedOutput.split('\n'); - // The composer soft-wraps across rows; rejoining the rows and dropping the - // one prompt prefix plus the 2-space padding/indent runs (continuations are - // indented under `> `) must reproduce the full typed prompt. - const composerFlattened = wrappedRows - .map((row) => row.replace(/\s+$/, '')) - .join('') - .replace(/ {2,}/g, '') - .replace('> ', ''); - expect(wrappedOutput).not.toContain('...'); - expect(composerFlattened).toContain(typed); + expect(store.get(composerStateAtom).text).toBe(typed); + expect(wrappedOutput).toContain('a long prompt that wraps across several visible composer'); + expect(wrappedOutput).toContain('rows'); expect(wrappedRows).toHaveLength(15); expect(wrappedRows.at(-2)).toContain('▀'); expect(wrappedRows.at(-1)).toContain('/ commands | @ mention | ? help'); @@ -320,7 +341,11 @@ describe('HomeScreen', () => { it('soft-wraps long prompts instead of truncating them with an ellipsis', async () => { const longPrompt = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - const { lastFrame, stdin } = renderHomeScreen({ bodyEntries: [], columns: 60, rows: 15 }); + const { lastFrame, stdin } = renderHomeScreen({ + bodyEntries: [], + columns: 60, + rows: 15 + }); stdin.write(longPrompt); await flushInput(); diff --git a/tui/src/__tests__/components/PromptComposer.caret.test.tsx b/tui/src/__tests__/components/PromptComposer.caret.test.tsx new file mode 100644 index 00000000..660ea142 --- /dev/null +++ b/tui/src/__tests__/components/PromptComposer.caret.test.tsx @@ -0,0 +1,172 @@ +import { createStore } from 'jotai'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { HomeScreenView } from '@components/HomeScreen/HomeScreenView.tsx'; +import { + composerCaretRefreshTickAtom, + composerStateAtom +} from '@state/ui/composer/index.ts'; +import { columnsTestOverrideAtom, rowsTestOverrideAtom } from '@state/ui/dimensions.ts'; +import { + bodyEntriesAtom, + composerRowsAtom, + composerTopAtom +} from '@state/ui/index.ts'; +import { startupStatusHintAtom } from '@state/ui/statusHint.ts'; +import { BACKEND_LOADING_HINT } from '@constants/statusHint.ts'; +import { + COMPOSER_BACKGROUND_TOP_PADDING_ROWS, + PROMPT_PREFIX +} from '@constants/ui.ts'; +import { flushInput } from '@test/flushInput.ts'; +import { renderWithJotai } from '@test/renderWithJotai.tsx'; + +const { composerFrameSpy, setCursorPositionSpy } = vi.hoisted(() => ({ + composerFrameSpy: vi.fn(), + setCursorPositionSpy: vi.fn() +})); + +vi.mock('ink', async () => { + const actual = await vi.importActual('ink'); + return { + ...actual, + useCursor: () => ({ setCursorPosition: setCursorPositionSpy }) + }; +}); + +vi.mock('@components/PromptComposer/ComposerFrame.tsx', async () => { + const actual = await vi.importActual< + typeof import('@components/PromptComposer/ComposerFrame.tsx') + >('@components/PromptComposer/ComposerFrame.tsx'); + return { + ...actual, + ComposerFrame: ( + props: Parameters[0] + ) => { + composerFrameSpy(); + return actual.ComposerFrame(props); + } + }; +}); + +describe('PromptComposer caret positioning', () => { + beforeEach(() => { + composerFrameSpy.mockClear(); + setCursorPositionSpy.mockClear(); + }); + + it('keeps the caret on the composer text row while startup locks input', async () => { + const store = createStore(); + store.set(columnsTestOverrideAtom, 60); + store.set(rowsTestOverrideAtom, 24); + store.set(startupStatusHintAtom, BACKEND_LOADING_HINT); + + const { stdin, unmount } = renderWithJotai(, store); + + stdin.write('blocked'); + await flushInput(); + + expect(store.get(composerStateAtom).text).toBe(''); + + await vi.waitFor(() => { + expect(setCursorPositionSpy.mock.calls.at(-1)?.[0]).toEqual({ + x: PROMPT_PREFIX.length, + y: store.get(composerTopAtom) + COMPOSER_BACKGROUND_TOP_PADDING_ROWS + }); + }); + + unmount(); + }); + + it('re-asserts the caret after a scroll repaint without re-rendering the frame', async () => { + const store = createStore(); + store.set(columnsTestOverrideAtom, 60); + store.set(rowsTestOverrideAtom, 24); + store.set( + bodyEntriesAtom, + Array.from({ length: 20 }, (_, index) => ({ + kind: 'assistant' as const, + text: `entry ${index + 1}` + })) + ); + + const { stdin, unmount } = renderWithJotai(, store); + await flushInput(); + await vi.waitFor(() => { + expect(setCursorPositionSpy.mock.calls.at(-1)?.[0]).toBeDefined(); + }); + const initialPosition = setCursorPositionSpy.mock.calls.at(-1)?.[0]; + composerFrameSpy.mockClear(); + setCursorPositionSpy.mockClear(); + + stdin.write('\u001B[5~'); + await flushInput(); + + expect(store.get(composerCaretRefreshTickAtom)).toBe(1); + await vi.waitFor(() => { + expect(setCursorPositionSpy.mock.calls.at(-1)?.[0]).toEqual(initialPosition); + }); + expect(composerFrameSpy).not.toHaveBeenCalled(); + + unmount(); + }); + + it('places a soft-wrap boundary caret at the next row start', async () => { + const store = createStore(); + store.set(columnsTestOverrideAtom, 60); + store.set(rowsTestOverrideAtom, 24); + // 60 terminal columns - 1 final-cell gutter - 2 prefix - 1 right padding. + const inputColumns = 56; + const text = 'a'.repeat(inputColumns * 2); + store.set(composerStateAtom, { text, cursorIndex: 0, validationError: null }); + + const { unmount } = renderWithJotai(, store); + await vi.waitFor(() => { + expect(setCursorPositionSpy.mock.calls.at(-1)?.[0]).toBeDefined(); + }); + const firstRowPosition = setCursorPositionSpy.mock.calls.at(-1)?.[0]; + + store.set(composerStateAtom, { text, cursorIndex: inputColumns, validationError: null }); + + await vi.waitFor(() => { + const boundaryPosition = setCursorPositionSpy.mock.calls.at(-1)?.[0]; + expect(boundaryPosition).toEqual({ + x: firstRowPosition.x, + y: firstRowPosition.y + 1 + }); + }); + + unmount(); + }); + + it('moves the caret to a new row when the final line exactly fills its width', async () => { + const store = createStore(); + store.set(columnsTestOverrideAtom, 60); + store.set(rowsTestOverrideAtom, 24); + const inputColumns = 56; + const text = 'a'.repeat(inputColumns); + store.set(composerStateAtom, { text, cursorIndex: 0, validationError: null }); + + const { unmount } = renderWithJotai(, store); + await vi.waitFor(() => { + expect(setCursorPositionSpy.mock.calls.at(-1)?.[0]).toBeDefined(); + }); + const firstRowPosition = setCursorPositionSpy.mock.calls.at(-1)?.[0]; + const initialComposerRows = store.get(composerRowsAtom); + + store.set(composerStateAtom, { + text, + cursorIndex: text.length, + validationError: null + }); + + await vi.waitFor(() => { + expect(setCursorPositionSpy.mock.calls.at(-1)?.[0]).toEqual({ + x: firstRowPosition.x, + y: firstRowPosition.y + }); + expect(store.get(composerRowsAtom)).toBe(initialComposerRows + 1); + }); + + unmount(); + }); +}); diff --git a/tui/src/__tests__/components/PromptComposer.test.tsx b/tui/src/__tests__/components/PromptComposer.test.tsx index 7570ed1a..a6931448 100644 --- a/tui/src/__tests__/components/PromptComposer.test.tsx +++ b/tui/src/__tests__/components/PromptComposer.test.tsx @@ -1,27 +1,39 @@ import { createStore } from 'jotai'; import { describe, expect, it, vi } from 'vitest'; -import { - formatVisiblePrompt, - PromptComposer, - resolveComposerCursorPosition -} from '@components/PromptComposer/index.tsx'; +import { Box } from 'ink'; +import { PromptComposer } from '@components/PromptComposer/index.tsx'; +import { resolveComposerCursorPosition } from '@libs/composer/cursorPosition.ts'; import { enqueuePromptAtom } from '@state/promptQueue/index.ts'; import { commandMenuDismissedAtom, highlightedCommandAtom } from '@state/ui/commands/index.ts'; import { armedActionAtom } from '@state/ui/index.ts'; import { ArmedAction } from '@constants/ui.ts'; import { helpVisibleAtom } from '@state/ui/help/index.ts'; -import { composerStateAtom } from '@state/ui/composer/index.ts'; -import { submittedPromptEntriesAtom } from '@state/ui/index.ts'; +import { composerScrollOffsetRowsAtom, composerStateAtom } from '@state/ui/composer/index.ts'; +import { columnsTestOverrideAtom, rowsTestOverrideAtom } from '@state/ui/dimensions.ts'; +import { scrollComposerByRowsAtom, submittedPromptEntriesAtom } from '@state/ui/index.ts'; import { flushInput } from '@test/flushInput.ts'; import { renderWithJotai } from '@test/renderWithJotai.tsx'; describe('PromptComposer', () => { - it('preserves long prompt content while rendering a wrapped visible view', () => { - expect(formatVisiblePrompt('abcdefghijklmnop', 8, 3)).toBe('abcdefgh\nijklmnop'); + it('keeps every composer row out of the terminal final column', () => { + const { lastFrame } = renderWithJotai( + + + + ); + + for (const line of (lastFrame() ?? '').split('\n')) { + expect(line.length).toBeLessThanOrEqual(19); + } }); - it('keeps authored newlines in the visible prompt window', () => { - expect(formatVisiblePrompt('first\nsecond', 10, 3)).toBe('first\nsecond'); + it('reserves one background column as right padding inside the composer', async () => { + const { lastFrame, stdin } = renderWithJotai(); + + stdin.write('abcdefghijklmnopq'); + await flushInput(); + + expect(lastFrame() ?? '').toContain('> abcdefghijklmnop\n q'); }); it('indents authored continuation lines under the prompt text', async () => { @@ -48,7 +60,9 @@ describe('PromptComposer', () => { ['Shift+Enter', '\u001B[13;2u'], ['Alt+Enter', '\u001B[13;3u'], ['Ctrl+Enter', '\u001B[13;5u'], - ['Ctrl+Shift+Enter', '\u001B[13;6u'] + ['Ctrl+Shift+Enter', '\u001B[13;6u'], + ['xterm Shift+Enter', '\u001B[27;2;13~'], + ['xterm Alt+Enter', '\u001B[27;3;13~'] ])('uses %s to insert a newline without submitting', async (_label, input) => { const onSubmit = vi.fn(); const { stdin } = renderWithJotai(); @@ -67,6 +81,33 @@ describe('PromptComposer', () => { expect(onSubmit).toHaveBeenCalledWith('first\nsecond'); }); + it('scrolls the caret back into view after a backslash-Enter edit that keeps the cursor index', async () => { + const store = createStore(); + store.set(columnsTestOverrideAtom, 60); + store.set(rowsTestOverrideAtom, 24); + // A long prompt (overflows the composer cap) ending in a backslash, caret at end. + const text = `${Array.from({ length: 20 }, (_, index) => `line ${index}`).join('\n')}\\`; + store.set(composerStateAtom, { text, cursorIndex: text.length, validationError: null }); + + const { stdin } = renderWithJotai(, store); + await flushInput(); + + // Peek toward the top so the caret (at the end) is scrolled off-window. + store.set(scrollComposerByRowsAtom, 999); + await flushInput(); + expect(store.get(composerScrollOffsetRowsAtom)).toBeGreaterThan(0); + + // Bare Enter on the trailing `\` deletes it and inserts a newline in one + // keypress: the text changes but the net cursor index does not. The caret + // must still snap back into view (regression guard for the effect keying on + // text as well as cursor index). + stdin.write('\r'); + await flushInput(); + + expect(store.get(composerStateAtom).text).toBe(`${text.slice(0, -1)}\n`); + expect(store.get(composerScrollOffsetRowsAtom)).toBe(0); + }); + it('uses backslash followed by Enter as a newline fallback without submitting', async () => { const onSubmit = vi.fn(); const { lastFrame, stdin } = renderWithJotai( @@ -103,24 +144,25 @@ describe('PromptComposer', () => { }); it('places the terminal cursor on the active composer row instead of the cwd row', () => { - // y = composerTop(7) + background-top-pad(1) + cursor-row + INK_CURSOR_ROW_ORIGIN_OFFSET(1, fullscreen) - expect(resolveComposerCursorPosition('', 38, 7)).toEqual({ x: 2, y: 9 }); - expect(resolveComposerCursorPosition('123', 38, 7)).toEqual({ x: 5, y: 9 }); + expect(resolveComposerCursorPosition('', 38, 7)).toEqual({ x: 2, y: 8 }); + expect(resolveComposerCursorPosition('123', 38, 7)).toEqual({ x: 5, y: 8 }); + }); + + it('places the terminal cursor using display width for wide glyphs', () => { + expect(resolveComposerCursorPosition('界a', 38, 7)).toEqual({ x: 5, y: 8 }); }); it('places the terminal cursor on the active authored multiline composer row', () => { - expect(resolveComposerCursorPosition('first\nsecond', 38, 7)).toEqual({ x: 8, y: 10 }); + expect(resolveComposerCursorPosition('first\nsecond', 38, 7)).toEqual({ x: 8, y: 9 }); }); it('places the terminal cursor on the active soft-wrapped composer row', () => { - const visibleText = formatVisiblePrompt('abcdefghijklmnop', 8, 3); - - expect(visibleText).toBe('abcdefgh\nijklmnop'); - expect(resolveComposerCursorPosition(visibleText, 8, 7)).toEqual({ x: 10, y: 10 }); + const visibleText = 'abcdefgh\nijklmnop'; + expect(resolveComposerCursorPosition(visibleText, 8, 7)).toEqual({ x: 10, y: 9 }); }); it('places the terminal cursor at an authored middle position', () => { - expect(resolveComposerCursorPosition('abcd', 38, 7, 2)).toEqual({ x: 4, y: 9 }); + expect(resolveComposerCursorPosition('abcd', 38, 7, 2)).toEqual({ x: 4, y: 8 }); }); it('submits exact non-empty text with leading and trailing spaces, then clears', async () => { @@ -379,4 +421,59 @@ describe('PromptComposer', () => { expect(store.get(composerStateAtom).text).toBe('hellox'); expect(lastFrame() ?? '').toContain('hellox'); }); + + it('renders the cursor-follow bottom window of an overflowing prompt at offset 0', () => { + const store = createStore(); + store.set(composerStateAtom, { + text: '1111\n2222\n3333\n4444\n5555', + cursorIndex: 24, + validationError: null + }); + + const { lastFrame } = renderWithJotai( + , + store + ); + const frame = lastFrame() ?? ''; + + expect(frame).toContain('5555'); + expect(frame).not.toContain('1111'); + }); + + it('scrolls the composer view to earlier rows when a scroll offset is set', () => { + const store = createStore(); + store.set(composerStateAtom, { + text: '1111\n2222\n3333\n4444\n5555', + cursorIndex: 24, + validationError: null + }); + store.set(composerScrollOffsetRowsAtom, 2); + + const { lastFrame } = renderWithJotai( + , + store + ); + const frame = lastFrame() ?? ''; + + expect(frame).toContain('1111'); + expect(frame).not.toContain('5555'); + }); + + it('moves the cursor between visual lines with the Up and Down arrows', async () => { + const store = createStore(); + const { stdin } = renderWithJotai(, store); + store.set(composerStateAtom, { + text: 'aaa\nbbb\nccc', + cursorIndex: 11, + validationError: null + }); + + stdin.write('\u001B[A'); // Up + await flushInput(); + expect(store.get(composerStateAtom).cursorIndex).toBe(7); + + stdin.write('\u001B[B'); // Down + await flushInput(); + expect(store.get(composerStateAtom).cursorIndex).toBe(11); + }); }); diff --git a/tui/src/__tests__/components/StatusBar.test.tsx b/tui/src/__tests__/components/StatusBar.test.tsx index 2ee5f884..147d4b77 100644 --- a/tui/src/__tests__/components/StatusBar.test.tsx +++ b/tui/src/__tests__/components/StatusBar.test.tsx @@ -32,4 +32,12 @@ describe('StatusBar', () => { const { lastFrame } = renderWithJotai(, store); expect(lastFrame() ?? '').toContain('ctrl+c again to exit'); }); + + it('keeps the model label out of the terminal final column', () => { + const { lastFrame } = renderWithJotai(, makeStore()); + const line = (lastFrame() ?? '').split('\n')[0] ?? ''; + + expect(line).toHaveLength(79); + expect(line.endsWith('GPT-5.5')).toBe(true); + }); }); diff --git a/tui/src/__tests__/inkCursorPatch.test.ts b/tui/src/__tests__/inkCursorPatch.test.ts new file mode 100644 index 00000000..e2ae6f0b --- /dev/null +++ b/tui/src/__tests__/inkCursorPatch.test.ts @@ -0,0 +1,65 @@ +import ansiEscapes from 'ansi-escapes'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + buildCursorOnlySequence, + buildCursorSuffix +} from '../../node_modules/ink/build/cursor-helpers.js'; +import parseKeypress from '../../node_modules/ink/build/parse-keypress.js'; + +const showCursor = '\u001B[?25h'; +const hideCursor = '\u001B[?25l'; + +describe('patched Ink fullscreen cursor baseline', () => { + it('treats the final visible row as the bottom when output has no newline', () => { + expect( + buildCursorSuffix(3, { x: 2, y: 2 }, false) + ).toBe(`${ansiEscapes.cursorTo(2)}${showCursor}`); + + expect( + buildCursorSuffix(3, { x: 2, y: 2 }, true) + ).toBe(`${ansiEscapes.cursorUp(1)}${ansiEscapes.cursorTo(2)}${showCursor}`); + }); + + describe('patched Ink modified Enter parsing', () => { + it.each([ + ['Shift+Enter', '\u001B[27;2;13~', { shift: true, meta: false, ctrl: false }], + ['Alt+Enter', '\u001B[27;3;13~', { shift: false, meta: true, ctrl: false }], + ['Ctrl+Enter', '\u001B[27;5;13~', { shift: false, meta: false, ctrl: true }] + ])('parses xterm %s sequences', (_label, input, modifiers) => { + expect(parseKeypress(input)).toMatchObject({ + name: 'return', + ...modifiers + }); + }); + }); + + it('moves cursor-only updates from the physical bottom without vertical drift', () => { + expect( + buildCursorOnlySequence({ + cursorWasShown: true, + previousLineCount: 3, + previousCursorPosition: { x: 4, y: 2 }, + visibleLineCount: 3, + cursorPosition: { x: 3, y: 1 }, + hasTrailingNewline: false + }) + ).toBe( + `${hideCursor}${ansiEscapes.cursorTo(0)}${ansiEscapes.cursorUp(1)}${ansiEscapes.cursorTo(3)}${showCursor}` + ); + }); + + it('writes the same newline shape that clear-terminal synchronization records', () => { + const testDir = path.dirname(fileURLToPath(import.meta.url)); + const inkSource = fs.readFileSync( + path.resolve(testDir, '../../node_modules/ink/build/ink.js'), + 'utf8' + ); + + expect(inkSource).toContain( + 'ansiEscapes.clearTerminal + this.fullStaticOutput + outputToRender' + ); + }); +}); diff --git a/tui/src/__tests__/selectionClipboard.test.ts b/tui/src/__tests__/selectionClipboard.test.ts new file mode 100644 index 00000000..fd89a0cb --- /dev/null +++ b/tui/src/__tests__/selectionClipboard.test.ts @@ -0,0 +1,225 @@ +import { createStore } from 'jotai'; +import { describe, expect, it, vi } from 'vitest'; +import { + PASTE_FAILED_HINT, + RIGHT_CLICK_PASTE_DEDUP_MS, + SELECTION_COPIED_HINT, + SELECTION_COPY_FAILED_HINT +} from '@constants/ui.ts'; +import { + copySelection, + handleRightClick +} from '@hooks/homeScreen/selectionClipboard.ts'; +import { composerTarget } from '@hooks/promptComposer/pasteFromClipboard.ts'; +import { PROMPT_MAX_BYTES } from '@libs/composer/promptText.ts'; +import { clipboardClientAtom } from '@state/global/index.ts'; +import { + bodyEntriesAtom, + bodySelectionAtom, + columnsTestOverrideAtom, + rowsTestOverrideAtom, + shouldInsertBracketedPasteAtom, + transientStatusHintAtom +} from '@state/ui/index.ts'; +import { composerStateAtom } from '@state/ui/composer/index.ts'; + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function seededStore() { + const store = createStore(); + store.set(columnsTestOverrideAtom, 80); + store.set(rowsTestOverrideAtom, 24); + store.set(bodyEntriesAtom, [{ kind: 'success', text: 'clipboard text' }]); + store.set(bodySelectionAtom, { + anchor: { rowIndex: 0, column: 0 }, + focus: { rowIndex: 0, column: 9 } + }); + return store; +} + +describe('selection clipboard actions', () => { + it('surfaces a failed clipboard write', async () => { + const store = seededStore(); + store.set(clipboardClientAtom, { + readText: vi.fn(), + writeText: vi.fn().mockResolvedValue(false) + }); + + expect(copySelection(store)).toBe(true); + await flushPromises(); + + expect(store.get(transientStatusHintAtom)?.text).toBe(SELECTION_COPY_FAILED_HINT); + }); + + it('keeps a failed copy fence only for the same-click native paste', async () => { + vi.useFakeTimers(); + try { + const store = seededStore(); + store.set(clipboardClientAtom, { + readText: vi.fn(), + writeText: vi.fn().mockResolvedValue(false) + }); + + handleRightClick(store, PROMPT_MAX_BYTES); + await vi.advanceTimersByTimeAsync(0); + + expect( + store.set(shouldInsertBracketedPasteAtom, { + target: composerTarget(store) + }) + ).toBe(false); + expect( + store.set(shouldInsertBracketedPasteAtom, { + target: composerTarget(store) + }) + ).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('surfaces a failed right-click clipboard read', async () => { + vi.useFakeTimers(); + try { + const store = seededStore(); + store.set(bodySelectionAtom, null); + store.set(clipboardClientAtom, { + readText: vi.fn().mockResolvedValue(null), + writeText: vi.fn() + }); + + handleRightClick(store, PROMPT_MAX_BYTES); + await vi.advanceTimersByTimeAsync(0); + + expect(store.get(transientStatusHintAtom)).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(RIGHT_CLICK_PASTE_DEDUP_MS); + + expect(store.get(transientStatusHintAtom)?.text).toBe(PASTE_FAILED_HINT); + } finally { + vi.useRealTimers(); + } + }); + + it('starts only one read for repeated right-clicks at the same target', async () => { + const store = seededStore(); + store.set(bodySelectionAtom, null); + let finishRead: (text: string) => void = () => undefined; + const readText = vi.fn( + () => + new Promise((resolve) => { + finishRead = resolve; + }) + ); + store.set(clipboardClientAtom, { readText, writeText: vi.fn() }); + + handleRightClick(store, PROMPT_MAX_BYTES); + handleRightClick(store, PROMPT_MAX_BYTES); + await flushPromises(); + + expect(readText).toHaveBeenCalledTimes(1); + + finishRead('clipboard once'); + await flushPromises(); + + expect(store.get(composerStateAtom).text).toBe('clipboard once'); + }); + + it('serializes a paste read after an earlier selection copy', async () => { + const store = seededStore(); + let finishWrite: (success: boolean) => void = () => undefined; + const writeText = vi.fn( + () => + new Promise((resolve) => { + finishWrite = resolve; + }) + ); + const readText = vi.fn().mockResolvedValue('new clipboard'); + store.set(clipboardClientAtom, { readText, writeText }); + + handleRightClick(store, PROMPT_MAX_BYTES); + handleRightClick(store, PROMPT_MAX_BYTES); + await flushPromises(); + + expect(readText).not.toHaveBeenCalled(); + finishWrite(true); + await flushPromises(); + await flushPromises(); + + expect(readText).toHaveBeenCalledTimes(1); + expect(store.get(composerStateAtom).text).toBe('new clipboard'); + }); + + it('drops buffered native text when the authoritative read fails', async () => { + vi.useFakeTimers(); + try { + const store = seededStore(); + let finishWrite: (success: boolean) => void = () => undefined; + const readText = vi.fn().mockResolvedValue(null); + store.set(clipboardClientAtom, { + readText, + writeText: vi.fn( + () => + new Promise((resolve) => { + finishWrite = resolve; + }) + ) + }); + + handleRightClick(store, PROMPT_MAX_BYTES); + handleRightClick(store, PROMPT_MAX_BYTES); + await vi.advanceTimersByTimeAsync(0); + + expect( + store.set(shouldInsertBracketedPasteAtom, { + target: composerTarget(store) + }) + ).toBe(false); + + finishWrite(true); + await vi.advanceTimersByTimeAsync(0); + + expect(readText).toHaveBeenCalledTimes(1); + expect(store.get(composerStateAtom).text).toBe(''); + expect(store.get(transientStatusHintAtom)?.text).toBe(SELECTION_COPIED_HINT); + expect( + store.set(shouldInsertBracketedPasteAtom, { + target: composerTarget(store) + }) + ).toBe(false); + + await vi.advanceTimersByTimeAsync(RIGHT_CLICK_PASTE_DEDUP_MS); + + expect(store.get(composerStateAtom).text).toBe(''); + expect(store.get(transientStatusHintAtom)?.text).toBe(PASTE_FAILED_HINT); + } finally { + vi.useRealTimers(); + } + }); + + it('drops a late clipboard result after the composer target changes', async () => { + const store = seededStore(); + store.set(bodySelectionAtom, null); + let finishRead: (text: string) => void = () => undefined; + store.set(clipboardClientAtom, { + readText: vi.fn( + () => + new Promise((resolve) => { + finishRead = resolve; + }) + ), + writeText: vi.fn() + }); + + handleRightClick(store, PROMPT_MAX_BYTES); + store.set(composerStateAtom, { + cursorIndex: 5, + text: 'typed', + validationError: null + }); + finishRead('stale clipboard'); + await flushPromises(); + + expect(store.get(composerStateAtom).text).toBe('typed'); + }); +}); diff --git a/tui/src/backend/client/__tests__/backendClient.test.ts b/tui/src/backend/client/__tests__/backendClient.test.ts index 04f6b221..55405f4b 100644 --- a/tui/src/backend/client/__tests__/backendClient.test.ts +++ b/tui/src/backend/client/__tests__/backendClient.test.ts @@ -1,4 +1,6 @@ import { PassThrough } from 'node:stream'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -10,8 +12,12 @@ import { } from 'vscode-jsonrpc/node'; import { BackendClientError, BackendErrorKind } from '@contracts/backend/index.ts'; import { type LaunchedBackend } from '@backend/process/backendProcess.ts'; -import { ACK_MESSAGE } from '@contracts/backend/index.ts'; -import { messageSubmitRequest, backendReadyNotification } from '@backend/protocol/messageProtocol.ts'; +import { SUBMIT_STATUS_NEEDS_CONFIGURATION } from '@contracts/backend/index.ts'; +import { + gitStatusRequest, + messageSubmitRequest, + backendReadyNotification +} from '@backend/protocol/messageProtocol.ts'; import type { MessageSubmitResult } from '@contracts/backend/index.ts'; import { BackendLifecycleState, @@ -26,12 +32,17 @@ type FakeBackend = { launched: LaunchedBackend; disposed: () => boolean; emitExit: () => void; + closeTransport: () => void; }; let openServers: MessageConnection[] = []; +// A fake backend whose submit deterministically acks needsConfiguration, since no +// provider is wired in this bootstrap slice. function ack(server: MessageConnection): void { - server.onRequest(messageSubmitRequest, ({ text }) => ({ message: ACK_MESSAGE, receivedText: text })); + server.onRequest(messageSubmitRequest, () => ({ + status: SUBMIT_STATUS_NEEDS_CONFIGURATION + })); } function makeFakeBackend( @@ -73,6 +84,10 @@ function makeFakeBackend( for (const listener of exitListeners) { listener({ code: 1, signal: null }); } + }, + closeTransport: () => { + backendStdout.end(); + backendStdin.end(); } }; } @@ -95,8 +110,8 @@ describe('createBackendClient (fake backend)', () => { expect(client.getState()).toBe(BackendLifecycleState.Ready); expect(launch).toHaveBeenCalledTimes(1); - const result = await client.submitMessage({ text: 'hello' }); - expect(result).toEqual({ message: ACK_MESSAGE, receivedText: 'hello' }); + const result = await client.submit({ text: 'hello' }); + expect(result).toEqual({ kind: 'needsConfiguration' }); expect(launch).toHaveBeenCalledTimes(1); client.dispose(); }); @@ -106,9 +121,9 @@ describe('createBackendClient (fake backend)', () => { const client = createBackendClient({ launch: async () => fake.launched }); expect(client.getState()).toBe(BackendLifecycleState.Idle); - const result = await client.submitMessage({ text: 'hello' }); + const result = await client.submit({ text: 'hello' }); - expect(result).toEqual({ message: ACK_MESSAGE, receivedText: 'hello' }); + expect(result).toEqual({ kind: 'needsConfiguration' }); expect(client.getState()).toBe(BackendLifecycleState.Ready); client.dispose(); }); @@ -128,6 +143,19 @@ describe('createBackendClient (fake backend)', () => { client.dispose(); }); + it('rejects with a transport error when the backend dies before readiness', async () => { + const fake = makeFakeBackend(ack, { signalReady: false }); + const client = createBackendClient({ launch: async () => fake.launched }); + + const start = client.ensureStarted(); + fake.closeTransport(); + + await expect(start).rejects.toMatchObject({ kind: BackendErrorKind.Transport }); + expect(client.getState()).toBe(BackendLifecycleState.Dead); + expect(fake.disposed()).toBe(true); + client.dispose(); + }); + it('keeps the backend alive after a recoverable JSON-RPC method error', async () => { const fake = makeFakeBackend((server) => server.onRequest(messageSubmitRequest, () => { @@ -137,13 +165,13 @@ describe('createBackendClient (fake backend)', () => { const launch = vi.fn(async () => fake.launched); const client = createBackendClient({ launch }); - await expect(client.submitMessage({ text: 'x' })).rejects.toMatchObject({ + await expect(client.submit({ text: 'x' })).rejects.toMatchObject({ kind: BackendErrorKind.Protocol }); expect(client.getState()).toBe(BackendLifecycleState.Ready); expect(fake.disposed()).toBe(false); - await expect(client.submitMessage({ text: 'y' })).rejects.toMatchObject({ + await expect(client.submit({ text: 'y' })).rejects.toMatchObject({ kind: BackendErrorKind.Protocol }); expect(launch).toHaveBeenCalledTimes(1); @@ -159,24 +187,39 @@ describe('createBackendClient (fake backend)', () => { const launch = vi.fn(async () => backends.shift()?.launched as LaunchedBackend); const client = createBackendClient({ launch, requestTimeoutMs: 100 }); - await expect(client.submitMessage({ text: 'first' })).rejects.toMatchObject({ + await expect(client.submit({ text: 'first' })).rejects.toMatchObject({ kind: BackendErrorKind.Timeout }); expect(client.getState()).toBe(BackendLifecycleState.Dead); expect(hung.disposed()).toBe(true); - const result = await client.submitMessage({ text: 'second' }); - expect(result.receivedText).toBe('second'); + const result = await client.submit({ text: 'second' }); + expect(result).toEqual({ kind: 'needsConfiguration' }); expect(client.getState()).toBe(BackendLifecycleState.Ready); expect(launch).toHaveBeenCalledTimes(2); client.dispose(); }); + it('does not mark the shared backend dead when best-effort git status times out', async () => { + const fake = makeFakeBackend((server) => { + ack(server); + server.onRequest(gitStatusRequest, () => new Promise(() => undefined)); + }); + const client = createBackendClient({ launch: async () => fake.launched, requestTimeoutMs: 50 }); + + await expect(client.gitStatus()).rejects.toMatchObject({ kind: BackendErrorKind.Timeout }); + expect(client.getState()).toBe(BackendLifecycleState.Ready); + + const result = await client.submit({ text: 'still alive' }); + expect(result).toEqual({ kind: 'needsConfiguration' }); + client.dispose(); + }); + it('marks the client dead when the backend process exits', async () => { const fake = makeFakeBackend(ack); const client = createBackendClient({ launch: async () => fake.launched }); - await client.submitMessage({ text: 'alive' }); + await client.submit({ text: 'alive' }); expect(client.getState()).toBe(BackendLifecycleState.Ready); fake.emitExit(); @@ -194,7 +237,7 @@ describe('createBackendClient (fake backend)', () => { client.dispose(); - await expect(client.submitMessage({ text: 'after dispose' })).rejects.toMatchObject({ + await expect(client.submit({ text: 'after dispose' })).rejects.toMatchObject({ kind: BackendErrorKind.Launch }); // The disposed client is terminal: no fresh backend is launched. @@ -213,7 +256,7 @@ describe('createBackendClient (fake backend)', () => { ); const client = createBackendClient({ launch }); - const submit = client.submitMessage({ text: 'race' }); + const submit = client.submit({ text: 'race' }); client.dispose(); resolveLaunch?.(fake.launched); @@ -225,15 +268,24 @@ describe('createBackendClient (fake backend)', () => { describe('createSourceBackendClient (integration)', () => { it( - 'starts the Rust backend, submits, and receives the ACK with exact receivedText', + 'builds and launches the Rust backend, routing to configuration without a key', async () => { - const client = createSourceBackendClient({ repoRoot, workspaceCwd: repoRoot }); + // Provider setup lands later, so bootstrap submit deterministically returns + // needsConfiguration regardless of the workspace. + const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'kqode-src-client-')); + const client = createSourceBackendClient({ repoRoot, workspaceCwd }); try { - const result = await client.submitMessage({ text: ' café\n☕ ' }); - expect(result.message).toBe(ACK_MESSAGE); - expect(result.receivedText).toBe(' café\n☕ '); + const outcome = await client.submit({ text: 'hello' }); + expect(outcome).toEqual({ kind: 'needsConfiguration' }); } finally { client.dispose(); + // Best-effort: on Windows the just-killed backend may still hold the cwd + // handle briefly; the OS reclaims the temp dir regardless. + try { + fs.rmSync(workspaceCwd, { recursive: true, force: true }); + } catch { + /* temp cleanup is best-effort */ + } } }, INTEGRATION_TIMEOUT_MS diff --git a/tui/src/backend/client/__tests__/packagedBackendClient.test.ts b/tui/src/backend/client/__tests__/packagedBackendClient.test.ts index 6417fe47..2d86d404 100644 --- a/tui/src/backend/client/__tests__/packagedBackendClient.test.ts +++ b/tui/src/backend/client/__tests__/packagedBackendClient.test.ts @@ -32,7 +32,7 @@ describe('createPackagedBackendClient', () => { cacheBaseDir: tempCacheBase() }); - await expect(client.submitMessage({ text: 'hello' })).rejects.toMatchObject({ + await expect(client.submit({ text: 'hello' })).rejects.toMatchObject({ kind: BackendErrorKind.Launch }); expect(client.getState()).toBe(BackendLifecycleState.Dead); diff --git a/tui/src/backend/client/backendClient.ts b/tui/src/backend/client/backendClient.ts index f5c62935..91a0e3e6 100644 --- a/tui/src/backend/client/backendClient.ts +++ b/tui/src/backend/client/backendClient.ts @@ -4,34 +4,20 @@ import type { BackendClient } from '@contracts/backend/index.ts'; import { DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_STARTUP_TIMEOUT_MS } from '@constants/backend.ts'; import type { LaunchedBackend } from '@backend/process/backendProcess.ts'; import { createMessageConnectionClient } from '@backend/client/messageConnectionClient.ts'; +import { BackendLifecycleState } from '@backend/client/backendLifecycle.ts'; +import type { BackendLifecycleState as BackendLifecycleStateValue } from '@backend/client/backendLifecycle.ts'; import { openReadyConnection } from '@backend/client/backendReadiness.ts'; import { isFatalBackendError, - toLaunchError, - withRequestTimeout + toLaunchError } from '@backend/client/backendClientErrors.ts'; -import type { MessageSubmitParams, MessageSubmitResult } from '@contracts/backend/index.ts'; - -/** Lifecycle of the TUI-owned backend connection. */ -export const BackendLifecycleState = { - /** No backend has been launched yet. */ - Idle: 'idle', - /** A backend launch/connect is in flight. */ - Starting: 'starting', - /** The backend is connected and accepting requests. */ - Ready: 'ready', - /** The TUI is disposing the backend on purpose. */ - Closing: 'closing', - /** The backend exited, crashed, or a fatal transport error occurred. */ - Dead: 'dead' -} as const; - -export type BackendLifecycleState = - (typeof BackendLifecycleState)[keyof typeof BackendLifecycleState]; +import type { SubmitOutcome, SubmitParams } from '@contracts/backend/index.ts'; + +export { BackendLifecycleState }; /** Lifecycle handle over a {@link BackendClient} that owns one child backend at a time. */ export type BackendClientHandle = BackendClient & { - getState(): BackendLifecycleState; + getState(): BackendLifecycleStateValue; ensureStarted(): Promise; dispose(): void; }; @@ -60,7 +46,7 @@ type BackendSession = { * backend (persisted session restore is added with the session methods), never * silently and never auto-replaying interrupted work. * - * `dispose()` is terminal: once disposed, `ensureStarted`/`submitMessage` reject + * `dispose()` is terminal: once disposed, `ensureStarted`/`submit` reject * with a `launch`-kind {@link BackendClientError} without spawning a replacement, * so a torn-down client can never orphan a new backend process. */ @@ -69,7 +55,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien const startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; const { launch } = options; - let state: BackendLifecycleState = BackendLifecycleState.Idle; + let state: BackendLifecycleStateValue = BackendLifecycleState.Idle; let session: BackendSession | null = null; let starting: Promise | null = null; let disposed = false; @@ -83,7 +69,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien 'backend launch was aborted before it became ready' ); - const teardown = (nextState: BackendLifecycleState): void => { + const teardown = (nextState: BackendLifecycleStateValue): void => { const current = session; session = null; state = nextState; @@ -142,7 +128,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien const opened: BackendSession = { backend, connection, - client: createMessageConnectionClient(connection) + client: createMessageConnectionClient(connection, { requestTimeoutMs }) }; session = opened; state = BackendLifecycleState.Ready; @@ -169,13 +155,13 @@ export function createBackendClient(options: BackendClientOptions): BackendClien async ensureStarted(): Promise { await ensureSession(); }, - async submitMessage(params: MessageSubmitParams): Promise { + async submit(params: SubmitParams): Promise { if (disposed) { throw disposedError(); } const active = await ensureSession(); try { - return await withRequestTimeout(active.client.submitMessage(params), requestTimeoutMs); + return await active.client.submit(params); } catch (error) { if (isFatalBackendError(error)) { markDead(); @@ -183,6 +169,20 @@ export function createBackendClient(options: BackendClientOptions): BackendClien throw error; } }, + async gitStatus() { + if (disposed) { + throw disposedError(); + } + const active = await ensureSession(); + return active.client.gitStatus(); + }, + async pullRequest() { + if (disposed) { + throw disposedError(); + } + const active = await ensureSession(); + return active.client.pullRequest(); + }, dispose() { disposed = true; if (state === BackendLifecycleState.Dead) { diff --git a/tui/src/backend/client/backendLifecycle.ts b/tui/src/backend/client/backendLifecycle.ts new file mode 100644 index 00000000..ebbcebf3 --- /dev/null +++ b/tui/src/backend/client/backendLifecycle.ts @@ -0,0 +1,16 @@ +/** Lifecycle of the TUI-owned backend connection. */ +export const BackendLifecycleState = { + /** No backend has been launched yet. */ + Idle: 'idle', + /** A backend launch/connect is in flight. */ + Starting: 'starting', + /** The backend is connected and accepting requests. */ + Ready: 'ready', + /** The TUI is disposing the backend on purpose. */ + Closing: 'closing', + /** The backend exited, crashed, or a fatal transport error occurred. */ + Dead: 'dead' +} as const; + +export type BackendLifecycleState = + (typeof BackendLifecycleState)[keyof typeof BackendLifecycleState]; diff --git a/tui/src/backend/client/messageConnectionClient.ts b/tui/src/backend/client/messageConnectionClient.ts index 3126ad72..f7d9626b 100644 --- a/tui/src/backend/client/messageConnectionClient.ts +++ b/tui/src/backend/client/messageConnectionClient.ts @@ -1,29 +1,84 @@ import { type MessageConnection, ResponseError } from 'vscode-jsonrpc'; import { BackendClientError, BackendErrorKind } from '@contracts/backend/index.ts'; -import type { BackendClient } from '@contracts/backend/index.ts'; -import { messageSubmitRequest } from '@backend/protocol/messageProtocol.ts'; -import type { MessageSubmitParams, MessageSubmitResult } from '@contracts/backend/index.ts'; +import { SUBMIT_STATUS_NEEDS_CONFIGURATION } from '@contracts/backend/index.ts'; +import type { BackendClient, SubmitOutcome, SubmitParams } from '@contracts/backend/index.ts'; +import { + gitStatusRequest, + messageSubmitRequest, + pullRequestRequest +} from '@backend/protocol/messageProtocol.ts'; +import { withRequestTimeout } from '@backend/client/backendClientErrors.ts'; +import { DEFAULT_REQUEST_TIMEOUT_MS } from '@constants/backend.ts'; + +/** Composition inputs for {@link createMessageConnectionClient}. */ +export type MessageConnectionClientOptions = { + /** Ceiling for the submit ack response. */ + requestTimeoutMs?: number; +}; /** * Builds a {@link BackendClient} over an already-established JSON-RPC connection. * - * The caller owns the connection lifecycle; this wrapper only routes the - * `kqode.message.submit` request and normalizes failures into typed errors, so - * it can be exercised over in-memory streams without a Rust child process. + * The caller owns the connection lifecycle. `submit` sends the prompt and + * resolves on the backend's ack; in this bootstrap slice that ack is always + * `needsConfiguration` because no provider is wired yet. Transport/timeout + * failures reject with a {@link BackendClientError} (connection death is also + * surfaced to the caller through its own fatal-teardown listeners), so this can + * be exercised over in-memory streams without a Rust child process. The + * streaming notification channel arrives with the provider PR. */ -export function createMessageConnectionClient(connection: MessageConnection): BackendClient { +export function createMessageConnectionClient( + connection: MessageConnection, + options: MessageConnectionClientOptions = {} +): BackendClient { + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + return { - async submitMessage(params: MessageSubmitParams): Promise { + async submit(params: SubmitParams): Promise { + try { + const ack = await withRequestTimeout( + connection.sendRequest(messageSubmitRequest, { text: params.text }), + requestTimeoutMs + ); + if (ack.status === SUBMIT_STATUS_NEEDS_CONFIGURATION) { + return { kind: 'needsConfiguration' }; + } + throw new BackendClientError( + BackendErrorKind.Protocol, + `backend returned an unsupported submit status \`${ack.status}\`` + ); + } catch (error) { + throw toBackendClientError('message submit', error); + } + }, + async gitStatus() { + try { + const result = await withRequestTimeout( + connection.sendRequest(gitStatusRequest), + requestTimeoutMs + ); + return result.label === null ? null : { label: result.label }; + } catch (error) { + throw toBackendClientError('git status', error); + } + }, + async pullRequest() { try { - return await connection.sendRequest(messageSubmitRequest, params); + const result = await withRequestTimeout( + connection.sendRequest(pullRequestRequest), + requestTimeoutMs + ); + return result.label === null + ? null + : { label: result.label, url: result.url ?? undefined }; } catch (error) { - throw toBackendClientError(error); + throw toBackendClientError('pull request', error); } } }; } -function toBackendClientError(error: unknown): BackendClientError { +function toBackendClientError(requestName: string, error: unknown): BackendClientError { if (error instanceof BackendClientError) { return error; } @@ -31,7 +86,7 @@ function toBackendClientError(error: unknown): BackendClientError { if (error instanceof ResponseError) { return new BackendClientError( BackendErrorKind.Protocol, - `backend rejected message submit: ${error.message}`, + `backend rejected ${requestName}: ${error.message}`, { cause: error } ); } diff --git a/tui/src/backend/packaged/__tests__/materializeBackend.test.ts b/tui/src/backend/packaged/__tests__/materializeBackend.test.ts index c1857989..134f9da2 100644 --- a/tui/src/backend/packaged/__tests__/materializeBackend.test.ts +++ b/tui/src/backend/packaged/__tests__/materializeBackend.test.ts @@ -39,6 +39,15 @@ function asset(bytes: Buffer, sha = sha256(bytes)): EmbeddedBackendAsset & { cal }; } +// Emulate writeBinary's user-only permissions so inspectExisting classifies the +// staged file as reusable on Unix, where loose group/other perms are treated as +// stale. Without this the concurrent-race fallback tests fail on non-Windows CI. +function writeCacheBinary(binaryPath: string, runtimeDir: string, bytes: Buffer): void { + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync(binaryPath, bytes); + fs.chmodSync(binaryPath, 0o700); +} + afterEach(() => { vi.restoreAllMocks(); while (tempDirs.length > 0) { @@ -216,11 +225,7 @@ describe('materializePackagedBackend', () => { // Simulate the winner writing the valid binary, then our own write failing // (e.g. the winner already spawned and locked it on Windows). const losingWrite = (binaryPath: string, runtimeDir: string): void => { - fs.mkdirSync(runtimeDir, { recursive: true }); - fs.writeFileSync(binaryPath, bytes); - if (!isWindows) { - fs.chmodSync(binaryPath, 0o700); - } + writeCacheBinary(binaryPath, runtimeDir, bytes); throw new Error('EBUSY: binary is locked by the winning instance'); }; @@ -258,11 +263,7 @@ describe('materializePackagedBackend', () => { // The injected write leaves a genuinely valid binary (as a concurrent winner // would)... const write = (binaryPath: string, runtimeDir: string): void => { - fs.mkdirSync(runtimeDir, { recursive: true }); - fs.writeFileSync(binaryPath, bytes); - if (!isWindows) { - fs.chmodSync(binaryPath, 0o700); - } + writeCacheBinary(binaryPath, runtimeDir, bytes); }; // ...but the immediate post-write read-back observes the atomic-replace gap // once (ENOENT), which must fall back to reusing the valid cache. diff --git a/tui/src/backend/packaged/backendCacheDir.ts b/tui/src/backend/packaged/backendCacheDir.ts index 77b7fef8..db9420b3 100644 --- a/tui/src/backend/packaged/backendCacheDir.ts +++ b/tui/src/backend/packaged/backendCacheDir.ts @@ -2,7 +2,7 @@ import os from 'node:os'; import path from 'node:path'; /** Per-user KQode home directory holding local runtime/data state. */ -export const KQODE_HOME_DIRNAME = '.kqcode'; +export const KQODE_HOME_DIRNAME = '.kqode'; /** Subdirectory under the KQode home for materialized backend binaries. */ export const BACKENDS_DIRNAME = 'backends'; @@ -10,7 +10,7 @@ export const BACKENDS_DIRNAME = 'backends'; /** Base name of the materialized packaged backend binary. */ export const PACKAGED_BACKEND_BASENAME = 'kqode-backend'; -/** Default per-user cache base, e.g. `~/.kqcode`. */ +/** Default per-user cache base, e.g. `~/.kqode`. */ export function defaultCacheBaseDir(homeDir: string = os.homedir()): string { return path.join(homeDir, KQODE_HOME_DIRNAME); } @@ -21,7 +21,7 @@ export function packagedBackendBinaryName(platform: NodeJS.Platform = process.pl } export type PackagedBackendPaths = { - /** Content-addressed directory, e.g. `~/.kqcode/backends/0.1.0/`. */ + /** Content-addressed directory, e.g. `~/.kqode/backends/0.1.0/`. */ runtimeDir: string; /** Absolute path to the materialized backend binary inside `runtimeDir`. */ binaryPath: string; diff --git a/tui/src/backend/process/__tests__/backendProcess.test.ts b/tui/src/backend/process/__tests__/backendProcess.test.ts index 77364786..3c3bb612 100644 --- a/tui/src/backend/process/__tests__/backendProcess.test.ts +++ b/tui/src/backend/process/__tests__/backendProcess.test.ts @@ -1,4 +1,7 @@ import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { execFileSync } from 'node:child_process'; import type { Readable } from 'node:stream'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; @@ -8,7 +11,11 @@ import { spawnBackend, type LaunchedBackend } from '@backend/process/backendProcess.ts'; -import { ACK_MESSAGE, MESSAGE_SUBMIT_METHOD } from '@contracts/backend/index.ts'; +import { + GIT_STATUS_METHOD, + MESSAGE_SUBMIT_METHOD, + SUBMIT_STATUS_NEEDS_CONFIGURATION +} from '@contracts/backend/index.ts'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '..'); const INTEGRATION_TIMEOUT_MS = 180_000; @@ -18,9 +25,7 @@ function frameRequest(payload: unknown): Buffer { return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'utf8'), body]); } -function readResponseFrame( - stream: Readable -): Promise<{ result: { message: string; receivedText: string } }> { +function readResponseFrame(stream: Readable): Promise<{ result: unknown }> { return new Promise((resolve, reject) => { let buffer = Buffer.alloc(0); const cleanup = () => { @@ -62,12 +67,45 @@ function readResponseFrame( }); } -async function ackThroughLauncher(backend: LaunchedBackend, text: string): Promise { +async function submitThroughLauncher( + backend: LaunchedBackend, + text: string +): Promise<{ status: string }> { const response = readResponseFrame(backend.stdout); - backend.stdin.write(frameRequest({ jsonrpc: '2.0', id: 1, method: MESSAGE_SUBMIT_METHOD, params: { text } })); + backend.stdin.write( + frameRequest({ + jsonrpc: '2.0', + id: 1, + method: MESSAGE_SUBMIT_METHOD, + params: { text } + }) + ); const frame = await response; - expect(frame.result.message).toBe(ACK_MESSAGE); - return frame.result.receivedText; + return frame.result as { status: string }; +} + +async function gitStatusThroughLauncher(backend: LaunchedBackend): Promise<{ label: string | null }> { + const response = readResponseFrame(backend.stdout); + backend.stdin.write( + frameRequest({ + jsonrpc: '2.0', + id: 2, + method: GIT_STATUS_METHOD, + params: null + }) + ); + const frame = await response; + return frame.result as { label: string | null }; +} + +// Best-effort temp cleanup: on Windows a just-killed backend may still hold its +// cwd handle for a moment; the OS reclaims the temp dir regardless. +function safeRemove(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + /* ignore */ + } } describe('spawnBackend', () => { @@ -83,27 +121,40 @@ describe('spawnBackend', () => { describe('launchSourceBackend (integration)', () => { it( - 'builds and launches the backend when invoked from the repo root workspace', + 'builds and launches the backend and returns a well-formed ack', async () => { - const backend = await launchSourceBackend({ repoRoot, workspaceCwd: repoRoot }); + // Provider setup lands later, so bootstrap submit deterministically returns + // needsConfiguration regardless of the workspace. + const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'kqode-launch-')); + const backend = await launchSourceBackend({ repoRoot, workspaceCwd }); try { - expect(await ackThroughLauncher(backend, 'hi from repo root')).toBe('hi from repo root'); + const result = await submitThroughLauncher(backend, 'hi from a temp workspace'); + expect(result.status).toBe(SUBMIT_STATUS_NEEDS_CONFIGURATION); } finally { backend.dispose(); + safeRemove(workspaceCwd); } }, INTEGRATION_TIMEOUT_MS ); it( - 'preserves a distinct workspace cwd such as the dummy React fixture path', + 'uses the distinct workspace directory for git status and submit', async () => { - const workspaceCwd = path.join(repoRoot, 'tests', 'fixtures', 'dummy-react-app'); + const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'kqode-launch-alt-')); + execFileSync('git', ['init', '--quiet', '--initial-branch=workspace-branch'], { + cwd: workspaceCwd + }); const backend = await launchSourceBackend({ repoRoot, workspaceCwd }); try { - expect(await ackThroughLauncher(backend, 'café ☕')).toBe('café ☕'); + const status = await gitStatusThroughLauncher(backend); + expect(status.label).toBe('⎇ workspace-branch'); + + const result = await submitThroughLauncher(backend, 'café ☕'); + expect(result.status).toBe(SUBMIT_STATUS_NEEDS_CONFIGURATION); } finally { backend.dispose(); + safeRemove(workspaceCwd); } }, INTEGRATION_TIMEOUT_MS diff --git a/tui/src/backend/process/__tests__/processEnv.test.ts b/tui/src/backend/process/__tests__/processEnv.test.ts index 49da5498..10b4b5ee 100644 --- a/tui/src/backend/process/__tests__/processEnv.test.ts +++ b/tui/src/backend/process/__tests__/processEnv.test.ts @@ -5,6 +5,7 @@ const unixSource: NodeJS.ProcessEnv = { PATH: '/usr/bin', HOME: '/home/dev', TMPDIR: '/tmp', + XDG_CONFIG_HOME: '/home/dev/.config', GITHUB_TOKEN: 'should-be-dropped', AWS_SECRET_ACCESS_KEY: 'should-be-dropped', CARGO_HOME: '/home/dev/.cargo', @@ -18,6 +19,8 @@ describe('buildHardenedEnv', () => { expect(env.PATH).toBe('/usr/bin'); expect(env.HOME).toBe('/home/dev'); expect(env.TMPDIR).toBe('/tmp'); + // gh resolves its auth config under XDG roots, so they must survive hardening. + expect(env.XDG_CONFIG_HOME).toBe('/home/dev/.config'); expect(env.GITHUB_TOKEN).toBeUndefined(); expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined(); expect(env.RANDOM_UNRELATED_VAR).toBeUndefined(); @@ -38,6 +41,8 @@ describe('buildHardenedEnv', () => { PATHEXT: '.COM;.EXE', SystemRoot: 'C:\\Windows', USERPROFILE: 'C:\\Users\\dev', + APPDATA: 'C:\\Users\\dev\\AppData\\Roaming', + LOCALAPPDATA: 'C:\\Users\\dev\\AppData\\Local', AZURE_CLIENT_SECRET: 'should-be-dropped' } }); @@ -46,6 +51,10 @@ describe('buildHardenedEnv', () => { expect(env.PATHEXT).toBe('.COM;.EXE'); expect(env.SystemRoot).toBe('C:\\Windows'); expect(env.USERPROFILE).toBe('C:\\Users\\dev'); + // gh loads its host auth config from %APPDATA%\GitHub CLI, so APPDATA (and + // LOCALAPPDATA for state) must survive hardening or `gh pr view` fails auth. + expect(env.APPDATA).toBe('C:\\Users\\dev\\AppData\\Roaming'); + expect(env.LOCALAPPDATA).toBe('C:\\Users\\dev\\AppData\\Local'); expect(env.AZURE_CLIENT_SECRET).toBeUndefined(); }); }); diff --git a/tui/src/backend/process/processEnv.ts b/tui/src/backend/process/processEnv.ts index fecf8f38..a136ee46 100644 --- a/tui/src/backend/process/processEnv.ts +++ b/tui/src/backend/process/processEnv.ts @@ -4,6 +4,12 @@ * A strict allowlist replaces the inherited environment so secret-bearing * variables (provider keys, registry tokens, SSH agent handles) never reach the * Rust child. Cargo/Rustup variables are added only when a build needs them. + * + * The allowlist still admits the OS config/data roots the backend's git/GitHub + * tooling needs to *locate* its credentials — notably `APPDATA` on Windows and + * the `XDG_*` roots on Unix, where `gh` stores its host auth config. These are + * directory paths, not secrets; omitting them makes `gh pr view` fail auth and + * silently drops the PR segment from the status line. */ const WINDOWS_ALLOWLIST = [ 'PATH', @@ -15,10 +21,28 @@ const WINDOWS_ALLOWLIST = [ 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', - 'HOME' + 'HOME', + // `gh` reads its auth config from %APPDATA%\GitHub CLI and its state/cache + // from %LOCALAPPDATA%\GitHub CLI; without these it cannot authenticate. + 'APPDATA', + 'LOCALAPPDATA' ]; -const UNIX_ALLOWLIST = ['PATH', 'HOME', 'TMPDIR', 'TERM', 'COLORTERM', 'LANG', 'LC_ALL', 'LC_CTYPE']; +const UNIX_ALLOWLIST = [ + 'PATH', + 'HOME', + 'TMPDIR', + 'TERM', + 'COLORTERM', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + // XDG roots `gh` honors for its config/state/data (falling back to ~/.config + // etc.); pass them through so a customized layout still resolves gh auth. + 'XDG_CONFIG_HOME', + 'XDG_STATE_HOME', + 'XDG_DATA_HOME' +]; const CARGO_ALLOWLIST = ['CARGO_HOME', 'RUSTUP_HOME', 'RUSTUP_TOOLCHAIN']; diff --git a/tui/src/backend/protocol/__tests__/messageProtocol.test.ts b/tui/src/backend/protocol/__tests__/messageProtocol.test.ts index 98412e53..4e72dd07 100644 --- a/tui/src/backend/protocol/__tests__/messageProtocol.test.ts +++ b/tui/src/backend/protocol/__tests__/messageProtocol.test.ts @@ -8,12 +8,18 @@ import { import { ErrorCodes, type MessageConnection, ResponseError } from 'vscode-jsonrpc'; import { BackendClientError, BackendErrorKind } from '@contracts/backend/index.ts'; import { createMessageConnectionClient } from '@backend/client/messageConnectionClient.ts'; -import { ACK_MESSAGE } from '@contracts/backend/index.ts'; -import { messageSubmitRequest } from '@backend/protocol/messageProtocol.ts'; +import { SUBMIT_STATUS_NEEDS_CONFIGURATION } from '@contracts/backend/index.ts'; +import type { MessageSubmitResult } from '@contracts/backend/index.ts'; +import { + gitStatusRequest, + messageSubmitRequest, + pullRequestRequest +} from '@backend/protocol/messageProtocol.ts'; type PairedConnections = { client: MessageConnection; server: MessageConnection; + closeTransport: () => void; dispose: () => void; }; @@ -36,6 +42,10 @@ function pairedConnections(): PairedConnections { const pair: PairedConnections = { client, server, + closeTransport: () => { + clientToServer.end(); + serverToClient.end(); + }, dispose: () => { client.dispose(); server.dispose(); @@ -45,13 +55,6 @@ function pairedConnections(): PairedConnections { return pair; } -function ackingServer(server: MessageConnection): void { - server.onRequest(messageSubmitRequest, ({ text }) => ({ - message: ACK_MESSAGE, - receivedText: text - })); -} - afterEach(() => { for (const pair of openPairs) { pair.dispose(); @@ -60,26 +63,39 @@ afterEach(() => { }); describe('message protocol client', () => { - it('sends kqode.message.submit and receives an ACK success response', async () => { + it('resolves needs-configuration when the ack reports no key', async () => { const { client, server } = pairedConnections(); - ackingServer(server); + server.onRequest(messageSubmitRequest, () => ({ + status: SUBMIT_STATUS_NEEDS_CONFIGURATION + })); + + const outcome = await createMessageConnectionClient(client).submit({ text: 'no key' }); - const result = await createMessageConnectionClient(client).submitMessage({ - text: 'hello from tui' + expect(outcome).toEqual({ kind: 'needsConfiguration' }); + }); + + it('sends the prompt text to the backend', async () => { + const { client, server } = pairedConnections(); + const received: Array<{ text: string }> = []; + server.onRequest(messageSubmitRequest, (params) => { + received.push(params); + return { status: SUBMIT_STATUS_NEEDS_CONFIGURATION }; }); - expect(result.message).toBe(ACK_MESSAGE); - expect(result.receivedText).toBe('hello from tui'); + await createMessageConnectionClient(client).submit({ text: ' café ☕ ' }); + + expect(received).toHaveLength(1); + expect(received[0]?.text).toBe(' café ☕ '); }); - it('preserves Unicode, surrounding spaces, and newlines in receivedText', async () => { + it('rejects an unsupported ack status as a protocol error', async () => { const { client, server } = pairedConnections(); - ackingServer(server); + server.onRequest(messageSubmitRequest, () => ({ status: 'streaming' })); - const text = ' café\n☕ 日本語 '; - const result = await createMessageConnectionClient(client).submitMessage({ text }); + const submit = createMessageConnectionClient(client).submit({ text: 'too early' }); - expect(result.receivedText).toBe(text); + await expect(submit).rejects.toBeInstanceOf(BackendClientError); + await expect(submit).rejects.toMatchObject({ kind: BackendErrorKind.Protocol }); }); it('surfaces JSON-RPC method errors as typed protocol client errors', async () => { @@ -88,9 +104,94 @@ describe('message protocol client', () => { throw new ResponseError(ErrorCodes.InvalidParams, 'invalid message submit params'); }); - const submit = createMessageConnectionClient(client).submitMessage({ text: 'boom' }); + const submit = createMessageConnectionClient(client).submit({ text: 'boom' }); await expect(submit).rejects.toBeInstanceOf(BackendClientError); await expect(submit).rejects.toMatchObject({ kind: BackendErrorKind.Protocol }); }); + + it('rejects with a typed backend error when the connection is disposed mid-submit', async () => { + const { client, server } = pairedConnections(); + // The backend never answers, so the request stays pending until the client + // connection is disposed — mirroring how the backend client tears down a dead + // connection (markDead → dispose), which rejects any in-flight request. + // Classifying that death and marking the client dead is owned by the backend + // client layer's fatal-teardown listeners; here we only assert the in-flight + // submit surfaces a typed error rather than hanging or leaking a raw rejection. + server.onRequest(messageSubmitRequest, () => new Promise(() => undefined)); + + const submit = createMessageConnectionClient(client).submit({ text: 'crash' }); + queueMicrotask(() => client.dispose()); + + await expect(submit).rejects.toBeInstanceOf(BackendClientError); + }); +}); + +describe('git status request', () => { + it('resolves the formatted label the backend returns', async () => { + const { client, server } = pairedConnections(); + server.onRequest(gitStatusRequest, () => ({ label: '⎇ main*' })); + + const status = await createMessageConnectionClient(client).gitStatus(); + + expect(status).toEqual({ label: '⎇ main*' }); + }); + + it('resolves null when the workspace is not a git repository', async () => { + const { client, server } = pairedConnections(); + server.onRequest(gitStatusRequest, () => ({ label: null })); + + const status = await createMessageConnectionClient(client).gitStatus(); + + expect(status).toBeNull(); + }); + + it('surfaces a JSON-RPC error as a typed protocol client error', async () => { + const { client, server } = pairedConnections(); + server.onRequest(gitStatusRequest, () => { + throw new ResponseError(ErrorCodes.InternalError, 'git failed'); + }); + + const status = createMessageConnectionClient(client).gitStatus(); + + await expect(status).rejects.toBeInstanceOf(BackendClientError); + await expect(status).rejects.toMatchObject({ kind: BackendErrorKind.Protocol }); + await expect(status).rejects.toThrow('git status'); + }); +}); + +describe('pull request status request', () => { + it('resolves the formatted PR label and URL the backend returns', async () => { + const { client, server } = pairedConnections(); + server.onRequest(pullRequestRequest, () => ({ + label: '#3', + url: 'https://github.com/o/r/pull/3' + })); + + const status = await createMessageConnectionClient(client).pullRequest(); + + expect(status).toEqual({ label: '#3', url: 'https://github.com/o/r/pull/3' }); + }); + + it('resolves null when the branch has no pull request', async () => { + const { client, server } = pairedConnections(); + server.onRequest(pullRequestRequest, () => ({ label: null, url: null })); + + const status = await createMessageConnectionClient(client).pullRequest(); + + expect(status).toBeNull(); + }); + + it('surfaces a JSON-RPC error as a typed protocol client error', async () => { + const { client, server } = pairedConnections(); + server.onRequest(pullRequestRequest, () => { + throw new ResponseError(ErrorCodes.InternalError, 'gh failed'); + }); + + const status = createMessageConnectionClient(client).pullRequest(); + + await expect(status).rejects.toBeInstanceOf(BackendClientError); + await expect(status).rejects.toMatchObject({ kind: BackendErrorKind.Protocol }); + await expect(status).rejects.toThrow('pull request'); + }); }); diff --git a/tui/src/backend/protocol/messageProtocol.ts b/tui/src/backend/protocol/messageProtocol.ts index 2c1dd78d..cc84da92 100644 --- a/tui/src/backend/protocol/messageProtocol.ts +++ b/tui/src/backend/protocol/messageProtocol.ts @@ -1,6 +1,16 @@ -import { NotificationType0, RequestType } from 'vscode-jsonrpc'; -import { BACKEND_READY_METHOD, MESSAGE_SUBMIT_METHOD } from '@contracts/backend/index.ts'; -import type { MessageSubmitParams, MessageSubmitResult } from '@contracts/backend/index.ts'; +import { NotificationType0, RequestType, RequestType0 } from 'vscode-jsonrpc'; +import { + BACKEND_READY_METHOD, + GIT_STATUS_METHOD, + MESSAGE_SUBMIT_METHOD, + PULL_REQUEST_METHOD +} from '@contracts/backend/index.ts'; +import type { + GitStatusResult, + MessageSubmitParams, + MessageSubmitResult, + PullRequestResult +} from '@contracts/backend/index.ts'; /** * Typed request descriptor for `kqode.message.submit`. @@ -13,6 +23,25 @@ export const messageSubmitRequest = new RequestType(GIT_STATUS_METHOD); + +/** + * Typed descriptor for the parameterless `kqode.git.pullRequest` request. + * + * The backend runs `gh` in its own workspace cwd, so the request carries no + * params; it resolves with the PR label + URL (or nulls). Fetched once at + * bootstrap because a branch's PR is static for the session. The method name and + * result shape come from the dependency-free `@contracts` seam. + */ +export const pullRequestRequest = new RequestType0(PULL_REQUEST_METHOD); + /** * Typed descriptor for the backend's one-shot readiness notification. * diff --git a/tui/src/bootstrap.ts b/tui/src/bootstrap.ts index 39d0628e..8b9d62cf 100644 --- a/tui/src/bootstrap.ts +++ b/tui/src/bootstrap.ts @@ -2,8 +2,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createStore } from 'jotai'; import type { BackendClientHandle } from '@backend/client/backendClient.ts'; -import { startBackendRuntime } from '@backend/runtime/backendRuntime.ts'; +import { startBackendRuntime } from '@/cli/backendRuntime.ts'; import { resolveRepoRoot, resolveWorkspaceCwd } from '@libs/path/runtimePaths.ts'; +import { systemClipboard } from '@libs/clipboard/systemClipboard.ts'; import { PRODUCT_NAME } from '@constants/product.ts'; import { resolveProductVersion } from '@libs/product/productMetadata.ts'; import { setTerminalWindowTitle, resetTerminalWindowTitle } from '@libs/terminal/windowTitle.ts'; @@ -15,10 +16,12 @@ import { enterAlternateScreen, leaveAlternateScreen } from '@libs/terminal/alternateScreen.ts'; -import { resolveSessionSeed } from '@components/AppExitSummary/resolveSessionSeed.ts'; +import { DISABLE_SGR_MOUSE_TRACKING } from '@libs/terminal/mouse.ts'; +import { resolveSessionSeed } from '@libs/exitSummary/resolveSessionSeed.ts'; import { windowColumnsAtom, windowRowsAtom } from '@state/ui/index.ts'; import { productVersionAtom, + clipboardClientAtom, repoRootAtom, sessionGitBaselineAtom, sessionStartedAtAtom, @@ -66,6 +69,7 @@ export async function createAppRuntime({ loadPackagedAsset }: CreateAppRuntimeOptions): Promise { const store = createStore(); + store.set(clipboardClientAtom, systemClipboard); const workspaceCwd = resolveWorkspaceCwd(); store.set(workspaceCwdAtom, workspaceCwd); @@ -131,6 +135,7 @@ export async function createAppRuntime({ // redundant restore. Mirror the enter order on teardown: reset the background // and window title, then leave the alt buffer. const restoreTerminal = () => { + process.stdout.write(DISABLE_SGR_MOUSE_TRACKING); resetTerminalBackground(); resetTerminalWindowTitle(); leaveAlternateScreen(); diff --git a/tui/src/backend/runtime/__tests__/backendRuntime.test.ts b/tui/src/cli/__tests__/backendRuntime.test.ts similarity index 79% rename from tui/src/backend/runtime/__tests__/backendRuntime.test.ts rename to tui/src/cli/__tests__/backendRuntime.test.ts index 5b09c2e5..641a8c75 100644 --- a/tui/src/backend/runtime/__tests__/backendRuntime.test.ts +++ b/tui/src/cli/__tests__/backendRuntime.test.ts @@ -1,16 +1,19 @@ import { createStore } from 'jotai'; import { describe, expect, it, vi } from 'vitest'; +import { BACKEND_LOADING_HINT } from '@constants/statusHint.ts'; import { BackendClientError, BackendErrorKind } from '@contracts/backend/index.ts'; import { backendClientAtom } from '@state/global/backend.ts'; -import { BACKEND_LOADING_HINT, startupStatusHintAtom } from '@state/ui/statusHint.ts'; +import { startupStatusHintAtom } from '@state/ui/statusHint.ts'; import { enqueuePromptAtom } from '@state/promptQueue/index.ts'; import { submittedPromptEntriesAtom } from '@state/ui/index.ts'; -import { startBackendRuntime } from '@backend/runtime/backendRuntime.ts'; -import type { RuntimeBackendClient } from '@backend/runtime/backendRuntime.ts'; +import { startBackendRuntime } from '@/cli/backendRuntime.ts'; +import type { RuntimeBackendClient } from '@/cli/backendRuntime.ts'; function fakeClient(overrides: Partial = {}): RuntimeBackendClient { return { - submitMessage: vi.fn(), + submit: vi.fn(), + gitStatus: vi.fn().mockResolvedValue(null), + pullRequest: vi.fn().mockResolvedValue(null), ensureStarted: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), ...overrides @@ -37,6 +40,11 @@ describe('startBackendRuntime', () => { expect(store.get(startupStatusHintAtom)).toBeUndefined(); expect(store.get(backendClientAtom)).toBe(client); + // The working-tree label refreshes once at bootstrap, then the static PR + // lookup runs once for the session. + expect(client.gitStatus).toHaveBeenCalledTimes(1); + expect(client.pullRequest).toHaveBeenCalledTimes(1); + dispose(); expect(client.dispose).toHaveBeenCalledTimes(1); expect(store.get(backendClientAtom)).toBeUndefined(); @@ -69,7 +77,7 @@ describe('startBackendRuntime', () => { const failure = new BackendClientError(BackendErrorKind.Launch, 'backend unavailable'); const client = fakeClient({ ensureStarted: vi.fn().mockRejectedValue(failure), - submitMessage: vi.fn().mockRejectedValue(failure) + submit: vi.fn().mockRejectedValue(failure) }); startBackendRuntime(store, client); @@ -80,6 +88,6 @@ describe('startBackendRuntime', () => { const entries = store.get(submittedPromptEntriesAtom); expect(entries.some((entry) => entry.kind === 'error')).toBe(true); - expect(client.submitMessage).toHaveBeenCalledWith({ text: 'still here?' }); + expect(client.submit).toHaveBeenCalledWith({ text: 'still here?' }); }); }); diff --git a/tui/src/backend/runtime/backendRuntime.ts b/tui/src/cli/backendRuntime.ts similarity index 73% rename from tui/src/backend/runtime/backendRuntime.ts rename to tui/src/cli/backendRuntime.ts index 5c3d8b99..7fd2cb7c 100644 --- a/tui/src/backend/runtime/backendRuntime.ts +++ b/tui/src/cli/backendRuntime.ts @@ -1,7 +1,12 @@ import type { createStore } from 'jotai'; +import { BACKEND_LOADING_HINT } from '@constants/statusHint.ts'; import type { BackendClient } from '@contracts/backend/index.ts'; import { backendClientAtom } from '@state/global/backend.ts'; -import { BACKEND_LOADING_HINT, startupStatusHintAtom } from '@state/ui/statusHint.ts'; +import { + refreshGitStatusAtom, + refreshPullRequestAtom +} from '@state/ui/gitStatus/index.ts'; +import { startupStatusHintAtom } from '@state/ui/statusHint.ts'; type Store = ReturnType; @@ -28,6 +33,13 @@ export function startBackendRuntime(store: Store, client: RuntimeBackendClient): void client .ensureStarted() + .then(async () => { + // Backend is ready: fetch the fast local label first, then the branch's + // pull request once. The PR is a `gh` network call that is static for the + // session, so it is fetched a single time at bootstrap rather than per turn. + await store.set(refreshGitStatusAtom); + await store.set(refreshPullRequestAtom); + }) .catch(() => { // Keep the Dead-state client in the seam (do not dispose or clear it): the // next submit retries through ensureSession() and, on repeat failure, diff --git a/tui/src/cli/exitSummary/README.md b/tui/src/cli/exitSummary/README.md new file mode 100644 index 00000000..4e3b5a98 --- /dev/null +++ b/tui/src/cli/exitSummary/README.md @@ -0,0 +1,12 @@ +# Exit summary + +This CLI composition module computes and prints the exit-summary card when the +TUI shuts down. Pure formatting and session-seed helpers live in +`src/libs/exitSummary/`; this folder owns state access and terminal output. + +## Layout + +- `src/libs/exitSummary/` — pure formatting, shared types, and session seeding. +- `computeExitSummary.ts` — derives duration and working-tree changes from state. +- `printExitSummary.ts`, `finishSession.ts` — write the card to the terminal's + normal buffer on the clean-exit path, after the alternate screen is left. diff --git a/tui/src/components/AppExitSummary/__tests__/computeExitSummary.test.ts b/tui/src/cli/exitSummary/__tests__/computeExitSummary.test.ts similarity index 96% rename from tui/src/components/AppExitSummary/__tests__/computeExitSummary.test.ts rename to tui/src/cli/exitSummary/__tests__/computeExitSummary.test.ts index ab0f8b00..0f8c527a 100644 --- a/tui/src/components/AppExitSummary/__tests__/computeExitSummary.test.ts +++ b/tui/src/cli/exitSummary/__tests__/computeExitSummary.test.ts @@ -1,6 +1,6 @@ import { createStore } from 'jotai'; import { describe, expect, it } from 'vitest'; -import { computeExitSummary } from '@components/AppExitSummary/computeExitSummary.ts'; +import { computeExitSummary } from '@/cli/exitSummary/computeExitSummary.ts'; import type { GitLineDelta } from '@libs/git/lineDelta.ts'; import { sessionGitBaselineAtom, diff --git a/tui/src/components/AppExitSummary/__tests__/finishSession.test.ts b/tui/src/cli/exitSummary/__tests__/finishSession.test.ts similarity index 82% rename from tui/src/components/AppExitSummary/__tests__/finishSession.test.ts rename to tui/src/cli/exitSummary/__tests__/finishSession.test.ts index 3486f9d4..7ceca3e8 100644 --- a/tui/src/components/AppExitSummary/__tests__/finishSession.test.ts +++ b/tui/src/cli/exitSummary/__tests__/finishSession.test.ts @@ -2,11 +2,11 @@ import { createStore } from 'jotai'; import { afterEach, describe, expect, it, vi } from 'vitest'; const printExitSummary = vi.fn(); -vi.mock('@components/AppExitSummary/printExitSummary.ts', () => ({ +vi.mock('@/cli/exitSummary/printExitSummary.ts', () => ({ printExitSummary: (deps: unknown) => printExitSummary(deps) })); -const { finishSession } = await import('@components/AppExitSummary/finishSession.ts'); +const { finishSession } = await import('@/cli/exitSummary/finishSession.ts'); afterEach(() => { vi.clearAllMocks(); diff --git a/tui/src/components/AppExitSummary/__tests__/printExitSummary.test.ts b/tui/src/cli/exitSummary/__tests__/printExitSummary.test.ts similarity index 94% rename from tui/src/components/AppExitSummary/__tests__/printExitSummary.test.ts rename to tui/src/cli/exitSummary/__tests__/printExitSummary.test.ts index acca6675..06ea7de9 100644 --- a/tui/src/components/AppExitSummary/__tests__/printExitSummary.test.ts +++ b/tui/src/cli/exitSummary/__tests__/printExitSummary.test.ts @@ -1,7 +1,7 @@ import { createStore } from 'jotai'; import { describe, expect, it, vi } from 'vitest'; -import { printExitSummary } from '@components/AppExitSummary/printExitSummary.ts'; -import type { Colorize } from '@components/AppExitSummary/types.ts'; +import { printExitSummary } from '@/cli/exitSummary/printExitSummary.ts'; +import type { Colorize } from '@libs/exitSummary/types.ts'; import { sessionGitBaselineAtom, sessionStartedAtAtom, diff --git a/tui/src/components/AppExitSummary/computeExitSummary.ts b/tui/src/cli/exitSummary/computeExitSummary.ts similarity index 95% rename from tui/src/components/AppExitSummary/computeExitSummary.ts rename to tui/src/cli/exitSummary/computeExitSummary.ts index 3c2699a6..a8916870 100644 --- a/tui/src/components/AppExitSummary/computeExitSummary.ts +++ b/tui/src/cli/exitSummary/computeExitSummary.ts @@ -1,6 +1,6 @@ import type { createStore } from 'jotai'; import { readWorkingTreeLineDelta, type GitLineDelta } from '@libs/git/lineDelta.ts'; -import type { ExitSummaryData } from '@components/AppExitSummary/types.ts'; +import type { ExitSummaryData } from '@libs/exitSummary/types.ts'; import { sessionGitBaselineAtom, sessionStartedAtAtom, workspaceCwdAtom } from '@state/global/index.ts'; type Store = ReturnType; diff --git a/tui/src/components/AppExitSummary/finishSession.ts b/tui/src/cli/exitSummary/finishSession.ts similarity index 90% rename from tui/src/components/AppExitSummary/finishSession.ts rename to tui/src/cli/exitSummary/finishSession.ts index 4b9c609b..c606adb0 100644 --- a/tui/src/components/AppExitSummary/finishSession.ts +++ b/tui/src/cli/exitSummary/finishSession.ts @@ -1,5 +1,5 @@ import type { createStore } from 'jotai'; -import { printExitSummary } from '@components/AppExitSummary/printExitSummary.ts'; +import { printExitSummary } from '@/cli/exitSummary/printExitSummary.ts'; type Store = ReturnType; diff --git a/tui/src/components/AppExitSummary/printExitSummary.ts b/tui/src/cli/exitSummary/printExitSummary.ts similarity index 85% rename from tui/src/components/AppExitSummary/printExitSummary.ts rename to tui/src/cli/exitSummary/printExitSummary.ts index 00103a08..447f9f52 100644 --- a/tui/src/components/AppExitSummary/printExitSummary.ts +++ b/tui/src/cli/exitSummary/printExitSummary.ts @@ -1,9 +1,9 @@ import type { createStore } from 'jotai'; -import { computeExitSummary } from '@components/AppExitSummary/computeExitSummary.ts'; -import { formatExitSummaryCard } from '@components/AppExitSummary/formatExitSummaryCard.ts'; +import { computeExitSummary } from '@/cli/exitSummary/computeExitSummary.ts'; +import { formatExitSummaryCard } from '@libs/exitSummary/formatExitSummaryCard.ts'; import type { GitLineDelta } from '@libs/git/lineDelta.ts'; import { colorize as ansiColorize } from '@libs/terminal/ansiColor.ts'; -import type { Colorize } from '@components/AppExitSummary/types.ts'; +import type { Colorize } from '@libs/exitSummary/types.ts'; import { DEFAULT_COLUMNS } from '@constants/ui.ts'; type Store = ReturnType; diff --git a/tui/src/cli/kqodeCli.tsx b/tui/src/cli/kqodeCli.tsx index 9fa354d7..5301cf4f 100644 --- a/tui/src/cli/kqodeCli.tsx +++ b/tui/src/cli/kqodeCli.tsx @@ -5,7 +5,7 @@ import type { EmbeddedBackendAsset } from '@backend/packaged/materializeBackend. import { App } from '@/App.tsx'; import { buildKqodeMeta } from '@/cli/meta.ts'; import { createAppRuntime } from '@/bootstrap.ts'; -import { finishSession } from '@components/AppExitSummary/finishSession.ts'; +import { finishSession } from '@/cli/exitSummary/finishSession.ts'; /** Inputs for the root CLI command; `loadPackagedAsset` is supplied only in packaged mode. */ export type RunKqodeCliOptions = { @@ -24,11 +24,15 @@ async function launchTui({ entryUrl, loadPackagedAsset }: RunKqodeCliOptions): P , // Rewrite only changed lines instead of repainting the whole screen each - // frame. Paired with FULLSCREEN_GUARD_ROWS keeping us under fullscreen, this - // avoids the per-keystroke clear+repaint that blinks in WezTerm on Windows. + // frame. WezTerm reserves one viewport row so Ink remains on this + // incremental path instead of clearing and repainting on every keystroke. // exitOnCtrlC is off so Ctrl+C flows to the global two-step-exit handler // (useGlobalKeys) instead of quitting on the first press. - { incrementalRendering: true, exitOnCtrlC: false } + { + incrementalRendering: true, + exitOnCtrlC: false, + kittyKeyboard: { mode: 'auto', flags: ['disambiguateEscapeCodes'] } + } ); // Restore the terminal, then print the exit summary card into the recovered diff --git a/tui/src/components/AppExitSummary/README.md b/tui/src/components/AppExitSummary/README.md deleted file mode 100644 index cf2fbaef..00000000 --- a/tui/src/components/AppExitSummary/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# AppExitSummary - -**Not React/Ink components.** This folder is pure logic — it formats the -exit-summary card as a string and prints it to the terminal when the TUI shuts -down. Nothing here renders JSX. - -It lives under `components/` because it is **UI-related**: it owns what the user -sees on exit. We group it with the UI rather than in `libs/` (which holds -generic, UI-agnostic, reusable helpers such as `math`, `text`, and `terminal`), -so UI concerns stay together. - -## Layout - -- `formatExitSummaryCard.ts`, `banner.ts`, `border.ts`, `formatDuration.ts` — - pure string formatting for the card. -- `computeExitSummary.ts`, `resolveSessionSeed.ts` — derive the card's data - (duration, working-tree change counts) from the session. -- `printExitSummary.ts`, `finishSession.ts` — write the card to the terminal's - normal buffer on the clean-exit path, after the alternate screen is left. -- `types.ts` — shared types. diff --git a/tui/src/components/BodyPane.tsx b/tui/src/components/BodyPane.tsx index 827eb214..879962c0 100644 --- a/tui/src/components/BodyPane.tsx +++ b/tui/src/components/BodyPane.tsx @@ -1,10 +1,15 @@ import { Box, Text } from 'ink'; import { useAtomValue } from 'jotai'; -import { DEFAULT_BODY_ENTRIES, resolveBodyRows } from '@libs/tui/bodyRows.ts'; +import type { ReactNode } from 'react'; +import { DEFAULT_BODY_ENTRIES } from '@libs/tui/bodyRows.ts'; import type { BodyEntry, BodyRow } from '@libs/tui/bodyRows.ts'; -import { clamp } from '@libs/math/clamp.ts'; +import { resolveBodyRowWindow } from '@libs/tui/bodyWindow.ts'; +import { selectionBounds } from '@libs/selection/bounds.ts'; +import { rowHighlight, type RowHighlight } from '@libs/selection/highlightRow.ts'; +import { displayWidth, padEndToWidth } from '@libs/text/displayWidth.ts'; import { bodyScrollOffsetRowsAtom, + bodySelectionAtom, displayedBodyEntriesAtom, layoutAtom } from '@state/ui/index.ts'; @@ -12,9 +17,6 @@ import { columnsAtom } from '@state/ui/index.ts'; import { theme } from '@theme/themeConfig.ts'; import { SCROLLBAR_THUMB, SCROLLBAR_TRACK } from '@constants/ui.ts'; -export type { BodyEntry } from '@libs/tui/bodyRows.ts'; -export { countBodyRows, DEFAULT_BODY_ENTRIES } from '@libs/tui/bodyRows.ts'; - type ScrollbarCell = { color: string; text: string; @@ -37,6 +39,7 @@ export function BodyPane({ const atomLayout = useAtomValue(layoutAtom); const atomColumns = useAtomValue(columnsAtom); const atomScrollOffsetRows = useAtomValue(bodyScrollOffsetRowsAtom); + const selection = useAtomValue(bodySelectionAtom); const resolvedEntries = entries ?? atomEntries ?? DEFAULT_BODY_ENTRIES; const resolvedRows = rows ?? atomLayout.bodyRows; @@ -44,17 +47,22 @@ export function BodyPane({ const resolvedScrollOffsetRows = scrollOffsetRows ?? atomScrollOffsetRows; const visibleRows = Math.max(1, resolvedRows); const visibleColumns = Math.max(1, resolvedColumns); - const allRows = resolveBodyRows(resolvedEntries, visibleColumns, visibleRows); + const { + allRows, + startIndex: start, + visibleRows: visibleRowsForOffset + } = resolveBodyRowWindow( + resolvedEntries, + visibleColumns, + visibleRows, + resolvedScrollOffsetRows + ); const maxScrollOffset = Math.max(0, allRows.length - visibleRows); - const scrollOffset = clamp(resolvedScrollOffsetRows, 0, maxScrollOffset); - // Offset counts rows back from the newest content at the bottom, matching - // terminal transcript behavior where scroll offset 0 means "stick to bottom". - const end = allRows.length - scrollOffset; - const start = Math.max(0, end - visibleRows); const isScrollable = maxScrollOffset > 0; const renderedRows = isScrollable ? visibleRows : Math.min(visibleRows, allRows.length + 1); const contentColumns = isScrollable ? Math.max(1, visibleColumns - 1) : visibleColumns; - const visibleRowsForOffset = allRows.slice(start, end); + const selectionBoundsValue = + selection === null ? null : selectionBounds(selection.anchor, selection.focus); const scrollbarCells = isScrollable ? renderScrollbar({ rows: visibleRows, @@ -71,11 +79,19 @@ export function BodyPane({ text: '' }; const marker = row.marker ?? ''; - const paddedTextColumns = Math.max(1, contentColumns - marker.length); + const markerWidth = displayWidth(marker); + const paddedTextColumns = Math.max(1, contentColumns - markerWidth); const shouldPadText = isScrollable || row.fillColumns === true; - const displayText = shouldPadText - ? padBodyText(row.text, paddedTextColumns) - : row.text || ' '; + const highlight = + selectionBoundsValue === null || row.decorative === true + ? null + : rowHighlight(row.text, start + index, selectionBoundsValue, markerWidth); + const displayText = + highlight === null + ? shouldPadText + ? padBodyText(row.text, paddedTextColumns) + : row.text || ' ' + : undefined; return ( @@ -84,9 +100,13 @@ export function BodyPane({ {marker} ) : null} - - {displayText} - + {highlight === null ? ( + + {displayText} + + ) : ( + renderHighlightedContent(highlight, row, paddedTextColumns, shouldPadText) + )} {isScrollable ? ( {scrollbarCells[index]?.text ?? SCROLLBAR_TRACK} @@ -127,5 +147,39 @@ function renderScrollbar({ } function padBodyText(text: string, contentColumns: number): string { - return text.padEnd(contentColumns, ' '); + return padEndToWidth(text, contentColumns); +} + +function renderHighlightedContent( + highlight: RowHighlight, + row: BodyRow, + paddedTextColumns: number, + shouldPadText: boolean +): ReactNode { + const padding = shouldPadText + ? ' '.repeat(Math.max(0, paddedTextColumns - displayWidth(row.text))) + : ''; + + return ( + <> + {highlight.pre.length > 0 ? ( + + {highlight.pre} + + ) : null} + + {highlight.selected} + + {highlight.post.length > 0 ? ( + + {highlight.post} + + ) : null} + {padding.length > 0 ? ( + + {padding} + + ) : null} + + ); } diff --git a/tui/src/components/CwdLine.tsx b/tui/src/components/CwdLine.tsx index d0ee7fff..3d8e17e2 100644 --- a/tui/src/components/CwdLine.tsx +++ b/tui/src/components/CwdLine.tsx @@ -1,17 +1,18 @@ import { Box, Text } from 'ink'; import { useAtomValue } from 'jotai'; -import { formatCwdLine } from '@libs/tui/cwdLine.ts'; -import { gitStatusLabelAtom } from '@state/ui/index.ts'; +import { renderCwdLine } from '@libs/tui/cwdLine.ts'; +import { chromeColumnsAtom, gitStatusAtom } from '@state/ui/index.ts'; import { workspaceCwdAtom } from '@state/global/index.ts'; import { theme } from '@theme/themeConfig.ts'; export function CwdLine() { const workspaceCwd = useAtomValue(workspaceCwdAtom); - const gitStatusLabel = useAtomValue(gitStatusLabelAtom); + const gitStatus = useAtomValue(gitStatusAtom); + const columns = useAtomValue(chromeColumnsAtom); return ( - - {formatCwdLine(workspaceCwd, gitStatusLabel)} + + {renderCwdLine(workspaceCwd, gitStatus)} ); } diff --git a/tui/src/components/HelpScreen/__tests__/helpContent.test.ts b/tui/src/components/HelpScreen/__tests__/helpContent.test.ts index bd4e26bb..b2d1f58a 100644 --- a/tui/src/components/HelpScreen/__tests__/helpContent.test.ts +++ b/tui/src/components/HelpScreen/__tests__/helpContent.test.ts @@ -3,7 +3,7 @@ import { buildCommandSection, buildHelpSections, flattenHelpLines -} from '@components/HelpScreen/helpContent.ts'; +} from '@libs/help/helpContent.ts'; import { COMMAND_REGISTRY } from '@libs/commands/registry.ts'; describe('helpContent', () => { @@ -22,7 +22,14 @@ describe('helpContent', () => { const titles = buildHelpSections().map((section) => section.title); expect(titles[0]).toBe('COMMANDS'); - expect(titles).toEqual(['COMMANDS', 'GLOBAL', 'INPUT', 'COMMAND MENU', 'SCROLL']); + expect(titles).toEqual([ + 'COMMANDS', + 'GLOBAL', + 'INPUT', + 'SELECTION', + 'COMMAND MENU', + 'SCROLL' + ]); }); it('flattens sections into title, entry, and blank-separator lines', () => { diff --git a/tui/src/components/HelpScreen/index.tsx b/tui/src/components/HelpScreen/index.tsx index e7ab6b74..f0db5a80 100644 --- a/tui/src/components/HelpScreen/index.tsx +++ b/tui/src/components/HelpScreen/index.tsx @@ -1,8 +1,8 @@ import { Box, Text, useInput } from 'ink'; import { useAtomValue, useSetAtom } from 'jotai'; import { useMemo } from 'react'; -import { buildHelpSections, flattenHelpLines } from '@components/HelpScreen/helpContent.ts'; -import type { HelpLine } from '@components/HelpScreen/helpContent.ts'; +import { buildHelpSections, flattenHelpLines } from '@libs/help/helpContent.ts'; +import type { HelpLine } from '@libs/help/helpContent.ts'; import { clamp } from '@libs/math/clamp.ts'; import { columnsAtom, rowsAtom } from '@state/ui/index.ts'; import { diff --git a/tui/src/components/HomeScreen/HomeScreenView.tsx b/tui/src/components/HomeScreen/HomeScreenView.tsx index 0780549f..a251d6e4 100644 --- a/tui/src/components/HomeScreen/HomeScreenView.tsx +++ b/tui/src/components/HomeScreen/HomeScreenView.tsx @@ -1,69 +1,25 @@ -import { Box, useInput, useStdout } from 'ink'; -import { useAtomValue, useSetAtom } from 'jotai'; -import { useEffect } from 'react'; +import { Box } from 'ink'; +import { useAtomValue } from 'jotai'; import { BodyPane } from '@components/BodyPane.tsx'; import { CwdLine } from '@components/CwdLine.tsx'; import { Header } from '@components/Header.tsx'; import { PromptComposer } from '@components/PromptComposer/index.tsx'; import { SlashCommandMenu } from '@components/SlashCommandMenu/index.tsx'; import { StatusBar } from '@components/StatusBar.tsx'; -import { - DISABLE_SGR_MOUSE_TRACKING, - ENABLE_SGR_MOUSE_TRACKING, - parseMouseWheelInput -} from '@libs/terminal/mouse.ts'; +import { useHomeScreenInput } from '@hooks/homeScreen/useHomeScreenInput.ts'; import { BODY_CWD_GAP_ROWS } from '@libs/tui/layout.ts'; import { bottomSpacerRowsAtom, - layoutAtom, - scrollBodyByRowsAtom + layoutAtom } from '@state/ui/index.ts'; import { columnsAtom, rowsAtom } from '@state/ui/index.ts'; import { commandMenuOpenAtom } from '@state/ui/commands/index.ts'; import { theme } from '@theme/themeConfig.ts'; -import { MOUSE_WHEEL_SCROLL_ROWS } from '@constants/ui.ts'; export function HomeScreenView() { - const { stdout } = useStdout(); const columns = useAtomValue(columnsAtom); const rows = useAtomValue(rowsAtom); - const layout = useAtomValue(layoutAtom); - const scrollBodyByRows = useSetAtom(scrollBodyByRowsAtom); - - useEffect(() => { - if (!stdout.isTTY) { - return; - } - - stdout.write(ENABLE_SGR_MOUSE_TRACKING); - return () => { - stdout.write(DISABLE_SGR_MOUSE_TRACKING); - }; - }, [stdout]); - - useInput((input, key) => { - const wheelDirection = parseMouseWheelInput(input); - if (wheelDirection !== null) { - scrollBodyByRows( - wheelDirection === 'up' ? MOUSE_WHEEL_SCROLL_ROWS : -MOUSE_WHEEL_SCROLL_ROWS - ); - return; - } - - if (key.pageUp) { - scrollBodyByRows(Math.max(1, layout.bodyRows - 2)); - return; - } - - if (key.pageDown) { - scrollBodyByRows(-Math.max(1, layout.bodyRows - 2)); - return; - } - - if (key.end) { - scrollBodyByRows(Number.NEGATIVE_INFINITY); - } - }); + useHomeScreenInput(); return ( ({ + openExternalUrl: vi.fn(() => true) +})); + +import { App } from '@/App.tsx'; +import type { GitStatus } from '@contracts/backend/index.ts'; +import { openExternalUrl } from '@libs/os/openExternalUrl.ts'; +import { pullRequestLabelOffset } from '@libs/tui/cwdLine.ts'; +import { flushInput } from '@test/flushInput.ts'; +import { renderWithJotai } from '@test/renderWithJotai.tsx'; +import { + columnsTestOverrideAtom, + composerTopAtom, + cwdRowsAtom, + gitStatusAtom, + rowsTestOverrideAtom +} from '@state/ui/index.ts'; +import { workspaceCwdAtom } from '@state/global/index.ts'; + +const workspaceCwd = path.join(os.homedir(), 'Projects', 'KQode'); +const url = 'https://github.com/o/r/pull/3'; +const gitStatus: GitStatus = { label: '⎇ main*', pullRequestLabel: '#3', pullRequestUrl: url }; + +/** Renders the App at a wide, single-row-cwd size and returns the click seam. */ +function renderApp() { + const store = createStore(); + store.set(workspaceCwdAtom, workspaceCwd); + store.set(gitStatusAtom, gitStatus); + store.set(columnsTestOverrideAtom, 100); + store.set(rowsTestOverrideAtom, 20); + const instance = renderWithJotai(, store); + return { store, ...instance }; +} + +/** SGR left-button press at a 1-based row/column. */ +function clickAt(column: number, row: number): string { + return `\u001B[<0;${column};${row}M`; +} + +describe('pull-request label click wiring', () => { + beforeEach(() => { + vi.mocked(openExternalUrl).mockClear(); + }); + + it('opens the PR url on a plain click on the #3 label', async () => { + const { store, stdin } = renderApp(); + await flushInput(); + + const composerTop = store.get(composerTopAtom); + const cwdRows = store.get(cwdRowsAtom); + const labelStart = pullRequestLabelOffset(workspaceCwd, gitStatus) ?? -1; + // Single cwd row at rowOffset 0 (1-based row); first character of #3. + const row = composerTop - cwdRows + 1; + const column = labelStart + 1; + + stdin.write(clickAt(column, row)); + await flushInput(); + + expect(openExternalUrl).toHaveBeenCalledWith(url); + }); + + it('does not open when the click lands on the bracket left of the label', async () => { + const { store, stdin } = renderApp(); + await flushInput(); + + const composerTop = store.get(composerTopAtom); + const cwdRows = store.get(cwdRowsAtom); + const labelStart = pullRequestLabelOffset(workspaceCwd, gitStatus) ?? -1; + const row = composerTop - cwdRows + 1; + + stdin.write(clickAt(labelStart, row)); // column = labelStart → offset labelStart-1 (the '[') + await flushInput(); + + expect(openExternalUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/tui/src/components/HomeScreen/__tests__/wheelRouting.test.ts b/tui/src/components/HomeScreen/__tests__/wheelRouting.test.ts new file mode 100644 index 00000000..020633a4 --- /dev/null +++ b/tui/src/components/HomeScreen/__tests__/wheelRouting.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { isPointerOverComposer, resolveWheelTarget } from '@libs/tui/wheelRouting.ts'; + +// rows = 24; composer block top at 0-based row 18; status row = 23. +const composerTop = 18; +const rows = 24; + +describe('isPointerOverComposer', () => { + it('is true from the composer top row down to just above the status row', () => { + expect(isPointerOverComposer(composerTop + 1, composerTop, rows)).toBe(true); // top cap + expect(isPointerOverComposer(rows - 1, composerTop, rows)).toBe(true); // bottom cap + }); + + it('is false one row above the composer block', () => { + expect(isPointerOverComposer(composerTop, composerTop, rows)).toBe(false); + }); + + it('is false on the header and on the status row', () => { + expect(isPointerOverComposer(1, composerTop, rows)).toBe(false); // header + expect(isPointerOverComposer(rows, composerTop, rows)).toBe(false); // status row + }); +}); + +describe('resolveWheelTarget', () => { + const over = { mouseRow: composerTop + 2, composerTop, rows }; + + it('routes to the composer when over it and it can scroll', () => { + expect(resolveWheelTarget({ ...over, composerCanScroll: true })).toBe('composer'); + }); + + it('falls through to the body when over a composer that cannot scroll', () => { + expect(resolveWheelTarget({ ...over, composerCanScroll: false })).toBe('body'); + }); + + it('defaults to the body for header/spacer/cwd and status rows', () => { + expect(resolveWheelTarget({ mouseRow: 2, composerTop, rows, composerCanScroll: true })).toBe('body'); + expect(resolveWheelTarget({ mouseRow: rows, composerTop, rows, composerCanScroll: true })).toBe('body'); + }); + + it('defaults to the body for an out-of-range pointer row', () => { + expect(resolveWheelTarget({ mouseRow: 9999, composerTop, rows, composerCanScroll: true })).toBe('body'); + }); +}); diff --git a/tui/src/components/HomeScreen/__tests__/wheelScroll.test.ts b/tui/src/components/HomeScreen/__tests__/wheelScroll.test.ts new file mode 100644 index 00000000..87555986 --- /dev/null +++ b/tui/src/components/HomeScreen/__tests__/wheelScroll.test.ts @@ -0,0 +1,40 @@ +import { createStore } from 'jotai'; +import { describe, expect, it, vi } from 'vitest'; +import { MOUSE_WHEEL_SCROLL_ROWS } from '@constants/ui.ts'; +import { handleWheelScroll } from '@hooks/homeScreen/handleWheelScroll.ts'; +import { + bodyEntriesAtom, + bodyScrollOffsetRowsAtom, + columnsTestOverrideAtom, + rowsTestOverrideAtom +} from '@state/ui/index.ts'; + +describe('handleWheelScroll', () => { + it('applies every body notch from a batched input chunk', () => { + const store = createStore(); + store.set(columnsTestOverrideAtom, 80); + store.set(rowsTestOverrideAtom, 24); + store.set( + bodyEntriesAtom, + Array.from({ length: 80 }, (_, index) => ({ + kind: 'assistant' as const, + text: `line ${index}` + })) + ); + const notifyScroll = vi.fn(); + + handleWheelScroll( + store, + Array.from({ length: 3 }, () => ({ direction: 'up' as const, row: 2 })), + notifyScroll + ); + expect(store.get(bodyScrollOffsetRowsAtom)).toBe(3 * MOUSE_WHEEL_SCROLL_ROWS); + expect(notifyScroll).toHaveBeenCalledTimes(1); + }); + + it('does nothing for an empty wheel batch', () => { + const notifyScroll = vi.fn(); + handleWheelScroll(createStore(), [], notifyScroll); + expect(notifyScroll).not.toHaveBeenCalled(); + }); +}); diff --git a/tui/src/components/PromptComposer/ComposerCaret.tsx b/tui/src/components/PromptComposer/ComposerCaret.tsx new file mode 100644 index 00000000..9545c677 --- /dev/null +++ b/tui/src/components/PromptComposer/ComposerCaret.tsx @@ -0,0 +1,17 @@ +import { useCursor } from 'ink'; +import { useAtomValue } from 'jotai'; +import { composerCaretRefreshTickAtom } from '@state/ui/composer/index.ts'; + +type ComposerCaretProps = { + position: { x: number; y: number } | undefined; +}; + +export function ComposerCaret({ position }: ComposerCaretProps) { + // Scroll repaints reset Ink's cursorDirty flag. Keep this subscription in a + // leaf so re-asserting the caret does not re-render the full composer. + useAtomValue(composerCaretRefreshTickAtom); + const { setCursorPosition } = useCursor(); + + setCursorPosition(position); + return null; +} diff --git a/tui/src/components/PromptComposer/ComposerFrame.tsx b/tui/src/components/PromptComposer/ComposerFrame.tsx index 120c2415..4c873a84 100644 --- a/tui/src/components/PromptComposer/ComposerFrame.tsx +++ b/tui/src/components/PromptComposer/ComposerFrame.tsx @@ -1,11 +1,22 @@ import { Box, Text } from 'ink'; -import { LOWER_HALF_BLOCK, UPPER_HALF_BLOCK } from '@libs/tui/backgroundBlock.ts'; -import { PROMPT_PREFIX } from '@constants/ui.ts'; -import { formatValidationError } from '@components/PromptComposer/promptTextView.ts'; +import { + resolveComposerBackgroundEnabled, + resolveComposerBorderColumns, + resolveSurfaceBorderGlyph, + type SurfaceBorderEdge +} from '@libs/terminal/surfaceBorder.ts'; +import { + COMPOSER_RIGHT_PADDING_COLUMNS, + PROMPT_PREFIX +} from '@constants/ui.ts'; +import { resolveComposerInputColumns } from '@libs/composer/layout.ts'; +import { formatValidationError } from '@libs/composer/promptTextView.ts'; +import { padEndToWidth } from '@libs/text/displayWidth.ts'; import { theme } from '@theme/themeConfig.ts'; type ComposerFrameProps = { columns: number; + terminalColumns: number; shouldRenderBackground: boolean; validationError: string | null; visibleTextRows: string[]; @@ -13,31 +24,59 @@ type ComposerFrameProps = { export function ComposerFrame({ columns, + terminalColumns, shouldRenderBackground, validationError, visibleTextRows }: ComposerFrameProps) { + const renderInputBackground = + shouldRenderBackground && resolveComposerBackgroundEnabled(); + return ( <> - {shouldRenderBackground ? : null} + {shouldRenderBackground ? ( + + ) : null} {visibleTextRows.map((row, index) => ( ))} {validationError === null ? null : ( - - {formatValidationError(validationError, columns, shouldRenderBackground)} - + + {formatValidationError( + validationError, + columns - COMPOSER_RIGHT_PADDING_COLUMNS, + renderInputBackground + )} + + {renderInputBackground ? ( + + {' '.repeat(COMPOSER_RIGHT_PADDING_COLUMNS)} + + ) : null} + )} - {shouldRenderBackground ? : null} + {shouldRenderBackground ? ( + + ) : null} ); } @@ -54,10 +93,15 @@ function ComposerTextRow({ shouldRenderBackground: boolean; }) { const prefix = promptPrefixForRow(rowIndex); - const rowColumns = Math.max(0, columns - prefix.length); + const rowColumns = resolveComposerInputColumns(columns); return ( - + // Prevent Yoga's default stretch from painting this row into the reserved + // final-column gutter before the first incremental repaint. + - {shouldRenderBackground ? row.padEnd(rowColumns, ' ') : row} + {padEndToWidth(row, rowColumns)} + + + {' '.repeat(COMPOSER_RIGHT_PADDING_COLUMNS)} ); } -function ComposerHalfLine({ glyph, columns }: { glyph: string; columns: number }) { +function ComposerBorder({ edge, columns }: { edge: SurfaceBorderEdge; columns: number }) { + const glyph = resolveSurfaceBorderGlyph(edge); + return ( void; }; -export { formatVisiblePrompt, resolveComposerCursorPosition }; - export function PromptComposer({ columns, onSubmit, @@ -41,20 +43,22 @@ export function PromptComposer({ onVisibleRowsChange }: PromptComposerProps) { const state = useAtomValue(composerStateAtom); - const atomColumns = useAtomValue(columnsAtom); + const scrollOffsetRows = useAtomValue(composerScrollOffsetRowsAtom); + const atomTerminalColumns = useAtomValue(columnsAtom); const atomInputLocked = useAtomValue(inputLockedAtom); const atomLayout = useAtomValue(layoutAtom); const atomComposerTop = useAtomValue(composerTopAtom); const enqueuePrompt = useSetAtom(enqueuePromptAtom); const setComposerRows = useSetAtom(composerRowsAtom); + const scrollCursorIntoView = useSetAtom(scrollComposerCursorIntoViewAtom); const { exit } = useApp(); const clearTranscript = useSetAtom(clearTranscriptAtom); const openHelp = useSetAtom(openHelpAtom); const composerRef = useRef(null); const composerMetrics = useBoxMetrics(composerRef); - const { setCursorPosition } = useCursor(); - const resolvedColumns = columns ?? atomColumns; + const resolvedTerminalColumns = columns ?? atomTerminalColumns; + const resolvedColumns = resolveChromeColumns(resolvedTerminalColumns); const resolvedSubmit = onSubmit ?? ((prompt: string) => void enqueuePrompt(prompt)); const resolvedIsActive = isActive ?? !atomInputLocked; const resolvedMaxVisibleLines = maxVisibleLines ?? atomLayout.composerVisibleRows ?? DEFAULT_COMPOSER_VISIBLE_LINES; @@ -73,18 +77,21 @@ export function PromptComposer({ state, commandActions }); + usePasteInput(maxBytes); - const inputColumns = Math.max(1, resolvedColumns - PROMPT_PREFIX.length); - const visiblePrompt = formatVisiblePromptView( - state.text, - inputColumns, - resolvedMaxVisibleLines, - state.cursorIndex - ); - const visibleText = visiblePrompt.text; + const inputColumns = resolveComposerInputColumns(resolvedColumns); + const composerWindow = resolveComposerWindow({ + text: state.text, + columns: inputColumns, + maxVisibleLines: resolvedMaxVisibleLines, + cursorIndex: state.cursorIndex, + offset: scrollOffsetRows + }); + const visibleText = composerWindow.text; + const visibleTextRows = visibleText.split('\n'); const shouldRenderBackground = true; const visibleRows = countVisibleComposerRows( - visibleText, + visibleTextRows.length, state.validationError !== null, shouldRenderBackground ); @@ -93,27 +100,38 @@ export function PromptComposer({ resolvedVisibleRowsChange?.(visibleRows); }, [resolvedVisibleRowsChange, visibleRows]); - if (resolvedIsActive && composerMetrics.hasMeasured) { - setCursorPosition( - resolveComposerCursorPosition( - visibleText, - inputColumns, - resolvedCursorTop ?? composerMetrics.top, - visiblePrompt.cursorIndex, - shouldRenderBackground - ) - ); - } else { - setCursorPosition(undefined); - } + // Keep the caret visible after an edit or cursor move without snapping the + // view to the bottom: only a change that leaves the caret off-window scrolls + // (minimally, to the nearest edge). Keyed on text too, not just cursorIndex, + // because compound edits (e.g. bare Enter replacing a trailing `\` with a + // newline) change the text and the caret's row while leaving cursorIndex net + // unchanged. Wheel scrolling changes neither, so peeking is preserved. + useEffect(() => { + scrollCursorIntoView(); + }, [state.cursorIndex, state.text, scrollCursorIntoView]); + + // Input locking disables key handling only; keep the terminal caret anchored + // in the composer while startup or another status hint is active. + const caretPosition = + composerMetrics.hasMeasured && composerWindow.cursorVisible + ? resolveComposerCursorPosition( + visibleText, + inputColumns, + resolvedCursorTop ?? composerMetrics.top, + composerWindow.cursorIndex, + shouldRenderBackground + ) + : undefined; return ( + ); diff --git a/tui/src/components/PromptComposer/input/handleCursorMove.ts b/tui/src/components/PromptComposer/input/handleCursorMove.ts deleted file mode 100644 index 617162f0..00000000 --- a/tui/src/components/PromptComposer/input/handleCursorMove.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ComposerKeyHandler } from '@components/PromptComposer/input/types.ts'; -import { - moveComposerCursorBackwardAtom, - moveComposerCursorForwardAtom -} from '@state/ui/composer/index.ts'; - -/** Left/Right arrows move the composer cursor by one code point. */ -export const handleCursorMove: ComposerKeyHandler = (context) => { - const { key, store } = context; - - if (key.leftArrow) { - store.set(moveComposerCursorBackwardAtom); - return true; - } - - if (key.rightArrow) { - store.set(moveComposerCursorForwardAtom); - return true; - } - - return false; -}; diff --git a/tui/src/components/PromptComposer/promptTextView.ts b/tui/src/components/PromptComposer/promptTextView.ts deleted file mode 100644 index 17eda23e..00000000 --- a/tui/src/components/PromptComposer/promptTextView.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { COMPOSER_BACKGROUND_PADDING_ROWS } from '@constants/ui.ts'; -import { clamp } from '@libs/math/clamp.ts'; - -type WrappedPromptRow = { - text: string; - start: number; - end: number; -}; - -export type VisiblePromptView = { - text: string; - cursorIndex: number; -}; - -export function formatVisiblePrompt( - text: string, - columns: number, - maxVisibleLines: number -): string { - return formatVisiblePromptView(text, columns, maxVisibleLines, text.length).text; -} - -export function formatVisiblePromptView( - text: string, - columns: number, - maxVisibleLines: number, - cursorIndex: number -): VisiblePromptView { - const safeColumns = Math.max(1, columns); - const safeMaxVisibleLines = Math.max(1, maxVisibleLines); - const rows = wrapText(text, safeColumns); - const safeCursorIndex = clamp(cursorIndex, 0, text.length); - const cursorRowIndex = resolveCursorRowIndex(rows, safeCursorIndex); - const lastVisibleStart = Math.max(0, rows.length - safeMaxVisibleLines); - // Keep the active cursor row visible by sliding the window upward only when - // the cursor would otherwise fall below the last visible composer row. - const visibleStart = clamp(cursorRowIndex - safeMaxVisibleLines + 1, 0, lastVisibleStart); - const visibleRows = rows.slice(visibleStart, visibleStart + safeMaxVisibleLines); - const visibleCursorIndex = resolveVisibleCursorIndex(visibleRows, safeCursorIndex); - - return { - text: visibleRows.map((row) => row.text).join('\n'), - cursorIndex: visibleCursorIndex - }; -} - -export function countVisibleComposerRows( - visibleText: string, - hasValidationError: boolean, - hasBackgroundPadding: boolean -): number { - return ( - visibleText.split('\n').length + - (hasValidationError ? 1 : 0) + - (hasBackgroundPadding ? COMPOSER_BACKGROUND_PADDING_ROWS : 0) - ); -} - -export function formatValidationError(error: string, columns: number, shouldPad: boolean): string { - const errorLine = `ERROR: ${error}`; - return shouldPad ? errorLine.padEnd(columns, ' ') : errorLine; -} - -function wrapText(text: string, columns: number): WrappedPromptRow[] { - if (text.length === 0) { - return [{ text: '', start: 0, end: 0 }]; - } - - const rows: WrappedPromptRow[] = []; - let lineStart = 0; - - while (lineStart <= text.length) { - const newlineIndex = text.indexOf('\n', lineStart); - const lineEnd = newlineIndex < 0 ? text.length : newlineIndex; - const rawLine = text.slice(lineStart, lineEnd); - const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; - - if (line.length === 0) { - rows.push({ text: '', start: lineStart, end: lineStart }); - } else { - for (let offset = 0; offset < line.length; offset += columns) { - const endOffset = Math.min(offset + columns, line.length); - rows.push({ - text: line.slice(offset, endOffset), - start: lineStart + offset, - end: lineStart + endOffset - }); - } - } - - if (newlineIndex < 0) { - break; - } - - lineStart = newlineIndex + 1; - } - - return rows; -} - -function resolveCursorRowIndex(rows: WrappedPromptRow[], cursorIndex: number): number { - return Math.max( - 0, - rows.findIndex((row) => cursorIndex >= row.start && cursorIndex <= row.end) - ); -} - -function resolveVisibleCursorIndex(rows: WrappedPromptRow[], cursorIndex: number): number { - let visibleCursorIndex = 0; - - for (const row of rows) { - if (cursorIndex >= row.start && cursorIndex <= row.end) { - return visibleCursorIndex + Math.min(row.text.length, cursorIndex - row.start); - } - - visibleCursorIndex += row.text.length + 1; - } - - return Math.max(0, visibleCursorIndex - 1); -} diff --git a/tui/src/components/StatusBar.tsx b/tui/src/components/StatusBar.tsx index 4fa6c3e4..4b606e76 100644 --- a/tui/src/components/StatusBar.tsx +++ b/tui/src/components/StatusBar.tsx @@ -1,7 +1,13 @@ import { Box, Text } from 'ink'; -import { useAtomValue } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import { useEffect, useState } from 'react'; -import { armedActionAtom, columnsAtom, statusHintAtom } from '@state/ui/index.ts'; +import { + armedActionAtom, + chromeColumnsAtom, + setTransientStatusHintAtom, + statusHintAtom, + transientStatusHintAtom +} from '@state/ui/index.ts'; import { modelLabelAtom } from '@state/global/index.ts'; import { ArmedAction, @@ -9,16 +15,19 @@ import { LOADING_FRAME_COUNT, LOADING_FRAME_INTERVAL_MS, PRESS_AGAIN_TO_CLEAR_HINT, - PRESS_AGAIN_TO_EXIT_HINT + PRESS_AGAIN_TO_EXIT_HINT, + TRANSIENT_STATUS_HINT_MS } from '@constants/ui.ts'; import { theme } from '@theme/themeConfig.ts'; export function StatusBar() { - const columns = useAtomValue(columnsAtom); + const columns = useAtomValue(chromeColumnsAtom); const modelLabel = useAtomValue(modelLabelAtom); const statusHint = useAtomValue(statusHintAtom); + const transientStatusHint = useAtomValue(transientStatusHintAtom); const armedAction = useAtomValue(armedActionAtom); const loadingFrame = useLoadingFrame(statusHint?.kind === 'loading'); + useTransientStatusHintClear(transientStatusHint); const baseHints = statusHint === undefined ? DEFAULT_STATUS_HINTS : statusHint.text; const armedHint = armedAction === ArmedAction.ClearInput @@ -31,11 +40,9 @@ export function StatusBar() { (statusHint?.kind === 'loading' ? `${baseHints}${'.'.repeat(loadingFrame)}` : baseHints); return ( - // Fill the terminal's final column for a tight right edge. Ink erases to - // end-of-line after each row, and some terminals (WezTerm) drop a glyph in - // the last column — Windows Terminal renders it fine, so the model label is - // allowed to reach the edge. Restore paddingRight={1} if a terminal clips it. - + // Chrome stops before the physical final cell; the root body background + // paints that safety gutter. BodyPane alone owns scrollbar rendering. + {leftHints} {modelLabel} @@ -44,6 +51,24 @@ export function StatusBar() { ); } +function useTransientStatusHintClear(transientStatusHint: unknown) { + const setTransientStatusHint = useSetAtom(setTransientStatusHintAtom); + + useEffect(() => { + if (transientStatusHint === undefined) { + return; + } + + const timer = setTimeout(() => { + setTransientStatusHint(undefined); + }, TRANSIENT_STATUS_HINT_MS); + + return () => { + clearTimeout(timer); + }; + }, [setTransientStatusHint, transientStatusHint]); +} + function useLoadingFrame(isLoading: boolean): number { const [frame, setFrame] = useState(0); diff --git a/tui/src/components/TerminalTooSmall.tsx b/tui/src/components/TerminalTooSmall.tsx index 39898e9a..f22428ef 100644 --- a/tui/src/components/TerminalTooSmall.tsx +++ b/tui/src/components/TerminalTooSmall.tsx @@ -1,6 +1,7 @@ import { Box, Text } from 'ink'; import { useAtomValue } from 'jotai'; -import { columnsAtom, MIN_USABLE_TERMINAL_COLUMNS, MIN_USABLE_TERMINAL_ROWS } from '@state/ui/index.ts'; +import { MIN_USABLE_TERMINAL_COLUMNS } from '@constants/ui.ts'; +import { columnsAtom, minimumUsableRowsAtom } from '@state/ui/index.ts'; import { theme } from '@theme/themeConfig.ts'; /** @@ -11,13 +12,14 @@ import { theme } from '@theme/themeConfig.ts'; */ export function TerminalTooSmall() { const columns = useAtomValue(columnsAtom); + const minimumUsableRows = useAtomValue(minimumUsableRowsAtom); return ( Terminal too small Please enlarge or maximize the window - {`(needs at least ${MIN_USABLE_TERMINAL_COLUMNS} cols \u00D7 ${MIN_USABLE_TERMINAL_ROWS} rows)`} + {`(needs at least ${MIN_USABLE_TERMINAL_COLUMNS} cols \u00D7 ${minimumUsableRows} rows)`} ); diff --git a/tui/src/constants/composer.ts b/tui/src/constants/composer.ts new file mode 100644 index 00000000..cff7ce70 --- /dev/null +++ b/tui/src/constants/composer.ts @@ -0,0 +1,6 @@ +/** Empty prompt composer value used for initialization and reset. */ +export const INITIAL_COMPOSER_STATE = { + text: '', + cursorIndex: 0, + validationError: null +} as const; diff --git a/tui/src/constants/model.ts b/tui/src/constants/model.ts new file mode 100644 index 00000000..d0557282 --- /dev/null +++ b/tui/src/constants/model.ts @@ -0,0 +1,2 @@ +/** Default model label shown before provider/model selection is configurable. */ +export const DEFAULT_MODEL_LABEL = 'GPT-5.5'; diff --git a/tui/src/constants/statusHint.ts b/tui/src/constants/statusHint.ts new file mode 100644 index 00000000..b0df119e --- /dev/null +++ b/tui/src/constants/statusHint.ts @@ -0,0 +1,10 @@ +export type StatusHint = { + text: string; + kind?: 'loading'; +}; + +/** Status hint shown while the backend process starts at launch. */ +export const BACKEND_LOADING_HINT = { + text: 'Loading backend', + kind: 'loading' +} as const satisfies StatusHint; diff --git a/tui/src/constants/terminal.ts b/tui/src/constants/terminal.ts new file mode 100644 index 00000000..4f44ae06 --- /dev/null +++ b/tui/src/constants/terminal.ts @@ -0,0 +1,5 @@ +/** `TERM_PROGRAM` value exported by WezTerm. */ +export const WEZTERM_TERM_PROGRAM = 'WezTerm'; + +/** Rows reserved in WezTerm so Ink stays below its fullscreen threshold. */ +export const WEZTERM_FULLSCREEN_GUARD_ROWS = 1; diff --git a/tui/src/constants/ui.ts b/tui/src/constants/ui.ts index 4a6291a3..c6fc0365 100644 --- a/tui/src/constants/ui.ts +++ b/tui/src/constants/ui.ts @@ -11,24 +11,37 @@ export const DEFAULT_ROWS = 24; export const MIN_ROWS = 15; export const MIN_COLUMNS = 60; export const DEFAULT_COMPOSER_VISIBLE_LINES = 3; +/** Physical terminal cells reserved from meaningful non-body chrome content. */ +export const TERMINAL_FINAL_COLUMN_GUARD = 1; +export const FULLSCREEN_GUARD_ROWS = 0; +export const MIN_USABLE_TERMINAL_ROWS = MIN_ROWS + FULLSCREEN_GUARD_ROWS; +export const MIN_USABLE_TERMINAL_COLUMNS = MIN_COLUMNS; // --- Prompt composer --- export const PROMPT_PREFIX = '> '; +/** Background-only space between prompt text and the composer's right edge. */ +export const COMPOSER_RIGHT_PADDING_COLUMNS = 1; + +/** Terminal input sequences emitted for modified Enter combinations. */ +export const MODIFIED_ENTER_INPUTS: readonly string[] = [ + '\u001B[13;2u', + '\u001B[13;3u', + '\u001B[13;5u', + '\u001B[13;6u' +]; -// The app fills the terminal exactly (FULLSCREEN_GUARD_ROWS = 0), so Ink treats -// each frame as fullscreen, omits its trailing newline, and shifts the cursor -// baseline up one row. This offset adds that row back so the measured composer -// top maps onto the editable row. (It was 0 while the app rendered just under -// fullscreen, where Ink appends the trailing newline and the baseline already -// lands on the output's bottom row.) NOTE: below the MIN_ROWS floor the layout -// makes content overflow the terminal and this can be off by one — but the -// too-small gate replaces the home screen before that, so it is a test-only -// degenerate case. -export const INK_CURSOR_ROW_ORIGIN_OFFSET = 1; export const COMPOSER_BACKGROUND_PADDING_ROWS = 2; export const COMPOSER_BACKGROUND_TOP_PADDING_ROWS = 1; +/** + * Divisor bounding the composer's visible box to a fraction of the terminal + * height (`2` = at most half) so a long prompt cannot bury the transcript. The + * text-line cap derived from it subtracts the background padding and the + * reserved error row, so the whole composer box stays within `rows / DIVISOR`. + */ +export const COMPOSER_MAX_HEIGHT_DIVISOR = 2; + // --- Slash commands --- /** @@ -60,6 +73,11 @@ export const PRESS_AGAIN_TO_EXIT_HINT = 'ctrl+c again to exit'; /** Default left-aligned status-bar hints shown when no transient hint is active. */ export const DEFAULT_STATUS_HINTS = '/ commands | @ mention | ? help'; +export const TRANSIENT_STATUS_HINT_MS = 1_500; +export const RIGHT_CLICK_PASTE_DEDUP_MS = 250; +export const PASTE_FAILED_HINT = 'paste failed'; +export const SELECTION_COPIED_HINT = 'copied selection'; +export const SELECTION_COPY_FAILED_HINT = 'copy failed'; // --- Status-bar loading spinner --- diff --git a/tui/src/contracts/backend/client.ts b/tui/src/contracts/backend/client.ts index 725156d9..2f35c2fd 100644 --- a/tui/src/contracts/backend/client.ts +++ b/tui/src/contracts/backend/client.ts @@ -1,5 +1,3 @@ -import type { MessageSubmitParams, MessageSubmitResult } from '@contracts/backend/messages.ts'; - /** * Consumer-facing backend seam shared by the `@state` and `@backend` layers. * @@ -33,13 +31,61 @@ export class BackendClientError extends Error { } } +/** Params the TUI submits. */ +export type SubmitParams = { + text: string; +}; + +/** + * Terminal outcome of a submitted prompt in this bootstrap slice. + * + * No provider is wired yet, so every accepted submit resolves + * `needsConfiguration`. Streaming outcomes (completed assistant text, provider + * errors) arrive with the provider PR. Transport/timeout failures reject with a + * {@link BackendClientError} instead of resolving. + */ +export type SubmitOutcome = { kind: 'needsConfiguration' }; + +/** Workspace source-control status already formatted by the Rust backend. */ +export type GitStatus = { + label: string; + pullRequestLabel?: string; + pullRequestUrl?: string; +}; + +/** + * The current branch's pull request, formatted by the Rust backend: a `#N` + * `label` and (when available) its web `url`. + */ +export type PullRequestStatus = { + label: string; + url?: string; +}; + /** - * Narrow backend seam the TUI uses for the first-slice ACK protocol. + * Narrow backend seam the TUI uses to submit chat turns. * - * `submitMessage` resolves with the backend ACK result or rejects with a - * {@link BackendClientError}; display components depend only on this interface - * so process and protocol mechanics stay out of the render tree. + * `submit` sends the prompt and resolves with a {@link SubmitOutcome} when the + * backend acks. In this bootstrap slice that outcome is always + * `needsConfiguration` (no provider is wired yet); it rejects with a + * {@link BackendClientError} only for transport/timeout failures. Display + * components depend only on this interface so process and protocol mechanics stay + * out of the render tree. */ export type BackendClient = { - submitMessage(params: MessageSubmitParams): Promise; + submit(params: SubmitParams): Promise; + /** + * Fetches the workspace working-tree label, or `null` when the workspace is + * not a git repository or `git` could not be queried. Refreshed after every + * turn. Rejects with a {@link BackendClientError} on transport/timeout + * failure. The backend formats display segments; the TUI only wraps them. + */ + gitStatus(): Promise; + /** + * Fetches the current branch's GitHub pull request (label + URL), or `null` + * when there is no PR (or `gh` could not be queried). Fetched once at bootstrap + * because a branch's PR is static for the session. Rejects with a + * {@link BackendClientError} on transport/timeout failure. + */ + pullRequest(): Promise; }; diff --git a/tui/src/contracts/backend/messages.ts b/tui/src/contracts/backend/messages.ts index a048f46a..17b4f96e 100644 --- a/tui/src/contracts/backend/messages.ts +++ b/tui/src/contracts/backend/messages.ts @@ -15,11 +15,23 @@ export const MESSAGE_SUBMIT_METHOD = 'kqode.message.submit'; /** - * ACK text the first-slice Rust backend returns for a received prompt. + * KQode-owned JSON-RPC method returning the workspace working-tree label. * - * Must match the `ACK_MESSAGE` constant in `src/protocol.rs`. + * Must match `RpcMethod::GitStatus` (via `RpcMethod::as_str`) in + * `src/protocol.rs`. The backend runs `git status` in the workspace and formats + * the returned label; the branch's pull request is a separate + * {@link PULL_REQUEST_METHOD} call. */ -export const ACK_MESSAGE = 'ACK: message received'; +export const GIT_STATUS_METHOD = 'kqode.git.status'; + +/** + * KQode-owned JSON-RPC method returning the current branch's GitHub pull request. + * + * Must match `RpcMethod::PullRequest` (via `RpcMethod::as_str`) in + * `src/protocol.rs`. The backend runs `gh pr view` (a network call), so the TUI + * requests it once at bootstrap rather than on every turn. + */ +export const PULL_REQUEST_METHOD = 'kqode.git.pullRequest'; /** * JSON-RPC notification the backend emits exactly once, as soon as it is @@ -32,20 +44,49 @@ export const ACK_MESSAGE = 'ACK: message received'; */ export const BACKEND_READY_METHOD = 'kqode.backend.ready'; +/** `status` value when a submit cannot run because no API key is configured. */ +export const SUBMIT_STATUS_NEEDS_CONFIGURATION = 'needsConfiguration'; + /** - * Params for `kqode.message.submit`; intentionally text-only for this slice. + * Params for `kqode.message.submit`. * - * The Rust backend deserializes this with serde `#[serde(deny_unknown_fields)]` - * (`MessageSubmitParams` in `src/protocol.rs`), so adding a field here without - * updating the Rust struct makes the backend reject the request as invalid - * params. Keep the two shapes in lockstep. + * Carries only the prompt `text` in this bootstrap slice. The Rust backend + * deserializes this with `#[serde(deny_unknown_fields)]` (`MessageSubmitParams` + * in `src/protocol.rs`), so keep the two shapes in lockstep. */ export type MessageSubmitParams = { text: string; }; -/** Result for `kqode.message.submit`. */ +/** + * Result for `kqode.message.submit`: an immediate ack. `status` is + * {@link SUBMIT_STATUS_NEEDS_CONFIGURATION} in this bootstrap slice because no + * provider is wired yet; streaming statuses arrive with the provider PR. + */ export type MessageSubmitResult = { - message: string; - receivedText: string; + status: string; +}; + +/** + * Result for `kqode.git.status`: the formatted working-tree label (e.g. + * `⎇ main*`), or `null` when the workspace is not a git repository or `git` + * could not be queried. Refreshed after every turn; the branch's pull request is + * a separate {@link PullRequestResult}. The Rust backend owns parsing and + * formatting (`GitStatusResult` in `src/protocol.rs`); keep the two shapes in + * lockstep. + */ +export type GitStatusResult = { + label: string | null; +}; + +/** + * Result for `kqode.git.pullRequest`: the current branch's GitHub PR as a + * display `label` (e.g. `#3`) and web `url`, or both `null` when there is no PR + * (or `gh` could not be queried). Fetched once at bootstrap because a branch's + * PR is static for the session. The Rust backend owns parsing and formatting + * (`PullRequestResult` in `src/protocol.rs`); keep the two shapes in lockstep. + */ +export type PullRequestResult = { + label: string | null; + url: string | null; }; diff --git a/tui/src/contracts/clipboard/index.ts b/tui/src/contracts/clipboard/index.ts new file mode 100644 index 00000000..8b492f52 --- /dev/null +++ b/tui/src/contracts/clipboard/index.ts @@ -0,0 +1,7 @@ +/** Clipboard operations exposed to the TUI runtime. */ +export type ClipboardClient = { + /** Returns `null` when clipboard text cannot be read. */ + readText(): Promise; + /** Returns `false` when `text` cannot be written. */ + writeText(text: string): Promise; +}; diff --git a/tui/src/hooks/homeScreen/handleWheelScroll.ts b/tui/src/hooks/homeScreen/handleWheelScroll.ts new file mode 100644 index 00000000..0c118d63 --- /dev/null +++ b/tui/src/hooks/homeScreen/handleWheelScroll.ts @@ -0,0 +1,48 @@ +import type { createStore } from 'jotai'; +import { MOUSE_WHEEL_SCROLL_ROWS } from '@constants/ui.ts'; +import { resolveWheelTarget } from '@libs/tui/wheelRouting.ts'; +import type { MouseWheelEvent } from '@libs/terminal/mouse.ts'; +import { + composerCanScrollAtom, + composerTopAtom, + layoutAtom, + rowsAtom, + scrollBodyByRowsAtom, + scrollComposerByRowsAtom +} from '@state/ui/index.ts'; + +type Store = ReturnType; + +/** Applies every wheel notch in one input chunk in source order. */ +export function handleWheelScroll( + store: Store, + wheels: readonly MouseWheelEvent[], + notifyScroll: () => void +): void { + if (wheels.length === 0) { + return; + } + + notifyScroll(); + for (const wheel of wheels) { + const target = resolveWheelTarget({ + mouseRow: wheel.row, + composerTop: store.get(composerTopAtom), + rows: store.get(rowsAtom), + composerCanScroll: store.get(composerCanScrollAtom) + }); + + if (target === 'composer') { + const step = Math.max( + 1, + Math.min(MOUSE_WHEEL_SCROLL_ROWS, store.get(layoutAtom).composerVisibleRows - 1) + ); + store.set(scrollComposerByRowsAtom, wheel.direction === 'up' ? step : -step); + } else { + store.set( + scrollBodyByRowsAtom, + wheel.direction === 'up' ? MOUSE_WHEEL_SCROLL_ROWS : -MOUSE_WHEEL_SCROLL_ROWS + ); + } + } +} diff --git a/tui/src/hooks/homeScreen/selectionClipboard.ts b/tui/src/hooks/homeScreen/selectionClipboard.ts new file mode 100644 index 00000000..19d47197 --- /dev/null +++ b/tui/src/hooks/homeScreen/selectionClipboard.ts @@ -0,0 +1,71 @@ +import type { createStore } from 'jotai'; +import { + SELECTION_COPIED_HINT, + SELECTION_COPY_FAILED_HINT +} from '@constants/ui.ts'; +import { + composerTarget, + pasteFromClipboard +} from '@hooks/promptComposer/pasteFromClipboard.ts'; +import { selectedText } from '@libs/selection/selectedText.ts'; +import { + clipboardWritePendingAtom, + writeClipboardAtom +} from '@state/global/index.ts'; +import { + beginRightClickCopyAtom, + beginRightClickPasteAtom, + bodySelectionAtom, + clearBodySelectionAtom, + setTransientStatusHintAtom, + visibleBodyRowsAtom +} from '@state/ui/index.ts'; + +type Store = ReturnType; + +/** Copies the active non-empty selection and reports whether copy was attempted. */ +export function copySelection(store: Store): boolean { + const selection = store.get(bodySelectionAtom); + if (selection === null) { + return false; + } + + const text = selectedText( + store.get(visibleBodyRowsAtom).allRows, + selection.anchor, + selection.focus + ); + if (text.length === 0) { + return false; + } + + void store + .set(writeClipboardAtom, text) + .then((success) => { + store.set(setTransientStatusHintAtom, { + text: success ? SELECTION_COPIED_HINT : SELECTION_COPY_FAILED_HINT + }); + }) + .catch(() => { + store.set(setTransientStatusHintAtom, { text: SELECTION_COPY_FAILED_HINT }); + }); + + return true; +} + +/** Copies and clears a selection, or pastes when no non-empty selection exists. */ +export function handleRightClick(store: Store, maxBytes: number): void { + if (copySelection(store)) { + store.set(beginRightClickCopyAtom); + } else { + const target = composerTarget(store); + const { operationId, started } = store.set(beginRightClickPasteAtom, { + bufferNative: store.get(clipboardWritePendingAtom), + target + }); + if (started) { + pasteFromClipboard(store, maxBytes, { operationId }); + } + } + store.set(clearBodySelectionAtom); +} diff --git a/tui/src/hooks/homeScreen/selectionInput.ts b/tui/src/hooks/homeScreen/selectionInput.ts new file mode 100644 index 00000000..42c80789 --- /dev/null +++ b/tui/src/hooks/homeScreen/selectionInput.ts @@ -0,0 +1,61 @@ +import type { createStore } from 'jotai'; +import { clamp } from '@libs/math/clamp.ts'; +import type { MouseButtonEvent } from '@libs/terminal/mouse.ts'; +import { + bodyTopAtom, + composerTopAtom, + layoutAtom, + rowsAtom, + startBodySelectionAtom, + updateBodySelectionAtom, + visibleBodyRowsAtom +} from '@state/ui/index.ts'; + +type Store = ReturnType; +export type GestureRegion = 'body' | 'composer'; + +/** Resolves the screen region that owns a new left-button gesture. */ +export function resolveGestureRegion(store: Store, row: number): GestureRegion | null { + const pointerRow = row - 1; + const bodyTop = store.get(bodyTopAtom); + const bodyRows = store.get(layoutAtom).bodyRows; + if (pointerRow >= bodyTop && pointerRow < bodyTop + bodyRows) { + return 'body'; + } + + const composerTop = store.get(composerTopAtom); + if (pointerRow >= composerTop && pointerRow < store.get(rowsAtom) - 1) { + return 'composer'; + } + + return null; +} + +/** Applies a press, drag, or release to the absolute transcript selection. */ +export function handleSelectionGesture(store: Store, gesture: MouseButtonEvent): boolean { + const { allRows, startIndex, visibleRows } = store.get(visibleBodyRowsAtom); + if (visibleRows.length === 0) { + return false; + } + + const bodyTop = store.get(bodyTopAtom); + const rawViewportRow = gesture.row - 1 - bodyTop; + if ( + gesture.kind === 'press' && + (rawViewportRow < 0 || rawViewportRow >= visibleRows.length) + ) { + return false; + } + + const viewportRow = clamp(rawViewportRow, 0, visibleRows.length - 1); + const rowIndex = clamp(startIndex + viewportRow, 0, Math.max(0, allRows.length - 1)); + const point = { rowIndex, column: Math.max(0, gesture.column - 1) }; + + if (gesture.kind === 'press') { + store.set(startBodySelectionAtom, point); + return true; + } + + store.set(updateBodySelectionAtom, point); + return true; +} diff --git a/tui/src/hooks/homeScreen/useComposerCaretRefresh.ts b/tui/src/hooks/homeScreen/useComposerCaretRefresh.ts new file mode 100644 index 00000000..667df78b --- /dev/null +++ b/tui/src/hooks/homeScreen/useComposerCaretRefresh.ts @@ -0,0 +1,15 @@ +import { useCallback } from 'react'; +import { useSetAtom } from 'jotai'; +import { composerCaretRefreshTickAtom } from '@state/ui/composer/index.ts'; + +/** + * Returns a callback that asks the composer to re-assert its current terminal + * caret position after scroll repaints. Scrolling should not hide or move it. + */ +export function useComposerCaretRefresh(): () => void { + const setRefreshTick = useSetAtom(composerCaretRefreshTickAtom); + + return useCallback(() => { + setRefreshTick((current) => current + 1); + }, [setRefreshTick]); +} diff --git a/tui/src/hooks/homeScreen/useHomeScreenInput.ts b/tui/src/hooks/homeScreen/useHomeScreenInput.ts new file mode 100644 index 00000000..3e0d398d --- /dev/null +++ b/tui/src/hooks/homeScreen/useHomeScreenInput.ts @@ -0,0 +1,175 @@ +import { useInput, useStdout } from 'ink'; +import { createStore, useAtomValue, useSetAtom, useStore } from 'jotai'; +import { useEffect, useRef } from 'react'; +import { handleRightClick } from '@hooks/homeScreen/selectionClipboard.ts'; +import { + handleSelectionGesture, + resolveGestureRegion, + type GestureRegion +} from '@hooks/homeScreen/selectionInput.ts'; +import { handleWheelScroll } from '@hooks/homeScreen/handleWheelScroll.ts'; +import { useComposerCaretRefresh } from '@hooks/homeScreen/useComposerCaretRefresh.ts'; +import { usePullRequestClick } from '@hooks/homeScreen/usePullRequestClick.ts'; +import { resolveClickResult } from '@libs/composer/composerWindow.ts'; +import { resolveComposerInputColumns } from '@libs/composer/layout.ts'; +import { PROMPT_MAX_BYTES } from '@libs/composer/promptText.ts'; +import { + DISABLE_SGR_MOUSE_TRACKING, + ENABLE_SGR_MOUSE_TRACKING, + parseMouseInputEvents, + type MouseClickEvent +} from '@libs/terminal/mouse.ts'; +import { + clearBodySelectionAtom, + chromeColumnsAtom, + columnsAtom, + composerTopAtom, + displayedBodyEntriesAtom, + layoutAtom, + scrollBodyByRowsAtom +} from '@state/ui/index.ts'; +import { + composerScrollOffsetRowsAtom, + composerStateAtom, + setComposerCursorWithOffsetAtom +} from '@state/ui/composer/index.ts'; +import { + COMPOSER_BACKGROUND_TOP_PADDING_ROWS, + PROMPT_PREFIX +} from '@constants/ui.ts'; + +type Store = ReturnType; + +/** Wires home-screen mouse gestures and transcript scroll keys. */ +export function useHomeScreenInput(): void { + const { stdout } = useStdout(); + const scrollBodyByRows = useSetAtom(scrollBodyByRowsAtom); + const notifyScroll = useComposerCaretRefresh(); + const handlePullRequestClick = usePullRequestClick(); + const store = useStore(); + const columns = useAtomValue(columnsAtom); + const bodyRows = useAtomValue(layoutAtom).bodyRows; + const bodyEntries = useAtomValue(displayedBodyEntriesAtom); + const gestureRegionRef = useRef(null); + const selectionGeometryRef = useRef({ bodyEntries, bodyRows, columns }); + + useEffect(() => { + if (!stdout.isTTY) { + return; + } + + stdout.write(ENABLE_SGR_MOUSE_TRACKING); + return () => { + stdout.write(DISABLE_SGR_MOUSE_TRACKING); + }; + }, [stdout]); + + useEffect(() => { + const previous = selectionGeometryRef.current; + if ( + previous.bodyEntries !== bodyEntries || + previous.bodyRows !== bodyRows || + previous.columns !== columns + ) { + store.set(clearBodySelectionAtom); + gestureRegionRef.current = null; + selectionGeometryRef.current = { bodyEntries, bodyRows, columns }; + } + }, [bodyEntries, bodyRows, columns, store]); + + useEffect( + () => () => { + store.set(clearBodySelectionAtom); + gestureRegionRef.current = null; + }, + [store] + ); + + useInput((input, key) => { + const mouseEvents = parseMouseInputEvents(input); + if (mouseEvents !== null) { + let scrollNotified = false; + for (const event of mouseEvents) { + if (event.kind === 'wheel') { + handleWheelScroll(store, [event], () => { + if (!scrollNotified) { + scrollNotified = true; + notifyScroll(); + } + }); + continue; + } + + if (event.kind === 'rightClick') { + handleRightClick(store, PROMPT_MAX_BYTES); + continue; + } + + if (event.kind === 'press') { + if (handlePullRequestClick(event)) { + gestureRegionRef.current = null; + continue; + } + + const region = resolveGestureRegion(store, event.row); + gestureRegionRef.current = region; + if (region === 'body') { + if (!handleSelectionGesture(store, event)) { + gestureRegionRef.current = null; + } + } else if (region === 'composer') { + positionComposerCaret(store, event); + } + continue; + } + + if (gestureRegionRef.current === 'body') { + handleSelectionGesture(store, event); + } + if (event.kind === 'release') { + gestureRegionRef.current = null; + } + } + return; + } + + if (key.pageUp) { + notifyScroll(); + scrollBodyByRows(Math.max(1, store.get(layoutAtom).bodyRows - 2)); + return; + } + + if (key.pageDown) { + notifyScroll(); + scrollBodyByRows(-Math.max(1, store.get(layoutAtom).bodyRows - 2)); + return; + } + + if (key.end) { + notifyScroll(); + scrollBodyByRows(Number.NEGATIVE_INFINITY); + } + }); +} + +function positionComposerCaret( + store: Store, + point: MouseClickEvent +): void { + const composerState = store.get(composerStateAtom); + const result = resolveClickResult({ + text: composerState.text, + columns: resolveComposerInputColumns(store.get(chromeColumnsAtom)), + maxVisibleLines: store.get(layoutAtom).composerVisibleRows, + cursorIndex: composerState.cursorIndex, + offset: store.get(composerScrollOffsetRowsAtom), + visibleRow: + point.row - + 1 - + (store.get(composerTopAtom) + COMPOSER_BACKGROUND_TOP_PADDING_ROWS), + column: point.column - 1 - PROMPT_PREFIX.length + }); + if (result !== null) { + store.set(setComposerCursorWithOffsetAtom, result); + } +} diff --git a/tui/src/hooks/homeScreen/usePullRequestClick.ts b/tui/src/hooks/homeScreen/usePullRequestClick.ts new file mode 100644 index 00000000..28ff23ba --- /dev/null +++ b/tui/src/hooks/homeScreen/usePullRequestClick.ts @@ -0,0 +1,44 @@ +import { useAtomValue, useStore } from 'jotai'; +import type { MouseClickEvent } from '@libs/terminal/mouse.ts'; +import { openExternalUrl } from '@libs/os/openExternalUrl.ts'; +import { resolvePullRequestClickTarget } from '@libs/tui/pullRequestClick.ts'; +import { + chromeColumnsAtom, + composerTopAtom, + cwdRowsAtom, + gitStatusAtom +} from '@state/ui/index.ts'; +import { workspaceCwdAtom } from '@state/global/index.ts'; + +/** + * Returns a handler that opens the pull request when a left click lands on the + * cwd line's `#3` label, reporting whether it consumed the click. + * + * The app owns the mouse (SGR tracking), so the label span is hit-tested here on + * a plain single click rather than relying on the terminal's modifier+click for + * the OSC 8 link. `columns`/`composerTop` are read reactively; the click-time + * values (`cwdRows`, `workspaceCwd`, `gitStatus`) are read from the store when + * the click fires. + */ +export function usePullRequestClick(): (click: MouseClickEvent) => boolean { + const store = useStore(); + const columns = useAtomValue(chromeColumnsAtom); + const composerTop = useAtomValue(composerTopAtom); + + return (click: MouseClickEvent): boolean => { + const url = resolvePullRequestClickTarget({ + clickRow: click.row, + clickColumn: click.column, + composerTop, + cwdRows: store.get(cwdRowsAtom), + columns, + workspaceCwd: store.get(workspaceCwdAtom), + gitStatus: store.get(gitStatusAtom) + }); + if (url === undefined) { + return false; + } + openExternalUrl(url); + return true; + }; +} diff --git a/tui/src/hooks/promptComposer/input/handleCursorMove.ts b/tui/src/hooks/promptComposer/input/handleCursorMove.ts new file mode 100644 index 00000000..43396b15 --- /dev/null +++ b/tui/src/hooks/promptComposer/input/handleCursorMove.ts @@ -0,0 +1,36 @@ +import type { ComposerKeyHandler } from '@hooks/promptComposer/input/types.ts'; +import { resolveComposerInputColumns } from '@libs/composer/layout.ts'; +import { chromeColumnsAtom } from '@state/ui/index.ts'; +import { + moveComposerCursorBackwardAtom, + moveComposerCursorDownAtom, + moveComposerCursorForwardAtom, + moveComposerCursorUpAtom +} from '@state/ui/composer/index.ts'; + +/** + * Left/Right move the composer cursor by one grapheme cluster; Up/Down move between + * visual (wrapped) lines. The slash menu's Up/Down is handled earlier in the + * dispatcher (handleMenuKey), so these only fire when the menu is closed. + */ +export const handleCursorMove: ComposerKeyHandler = (context) => { + const { key, store } = context; + + if (key.leftArrow) { + store.set(moveComposerCursorBackwardAtom); + return true; + } + + if (key.rightArrow) { + store.set(moveComposerCursorForwardAtom); + return true; + } + + if (key.upArrow || key.downArrow) { + const columns = resolveComposerInputColumns(store.get(chromeColumnsAtom)); + store.set(key.upArrow ? moveComposerCursorUpAtom : moveComposerCursorDownAtom, { columns }); + return true; + } + + return false; +}; diff --git a/tui/src/components/PromptComposer/input/handleEscArmedClear.ts b/tui/src/hooks/promptComposer/input/handleEscArmedClear.ts similarity index 91% rename from tui/src/components/PromptComposer/input/handleEscArmedClear.ts rename to tui/src/hooks/promptComposer/input/handleEscArmedClear.ts index a89ab5c8..7e00e1c7 100644 --- a/tui/src/components/PromptComposer/input/handleEscArmedClear.ts +++ b/tui/src/hooks/promptComposer/input/handleEscArmedClear.ts @@ -1,5 +1,5 @@ import { ArmedAction } from '@constants/ui.ts'; -import type { ComposerKeyHandler } from '@components/PromptComposer/input/types.ts'; +import type { ComposerKeyHandler } from '@hooks/promptComposer/input/types.ts'; import { clearComposerAtom } from '@state/ui/composer/index.ts'; import { armedActionAtom } from '@state/ui/index.ts'; diff --git a/tui/src/components/SlashCommandMenu/handleMenuKey.ts b/tui/src/hooks/promptComposer/input/handleMenuKey.ts similarity index 95% rename from tui/src/components/SlashCommandMenu/handleMenuKey.ts rename to tui/src/hooks/promptComposer/input/handleMenuKey.ts index 534ea536..7842df54 100644 --- a/tui/src/components/SlashCommandMenu/handleMenuKey.ts +++ b/tui/src/hooks/promptComposer/input/handleMenuKey.ts @@ -1,4 +1,4 @@ -import type { ComposerKeyHandler } from '@components/PromptComposer/input/types.ts'; +import type { ComposerKeyHandler } from '@hooks/promptComposer/input/types.ts'; import { appendUnknownCommandAtom } from '@state/promptQueue/index.ts'; import { executeCommand } from '@libs/commands/executeCommand.ts'; import { diff --git a/tui/src/components/PromptComposer/input/handleNewline.ts b/tui/src/hooks/promptComposer/input/handleNewline.ts similarity index 84% rename from tui/src/components/PromptComposer/input/handleNewline.ts rename to tui/src/hooks/promptComposer/input/handleNewline.ts index fc1ceb89..2c7dfad6 100644 --- a/tui/src/components/PromptComposer/input/handleNewline.ts +++ b/tui/src/hooks/promptComposer/input/handleNewline.ts @@ -1,5 +1,5 @@ -import { MODIFIED_ENTER_INPUTS } from '@components/PromptComposer/constants.ts'; -import type { ComposerKeyContext, ComposerKeyHandler } from '@components/PromptComposer/input/types.ts'; +import { MODIFIED_ENTER_INPUTS } from '@constants/ui.ts'; +import type { ComposerKeyContext, ComposerKeyHandler } from '@hooks/promptComposer/input/types.ts'; import { deleteComposerBackwardAtom, insertComposerTextAtom } from '@state/ui/composer/index.ts'; type PromptNewlineInput = 'insert-newline' | 'replace-backslash'; @@ -25,7 +25,7 @@ export const handleNewline: ComposerKeyHandler = (context) => { }; function classifyNewlineInput({ input, key, state }: ComposerKeyContext): PromptNewlineInput | null { - if (MODIFIED_ENTER_INPUTS.has(input)) { + if (MODIFIED_ENTER_INPUTS.includes(input)) { return 'insert-newline'; } diff --git a/tui/src/components/PromptComposer/input/handleSubmit.ts b/tui/src/hooks/promptComposer/input/handleSubmit.ts similarity index 94% rename from tui/src/components/PromptComposer/input/handleSubmit.ts rename to tui/src/hooks/promptComposer/input/handleSubmit.ts index 70a60f2c..8c4dc03a 100644 --- a/tui/src/components/PromptComposer/input/handleSubmit.ts +++ b/tui/src/hooks/promptComposer/input/handleSubmit.ts @@ -1,4 +1,4 @@ -import type { ComposerKeyContext, ComposerKeyHandler } from '@components/PromptComposer/input/types.ts'; +import type { ComposerKeyContext, ComposerKeyHandler } from '@hooks/promptComposer/input/types.ts'; import { appendUnknownCommandAtom } from '@state/promptQueue/index.ts'; import { executeCommand } from '@libs/commands/executeCommand.ts'; import { exactCommandMatch } from '@libs/commands/matchCommand.ts'; diff --git a/tui/src/components/PromptComposer/input/handleTextEdit.ts b/tui/src/hooks/promptComposer/input/handleTextEdit.ts similarity index 93% rename from tui/src/components/PromptComposer/input/handleTextEdit.ts rename to tui/src/hooks/promptComposer/input/handleTextEdit.ts index 70a940c7..d24358a3 100644 --- a/tui/src/components/PromptComposer/input/handleTextEdit.ts +++ b/tui/src/hooks/promptComposer/input/handleTextEdit.ts @@ -1,4 +1,4 @@ -import type { ComposerKeyHandler } from '@components/PromptComposer/input/types.ts'; +import type { ComposerKeyHandler } from '@hooks/promptComposer/input/types.ts'; import { commandMenuDismissedAtom, resetCommandHighlightAtom } from '@state/ui/commands/index.ts'; import { printableInput } from '@libs/composer/promptText.ts'; import { diff --git a/tui/src/components/PromptComposer/input/types.ts b/tui/src/hooks/promptComposer/input/types.ts similarity index 100% rename from tui/src/components/PromptComposer/input/types.ts rename to tui/src/hooks/promptComposer/input/types.ts diff --git a/tui/src/hooks/promptComposer/pasteFromClipboard.ts b/tui/src/hooks/promptComposer/pasteFromClipboard.ts new file mode 100644 index 00000000..7dbd9354 --- /dev/null +++ b/tui/src/hooks/promptComposer/pasteFromClipboard.ts @@ -0,0 +1,101 @@ +import type { createStore } from 'jotai'; +import { PASTE_FAILED_HINT, RIGHT_CLICK_PASTE_DEDUP_MS } from '@constants/ui.ts'; +import { sanitizePastedText } from '@libs/composer/pastedText.ts'; +import { readClipboardAtom } from '@state/global/index.ts'; +import { commandMenuDismissedAtom, resetCommandHighlightAtom } from '@state/ui/commands/index.ts'; +import { composerStateAtom, insertComposerTextAtom } from '@state/ui/composer/index.ts'; +import { + claimRightClickPasteReadAtom, + type ComposerPasteTarget, + finalizeRightClickPasteFailureAtom, + resolveRightClickPasteFailureAtom, + setTransientStatusHintAtom +} from '@state/ui/index.ts'; + +type Store = ReturnType; + +type PasteFromClipboardOptions = { + operationId?: number; +}; + +/** Inserts sanitized pasted text and refreshes command-menu filtering. */ +export function insertPastedText(store: Store, text: string, maxBytes: number): void { + store.set(setTransientStatusHintAtom, undefined); + store.set(insertComposerTextAtom, { maxBytes, text: sanitizePastedText(text) }); + store.set(resetCommandHighlightAtom); + store.set(commandMenuDismissedAtom, false); +} + +/** Reads the injected clipboard and inserts its text into the composer. */ +export function pasteFromClipboard( + store: Store, + maxBytes: number, + options: PasteFromClipboardOptions = {} +): void { + void store + .set(readClipboardAtom) + .then((text) => { + if (text === null) { + resolvePasteFailure(store, options); + return; + } + + const sanitizedText = sanitizePastedText(text); + if (!claimPasteResult(store, options)) { + return; + } + insertPastedText(store, sanitizedText, maxBytes); + }) + .catch(() => { + resolvePasteFailure(store, options); + }); +} + +function claimPasteResult( + store: Store, + { operationId }: PasteFromClipboardOptions +): boolean { + if (operationId === undefined) { + return true; + } + + return store.set(claimRightClickPasteReadAtom, { + operationId, + target: composerTarget(store) + }); +} + +function resolvePasteFailure( + store: Store, + { operationId }: PasteFromClipboardOptions +): void { + if (operationId === undefined) { + store.set(setTransientStatusHintAtom, { text: PASTE_FAILED_HINT }); + return; + } + + const resolution = store.set(resolveRightClickPasteFailureAtom, { + operationId, + target: composerTarget(store) + }); + if (resolution.kind === 'ignore') { + return; + } + + setTimeout(() => { + if ( + store.set(finalizeRightClickPasteFailureAtom, { + operationId, + target: composerTarget(store) + }) + ) { + store.set(setTransientStatusHintAtom, { text: PASTE_FAILED_HINT }); + } + }, RIGHT_CLICK_PASTE_DEDUP_MS); +} + +/** Current composer target used to reject late right-click paste completions. */ +export function composerTarget(store: Store): ComposerPasteTarget { + const state = store.get(composerStateAtom); + return { cursorIndex: state.cursorIndex, text: state.text }; +} diff --git a/tui/src/hooks/promptComposer/usePasteInput.ts b/tui/src/hooks/promptComposer/usePasteInput.ts new file mode 100644 index 00000000..407753d2 --- /dev/null +++ b/tui/src/hooks/promptComposer/usePasteInput.ts @@ -0,0 +1,37 @@ +import { usePaste } from 'ink'; +import { useStore } from 'jotai'; +import { useEffect } from 'react'; +import { + composerTarget, + insertPastedText +} from '@hooks/promptComposer/pasteFromClipboard.ts'; +import { sanitizePastedText } from '@libs/composer/pastedText.ts'; +import { + cancelPendingRightClickPasteAtom, + shouldInsertBracketedPasteAtom +} from '@state/ui/index.ts'; + +/** Handles bracketed terminal paste and suppresses duplicate right-click fallout. */ +export function usePasteInput(maxBytes: number): void { + const store = useStore(); + + usePaste((text) => { + const sanitizedText = sanitizePastedText(text); + if ( + !store.set(shouldInsertBracketedPasteAtom, { + target: composerTarget(store) + }) + ) { + return; + } + + insertPastedText(store, sanitizedText, maxBytes); + }); + + useEffect( + () => () => { + store.set(cancelPendingRightClickPasteAtom); + }, + [store] + ); +} diff --git a/tui/src/components/PromptComposer/usePromptComposerInput.ts b/tui/src/hooks/promptComposer/usePromptComposerInput.ts similarity index 77% rename from tui/src/components/PromptComposer/usePromptComposerInput.ts rename to tui/src/hooks/promptComposer/usePromptComposerInput.ts index 27856951..5ab7a88b 100644 --- a/tui/src/components/PromptComposer/usePromptComposerInput.ts +++ b/tui/src/hooks/promptComposer/usePromptComposerInput.ts @@ -1,19 +1,19 @@ import { useInput } from 'ink'; import { useStore } from 'jotai'; -import { handleCursorMove } from '@components/PromptComposer/input/handleCursorMove.ts'; -import { handleEscArmedClear } from '@components/PromptComposer/input/handleEscArmedClear.ts'; -import { handleNewline } from '@components/PromptComposer/input/handleNewline.ts'; -import { handleSubmit } from '@components/PromptComposer/input/handleSubmit.ts'; -import { handleTextEdit } from '@components/PromptComposer/input/handleTextEdit.ts'; +import { handleCursorMove } from '@hooks/promptComposer/input/handleCursorMove.ts'; +import { handleEscArmedClear } from '@hooks/promptComposer/input/handleEscArmedClear.ts'; +import { handleMenuKey } from '@hooks/promptComposer/input/handleMenuKey.ts'; +import { handleNewline } from '@hooks/promptComposer/input/handleNewline.ts'; +import { handleSubmit } from '@hooks/promptComposer/input/handleSubmit.ts'; +import { handleTextEdit } from '@hooks/promptComposer/input/handleTextEdit.ts'; import type { ComposerInputState, ComposerKeyContext, ComposerKeyHandler -} from '@components/PromptComposer/input/types.ts'; -import { handleMenuKey } from '@components/SlashCommandMenu/handleMenuKey.ts'; +} from '@hooks/promptComposer/input/types.ts'; import { isMouseInput } from '@libs/terminal/mouse.ts'; import type { CommandActions } from '@libs/commands/executeCommand.ts'; -import { armedActionAtom } from '@state/ui/index.ts'; +import { armedActionAtom, cancelPendingRightClickPasteAtom } from '@state/ui/index.ts'; type PromptComposerInputOptions = { isActive: boolean; @@ -61,6 +61,8 @@ export function usePromptComposerInput({ return; } + store.set(cancelPendingRightClickPasteAtom); + // Ctrl+C is owned by the global two-step-exit hook; never handle it here. if (key.ctrl === true && input === 'c') { return; diff --git a/tui/src/useGlobalKeys.ts b/tui/src/hooks/useGlobalKeys.ts similarity index 100% rename from tui/src/useGlobalKeys.ts rename to tui/src/hooks/useGlobalKeys.ts diff --git a/tui/src/libs/clipboard/__tests__/systemClipboard.test.ts b/tui/src/libs/clipboard/__tests__/systemClipboard.test.ts new file mode 100644 index 00000000..87e78c15 --- /dev/null +++ b/tui/src/libs/clipboard/__tests__/systemClipboard.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + CLIPBOARD_TIMEOUT_MS, + PBCOPY_COMMAND, + PBPASTE_COMMAND, + POWERSHELL_COMMAND, + WL_COPY_COMMAND, + WL_PASTE_COMMAND, + XCLIP_COMMAND, + XSEL_COMMAND, + resolveClipboardCommand, + systemClipboard +} from '@libs/clipboard/systemClipboard.ts'; + +const execFileMock = vi.hoisted(() => vi.fn()); + +vi.mock('node:child_process', () => ({ + execFile: execFileMock +})); + +describe('resolveClipboardCommand', () => { + it('uses the native macOS clipboard commands', () => { + expect(resolveClipboardCommand('darwin')).toEqual({ + read: { command: PBPASTE_COMMAND, args: [] }, + write: { command: PBCOPY_COMMAND, args: [] } + }); + }); + + it('moves Windows clipboard bytes as explicit UTF-8', () => { + const commands = resolveClipboardCommand('win32'); + const read = commands?.read.args.at(-1) ?? ''; + const write = commands?.write.args.at(-1) ?? ''; + + expect(commands?.read.command).toBe(POWERSHELL_COMMAND); + expect(commands?.write.command).toBe(POWERSHELL_COMMAND); + expect(read).toContain('Get-Clipboard -Raw'); + expect(read).toContain('[System.Text.Encoding]::UTF8.GetBytes'); + expect(read).toContain('OpenStandardOutput'); + expect(write).toContain('OpenStandardInput'); + expect(write).toContain('[System.Text.Encoding]::UTF8.GetString'); + expect(write).toContain('Set-Clipboard'); + }); + + it('avoids the synthetic newline from wl-paste', () => { + expect(resolveClipboardCommand('linux', { WAYLAND_DISPLAY: 'wayland-0' })).toEqual({ + read: { command: WL_PASTE_COMMAND, args: ['--no-newline'] }, + write: { command: WL_COPY_COMMAND, args: [] } + }); + }); + + it('uses xclip by default and xsel when configured for X11', () => { + expect(resolveClipboardCommand('linux', { DISPLAY: ':0' })).toEqual({ + read: { command: XCLIP_COMMAND, args: ['-selection', 'clipboard', '-o'] }, + write: { command: XCLIP_COMMAND, args: ['-selection', 'clipboard'] } + }); + expect( + resolveClipboardCommand('linux', { + DISPLAY: ':0', + KQODE_X11_CLIPBOARD_TOOL: 'xsel' + }) + ).toEqual({ + read: { command: XSEL_COMMAND, args: ['-ob'] }, + write: { command: XSEL_COMMAND, args: ['-ib'] } + }); + }); + + it('returns no command for unsupported or displayless platforms', () => { + expect(resolveClipboardCommand('aix')).toBeUndefined(); + expect(resolveClipboardCommand('linux', {})).toBeUndefined(); + }); + + it('keeps clipboard payloads out of command arguments', () => { + const payload = 'hello; $(bad)\n`more`'; + + for (const platform of ['darwin', 'win32'] as const) { + const command = resolveClipboardCommand(platform)?.write; + expect(command).toBeDefined(); + expect([command?.command, ...(command?.args ?? [])].join(' ')).not.toContain(payload); + } + }); +}); + +describe('systemClipboard', () => { + beforeEach(() => { + execFileMock.mockReset(); + Object.defineProperty(process, 'platform', { value: 'darwin' }); + }); + + it('reports unavailable clipboard commands', async () => { + Object.defineProperty(process, 'platform', { value: 'aix' }); + + await expect(systemClipboard.readText()).resolves.toBeNull(); + await expect(systemClipboard.writeText('value')).resolves.toBe(false); + expect(execFileMock).not.toHaveBeenCalled(); + }); + + it('preserves an available but empty clipboard', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + complete(callback, null, ''); + return childProcess(); + }); + + await expect(systemClipboard.readText()).resolves.toBe(''); + expect(execFileMock.mock.calls[0]?.[2]).toMatchObject({ + timeout: CLIPBOARD_TIMEOUT_MS + }); + }); + + it('reports command failures without throwing', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + complete(callback, new Error('failed'), ''); + return childProcess(); + }); + + await expect(systemClipboard.readText()).resolves.toBeNull(); + await expect(systemClipboard.writeText('value')).resolves.toBe(false); + }); + + it('writes clipboard content only through stdin', async () => { + const stdinEnd = vi.fn(); + execFileMock.mockImplementation((_command, _args, _options, callback) => { + complete(callback, null, ''); + return childProcess(stdinEnd); + }); + + const payload = 'hello; $(bad)\n`more`'; + await expect(systemClipboard.writeText(payload)).resolves.toBe(true); + + expect(stdinEnd).toHaveBeenCalledWith(payload); + expect(execFileMock.mock.calls[0]?.[1]).toEqual([]); + }); + + it('hides clipboard children so they cannot replace the terminal title', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + execFileMock.mockImplementation((_command, _args, _options, callback) => { + complete(callback, null, ''); + return childProcess(); + }); + + await systemClipboard.readText(); + await systemClipboard.writeText('value'); + + for (const call of execFileMock.mock.calls) { + expect(call[2]).toMatchObject({ windowsHide: true }); + } + }); +}); + +function childProcess(stdinEnd = vi.fn()) { + return { stdin: { end: stdinEnd } }; +} + +function complete(callback: unknown, error: Error | null, stdout: string) { + (callback as (error: Error | null, stdout: string, stderr: string) => void)( + error, + stdout, + '' + ); +} diff --git a/tui/src/libs/clipboard/systemClipboard.ts b/tui/src/libs/clipboard/systemClipboard.ts new file mode 100644 index 00000000..a7e69be9 --- /dev/null +++ b/tui/src/libs/clipboard/systemClipboard.ts @@ -0,0 +1,133 @@ +import { execFile } from 'node:child_process'; +import type { ClipboardClient } from '@contracts/clipboard/index.ts'; + +export const CLIPBOARD_TIMEOUT_MS = 2_000; +export const PBCOPY_COMMAND = 'pbcopy'; +export const PBPASTE_COMMAND = 'pbpaste'; +export const POWERSHELL_COMMAND = 'powershell'; +export const WL_COPY_COMMAND = 'wl-copy'; +export const WL_PASTE_COMMAND = 'wl-paste'; +export const XCLIP_COMMAND = 'xclip'; +export const XSEL_COMMAND = 'xsel'; + +const POWERSHELL_NO_PROFILE_ARG = '-NoProfile'; +const POWERSHELL_COMMAND_ARG = '-Command'; +const POWERSHELL_READ_SCRIPT = + '$t=Get-Clipboard -Raw; if($null -ne $t){$b=[System.Text.Encoding]::UTF8.GetBytes($t);$o=[Console]::OpenStandardOutput();$o.Write($b,0,$b.Length);$o.Flush()}'; +const POWERSHELL_WRITE_SCRIPT = + '$in=[Console]::OpenStandardInput();$ms=[System.IO.MemoryStream]::new();$in.CopyTo($ms);Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString($ms.ToArray()))'; +const WL_PASTE_ARGS = ['--no-newline'] as const; +const XCLIP_SELECTION_ARGS = ['-selection', 'clipboard'] as const; +const XCLIP_READ_ARGS = ['-selection', 'clipboard', '-o'] as const; +const XSEL_WRITE_ARGS = ['-ib'] as const; +const XSEL_READ_ARGS = ['-ob'] as const; +const XSEL_TOOL_VALUE = 'xsel'; + +export type ClipboardCommand = { + command: string; + args: readonly string[]; +}; + +export type ClipboardCommandSet = { + read: ClipboardCommand; + write: ClipboardCommand; +}; + +/** Resolves the platform command pair used for clipboard reads and writes. */ +export function resolveClipboardCommand( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = process.env +): ClipboardCommandSet | undefined { + if (platform === 'darwin') { + return { + read: { command: PBPASTE_COMMAND, args: [] }, + write: { command: PBCOPY_COMMAND, args: [] } + }; + } + + if (platform === 'win32') { + const args = [POWERSHELL_NO_PROFILE_ARG, POWERSHELL_COMMAND_ARG] as const; + return { + read: { command: POWERSHELL_COMMAND, args: [...args, POWERSHELL_READ_SCRIPT] }, + write: { command: POWERSHELL_COMMAND, args: [...args, POWERSHELL_WRITE_SCRIPT] } + }; + } + + if (platform !== 'linux') { + return undefined; + } + + if (env.WAYLAND_DISPLAY) { + return { + read: { command: WL_PASTE_COMMAND, args: WL_PASTE_ARGS }, + write: { command: WL_COPY_COMMAND, args: [] } + }; + } + + if (!env.DISPLAY) { + return undefined; + } + + if (env.KQODE_X11_CLIPBOARD_TOOL === XSEL_TOOL_VALUE) { + return { + read: { command: XSEL_COMMAND, args: XSEL_READ_ARGS }, + write: { command: XSEL_COMMAND, args: XSEL_WRITE_ARGS } + }; + } + + return { + read: { command: XCLIP_COMMAND, args: XCLIP_READ_ARGS }, + write: { command: XCLIP_COMMAND, args: XCLIP_SELECTION_ARGS } + }; +} + +/** System clipboard client backed by native platform commands. */ +export const systemClipboard: ClipboardClient = { + readText: async () => { + const command = resolveClipboardCommand(process.platform)?.read; + if (command === undefined) { + return null; + } + + try { + return await runClipboardCommand(command, undefined); + } catch { + return null; + } + }, + + writeText: async (text: string) => { + const command = resolveClipboardCommand(process.platform)?.write; + if (command === undefined) { + return false; + } + + try { + await runClipboardCommand(command, text); + return true; + } catch { + return false; + } + } +}; + +function runClipboardCommand(command: ClipboardCommand, input: string | undefined): Promise { + return new Promise((resolve, reject) => { + const child = execFile( + command.command, + [...command.args], + { encoding: 'utf8', timeout: CLIPBOARD_TIMEOUT_MS, windowsHide: true }, + (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + } + ); + + if (input !== undefined) { + child.stdin?.end(input); + } + }); +} diff --git a/tui/src/libs/composer/__tests__/composerWindow.test.ts b/tui/src/libs/composer/__tests__/composerWindow.test.ts new file mode 100644 index 00000000..dcbc12b9 --- /dev/null +++ b/tui/src/libs/composer/__tests__/composerWindow.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveClickResult, + resolveComposerWindow, + resolveScrollIntoViewOffset, + resolveVerticalCursorIndex +} from '@libs/composer/composerWindow.ts'; + +describe('resolveComposerWindow', () => { + // 6 wrapped rows at columns=4: '0000','1111','2222','3333','4444','5555' + const text = '000011112222333344445555'; + const columns = 4; + const maxVisibleLines = 3; + const atEnd = { text, columns, maxVisibleLines, cursorIndex: text.length }; + + it('follows the cursor at offset 0 (bottom window when cursor at end)', () => { + const window = resolveComposerWindow(atEnd); + expect(window.text).toBe('4444\n5555\n'); + expect(window.cursorVisible).toBe(true); + expect(window.canScroll).toBe(true); + }); + + it('reveals earlier rows with a positive offset, hiding the cursor row', () => { + const window = resolveComposerWindow({ ...atEnd, offset: 3 }); + expect(window.text).toBe('1111\n2222\n3333'); + expect(window.cursorVisible).toBe(false); + }); + + it('clamps a stale over-large offset to the top row', () => { + expect(resolveComposerWindow({ ...atEnd, offset: 999 }).text).toBe('0000\n1111\n2222'); + }); + + it('exposes clamp bounds spanning the full range (cursor at end)', () => { + const window = resolveComposerWindow(atEnd); + expect(window.maxOffset).toBe(4); // baseStart = lastStart = 7 - 3 + expect(window.minOffset).toBe(0); // baseStart - lastStart + }); + + it('reports canScroll false when the prompt fits', () => { + const window = resolveComposerWindow({ text: 'short', columns: 20, maxVisibleLines: 3, cursorIndex: 5 }); + expect(window.canScroll).toBe(false); + }); +}); + +describe('resolveScrollIntoViewOffset', () => { + // 6 wrapped rows at columns 4: '0000'(0-4) '1111'(4-8) '2222'(8-12) '3333'(12-16) '4444'(16-20) '5555'(20-24) + const text = '000011112222333344445555'; + const columns = 4; + const maxVisibleLines = 3; + + it('keeps a scrolled offset when the cursor is already visible', () => { + // cursor on row 2 (index 10); offset -1 shows rows 1..3 with the cursor visible + expect(resolveScrollIntoViewOffset({ text, columns, maxVisibleLines, cursorIndex: 10, offset: -1 })).toBe(-1); + }); + + it('scrolls to the bottom edge when the cursor is below the window', () => { + // offset 3 shows rows 0..2 but the cursor (row 5) is below -> pin to bottom (offset 0) + expect(resolveScrollIntoViewOffset({ text, columns, maxVisibleLines, cursorIndex: 24, offset: 3 })).toBe(0); + }); + + it('scrolls to the top edge when the cursor is above the window', () => { + // cursor on row 0; offset -2 shows rows 2..4 (cursor above) -> pin to top (offset 0) + expect(resolveScrollIntoViewOffset({ text, columns, maxVisibleLines, cursorIndex: 0, offset: -2 })).toBe(0); + }); +}); + +describe('resolveVerticalCursorIndex', () => { + // 'aaa\nbbb\nccc' -> rows 'aaa'(0-3), 'bbb'(4-7), 'ccc'(8-11) + const text = 'aaa\nbbb\nccc'; + + it('moves up a visual line preserving the column', () => { + expect(resolveVerticalCursorIndex(text, 40, 11, 'up')).toBe(7); // row2 col3 -> row1 col3 + }); + + it('moves down a visual line preserving the column', () => { + expect(resolveVerticalCursorIndex(text, 40, 7, 'down')).toBe(11); // row1 col3 -> row2 col3 + }); + + it('clamps the column to a shorter target row', () => { + // 'aa\nb' -> 'aa'(0-2), 'b'(3-4); row0 col2 down -> row1 (len 1) -> index 4 + expect(resolveVerticalCursorIndex('aa\nb', 40, 2, 'down')).toBe(4); + }); + + it('returns null at the first row (up) and last row (down)', () => { + expect(resolveVerticalCursorIndex(text, 40, 1, 'up')).toBeNull(); + expect(resolveVerticalCursorIndex(text, 40, 11, 'down')).toBeNull(); + }); + + it('returns null for a single-row prompt', () => { + expect(resolveVerticalCursorIndex('hello', 40, 3, 'up')).toBeNull(); + expect(resolveVerticalCursorIndex('hello', 40, 3, 'down')).toBeNull(); + }); + + it('navigates wrapped rows of a single logical line', () => { + // 'abcdefghij' cols 4 -> 'abcd'(0-4), 'efgh'(4-8), 'ij'(8-10) + expect(resolveVerticalCursorIndex('abcdefghij', 4, 2, 'down')).toBe(6); + expect(resolveVerticalCursorIndex('abcdefghij', 4, 6, 'up')).toBe(2); + }); + + it('treats a soft-wrap boundary as the start of the following visual row', () => { + const text = 'abcdefghijkl'; + + expect(resolveVerticalCursorIndex(text, 4, 4, 'up')).toBe(0); + expect(resolveVerticalCursorIndex(text, 4, 4, 'down')).toBe(8); + }); + + it('moves back down to the trailing caret row after moving up from it', () => { + const text = 'abcdefgh'; + const previousRow = resolveVerticalCursorIndex(text, 4, text.length, 'up'); + + expect(previousRow).toBe(4); + expect(resolveVerticalCursorIndex(text, 4, previousRow ?? -1, 'down')).toBe(text.length); + }); + + it('preserves the visual column across wide glyphs', () => { + expect(resolveVerticalCursorIndex('界a\n123', 40, 2, 'down')).toBe(6); + }); +}); + +describe('resolveClickResult', () => { + // 'aaa\nbbb\nccc' -> rows 'aaa'(0-3), 'bbb'(4-7), 'ccc'(8-11); all visible at maxVisibleLines 3 + const base = { text: 'aaa\nbbb\nccc', columns: 40, maxVisibleLines: 3, cursorIndex: 11, offset: 0 }; + + it('maps a click on a visible row + column to the absolute index', () => { + expect(resolveClickResult({ ...base, visibleRow: 0, column: 1 })?.index).toBe(1); + expect(resolveClickResult({ ...base, visibleRow: 1, column: 2 })?.index).toBe(6); + }); + + it('maps clicks inside wide and joined graphemes to safe boundaries', () => { + const wide = { text: '界a', columns: 40, maxVisibleLines: 1, cursorIndex: 2, offset: 0 }; + expect(resolveClickResult({ ...wide, visibleRow: 0, column: 1 })?.index).toBe(0); + expect(resolveClickResult({ ...wide, visibleRow: 0, column: 2 })?.index).toBe(1); + + const emoji = '👨‍👩‍👧‍👦x'; + expect( + resolveClickResult({ + text: emoji, + columns: 40, + maxVisibleLines: 1, + cursorIndex: emoji.length, + offset: 0, + visibleRow: 0, + column: 1 + })?.index + ).toBe(0); + }); + + it('clamps the column to the clicked row length (and floors at 0)', () => { + expect(resolveClickResult({ ...base, visibleRow: 2, column: 99 })?.index).toBe(11); + expect(resolveClickResult({ ...base, visibleRow: 0, column: -5 })?.index).toBe(0); + }); + + it('returns null for rows outside the visible window', () => { + expect(resolveClickResult({ ...base, visibleRow: -1, column: 0 })).toBeNull(); + expect(resolveClickResult({ ...base, visibleRow: 3, column: 0 })).toBeNull(); + }); + + it('accounts for the scroll offset when mapping the clicked row', () => { + // 6 single-char rows; offset 2 shows rows 1..3, so visibleRow 0 -> wrap row 1 ('1' at index 2) + const scrolled = { text: '0\n1\n2\n3\n4\n5', columns: 40, maxVisibleLines: 3, cursorIndex: 11, offset: 2 }; + expect(resolveClickResult({ ...scrolled, visibleRow: 0, column: 0 })?.index).toBe(2); + }); + + it('returns an offset that keeps the visible window fixed (no scroll on click)', () => { + // 6 single-char rows; offset 2 shows rows 1..3. Clicking the top visible row + // must leave those same rows visible after the caret moves there. + const scrolled = { text: '0\n1\n2\n3\n4\n5', columns: 40, maxVisibleLines: 3, cursorIndex: 11, offset: 2 }; + const before = resolveComposerWindow(scrolled).text; + const result = resolveClickResult({ ...scrolled, visibleRow: 0, column: 0 }); + expect(result).not.toBeNull(); + if (result === null) { + return; + } + const after = resolveComposerWindow({ + text: scrolled.text, + columns: scrolled.columns, + maxVisibleLines: scrolled.maxVisibleLines, + cursorIndex: result.index, + offset: result.offset + }).text; + expect(after).toBe(before); + }); + + it('keeps the window fixed when clicking a soft-wrapped row at a wrap boundary', () => { + // 'abcdefghij' at columns 2 -> rows ab(0-2) cd(2-4) ef(4-6) gh(6-8) ij(8-10). + // offset 2 shows cd/ef (visibleStart 1). A column-0 click lands on a wrap + // boundary index (row.start === previousRow.end); the window must not scroll. + const scrolled = { text: 'abcdefghij', columns: 2, maxVisibleLines: 2, cursorIndex: 9, offset: 2 }; + const before = resolveComposerWindow(scrolled).text; + expect(before).toBe('cd\nef'); + for (const visibleRow of [0, 1]) { + const result = resolveClickResult({ ...scrolled, visibleRow, column: 0 }); + expect(result).not.toBeNull(); + if (result === null) { + continue; + } + const after = resolveComposerWindow({ + text: scrolled.text, + columns: scrolled.columns, + maxVisibleLines: scrolled.maxVisibleLines, + cursorIndex: result.index, + offset: result.offset + }).text; + expect(after).toBe(before); + } + }); +}); diff --git a/tui/src/libs/composer/__tests__/pastedText.test.ts b/tui/src/libs/composer/__tests__/pastedText.test.ts new file mode 100644 index 00000000..aea4a130 --- /dev/null +++ b/tui/src/libs/composer/__tests__/pastedText.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { sanitizePastedText } from '@libs/composer/pastedText.ts'; + +describe('sanitizePastedText', () => { + it('normalizes newlines and strips terminal control bytes', () => { + expect(sanitizePastedText('a\r\nb\rc\u001B[31m\tz')).toBe( + 'a\nb\nc[31m z' + ); + }); +}); diff --git a/tui/src/libs/composer/__tests__/promptTextView.test.ts b/tui/src/libs/composer/__tests__/promptTextView.test.ts new file mode 100644 index 00000000..085e771d --- /dev/null +++ b/tui/src/libs/composer/__tests__/promptTextView.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { formatValidationError } from '@libs/composer/promptTextView.ts'; +import { displayWidth } from '@libs/text/displayWidth.ts'; + +describe('formatValidationError', () => { + it('keeps validation feedback within its single-row content width', () => { + const line = formatValidationError('xxxxxxxxxxxx', 18, true); + + expect(displayWidth(line)).toBe(18); + expect(line).toBe('ERROR: xxxxxxxxxxx'); + }); + + it('does not split a wide grapheme at the width boundary', () => { + expect(formatValidationError('界界', 9, false)).toBe('ERROR: 界'); + }); +}); diff --git a/tui/src/libs/composer/__tests__/wrapPromptText.test.ts b/tui/src/libs/composer/__tests__/wrapPromptText.test.ts new file mode 100644 index 00000000..8a22a506 --- /dev/null +++ b/tui/src/libs/composer/__tests__/wrapPromptText.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { wrapPromptText } from '@libs/composer/wrapPromptText.ts'; + +describe('wrapPromptText', () => { + it('wraps a long logical line every `columns` characters', () => { + expect(wrapPromptText('abcdefghij', 4).map((row) => row.text)).toEqual(['abcd', 'efgh', 'ij']); + }); + + it('adds a caret row when the final logical line exactly fills its width', () => { + expect(wrapPromptText('abcdefgh', 4, true).map((row) => row.text)).toEqual([ + 'abcd', + 'efgh', + '' + ]); + }); + + it('keeps authored newlines as separate rows', () => { + expect(wrapPromptText('a\nbb', 10).map((row) => row.text)).toEqual(['a', 'bb']); + }); + + it('returns a single empty row for empty text', () => { + expect(wrapPromptText('', 10)).toEqual([{ text: '', start: 0, end: 0 }]); + }); + + it('wraps CJK text by terminal display columns', () => { + expect(wrapPromptText('界界界', 4).map((row) => row.text)).toEqual(['界界', '界']); + }); + + it('does not split combining or joined emoji graphemes', () => { + expect(wrapPromptText('e\u0301e\u0301', 1).map((row) => row.text)).toEqual([ + 'e\u0301', + 'e\u0301' + ]); + expect(wrapPromptText('👨‍👩‍👧‍👦x', 2).map((row) => row.text)).toEqual(['👨‍👩‍👧‍👦', 'x']); + }); + + it('reuses the cached row array for repeated identical inputs', () => { + const first = wrapPromptText('cache\nme', 10); + const second = wrapPromptText('cache\nme', 10); + + expect(second).toBe(first); + }); + + it('recomputes when the text or width changes', () => { + const base = wrapPromptText('abcdef', 4); + const sameAgain = wrapPromptText('abcdef', 4); + const widerWidth = wrapPromptText('abcdef', 6); + + expect(sameAgain).toBe(base); + expect(widerWidth).not.toBe(base); + }); +}); diff --git a/tui/src/libs/composer/composerWindow.ts b/tui/src/libs/composer/composerWindow.ts new file mode 100644 index 00000000..96133b46 --- /dev/null +++ b/tui/src/libs/composer/composerWindow.ts @@ -0,0 +1,224 @@ +import { clamp } from '@libs/math/clamp.ts'; +import { wrapPromptText } from '@libs/composer/wrapPromptText.ts'; +import type { WrappedPromptRow } from '@libs/composer/wrapPromptText.ts'; +import { + clampToGraphemeBoundary, + displayWidthBeforeIndex, + indexAtDisplayColumn +} from '@libs/text/displayWidth.ts'; + +export type ComposerWindowParams = { + text: string; + /** Prompt input width (terminal columns minus the prompt prefix). */ + columns: number; + maxVisibleLines: number; + cursorIndex: number; + /** Signed rows scrolled away from the cursor-follow baseline (+ up, - down). */ + offset?: number; +}; + +export type ComposerWindow = { + /** The visible wrapped rows joined by newlines. */ + text: string; + /** Cursor index within `text` (only meaningful when `cursorVisible`). */ + cursorIndex: number; + /** Whether the cursor's row is inside the visible window. */ + cursorVisible: boolean; + /** Whether the prompt overflows the visible height (cursor-independent). */ + canScroll: boolean; + /** Most-negative offset — scrolls to the last wrapped row. */ + minOffset: number; + /** Most-positive offset — scrolls to the first wrapped row. */ + maxOffset: number; +}; + +/** + * Resolves the visible window of a (possibly scrolled) multi-line prompt. + * + * `offset` is a signed number of rows away from the cursor-follow baseline: + * positive scrolls toward earlier rows (up), negative toward later rows (down), + * and `0` reproduces the cursor-follow window. `visibleStart` is always clamped + * into `[0, lastStart]`, so a stale offset (after a resize/edit) stays safe and + * self-heals. `minOffset`/`maxOffset` are the clamp bounds a scroll action reuses. + */ +export function resolveComposerWindow(params: ComposerWindowParams): ComposerWindow { + const { text, columns, maxVisibleLines, cursorIndex, offset = 0 } = params; + const safeMaxVisibleLines = Math.max(1, maxVisibleLines); + const rows = wrapPromptText(text, columns, cursorIndex >= text.length); + const safeCursorIndex = clampToGraphemeBoundary(text, clamp(cursorIndex, 0, text.length)); + // Cursor-follow baseline (slide up only when the cursor would fall below the + // last visible row); the signed offset then shifts the window from there. + const { visibleStart, lastStart, baseStart, cursorRowIndex } = resolveWindowBounds( + rows, + safeMaxVisibleLines, + safeCursorIndex, + offset + ); + const visibleRows = rows.slice(visibleStart, visibleStart + safeMaxVisibleLines); + + return { + text: visibleRows.map((row) => row.text).join('\n'), + cursorIndex: resolveVisibleCursorIndex(visibleRows, safeCursorIndex), + cursorVisible: + cursorRowIndex >= visibleStart && cursorRowIndex < visibleStart + safeMaxVisibleLines, + canScroll: rows.length > safeMaxVisibleLines, + minOffset: baseStart - lastStart, + maxOffset: baseStart + }; +} + +/** + * Returns the scroll offset that brings the cursor into the visible window with + * the least movement: the current `offset` when the cursor is already visible, + * or the offset that pins the cursor to the nearest window edge otherwise. Used + * to keep the caret visible after an edit or navigation without snapping the + * view to the bottom (offset `0`). + */ +export function resolveScrollIntoViewOffset(params: ComposerWindowParams): number { + const { text, columns, maxVisibleLines, cursorIndex, offset = 0 } = params; + const safeMaxVisibleLines = Math.max(1, maxVisibleLines); + const rows = wrapPromptText(text, columns, cursorIndex >= text.length); + const safeCursorIndex = clampToGraphemeBoundary(text, clamp(cursorIndex, 0, text.length)); + const { visibleStart, baseStart, cursorRowIndex } = resolveWindowBounds( + rows, + safeMaxVisibleLines, + safeCursorIndex, + offset + ); + + if (cursorRowIndex < visibleStart) { + return baseStart - cursorRowIndex; // above the window: pin to the top row + } + if (cursorRowIndex >= visibleStart + safeMaxVisibleLines) { + return baseStart - (cursorRowIndex - safeMaxVisibleLines + 1); // below: pin to the bottom row + } + return offset; // already visible — keep the current view +} + +/** + * Maps a click on visible row `visibleRow` (0-based within the window) at + * `column` (0-based within that row's text) to the resulting cursor `index` and + * the scroll `offset` that keeps the current visible window fixed — so clicking + * repositions the caret without scrolling the composer. Returns `null` when the + * click lands outside the visible wrapped rows. + */ +export function resolveClickResult( + params: ComposerWindowParams & { visibleRow: number; column: number } +): { index: number; offset: number } | null { + const { text, columns, maxVisibleLines, cursorIndex, offset = 0, visibleRow, column } = params; + const safeMaxVisibleLines = Math.max(1, maxVisibleLines); + if (visibleRow < 0 || visibleRow >= safeMaxVisibleLines) { + return null; + } + + const rows = wrapPromptText(text, columns, cursorIndex >= text.length); + const safeCursorIndex = clampToGraphemeBoundary(text, clamp(cursorIndex, 0, text.length)); + const { visibleStart, lastStart } = resolveWindowBounds( + rows, + safeMaxVisibleLines, + safeCursorIndex, + offset + ); + const targetRowIndex = visibleStart + visibleRow; + if (targetRowIndex >= rows.length) { + return null; + } + + const row = rows[targetRowIndex]; + const index = row.start + indexAtDisplayColumn(row.text, column); + // Keep the window fixed: reproduce the current visibleStart against the NEW + // cursor's follow baseline. Resolve the cursor row the same way + // resolveComposerWindow does — at a soft-wrap boundary `index === row.start === + // previousRow.end` resolves to the earlier row — so the two agree and the + // window does not shift. + const baseStart = clamp( + resolveCursorRowIndex(rows, index) - safeMaxVisibleLines + 1, + 0, + lastStart + ); + return { index, offset: baseStart - visibleStart }; +} + +function resolveWindowBounds( + rows: WrappedPromptRow[], + safeMaxVisibleLines: number, + cursorIndex: number, + offset: number +): { visibleStart: number; lastStart: number; baseStart: number; cursorRowIndex: number } { + const cursorRowIndex = resolveCursorRowIndex(rows, cursorIndex); + const lastStart = Math.max(0, rows.length - safeMaxVisibleLines); + const baseStart = clamp(cursorRowIndex - safeMaxVisibleLines + 1, 0, lastStart); + const visibleStart = clamp(baseStart - offset, 0, lastStart); + return { visibleStart, lastStart, baseStart, cursorRowIndex }; +} + +function resolveCursorRowIndex(rows: WrappedPromptRow[], cursorIndex: number): number { + return findCursorRowIndex(rows, cursorIndex) ?? 0; +} + +function findCursorRowIndex( + rows: readonly WrappedPromptRow[], + cursorIndex: number +): number | null { + for (const [index, row] of rows.entries()) { + if (cursorIndex < row.start || cursorIndex > row.end) { + continue; + } + + const next = rows[index + 1]; + const isSharedSoftWrapBoundary = + cursorIndex === row.end && + row.start !== row.end && + next?.start === cursorIndex; + if (!isSharedSoftWrapBoundary) { + return index; + } + } + return null; +} + +export type VerticalDirection = 'up' | 'down'; + +/** + * The cursor index one visual row up or down from `cursorIndex`, preserving the + * visual column (clamped to the target row's length). Returns `null` when there + * is no visual row that direction (first/last row, or a single-row prompt) — the + * caller treats that as a no-op, leaving a seam for future history traversal. + */ +export function resolveVerticalCursorIndex( + text: string, + columns: number, + cursorIndex: number, + direction: VerticalDirection +): number | null { + const rows = wrapPromptText(text, columns, true); + const safeCursorIndex = clampToGraphemeBoundary(text, clamp(cursorIndex, 0, text.length)); + const currentRow = resolveCursorRowIndex(rows, safeCursorIndex); + const targetRow = currentRow + (direction === 'up' ? -1 : 1); + if (targetRow < 0 || targetRow >= rows.length) { + return null; + } + + const column = displayWidthBeforeIndex( + rows[currentRow].text, + safeCursorIndex - rows[currentRow].start + ); + const target = rows[targetRow]; + return target.start + indexAtDisplayColumn(target.text, column); +} + +function resolveVisibleCursorIndex(rows: WrappedPromptRow[], cursorIndex: number): number { + const cursorRowIndex = findCursorRowIndex(rows, cursorIndex); + if (cursorRowIndex === null) { + return Math.max( + 0, + rows.reduce((length, row) => length + row.text.length + 1, 0) - 1 + ); + } + + const precedingLength = rows + .slice(0, cursorRowIndex) + .reduce((length, row) => length + row.text.length + 1, 0); + const row = rows[cursorRowIndex]; + return precedingLength + Math.min(row.text.length, cursorIndex - row.start); +} diff --git a/tui/src/libs/composer/cursorIndex.ts b/tui/src/libs/composer/cursorIndex.ts new file mode 100644 index 00000000..d1451cd2 --- /dev/null +++ b/tui/src/libs/composer/cursorIndex.ts @@ -0,0 +1,6 @@ +import { clamp } from '@libs/math/clamp.ts'; +import { clampToGraphemeBoundary } from '@libs/text/displayWidth.ts'; + +export function clampComposerCursorIndex(text: string, cursorIndex: number): number { + return clampToGraphemeBoundary(text, clamp(cursorIndex, 0, text.length)); +} diff --git a/tui/src/components/PromptComposer/cursorPosition.ts b/tui/src/libs/composer/cursorPosition.ts similarity index 70% rename from tui/src/components/PromptComposer/cursorPosition.ts rename to tui/src/libs/composer/cursorPosition.ts index eadac40b..af695cec 100644 --- a/tui/src/components/PromptComposer/cursorPosition.ts +++ b/tui/src/libs/composer/cursorPosition.ts @@ -1,9 +1,9 @@ import { COMPOSER_BACKGROUND_TOP_PADDING_ROWS, - INK_CURSOR_ROW_ORIGIN_OFFSET, PROMPT_PREFIX } from '@constants/ui.ts'; import { clamp } from '@libs/math/clamp.ts'; +import { displayWidth } from '@libs/text/displayWidth.ts'; export function resolveComposerCursorPosition( visibleText: string, @@ -17,10 +17,7 @@ export function resolveComposerCursorPosition( return { x: PROMPT_PREFIX.length + cursorPosition.x, - // The measured composer top plus any half-line padding lands on the editable - // row; INK_CURSOR_ROW_ORIGIN_OFFSET absorbs Ink's cursor-baseline origin - // (1 while filling the terminal fullscreen — see its definition). - y: composerTop + topPaddingRows + cursorPosition.y + INK_CURSOR_ROW_ORIGIN_OFFSET + y: composerTop + topPaddingRows + cursorPosition.y }; } @@ -33,7 +30,7 @@ function cursorPositionForVisibleText( const lines = textBeforeCursor.split('\n'); const lastLine = lines.at(-1) ?? ''; return { - x: Math.min(lastLine.length, columns), + x: Math.min(displayWidth(lastLine), columns), y: lines.length - 1 }; } diff --git a/tui/src/libs/composer/layout.ts b/tui/src/libs/composer/layout.ts new file mode 100644 index 00000000..adca2ae6 --- /dev/null +++ b/tui/src/libs/composer/layout.ts @@ -0,0 +1,12 @@ +import { + COMPOSER_RIGHT_PADDING_COLUMNS, + PROMPT_PREFIX +} from '@constants/ui.ts'; + +/** Display columns available to authored prompt text inside the composer row. */ +export function resolveComposerInputColumns(composerColumns: number): number { + return Math.max( + 1, + composerColumns - PROMPT_PREFIX.length - COMPOSER_RIGHT_PADDING_COLUMNS + ); +} diff --git a/tui/src/libs/composer/pasteTarget.ts b/tui/src/libs/composer/pasteTarget.ts new file mode 100644 index 00000000..e2a6ddec --- /dev/null +++ b/tui/src/libs/composer/pasteTarget.ts @@ -0,0 +1,11 @@ +export type ComposerPasteTarget = { + cursorIndex: number; + text: string; +}; + +export function sameComposerPasteTarget( + left: ComposerPasteTarget, + right: ComposerPasteTarget +): boolean { + return left.cursorIndex === right.cursorIndex && left.text === right.text; +} diff --git a/tui/src/libs/composer/pastedText.ts b/tui/src/libs/composer/pastedText.ts new file mode 100644 index 00000000..4d962f69 --- /dev/null +++ b/tui/src/libs/composer/pastedText.ts @@ -0,0 +1,11 @@ +import { expandTabs } from '@libs/text/expandTabs.ts'; + +const CARRIAGE_RETURN_PATTERN = /\r\n?/g; +const PASTE_CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g; + +/** Normalizes pasted layout and strips terminal control bytes. */ +export function sanitizePastedText(text: string): string { + return expandTabs( + text.replace(CARRIAGE_RETURN_PATTERN, '\n').replace(PASTE_CONTROL_CHAR_PATTERN, '') + ); +} diff --git a/tui/src/libs/composer/promptTextView.ts b/tui/src/libs/composer/promptTextView.ts new file mode 100644 index 00000000..d9570463 --- /dev/null +++ b/tui/src/libs/composer/promptTextView.ts @@ -0,0 +1,19 @@ +import { COMPOSER_BACKGROUND_PADDING_ROWS } from '@constants/ui.ts'; +import { padEndToWidth, truncateToWidth } from '@libs/text/displayWidth.ts'; + +export function countVisibleComposerRows( + visibleRowCount: number, + hasValidationError: boolean, + hasBackgroundPadding: boolean +): number { + return ( + visibleRowCount + + (hasValidationError ? 1 : 0) + + (hasBackgroundPadding ? COMPOSER_BACKGROUND_PADDING_ROWS : 0) + ); +} + +export function formatValidationError(error: string, columns: number, shouldPad: boolean): string { + const errorLine = truncateToWidth(`ERROR: ${error}`, columns); + return shouldPad ? padEndToWidth(errorLine, columns) : errorLine; +} diff --git a/tui/src/libs/composer/types.ts b/tui/src/libs/composer/types.ts new file mode 100644 index 00000000..94f0feba --- /dev/null +++ b/tui/src/libs/composer/types.ts @@ -0,0 +1,5 @@ +export type ComposerState = { + text: string; + cursorIndex: number; + validationError: string | null; +}; diff --git a/tui/src/libs/composer/wrapPromptText.ts b/tui/src/libs/composer/wrapPromptText.ts new file mode 100644 index 00000000..6dd2ade8 --- /dev/null +++ b/tui/src/libs/composer/wrapPromptText.ts @@ -0,0 +1,128 @@ +import { displayWidth, measureGraphemes } from '@libs/text/displayWidth.ts'; + +/** One wrapped visual row of the prompt, with its source index range. */ +export type WrappedPromptRow = { + text: string; + start: number; + end: number; +}; + +// Single-slot memo: the prompt is re-wrapped several times per keystroke (the +// render window, the scroll-into-view effect, and the scroll atoms), always for +// the current text and width. Caching the last result — compared with `===` +// (value equality, with V8's same-string-object fast path) rather than a Map +// hash — collapses those into a single wrap. Callers never mutate the returned +// rows, so sharing the cached array is safe. +let cachedText: string | undefined; +let cachedColumns = -1; +let cachedTrailingCaretRow = false; +let cachedRows: WrappedPromptRow[] | undefined; + +/** + * Splits `text` into visual rows: authored newlines start new rows, and each + * logical line is wrapped to `columns` terminal display columns. An empty prompt + * yields a single empty row. Wide and multi-code-point graphemes stay intact. + * + * The last `(text, columns)` result is memoized, so the repeated wraps of the + * current prompt within a keystroke reuse one computation. + */ +export function wrapPromptText( + text: string, + columns: number, + includeTrailingCaretRow = false +): WrappedPromptRow[] { + const safeColumns = Math.max(1, columns); + if ( + cachedRows !== undefined && + cachedText === text && + cachedColumns === safeColumns && + cachedTrailingCaretRow === includeTrailingCaretRow + ) { + return cachedRows; + } + + const rows = computeWrappedPromptRows(text, safeColumns, includeTrailingCaretRow); + cachedText = text; + cachedColumns = safeColumns; + cachedTrailingCaretRow = includeTrailingCaretRow; + cachedRows = rows; + return rows; +} + +function computeWrappedPromptRows( + text: string, + safeColumns: number, + includeTrailingCaretRow: boolean +): WrappedPromptRow[] { + if (text.length === 0) { + return [{ text: '', start: 0, end: 0 }]; + } + + const rows: WrappedPromptRow[] = []; + let lineStart = 0; + + while (lineStart <= text.length) { + const newlineIndex = text.indexOf('\n', lineStart); + const lineEnd = newlineIndex < 0 ? text.length : newlineIndex; + const rawLine = text.slice(lineStart, lineEnd); + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + + appendLineRows(rows, line, lineStart, safeColumns); + + if (newlineIndex < 0) { + const finalRow = rows.at(-1); + if ( + includeTrailingCaretRow && + finalRow !== undefined && + finalRow.end === text.length && + displayWidth(finalRow.text) === safeColumns + ) { + rows.push({ text: '', start: text.length, end: text.length }); + } + break; + } + + lineStart = newlineIndex + 1; + } + + return rows; +} + +function appendLineRows( + rows: WrappedPromptRow[], + line: string, + lineStart: number, + safeColumns: number +): void { + if (line.length === 0) { + rows.push({ text: '', start: lineStart, end: lineStart }); + return; + } + + let rowStart = 0; + let offset = 0; + let rowWidth = 0; + for (const { segment, width } of measureGraphemes(line)) { + if (rowWidth + width > safeColumns && offset > rowStart) { + rows.push(rowSlice(line, lineStart, rowStart, offset)); + rowStart = offset; + rowWidth = 0; + } + rowWidth += width; + offset += segment.length; + } + rows.push(rowSlice(line, lineStart, rowStart, offset)); +} + +function rowSlice( + line: string, + lineStart: number, + start: number, + end: number +): WrappedPromptRow { + return { + text: line.slice(start, end), + start: lineStart + start, + end: lineStart + end + }; +} diff --git a/tui/src/components/AppExitSummary/__tests__/banner.test.ts b/tui/src/libs/exitSummary/__tests__/banner.test.ts similarity index 90% rename from tui/src/components/AppExitSummary/__tests__/banner.test.ts rename to tui/src/libs/exitSummary/__tests__/banner.test.ts index ad62a31c..3ec6331d 100644 --- a/tui/src/components/AppExitSummary/__tests__/banner.test.ts +++ b/tui/src/libs/exitSummary/__tests__/banner.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { bannerLines } from '@components/AppExitSummary/banner.ts'; +import { bannerLines } from '@libs/exitSummary/banner.ts'; describe('bannerLines', () => { it('renders a fixed 5-row block banner for the KQode wordmark', () => { diff --git a/tui/src/components/AppExitSummary/__tests__/border.test.ts b/tui/src/libs/exitSummary/__tests__/border.test.ts similarity index 94% rename from tui/src/components/AppExitSummary/__tests__/border.test.ts rename to tui/src/libs/exitSummary/__tests__/border.test.ts index 23125a9b..257e6390 100644 --- a/tui/src/components/AppExitSummary/__tests__/border.test.ts +++ b/tui/src/libs/exitSummary/__tests__/border.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { boxed } from '@components/AppExitSummary/border.ts'; +import { boxed } from '@libs/exitSummary/border.ts'; describe('boxed', () => { it('wraps lines in a rounded border sized to the widest line', () => { diff --git a/tui/src/components/AppExitSummary/__tests__/formatDuration.test.ts b/tui/src/libs/exitSummary/__tests__/formatDuration.test.ts similarity index 88% rename from tui/src/components/AppExitSummary/__tests__/formatDuration.test.ts rename to tui/src/libs/exitSummary/__tests__/formatDuration.test.ts index 5dd287c1..2d2e1516 100644 --- a/tui/src/components/AppExitSummary/__tests__/formatDuration.test.ts +++ b/tui/src/libs/exitSummary/__tests__/formatDuration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatDuration } from '@components/AppExitSummary/formatDuration.ts'; +import { formatDuration } from '@libs/exitSummary/formatDuration.ts'; describe('formatDuration', () => { it('renders seconds only under a minute', () => { diff --git a/tui/src/components/AppExitSummary/__tests__/formatExitSummaryCard.test.ts b/tui/src/libs/exitSummary/__tests__/formatExitSummaryCard.test.ts similarity index 95% rename from tui/src/components/AppExitSummary/__tests__/formatExitSummaryCard.test.ts rename to tui/src/libs/exitSummary/__tests__/formatExitSummaryCard.test.ts index 4d36b4b4..d4a6d24c 100644 --- a/tui/src/components/AppExitSummary/__tests__/formatExitSummaryCard.test.ts +++ b/tui/src/libs/exitSummary/__tests__/formatExitSummaryCard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { formatExitSummaryCard } from '@components/AppExitSummary/formatExitSummaryCard.ts'; -import type { Colorize } from '@components/AppExitSummary/types.ts'; +import { formatExitSummaryCard } from '@libs/exitSummary/formatExitSummaryCard.ts'; +import type { Colorize } from '@libs/exitSummary/types.ts'; const identity: Colorize = (text) => text; diff --git a/tui/src/components/AppExitSummary/__tests__/resolveSessionSeed.test.ts b/tui/src/libs/exitSummary/__tests__/resolveSessionSeed.test.ts similarity index 89% rename from tui/src/components/AppExitSummary/__tests__/resolveSessionSeed.test.ts rename to tui/src/libs/exitSummary/__tests__/resolveSessionSeed.test.ts index f93b1e18..bff0e0c5 100644 --- a/tui/src/components/AppExitSummary/__tests__/resolveSessionSeed.test.ts +++ b/tui/src/libs/exitSummary/__tests__/resolveSessionSeed.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { resolveSessionSeed } from '@components/AppExitSummary/resolveSessionSeed.ts'; +import { resolveSessionSeed } from '@libs/exitSummary/resolveSessionSeed.ts'; describe('resolveSessionSeed', () => { it('captures the start time and git baseline from the injected seams', () => { diff --git a/tui/src/components/AppExitSummary/banner.ts b/tui/src/libs/exitSummary/banner.ts similarity index 100% rename from tui/src/components/AppExitSummary/banner.ts rename to tui/src/libs/exitSummary/banner.ts diff --git a/tui/src/components/AppExitSummary/border.ts b/tui/src/libs/exitSummary/border.ts similarity index 100% rename from tui/src/components/AppExitSummary/border.ts rename to tui/src/libs/exitSummary/border.ts diff --git a/tui/src/components/AppExitSummary/formatDuration.ts b/tui/src/libs/exitSummary/formatDuration.ts similarity index 100% rename from tui/src/components/AppExitSummary/formatDuration.ts rename to tui/src/libs/exitSummary/formatDuration.ts diff --git a/tui/src/components/AppExitSummary/formatExitSummaryCard.ts b/tui/src/libs/exitSummary/formatExitSummaryCard.ts similarity index 91% rename from tui/src/components/AppExitSummary/formatExitSummaryCard.ts rename to tui/src/libs/exitSummary/formatExitSummaryCard.ts index dfad4e00..b3ab9a32 100644 --- a/tui/src/components/AppExitSummary/formatExitSummaryCard.ts +++ b/tui/src/libs/exitSummary/formatExitSummaryCard.ts @@ -1,7 +1,7 @@ -import { bannerLines } from '@components/AppExitSummary/banner.ts'; -import { boxed } from '@components/AppExitSummary/border.ts'; -import { formatDuration } from '@components/AppExitSummary/formatDuration.ts'; -import type { Colorize, ExitSummaryData } from '@components/AppExitSummary/types.ts'; +import { bannerLines } from '@libs/exitSummary/banner.ts'; +import { boxed } from '@libs/exitSummary/border.ts'; +import { formatDuration } from '@libs/exitSummary/formatDuration.ts'; +import type { Colorize, ExitSummaryData } from '@libs/exitSummary/types.ts'; import { PRODUCT_NAME } from '@constants/product.ts'; import { visibleLength } from '@libs/terminal/ansiColor.ts'; import { maxWidth } from '@libs/text/maxWidth.ts'; diff --git a/tui/src/components/AppExitSummary/resolveSessionSeed.ts b/tui/src/libs/exitSummary/resolveSessionSeed.ts similarity index 100% rename from tui/src/components/AppExitSummary/resolveSessionSeed.ts rename to tui/src/libs/exitSummary/resolveSessionSeed.ts diff --git a/tui/src/components/AppExitSummary/types.ts b/tui/src/libs/exitSummary/types.ts similarity index 100% rename from tui/src/components/AppExitSummary/types.ts rename to tui/src/libs/exitSummary/types.ts diff --git a/tui/src/libs/git/__tests__/gitStatus.test.ts b/tui/src/libs/git/__tests__/gitStatus.test.ts deleted file mode 100644 index b4a395f2..00000000 --- a/tui/src/libs/git/__tests__/gitStatus.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { formatGitStatusLabel, parseGitStatus } from '@libs/git/gitStatus.ts'; - -describe('git status labels', () => { - it('formats branch labels with staged, unstaged, and untracked flags', () => { - const status = parseGitStatus( - [ - '## feat/first-ink-tui-jsonrpc-backend...origin/feat/first-ink-tui-jsonrpc-backend', - ' M tui/src/App.tsx', - 'A tui/src/libs/git/gitStatus.ts', - '?? tui/src/libs/git/__tests__/gitStatus.test.ts' - ].join('\n') - ); - - expect(formatGitStatusLabel(status)).toBe('⎇ feat/first-ink-tui-jsonrpc-backend*+%'); - }); - - it('returns a clean branch label when the worktree has no changes', () => { - const status = parseGitStatus('## main...origin/main\n'); - - expect(formatGitStatusLabel(status)).toBe('⎇ main'); - }); -}); diff --git a/tui/src/libs/git/gitStatus.ts b/tui/src/libs/git/gitStatus.ts deleted file mode 100644 index b94f3752..00000000 --- a/tui/src/libs/git/gitStatus.ts +++ /dev/null @@ -1,103 +0,0 @@ -import {execFileSync} from 'node:child_process'; - -const GIT_BRANCH_ICON = '⎇'; -const UNSTAGED_CHANGE_FLAG = '*'; -const STAGED_CHANGE_FLAG = '+'; -const UNTRACKED_CHANGE_FLAG = '%'; -const STATUS_BRANCH_PREFIX = '## '; -const STATUS_UPSTREAM_SEPARATOR = '...'; -const NO_COMMITS_BRANCH_PREFIX = 'No commits yet on '; -const DETACHED_HEAD_STATUS = 'HEAD (no branch)'; -const GIT_STATUS_TIMEOUT_MS = 2_000; - -export type GitStatus = { - branch: string; - hasUnstagedChanges: boolean; - hasStagedChanges: boolean; - hasUntrackedChanges: boolean; -}; - -export function readGitStatusLabel(cwd: string): string | undefined { - try { - const porcelainStatus = execFileSync( - 'git', - ['-C', cwd, 'status', '--porcelain=v1', '--branch'], - { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - timeout: GIT_STATUS_TIMEOUT_MS - } - ); - - return formatGitStatusLabel(parseGitStatus(porcelainStatus)); - } catch { - return undefined; - } -} - -export function formatGitStatusLabel(status: GitStatus | undefined): string | undefined { - if (status === undefined) { - return undefined; - } - - return `${GIT_BRANCH_ICON} ${status.branch}${formatGitStatusFlags(status)}`; -} - -export function parseGitStatus(porcelainStatus: string): GitStatus | undefined { - const lines = porcelainStatus.split(/\r?\n/).filter(Boolean); - const branchLine = lines.find((line) => line.startsWith(STATUS_BRANCH_PREFIX)); - const branch = parseBranchName(branchLine); - - if (branch === undefined) { - return undefined; - } - - return lines.reduce( - (status, line) => { - if (line.startsWith(STATUS_BRANCH_PREFIX)) { - return status; - } - - return { - branch: status.branch, - hasUnstagedChanges: - status.hasUnstagedChanges || (line[1] !== ' ' && line[1] !== '?' && line[1] !== '!'), - hasStagedChanges: - status.hasStagedChanges || (line[0] !== ' ' && line[0] !== '?' && line[0] !== '!'), - hasUntrackedChanges: status.hasUntrackedChanges || line.startsWith('??') - }; - }, - { - branch, - hasUnstagedChanges: false, - hasStagedChanges: false, - hasUntrackedChanges: false - } - ); -} - -function parseBranchName(branchLine: string | undefined): string | undefined { - if (branchLine === undefined) { - return undefined; - } - - const branchStatus = branchLine.slice(STATUS_BRANCH_PREFIX.length); - - if (branchStatus.startsWith(NO_COMMITS_BRANCH_PREFIX)) { - return branchStatus.slice(NO_COMMITS_BRANCH_PREFIX.length); - } - - if (branchStatus === DETACHED_HEAD_STATUS) { - return 'HEAD'; - } - - return branchStatus.split(STATUS_UPSTREAM_SEPARATOR)[0].split(' [')[0]; -} - -function formatGitStatusFlags(status: GitStatus): string { - return [ - status.hasUnstagedChanges ? UNSTAGED_CHANGE_FLAG : '', - status.hasStagedChanges ? STAGED_CHANGE_FLAG : '', - status.hasUntrackedChanges ? UNTRACKED_CHANGE_FLAG : '' - ].join(''); -} diff --git a/tui/src/components/HelpScreen/helpContent.ts b/tui/src/libs/help/helpContent.ts similarity index 89% rename from tui/src/components/HelpScreen/helpContent.ts rename to tui/src/libs/help/helpContent.ts index c9995e07..7ef8f243 100644 --- a/tui/src/components/HelpScreen/helpContent.ts +++ b/tui/src/libs/help/helpContent.ts @@ -33,10 +33,18 @@ const KEYBINDING_SECTIONS: readonly HelpSection[] = [ title: 'INPUT', entries: [ { keys: 'enter', description: 'Submit the prompt' }, - { keys: 'shift+enter', description: 'Insert a newline' }, + { keys: 'shift/alt+enter', description: 'Insert a newline (supported terminals)' }, { keys: '\\ then enter', description: 'Insert a newline' }, { keys: '← / →', description: 'Move the cursor' }, - { keys: 'backspace', description: 'Delete the previous character' } + { keys: 'backspace', description: 'Delete the previous character' }, + { keys: 'right-click', description: 'Paste when no transcript selection is active' } + ] + }, + { + title: 'SELECTION', + entries: [ + { keys: 'drag', description: 'Highlight transcript text' }, + { keys: 'right-click', description: 'Copy the highlight, then clear it' } ] }, { diff --git a/tui/src/libs/os/__tests__/openExternalUrl.test.ts b/tui/src/libs/os/__tests__/openExternalUrl.test.ts new file mode 100644 index 00000000..967ab45e --- /dev/null +++ b/tui/src/libs/os/__tests__/openExternalUrl.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + isOpenableUrl, + openExternalUrl, + resolveOpenCommand +} from '@libs/os/openExternalUrl.ts'; + +const url = 'https://github.com/o/r/pull/3'; + +describe('isOpenableUrl', () => { + it('accepts http and https urls', () => { + expect(isOpenableUrl(url)).toBe(true); + expect(isOpenableUrl('http://example.com')).toBe(true); + }); + + it('rejects non-http(s) schemes and malformed urls', () => { + expect(isOpenableUrl('file:///etc/passwd')).toBe(false); + expect(isOpenableUrl('javascript:alert(1)')).toBe(false); + expect(isOpenableUrl('not a url')).toBe(false); + expect(isOpenableUrl('')).toBe(false); + }); +}); + +describe('resolveOpenCommand', () => { + it('uses a non-shell opener (explorer.exe) on Windows so the url cannot inject', () => { + expect(resolveOpenCommand(url, 'win32')).toEqual({ command: 'explorer.exe', args: [url] }); + }); + + it('passes query-string metacharacters through literally (no shell to reparse them)', () => { + const query = 'https://example.com/?a=1&b=2'; + expect(resolveOpenCommand(query, 'win32')).toEqual({ command: 'explorer.exe', args: [query] }); + }); + + it('uses open on macOS', () => { + expect(resolveOpenCommand(url, 'darwin')).toEqual({ command: 'open', args: [url] }); + }); + + it('uses xdg-open on Linux', () => { + expect(resolveOpenCommand(url, 'linux')).toEqual({ command: 'xdg-open', args: [url] }); + }); + + it('returns null for a non-openable url so no process is launched', () => { + expect(resolveOpenCommand('file:///x', 'linux')).toBeNull(); + }); +}); + +describe('openExternalUrl', () => { + function fakeSpawner() { + const on = vi.fn(); + const unref = vi.fn(); + const spawnProcess = vi.fn( + (_command: string, _args: string[], _options: { stdio: 'ignore'; detached: true }) => ({ + on, + unref + }) + ); + return { on, unref, spawnProcess }; + } + + it('spawns the resolved opener detached and unref’d, reporting success', () => { + const { unref, spawnProcess } = fakeSpawner(); + + expect(openExternalUrl(url, 'linux', spawnProcess)).toBe(true); + expect(spawnProcess).toHaveBeenCalledWith('xdg-open', [url], { + stdio: 'ignore', + detached: true + }); + expect(unref).toHaveBeenCalledOnce(); + }); + + it('attaches an error listener that swallows an async spawn failure', () => { + const { on, spawnProcess } = fakeSpawner(); + + openExternalUrl(url, 'linux', spawnProcess); + + const [event, listener] = on.mock.calls[0] ?? []; + expect(event).toBe('error'); + // A missing opener (e.g. no xdg-open) emits 'error' asynchronously; the + // listener must not rethrow, or it would crash the input loop. + expect(() => (listener as (error: Error) => void)(new Error('ENOENT'))).not.toThrow(); + }); + + it('does not spawn for a non-openable url', () => { + const { spawnProcess } = fakeSpawner(); + + expect(openExternalUrl('file:///x', 'linux', spawnProcess)).toBe(false); + expect(spawnProcess).not.toHaveBeenCalled(); + }); + + it('returns false instead of throwing when the spawn fails synchronously', () => { + const spawnProcess = vi.fn( + (_command: string, _args: string[], _options: { stdio: 'ignore'; detached: true }) => { + throw new Error('spawn failed'); + } + ); + + expect(openExternalUrl(url, 'linux', spawnProcess)).toBe(false); + }); +}); diff --git a/tui/src/libs/os/openExternalUrl.ts b/tui/src/libs/os/openExternalUrl.ts new file mode 100644 index 00000000..c0704e22 --- /dev/null +++ b/tui/src/libs/os/openExternalUrl.ts @@ -0,0 +1,85 @@ +import { spawn } from 'node:child_process'; + +const OPENABLE_PROTOCOLS = new Set(['http:', 'https:']); + +/** Whether `url` is a well-formed http(s) URL safe to hand to the OS opener. */ +export function isOpenableUrl(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + return OPENABLE_PROTOCOLS.has(parsed.protocol); +} + +/** A command and its argument vector for launching the platform URL opener. */ +export type OpenCommand = { command: string; args: string[] }; + +/** + * The platform command + argv that opens `url` in the default browser, or `null` + * when `url` is not an openable http(s) URL. + * + * Every platform uses a non-shell opener (`explorer.exe` / `open` / `xdg-open`) + * invoked directly — never `cmd` or a shell string — so URL metacharacters such + * as the `&` between query parameters are handed to the opener literally and + * cannot be reinterpreted as extra commands. Combined with the http(s) allowlist + * in {@link isOpenableUrl}, this blocks command injection from a hostile URL. + */ +export function resolveOpenCommand( + url: string, + platform: NodeJS.Platform = process.platform +): OpenCommand | null { + if (!isOpenableUrl(url)) { + return null; + } + if (platform === 'win32') { + return { command: 'explorer.exe', args: [url] }; + } + if (platform === 'darwin') { + return { command: 'open', args: [url] }; + } + return { command: 'xdg-open', args: [url] }; +} + +/** Minimal spawn seam so tests can observe the launch without a real process. */ +export type ProcessSpawner = ( + command: string, + args: string[], + options: { stdio: 'ignore'; detached: true } +) => { + on: (event: 'error', listener: (error: Error) => void) => void; + unref: () => void; +}; + +/** + * Opens `url` in the user's default browser as a detached, unref'd process. + * + * Returns whether a launch was attempted: `false` for a non-openable URL or a + * synchronous spawn failure. Best-effort by design — KQode owns the terminal + * input loop, so a failure must never throw into it. A missing opener binary is + * reported asynchronously via the child's `error` event (e.g. Linux without + * `xdg-open`), so an `error` listener is attached to swallow it rather than let + * it surface as an uncaught exception. + */ +export function openExternalUrl( + url: string, + platform: NodeJS.Platform = process.platform, + spawnProcess: ProcessSpawner = (command, args, options) => spawn(command, args, options) +): boolean { + const resolved = resolveOpenCommand(url, platform); + if (resolved === null) { + return false; + } + try { + const child = spawnProcess(resolved.command, resolved.args, { + stdio: 'ignore', + detached: true + }); + child.on('error', () => {}); + child.unref(); + return true; + } catch { + return false; + } +} diff --git a/tui/src/libs/promptQueue/__tests__/promptQueue.test.ts b/tui/src/libs/promptQueue/__tests__/promptQueue.test.ts new file mode 100644 index 00000000..6d68f245 --- /dev/null +++ b/tui/src/libs/promptQueue/__tests__/promptQueue.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { BodyEntryKind } from '@constants/bodyEntry.ts'; +import { queueToBodyEntries } from '@libs/promptQueue/promptQueue.ts'; +import type { QueueItem } from '@libs/promptQueue/promptQueue.ts'; + +describe('queueToBodyEntries memoization', () => { + it('returns identical entry references for an unchanged item', () => { + const queue: QueueItem[] = [{ id: 1, text: 'hello', state: 'active' }]; + + const first = queueToBodyEntries(queue); + const second = queueToBodyEntries(queue); + + expect(second[0]).toBe(first[0]); + }); + + it('reuses both entries for a settled item across calls', () => { + const item: QueueItem = { + id: 2, + text: 'done', + state: 'settled', + result: { kind: BodyEntryKind.Assistant, text: 'reply' } + }; + + const first = queueToBodyEntries([item]); + const second = queueToBodyEntries([item]); + + expect(second[0]).toBe(first[0]); + expect(second[1]).toBe(first[1]); + expect(first[1]).toMatchObject({ kind: BodyEntryKind.Assistant, text: 'reply' }); + }); + + it('does not share cached entries between distinct item objects', () => { + const a: QueueItem = { id: 4, text: 'x', state: 'active' }; + const b: QueueItem = { id: 4, text: 'x', state: 'active' }; + + const entriesA = queueToBodyEntries([a]); + const entriesB = queueToBodyEntries([b]); + + expect(entriesB[0]).not.toBe(entriesA[0]); + expect(entriesB[0]).toEqual(entriesA[0]); + }); +}); diff --git a/tui/src/libs/promptQueue/promptQueue.ts b/tui/src/libs/promptQueue/promptQueue.ts index 29643ef9..1fd9056e 100644 --- a/tui/src/libs/promptQueue/promptQueue.ts +++ b/tui/src/libs/promptQueue/promptQueue.ts @@ -1,4 +1,5 @@ import { BackendClientError } from '@contracts/backend/index.ts'; +import type { SubmitOutcome } from '@contracts/backend/index.ts'; import { BodyEntryKind } from '@constants/bodyEntry.ts'; import { sanitizeDisplayText } from '@libs/text/sanitizeDisplayText.ts'; import type { BodyEntry } from '@libs/tui/bodyRows.ts'; @@ -6,13 +7,20 @@ import type { BodyEntry } from '@libs/tui/bodyRows.ts'; export type QueueItemState = 'active' | 'queued' | 'settled'; export type BackendResult = { - kind: typeof BodyEntryKind.Success | typeof BodyEntryKind.Error; + kind: + | typeof BodyEntryKind.Assistant + | typeof BodyEntryKind.Success + | typeof BodyEntryKind.Error; text: string; }; /** Shown when a prompt is submitted with no backend client wired into the seam. */ export const BACKEND_UNAVAILABLE_MESSAGE = 'Rust backend unavailable'; +/** Shown when provider configuration has not landed in this bootstrap slice. */ +export const NEEDS_CONFIGURATION_MESSAGE = + 'Provider configuration is not available yet. Continue with the provider setup PR.'; + export type QueueItem = { id: number; text: string; @@ -20,20 +28,64 @@ export type QueueItem = { result?: BackendResult; }; +/** Cached body entries for one queue item. */ +type ItemEntriesCacheSlot = { entries: BodyEntry[] }; + +// Body entries for an unchanged item are stable, so memoize them per item +// identity. `QueueItem` objects are immutable — state/result transitions create a +// new object (see the prompt-queue atoms) — so a cache hit needs only an identity +// match. This keeps `BodyEntry` references stable (so downstream row wrapping +// stays cached) and skips re-sanitizing the whole transcript on every render. +// Settled/cleared items are GC'd from the WeakMap once the queue drops them. +const entriesByItem = new WeakMap(); + +/** + * Maps the prompt queue to transcript body entries. + * + * Results are memoized per item (see `entriesByItem`), so an unchanged item + * returns identical `BodyEntry` references and only a new item object is rebuilt. + */ export function queueToBodyEntries(queue: readonly QueueItem[]): BodyEntry[] { return queue.flatMap((item) => { - const promptText = sanitizeDisplayText(item.text); - const promptEntry: BodyEntry = - item.state === 'queued' - ? { id: `prompt-${item.id}`, kind: BodyEntryKind.Pending, text: promptText } - : { id: `prompt-${item.id}`, kind: BodyEntryKind.User, text: promptText }; - - return item.result === undefined - ? [promptEntry] - : [promptEntry, { id: `result-${item.id}`, kind: item.result.kind, text: item.result.text }]; + const cached = entriesByItem.get(item); + if (cached !== undefined) { + return cached.entries; + } + + const entries = buildItemEntries(item); + entriesByItem.set(item, { entries }); + return entries; }); } +function buildItemEntries(item: QueueItem): BodyEntry[] { + const promptText = sanitizeDisplayText(item.text); + const promptEntry: BodyEntry = + item.state === 'queued' + ? { id: `prompt-${item.id}`, kind: BodyEntryKind.Pending, text: promptText } + : { id: `prompt-${item.id}`, kind: BodyEntryKind.User, text: promptText }; + + if (item.result !== undefined) { + return [ + promptEntry, + { id: `result-${item.id}`, kind: item.result.kind, text: item.result.text } + ]; + } + + return [promptEntry]; +} + +/** Maps a submitted turn's terminal {@link SubmitOutcome} to a transcript result. */ +export function outcomeToResult(outcome: SubmitOutcome): BackendResult { + switch (outcome.kind) { + case 'needsConfiguration': + return { + kind: BodyEntryKind.Error, + text: sanitizeDisplayText(NEEDS_CONFIGURATION_MESSAGE) + }; + } +} + export function backendErrorMessage(error: unknown): string { if (error instanceof BackendClientError) { return `Rust backend failed: ${error.message}`; diff --git a/tui/src/libs/selection/__tests__/selection.test.ts b/tui/src/libs/selection/__tests__/selection.test.ts new file mode 100644 index 00000000..e432aff1 --- /dev/null +++ b/tui/src/libs/selection/__tests__/selection.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { BodyEntryKind } from '@constants/bodyEntry.ts'; +import { selectionBounds } from '@libs/selection/bounds.ts'; +import { rowHighlight } from '@libs/selection/highlightRow.ts'; +import { selectedText } from '@libs/selection/selectedText.ts'; +import { resolveBodyRows, type BodyRow } from '@libs/tui/bodyRows.ts'; + +describe('selection bounds and highlighting', () => { + it('normalizes a backwards drag into reading order', () => { + expect( + selectionBounds( + { rowIndex: 3, column: 8 }, + { rowIndex: 1, column: 2 } + ) + ).toEqual({ + start: { rowIndex: 1, column: 2 }, + end: { rowIndex: 3, column: 8 } + }); + }); + + it('splits a row on display-column boundaries after its marker', () => { + expect( + rowHighlight( + '界abc', + 0, + { + start: { rowIndex: 0, column: 2 }, + end: { rowIndex: 0, column: 5 } + }, + 2 + ) + ).toEqual({ pre: '', selected: '界a', post: 'bc' }); + }); +}); + +describe('selectedText', () => { + it('skips decorative rows and rejoins soft-wrapped content', () => { + const rows: BodyRow[] = [ + { decorative: true, text: '────' }, + { marker: '• ', text: 'hello' }, + { continuesPrevious: '', marker: ' ', text: ' world' } + ]; + + expect( + selectedText( + rows, + { rowIndex: 0, column: 0 }, + { rowIndex: 2, column: 999 } + ) + ).toBe('hello world'); + }); + + it('copies only the highlighted columns', () => { + const rows: BodyRow[] = [{ text: 'selectable line' }]; + + expect( + selectedText( + rows, + { rowIndex: 0, column: 1 }, + { rowIndex: 0, column: 10 } + ) + ).toBe('electable'); + }); + + it('preserves selected whitespace exactly', () => { + const rows: BodyRow[] = [{ text: 'a b' }]; + + expect( + selectedText( + rows, + { rowIndex: 0, column: 1 }, + { rowIndex: 0, column: 3 } + ) + ).toBe(' '); + }); + + it('excludes a separately rendered user prompt marker', () => { + const rows: BodyRow[] = [{ marker: ' ❯ ', text: 'hello' }]; + + expect( + selectedText( + rows, + { rowIndex: 0, column: 0 }, + { rowIndex: 0, column: 999 } + ) + ).toBe('hello'); + }); + + it('highlights and copies tab-expanded transcript cells consistently', () => { + const rows = resolveBodyRows( + [{ kind: BodyEntryKind.Assistant, text: 'a\tb' }], + 12, + 10 + ); + const bounds = { + start: { rowIndex: 0, column: 2 }, + end: { rowIndex: 0, column: 7 } + }; + + expect(rowHighlight(rows[0]?.text ?? '', 0, bounds, 2)?.selected).toBe('a b'); + expect(selectedText(rows, bounds.start, bounds.end)).toBe('a b'); + }); +}); diff --git a/tui/src/libs/selection/bounds.ts b/tui/src/libs/selection/bounds.ts new file mode 100644 index 00000000..ff82371d --- /dev/null +++ b/tui/src/libs/selection/bounds.ts @@ -0,0 +1,18 @@ +/** A position inside the absolute wrapped body-row list. */ +export type SelectionPoint = { rowIndex: number; column: number }; + +/** An ordered selection span where `start` precedes or equals `end`. */ +export type SelectionBounds = { start: SelectionPoint; end: SelectionPoint }; + +/** Orders a possibly backwards drag into reading order. */ +export function selectionBounds(anchor: SelectionPoint, focus: SelectionPoint): SelectionBounds { + const anchorFirst = + anchor.rowIndex < focus.rowIndex || + (anchor.rowIndex === focus.rowIndex && anchor.column <= focus.column); + return anchorFirst ? { start: anchor, end: focus } : { start: focus, end: anchor }; +} + +/** Returns whether the selection covers no terminal cells. */ +export function isSelectionEmpty(bounds: SelectionBounds): boolean { + return bounds.start.rowIndex === bounds.end.rowIndex && bounds.start.column === bounds.end.column; +} diff --git a/tui/src/libs/selection/highlightRow.ts b/tui/src/libs/selection/highlightRow.ts new file mode 100644 index 00000000..3e45f9c2 --- /dev/null +++ b/tui/src/libs/selection/highlightRow.ts @@ -0,0 +1,24 @@ +import type { SelectionBounds } from '@libs/selection/bounds.ts'; +import { rowSelectionSpan } from '@libs/selection/rowSpan.ts'; + +/** A row split around its selected display-column span. */ +export type RowHighlight = { pre: string; selected: string; post: string }; + +/** Resolves the highlighted text span for one absolute body row. */ +export function rowHighlight( + text: string, + rowIndex: number, + bounds: SelectionBounds, + markerWidth: number +): RowHighlight | null { + const span = rowSelectionSpan(text, rowIndex, bounds, markerWidth); + if (span === null || span.endChar <= span.startChar) { + return null; + } + + return { + pre: text.slice(0, span.startChar), + selected: text.slice(span.startChar, span.endChar), + post: text.slice(span.endChar) + }; +} diff --git a/tui/src/libs/selection/rowSpan.ts b/tui/src/libs/selection/rowSpan.ts new file mode 100644 index 00000000..17863ca5 --- /dev/null +++ b/tui/src/libs/selection/rowSpan.ts @@ -0,0 +1,35 @@ +import { + isSelectionEmpty, + type SelectionBounds +} from '@libs/selection/bounds.ts'; +import { indexAtDisplayColumn } from '@libs/text/displayWidth.ts'; + +export type RowSelectionSpan = { + endChar: number; + startChar: number; +}; + +/** Maps normalized selection columns onto source-string indexes for one row. */ +export function rowSelectionSpan( + text: string, + rowIndex: number, + bounds: SelectionBounds, + markerWidth: number +): RowSelectionSpan | null { + if ( + isSelectionEmpty(bounds) || + rowIndex < bounds.start.rowIndex || + rowIndex > bounds.end.rowIndex + ) { + return null; + } + + const startColumn = rowIndex === bounds.start.rowIndex ? bounds.start.column : 0; + const endColumn = + rowIndex === bounds.end.rowIndex ? bounds.end.column : Number.POSITIVE_INFINITY; + + return { + startChar: indexAtDisplayColumn(text, Math.max(0, startColumn - markerWidth)), + endChar: indexAtDisplayColumn(text, Math.max(0, endColumn - markerWidth)) + }; +} diff --git a/tui/src/libs/selection/selectedText.ts b/tui/src/libs/selection/selectedText.ts new file mode 100644 index 00000000..347bb7ee --- /dev/null +++ b/tui/src/libs/selection/selectedText.ts @@ -0,0 +1,39 @@ +import { isSelectionEmpty, selectionBounds, type SelectionPoint } from '@libs/selection/bounds.ts'; +import { rowSelectionSpan } from '@libs/selection/rowSpan.ts'; +import { displayWidth } from '@libs/text/displayWidth.ts'; +import type { BodyRow } from '@libs/tui/bodyRows.ts'; + +/** Reconstructs clean logical text from a transcript selection. */ +export function selectedText( + allRows: readonly BodyRow[], + anchor: SelectionPoint, + focus: SelectionPoint +): string { + const bounds = selectionBounds(anchor, focus); + if (isSelectionEmpty(bounds)) { + return ''; + } + + const logicalLines: string[] = []; + for (let rowIndex = bounds.start.rowIndex; rowIndex <= bounds.end.rowIndex; rowIndex += 1) { + const row = allRows[rowIndex]; + if (row === undefined || row.decorative === true) { + continue; + } + + const markerWidth = displayWidth(row.marker ?? ''); + const span = rowSelectionSpan(row.text, rowIndex, bounds, markerWidth); + if (span === null) { + continue; + } + const fragment = row.text.slice(span.startChar, span.endChar); + + if (row.continuesPrevious !== undefined && logicalLines.length > 0) { + logicalLines[logicalLines.length - 1] += row.continuesPrevious + fragment; + } else { + logicalLines.push(fragment); + } + } + + return logicalLines.join('\n'); +} diff --git a/tui/src/libs/terminal/__tests__/fullscreenGuard.test.ts b/tui/src/libs/terminal/__tests__/fullscreenGuard.test.ts new file mode 100644 index 00000000..108940be --- /dev/null +++ b/tui/src/libs/terminal/__tests__/fullscreenGuard.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { WEZTERM_FULLSCREEN_GUARD_ROWS } from '@constants/terminal.ts'; +import { resolveFullscreenGuardRows } from '@libs/terminal/fullscreenGuard.ts'; + +describe('resolveFullscreenGuardRows', () => { + it('reserves one row for WezTerm', () => { + expect(resolveFullscreenGuardRows('WezTerm')).toBe( + WEZTERM_FULLSCREEN_GUARD_ROWS + ); + }); + + it('does not reduce the canvas in other terminals', () => { + expect(resolveFullscreenGuardRows('Windows_Terminal')).toBe(0); + expect(resolveFullscreenGuardRows('')).toBe(0); + }); +}); diff --git a/tui/src/libs/terminal/__tests__/hyperlink.test.ts b/tui/src/libs/terminal/__tests__/hyperlink.test.ts new file mode 100644 index 00000000..5af5913e --- /dev/null +++ b/tui/src/libs/terminal/__tests__/hyperlink.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { dottedUnderline, hyperlink } from '@libs/terminal/hyperlink.ts'; + +describe('hyperlink', () => { + it('wraps text in an OSC 8 hyperlink pointing at the url', () => { + expect(hyperlink('#3', 'https://github.com/o/r/pull/3')).toBe( + '\u001B]8;;https://github.com/o/r/pull/3\u0007#3\u001B]8;;\u0007' + ); + }); +}); + +describe('dottedUnderline', () => { + it('primes a standard underline before the dotted refinement so the reset survives', () => { + // Leading `4m` (plain underline) keeps Ink's slice-ansi tracking the state so + // the `24m` reset is preserved; `4:3m` upgrades to dotted where supported. + expect(dottedUnderline('#3')).toBe('\u001B[4m\u001B[4:3m#3\u001B[24m'); + }); +}); diff --git a/tui/src/libs/terminal/__tests__/mouse.test.ts b/tui/src/libs/terminal/__tests__/mouse.test.ts new file mode 100644 index 00000000..116b88e3 --- /dev/null +++ b/tui/src/libs/terminal/__tests__/mouse.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { isMouseInput, parseMouseInputEvents } from '@libs/terminal/mouse.ts'; +import { SgrMouseButton, sgrMouseInput } from '@test/terminalInput.ts'; + +describe('parseMouseInputEvents', () => { + it('parses wheel-up with its 1-based pointer row', () => { + expect(parseMouseInputEvents(sgrMouseInput(SgrMouseButton.WheelUp, 1, 1))).toEqual([ + { kind: 'wheel', direction: 'up', row: 1 } + ]); + }); + + it('parses wheel-down with the row from the third SGR field', () => { + expect(parseMouseInputEvents(sgrMouseInput(SgrMouseButton.WheelDown, 10, 7))).toEqual([ + { kind: 'wheel', direction: 'down', row: 7 } + ]); + }); + + it('decodes wheel buttons carrying modifier bits as plain up/down', () => { + expect( + parseMouseInputEvents(sgrMouseInput(SgrMouseButton.WheelUpWithCtrl, 3, 4)) + ).toEqual([ + { kind: 'wheel', direction: 'up', row: 4 } + ]); + }); + + it('returns every wheel notch in a batched chunk', () => { + const input = + sgrMouseInput(SgrMouseButton.WheelUp, 1, 1) + + sgrMouseInput(SgrMouseButton.WheelDown, 2, 7); + expect(parseMouseInputEvents(input)).toEqual([ + { kind: 'wheel', direction: 'up', row: 1 }, + { kind: 'wheel', direction: 'down', row: 7 } + ]); + }); + + it('does not consume pasted text containing a wheel-like substring', () => { + const wheelLikeText = sgrMouseInput(SgrMouseButton.WheelUp, 1, 1).slice(1); + const input = `prefix ${wheelLikeText} suffix`; + expect(parseMouseInputEvents(input)).toBeNull(); + expect(isMouseInput(input)).toBe(false); + }); + + it('recognizes a batched click press and release as mouse input', () => { + const input = + sgrMouseInput(SgrMouseButton.Left, 12, 5) + + sgrMouseInput(SgrMouseButton.Left, 12, 5, 'm'); + expect(isMouseInput(input)).toBe(true); + expect(parseMouseInputEvents(input)).toEqual([ + { kind: 'press', row: 5, column: 12 }, + { kind: 'release', row: 5, column: 12 } + ]); + }); + + it('parses a left-button press into its 1-based row/column', () => { + expect(parseMouseInputEvents(sgrMouseInput(SgrMouseButton.Left, 12, 5))).toEqual([ + { kind: 'press', row: 5, column: 12 } + ]); + }); + + it('parses left drag/release and right-button presses', () => { + expect(parseMouseInputEvents(sgrMouseInput(SgrMouseButton.LeftDrag, 12, 5))).toEqual([ + { kind: 'drag', row: 5, column: 12 } + ]); + expect( + parseMouseInputEvents(sgrMouseInput(SgrMouseButton.Left, 12, 5, 'm')) + ).toEqual([ + { kind: 'release', row: 5, column: 12 } + ]); + expect(parseMouseInputEvents(sgrMouseInput(SgrMouseButton.Right, 1, 1))).toEqual([ + { kind: 'rightClick', row: 1, column: 1 } + ]); + }); + + it('ignores right-button motion and unsupported horizontal wheel input', () => { + expect(parseMouseInputEvents(sgrMouseInput(SgrMouseButton.RightDrag, 4, 5))).toEqual([]); + expect(parseMouseInputEvents(sgrMouseInput(SgrMouseButton.WheelLeft, 4, 5))).toEqual([]); + }); + + it('returns null for non-mouse input', () => { + expect(parseMouseInputEvents('hello')).toBeNull(); + }); +}); + +describe('isMouseInput', () => { + it('matches SGR mouse sequences and rejects plain text', () => { + expect(isMouseInput(sgrMouseInput(SgrMouseButton.WheelUp, 1, 1))).toBe(true); + expect(isMouseInput(sgrMouseInput(SgrMouseButton.Left, 5, 9, 'm'))).toBe(true); + expect(isMouseInput('abc')).toBe(false); + }); +}); diff --git a/tui/src/libs/terminal/__tests__/surfaceBorder.test.ts b/tui/src/libs/terminal/__tests__/surfaceBorder.test.ts new file mode 100644 index 00000000..ba4b80a6 --- /dev/null +++ b/tui/src/libs/terminal/__tests__/surfaceBorder.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveComposerBackgroundEnabled, + resolveComposerBorderColumns, + resolveMessageBorderGlyph, + resolveSurfaceBorderGlyph +} from '@libs/terminal/surfaceBorder.ts'; +import { + LOWER_HALF_BLOCK, + UPPER_HALF_BLOCK +} from '@libs/tui/backgroundBlock.ts'; + +describe('surface border policy', () => { + it('uses continuous horizontal lines in macOS Terminal.app', () => { + expect(resolveSurfaceBorderGlyph('top', 'Apple_Terminal')).toBe('─'); + expect(resolveSurfaceBorderGlyph('bottom', 'Apple_Terminal')).toBe('─'); + }); + + it('keeps inward half-blocks in other terminals', () => { + expect(resolveSurfaceBorderGlyph('top', 'ghostty')).toBe(LOWER_HALF_BLOCK); + expect(resolveSurfaceBorderGlyph('bottom', 'ghostty')).toBe(UPPER_HALF_BLOCK); + }); + + it('blends Terminal.app text rows into the terminal background', () => { + expect(resolveComposerBackgroundEnabled('Apple_Terminal')).toBe(false); + expect(resolveComposerBackgroundEnabled('ghostty')).toBe(true); + }); + + it('lets only Terminal.app decorative lines span the final-cell gutter', () => { + expect(resolveComposerBorderColumns(59, 60, 'Apple_Terminal')).toBe(60); + expect(resolveComposerBorderColumns(59, 60, 'ghostty')).toBe(59); + }); + + it('removes user-message borders only in Terminal.app', () => { + expect(resolveMessageBorderGlyph('top', 'Apple_Terminal')).toBeNull(); + expect(resolveMessageBorderGlyph('bottom', 'Apple_Terminal')).toBeNull(); + expect(resolveMessageBorderGlyph('top', 'ghostty')).toBe(LOWER_HALF_BLOCK); + expect(resolveMessageBorderGlyph('bottom', 'ghostty')).toBe(UPPER_HALF_BLOCK); + }); +}); diff --git a/tui/src/libs/terminal/alternateScreen.ts b/tui/src/libs/terminal/alternateScreen.ts index 6286a542..5eab5e4a 100644 --- a/tui/src/libs/terminal/alternateScreen.ts +++ b/tui/src/libs/terminal/alternateScreen.ts @@ -5,11 +5,9 @@ * The alternate buffer has no scrollback region, so while the TUI owns the * screen the terminal's native scrollbar has no prior history to scroll into — * the user stays pinned to the live UI instead of drifting into pre-launch - * output. This is independent of Ink's "fullscreen" detection: with - * `FULLSCREEN_GUARD_ROWS` now `0` the UI fills the alt buffer fullscreen, so - * `incrementalRendering` only avoids a per-keystroke repaint on terminals that - * skip the whole-screen clear on fullscreen frames (Windows Terminal is fine; - * WezTerm repaints and blinks). + * output. This is independent of Ink's "fullscreen" detection: KQode keeps the + * alternate buffer in WezTerm but reserves one row there so Ink can update + * incrementally without a visible whole-screen clear on every keystroke. */ export const ENTER_ALTERNATE_SCREEN_SEQUENCE = '\u001B[?1049h'; diff --git a/tui/src/libs/terminal/fullscreenGuard.ts b/tui/src/libs/terminal/fullscreenGuard.ts new file mode 100644 index 00000000..f33e41f9 --- /dev/null +++ b/tui/src/libs/terminal/fullscreenGuard.ts @@ -0,0 +1,16 @@ +import { + WEZTERM_FULLSCREEN_GUARD_ROWS, + WEZTERM_TERM_PROGRAM +} from '@constants/terminal.ts'; + +/** + * Returns the terminal-specific row reservation that keeps Ink off its + * fullscreen clear-and-repaint path. + */ +export function resolveFullscreenGuardRows( + termProgram = process.env.TERM_PROGRAM +): number { + return termProgram === WEZTERM_TERM_PROGRAM + ? WEZTERM_FULLSCREEN_GUARD_ROWS + : 0; +} diff --git a/tui/src/libs/terminal/hyperlink.ts b/tui/src/libs/terminal/hyperlink.ts new file mode 100644 index 00000000..4fe92bc7 --- /dev/null +++ b/tui/src/libs/terminal/hyperlink.ts @@ -0,0 +1,41 @@ +// OSC 8 hyperlink: `ESC ] 8 ; ; BEL ESC ] 8 ; ; BEL`. +// The params field is left empty; BEL (`\u0007`) terminates each OSC, matching +// the terminal's other OSC usage (see windowTitle.ts) and staying broadly +// compatible. +const OSC_HYPERLINK_INTRODUCER = '\u001B]8;;'; +const OSC_TERMINATOR = '\u0007'; + +/** + * Wraps `text` in an OSC 8 hyperlink pointing at `url`. + * + * Terminals that support OSC 8 (Windows Terminal, WezTerm, iTerm2, VTE) turn the + * enclosed text into a clickable link; terminals that don't simply render `text` + * unchanged. The sequence adds no printed columns, so callers can keep measuring + * width on the undecorated text. + */ +export function hyperlink(text: string, url: string): string { + return `${OSC_HYPERLINK_INTRODUCER}${url}${OSC_TERMINATOR}${text}${OSC_HYPERLINK_INTRODUCER}${OSC_TERMINATOR}`; +} + +// SGR `4` enables a standard underline; `4:3` upgrades it to a dotted style on +// terminals that support the sub-parameter. Leading with plain `4` matters: +// Ink's slice-ansi tracks the standard underline state and so preserves the +// matching `24` (underline off) reset, whereas a lone `4:3` gets its reset +// dropped and the underline bleeds past the label. +const UNDERLINE_ON = '\u001B[4m'; +const DOTTED_UNDERLINE_ON = '\u001B[4:3m'; +const UNDERLINE_OFF = '\u001B[24m'; + +/** + * Wraps `text` in a dotted underline as an always-visible "this is clickable" + * affordance. + * + * Emits a standard underline (`4`) followed by the dotted refinement (`4:3`): + * capable terminals (Windows Terminal, WezTerm, kitty) render dotted, others + * fall back to a straight underline, and either way the `24` reset stays paired + * so the underline never bleeds past `text`. Like {@link hyperlink}, the escapes + * add no printed columns. + */ +export function dottedUnderline(text: string): string { + return `${UNDERLINE_ON}${DOTTED_UNDERLINE_ON}${text}${UNDERLINE_OFF}`; +} diff --git a/tui/src/libs/terminal/mouse.ts b/tui/src/libs/terminal/mouse.ts index 7121c469..9255a23a 100644 --- a/tui/src/libs/terminal/mouse.ts +++ b/tui/src/libs/terminal/mouse.ts @@ -1,23 +1,59 @@ -export const ENABLE_SGR_MOUSE_TRACKING = '\u001B[?1000h\u001B[?1006h'; -export const DISABLE_SGR_MOUSE_TRACKING = '\u001B[?1006l\u001B[?1000l'; +// Button-event tracking reports press, release, and drag motion while a button +// is held. Extended coordinates keep positions unambiguous on larger terminals. +export const ENABLE_SGR_MOUSE_TRACKING = '\u001B[?1002h\u001B[?1006h'; +export const DISABLE_SGR_MOUSE_TRACKING = '\u001B[?1006l\u001B[?1002l'; type MouseWheelDirection = 'up' | 'down'; -const SGR_MOUSE_INPUT_PATTERN = /^(?:\u001B)?\[<(?\d+);\d+;\d+(?[mM])$/; +/** A parsed SGR mouse-wheel press with its 1-based pointer row. */ +export type MouseWheelEvent = { + direction: MouseWheelDirection; + /** 1-based terminal row (SGR Y coordinate) the pointer was on. */ + row: number; +}; + +/** A parsed SGR left-button press with its 1-based pointer position. */ +export type MouseClickEvent = { + row: number; + column: number; +}; + +export type MouseButtonEvent = MouseClickEvent & { + kind: 'press' | 'drag' | 'release'; +}; + +export type MouseInputEvent = + | ({ kind: 'wheel' } & MouseWheelEvent) + | MouseButtonEvent + | ({ kind: 'rightClick' } & MouseClickEvent); + +// SGR mouse reports are `ESC[\d+);(?\d+);(?\d+)(?[mM])/g; const WHEEL_BUTTON_OFFSET = 64; const WHEEL_BUTTON_COUNT = 4; +const LEFT_BUTTON_CODE = 0; +const RIGHT_BUTTON_CODE = 2; +const MOUSE_MOTION_BIT = 32; +const MOUSE_BUTTON_MASK = 0b11; export function isMouseInput(input: string): boolean { - return SGR_MOUSE_INPUT_PATTERN.test(input); + return parseMouseInputEvents(input) !== null; } -export function parseMouseWheelInput(input: string): MouseWheelDirection | null { - const match = SGR_MOUSE_INPUT_PATTERN.exec(input); - if (match?.groups === undefined || match.groups.eventType !== 'M') { +type SgrMouseGroups = { + buttonCode: string; + column: string; + row: string; + eventType: string; +}; + +function wheelEventFromGroups(groups: SgrMouseGroups): MouseWheelEvent | null { + if (groups.eventType !== 'M') { return null; } - const buttonCode = Number.parseInt(match.groups.buttonCode, 10); + const buttonCode = Number.parseInt(groups.buttonCode, 10); if (buttonCode < WHEEL_BUTTON_OFFSET) { return null; } @@ -25,13 +61,68 @@ export function parseMouseWheelInput(input: string): MouseWheelDirection | null // SGR mouse encodes wheel events starting at button 64; modulo strips any // modifier bits while keeping 0/1 as vertical wheel up/down. const wheelButton = (buttonCode - WHEEL_BUTTON_OFFSET) % WHEEL_BUTTON_COUNT; - if (wheelButton === 0) { - return 'up'; + const direction = wheelButton === 0 ? 'up' : wheelButton === 1 ? 'down' : null; + if (direction === null) { + return null; } - if (wheelButton === 1) { - return 'down'; + return { direction, row: Number.parseInt(groups.row, 10) }; +} + +/** + * Parses a chunk made entirely of SGR mouse reports. Unsupported reports are + * recognized but omitted from the actionable event list. + */ +export function parseMouseInputEvents(input: string): MouseInputEvent[] | null { + if (input.length === 0) { + return null; } - return null; + const events: MouseInputEvent[] = []; + let consumed = 0; + for (const match of input.matchAll(SGR_MOUSE_INPUT_PATTERN_ALL)) { + if (match.groups === undefined || match.index !== consumed) { + return null; + } + const groups = match.groups as SgrMouseGroups; + const wheel = wheelEventFromGroups(groups); + if (wheel !== null) { + events.push({ kind: 'wheel', ...wheel }); + consumed += match[0].length; + continue; + } + + const buttonCode = Number.parseInt(groups.buttonCode, 10); + if ((buttonCode & WHEEL_BUTTON_OFFSET) !== 0) { + consumed += match[0].length; + continue; + } + + const button = buttonCode & MOUSE_BUTTON_MASK; + if (button === LEFT_BUTTON_CODE) { + const kind: MouseButtonEvent['kind'] = + groups.eventType === 'm' + ? 'release' + : (buttonCode & MOUSE_MOTION_BIT) !== 0 + ? 'drag' + : 'press'; + events.push({ + kind, + row: Number.parseInt(groups.row, 10), + column: Number.parseInt(groups.column, 10) + }); + } else if ( + groups.eventType === 'M' && + button === RIGHT_BUTTON_CODE && + (buttonCode & MOUSE_MOTION_BIT) === 0 + ) { + events.push({ + kind: 'rightClick', + row: Number.parseInt(groups.row, 10), + column: Number.parseInt(groups.column, 10) + }); + } + consumed += match[0].length; + } + return consumed === input.length ? events : null; } diff --git a/tui/src/libs/terminal/surfaceBorder.ts b/tui/src/libs/terminal/surfaceBorder.ts new file mode 100644 index 00000000..8612e73f --- /dev/null +++ b/tui/src/libs/terminal/surfaceBorder.ts @@ -0,0 +1,48 @@ +import { + LOWER_HALF_BLOCK, + UPPER_HALF_BLOCK +} from '@libs/tui/backgroundBlock.ts'; + +export type SurfaceBorderEdge = 'top' | 'bottom'; + +const APPLE_TERMINAL_PROGRAM = 'Apple_Terminal'; +const HORIZONTAL_LINE = '─'; + +/** Terminal-specific composer border glyph without changing its row budget. */ +export function resolveSurfaceBorderGlyph( + edge: SurfaceBorderEdge, + termProgram = process.env.TERM_PROGRAM +): string { + if (termProgram === APPLE_TERMINAL_PROGRAM) { + return HORIZONTAL_LINE; + } + return edge === 'top' ? LOWER_HALF_BLOCK : UPPER_HALF_BLOCK; +} + +/** Terminal.app user messages use only their filled text rows, without borders. */ +export function resolveMessageBorderGlyph( + edge: SurfaceBorderEdge, + termProgram = process.env.TERM_PROGRAM +): string | null { + return termProgram === APPLE_TERMINAL_PROGRAM + ? null + : resolveSurfaceBorderGlyph(edge, termProgram); +} + +/** Terminal.app uses line borders without a distinct filled composer surface. */ +export function resolveComposerBackgroundEnabled( + termProgram = process.env.TERM_PROGRAM +): boolean { + return termProgram !== APPLE_TERMINAL_PROGRAM; +} + +/** Terminal.app may extend decorative lines through the final-cell gutter. */ +export function resolveComposerBorderColumns( + composerColumns: number, + terminalColumns: number, + termProgram = process.env.TERM_PROGRAM +): number { + return termProgram === APPLE_TERMINAL_PROGRAM + ? Math.max(1, terminalColumns) + : Math.max(1, composerColumns); +} diff --git a/tui/src/libs/text/displayWidth.ts b/tui/src/libs/text/displayWidth.ts new file mode 100644 index 00000000..e49c2abc --- /dev/null +++ b/tui/src/libs/text/displayWidth.ts @@ -0,0 +1,134 @@ +import stringWidth from 'string-width'; + +/** One grapheme cluster paired with its terminal display width in columns. */ +export type MeasuredGrapheme = { + segment: string; + width: number; + start: number; + end: number; +}; + +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + +/** Terminal display width of `text`, using the same measurement as Ink. */ +export function displayWidth(text: string): number { + return stringWidth(text); +} + +/** Splits `text` into grapheme clusters with source offsets and display widths. */ +export function measureGraphemes(text: string): MeasuredGrapheme[] { + const measured: MeasuredGrapheme[] = []; + let start = 0; + for (const { segment } of graphemeSegmenter.segment(text)) { + const end = start + segment.length; + measured.push({ segment, width: stringWidth(segment), start, end }); + start = end; + } + return measured; +} + +/** Right-pads `text` to `width` display columns without truncating it. */ +export function padEndToWidth(text: string, width: number): string { + const padding = width - displayWidth(text); + return padding > 0 ? text + ' '.repeat(padding) : text; +} + +/** Truncates `text` to complete graphemes fitting within `width` columns. */ +export function truncateToWidth(text: string, width: number): string { + const safeWidth = Math.max(0, width); + let usedWidth = 0; + let end = 0; + for (const grapheme of measureGraphemes(text)) { + if (usedWidth + grapheme.width > safeWidth) { + break; + } + usedWidth += grapheme.width; + end = grapheme.end; + } + return text.slice(0, end); +} + +/** Previous grapheme boundary at or before `index`. */ +export function previousGraphemeStart(text: string, index: number): number { + const safeIndex = Math.max(0, Math.min(index, text.length)); + if (safeIndex === 0) { + return 0; + } + + let previousEnd = 0; + for (const grapheme of measureGraphemes(text)) { + if (safeIndex <= grapheme.start) { + return previousEnd; + } + if (safeIndex <= grapheme.end) { + return grapheme.start; + } + previousEnd = grapheme.end; + } + return previousEnd; +} + +/** Next grapheme boundary at or after `index`. */ +export function nextGraphemeEnd(text: string, index: number): number { + const safeIndex = Math.max(0, Math.min(index, text.length)); + for (const grapheme of measureGraphemes(text)) { + if (safeIndex < grapheme.end) { + return grapheme.end; + } + } + return text.length; +} + +/** Clamps `index` to the preceding grapheme boundary. */ +export function clampToGraphemeBoundary(text: string, index: number): number { + const safeIndex = Math.max(0, Math.min(index, text.length)); + if (safeIndex === 0 || safeIndex === text.length) { + return safeIndex; + } + + for (const grapheme of measureGraphemes(text)) { + if (safeIndex === grapheme.start || safeIndex === grapheme.end) { + return safeIndex; + } + if (safeIndex < grapheme.end) { + return grapheme.start; + } + } + return safeIndex; +} + +/** Display columns occupied before `index` within `text`. */ +export function displayWidthBeforeIndex(text: string, index: number): number { + const safeIndex = clampToGraphemeBoundary(text, index); + let width = 0; + for (const grapheme of measureGraphemes(text)) { + if (safeIndex <= grapheme.start) { + break; + } + width += grapheme.width; + if (safeIndex === grapheme.end) { + break; + } + } + return width; +} + +/** Grapheme boundary at display `column` within `text`. */ +export function indexAtDisplayColumn(text: string, column: number): number { + const safeColumn = Math.max(0, column); + let width = 0; + for (const grapheme of measureGraphemes(text)) { + if (safeColumn <= width) { + return grapheme.start; + } + const nextWidth = width + grapheme.width; + if (safeColumn < nextWidth) { + return grapheme.start; + } + if (safeColumn === nextWidth) { + return grapheme.end; + } + width = nextWidth; + } + return text.length; +} diff --git a/tui/src/libs/text/expandTabs.ts b/tui/src/libs/text/expandTabs.ts new file mode 100644 index 00000000..6db850ae --- /dev/null +++ b/tui/src/libs/text/expandTabs.ts @@ -0,0 +1,32 @@ +import { measureGraphemes } from '@libs/text/displayWidth.ts'; + +const DEFAULT_TAB_WIDTH = 4; + +/** Expands tabs to stable display cells, resetting tab stops after newlines. */ +export function expandTabs( + text: string, + tabWidth: number = DEFAULT_TAB_WIDTH +): string { + const safeTabWidth = Math.max(1, tabWidth); + let column = 0; + let expanded = ''; + + for (const grapheme of measureGraphemes(text)) { + if (grapheme.segment === '\n') { + expanded += '\n'; + column = 0; + continue; + } + if (grapheme.segment === '\t') { + const spaces = safeTabWidth - (column % safeTabWidth); + expanded += ' '.repeat(spaces); + column += spaces; + continue; + } + + expanded += grapheme.segment; + column += grapheme.width; + } + + return expanded; +} diff --git a/tui/src/libs/tui/__tests__/bodyRows.test.ts b/tui/src/libs/tui/__tests__/bodyRows.test.ts index 4d76de63..092798ab 100644 --- a/tui/src/libs/tui/__tests__/bodyRows.test.ts +++ b/tui/src/libs/tui/__tests__/bodyRows.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { BodyEntryKind } from '@constants/bodyEntry.ts'; import { resolveBodyRows } from '@libs/tui/bodyRows.ts'; +import { displayWidth } from '@libs/text/displayWidth.ts'; const WIDE_COLUMNS = 80; const TALL_ROWS = 40; @@ -39,10 +40,84 @@ describe('resolveBodyRows hard line breaks', () => { ); }); + it('renders user messages without decorative borders in Terminal.app', () => { + const previousTermProgram = process.env.TERM_PROGRAM; + process.env.TERM_PROGRAM = 'Apple_Terminal'; + try { + const rows = resolveBodyRows( + [{ kind: BodyEntryKind.User, text: 'hello' }], + WIDE_COLUMNS, + TALL_ROWS + ); + expect(rows).toHaveLength(1); + expect(`${rows[0]?.marker}${rows[0]?.text}`).toBe(' ❯ hello'); + } finally { + if (previousTermProgram === undefined) { + delete process.env.TERM_PROGRAM; + } else { + process.env.TERM_PROGRAM = previousTermProgram; + } + } + }); + it('still wraps a single long line to the available width', () => { const texts = rowTexts(BodyEntryKind.Success, 'x'.repeat(WIDE_COLUMNS + 5)); expect(texts.length).toBeGreaterThan(1); expect(texts.every((line) => line.length <= WIDE_COLUMNS)).toBe(true); }); + + it('wraps wide and multi-code-point graphemes by terminal display width', () => { + const text = `界界界${'👨‍💻'}a`; + const rows = resolveBodyRows( + [{ kind: BodyEntryKind.Success, text }], + 5, + TALL_ROWS + ); + + expect(rows.map((row) => row.text).join('')).toBe(text); + expect(rows.every((row) => displayWidth(row.text) <= 5)).toBe(true); + }); + + it('expands tabs before wrapping so selection columns match rendered cells', () => { + const rows = resolveBodyRows( + [{ kind: BodyEntryKind.Assistant, text: 'a\tb' }], + 12, + TALL_ROWS + ); + + expect(rows[0]?.text).toBe('a b'); + }); +}); + +describe('resolveBodyRows memoization', () => { + it('returns the same row objects for an unchanged entry and width', () => { + const entry = { kind: BodyEntryKind.Assistant, text: 'stable text' }; + + const first = resolveBodyRows([entry], WIDE_COLUMNS, TALL_ROWS); + const second = resolveBodyRows([entry], WIDE_COLUMNS, TALL_ROWS); + + expect(second[0]).toBe(first[0]); + expect(second).toEqual(first); + }); + + it('recomputes fresh rows when the column width changes', () => { + const entry = { kind: BodyEntryKind.Assistant, text: 'stable text' }; + + const wide = resolveBodyRows([entry], WIDE_COLUMNS, TALL_ROWS); + const narrow = resolveBodyRows([entry], 20, TALL_ROWS); + + expect(narrow[0]).not.toBe(wide[0]); + }); + + it('does not reuse rows across distinct entry objects with equal content', () => { + const a = { kind: BodyEntryKind.Assistant, text: 'same' }; + const b = { kind: BodyEntryKind.Assistant, text: 'same' }; + + const rowsA = resolveBodyRows([a], WIDE_COLUMNS, TALL_ROWS); + const rowsB = resolveBodyRows([b], WIDE_COLUMNS, TALL_ROWS); + + expect(rowsB[0]).not.toBe(rowsA[0]); + expect(rowsB).toEqual(rowsA); + }); }); diff --git a/tui/src/libs/tui/__tests__/cwdLine.test.ts b/tui/src/libs/tui/__tests__/cwdLine.test.ts new file mode 100644 index 00000000..bda0bc6b --- /dev/null +++ b/tui/src/libs/tui/__tests__/cwdLine.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import type { GitStatus } from '@contracts/backend/index.ts'; +import { + countCwdRows, + formatCwdLine, + pullRequestLabelOffset, + renderCwdLine +} from '@libs/tui/cwdLine.ts'; + +describe('formatCwdLine', () => { + it('joins the cwd and git status as plain text', () => { + expect(formatCwdLine('/tmp/x', { label: '⎇ main*' })).toContain('/tmp/x [⎇ main*]'); + }); + + it('appends the pull-request label as plain text', () => { + expect(formatCwdLine('/tmp/x', { label: '⎇ main*', pullRequestLabel: '#3' })).toBe( + '/tmp/x [⎇ main*] [#3]' + ); + }); + + it('sanitizes terminal controls from the cwd and git status', () => { + expect( + formatCwdLine('/tmp/\u001B[31mx', { + label: '⎇ main\u0007', + pullRequestLabel: '#3\u001B' + }) + ).toBe('/tmp/\\x1b[31mx [⎇ main\\x07] [#3\\x1b]'); + }); + + it('renders the pull-request label as a hyperlink when a url is present', () => { + expect( + renderCwdLine('/tmp/x', { + label: '⎇ main*', + pullRequestLabel: '#3', + pullRequestUrl: 'https://github.com/o/r/pull/3' + }) + ).toContain( + '\u001B]8;;https://github.com/o/r/pull/3\u0007\u001B[4m\u001B[4:3m#3\u001B[24m' + ); + }); + + it('escapes tabs and newlines so the cwd remains one logical row', () => { + expect(formatCwdLine('/tmp/a\nb', { label: '⎇ main\tbad' })).toBe( + '/tmp/a\\x0ab [⎇ main\\x09bad]' + ); + expect(countCwdRows('/tmp/a\nb', undefined, 80)).toBe(1); + }); +}); + +describe('countCwdRows', () => { + it('uses terminal display width for wide glyphs', () => { + expect(countCwdRows('/界界', undefined, 4)).toBe(2); + }); +}); + +describe('pullRequestLabelOffset', () => { + it('returns the display-column offset for the pull-request label', () => { + const gitStatus: GitStatus = { label: '⎇ 主', pullRequestLabel: '#3' }; + + expect(pullRequestLabelOffset('/界', gitStatus)).toBe(12); + }); +}); diff --git a/tui/src/libs/tui/__tests__/layout.test.ts b/tui/src/libs/tui/__tests__/layout.test.ts index f2df7d2e..a038459b 100644 --- a/tui/src/libs/tui/__tests__/layout.test.ts +++ b/tui/src/libs/tui/__tests__/layout.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; import { resolveHomeScreenLayout } from '@libs/tui/layout.ts'; +import { + COMPOSER_BACKGROUND_PADDING_ROWS, + COMPOSER_MAX_HEIGHT_DIVISOR +} from '@constants/ui.ts'; + +const COMPOSER_ERROR_RESERVE_ROWS = 1; describe('resolveHomeScreenLayout with a command menu', () => { it('reflows the body by the menu height when the transcript fills the pane', () => { @@ -15,3 +21,27 @@ describe('resolveHomeScreenLayout with a command menu', () => { expect(layout.bodyRows).toBeGreaterThanOrEqual(1); }); }); + +describe('resolveHomeScreenLayout composer height cap', () => { + it.each([24, 40, 15])('caps the composer box within floor(rows/2) at rows=%i', (rows) => { + const layout = resolveHomeScreenLayout(rows, 1000, 3, 1, 0); + const boxRows = + layout.composerVisibleRows + COMPOSER_BACKGROUND_PADDING_ROWS + COMPOSER_ERROR_RESERVE_ROWS; + + expect(boxRows).toBeLessThanOrEqual(Math.floor(rows / COMPOSER_MAX_HEIGHT_DIVISOR)); + expect(layout.composerVisibleRows).toBeGreaterThanOrEqual(1); + expect(layout.bodyRows).toBeGreaterThanOrEqual(1); + }); + + it('stops composer growth at the cap regardless of transcript length', () => { + const short = resolveHomeScreenLayout(24, 2, 3, 1, 0); + const long = resolveHomeScreenLayout(24, 100_000, 3, 1, 0); + const expected = + Math.floor(24 / COMPOSER_MAX_HEIGHT_DIVISOR) - + COMPOSER_BACKGROUND_PADDING_ROWS - + COMPOSER_ERROR_RESERVE_ROWS; + + expect(long.composerVisibleRows).toBe(short.composerVisibleRows); + expect(long.composerVisibleRows).toBe(expected); + }); +}); diff --git a/tui/src/libs/tui/__tests__/pullRequestClick.test.ts b/tui/src/libs/tui/__tests__/pullRequestClick.test.ts new file mode 100644 index 00000000..bd8c03f1 --- /dev/null +++ b/tui/src/libs/tui/__tests__/pullRequestClick.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import type { GitStatus } from '@contracts/backend/index.ts'; +import { pullRequestLabelOffset } from '@libs/tui/cwdLine.ts'; +import { resolvePullRequestClickTarget } from '@libs/tui/pullRequestClick.ts'; + +const url = 'https://github.com/o/r/pull/3'; +const workspaceCwd = '/tmp/proj'; +const gitStatus: GitStatus = { label: '⎇ main', pullRequestLabel: '#3', pullRequestUrl: url }; + +// The cwd line renders one row directly above the composer at a wide width, so +// its 0-based row is composerTop - 1 (1-based SGR row = composerTop). +const composerTop = 12; +const singleRow = { composerTop, cwdRows: 1, columns: 200, workspaceCwd, gitStatus }; +const labelStart = pullRequestLabelOffset(workspaceCwd, gitStatus) ?? -1; + +describe('resolvePullRequestClickTarget', () => { + it('returns the url for a click on the first character of the label', () => { + expect( + resolvePullRequestClickTarget({ ...singleRow, clickRow: composerTop, clickColumn: labelStart + 1 }) + ).toBe(url); + }); + + it('returns the url for a click on the last character of the label', () => { + expect( + resolvePullRequestClickTarget({ + ...singleRow, + clickRow: composerTop, + clickColumn: labelStart + gitStatus.pullRequestLabel!.length + }) + ).toBe(url); + }); + + it('misses the opening bracket just before the label', () => { + expect( + resolvePullRequestClickTarget({ ...singleRow, clickRow: composerTop, clickColumn: labelStart }) + ).toBeUndefined(); + }); + + it('misses the closing bracket just after the label', () => { + expect( + resolvePullRequestClickTarget({ + ...singleRow, + clickRow: composerTop, + clickColumn: labelStart + gitStatus.pullRequestLabel!.length + 1 + }) + ).toBeUndefined(); + }); + + it('misses a click on the composer row below the cwd line', () => { + expect( + resolvePullRequestClickTarget({ ...singleRow, clickRow: composerTop + 1, clickColumn: labelStart + 1 }) + ).toBeUndefined(); + }); + + it('returns undefined when the status has no PR url', () => { + expect( + resolvePullRequestClickTarget({ + ...singleRow, + gitStatus: { label: '⎇ main', pullRequestLabel: '#3' }, + clickRow: composerTop, + clickColumn: labelStart + 1 + }) + ).toBeUndefined(); + }); + + it('returns undefined when the cwd line is hidden (command palette open, cwdRows 0)', () => { + expect( + resolvePullRequestClickTarget({ ...singleRow, cwdRows: 0, clickRow: composerTop, clickColumn: labelStart + 1 }) + ).toBeUndefined(); + }); + + it('maps a click onto a wrapped row using the hard-wrap model', () => { + // Force the label onto the second visual row by making each row narrower + // than the label's offset, then derive the click from that wrap model. + const columns = Math.max(1, labelStart - 1); + const rowOffset = Math.floor(labelStart / columns); + const columnInRow = labelStart % columns; + const cwdRows = rowOffset + 1; + const clickRow = composerTop - cwdRows + rowOffset + 1; + + expect( + resolvePullRequestClickTarget({ + composerTop, + cwdRows, + columns, + workspaceCwd, + gitStatus, + clickRow, + clickColumn: columnInRow + 1 + }) + ).toBe(url); + }); + + it('does not map the reserved final-column gutter onto the next wrapped row', () => { + const columns = Math.max(1, labelStart); + const cwdRows = 2; + const firstRow = composerTop - cwdRows + 1; + + expect( + resolvePullRequestClickTarget({ + composerTop, + cwdRows, + columns, + workspaceCwd, + gitStatus, + clickRow: firstRow, + clickColumn: columns + 1 + }) + ).toBeUndefined(); + }); +}); diff --git a/tui/src/libs/tui/bodyRows.ts b/tui/src/libs/tui/bodyRows.ts index 477381b8..7402a4e2 100644 --- a/tui/src/libs/tui/bodyRows.ts +++ b/tui/src/libs/tui/bodyRows.ts @@ -1,8 +1,6 @@ -import { - LOWER_HALF_BLOCK, - UPPER_HALF_BLOCK -} from '@libs/tui/backgroundBlock.ts'; import { BodyEntryKind } from '@constants/bodyEntry.ts'; +import { wrapBodyLines } from '@libs/tui/wrapBodyLines.ts'; +import { resolveMessageBorderGlyph } from '@libs/terminal/surfaceBorder.ts'; import { theme } from '@theme/themeConfig.ts'; export type BodyEntry = { @@ -14,6 +12,8 @@ export type BodyEntry = { export type BodyRow = { backgroundColor?: string; color?: string; + continuesPrevious?: string; + decorative?: boolean; fillColumns?: boolean; marker?: string; markerColor?: string; @@ -53,7 +53,45 @@ function toBodyRowsWithEntryGaps(entries: readonly BodyEntry[], columns: number) return entries.flatMap((entry) => toBodyRows(entry, columns)); } +// Wrapping a `BodyEntry` depends only on its immutable kind/text and the column +// width, so memoize the rendered rows per entry identity and width. During +// streaming only the changed entry is a fresh object (new identity), so the rest +// of the transcript hits the cache instead of re-wrapping on every token/render; +// resizes and scrolls reuse it too. Entries are GC'd from the WeakMap once the +// transcript drops them (e.g. on `/clear`). +// NOTE: assumes `theme` colors are static for the process. If runtime theming is +// added, include the active theme in the cache key. +const MAX_CACHED_WIDTHS = 4; +const bodyRowsByEntry = new WeakMap>(); + function toBodyRows(entry: BodyEntry, columns: number): BodyRow[] { + let rowsByWidth = bodyRowsByEntry.get(entry); + if (rowsByWidth === undefined) { + rowsByWidth = new Map(); + bodyRowsByEntry.set(entry, rowsByWidth); + } + + const cached = rowsByWidth.get(columns); + if (cached !== undefined) { + return cached; + } + + // Bound the per-entry width cache so a continuous terminal resize (many + // distinct widths) cannot grow it without limit; the current and scrollbar + // widths always stay resident. + if (rowsByWidth.size >= MAX_CACHED_WIDTHS) { + const oldest = rowsByWidth.keys().next().value; + if (oldest !== undefined) { + rowsByWidth.delete(oldest); + } + } + + const rows = computeBodyRows(entry, columns); + rowsByWidth.set(columns, rows); + return rows; +} + +function computeBodyRows(entry: BodyEntry, columns: number): BodyRow[] { if (entry.kind === BodyEntryKind.User) { return toPromptRows(entry.text, columns); } @@ -62,21 +100,23 @@ function toBodyRows(entry: BodyEntry, columns: number): BodyRow[] { return toAssistantRows(entry.text, columns); } - return wrapBodyText(labelForEntry(entry), columns).map((text) => ({ + return wrapBodyLines(labelForEntry(entry), columns).map((line) => ({ color: colorForEntry(entry.kind), - text + continuesPrevious: line.continuesPrevious, + text: line.text })); } function toAssistantRows(text: string, columns: number): BodyRow[] { const continuationPrefix = ' '.repeat(ASSISTANT_MESSAGE_PREFIX.length); - const wrappedText = wrapBodyText(text, Math.max(1, columns - ASSISTANT_MESSAGE_PREFIX.length)); + const wrappedText = wrapBodyLines(text, Math.max(1, columns - ASSISTANT_MESSAGE_PREFIX.length)); return wrappedText.map((line, index) => ({ color: theme.colors.foreground, + continuesPrevious: line.continuesPrevious, marker: index === 0 ? ASSISTANT_MESSAGE_PREFIX : continuationPrefix, markerColor: index === 0 ? theme.colors.accentBlue : theme.colors.foreground, - text: line + text: line.text })); } @@ -86,29 +126,37 @@ function toPromptRows(text: string, columns: number): BodyRow[] { // continuation rows replace the visible prefix with spaces to align wrapped text. const textColumns = Math.max(1, columns - promptIndent - USER_MESSAGE_HORIZONTAL_PADDING); const continuationPrefix = ' '.repeat(promptIndent); - const wrappedText = wrapBodyText(text, textColumns); + const wrappedText = wrapBodyLines(text, textColumns); const textRows = wrappedText.map((line, index) => ({ backgroundColor: theme.colors.messageBackground, color: theme.colors.foreground, + continuesPrevious: line.continuesPrevious, fillColumns: true, - text: `${index === 0 ? promptPrefix() : continuationPrefix}${line}` + marker: index === 0 ? promptPrefix() : continuationPrefix, + markerColor: theme.colors.foreground, + text: line.text })); return [ - halfLineRow(columns, LOWER_HALF_BLOCK), + surfaceBorderRow(columns, 'top'), ...textRows, - halfLineRow(columns, UPPER_HALF_BLOCK) - ]; + surfaceBorderRow(columns, 'bottom') + ].filter((row): row is BodyRow => row !== null); } function promptPrefix(): string { return `${' '.repeat(USER_MESSAGE_HORIZONTAL_PADDING)}${USER_MESSAGE_PREFIX}`; } -function halfLineRow(columns: number, glyph: string): BodyRow { +function surfaceBorderRow(columns: number, edge: 'top' | 'bottom'): BodyRow | null { + const glyph = resolveMessageBorderGlyph(edge); + if (glyph === null) { + return null; + } return { backgroundColor: theme.colors.bodyBackground, color: theme.colors.messageBackground, + decorative: true, text: glyph.repeat(columns) }; } @@ -139,25 +187,3 @@ function labelForEntry(entry: BodyEntry): string { return entry.text; } - -// Splits on hard line breaks (`\n`, normalizing `\r\n`/`\r` first) so multi-line -// backend output, errors, and prompts all keep their author-intended rows, then -// wraps each line to `columns`. The display sanitizer preserves `\n` as a real -// layout character, so newlines here are trusted content rather than escaped. -function wrapBodyText(text: string, columns: number): string[] { - const wrappedRows: string[] = []; - const hardLines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); - - for (const line of hardLines) { - if (line.length === 0) { - wrappedRows.push(''); - continue; - } - - for (let start = 0; start < line.length; start += columns) { - wrappedRows.push(line.slice(start, start + columns)); - } - } - - return wrappedRows; -} diff --git a/tui/src/libs/tui/bodyWindow.ts b/tui/src/libs/tui/bodyWindow.ts new file mode 100644 index 00000000..97e004cf --- /dev/null +++ b/tui/src/libs/tui/bodyWindow.ts @@ -0,0 +1,29 @@ +import { clamp } from '@libs/math/clamp.ts'; +import { resolveBodyRows, type BodyEntry, type BodyRow } from '@libs/tui/bodyRows.ts'; + +export type BodyRowWindow = { + allRows: BodyRow[]; + startIndex: number; + visibleRows: BodyRow[]; +}; + +/** Resolves the wrapped body rows and visible scroll window from shared inputs. */ +export function resolveBodyRowWindow( + entries: readonly BodyEntry[], + columns: number, + rows: number, + scrollOffsetRows: number +): BodyRowWindow { + const visibleRows = Math.max(1, rows); + const allRows = resolveBodyRows(entries, Math.max(1, columns), visibleRows); + const maxScrollOffset = Math.max(0, allRows.length - visibleRows); + const scrollOffset = clamp(scrollOffsetRows, 0, maxScrollOffset); + const end = allRows.length - scrollOffset; + const startIndex = Math.max(0, end - visibleRows); + + return { + allRows, + startIndex, + visibleRows: allRows.slice(startIndex, end) + }; +} diff --git a/tui/src/libs/tui/cwdLine.ts b/tui/src/libs/tui/cwdLine.ts index 7f2ce8af..03d44b18 100644 --- a/tui/src/libs/tui/cwdLine.ts +++ b/tui/src/libs/tui/cwdLine.ts @@ -1,15 +1,88 @@ import os from 'node:os'; import path from 'node:path'; +import type { GitStatus } from '@contracts/backend/index.ts'; +import { displayWidth } from '@libs/text/displayWidth.ts'; +import { sanitizeDisplayText } from '@libs/text/sanitizeDisplayText.ts'; +import { dottedUnderline, hyperlink } from '@libs/terminal/hyperlink.ts'; -export function formatCwdLine(workspaceCwd: string, gitStatusLabel?: string): string { - const cwdSegment = formatDisplayCwd(workspaceCwd); - const gitSegment = gitStatusLabel === undefined ? '' : ` [${gitStatusLabel}]`; +/** The cwd + git-label prefix shared by the plain and decorated renderings. */ +function cwdAndGitPrefix(workspaceCwd: string, gitStatus?: GitStatus): string { + const cwdSegment = sanitizeSingleLine(formatDisplayCwd(workspaceCwd)); + const gitSegment = + gitStatus === undefined ? '' : ` [${sanitizeSingleLine(gitStatus.label)}]`; return `${cwdSegment}${gitSegment}`; } -export function countCwdRows(workspaceCwd: string, gitStatusLabel: string | undefined, columns: number): number { +/** + * The cwd row as plain text (no escape sequences), e.g. `~/proj [⎇ main*] [#3]`. + * + * Width and wrap math ({@link countCwdRows}) measure this string; + * {@link renderCwdLine} produces the display string, which has the same visible + * characters plus zero-width decoration, so both agree on the row's width. + */ +export function formatCwdLine(workspaceCwd: string, gitStatus?: GitStatus): string { + const pullRequestSegment = + gitStatus?.pullRequestLabel === undefined + ? '' + : ` [${sanitizeSingleLine(gitStatus.pullRequestLabel)}]`; + return `${cwdAndGitPrefix(workspaceCwd, gitStatus)}${pullRequestSegment}`; +} + +/** + * The cwd row for display: identical visible text to {@link formatCwdLine}, but + * when the status carries a pull-request URL the PR label (e.g. `#3`) is wrapped + * in a dotted underline and an OSC 8 hyperlink so supporting terminals render it + * as a clickable link. Only zero-width escapes are added, so {@link formatCwdLine} + * stays the source of truth for width. + */ +export function renderCwdLine(workspaceCwd: string, gitStatus?: GitStatus): string { + return `${cwdAndGitPrefix(workspaceCwd, gitStatus)}${renderPullRequestSegment(gitStatus)}`; +} + +/** + * The ` [#3]` pull-request segment for display. The label is decorated (dotted + * underline + OSC 8 link) only when a URL is present, so a bare label never + * implies a link that cannot be opened. + */ +function renderPullRequestSegment(gitStatus?: GitStatus): string { + const label = gitStatus?.pullRequestLabel; + if (label === undefined) { + return ''; + } + const sanitizedLabel = sanitizeSingleLine(label); + const url = gitStatus?.pullRequestUrl; + const rendered = + url === undefined ? sanitizedLabel : hyperlink(dottedUnderline(sanitizedLabel), url); + return ` [${rendered}]`; +} + +/** + * The visible column offset at which the pull-request label (e.g. `#3`) starts + * within {@link formatCwdLine}, or `undefined` when no PR label is shown. + * + * The click router uses it to map a pointer position back to the label's span. + * The PR segment is ` [