Security updates + terminal URL, /tmp file-link and copy fixes #68
No files matched your search
@@ -61,6 +61,13 @@ name: Build App (Preview)
|
||||
# for real releases. See triple-c#32.)
|
||||
|
||||
env:
|
||||
# The ephemeral Proxmox runners (eph-pve3-*) ship needrestart, which after
|
||||
# any apt-get install stops at an interactive "Daemons using outdated
|
||||
# libraries" prompt and hangs the job until it times out. `sudo` resets the
|
||||
# environment, so the apt-get calls below also pass these via `sudo env`;
|
||||
# here they reach the NodeSource setup script through `sudo -E`.
|
||||
NEEDRESTART_MODE: a
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
# How many preview releases survive a run, newest first — including the one
|
||||
@@ -262,7 +269,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
node --version
|
||||
@@ -273,8 +280,8 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
@@ -344,7 +351,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
node --version
|
||||
@@ -367,8 +374,8 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
@@ -655,7 +662,7 @@ jobs:
|
||||
where node >nul 2>&1 && (
|
||||
node --version
|
||||
) || (
|
||||
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi"
|
||||
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.23.4/node-v22.23.4-x64.msi"
|
||||
msiexec /i node-install.msi /quiet /norestart
|
||||
del node-install.msi
|
||||
)
|
||||
|
||||
@@ -17,6 +17,13 @@ on:
|
||||
# was unreachable. Previews now carry the PR check; this workflow is releases.
|
||||
|
||||
env:
|
||||
# The ephemeral Proxmox runners (eph-pve3-*) ship needrestart, which after
|
||||
# any apt-get install stops at an interactive "Daemons using outdated
|
||||
# libraries" prompt and hangs the job until it times out. `sudo` resets the
|
||||
# environment, so the apt-get calls below also pass these via `sudo env`;
|
||||
# here they reach the NodeSource setup script through `sudo -E`.
|
||||
NEEDRESTART_MODE: a
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
|
||||
@@ -118,7 +125,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
echo "Node.js at: $(which node)"
|
||||
@@ -140,8 +147,8 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
@@ -596,7 +603,7 @@ jobs:
|
||||
where node >nul 2>&1 && (
|
||||
node --version
|
||||
) || (
|
||||
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi"
|
||||
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.23.4/node-v22.23.4-x64.msi"
|
||||
msiexec /i node-install.msi /quiet /norestart
|
||||
del node-install.msi
|
||||
)
|
||||
|
||||
@@ -55,5 +55,10 @@ jobs:
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
ghcr.io/shadowdao/triple-c-stt:latest
|
||||
ghcr.io/shadowdao/triple-c-stt:${{ gitea.sha }}
|
||||
# The gha cache is served by the runner that picks the job up, and
|
||||
# is unreachable from the job on some of ours (the home box's
|
||||
# firewalled per-job bridge; ephemeral runners). Import failures
|
||||
# only warn, but an export failure fails the build unless told not
|
||||
# to -- the same setting build.yml already carries.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max,ignore-error=true
|
||||
@@ -11,6 +11,11 @@ on:
|
||||
paths:
|
||||
- "container/**"
|
||||
- ".gitea/workflows/build.yml"
|
||||
# Weekly rebuild with no commit needed, so OS security fixes published since
|
||||
# the last push reach the image (see APT_REFRESH in container/Dockerfile).
|
||||
# Scheduled runs use the default branch, i.e. main.
|
||||
schedule:
|
||||
- cron: "17 4 * * 1"
|
||||
|
||||
env:
|
||||
REGISTRY: repo.anhonesthost.net
|
||||
@@ -64,13 +69,23 @@ jobs:
|
||||
username: shadowdao
|
||||
password: ${{ secrets.GH_PAT }}
|
||||
|
||||
# Changes once a week, which invalidates the cached `apt-get upgrade`
|
||||
# layer (container/Dockerfile) so later security fixes are picked up.
|
||||
- name: Compute the apt refresh stamp
|
||||
id: apt
|
||||
run: echo "stamp=$(date -u +%G-W%V)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push container image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./container
|
||||
file: ./container/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ gitea.event_name == 'push' }}
|
||||
# Publish from main pushes and the weekly scheduled rebuild (which
|
||||
# runs on main); a pull_request run only validates.
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
build-args: |
|
||||
APT_REFRESH=${{ steps.apt.outputs.stamp }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
|
||||
@@ -73,6 +73,11 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
|
||||
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
|
||||
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
||||
- **Copy paths** (`copyCleanup.ts`, `osc52.ts`). OSC 52 base64 is UTF-8 *bytes* — never hand
|
||||
`atob()`'s output to the clipboard (that was the `’` mojibake); OSC 52 text then gets only
|
||||
`sanitizeInvisibles`. Ctrl+Shift+C / "Copy trimmed" run `cleanSelection`, which also undoes
|
||||
Claude Code's hard wraps (it wraps its own output and moves the cursor, so rows are never
|
||||
`isWrapped`) using `term.cols` and the selection's start column. Ctrl+Shift+Alt+C stays raw.
|
||||
- **`viewer/`** — the terminal file viewer's window (second Vite entry `viewer.html` →
|
||||
`src/viewer/main.tsx`; CodeMirror 6). `lib/filePathLinks.ts` decides what a path is;
|
||||
`components/terminal/filePathLinkProvider.ts` registers it with xterm. The OSC 8 handler now
|
||||
@@ -83,6 +88,11 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
lookup silently fall back to `index.html`, so the window just opens the *main app*, full UI and
|
||||
all, with no error anywhere; `file_viewer::tests::the_viewer_entry_exists_and_is_a_vite_input`
|
||||
in `file_viewer/mod.rs` is the only thing pinning this.
|
||||
**Claude Code wraps its own output, so xterm's `isWrapped` misses most wrapped paths.** A word
|
||||
longer than the line (a `/tmp/claude-<uid>/…` scratchpad path) is broken mid-word and the rest
|
||||
drawn on the next row with cursor moves. `lib/xtermLineJoin.ts` bridges that when given `cols`
|
||||
(row runs to the edge, next row continues with path characters, and the two runs could not
|
||||
have fit on one line — the last test is what keeps prose from being glued together).
|
||||
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
|
||||
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
|
||||
and the editors reused by Project Home
|
||||
@@ -172,7 +182,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`,
|
||||
`auth_token_commands.rs`.
|
||||
- **`file_viewer/`** — one window per click (`file-viewer-<n>`), a managed `ViewerRegistry`,
|
||||
resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`,
|
||||
resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`
|
||||
(`~/p` is `/home/claude/p`; a relative miss gets a second exec at `/home/claude/<p>`, `/tmp/<p>`),
|
||||
polling by `sha256sum`, saves staged in `/tmp` and swapped in by a `sh` script as the container
|
||||
user (spec §5 says why the archive API never writes to the target directory). Commands take
|
||||
`window: tauri::Window`, gate on the label and act on the caller's own registry entry — no
|
||||
|
||||
Generated
+207
-190
@@ -124,13 +124,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -139,9 +139,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
||||
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -149,21 +149,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-compilation-targets": "^7.29.7",
|
||||
"@babel/helper-module-transforms": "^7.29.7",
|
||||
"@babel/helpers": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
@@ -180,14 +180,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"version": "7.29.12",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz",
|
||||
"integrity": "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/parser": "^7.29.12",
|
||||
"@babel/types": "^7.29.11",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -197,14 +197,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"@babel/compat-data": "^7.29.7",
|
||||
"@babel/helper-validator-option": "^7.29.7",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
@@ -214,9 +214,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -224,29 +224,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/traverse": "^7.28.6"
|
||||
"@babel/helper-module-imports": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -266,9 +266,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.11",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz",
|
||||
"integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -276,9 +276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -286,9 +286,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-option": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -296,27 +296,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
|
||||
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
|
||||
"version": "7.29.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz",
|
||||
"integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
||||
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
||||
"version": "7.29.12",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz",
|
||||
"integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.11"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -368,33 +368,33 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"version": "7.29.12",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.12.tgz",
|
||||
"integrity": "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.12",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.12",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.11",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -402,14 +402,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.11",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz",
|
||||
"integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.11",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -2526,31 +2526,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
||||
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.0.18",
|
||||
"@vitest/utils": "4.0.18",
|
||||
"chai": "^6.2.1",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
|
||||
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.0.18",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -2559,7 +2559,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0-0"
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
@@ -2571,26 +2571,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
|
||||
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
|
||||
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.0.3"
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
|
||||
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.0.18",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -2598,13 +2598,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
|
||||
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -2613,9 +2614,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
|
||||
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2623,14 +2624,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
|
||||
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
@@ -2759,9 +2761,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||
"version": "2.11.34",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz",
|
||||
"integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -2782,9 +2784,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||
"version": "4.29.5",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz",
|
||||
"integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2802,11 +2804,11 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
"electron-to-chromium": "^1.5.263",
|
||||
"node-releases": "^2.0.27",
|
||||
"update-browserslist-db": "^1.2.0"
|
||||
"baseline-browser-mapping": "^2.11.33",
|
||||
"caniuse-lite": "^1.0.30001819",
|
||||
"electron-to-chromium": "^1.5.464",
|
||||
"node-releases": "^2.0.60",
|
||||
"update-browserslist-db": "^1.3.4"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -2816,9 +2818,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001774",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
|
||||
"integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
|
||||
"version": "1.0.30001820",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001820.tgz",
|
||||
"integrity": "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2837,9 +2839,9 @@
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2981,9 +2983,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.302",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
|
||||
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
|
||||
"version": "1.5.472",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.472.tgz",
|
||||
"integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -3015,9 +3017,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -3616,9 +3618,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3635,11 +3637,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"version": "2.0.63",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz",
|
||||
"integrity": "sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
@@ -3680,9 +3685,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3693,9 +3698,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.32",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
|
||||
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3713,9 +3718,9 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.20",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
@@ -3899,9 +3904,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
@@ -3916,9 +3921,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
|
||||
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -4004,9 +4009,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
|
||||
"integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4074,9 +4079,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
||||
"version": "7.30.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
|
||||
"integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4084,9 +4089,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz",
|
||||
"integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4115,9 +4120,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz",
|
||||
"integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4190,31 +4195,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
|
||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.18",
|
||||
"@vitest/mocker": "4.0.18",
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"@vitest/runner": "4.0.18",
|
||||
"@vitest/snapshot": "4.0.18",
|
||||
"@vitest/spy": "4.0.18",
|
||||
"@vitest/utils": "4.0.18",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"expect-type": "^1.2.2",
|
||||
"@vitest/expect": "4.1.12",
|
||||
"@vitest/mocker": "4.1.12",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/runner": "4.1.12",
|
||||
"@vitest/snapshot": "4.1.12",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"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": "^3.10.0",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.0.3",
|
||||
"vite": "^6.0.0 || ^7.0.0",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -4230,12 +4235,15 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.0.18",
|
||||
"@vitest/browser-preview": "4.0.18",
|
||||
"@vitest/browser-webdriverio": "4.0.18",
|
||||
"@vitest/ui": "4.0.18",
|
||||
"@vitest/browser-playwright": "4.1.12",
|
||||
"@vitest/browser-preview": "4.1.12",
|
||||
"@vitest/browser-webdriverio": "^4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.12",
|
||||
"@vitest/coverage-v8": "4.1.12",
|
||||
"@vitest/ui": "4.1.12",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
@@ -4256,6 +4264,12 @@
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -4264,6 +4278,9 @@
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+114
-56
@@ -26,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -84,9 +84,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
@@ -105,7 +105,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
@@ -674,6 +674,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -816,6 +827,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -1367,16 +1387,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
version = "5.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
@@ -1768,11 +1787,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1782,10 +1799,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3742,9 +3762,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
@@ -4303,7 +4323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
|
||||
dependencies = [
|
||||
"phf_shared 0.10.0",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4313,7 +4333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4433,9 +4453,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.13.0",
|
||||
@@ -4491,7 +4511,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
@@ -4661,9 +4681,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -4690,15 +4710,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.1",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg 0.10.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -4721,7 +4742,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4750,14 +4771,14 @@ dependencies = [
|
||||
"rand_chacha 0.2.2",
|
||||
"rand_core 0.5.1",
|
||||
"rand_hc",
|
||||
"rand_pcg",
|
||||
"rand_pcg 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
@@ -4766,14 +4787,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@@ -4831,6 +4863,12 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@@ -4849,6 +4887,15 @@ dependencies = [
|
||||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -5071,14 +5118,14 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
@@ -5129,7 +5176,7 @@ dependencies = [
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5140,9 +5187,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -5517,7 +5564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -5534,7 +5581,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -5608,12 +5655,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5764,6 +5811,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
@@ -5850,9 +5908,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.44"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -6169,10 +6227,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.1",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6294,9 +6352,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.49.0"
|
||||
version = "1.54.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -6311,13 +6369,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.0"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6570,7 +6628,7 @@ dependencies = [
|
||||
"keyring",
|
||||
"local-ip-address",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6606,7 +6664,7 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
@@ -7045,18 +7103,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -7119,7 +7177,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::file_viewer::poll::{poll_file, ViewerPoll};
|
||||
use crate::file_viewer::registry::{
|
||||
Choice, Location, Reservation, ViewerRegistry, ViewerTarget, ViewerTargetState,
|
||||
};
|
||||
use crate::file_viewer::resolve::{candidate_paths, probe_candidates};
|
||||
use crate::file_viewer::resolve::{candidate_paths, fallback_candidate_paths, probe_candidates};
|
||||
use crate::file_viewer::window::open_viewer_window;
|
||||
use crate::file_viewer::write::{sha256_hex, write_file, SavedFile, MAX_WRITE_BYTES};
|
||||
use crate::models::Project;
|
||||
@@ -159,8 +159,17 @@ pub async fn open_file_viewer(
|
||||
let container_id = running_container_of(&project, "opening files").await?;
|
||||
|
||||
let mounts: Vec<String> = project.paths.iter().map(|p| p.mount_name.clone()).collect();
|
||||
let candidates = candidate_paths(&path, &mounts)?;
|
||||
let matches = probe_candidates(&container_id, &candidates).await?;
|
||||
let mut candidates = candidate_paths(&path, &mounts)?;
|
||||
let mut matches = probe_candidates(&container_id, &candidates).await?;
|
||||
// Not in the project: try home and /tmp, as a second exec so a project file
|
||||
// never becomes a chooser because a namesake sits in /tmp.
|
||||
if matches.is_empty() {
|
||||
let fallbacks = fallback_candidate_paths(&path);
|
||||
if !fallbacks.is_empty() {
|
||||
matches = probe_candidates(&container_id, &fallbacks).await?;
|
||||
candidates.extend(fallbacks);
|
||||
}
|
||||
}
|
||||
let initial = Location { line, col, end_line };
|
||||
|
||||
let target_state = match matches.len() {
|
||||
|
||||
@@ -63,6 +63,7 @@ use tauri::State;
|
||||
use crate::docker::client::get_docker;
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::docker::exec::{exec_oneshot_env, exec_oneshot_env_status};
|
||||
use crate::docker::CONTAINER_HOME;
|
||||
use crate::AppState;
|
||||
|
||||
/// Newest N session transcripts to inspect. Caps the work done inside the
|
||||
@@ -72,8 +73,6 @@ const MAX_SESSIONS: usize = 50;
|
||||
/// Newest N scheduler notifications to return.
|
||||
const MAX_NOTIFICATIONS: usize = 50;
|
||||
|
||||
const CONTAINER_HOME: &str = "/home/claude";
|
||||
|
||||
/// Caps on the free-text fields of a scheduled task. They exist to keep a
|
||||
/// runaway paste out of the container's task JSON and out of the `docker exec`
|
||||
/// payload; they are generous enough for a real prompt.
|
||||
|
||||
@@ -124,6 +124,16 @@ Do not change `sandbox.enabled` in `settings.json` — Triple-C overwrites it
|
||||
on every container start. To turn sandbox off, stop the container in
|
||||
Triple-C, flip the "Sandbox mode" switch off, then start the container."#;
|
||||
|
||||
/// The Triple-C terminal opens a printed path on click (Shift/Option+click while
|
||||
/// a program holds the mouse — `opensOnClick` in TerminalView) by looking for it
|
||||
/// in the project, then `~` and `/tmp`. A relative name for a file elsewhere
|
||||
/// (Claude Code's scratchpad under `/tmp/claude-<uid>/...` above all) cannot
|
||||
/// be found, so ask for the whole path. Part of the instructions fingerprint,
|
||||
/// so adding or changing it recreates each container once on its next start.
|
||||
const FILE_LINK_INSTRUCTIONS: &str = r#"## Referring to files
|
||||
|
||||
The user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened."#;
|
||||
|
||||
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
|
||||
/// instructions, appending port mapping docs, and appending scheduler docs.
|
||||
/// Used by both create_container() and container_needs_recreation() to ensure
|
||||
@@ -163,6 +173,11 @@ fn build_claude_instructions(
|
||||
None => SCHEDULER_INSTRUCTIONS.to_string(),
|
||||
});
|
||||
|
||||
combined = Some(match combined {
|
||||
Some(existing) => format!("{}\n\n{}", existing, FILE_LINK_INSTRUCTIONS),
|
||||
None => FILE_LINK_INSTRUCTIONS.to_string(),
|
||||
});
|
||||
|
||||
if sandbox_enabled {
|
||||
combined = Some(match combined {
|
||||
Some(existing) => format!("{}\n\n{}", existing, SANDBOX_INSTRUCTIONS),
|
||||
@@ -4137,6 +4152,31 @@ mod tests {
|
||||
const HAIKU: &str = ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
||||
const FABLE: &str = ANTHROPIC_DEFAULT_FABLE_MODEL;
|
||||
|
||||
/// The terminal can only open a file it can find: a bare name is looked
|
||||
/// for in the project, home and /tmp, but a long scratchpad path printed
|
||||
/// as `scratchpad/notes.md` is nowhere. Every container gets the ask to
|
||||
/// print the full path, whatever else is configured.
|
||||
#[test]
|
||||
fn every_container_is_asked_to_print_full_paths_for_files_outside_the_project() {
|
||||
for (global, project, sandbox) in [(None, None, false), (Some("g"), Some("p"), true)] {
|
||||
let s = build_claude_instructions(global, project, &[], false, sandbox).unwrap();
|
||||
assert!(s.contains(FILE_LINK_INSTRUCTIONS), "{:?}", (global, project, sandbox));
|
||||
}
|
||||
assert!(FILE_LINK_INSTRUCTIONS.contains("full absolute path"));
|
||||
assert!(FILE_LINK_INSTRUCTIONS.contains("/tmp"));
|
||||
}
|
||||
|
||||
/// The gesture is TerminalView's `opensOnClick`: a plain click, unless a
|
||||
/// program holds the mouse — then xterm's force-selection modifier, which
|
||||
/// is Shift except on macOS, where it is Option. Naming only Shift sent Mac
|
||||
/// users to a key that does nothing.
|
||||
#[test]
|
||||
fn the_file_link_instructions_name_the_gesture_for_every_platform() {
|
||||
assert!(FILE_LINK_INSTRUCTIONS.contains("can click a file path"));
|
||||
assert!(FILE_LINK_INSTRUCTIONS.contains("Shift+click"));
|
||||
assert!(FILE_LINK_INSTRUCTIONS.contains("Option+click on macOS"));
|
||||
}
|
||||
|
||||
fn aliases(model: Option<&str>, haiku: Option<&str>) -> Vec<(&'static str, String)> {
|
||||
compute_model_aliases(model, haiku)
|
||||
}
|
||||
|
||||
@@ -45,10 +45,11 @@ const GATEWAY_CONFIG_VOLUME: &str = "triple-c-gateway-config";
|
||||
/// Upstream LiteLLM, pinned to an exact release.
|
||||
///
|
||||
/// LiteLLM 1.82.7 and 1.82.8 shipped credential-harvesting malware on PyPI, so
|
||||
/// nothing here may float a tag or resolve `litellm` at build time. v1.96.0 is
|
||||
/// also above the 1.84.0 floor set by the proxy auth-bypass CVEs — see the long
|
||||
/// comment in `gateway-container/Dockerfile`, and keep the two in lockstep.
|
||||
const GATEWAY_REGISTRY_IMAGE: &str = "ghcr.io/berriai/litellm:v1.96.0";
|
||||
/// nothing here may float a tag or resolve `litellm` at build time. v1.106.6 is
|
||||
/// also above the floors set by the proxy auth-bypass, SSRF and salt-key CVEs —
|
||||
/// see the long comment in `gateway-container/Dockerfile`, and keep the two in
|
||||
/// lockstep.
|
||||
const GATEWAY_REGISTRY_IMAGE: &str = "ghcr.io/berriai/litellm:v1.106.6";
|
||||
const GATEWAY_LOCAL_IMAGE: &str = "triple-c-gateway:latest";
|
||||
|
||||
const GATEWAY_DOCKERFILE: &str = include_str!("../../../../gateway-container/Dockerfile");
|
||||
|
||||
@@ -339,3 +339,54 @@ mod tests {
|
||||
assert_eq!(read(&dest.join("config.d/work")), "Host work");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod apt_refresh_tests {
|
||||
use super::DOCKERFILE;
|
||||
|
||||
const BUILD_WORKFLOW: &str = include_str!("../../../../.gitea/workflows/build.yml");
|
||||
|
||||
/// The `RUN` instruction (with its `\` continuations) that contains `needle`.
|
||||
fn run_containing(needle: &str) -> String {
|
||||
let mut run = String::new();
|
||||
let mut current = String::new();
|
||||
for line in DOCKERFILE.lines() {
|
||||
if line.starts_with("RUN ") {
|
||||
current.clear();
|
||||
}
|
||||
current.push_str(line);
|
||||
current.push('\n');
|
||||
if !line.trim_end().ends_with('\\') {
|
||||
if current.contains(needle) && current.starts_with("RUN ") {
|
||||
run = current.clone();
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
run
|
||||
}
|
||||
|
||||
/// CI builds with `cache-from: type=gha`, so the `apt-get upgrade` layer is
|
||||
/// only re-run when its cache key changes. An ARG changes a RUN's key only
|
||||
/// where it is *used*, so the upgrade's RUN must reference it, after an
|
||||
/// `ARG APT_REFRESH` declared in its stage.
|
||||
#[test]
|
||||
fn the_apt_upgrade_layer_is_keyed_on_apt_refresh() {
|
||||
let arg = DOCKERFILE.find("\nARG APT_REFRESH").expect("ARG APT_REFRESH declared");
|
||||
let run = run_containing("upgrade -y");
|
||||
assert!(!run.is_empty(), "no RUN with apt-get upgrade");
|
||||
assert!(DOCKERFILE.find(&run).unwrap() > arg, "ARG must come before the RUN");
|
||||
assert!(run.contains("${APT_REFRESH"), "RUN must use APT_REFRESH: {run}");
|
||||
}
|
||||
|
||||
/// …and CI must actually pass a value that changes at least weekly, and
|
||||
/// rebuild on a schedule so a week with no commits still gets the fixes.
|
||||
#[test]
|
||||
fn ci_passes_a_weekly_apt_refresh_and_rebuilds_on_a_schedule() {
|
||||
assert!(BUILD_WORKFLOW.contains("APT_REFRESH=${{ steps.apt.outputs.stamp }}"));
|
||||
assert!(BUILD_WORKFLOW.contains("date -u +%G-W%V"));
|
||||
assert!(BUILD_WORKFLOW.contains("schedule:"));
|
||||
// A scheduled run must publish, a PR run must not.
|
||||
assert!(BUILD_WORKFLOW.contains("push: ${{ gitea.event_name != 'pull_request' }}"));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ pub mod legacy_cleanup;
|
||||
pub mod migration;
|
||||
pub mod stt;
|
||||
|
||||
/// The container user `claude`'s home directory, as the image creates it.
|
||||
pub(crate) const CONTAINER_HOME: &str = "/home/claude";
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use gateway::*;
|
||||
#[allow(unused_imports)]
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
//!
|
||||
//! Relative paths are the common case (Claude prints project-relative paths). The
|
||||
//! terminal exec's cwd is `/workspace`, and each project path is mounted at
|
||||
//! `/workspace/<mount_name>`, so those are the roots probed, in that order. The probe
|
||||
//! `/workspace/<mount_name>`, so those are the roots probed, in that order.
|
||||
//! A relative path none of them has is then tried under `/home/claude` and `/tmp`
|
||||
//! ([`fallback_candidate_paths`]); `~/p` is `/home/claude/p`. The probe
|
||||
//! is one exec as the container user and prints `realpath -e` of every candidate that
|
||||
//! is a regular file: `fetch_container_file` refuses a symlink, so the registry must
|
||||
//! hold the resolved path, not the one that was clicked.
|
||||
|
||||
use crate::commands::file_commands::validate_container_path;
|
||||
use crate::docker::exec::exec_oneshot_streams_as;
|
||||
use crate::docker::CONTAINER_HOME;
|
||||
|
||||
pub const MAX_CANDIDATES: usize = 16;
|
||||
const MAX_RAW_LEN: usize = 4096;
|
||||
@@ -36,12 +39,20 @@ pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result<Vec<String>,
|
||||
return Ok(vec![normalised]);
|
||||
}
|
||||
|
||||
let rel = collapse(raw.strip_prefix("./").unwrap_or(raw));
|
||||
let rel = rel.trim_start_matches("./");
|
||||
if rel.is_empty() {
|
||||
return Err("The path is empty.".into());
|
||||
// Claude Code prints paths under $HOME as `~/...`. Only the bare `~/` form:
|
||||
// `~user/` names some other account's home and stays an ordinary name.
|
||||
if let Some(rest) = raw.strip_prefix("~/") {
|
||||
let rest = collapse(rest);
|
||||
if rest.is_empty() {
|
||||
return Err("The path is empty.".into());
|
||||
}
|
||||
let p = format!("{}/{}", CONTAINER_HOME, rest);
|
||||
validate_container_path("File", &p)?;
|
||||
return Ok(vec![p]);
|
||||
}
|
||||
|
||||
let rel = relative_part(raw).ok_or_else(|| "The path is empty.".to_string())?;
|
||||
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut push = |candidate: String| {
|
||||
if out.len() < MAX_CANDIDATES && !out.contains(&candidate) {
|
||||
@@ -61,6 +72,36 @@ pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result<Vec<String>,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Where a relative path is looked for when no project root has it, in order.
|
||||
/// The container user's home, then `/tmp` (drops, pastes and scratch files).
|
||||
/// Probed only after [`candidate_paths`] found nothing, so a project file never
|
||||
/// turns into a "which one?" chooser because a same-named file sits in `/tmp`.
|
||||
const FALLBACK_ROOTS: &[&str] = &[CONTAINER_HOME, "/tmp"];
|
||||
|
||||
/// The second tier for a relative path: [`FALLBACK_ROOTS`]`/<p>`. Empty for an
|
||||
/// absolute or `~/` path (those name one place already) and for anything
|
||||
/// [`candidate_paths`] refuses — it is always called after that succeeded, but
|
||||
/// must not be a way around its checks.
|
||||
pub fn fallback_candidate_paths(raw: &str) -> Vec<String> {
|
||||
if raw.starts_with('/') || raw.starts_with("~/") || candidate_paths(raw, &[]).is_err() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(rel) = relative_part(raw) else { return Vec::new() };
|
||||
FALLBACK_ROOTS
|
||||
.iter()
|
||||
.map(|root| format!("{}/{}", root, rel))
|
||||
.filter(|c| validate_container_path("File", c).is_ok())
|
||||
.take(MAX_CANDIDATES)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `./a//b` → `a/b`; `None` when nothing is left.
|
||||
fn relative_part(raw: &str) -> Option<String> {
|
||||
let rel = collapse(raw.strip_prefix("./").unwrap_or(raw));
|
||||
let rel = rel.trim_start_matches("./");
|
||||
(!rel.is_empty()).then(|| rel.to_string())
|
||||
}
|
||||
|
||||
/// `a//b/./c` → `a/b/c`. Never touches `..` (rejected before this runs).
|
||||
fn collapse(path: &str) -> String {
|
||||
let absolute = path.starts_with('/');
|
||||
@@ -129,6 +170,38 @@ mod tests {
|
||||
assert_eq!(c, vec!["/workspace/src/main.rs", "/workspace/api/src/main.rs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absolute_tmp_path_is_accepted_as_is() {
|
||||
// Claude Code's scratchpad: long, and with a segment that starts with `-`.
|
||||
let p = "/tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e039e2a07562/scratchpad/notes.md";
|
||||
assert_eq!(candidate_paths(p, &mounts(&["api"])).unwrap(), vec![p]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_home_relative_path_is_the_container_users_home() {
|
||||
// Claude Code prints paths under $HOME as `~/...`.
|
||||
let c = candidate_paths("~/.claude/plans/plan.md", &mounts(&["api"])).unwrap();
|
||||
assert_eq!(c, vec!["/home/claude/.claude/plans/plan.md"]);
|
||||
assert!(candidate_paths("~/", &[]).is_err());
|
||||
assert!(candidate_paths("~/../etc/passwd", &[]).is_err());
|
||||
// `~user/` is not expanded; it stays an ordinary relative name.
|
||||
assert_eq!(candidate_paths("~bob/x.md", &[]).unwrap(), vec!["/workspace/~bob/x.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relative_path_falls_back_to_home_and_tmp() {
|
||||
assert_eq!(
|
||||
fallback_candidate_paths("scratch/notes.md"),
|
||||
vec!["/home/claude/scratch/notes.md", "/tmp/scratch/notes.md"]
|
||||
);
|
||||
assert_eq!(fallback_candidate_paths("./notes.md"), vec!["/home/claude/notes.md", "/tmp/notes.md"]);
|
||||
// Absolute, home-relative and refused paths have no fallbacks.
|
||||
assert!(fallback_candidate_paths("/tmp/notes.md").is_empty());
|
||||
assert!(fallback_candidate_paths("~/notes.md").is_empty());
|
||||
assert!(fallback_candidate_paths("../notes.md").is_empty());
|
||||
assert!(fallback_candidate_paths("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_nul_and_oversize_are_refused() {
|
||||
assert!(candidate_paths("../etc/passwd", &[]).is_err());
|
||||
|
||||
@@ -60,6 +60,8 @@ const ptyOutput = vi.hoisted(() => ({
|
||||
const xterm = vi.hoisted(() => ({
|
||||
options: null as Record<string, unknown> | null,
|
||||
instances: [] as unknown[],
|
||||
/** Every link provider registered on any terminal, for asking what gets underlined. */
|
||||
linkProviders: [] as import("@xterm/xterm").ILinkProvider[],
|
||||
}));
|
||||
|
||||
/**
|
||||
@@ -82,6 +84,10 @@ vi.mock("@xterm/xterm", async (importOriginal) => {
|
||||
xterm.options = (options ?? null) as Record<string, unknown> | null;
|
||||
xterm.instances.push(this);
|
||||
}
|
||||
override registerLinkProvider(provider: import("@xterm/xterm").ILinkProvider) {
|
||||
xterm.linkProviders.push(provider);
|
||||
return super.registerLinkProvider(provider);
|
||||
}
|
||||
}
|
||||
return { ...actual, Terminal: SpyTerminal };
|
||||
});
|
||||
@@ -1974,3 +1980,151 @@ describe("the plain-text URL path is gated the same way", () => {
|
||||
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The underline is the link: `WebLinksAddon` paints exactly what its regex
|
||||
* matched and hands that same text to the click handler. So trailing prose
|
||||
* punctuation has to be kept out of the *match*, not trimmed afterwards —
|
||||
* otherwise `See https://x/docs.` underlines the full stop and opens it.
|
||||
*/
|
||||
describe("plain-text URLs stop before trailing punctuation", () => {
|
||||
function term() {
|
||||
return xterm.instances.at(-1) as unknown as {
|
||||
write(d: string, cb: () => void): void;
|
||||
};
|
||||
}
|
||||
|
||||
async function write(data: string) {
|
||||
await act(() => new Promise<void>((resolve) => term().write(data, resolve)));
|
||||
}
|
||||
|
||||
/** The links every registered provider reports for buffer row 1. */
|
||||
async function linksOnFirstRow() {
|
||||
const found: Array<{ text: string; startX: number; endX: number }> = [];
|
||||
for (const provider of xterm.linkProviders) {
|
||||
await new Promise<void>((resolve) =>
|
||||
provider.provideLinks(1, (links) => {
|
||||
for (const l of links ?? []) {
|
||||
found.push({ text: l.text, startX: l.range.start.x, endX: l.range.end.x });
|
||||
}
|
||||
resolve();
|
||||
}),
|
||||
);
|
||||
}
|
||||
return found.filter((l) => l.text.startsWith("http"));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
xterm.linkProviders.length = 0;
|
||||
});
|
||||
|
||||
it.each([
|
||||
["See https://example.com/docs.", "https://example.com/docs", 5],
|
||||
["(https://example.com/foo)", "https://example.com/foo", 2],
|
||||
["[text](https://x.example/z).", "https://x.example/z", 8],
|
||||
["<https://x.example/a>", "https://x.example/a", 2],
|
||||
["https://en.wikipedia.org/wiki/Foo_(bar)", "https://en.wikipedia.org/wiki/Foo_(bar)", 1],
|
||||
])("underlines only the URL in %j", async (line, url, startX) => {
|
||||
mountSession("bash");
|
||||
await write(line);
|
||||
|
||||
expect(await linksOnFirstRow()).toEqual([
|
||||
{ text: url, startX, endX: startX + url.length - 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalView — copying text out", () => {
|
||||
let writeText: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
writeText = vi.fn(async () => {});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
async function emitBytes(bytes: Uint8Array) {
|
||||
const emit = ptyOutput.listeners.get("terminal-output-s1");
|
||||
if (!emit) throw new Error("no terminal-output listener registered");
|
||||
await act(async () => {
|
||||
emit({ payload: Array.from(bytes) });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
const utf8 = (s: string) => new TextEncoder().encode(s);
|
||||
|
||||
function pressCopy(container: HTMLElement, altKey = false) {
|
||||
helperTextarea(container).dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "C",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
altKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it("puts OSC 52 text on the clipboard as UTF-8, not mojibake", async () => {
|
||||
mountSession("claude");
|
||||
await act(async () => {});
|
||||
// What the xclip shim (`printf '%s' | base64`) and Claude Code both send.
|
||||
const text = "it’s café — naïve 日本語\u00A0👍";
|
||||
const b64 = Buffer.from(text, "utf8").toString("base64");
|
||||
await emitBytes(utf8(`\x1b]52;c;${b64}\x07`));
|
||||
|
||||
expect(writeText).toHaveBeenCalledTimes(1);
|
||||
// NBSP normalized; everything else exactly as sent.
|
||||
expect(writeText).toHaveBeenCalledWith("it’s café — naïve 日本語 👍");
|
||||
});
|
||||
|
||||
it("does not write anything for an OSC 52 read request", async () => {
|
||||
mountSession("claude");
|
||||
await act(async () => {});
|
||||
await emitBytes(utf8("\x1b]52;c;?\x07"));
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Claude Code's own hard wrap: it moves to the next row itself (CR + cursor
|
||||
// moves in the real stream), so the rows are not `isWrapped` and the
|
||||
// selection carries a newline at every break.
|
||||
const CLAUDE_ROWS = [
|
||||
"● The café’s naïve résumé handling means the parser — which was",
|
||||
" written years ago by someone who has since left the team — silently",
|
||||
" drops every accented character it cannot map.",
|
||||
];
|
||||
|
||||
it("Ctrl+Shift+C rejoins Claude's wrapped rows and drops its gutter", async () => {
|
||||
const { container } = mountSession("claude");
|
||||
await act(async () => {});
|
||||
const term = xterm.instances[0] as Terminal;
|
||||
act(() => term.resize(70, 24));
|
||||
await emitBytes(utf8(CLAUDE_ROWS.join("\r\n")));
|
||||
term.selectAll();
|
||||
|
||||
pressCopy(container);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
"The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.",
|
||||
);
|
||||
});
|
||||
|
||||
it("Ctrl+Shift+Alt+C copies the selection raw", async () => {
|
||||
const { container } = mountSession("claude");
|
||||
await act(async () => {});
|
||||
const term = xterm.instances[0] as Terminal;
|
||||
act(() => term.resize(70, 24));
|
||||
await emitBytes(utf8(CLAUDE_ROWS.join("\r\n")));
|
||||
term.selectAll();
|
||||
|
||||
pressCopy(container, true);
|
||||
|
||||
expect(writeText).toHaveBeenCalledTimes(1);
|
||||
expect(writeText.mock.calls[0][0]).toBe(term.getSelection());
|
||||
expect(writeText.mock.calls[0][0]).toContain("● The café’s");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../../lib/tauri-commands";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { UrlDetector, type UrlSource } from "../../lib/urlDetector";
|
||||
import { TERMINAL_URL_REGEX } from "../../lib/urlMatch";
|
||||
import {
|
||||
RelayRateLimiter,
|
||||
URL_RELAY_OSC,
|
||||
@@ -31,7 +32,8 @@ import UrlToast, {
|
||||
URL_TOAST_SELECTOR,
|
||||
URL_TOAST_SHORTCUT,
|
||||
} from "./UrlToast";
|
||||
import { trimSelection } from "./trimSelection";
|
||||
import { cleanSelection, sanitizeInvisibles, type CopyGeometry } from "./copyCleanup";
|
||||
import { decodeOsc52 } from "./osc52";
|
||||
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
||||
import TerminalContextMenu from "./TerminalContextMenu";
|
||||
import { createFilePathLinkProvider } from "./filePathLinkProvider";
|
||||
@@ -122,6 +124,16 @@ export function supersedes(
|
||||
*/
|
||||
export const OSC8_HOVER_CLASS = "xterm-hover";
|
||||
|
||||
/**
|
||||
* What `cleanSelection` needs to tell Claude's hard wraps from real line
|
||||
* breaks: the width it wrapped to, and where the selection's first row began.
|
||||
* `getSelectionPosition().start.x` is documented as 1-based but xterm 5.5
|
||||
* returns the selection model's 0-based column — which is what is wanted.
|
||||
*/
|
||||
function selectionGeometry(term: Terminal): CopyGeometry {
|
||||
return { cols: term.cols, startCol: term.getSelectionPosition()?.start.x ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a failed handoff to the host's browser.
|
||||
*
|
||||
@@ -1069,9 +1081,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
// Web links addon — opens URLs in host browser via Tauri, with a permissive regex
|
||||
// that matches URLs even if they lack trailing path segments (the default regex
|
||||
// misses OAuth URLs that end mid-line).
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const urlRegex = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/;
|
||||
// misses OAuth URLs that end mid-line). `TERMINAL_URL_REGEX` also leaves
|
||||
// trailing prose punctuation and wrapping brackets out of the match, which
|
||||
// is what the addon underlines *and* what it hands the handler below.
|
||||
const urlRegex = TERMINAL_URL_REGEX;
|
||||
const webLinksAddon = new WebLinksAddon((event, uri) => {
|
||||
// Same gate and same sink as `createOsc8LinkHandler`, because this
|
||||
// reaches the same `openUrlExternal` through the same
|
||||
@@ -1126,15 +1139,17 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
term.open(containerRef.current);
|
||||
|
||||
// Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding
|
||||
// stripped, internal indentation preserved). Ctrl+Shift+Alt+C copies raw.
|
||||
// Ctrl+Shift+C copies the selection cleaned up for pasting (UI padding,
|
||||
// Claude's gutter markers and hard wraps, and invisible characters removed;
|
||||
// internal indentation preserved — see `copyCleanup.ts`). Ctrl+Shift+Alt+C
|
||||
// copies raw.
|
||||
// Both prevent the keystroke from reaching the container (where Ctrl+C
|
||||
// would send SIGINT and cancel running work).
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") {
|
||||
const sel = term.getSelection();
|
||||
if (sel) {
|
||||
const out = event.altKey ? sel : trimSelection(sel);
|
||||
const out = event.altKey ? sel : cleanSelection(sel, selectionGeometry(term));
|
||||
navigator.clipboard.writeText(out).catch((e) =>
|
||||
console.error("Ctrl+Shift+C clipboard write failed:", e),
|
||||
);
|
||||
@@ -1214,16 +1229,18 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx === -1) return false;
|
||||
const payload = data.substring(idx + 1);
|
||||
if (payload === "?") return false; // clipboard read request, not supported
|
||||
try {
|
||||
const decoded = atob(payload);
|
||||
navigator.clipboard.writeText(decoded).catch((e) =>
|
||||
console.error("OSC 52 clipboard write failed:", e),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("OSC 52 decode failed:", e);
|
||||
if (data.substring(idx + 1) === "?") return false; // clipboard read request, not supported
|
||||
// Decoded as UTF-8 — `atob` alone mojibaked every non-ASCII character.
|
||||
// Only invisible characters are normalized: the program chose this text,
|
||||
// so it is not dedented or reflowed the way a terminal selection is.
|
||||
const decoded = decodeOsc52(data);
|
||||
if (decoded === null) {
|
||||
console.error("OSC 52 decode failed: invalid base64");
|
||||
return true;
|
||||
}
|
||||
navigator.clipboard.writeText(sanitizeInvisibles(decoded)).catch((e) =>
|
||||
console.error("OSC 52 clipboard write failed:", e),
|
||||
);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -1625,7 +1642,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
if (!term) return;
|
||||
const sel = term.getSelection();
|
||||
if (!sel) return;
|
||||
const out = mode === "raw" ? sel : trimSelection(sel);
|
||||
const out = mode === "raw" ? sel : cleanSelection(sel, selectionGeometry(term));
|
||||
navigator.clipboard.writeText(out).catch((e) =>
|
||||
console.error("Context menu clipboard write failed:", e),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
sanitizeInvisibles,
|
||||
stripClaudeGutter,
|
||||
rejoinHardWraps,
|
||||
cleanSelection,
|
||||
cellWidth,
|
||||
} from "./copyCleanup";
|
||||
|
||||
/**
|
||||
* Rows of a real Claude Code 2.1.316 reply, captured from a 70-column pty and
|
||||
* replayed through @xterm/headless 5.5 (what `getSelection()` would return for
|
||||
* a drag from column 0 of the first row to the end of the last). Claude
|
||||
* hard-wraps its own output — a real `\r` + cursor move between rows, never an
|
||||
* xterm soft wrap, so every row has `isWrapped === false` and the selection
|
||||
* carries a `\n` at each of Claude's breaks. Note the URL split mid-token at
|
||||
* exactly column 70, and the code line Claude also wrapped at a space.
|
||||
*/
|
||||
const CLAUDE_ROWS_70 = [
|
||||
"● The café’s naïve résumé handling means the parser — which was",
|
||||
" written years ago by someone who has since left the team — silently",
|
||||
" drops every accented character it cannot map.",
|
||||
"",
|
||||
" See https://example.com/a/very/long/path/that/keeps/going/and/going/",
|
||||
" until/it/wraps/index.html?query=1 for details.",
|
||||
"",
|
||||
" - The first item is a long sentence that describes one important",
|
||||
" consideration about the parser in considerable detail, so that it",
|
||||
" lands near twenty-five words.",
|
||||
" - The second item is another long sentence that explains the second",
|
||||
" consideration for the reader, written to contain roughly the same",
|
||||
" number of words as the first.",
|
||||
"",
|
||||
" curl -fsSL https://example.com/install.sh | bash -s --",
|
||||
" --prefix=/opt/some/really/long/prefix/path --yes",
|
||||
];
|
||||
|
||||
describe("sanitizeInvisibles", () => {
|
||||
it("turns non-breaking and odd-width spaces into plain spaces", () => {
|
||||
expect(sanitizeInvisibles("git\u00A0status")).toBe("git status");
|
||||
expect(sanitizeInvisibles("a\u202Fb\u2007c\u2002d\u2009e\u200Af\u205Fg")).toBe("a b c d e f g");
|
||||
});
|
||||
|
||||
it("removes zero-width spaces, word joiners and BOMs", () => {
|
||||
expect(sanitizeInvisibles("\uFEFFnpm\u200B run\u2060 build")).toBe("npm run build");
|
||||
});
|
||||
|
||||
it("removes ZWJ/ZWNJ next to ASCII, where they only break commands", () => {
|
||||
expect(sanitizeInvisibles("ls\u200D -la\u200C")).toBe("ls -la");
|
||||
});
|
||||
|
||||
it("keeps ZWJ inside emoji sequences and ZWNJ inside non-Latin words", () => {
|
||||
const family = "👨\u200D👩\u200D👧";
|
||||
expect(sanitizeInvisibles(`ok ${family} ok`)).toBe(`ok ${family} ok`);
|
||||
const persian = "می\u200Cخواهم"; // ZWNJ is orthographically required here
|
||||
expect(sanitizeInvisibles(persian)).toBe(persian);
|
||||
const devanagari = "क्\u200Dष";
|
||||
expect(sanitizeInvisibles(devanagari)).toBe(devanagari);
|
||||
});
|
||||
|
||||
it("leaves legitimate non-ASCII text alone", () => {
|
||||
const text = "it’s café — naïve “quotes” 日本語\u3000テキスト 👍🏽 ● ⎿ │";
|
||||
expect(sanitizeInvisibles(text)).toBe(text); // U+3000 ideographic space kept
|
||||
});
|
||||
|
||||
it("turns Unicode line/paragraph separators into newlines", () => {
|
||||
expect(sanitizeInvisibles("a\u2028b\u2029c")).toBe("a\nb\nc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cellWidth", () => {
|
||||
it("counts wide CJK and emoji as two cells and zero-width marks as none", () => {
|
||||
expect(cellWidth("abc")).toBe(3);
|
||||
expect(cellWidth("日本")).toBe(4);
|
||||
expect(cellWidth("👍")).toBe(2);
|
||||
expect(cellWidth("é")).toBe(1);
|
||||
expect(cellWidth("—●⎿")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripClaudeGutter", () => {
|
||||
it("blanks Claude's message bullet at column 0, keeping columns", () => {
|
||||
expect(stripClaudeGutter("● Hello\n world", 0)).toBe(" Hello\n world");
|
||||
});
|
||||
|
||||
it("blanks the tool-result elbow", () => {
|
||||
expect(stripClaudeGutter("● Bash(ls)\n ⎿ a.txt\n b.txt", 0)).toBe(
|
||||
" Bash(ls)\n a.txt\n b.txt",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not touch a bullet that is not in the gutter", () => {
|
||||
expect(stripClaudeGutter(" ● item", 0)).toBe(" ● item");
|
||||
// Selection started mid-row: the first line's column 0 is not the gutter.
|
||||
expect(stripClaudeGutter("● x", 5)).toBe("● x");
|
||||
});
|
||||
|
||||
it("strips box borders only when every line is inside the box", () => {
|
||||
const box = [
|
||||
"╭────────────────╮",
|
||||
"│ Do you want to │",
|
||||
"│ proceed? │",
|
||||
"╰────────────────╯",
|
||||
].join("\n");
|
||||
expect(stripClaudeGutter(box, 0)).toBe(" Do you want to\n proceed?");
|
||||
});
|
||||
|
||||
it("leaves tables and partial boxes alone", () => {
|
||||
const table = "│ a │ b │\n├───┼───┤\n│ 1 │ 2 │";
|
||||
expect(stripClaudeGutter(table, 0)).toBe(table);
|
||||
const mixed = "│ inside │\noutside";
|
||||
expect(stripClaudeGutter(mixed, 0)).toBe(mixed);
|
||||
});
|
||||
|
||||
it("leaves a selection of only a table's body rows alone", () => {
|
||||
const body = "│ a │ b │\n│ 1 │ 2 │";
|
||||
expect(stripClaudeGutter(body, 0)).toBe(body);
|
||||
expect(stripClaudeGutter("│ only │ row │", 0)).toBe("│ only │ row │");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejoinHardWraps", () => {
|
||||
it("rejoins a real Claude Code reply captured at 70 columns", () => {
|
||||
const sel = stripClaudeGutter(CLAUDE_ROWS_70.join("\n"), 0);
|
||||
expect(rejoinHardWraps(sel, { cols: 70 })).toBe(
|
||||
[
|
||||
" The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.",
|
||||
"",
|
||||
" See https://example.com/a/very/long/path/that/keeps/going/and/going/until/it/wraps/index.html?query=1 for details.",
|
||||
"",
|
||||
" - The first item is a long sentence that describes one important consideration about the parser in considerable detail, so that it lands near twenty-five words.",
|
||||
" - The second item is another long sentence that explains the second consideration for the reader, written to contain roughly the same number of words as the first.",
|
||||
"",
|
||||
" curl -fsSL https://example.com/install.sh | bash -s -- --prefix=/opt/some/really/long/prefix/path --yes",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejoins Claude's echo of a submitted prompt, which wraps one column short", () => {
|
||||
// Same capture: the transcript's echo of the prompt that was typed.
|
||||
const echo = [
|
||||
"❯ Reply with exactly the following markdown and nothing else (no",
|
||||
" tools). First a paragraph: \"The café’s naïve résumé handling means",
|
||||
" the parser — which was written years ago by someone who has since",
|
||||
" left the team — silently drops every accented character it cannot",
|
||||
" map.\" Then a line: \"See https://example.com/a/very/long/path/that/k",
|
||||
" eeps/going/and/going/until/it/wraps/index.html?query=1 for",
|
||||
" details.\" Then a bullet list with two items, each a long sentence",
|
||||
" of about 25 words. Then a bash code block containing: curl -fsSL",
|
||||
" https://example.com/install.sh | bash -s --",
|
||||
" --prefix=/opt/some/really/long/prefix/path --yes",
|
||||
].join("\n");
|
||||
const typed =
|
||||
"Reply with exactly the following markdown and nothing else (no tools). First a paragraph: \"The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.\" Then a line: \"See https://example.com/a/very/long/path/that/keeps/going/and/going/until/it/wraps/index.html?query=1 for details.\" Then a bullet list with two items, each a long sentence of about 25 words. Then a bash code block containing: curl -fsSL https://example.com/install.sh | bash -s -- --prefix=/opt/some/really/long/prefix/path --yes";
|
||||
expect(rejoinHardWraps(echo, { cols: 70 })).toBe(`❯ ${typed}`);
|
||||
});
|
||||
|
||||
it("joins a word that exactly filled the row with a space", () => {
|
||||
// 70 cells: the last token ("wordabc") and the next ("next") are short,
|
||||
// so this was a break at a space that happened to land on the edge.
|
||||
const full = " " + "x".repeat(60) + " wordabc";
|
||||
expect(cellWidth(full)).toBe(70);
|
||||
expect(rejoinHardWraps(`${full}\n next words`, { cols: 70 })).toBe(`${full} next words`);
|
||||
});
|
||||
|
||||
it("does not join short lines (code, prose that ended early)", () => {
|
||||
const code = " def foo():\n return 1\n print(foo())";
|
||||
expect(rejoinHardWraps(code, { cols: 70 })).toBe(code);
|
||||
const prose = " This sentence ends well before the edge.\n So does this one.";
|
||||
expect(rejoinHardWraps(prose, { cols: 70 })).toBe(prose);
|
||||
});
|
||||
|
||||
it("does not join a line whose next word would have fitted", () => {
|
||||
const line = " " + "a ".repeat(30).trimEnd(); // 61 cells
|
||||
expect(rejoinHardWraps(`${line}\n bb`, { cols: 70 })).toBe(`${line}\n bb`);
|
||||
});
|
||||
|
||||
it("does not join across a change of indentation", () => {
|
||||
const a = " " + "word ".repeat(13).trimEnd(); // 66 cells
|
||||
expect(rejoinHardWraps(`${a}\n indented-continuation`, { cols: 70 })).toBe(
|
||||
`${a}\n indented-continuation`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not join into the next list item or a border", () => {
|
||||
const a = " - " + "word ".repeat(12).trimEnd(); // 63 cells
|
||||
const next = " - another item with a longword";
|
||||
expect(rejoinHardWraps(`${a}\n${next}`, { cols: 70 })).toBe(`${a}\n${next}`);
|
||||
expect(rejoinHardWraps(`${a}\n │ table`, { cols: 70 })).toBe(`${a}\n │ table`);
|
||||
});
|
||||
|
||||
it("leaves column-0 shell output alone (ls-style columns, plain output)", () => {
|
||||
const ls =
|
||||
"Cargo.lock Cargo.toml README.md build.rs capabilities gen icons s\nsrc target tauri.conf.json";
|
||||
expect(rejoinHardWraps(ls, { cols: 70 })).toBe(ls);
|
||||
const plain = "x".repeat(65) + "\nyyyyyyyyyy";
|
||||
expect(rejoinHardWraps(plain, { cols: 70 })).toBe(plain);
|
||||
});
|
||||
|
||||
it("leaves tabular rows alone even when indented", () => {
|
||||
const t = " NAME STATUS PORTS CREATED AT SIZE\n triple-c-abc running 8080/tcp";
|
||||
expect(rejoinHardWraps(t, { cols: 70 })).toBe(t);
|
||||
});
|
||||
|
||||
it("joins wide-character text without inserting a space", () => {
|
||||
const a = " " + "日本語".repeat(11); // 2 + 66 = 68 cells; the next wide char needs 2 more
|
||||
expect(cellWidth(a)).toBe(68);
|
||||
expect(rejoinHardWraps(`${a}\n の続き`, { cols: 69 })).toBe(`${a}の続き`);
|
||||
});
|
||||
|
||||
it("joins astral wide characters (CJK Ext-B) without inserting a space", () => {
|
||||
const a = " " + "\u{20000}".repeat(33); // 2 + 66 = 68 cells
|
||||
expect(cellWidth(a)).toBe(68);
|
||||
expect(rejoinHardWraps(`${a}\n \u{20001}\u{20002}`, { cols: 69 })).toBe(`${a}\u{20001}\u{20002}`);
|
||||
const e = " " + "\u{1F600}".repeat(33); // emoji, also two cells each
|
||||
expect(rejoinHardWraps(`${e}\n \u{1F601}`, { cols: 69 })).toBe(`${e}\u{1F601}`);
|
||||
});
|
||||
|
||||
it("keeps the space when a short word ended at the edge and a URL that fits was moved down whole", () => {
|
||||
const prev = " " + "x".repeat(64) + " the";
|
||||
expect(cellWidth(prev)).toBe(70);
|
||||
const url = "https://example.com/" + "p".repeat(35) + "/index.html";
|
||||
expect(url.length).toBe(66); // fits in the 68 cells after the gutter
|
||||
expect(rejoinHardWraps(`${prev}\n ${url}`, { cols: 70 })).toBe(`${prev} ${url}`);
|
||||
});
|
||||
|
||||
it("still joins without a space when a row-filling piece of a long token is followed by a URL start", () => {
|
||||
// A redirect URL whose split happened to land just before its inner `https://`.
|
||||
const url = "https://r.example/" + "r".repeat(47) + "?u=https://inner.example/x";
|
||||
const rows = [" " + url.slice(0, 68), " " + url.slice(68) + " ok"];
|
||||
expect(cellWidth(rows[0])).toBe(70);
|
||||
expect(rows[1].startsWith(" https://")).toBe(true);
|
||||
expect(rejoinHardWraps(rows.join("\n"), { cols: 70 })).toBe(` ${url} ok`);
|
||||
});
|
||||
|
||||
it("rejoins a URL split over three rows without spaces", () => {
|
||||
const url = "https://example.com/" + "q".repeat(130) + "/end.html";
|
||||
const rows = [" See " + url.slice(0, 64), " " + url.slice(64, 132), " " + url.slice(132) + " ok"];
|
||||
expect(rows.map(cellWidth).slice(0, 2)).toEqual([70, 70]);
|
||||
expect(rejoinHardWraps(rows.join("\n"), { cols: 70 })).toBe(` See ${url} ok`);
|
||||
});
|
||||
|
||||
it("uses the selection's start column for a first line that began mid-row", () => {
|
||||
// Selection started at column 18, so the first line is only 51
|
||||
// characters but ended at column 69 of its row.
|
||||
const first = "written years ago by someone who has since left the";
|
||||
expect(rejoinHardWraps(`${first}\n team — silently`, { cols: 70, startCol: 18 })).toBe(
|
||||
`${first} team — silently`,
|
||||
);
|
||||
// Without the start column it looks short and is left alone.
|
||||
expect(rejoinHardWraps(`${first}\n team — silently`, { cols: 70 })).toBe(
|
||||
`${first}\n team — silently`,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves text unchanged when cols is unknown", () => {
|
||||
const sel = CLAUDE_ROWS_70.join("\n");
|
||||
expect(rejoinHardWraps(sel, { cols: 0 })).toBe(sel);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanSelection", () => {
|
||||
it("produces paste-ready text from a Claude reply (gutter, wraps, dedent)", () => {
|
||||
const out = cleanSelection(CLAUDE_ROWS_70.join("\n"), { cols: 70 });
|
||||
expect(out.split("\n")[0]).toBe(
|
||||
"The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.",
|
||||
);
|
||||
expect(out).toContain(
|
||||
"\nSee https://example.com/a/very/long/path/that/keeps/going/and/going/until/it/wraps/index.html?query=1 for details.\n",
|
||||
);
|
||||
expect(out.endsWith("\ncurl -fsSL https://example.com/install.sh | bash -s -- --prefix=/opt/some/really/long/prefix/path --yes")).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes Windows line endings from xterm's getSelection", () => {
|
||||
expect(cleanSelection(" a\r\n b\r\n", { cols: 70 })).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("strips invisible characters", () => {
|
||||
expect(cleanSelection(" git\u202Fcommit\u200B -m x", { cols: 70 })).toBe("git commit -m x");
|
||||
});
|
||||
|
||||
it("keeps the old trimSelection behaviour for ordinary text", () => {
|
||||
expect(cleanSelection(" def foo():\n return 1\n", { cols: 80 })).toBe(
|
||||
"def foo():\n return 1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { trimSelection } from "./trimSelection";
|
||||
|
||||
/**
|
||||
* Clean-up applied to text copied out of a terminal, so that pasting it into
|
||||
* an editor, a shell or a chat works.
|
||||
*
|
||||
* Three separate problems, fixed by separate pure functions:
|
||||
*
|
||||
* - **Invisible characters** (`sanitizeInvisibles`). Claude Code prints
|
||||
* `❯` + U+00A0 for its prompt, and model output can carry NBSP / narrow
|
||||
* NBSP / zero-width spaces; pasted into a shell they make `git status` an
|
||||
* unknown command. xterm's `getSelection()` already maps U+00A0 (only that
|
||||
* one) to a space; everything else survived until now.
|
||||
* - **Claude's TUI chrome** (`stripClaudeGutter`): the `●` message bullet and
|
||||
* `⎿` tool-result elbow in the left gutter, and `│ … │` dialog borders.
|
||||
* - **Claude's hard wraps** (`rejoinHardWraps`). Claude Code wraps its own
|
||||
* output to the terminal width and moves the cursor to the next row itself,
|
||||
* so xterm never marks those rows `isWrapped` and a selection carries a real
|
||||
* newline (plus the 2-column gutter indent) at every break — mid-sentence,
|
||||
* and mid-URL when a long token was split at the edge.
|
||||
*
|
||||
* `cleanSelection` composes them with `trimSelection` for the trimmed copy
|
||||
* (Ctrl+Shift+C, context-menu "Copy"). OSC 52 content gets only
|
||||
* `sanitizeInvisibles`: the program chose that text, so it is not reflowed or
|
||||
* dedented — only made safe to paste. Raw copy (Ctrl+Shift+Alt+C) gets none.
|
||||
*/
|
||||
|
||||
// ── invisible characters ────────────────────────────────────────────────────
|
||||
|
||||
/** NBSP, figure space, narrow NBSP, the U+2000–U+200A width spaces, medium
|
||||
* math space. U+3000 (ideographic space) is deliberately absent: it is real
|
||||
* CJK typography and occupies two cells. */
|
||||
const ODD_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F]/g;
|
||||
/** Zero-width space, word joiner, BOM / zero-width no-break space. */
|
||||
const ZERO_WIDTH = /[\u200B\u2060\uFEFF]/g;
|
||||
/** ZWNJ / ZWJ touching ASCII (or a string edge). Between two non-ASCII
|
||||
* characters they are meaningful — emoji ZWJ sequences, Persian ZWNJ,
|
||||
* Indic conjunct control — so only this case is removed. */
|
||||
const JOINER_NEAR_ASCII = /(^|[\x00-\x7F])[\u200C\u200D]+|[\u200C\u200D]+(?=[\x00-\x7F]|$)/g;
|
||||
|
||||
export function sanitizeInvisibles(text: string): string {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(/[\u2028\u2029]/g, "\n")
|
||||
.replace(ODD_SPACES, " ")
|
||||
.replace(ZERO_WIDTH, "")
|
||||
.replace(JOINER_NEAR_ASCII, "$1");
|
||||
}
|
||||
|
||||
// ── cell width ──────────────────────────────────────────────────────────────
|
||||
|
||||
const WIDE_RANGES: ReadonlyArray<readonly [number, number]> = [
|
||||
[0x1100, 0x115f],
|
||||
[0x2e80, 0x303e],
|
||||
[0x3041, 0x33ff],
|
||||
[0x3400, 0x4dbf],
|
||||
[0x4e00, 0x9fff],
|
||||
[0xa000, 0xa4cf],
|
||||
[0xac00, 0xd7a3],
|
||||
[0xf900, 0xfaff],
|
||||
[0xfe30, 0xfe4f],
|
||||
[0xff00, 0xff60],
|
||||
[0xffe0, 0xffe6],
|
||||
[0x1f300, 0x1f64f],
|
||||
[0x1f900, 0x1f9ff],
|
||||
[0x20000, 0x3fffd],
|
||||
];
|
||||
const ZERO_CELL = /^[\p{M}\u200B-\u200D\u2060\uFE00-\uFE0F\uFEFF]$/u;
|
||||
|
||||
function charWidth(ch: string): number {
|
||||
if (ZERO_CELL.test(ch)) return 0;
|
||||
const cp = ch.codePointAt(0) ?? 0;
|
||||
for (const [lo, hi] of WIDE_RANGES) if (cp >= lo && cp <= hi) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Terminal cells a string occupies — an approximation of xterm's Unicode 6
|
||||
* width table, good enough to tell whether a row reached the right edge. */
|
||||
export function cellWidth(text: string): number {
|
||||
let w = 0;
|
||||
for (const ch of text) w += charWidth(ch);
|
||||
return w;
|
||||
}
|
||||
|
||||
// ── Claude Code's gutter and borders ────────────────────────────────────────
|
||||
|
||||
const BOX_TOP_BOTTOM = /^\s*[╭╰┌└][─━]*[╮╯┐┘]\s*$/;
|
||||
const BOX_ROW = /^(\s*)│(.*)│\s*$/;
|
||||
/** A vertical rule between the borders: a table cell separator, not a box. */
|
||||
const INTERIOR_VERTICAL = /[│┃║]/;
|
||||
const isBoxRow = (l: string) => {
|
||||
const m = BOX_ROW.exec(l);
|
||||
return !!m && !INTERIOR_VERTICAL.test(m[2]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Blanks Claude Code's TUI markers without moving any text: `●` at column 0
|
||||
* and `⎿` at column 2 become spaces (so `rejoinHardWraps` still sees true
|
||||
* columns, and `trimSelection` dedents them away). A selection that is wholly
|
||||
* inside a `╭─╮ │ … │ ╰─╯` box loses the box. Anything less certain — a table
|
||||
* (`├─┼─┤` rows, or body rows alone: `│ a │ b │` has a `│` inside its borders),
|
||||
* a bullet elsewhere, a box mixed with outside text — is left as it is.
|
||||
*
|
||||
* `startCol` is the column the selection began at; when it is not 0 the first
|
||||
* line's column 0 is not the gutter.
|
||||
*/
|
||||
export function stripClaudeGutter(text: string, startCol = 0): string {
|
||||
if (!text) return text;
|
||||
let lines = text.split("\n");
|
||||
|
||||
const content = lines.filter((l) => l.trim() !== "" && !BOX_TOP_BOTTOM.test(l));
|
||||
if (content.length > 0 && content.every(isBoxRow)) {
|
||||
lines = lines
|
||||
.filter((l) => !BOX_TOP_BOTTOM.test(l))
|
||||
.map((l) => l.replace(BOX_ROW, (_m, lead: string, inner: string) => `${lead} ${inner}`.trimEnd()));
|
||||
}
|
||||
|
||||
return lines
|
||||
.map((line, i) => {
|
||||
if (i === 0 && startCol !== 0) return line;
|
||||
if (line.startsWith("● ")) return " " + line.slice(1);
|
||||
if (line.startsWith(" ⎿ ")) return " " + line.slice(3);
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// ── Claude Code's hard wraps ────────────────────────────────────────────────
|
||||
|
||||
export interface CopyGeometry {
|
||||
/** Terminal width in columns (`term.cols`). 0 / unknown disables rejoining. */
|
||||
cols: number;
|
||||
/** Column (0-based) where the selection's first row began. */
|
||||
startCol?: number;
|
||||
}
|
||||
|
||||
/** A list marker or Claude prompt glyph, and the spaces after it. */
|
||||
const MARKER = /^([-*+•●❯⎿]|\d{1,3}[.)]) +/;
|
||||
/** A row that starts a border, rule or table line. */
|
||||
const BORDER_START = /^[│┃├┤┌┐└┘╭╮╰╯─━┼]/;
|
||||
/** Two or more spaces between words: column-aligned output, not prose. */
|
||||
const TABULAR = /\S {2,}\S/;
|
||||
/** Claude Code always renders its transcript at least 2 columns in. */
|
||||
const MIN_GUTTER = 2;
|
||||
|
||||
interface Row {
|
||||
text: string;
|
||||
/** Column of the first non-space character. */
|
||||
lead: number;
|
||||
/** Column just past the last character. */
|
||||
end: number;
|
||||
/** Text after the indent. */
|
||||
body: string;
|
||||
/** Column where text after a list marker starts (or `lead` if none). */
|
||||
contentCol: number;
|
||||
hasMarker: boolean;
|
||||
}
|
||||
|
||||
function toRow(text: string, offset: number): Row {
|
||||
const body = text.trimStart();
|
||||
const lead = offset + cellWidth(text.slice(0, text.length - body.length));
|
||||
const marker = body.match(MARKER);
|
||||
return {
|
||||
text,
|
||||
lead,
|
||||
end: offset + cellWidth(text),
|
||||
body,
|
||||
contentCol: lead + (marker ? cellWidth(marker[0]) : 0),
|
||||
hasMarker: !!marker,
|
||||
};
|
||||
}
|
||||
|
||||
const lastToken = (s: string) => s.slice(s.lastIndexOf(" ") + 1);
|
||||
const firstToken = (s: string) => s.split(" ", 1)[0];
|
||||
/** First / last *code point* — an astral character (CJK Ext-B, most emoji) is two UTF-16 units. */
|
||||
const firstChar = (s: string) => (s ? String.fromCodePoint(s.codePointAt(0)!) : "");
|
||||
const lastChar = (s: string) => Array.from(s.slice(-2)).pop() ?? "";
|
||||
const isWide = (ch: string) => !!ch && charWidth(ch) === 2;
|
||||
|
||||
/**
|
||||
* Re-joins rows that Claude Code broke to fit the terminal width.
|
||||
*
|
||||
* A break is undone only when all of these hold — each is a property every
|
||||
* Claude wrap has, so together they are conservative:
|
||||
* - both rows have text, and the next row does not start a list item or a
|
||||
* border/table line;
|
||||
* - the next row is indented exactly to where the logical line's text starts
|
||||
* (the gutter, or the hanging indent after `- ` / `1. `), and at least 2
|
||||
* columns — so column-0 shell output is never touched;
|
||||
* - the previous row is not column-aligned (no double spaces between words);
|
||||
* - the next row's first word would *not* have fitted on the previous row,
|
||||
* i.e. the wrapper had no choice but to break there.
|
||||
*
|
||||
* Joined with a space (Claude drops the space it broke at), except when a long
|
||||
* token such as a URL was split mid-token (see `splitMidToken`) or the break
|
||||
* is between two wide (CJK) characters.
|
||||
*/
|
||||
export function rejoinHardWraps(text: string, { cols, startCol = 0 }: CopyGeometry): string {
|
||||
if (!text || !(cols > 0)) return text;
|
||||
const rows = text.split("\n").map((t, i) => toRow(t, i === 0 ? startCol : 0));
|
||||
if (rows.length < 2) return text;
|
||||
|
||||
const out: string[] = [rows[0].text];
|
||||
let head = rows[0];
|
||||
// A first row that began mid-row: what was left of it is unknown.
|
||||
let headPartial = startCol > 0 && !rows[0].hasMarker;
|
||||
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const prev = rows[i - 1];
|
||||
const cur = rows[i];
|
||||
const joiner = joinerFor(prev, cur, head, headPartial, cols);
|
||||
if (joiner === null) {
|
||||
out.push(cur.text);
|
||||
head = cur;
|
||||
headPartial = false;
|
||||
} else {
|
||||
out[out.length - 1] += joiner + cur.body;
|
||||
}
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function joinerFor(prev: Row, cur: Row, head: Row, headPartial: boolean, cols: number): string | null {
|
||||
if (!prev.body || !cur.body) return null;
|
||||
if (cur.hasMarker || BORDER_START.test(cur.body)) return null;
|
||||
if (cur.lead < MIN_GUTTER) return null;
|
||||
if (headPartial ? cur.lead > head.lead : cur.lead !== head.contentCol) return null;
|
||||
|
||||
const prevText = prev === head && prev.hasMarker ? prev.body.replace(MARKER, "") : prev.body;
|
||||
if (TABULAR.test(prevText)) return null;
|
||||
|
||||
// Claude's echo of a submitted prompt (`❯ …`) wraps one column short of
|
||||
// its replies — measured on 2.1.316 at 70 columns: rows end at 69, and a
|
||||
// long token is split at column 69.
|
||||
const width = head.body.startsWith("❯") ? cols - 1 : cols;
|
||||
if (prev.end > width) return null;
|
||||
const next = firstToken(cur.body);
|
||||
if (prev.end + 1 + cellWidth(next) <= width) return null;
|
||||
|
||||
if (isWide(lastChar(prev.body)) && isWide(firstChar(cur.body))) return "";
|
||||
return prev.end === width && splitMidToken(prev, cur, next, width) ? "" : " ";
|
||||
}
|
||||
|
||||
/** The start of a URL: a token that begins here was not cut off mid-token. */
|
||||
const URL_START = /^[a-z][a-z0-9+.-]*:\/\//i;
|
||||
|
||||
/**
|
||||
* Did Claude break a single long token at the edge of `prev` (so the rows
|
||||
* join with no space), rather than moving the next word down whole?
|
||||
*
|
||||
* Ink's wrap-ansi splits a word only when it is wider than the line, so the
|
||||
* combined token must not fit in the line's available width. That alone
|
||||
* cannot tell `the` + a 66-cell URL (two words; the URL fits on a row and was
|
||||
* moved down) from one 69-cell token split after `the` — the lengths are the
|
||||
* same. So: a next-row token that *starts a URL* (`scheme://`) was moved down
|
||||
* whole, unless one of the two pieces fills its entire row, which only the
|
||||
* middle piece of a token longer than a line does.
|
||||
*/
|
||||
function splitMidToken(prev: Row, cur: Row, next: string, width: number): boolean {
|
||||
const tail = cellWidth(lastToken(prev.body));
|
||||
const head = cellWidth(next);
|
||||
if (tail + head <= width - cur.lead) return false; // would have been moved down whole
|
||||
const prevIsPiece = tail >= width - prev.lead;
|
||||
const curIsPiece = cur.end === width && head === cellWidth(cur.body);
|
||||
if (prevIsPiece || curIsPiece) return true;
|
||||
return !URL_START.test(next);
|
||||
}
|
||||
|
||||
// ── the trimmed copy ────────────────────────────────────────────────────────
|
||||
|
||||
/** The full clean-up for a trimmed copy of an xterm selection. */
|
||||
export function cleanSelection(text: string, geometry: CopyGeometry): string {
|
||||
if (!text) return text;
|
||||
const lf = text.replace(/\r\n?/g, "\n"); // getSelection() joins with CRLF on Windows
|
||||
const visible = sanitizeInvisibles(lf);
|
||||
const unframed = stripClaudeGutter(visible, geometry.startCol ?? 0);
|
||||
return trimSelection(rejoinHardWraps(unframed, geometry));
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createFilePathLinkProvider } from "./filePathLinkProvider";
|
||||
|
||||
const fakeTerm = (rows: Array<[string, boolean]>) => ({
|
||||
const fakeTerm = (rows: Array<[string, boolean]>, cols?: number) => ({
|
||||
cols,
|
||||
buffer: {
|
||||
active: {
|
||||
getLine: (y: number) => rows[y] && { isWrapped: rows[y][1], translateToString: () => rows[y][0] },
|
||||
@@ -40,6 +41,29 @@ describe("createFilePathLinkProvider", () => {
|
||||
expect(links.mock.calls[0][0][0].range).toEqual({ start: { x: 5, y: 1 }, end: { x: 12, y: 2 } });
|
||||
});
|
||||
|
||||
it("links a long /tmp path that Claude Code hard-wrapped, from either row", () => {
|
||||
// Captured from Claude Code at 70 columns: the path is broken mid-word and
|
||||
// the rest drawn two cells in on the next row, with no `isWrapped`.
|
||||
const rows: Array<[string, boolean]> = [
|
||||
["\u25cf The file is at /tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e", false],
|
||||
[" 039e2a07562/scratchpad/notes.md ok", false],
|
||||
];
|
||||
const onOpen = vi.fn();
|
||||
const provider = createFilePathLinkProvider(fakeTerm(rows, 70), onOpen, () => true);
|
||||
for (const row of [1, 2]) {
|
||||
const links = vi.fn();
|
||||
provider.provideLinks(row, links);
|
||||
const [list] = links.mock.calls[0];
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].range).toEqual({ start: { x: 18, y: 1 }, end: { x: 33, y: 2 } });
|
||||
list[0].activate(new MouseEvent("click"), list[0].text);
|
||||
}
|
||||
const scratch = "/tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e039e2a07562/scratchpad/notes.md";
|
||||
expect(onOpen).toHaveBeenCalledTimes(2);
|
||||
expect(onOpen).toHaveBeenNthCalledWith(1, expect.objectContaining({ path: scratch }));
|
||||
expect(onOpen).toHaveBeenNthCalledWith(2, expect.objectContaining({ path: scratch }));
|
||||
});
|
||||
|
||||
it("answers undefined for a row with nothing", () => {
|
||||
const provider = createFilePathLinkProvider(fakeTerm([["plain words", false]]), vi.fn(), () => true);
|
||||
const links = vi.fn();
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface FilePathHover {
|
||||
}
|
||||
|
||||
export function createFilePathLinkProvider(
|
||||
term: Pick<Terminal, "buffer">,
|
||||
term: Pick<Terminal, "buffer" | "cols">,
|
||||
onOpen: (match: FilePathMatch) => void,
|
||||
gate: (event: MouseEvent) => boolean,
|
||||
hover?: FilePathHover,
|
||||
@@ -27,7 +27,9 @@ export function createFilePathLinkProvider(
|
||||
const row = bufferLineNumber - 1;
|
||||
const line = term.buffer.active.getLine(row);
|
||||
if (!line) return callback(undefined);
|
||||
const joined = joinWrappedRows(term.buffer.active, row);
|
||||
// `cols` lets the join bridge Claude Code's own mid-word wraps, which is
|
||||
// how a long `/tmp/claude-<uid>/...` path reaches the screen.
|
||||
const joined = joinWrappedRows(term.buffer.active, row, { cols: term.cols });
|
||||
// `offsetToCell` has no row to map onto when nothing was joined.
|
||||
if (joined.rowStarts.length === 0) return callback(undefined);
|
||||
const matches = findFilePathLinks(joined.text);
|
||||
|
||||
@@ -1,59 +1,76 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decodeOsc52 } from "./osc52";
|
||||
|
||||
/**
|
||||
* Tests the OSC 52 clipboard parsing logic used in TerminalView.
|
||||
* Extracted here to validate the decode/write path independently.
|
||||
*/
|
||||
|
||||
// Mirrors the handler registered in TerminalView.tsx
|
||||
function handleOsc52(data: string): string | null {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx === -1) return null;
|
||||
const payload = data.substring(idx + 1);
|
||||
if (payload === "?") return null;
|
||||
try {
|
||||
return atob(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
/** Base64 of the UTF-8 bytes of `text` — what the container shim
|
||||
* (`printf '%s' "$data" | base64`) and Claude Code's own OSC 52 both send. */
|
||||
function utf8Base64(text: string): string {
|
||||
return Buffer.from(text, "utf8").toString("base64");
|
||||
}
|
||||
|
||||
describe("OSC 52 clipboard handler", () => {
|
||||
describe("decodeOsc52", () => {
|
||||
it("decodes a valid clipboard write sequence", () => {
|
||||
// "c;BASE64" where BASE64 encodes "https://example.com"
|
||||
const encoded = btoa("https://example.com");
|
||||
const result = handleOsc52(`c;${encoded}`);
|
||||
expect(result).toBe("https://example.com");
|
||||
expect(decodeOsc52(`c;${utf8Base64("https://example.com")}`)).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("decodes multi-line content", () => {
|
||||
const text = "line1\nline2\nline3";
|
||||
const encoded = btoa(text);
|
||||
const result = handleOsc52(`c;${encoded}`);
|
||||
expect(result).toBe(text);
|
||||
expect(decodeOsc52(`c;${utf8Base64(text)}`)).toBe(text);
|
||||
});
|
||||
|
||||
it("handles primary selection target (p)", () => {
|
||||
const encoded = btoa("selected text");
|
||||
const result = handleOsc52(`p;${encoded}`);
|
||||
expect(result).toBe("selected text");
|
||||
it("handles primary selection target (p) and an empty target", () => {
|
||||
expect(decodeOsc52(`p;${utf8Base64("selected text")}`)).toBe("selected text");
|
||||
expect(decodeOsc52(`;${utf8Base64("selected text")}`)).toBe("selected text");
|
||||
});
|
||||
|
||||
// The user-visible bug: `atob` returns one char per *byte*, so every
|
||||
// multi-byte UTF-8 sequence came out as Latin-1 mojibake (’ → "â\x80\x99",
|
||||
// é → "é"), which Windows apps render as `’`.
|
||||
it("decodes UTF-8 multi-byte text instead of mojibaking it", () => {
|
||||
const text = "it’s café — naïve 日本語 👍🏽 👨\u200D👩\u200D👧";
|
||||
const decoded = decodeOsc52(`c;${utf8Base64(text)}`);
|
||||
expect(decoded).toBe(text);
|
||||
expect(decoded).not.toContain("Ã");
|
||||
expect(decoded).not.toContain("â");
|
||||
});
|
||||
|
||||
// One bad or truncated byte must not re-mojibake the whole payload: only
|
||||
// the bad bytes become U+FFFD, the valid UTF-8 around them is kept.
|
||||
it("replaces only invalid bytes with U+FFFD, keeping the valid UTF-8", () => {
|
||||
const bytes = Buffer.concat([Buffer.from("it’s ", "utf8"), Buffer.from([0xff]), Buffer.from(" café", "utf8")]);
|
||||
expect(decodeOsc52(`c;${bytes.toString("base64")}`)).toBe("it’s \uFFFD café");
|
||||
});
|
||||
|
||||
it("keeps the text before a multi-byte sequence truncated at the end", () => {
|
||||
const full = Buffer.from("naïve ’", "utf8");
|
||||
const truncated = full.subarray(0, full.length - 1); // drop the last byte of ’
|
||||
expect(decodeOsc52(`c;${truncated.toString("base64")}`)).toBe("naïve \uFFFD");
|
||||
});
|
||||
|
||||
it("does not guess Latin-1 for a payload that is not UTF-8", () => {
|
||||
// Every sender this app sees sends UTF-8; a Latin-1 é is just a bad byte.
|
||||
const latin1 = Buffer.from([0x63, 0x61, 0x66, 0xe9]).toString("base64");
|
||||
expect(decodeOsc52(`c;${latin1}`)).toBe("caf\uFFFD");
|
||||
});
|
||||
|
||||
it("tolerates whitespace/newlines inside the base64 (base64 without -w0)", () => {
|
||||
const b64 = utf8Base64("x".repeat(100));
|
||||
const wrapped = b64.slice(0, 76) + "\n" + b64.slice(76);
|
||||
expect(decodeOsc52(`c;${wrapped}`)).toBe("x".repeat(100));
|
||||
});
|
||||
|
||||
it("returns null for clipboard read request (?)", () => {
|
||||
expect(handleOsc52("c;?")).toBe(null);
|
||||
expect(decodeOsc52("c;?")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for missing semicolon", () => {
|
||||
expect(handleOsc52("invalid")).toBe(null);
|
||||
expect(decodeOsc52("invalid")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for invalid base64", () => {
|
||||
expect(handleOsc52("c;!!!not-base64!!!")).toBe(null);
|
||||
expect(decodeOsc52("c;!!!not-base64!!!")).toBe(null);
|
||||
});
|
||||
|
||||
it("handles empty payload after selection target", () => {
|
||||
// btoa("") = ""
|
||||
const result = handleOsc52("c;");
|
||||
expect(result).toBe("");
|
||||
expect(decodeOsc52("c;")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Decodes the payload of an OSC 52 clipboard *write* (`Pc ; Pd`, where `Pd` is
|
||||
* base64) into the text to put on the clipboard.
|
||||
*
|
||||
* The base64 carries **bytes**, and every sender this app sees — the
|
||||
* container's xclip/xsel/pbcopy shim (`printf '%s' | base64`) and Claude Code's
|
||||
* own OSC 52 copy — sends UTF-8. `atob` alone returns one UTF-16 code unit per
|
||||
* byte, so it turned `’` into `â\x80\x99` and `é` into `é` (the `’` mojibake
|
||||
* users saw after pasting). The bytes are therefore decoded as UTF-8,
|
||||
* non-fatally: a bad or truncated byte becomes U+FFFD and only that byte is
|
||||
* lost. There is deliberately no Latin-1 fallback — re-reading the *whole*
|
||||
* payload as Latin-1 because of one bad byte brought the mojibake back for
|
||||
* everything else, and no sender here emits Latin-1.
|
||||
*
|
||||
* Returns `null` for a read request (`?`), a malformed sequence, or invalid
|
||||
* base64 — the caller writes nothing in those cases. Pure function.
|
||||
*/
|
||||
const UTF8 = new TextDecoder("utf-8");
|
||||
|
||||
export function decodeOsc52(data: string): string | null {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx === -1) return null;
|
||||
const payload = data.substring(idx + 1);
|
||||
if (payload === "?") return null; // clipboard read request, not supported
|
||||
|
||||
let binary: string;
|
||||
try {
|
||||
binary = atob(payload); // tolerates ASCII whitespace, as base64 without -w0 emits
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return UTF8.decode(bytes);
|
||||
}
|
||||
@@ -34,12 +34,23 @@ const KNOWN_BASENAMES = new Set([
|
||||
"README", "CHANGELOG", "PKGBUILD",
|
||||
]);
|
||||
|
||||
/**
|
||||
* The characters a path segment is made of, as a regex character-class body
|
||||
* (no brackets). Shared with `xtermLineJoin`, which must agree with `TOKEN` on
|
||||
* what counts as part of a path when it decides whether a row was broken
|
||||
* mid-path.
|
||||
*/
|
||||
export const PATH_SEGMENT_CLASS = String.raw`A-Za-z0-9_.\-~+@`;
|
||||
|
||||
/**
|
||||
* A candidate token: path characters, optionally starting with `/`, `./`, `../`
|
||||
* or `.` (dotfile). Excludes the wrapping characters the surrounding markdown
|
||||
* leaves (`(`, `)`, `[`, `]`, backtick, quotes) and whitespace.
|
||||
*/
|
||||
const TOKEN = /(?:\.{1,2}\/|\/)?[A-Za-z0-9_.\-~+@]+(?:\/[A-Za-z0-9_.\-~+@]+)*\/?/g;
|
||||
const TOKEN = new RegExp(
|
||||
String.raw`(?:\.{1,2}\/|\/)?[${PATH_SEGMENT_CLASS}]+(?:\/[${PATH_SEGMENT_CLASS}]+)*\/?`,
|
||||
"g",
|
||||
);
|
||||
const URL_SCHEME = /[a-z][a-z0-9+.-]*:\/\//gi;
|
||||
const LINE_SUFFIX = /^(?::(\d+)(?::(\d+))?(?:-(\d+))?|#L(\d+)(?:-L?(\d+))?)/;
|
||||
const VERSION_LIKE = /^v?\d+(\.\d+)+$/;
|
||||
|
||||
@@ -146,6 +146,62 @@ describe("UrlDetector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("UrlDetector — trailing punctuation", () => {
|
||||
it("leaves a full stop after the link out of the candidate", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
|
||||
feed(d, `Open ${SIGN_IN_URL}. Then paste the code.\r\n`);
|
||||
|
||||
expect(seen).toEqual([SIGN_IN_URL]);
|
||||
});
|
||||
|
||||
it("leaves wrapping brackets out of the candidate", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
|
||||
feed(d, `[Sign in](${SIGN_IN_URL}).\r\nmore\r\n`);
|
||||
|
||||
expect(seen).toEqual([SIGN_IN_URL]);
|
||||
});
|
||||
|
||||
it("trims a link that ends the buffer once it is confirmed", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
|
||||
feed(d, `See ${SIGN_IN_URL}.`);
|
||||
|
||||
expect(seen).toEqual([SIGN_IN_URL]);
|
||||
});
|
||||
|
||||
it("still waits for more when the buffer ends on a full stop", () => {
|
||||
// A `.` at the end of a chunk may be the middle of the URL (`file.tar.gz`)
|
||||
// with the rest still in flight. Whether to wait is decided by where the
|
||||
// raw text ran to, not by where the trimmed URL ends.
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => 1000);
|
||||
const head = "https://example.com/" + "a".repeat(90) + ".";
|
||||
const url = head + "tar.gz";
|
||||
|
||||
d.feed(enc.encode(head));
|
||||
vi.advanceTimersByTime(400); // scanned, but inside the confirmation wait
|
||||
d.feed(enc.encode("tar.gz\r\nnext\r\n"));
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
expect(seen).toEqual([url]);
|
||||
});
|
||||
|
||||
it("measures the length threshold on the trimmed URL", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
const url = "https://example.com/" + "a".repeat(79); // 99 characters
|
||||
|
||||
feed(d, `(${url}).\r\nmore\r\n`);
|
||||
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usableLink", () => {
|
||||
// Ports `usable_sign_in_link` from `commands/auth_token_commands.rs` — a junk
|
||||
// filter, not the security decision. `sanitizeRelayUrl` is still what stands
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
*/
|
||||
|
||||
import { extendsUrl } from "./urlRelay";
|
||||
import { findUrls } from "./urlMatch";
|
||||
|
||||
const ANSI_RE =
|
||||
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g;
|
||||
@@ -297,19 +298,19 @@ export class UrlDetector {
|
||||
// still re-validated by `sanitizeRelayUrl` before it can reach the opener;
|
||||
// stopping the match early only means the legitimate prefix survives
|
||||
// instead of the whole candidate being thrown away.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const urlRe = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/g;
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
while ((m = urlRe.exec(flat)) !== null) {
|
||||
const url = m[0];
|
||||
|
||||
//
|
||||
// `findUrls` also trims trailing prose punctuation and wrapping brackets
|
||||
// (`…/authorize?….` → no `.`), the same rule the terminal underline uses
|
||||
// — see `urlMatch.ts`. It reports the raw run's end separately, because
|
||||
// that, not the trimmed URL's end, is what says whether the text may
|
||||
// still be growing: a chunk ending `…/file.` may be followed by `tar.gz`.
|
||||
for (const { url, rawEnd } of findUrls(flat)) {
|
||||
// 4. Filter by length
|
||||
if (url.length < MIN_URL_LENGTH) continue;
|
||||
|
||||
// 5. If the match extends to the very end of the flattened string,
|
||||
// more chunks may still be arriving — defer emission.
|
||||
if (m.index + url.length >= flat.length) {
|
||||
if (rawEnd >= flat.length) {
|
||||
this.pendingUrl = url;
|
||||
this.confirmTimer = setTimeout(() => {
|
||||
this.confirmTimer = null;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { TERMINAL_URL_REGEX, findUrls, trimUrlCandidate } from "./urlMatch";
|
||||
|
||||
/**
|
||||
* Every URL `WebLinksAddon` would underline in `line`.
|
||||
*
|
||||
* Mirrors `LinkComputer.computeLink` in `@xterm/addon-web-links`: it rebuilds
|
||||
* the regex with the `g` flag added to whatever flags it was given and
|
||||
* `exec`s in a loop. Run the same way here so the test exercises the regex as
|
||||
* the addon will, not as a one-off `match` would.
|
||||
*/
|
||||
function underlined(line: string): string[] {
|
||||
const rex = new RegExp(TERMINAL_URL_REGEX.source, (TERMINAL_URL_REGEX.flags || "") + "g");
|
||||
const out: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = rex.exec(line))) out.push(m[0]);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("TERMINAL_URL_REGEX", () => {
|
||||
it("carries no g or y flag, which the addon adds itself", () => {
|
||||
expect(TERMINAL_URL_REGEX.flags).not.toMatch(/[gy]/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["See https://example.com/docs.", "https://example.com/docs"],
|
||||
["at https://example.com/docs, then", "https://example.com/docs"],
|
||||
["is it https://example.com/docs?", "https://example.com/docs"],
|
||||
["wow https://example.com/docs!", "https://example.com/docs"],
|
||||
["go to https://example.com/docs: it", "https://example.com/docs"],
|
||||
["https://example.com/docs; and", "https://example.com/docs"],
|
||||
["end https://example.com/docs...", "https://example.com/docs"],
|
||||
["(https://example.com/foo)", "https://example.com/foo"],
|
||||
["(see https://example.com/foo).", "https://example.com/foo"],
|
||||
["[text](https://x.y/z)", "https://x.y/z"],
|
||||
["[text](https://x.y/z).", "https://x.y/z"],
|
||||
["<https://x.example>", "https://x.example"],
|
||||
["[https://x.example/a]", "https://x.example/a"],
|
||||
["{https://x.example/a}", "https://x.example/a"],
|
||||
["\"https://x.example/a\"", "https://x.example/a"],
|
||||
["'https://x.example/a'", "https://x.example/a"],
|
||||
["`https://x.example/a`", "https://x.example/a"],
|
||||
["**https://x.example/a**", "https://x.example/a"],
|
||||
["https://x.example/a/", "https://x.example/a/"],
|
||||
])("leaves the trailing punctuation off %j", (line, url) => {
|
||||
expect(underlined(line)).toEqual([url]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
"https://example.com/search?q=a.b&x=1",
|
||||
"https://example.com/a,b/c",
|
||||
"https://example.com/path/file.tar.gz",
|
||||
"https://example.com/#/route",
|
||||
"https://example.com/a?arr[]=1&arr[]=2",
|
||||
"http://[::1]:8080/x",
|
||||
"https://example.com/a)b",
|
||||
"https://example.com/a(b",
|
||||
"https://example.com/api/{id}/x",
|
||||
// OAuth state and code_challenge are base64url, which can end in `_`, `-`
|
||||
// or `=` padding. None of those may be trimmed.
|
||||
"https://claude.ai/oauth/authorize?code=true&state=abc-_",
|
||||
"https://claude.ai/oauth/authorize?code_challenge=abc-",
|
||||
"https://example.com/x?t=YWJj==",
|
||||
"https://example.com/~user",
|
||||
])("keeps %j whole", (url) => {
|
||||
expect(underlined(url)).toEqual([url]);
|
||||
expect(underlined(`see ${url} now`)).toEqual([url]);
|
||||
});
|
||||
|
||||
it("keeps a balanced paren and drops the one that wraps the link", () => {
|
||||
expect(underlined("(https://en.wikipedia.org/wiki/Foo_(bar))")).toEqual([
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
]);
|
||||
expect(underlined("[Foo](https://en.wikipedia.org/wiki/Foo_(bar)).")).toEqual([
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds several links on one line", () => {
|
||||
expect(underlined("a https://a.example/1, b (https://b.example/2).")).toEqual([
|
||||
"https://a.example/1",
|
||||
"https://b.example/2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches nothing after a bare scheme", () => {
|
||||
expect(underlined("https://.")).toEqual([]);
|
||||
expect(underlined("https:// x")).toEqual([]);
|
||||
});
|
||||
|
||||
it("still stops at control characters", () => {
|
||||
expect(underlined("https://example.com/a\x1bb")).toEqual(["https://example.com/a"]);
|
||||
expect(underlined("https://example.com/a\x00b")).toEqual(["https://example.com/a"]);
|
||||
expect(underlined("https://example.com/a\x7fb")).toEqual(["https://example.com/a"]);
|
||||
});
|
||||
|
||||
it("does not hang on a long run of punctuation", () => {
|
||||
const line = "https://example.com/" + "(.".repeat(5000) + ")".repeat(5000);
|
||||
const t0 = Date.now();
|
||||
underlined(line);
|
||||
expect(Date.now() - t0).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findUrls", () => {
|
||||
it("returns the trimmed URL and the extent of the raw run", () => {
|
||||
expect(findUrls("See https://example.com/docs. next")).toEqual([
|
||||
{ url: "https://example.com/docs", index: 4, rawEnd: 29 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trimUrlCandidate", () => {
|
||||
it("trims trailing punctuation and unbalanced closers", () => {
|
||||
expect(trimUrlCandidate("https://example.com/docs.")).toBe("https://example.com/docs");
|
||||
expect(trimUrlCandidate("https://example.com/foo).")).toBe("https://example.com/foo");
|
||||
expect(trimUrlCandidate("https://en.wikipedia.org/wiki/Foo_(bar)")).toBe(
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for something that is not a URL at all", () => {
|
||||
expect(trimUrlCandidate("https://.")).toBeNull();
|
||||
expect(trimUrlCandidate("not a url")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Where a plain-text URL in terminal output starts and ends.
|
||||
*
|
||||
* The old pattern, `https?:\/\/[^\s'"`<>\x00-\x20\x7f]+`, ran to the next
|
||||
* whitespace, so the prose around a link came with it: `See https://x/docs.`
|
||||
* underlined and opened `https://x/docs.`, `(https://x/a)` opened
|
||||
* `https://x/a)`, and a markdown link `[t](https://x/a)` the same. This uses
|
||||
* the rules GitHub's autolinker and linkify apply:
|
||||
*
|
||||
* - A URL never *ends* in `.` `,` `;` `:` `!` `?` `*` — those are sentence
|
||||
* punctuation (and markdown emphasis) when they come last. In the middle
|
||||
* (`a.b`, `a,b`, `?q=`) they are kept.
|
||||
* - A closing `)` `]` `}` is kept only when it closes a bracket opened inside
|
||||
* the URL — `https://en.wikipedia.org/wiki/Foo_(bar)` keeps its paren,
|
||||
* `(https://x/a)` loses the one that wraps it. A closer followed by more URL
|
||||
* (`/a)b`) is kept either way, since it cannot be wrapping anything.
|
||||
* - `-` `_` `~` `=` `/` are never trimmed: OAuth `state` and
|
||||
* `code_challenge` are base64url, which can end in any of the first two,
|
||||
* and `=` is base64 padding.
|
||||
*
|
||||
* It is a single regex rather than a match-then-trim because `WebLinksAddon`
|
||||
* underlines exactly what its regex matches: trimming only the URL handed to
|
||||
* the click handler would still paint the `.` as part of the link. The
|
||||
* bracket rule is one level deep, which covers every real case (Wikipedia
|
||||
* disambiguations, IPv6 hosts, `a[]=` query keys) without needing a parser.
|
||||
*
|
||||
* The set of characters a URL may contain at all is unchanged — whitespace,
|
||||
* quotes, backtick, `<` `>`, the whole C0 range and DEL still end a match —
|
||||
* and nothing here is a security decision: `sanitizeRelayUrl` still decides
|
||||
* what may be opened.
|
||||
*/
|
||||
|
||||
/** Any character a URL may contain, minus brackets (handled separately). */
|
||||
const C = "[^\\s'\"`<>()\\[\\]{}\\x00-\\x20\\x7f]";
|
||||
/** A character a URL may end with: `C` minus trailing punctuation. */
|
||||
const END_CHAR = "[^\\s'\"`<>()\\[\\]{}\\x00-\\x20\\x7f.,;:!?*]";
|
||||
/** A one-level balanced bracket group: `(bar)`, `[::1]`, `[]`, `{id}`. */
|
||||
const GROUP = `\\(${C}*\\)|\\[${C}*\\]|\\{${C}*\\}`;
|
||||
/** An opener that starts no balanced group (`/a(b`), so it is plain text. */
|
||||
const LONE_OPEN = `\\((?!${C}*\\))|\\[(?!${C}*\\])|\\{(?!${C}*\\})`;
|
||||
/** A closer, allowed mid-URL; the final position excludes it unless balanced. */
|
||||
const CLOSE = "[)\\]}]";
|
||||
|
||||
const URL_SOURCE = `https?:\\/\\/(?:${GROUP}|${C}|${LONE_OPEN}|${CLOSE})*(?:${GROUP}|${END_CHAR})`;
|
||||
|
||||
/**
|
||||
* The regex handed to `WebLinksAddon` as `urlRegex`.
|
||||
*
|
||||
* No flags: `LinkComputer.computeLink` rebuilds it with `g` appended to
|
||||
* whatever flags it has, so giving it `g` here would be redundant and `y`
|
||||
* would break the scan.
|
||||
*/
|
||||
export const TERMINAL_URL_REGEX = new RegExp(URL_SOURCE);
|
||||
|
||||
const ANCHORED = new RegExp(`^(?:${URL_SOURCE})`);
|
||||
|
||||
/**
|
||||
* The maximal run of URL characters starting at a scheme — the old pattern.
|
||||
* Still useful for knowing how far the raw text ran, e.g. whether it reached
|
||||
* the end of a buffer that may still be growing.
|
||||
*/
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const RAW_RUN = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/g;
|
||||
|
||||
/**
|
||||
* The URL at the start of `candidate`, with trailing punctuation and
|
||||
* unbalanced closers removed; `null` if nothing URL-shaped is left.
|
||||
*/
|
||||
export function trimUrlCandidate(candidate: string): string | null {
|
||||
const m = ANCHORED.exec(candidate);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
export interface UrlMatch {
|
||||
/** The URL as it should be shown and opened. */
|
||||
url: string;
|
||||
/** Where it starts in the input. */
|
||||
index: number;
|
||||
/** Where the raw run of URL characters ended, trailing punctuation included. */
|
||||
rawEnd: number;
|
||||
}
|
||||
|
||||
/** Every URL in `text`, trimmed, with the extent of the raw run it came from. */
|
||||
export function findUrls(text: string): UrlMatch[] {
|
||||
const out: UrlMatch[] = [];
|
||||
RAW_RUN.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = RAW_RUN.exec(text)) !== null) {
|
||||
const url = trimUrlCandidate(m[0]);
|
||||
if (url) out.push({ url, index: m.index, rawEnd: m.index + m[0].length });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const buffer = (rows: Array<[string, boolean]>): RowSource => ({
|
||||
describe("joinWrappedRows", () => {
|
||||
it("returns a single unwrapped row as-is", () => {
|
||||
const j = joinWrappedRows(buffer([["hello src/a.ts", false]]), 0);
|
||||
expect(j).toEqual({ text: "hello src/a.ts", firstRow: 0, rowStarts: [0] });
|
||||
expect(j).toEqual({ text: "hello src/a.ts", firstRow: 0, rowStarts: [0], rowIndents: [0] });
|
||||
});
|
||||
|
||||
it("walks up to the row that started the wrap and down through continuations", () => {
|
||||
@@ -38,6 +38,120 @@ describe("joinWrappedRows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinWrappedRows on a long chain", () => {
|
||||
const cols = 40;
|
||||
/** 1000 hard-wrapped rows: row 0 at column 0, every later row indented 2. */
|
||||
const chainRows = (): Array<[string, boolean]> => {
|
||||
const rows: Array<[string, boolean]> = [["/" + "a".repeat(cols - 1), false]];
|
||||
for (let i = 1; i < 1000; i++) rows.push([" " + String(i).padStart(4, "0") + "b".repeat(cols - 6), false]);
|
||||
return rows;
|
||||
};
|
||||
const counting = (rows: Array<[string, boolean]>) => {
|
||||
const b = buffer(rows);
|
||||
let reads = 0;
|
||||
return {
|
||||
reads: () => reads,
|
||||
source: { getLine: (y: number) => (reads++, b.getLine(y)) } as RowSource,
|
||||
};
|
||||
};
|
||||
|
||||
it("always includes the hovered row, deep in a chain longer than the budget", () => {
|
||||
const rows = chainRows();
|
||||
for (const row of [45, 300, 999]) {
|
||||
const j = joinWrappedRows(buffer(rows), row, { cols });
|
||||
expect(j.firstRow).toBeLessThanOrEqual(row);
|
||||
expect(j.firstRow + j.rowStarts.length).toBeGreaterThan(row);
|
||||
expect(j.text).toContain(String(row).padStart(4, "0"));
|
||||
expect(j.text.length).toBeLessThanOrEqual(MAX_JOINED_LENGTH);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops a continuation's indent when the window starts mid-chain", () => {
|
||||
const j = joinWrappedRows(buffer(chainRows()), 500, { cols });
|
||||
expect(j.firstRow).toBeGreaterThan(0);
|
||||
expect(j.rowIndents[0]).toBe(2);
|
||||
expect(j.text.startsWith(" ")).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a bounded number of rows, not the whole chain above", () => {
|
||||
const c = counting(chainRows());
|
||||
joinWrappedRows(c.source, 999, { cols });
|
||||
// The window holds at most MAX_JOINED_LENGTH / (cols - 2) rows; each is
|
||||
// read a few times by the walk. Reading all 1000 rows would be far more.
|
||||
expect(c.reads()).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Claude Code wraps its own output: a word longer than the line is broken
|
||||
* mid-word and the rest is drawn on the next row with cursor moves, so xterm
|
||||
* never marks that row `isWrapped`. Captured from Claude Code at 70 columns
|
||||
* (bytes: `...9329-e\r\x1b[2C\x1b[1B039e2a...`).
|
||||
*/
|
||||
const CLAUDE_ROW_1 = "\u25cf The file is at /tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e";
|
||||
const CLAUDE_ROW_2 = " 039e2a07562/scratchpad/notes.md ok";
|
||||
const SCRATCH = "/tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e039e2a07562/scratchpad/notes.md";
|
||||
|
||||
describe("joinWrappedRows across Claude Code's hard wraps", () => {
|
||||
it("the captured row really does end at the right edge", () => {
|
||||
expect(CLAUDE_ROW_1.length).toBe(70);
|
||||
});
|
||||
|
||||
it("joins a path broken mid-word onto the next row, dropping the indent", () => {
|
||||
const b = buffer([["before", false], [CLAUDE_ROW_1, false], [CLAUDE_ROW_2, false], ["after", false]]);
|
||||
const j = joinWrappedRows(b, 1, { cols: 70 });
|
||||
expect(j.text).toBe(`\u25cf The file is at ${SCRATCH} ok`);
|
||||
expect(j.firstRow).toBe(1);
|
||||
expect(j.rowStarts).toEqual([0, 70]);
|
||||
expect(j.rowIndents).toEqual([0, 2]);
|
||||
// Asked from the continuation row, the same line comes back.
|
||||
expect(joinWrappedRows(b, 2, { cols: 70 })).toEqual(j);
|
||||
// The first character of the continuation is drawn at column 3.
|
||||
expect(offsetToCell(j, 70)).toEqual({ x: 3, y: 3 });
|
||||
});
|
||||
|
||||
it("joins a path broken over three rows", () => {
|
||||
const cols = 30;
|
||||
const path = "/tmp/" + "a".repeat(60) + "/notes.md"; // 74 chars
|
||||
const rows: Array<[string, boolean]> = [
|
||||
[path.slice(0, 30), false],
|
||||
[" " + path.slice(30, 58), false],
|
||||
[" " + path.slice(58), false],
|
||||
];
|
||||
const j = joinWrappedRows(buffer(rows), 2, { cols });
|
||||
expect(j.text).toBe(path);
|
||||
expect(j.rowIndents).toEqual([0, 2, 2]);
|
||||
});
|
||||
|
||||
it("does not glue an ordinary word at the edge to a short word below it", () => {
|
||||
const cols = 40;
|
||||
const row1 = "Run the tests and then go and look at it"; // ends at the edge
|
||||
expect(row1.length).toBe(cols);
|
||||
const b = buffer([[row1, false], [" src/main.rs for details", false]]);
|
||||
expect(joinWrappedRows(b, 0, { cols }).text).toBe(row1);
|
||||
expect(joinWrappedRows(b, 1, { cols }).text).toBe(" src/main.rs for details");
|
||||
});
|
||||
|
||||
it("does not glue two column-0 lines together (continuations are indented)", () => {
|
||||
const cols = 40;
|
||||
const row0 = "/var/log/some/very/long/dir/file-aaa.txt";
|
||||
expect(row0.length).toBe(cols);
|
||||
const b = buffer([[row0, false], ["src/main.rs", false]]);
|
||||
expect(joinWrappedRows(b, 0, { cols }).text).toBe(row0);
|
||||
expect(joinWrappedRows(b, 1, { cols }).text).toBe("src/main.rs");
|
||||
});
|
||||
|
||||
it("does not bridge a row that stops short of the edge", () => {
|
||||
const b = buffer([["/tmp/claude-1000/-workspace/abc", false], ["def/notes.md", false]]);
|
||||
expect(joinWrappedRows(b, 1, { cols: 70 }).text).toBe("def/notes.md");
|
||||
});
|
||||
|
||||
it("does not bridge without knowing the width", () => {
|
||||
const b = buffer([[CLAUDE_ROW_1, false], [CLAUDE_ROW_2, false]]);
|
||||
expect(joinWrappedRows(b, 0).text).toBe(CLAUDE_ROW_1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("offsetToCell", () => {
|
||||
it("maps offsets to 1-based cells on the right row", () => {
|
||||
const j = { text: "abcdefgh", firstRow: 4, rowStarts: [0, 3, 6] };
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* Joins an xterm buffer row with its wrapped continuations.
|
||||
*
|
||||
* Two kinds: xterm's own soft wraps (`isWrapped`), and — when the caller
|
||||
* passes the terminal width — the mid-word breaks Claude Code makes itself,
|
||||
* which xterm cannot see (see `hardWrapIndent`).
|
||||
*
|
||||
* `WebLinksAddon` does the same in its private `LinkComputer`, which the
|
||||
* built package does not export — so the walk is repeated here, with the same
|
||||
* 2048-character budget. Rows are read with `translateToString(true)`, which
|
||||
@@ -13,6 +17,8 @@
|
||||
* lands a cell early; the click still resolves the same link).
|
||||
*/
|
||||
|
||||
import { PATH_SEGMENT_CLASS } from "./filePathLinks";
|
||||
|
||||
export const MAX_JOINED_LENGTH = 2048;
|
||||
|
||||
/** Minimal slice of xterm's IBuffer this needs. */
|
||||
@@ -20,28 +26,118 @@ export interface RowSource {
|
||||
getLine(y: number): { isWrapped: boolean; translateToString(trimRight?: boolean): string } | undefined;
|
||||
}
|
||||
|
||||
export interface JoinOptions {
|
||||
/**
|
||||
* The terminal's width. Given, rows that Claude Code broke mid-word are
|
||||
* joined too (see `hardWrapIndent`); omitted, only xterm's soft wraps are.
|
||||
*/
|
||||
cols?: number;
|
||||
}
|
||||
|
||||
export interface JoinedLine {
|
||||
text: string;
|
||||
/** 0-based index of the first buffer row that contributed. */
|
||||
firstRow: number;
|
||||
/** For each contributed row (in order), the string offset at which it starts. */
|
||||
rowStarts: number[];
|
||||
/**
|
||||
* For each contributed row, how many leading cells were left out of `text`:
|
||||
* the indent of a hard-wrapped continuation, 0 for every other row.
|
||||
*/
|
||||
rowIndents: number[];
|
||||
}
|
||||
|
||||
export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine {
|
||||
/** The characters `findFilePathLinks`' token is made of: segment characters and `/`. */
|
||||
const PATH_CLASS = `[${PATH_SEGMENT_CLASS}/]`;
|
||||
const PATH_CHAR = new RegExp(PATH_CLASS);
|
||||
const TRAILING_PATH_RUN = new RegExp(`${PATH_CLASS}+$`);
|
||||
const LEADING_PATH_RUN = new RegExp(`^${PATH_CLASS}+`);
|
||||
/** Cells a full row may stop short of the right edge (Ink boxes pad by one or two). */
|
||||
const EDGE_SLACK = 2;
|
||||
/** Claude Code draws its transcript (and so every continuation row) at least this far in. */
|
||||
const MIN_CONTINUATION_INDENT = 2;
|
||||
|
||||
/**
|
||||
* Is `next` the rest of a word that Claude Code broke at the end of `prev`?
|
||||
* Returns the continuation's indent (cells to skip), or `null`.
|
||||
*
|
||||
* Claude Code wraps its own output (Ink / wrap-ansi) and draws continuation
|
||||
* rows with cursor moves, so xterm never marks them `isWrapped` — captured at
|
||||
* 70 columns: `…/5e4ab145-915e-4902-9329-e\r\x1b[2C\x1b[1B039e2a…/notes.md`.
|
||||
* It breaks *inside* a word only when the word is longer than the line, which
|
||||
* is exactly what a long `/tmp/claude-<uid>/…` path is. So bridge only when
|
||||
* `prev` runs to (within {@link EDGE_SLACK} of) the right edge and ends in a
|
||||
* path character, `next` starts (after an indent of at least
|
||||
* {@link MIN_CONTINUATION_INDENT} cells — so two column-0 lines of shell
|
||||
* output are never glued) with one, and the two runs
|
||||
* together would not have fit on one line — i.e. word wrap could not have
|
||||
* moved the word down whole. That last test is what keeps ordinary prose (a
|
||||
* word that happens to end at the edge above `src/main.rs`) from being glued.
|
||||
*/
|
||||
function hardWrapIndent(prev: string, next: string, cols: number): number | null {
|
||||
if (prev.length < cols - EDGE_SLACK || prev.length > cols) return null;
|
||||
if (!PATH_CHAR.test(prev[prev.length - 1])) return null;
|
||||
const indent = next.search(/\S/);
|
||||
if (indent < MIN_CONTINUATION_INDENT || !PATH_CHAR.test(next[indent])) return null;
|
||||
const tail = TRAILING_PATH_RUN.exec(prev)?.[0].length ?? 0;
|
||||
const head = LEADING_PATH_RUN.exec(next.slice(indent))?.[0].length ?? 0;
|
||||
if (tail + head <= cols - indent - EDGE_SLACK) return null;
|
||||
return indent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cells to skip if row `y` continues row `y - 1`: 0 for an xterm soft wrap,
|
||||
* the indent for a Claude Code hard wrap, `null` if `y` starts a new line.
|
||||
*/
|
||||
function continuationIndent(buffer: RowSource, y: number, cols: number | undefined): number | null {
|
||||
if (y <= 0) return null;
|
||||
const line = buffer.getLine(y);
|
||||
if (!line) return null;
|
||||
if (line.isWrapped) return 0;
|
||||
if (!cols) return null;
|
||||
const prev = buffer.getLine(y - 1);
|
||||
if (!prev) return null;
|
||||
return hardWrapIndent(prev.translateToString(true), line.translateToString(true), cols);
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins `row` with the rows it wraps from and into. A chain longer than
|
||||
* {@link MAX_JOINED_LENGTH} is windowed around `row`: the walk up stops after
|
||||
* half the budget, the walk down after the whole of it, and `row` itself is
|
||||
* always included — so a hover deep in a huge wrapped blob reads a bounded
|
||||
* number of rows and still finds the text under the pointer.
|
||||
*/
|
||||
export function joinWrappedRows(buffer: RowSource, row: number, options: JoinOptions = {}): JoinedLine {
|
||||
const { cols } = options;
|
||||
let top = row;
|
||||
while (top > 0 && buffer.getLine(top)?.isWrapped) top--;
|
||||
let above = 0;
|
||||
while (top > 0 && continuationIndent(buffer, top, cols) !== null) {
|
||||
// Every row counts at least 1, so a run of blank soft-wrapped rows is bounded too.
|
||||
const len = Math.max(1, buffer.getLine(top - 1)?.translateToString(true).length ?? 0);
|
||||
if (above + len > MAX_JOINED_LENGTH / 2) break;
|
||||
above += len;
|
||||
top--;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
const rowIndents: number[] = [];
|
||||
let length = 0;
|
||||
let y = top;
|
||||
for (;;) {
|
||||
const line = buffer.getLine(y);
|
||||
if (!line) break;
|
||||
if (y !== top && !line.isWrapped) break;
|
||||
const text = line.translateToString(true);
|
||||
if (length + text.length > MAX_JOINED_LENGTH && parts.length > 0) break;
|
||||
let indent = 0;
|
||||
const c = continuationIndent(buffer, y, cols);
|
||||
if (y !== top) {
|
||||
if (c === null) break;
|
||||
indent = c;
|
||||
} else if (c !== null) {
|
||||
indent = c; // the window starts mid-chain: drop this row's indent as well
|
||||
}
|
||||
const text = line.translateToString(true).slice(indent);
|
||||
if (length + text.length > MAX_JOINED_LENGTH && y > row) break;
|
||||
parts.push(text);
|
||||
rowIndents.push(indent);
|
||||
length += text.length;
|
||||
y++;
|
||||
}
|
||||
@@ -52,14 +148,18 @@ export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine {
|
||||
rowStarts.push(offset);
|
||||
offset += p.length;
|
||||
}
|
||||
return { text: parts.join(""), firstRow: top, rowStarts };
|
||||
return { text: parts.join(""), firstRow: top, rowStarts, rowIndents };
|
||||
}
|
||||
|
||||
/** String offset → 1-based {x, y} cell (y is the buffer row + 1). */
|
||||
export function offsetToCell(joined: JoinedLine, offset: number): { x: number; y: number } {
|
||||
export function offsetToCell(
|
||||
joined: Pick<JoinedLine, "firstRow" | "rowStarts"> & Partial<Pick<JoinedLine, "rowIndents">>,
|
||||
offset: number,
|
||||
): { x: number; y: number } {
|
||||
let rowIdx = 0;
|
||||
for (let i = 0; i < joined.rowStarts.length; i++) {
|
||||
if (joined.rowStarts[i] <= offset) rowIdx = i;
|
||||
}
|
||||
return { x: offset - joined.rowStarts[rowIdx] + 1, y: joined.firstRow + rowIdx + 1 };
|
||||
const indent = joined.rowIndents?.[rowIdx] ?? 0;
|
||||
return { x: offset - joined.rowStarts[rowIdx] + indent + 1, y: joined.firstRow + rowIdx + 1 };
|
||||
}
|
||||
+25
-2
@@ -28,12 +28,33 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
# archive.ubuntu.com returns stale Packages.gz files with mismatched hashes
|
||||
# during hourly resyncs. Clearing /var/lib/apt/lists/* between attempts
|
||||
# forces a fresh fetch.
|
||||
RUN for i in 1 2 3 4 5; do \
|
||||
#
|
||||
# `apt-get upgrade` is what picks up security fixes for packages the
|
||||
# `ubuntu:24.04` image already ships. `install` below never touches those, so
|
||||
# without it a fix that lands in noble-security between two refreshes of the
|
||||
# base tag stays out of this image until the next refresh. Packages the later
|
||||
# layers install come straight from the archive, so this one upgrade is enough.
|
||||
#
|
||||
# CI builds with `cache-from: type=gha`, so this layer (and everything after
|
||||
# it) only re-runs when its cache key changes: the base digest, this layer's
|
||||
# text, or `APT_REFRESH`. CI sets that to the ISO week (`2026-W45`) and also
|
||||
# rebuilds weekly on a schedule, so a security fix published after the last
|
||||
# commit is in the image within a week. An ARG only changes the key of a RUN
|
||||
# that uses it, hence the `echo`. Local builds leave it empty.
|
||||
#
|
||||
# Scanned on 2026-11-02, the published image of 2026-10-16 carried
|
||||
# libbrotli1 1.1.0-2build2 (CVE-2026-111497, HIGH) and zlib1g 1.3.dfsg-3.1ubuntu2.2
|
||||
# (CVE-2026-85091), both with fixed versions in the archive, and pnpm's bundled
|
||||
# undici 6.29.0 (three HIGH CVEs, fixed in the pnpm 12.16.1 a rebuild installs).
|
||||
ARG APT_REFRESH=
|
||||
RUN echo "apt refresh: ${APT_REFRESH:-unset}" \
|
||||
&& for i in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=3 update && break; \
|
||||
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
sleep 10; \
|
||||
done \
|
||||
&& apt-get -o Acquire::Retries=3 upgrade -y \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
@@ -274,7 +295,9 @@ RUN npx --yes playwright@latest --version \
|
||||
&& rm -rf /var/lib/apt/lists/* /root/.npm
|
||||
|
||||
# ── Python 3 + pip + uv + ruff ──────────────────────────────────────────────
|
||||
RUN for i in 1 2 3 4 5; do \
|
||||
ARG APT_REFRESH=
|
||||
RUN echo "apt refresh: ${APT_REFRESH:-unset}" \
|
||||
&& for i in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=3 update && break; \
|
||||
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
|
||||
@@ -28,19 +28,26 @@
|
||||
# to keep it that way: a floating tag re-resolves, and this container ends up
|
||||
# holding an OpenAI API key.
|
||||
#
|
||||
# v1.96.0 is chosen rather than the nearest clean post-incident release (1.83.0)
|
||||
# v1.106.6 is chosen rather than the nearest clean post-incident release (1.83.0)
|
||||
# because the intervening releases fix a run of *ordinary* proxy CVEs that matter
|
||||
# for a gateway published on a host port: SQL injection in API-key verification
|
||||
# (CVE-2026-42208, fixed 1.83.7), auth bypass via Host header injection
|
||||
# (CVE-2026-49468, fixed 1.84.0) and MCP auth bypass (CVE-2026-59822, fixed
|
||||
# 1.84.0). 1.84.0 is the floor; v1.96.0 is the newest release with no open OSV
|
||||
# advisories at the time of writing. Note LiteLLM has retired the `main-*` tag
|
||||
# family (`main-latest` is no longer updated, `main-stable` is deprecated) in
|
||||
# favour of plain semver tags, which is why this is `v1.96.0` and not
|
||||
# `main-v1.96.0-stable`.
|
||||
# (CVE-2026-49468, fixed 1.84.0), MCP auth bypass (CVE-2026-59822, fixed
|
||||
# 1.84.0), authenticated SSRF and provider-credential exfiltration via
|
||||
# unvalidated request-body URLs (CVE-2026-84377 / GHSA-3cv6-jpf6-8222, fixed
|
||||
# 1.96.2 on the line this file previously pinned) and privilege escalation to
|
||||
# proxy admin via cross-domain reuse of the salt key (CVE-2026-114612 /
|
||||
# GHSA-7hp6-4w63-5g45, fixed 1.103.1; no 1.96.x fix). The SSRF one is the
|
||||
# sharpest for Triple-C: this container holds a billed provider key. v1.106.6 is
|
||||
# the newest stable release with no open OSV advisories at the time of writing
|
||||
# (2026-11-02), and its digest was checked with
|
||||
# `cosign verify --key https://raw.githubusercontent.com/BerriAI/litellm/e82a3c646908ba0a6ee0beddc4310d7a6cac4065/cosign.pub`.
|
||||
# Note LiteLLM has retired the `main-*` tag family (`main-latest` is no longer
|
||||
# updated, `main-stable` is deprecated) in favour of plain semver tags, which is
|
||||
# why this is `v1.106.6` and not `main-v1.106.6-stable`.
|
||||
#
|
||||
# Bump deliberately, never automatically, and re-check the digest when you do.
|
||||
FROM ghcr.io/berriai/litellm:v1.96.0@sha256:90d8de0ea6fbb3cad145d1019d00a0149ae400b1e18e2011a60f1988f143f672
|
||||
FROM ghcr.io/berriai/litellm:v1.106.6@sha256:37e2dafb088a7723beb998590682957386dfc83ffea1848486f8e0e5cfac0b06
|
||||
|
||||
# A placeholder config so the image is runnable on its own. Triple-C overwrites
|
||||
# /etc/litellm/config.yaml (a named volume) with the generated one before the
|
||||
|
||||
@@ -235,6 +235,16 @@ done
|
||||
|
||||
echo "Repacking."
|
||||
|
||||
# The AppDir must be readable (and its directories and programs executable) by
|
||||
# everyone: the image is mounted with these modes and run by users other than
|
||||
# the packager. `--appimage-extract` writes it under the runner's umask, which
|
||||
# on some runners is 007 — the extracted root came out 0700 and AppRun.wrapped
|
||||
# 0770, and we used to repack exactly that. appimagetool's continuous build of
|
||||
# 2026-11-01 started refusing such an AppDir, which is right, so fix the modes
|
||||
# rather than pass --no-check-permissions. `a+rX` adds x only where some x bit
|
||||
# is already set (directories and programs), and grants no write to anyone.
|
||||
chmod -R a+rX "$root"
|
||||
|
||||
tool="$work/appimagetool"
|
||||
curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL"
|
||||
chmod +x "$tool"
|
||||
@@ -271,6 +281,15 @@ if [ "$demoted" = true ]; then
|
||||
fi
|
||||
[ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c."
|
||||
|
||||
# Every file readable by all, every program executable by all — what the
|
||||
# `chmod -R a+rX` before the repack promised (AppRun.wrapped used to ship 0770).
|
||||
# Files only: the AppImage runtime's --appimage-extract creates every
|
||||
# *directory* 0700 whatever the image stores (verified by packing a 0755
|
||||
# AppDir and extracting it under umask 022), so directory modes cannot be
|
||||
# checked from an extraction. appimagetool's own check covers them pre-pack.
|
||||
unreadable="$(find "$out" -type f \( ! -perm -a+r -o \( -perm /111 ! -perm -a+x \) \) -print -quit)"
|
||||
[ -z "$unreadable" ] || fail "${unreadable#"$out"/} is not readable/executable by all users."
|
||||
|
||||
# An empty Categories or missing metadata ships an image a manager cannot file
|
||||
# or describe, and both fail silently at runtime rather than at build time.
|
||||
# Asserted positively, over every entry: the earlier form checked only that no
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
# The python:3.11 images freeze the pip and setuptools that shipped with that
|
||||
# CPython branch (pip 24.0, setuptools 79), and both have since collected
|
||||
# advisories: pip path traversal / tar-symlink / index-URL handling
|
||||
# (CVE-2025-8869, CVE-2026-1703, CVE-2026-3219, CVE-2026-6357, CVE-2026-8643,
|
||||
# CVE-2026-13346) and setuptools sdist/legacy-wheel path issues
|
||||
# (CVE-2026-59890, GHSA-xmj3-9gf7-h52w). Upgrade them first, so the installs
|
||||
# below also run under a patched pip.
|
||||
RUN pip install --no-cache-dir --upgrade "pip>=26.2" "setuptools>=84.1" \
|
||||
&& pip install --no-cache-dir \
|
||||
faster-whisper \
|
||||
fastapi \
|
||||
uvicorn[standard] \
|
||||
|
||||
Reference in new issue
Block a user