Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb4c2eaad9 | ||
|
|
cfde4e91a1 | ||
|
|
5082bd47ee | ||
|
|
5d70140d62 | ||
|
|
299c6bcfcb | ||
|
|
6be9ab0aea | ||
|
|
0e4e446fe4 | ||
|
|
008b6623f6 | ||
|
|
f8cd33d19d | ||
|
|
2e40ebb08b | ||
|
|
fcb7588312 | ||
|
|
9ab038f573 | ||
|
|
dd5711f24f | ||
|
|
a690987ea2 | ||
|
|
95d4f19650 | ||
|
|
99e09cfaf5 | ||
|
|
0c580afb5f | ||
|
|
c61977cb70 | ||
|
|
781fb44659 | ||
|
|
a640816c38 | ||
|
|
256ff2528e | ||
|
|
2820dd5f2b | ||
|
|
11b5a47ef0 | ||
|
|
bc93b540cd | ||
|
|
0189009822 | ||
|
|
206711bed5 | ||
|
|
443de79e2f | ||
|
|
be0983237e | ||
|
|
3ccb6e5e91 |
No files matched your search
@@ -17,8 +17,9 @@ import) or any `KIMI_*` env var.
|
||||
Upstream sync record (update on every sync): fork baseline is upstream
|
||||
snapshot `8adf901` (2026-07-16, ≈v0.2.102); bug fixes evaluated and
|
||||
selectively ported through upstream `77cd7eb` (2026-08-25, ≈v1.0.10) in
|
||||
the 0.1.13 cycle and through `3794978` (2026-09-09, ≈v1.0.24) in the
|
||||
0.1.15 cycle. Upstream is daily "Synced from monorepo" snapshots —
|
||||
the 0.1.13 cycle, through `3794978` (2026-09-09, ≈v1.0.24) in the 0.1.15
|
||||
cycle, and through `a28ee2b` (2026-09-17, ≈v1.0.35) in the 0.1.16 cycle.
|
||||
Upstream is daily "Synced from monorepo" snapshots —
|
||||
the commit BODIES carry per-change bullet lists, and
|
||||
`crates/codegen/xai-grok-shell/changelogs/` maps releases to dates;
|
||||
start any future sync from those two, not from raw diffs.
|
||||
@@ -30,6 +31,23 @@ that stops reading still blocks exit-path prints behind std's stderr lock
|
||||
builder re-checks `Path::exists("/data")` and can omit the devbox bind on
|
||||
a stat error that the profile classification treated as present.
|
||||
|
||||
Known limitation left by the 0.1.16 sync: `notifications/cancelled` for a
|
||||
timed-out MCP call cannot reach a streamable-HTTP server that answers
|
||||
`tools/call` with a plain JSON body. rmcp 2.2.0's HTTP worker awaits each
|
||||
POST in turn, so the cancel queues behind the hung call;
|
||||
`servers/cancel_aware.rs` waits 500 ms, warns, and the transport reset
|
||||
then drops the connection. stdio and SSE-mode servers get the cancel.
|
||||
The cure is rmcp 3.x (upstream runs 3.2), a major bump not yet taken.
|
||||
|
||||
Evaluated in the 0.1.16 window and left for a later cycle: MCP qualified
|
||||
tool names past 64 chars (first prove the name never reaches a provider
|
||||
function list), the auto-update guard for a binary still in use (needs
|
||||
per-OS process scans), the DA1 fence around the kitty keyboard pop (needs
|
||||
a design against the 0.1.15 teardown), MCP servers on protocol 2026-07-28
|
||||
(rmcp 3.x again), and `pager.toml`, whose writer in
|
||||
`kigi-pager-render/src/appearance/config.rs` still ends in a bare
|
||||
`fs::rename` and replaces a symlink.
|
||||
|
||||
## Hard constraints
|
||||
|
||||
- **Zero egress**: only `auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`,
|
||||
@@ -56,6 +74,15 @@ a stat error that the profile classification treated as present.
|
||||
switch that never stuck). Plain rename stays correct only for true moves
|
||||
whose destination doesn't pre-exist (worktree-pool markers, corrupt-file
|
||||
backups). Write failures must at least `warn!` — never `let _ =`.
|
||||
- **config.toml writers resolve their destination through
|
||||
`util::config::config_write_dest` before they name the tmp file.** A
|
||||
symlinked `~/.kigi/config.toml` (dotfiles) keeps its link: the tmp sits
|
||||
beside the referent and replaces it. Only that one path is followed, and
|
||||
only while `user_kigi_home()` resolves (with no home, `kigi_home()` is the
|
||||
project's own `./.kigi`). A project `.kigi/config.toml` is
|
||||
repo-controlled, and following its symlink would overwrite whatever file
|
||||
a hostile repo points it at; there the link itself is replaced.
|
||||
Credential and managed stores never follow symlinks.
|
||||
- The root `Cargo.toml` is hand-maintained (upstream's generator is not in
|
||||
this repo). Members sorted; versions inherited from
|
||||
`workspace.package.version` — the single source of truth for the release
|
||||
@@ -89,6 +116,24 @@ a stat error that the profile classification treated as present.
|
||||
- Graph node worktrees are removed right after a successful merge-back;
|
||||
only FAILED nodes keep theirs for postmortem.
|
||||
|
||||
## Terminal keyboard
|
||||
|
||||
`Shift+Enter` inserts a newline in the prompt wherever the terminal can
|
||||
report the modifier, and `kitty_skip_reason` decides where that is —
|
||||
`shift_enter_unavailable` matches on it rather than keeping a second brand
|
||||
list, because the two drifted once and the UI advertised a chord that never
|
||||
arrived. Two skips still deliver the modifier: Apple Terminal, where
|
||||
`route_enter` polls CoreGraphics, and Windows Terminal, where the console
|
||||
key record carries SHIFT with no protocol at all (a bare ConHost is only
|
||||
optimistically refined to Windows Terminal, so it keeps advertising
|
||||
Alt+Enter). Everywhere else — VTE, xterm.js, JediTerm, screen, tmux below
|
||||
3.3, tmux with `extended-keys off` — the byte on the wire is a plain CR and
|
||||
no client-side trick recovers it: crossterm 0.28 parses kitty `CSI 13;2u`
|
||||
but not xterm's `CSI 27;2;13~`, so `modifyOtherKeys` buys nothing. NO VTE
|
||||
release carries the protocol (checked at tag 0.82.0:
|
||||
`_vte_keymap_GDK_Return` encodes only Alt); a version cutoff for it is a
|
||||
bug, not a feature. `/terminal-setup` owns the per-terminal recipe.
|
||||
|
||||
## Test seams
|
||||
|
||||
Cross-crate test hooks are behind the `test-support` cargo feature
|
||||
@@ -355,15 +400,18 @@ client-side, no backend surface.
|
||||
reasoning replay requires `include:["reasoning.encrypted_content"]`.
|
||||
API-key `openai` Responses requests carry NONE of this
|
||||
(byte-identical, pinned by a control wire test). `reasoning.effort`
|
||||
carries the thinking level (incl. the codex-only `ultra`). NO
|
||||
websocket, NO base_instructions.
|
||||
carries the thinking level. NO websocket, NO base_instructions.
|
||||
CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` →
|
||||
`openai_codex_wire_models`, mapped through the SAME
|
||||
`platform_wire_model_to_entry` output): exactly the 4 `visibility=list` &&
|
||||
`supported_in_api=true` models (`gpt-5.6-sol/terra/luna`, `gpt-5.5`, ctx
|
||||
272000, per-model efforts) — NO live `/models` fetch, NO codex-CLI /
|
||||
`~/.codex` dependency; `gpt-5.3-codex-spark` (api=false) and
|
||||
`gpt-5.4`/`gpt-5.4-mini`/`codex-auto-review` (hidden) are EXCLUDED.
|
||||
`platform_wire_model_to_entry` output): the 7 `visibility=list` &&
|
||||
`supported_in_api=true` models the backend served on 2026-09-22
|
||||
(`gpt-6-astra/sol/luna`, `gpt-5.6-sol/terra/luna`, `gpt-5.5`, ctx 272000,
|
||||
per-model efforts) — NO live `/models` fetch, NO codex-CLI / `~/.codex`
|
||||
dependency; the hidden `gpt-reserve` and `codex-auto-review` are
|
||||
EXCLUDED. Refresh the table by reading
|
||||
`GET {base}/models?client_version=<a current codex release>` with a
|
||||
ChatGPT bearer: an old `client_version` is answered with a truncated
|
||||
list, so a stale version reads as "the model does not exist".
|
||||
- `OAuthFlow::GithubDeviceCopilot` → `auth::github_copilot` (TWO-STAGE).
|
||||
Provider: `github-copilot` (`scope_key oauth/github-copilot`, base
|
||||
`api.individual.githubcopilot.com`, ChatCompletions wire). Stage 1 is an
|
||||
@@ -505,10 +553,11 @@ client-side, no backend surface.
|
||||
`parse_api_json` for bundled + runtime refresh; 24h cache
|
||||
`~/.kigi/models_dev_cache.json`). Wire values always win; enrichment
|
||||
never invents model availability. Canonical reasoning efforts:
|
||||
none/minimal/low/medium/high/xhigh/max/ultra (`max` split from `xhigh`
|
||||
2026-07; `ultra` is codex-only, above `max`, surfaced only via a model's
|
||||
server-declared effort menu; Kimi wire spells its top tier `max`, kimi_compat
|
||||
renames).
|
||||
none/minimal/low/medium/high/xhigh/max (`max` split from `xhigh` 2026-07;
|
||||
the Codex backend advertises an `ultra` tier above `max` that kigi has no
|
||||
level for; Kimi wire spells its top tier `max`, kimi_compat renames).
|
||||
config.toml accepts any letter case: `ReasoningEffort` deserializes
|
||||
through `FromStr`.
|
||||
|
||||
## Milestones (PRD §8.3)
|
||||
|
||||
|
||||
Generated
+70
-70
@@ -720,9 +720,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.17.1"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
@@ -731,9 +731,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.42.0"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
@@ -5442,7 +5442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-acp-lib"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"async-trait",
|
||||
@@ -5456,7 +5456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-agent"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -5486,7 +5486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-agent-lifecycle"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"tokio",
|
||||
@@ -5495,7 +5495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-auth"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"http 1.4.2",
|
||||
@@ -5508,7 +5508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-bin"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -5543,7 +5543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-chat-state"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"kigi-compaction",
|
||||
@@ -5560,7 +5560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-codebase-graph"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"clap",
|
||||
@@ -5596,7 +5596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-compaction"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -5609,7 +5609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-config"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"blake3",
|
||||
@@ -5632,7 +5632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-config-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"indexmap",
|
||||
@@ -5646,7 +5646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-crash-handler"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"libc",
|
||||
@@ -5657,7 +5657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-env"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -5665,7 +5665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-fast-worktree"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -5697,7 +5697,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-file-utils"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-config",
|
||||
@@ -5721,7 +5721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-fsnotify"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"dunce",
|
||||
@@ -5742,7 +5742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-gix-status"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"gix",
|
||||
"kigi-test-utils",
|
||||
@@ -5752,7 +5752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hooks"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"kigi-config",
|
||||
@@ -5771,7 +5771,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hooks-plugins-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5779,7 +5779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-http"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"kigi-auth",
|
||||
"kigi-log",
|
||||
@@ -5794,7 +5794,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hunk-tracker"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -5815,14 +5815,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-interjection-core"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-log"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -5840,7 +5840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-markdown"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-lossy",
|
||||
@@ -5864,14 +5864,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-markdown-core"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"pulldown-cmark",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-mcp"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"async-trait",
|
||||
@@ -5908,7 +5908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-memory"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -5942,7 +5942,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-mermaid"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"fontdb",
|
||||
"image",
|
||||
@@ -5960,7 +5960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-models"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"kigi-env",
|
||||
"serde",
|
||||
@@ -5970,7 +5970,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-minimal"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crossterm",
|
||||
@@ -5987,7 +5987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-pty-harness"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -6012,7 +6012,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-render"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anstyle",
|
||||
@@ -6064,7 +6064,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-paths"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"serde",
|
||||
@@ -6074,7 +6074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-prompt-queue"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6082,7 +6082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-proto-build"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pbjson-build",
|
||||
@@ -6093,7 +6093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-ratatui-inline"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"ansi-width",
|
||||
"anstyle-parse 0.2.7",
|
||||
@@ -6110,7 +6110,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-ratatui-textarea"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6131,7 +6131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sampler"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"async-openai",
|
||||
"async-stream",
|
||||
@@ -6155,7 +6155,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sampling-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"assert_matches",
|
||||
"async-openai",
|
||||
@@ -6172,7 +6172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sandbox"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6194,7 +6194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-secrets"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde_json",
|
||||
@@ -6232,7 +6232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-shell"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6370,7 +6370,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-shell-base"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6395,7 +6395,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sqlite-journal"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rusqlite",
|
||||
@@ -6406,7 +6406,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-subagent-resolution"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"kigi-sampling-types",
|
||||
"kigi-tool-types",
|
||||
@@ -6421,7 +6421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-system-power"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"zbus",
|
||||
@@ -6429,7 +6429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-test-support"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6451,7 +6451,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-test-utils"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"runfiles",
|
||||
"tracing",
|
||||
@@ -6460,11 +6460,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-token-estimation"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-protocol"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"kigi-tool-types",
|
||||
"serde",
|
||||
@@ -6475,7 +6475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-runtime"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -6493,7 +6493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"minijinja",
|
||||
"schemars 1.2.1",
|
||||
@@ -6503,7 +6503,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tools"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -6580,7 +6580,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tools-api"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"kigi-proto-build",
|
||||
"kigi-tool-protocol",
|
||||
@@ -6593,11 +6593,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tracing-macros"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tty-utils"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"nix 0.30.1",
|
||||
@@ -6607,7 +6607,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tui"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"ansi-to-tui",
|
||||
@@ -6694,7 +6694,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-update"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
@@ -6723,14 +6723,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-version"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-workspace"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6809,7 +6809,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-workspace-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
@@ -8843,7 +8843,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ptyctl"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -8861,7 +8861,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ptyctl-cli"
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -9636,9 +9636,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.42"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -9701,9 +9701,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.15"
|
||||
version = "0.1.18"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
|
||||
|
||||
+69
-69
@@ -12629,7 +12629,7 @@ Applies to:
|
||||
- [rustix 0.38.44](https://github.com/bytecodealliance/rustix)
|
||||
- [rustix 1.1.4](https://github.com/bytecodealliance/rustix)
|
||||
- [rustls-native-certs 0.8.4](https://github.com/rustls/rustls-native-certs)
|
||||
- [rustls 0.23.42](https://github.com/rustls/rustls)
|
||||
- [rustls 0.23.45](https://github.com/rustls/rustls)
|
||||
- [scopeguard 1.2.0](https://github.com/bluss/scopeguard)
|
||||
- [security-framework-sys 2.17.0](https://github.com/kornelski/rust-security-framework)
|
||||
- [security-framework 3.7.0](https://github.com/kornelski/rust-security-framework)
|
||||
@@ -16436,68 +16436,68 @@ limitations under the License.
|
||||
|
||||
Applies to:
|
||||
|
||||
- [kigi-proto-build 0.1.15](https://crates.io/crates/kigi-proto-build)
|
||||
- [kigi-acp-lib 0.1.15](https://crates.io/crates/kigi-acp-lib)
|
||||
- [kigi-agent 0.1.15](https://crates.io/crates/kigi-agent)
|
||||
- [kigi-agent-lifecycle 0.1.15](https://crates.io/crates/kigi-agent-lifecycle)
|
||||
- [kigi-auth 0.1.15](https://crates.io/crates/kigi-auth)
|
||||
- [kigi-bin 0.1.15](https://crates.io/crates/kigi-bin)
|
||||
- [kigi-chat-state 0.1.15](https://crates.io/crates/kigi-chat-state)
|
||||
- [kigi-codebase-graph 0.1.15](https://crates.io/crates/kigi-codebase-graph)
|
||||
- [kigi-config 0.1.15](https://crates.io/crates/kigi-config)
|
||||
- [kigi-config-types 0.1.15](https://crates.io/crates/kigi-config-types)
|
||||
- [kigi-crash-handler 0.1.15](https://crates.io/crates/kigi-crash-handler)
|
||||
- [kigi-env 0.1.15](https://crates.io/crates/kigi-env)
|
||||
- [kigi-fast-worktree 0.1.15](https://crates.io/crates/kigi-fast-worktree)
|
||||
- [kigi-file-utils 0.1.15](https://crates.io/crates/kigi-file-utils)
|
||||
- [kigi-fsnotify 0.1.15](https://crates.io/crates/kigi-fsnotify)
|
||||
- [kigi-gix-status 0.1.15](https://crates.io/crates/kigi-gix-status)
|
||||
- [kigi-hooks 0.1.15](https://crates.io/crates/kigi-hooks)
|
||||
- [kigi-hooks-plugins-types 0.1.15](https://crates.io/crates/kigi-hooks-plugins-types)
|
||||
- [kigi-http 0.1.15](https://crates.io/crates/kigi-http)
|
||||
- [kigi-hunk-tracker 0.1.15](https://crates.io/crates/kigi-hunk-tracker)
|
||||
- [kigi-log 0.1.15](https://crates.io/crates/kigi-log)
|
||||
- [kigi-markdown 0.1.15](https://crates.io/crates/kigi-markdown)
|
||||
- [kigi-markdown-core 0.1.15](https://crates.io/crates/kigi-markdown-core)
|
||||
- [kigi-mcp 0.1.15](https://crates.io/crates/kigi-mcp)
|
||||
- [kigi-memory 0.1.15](https://crates.io/crates/kigi-memory)
|
||||
- [kigi-models 0.1.15](https://crates.io/crates/kigi-models)
|
||||
- [kigi-pager-minimal 0.1.15](https://crates.io/crates/kigi-pager-minimal)
|
||||
- [kigi-pager-pty-harness 0.1.15](https://crates.io/crates/kigi-pager-pty-harness)
|
||||
- [kigi-pager-render 0.1.15](https://crates.io/crates/kigi-pager-render)
|
||||
- [kigi-paths 0.1.15](https://crates.io/crates/kigi-paths)
|
||||
- [kigi-prompt-queue 0.1.15](https://crates.io/crates/kigi-prompt-queue)
|
||||
- [kigi-ratatui-inline 0.1.15](https://crates.io/crates/kigi-ratatui-inline)
|
||||
- [kigi-ratatui-textarea 0.1.15](https://crates.io/crates/kigi-ratatui-textarea)
|
||||
- [kigi-sampler 0.1.15](https://crates.io/crates/kigi-sampler)
|
||||
- [kigi-sampling-types 0.1.15](https://crates.io/crates/kigi-sampling-types)
|
||||
- [kigi-sandbox 0.1.15](https://crates.io/crates/kigi-sandbox)
|
||||
- [kigi-secrets 0.1.15](https://crates.io/crates/kigi-secrets)
|
||||
- [kigi-proto-build 0.1.18](https://crates.io/crates/kigi-proto-build)
|
||||
- [kigi-acp-lib 0.1.18](https://crates.io/crates/kigi-acp-lib)
|
||||
- [kigi-agent 0.1.18](https://crates.io/crates/kigi-agent)
|
||||
- [kigi-agent-lifecycle 0.1.18](https://crates.io/crates/kigi-agent-lifecycle)
|
||||
- [kigi-auth 0.1.18](https://crates.io/crates/kigi-auth)
|
||||
- [kigi-bin 0.1.18](https://crates.io/crates/kigi-bin)
|
||||
- [kigi-chat-state 0.1.18](https://crates.io/crates/kigi-chat-state)
|
||||
- [kigi-codebase-graph 0.1.18](https://crates.io/crates/kigi-codebase-graph)
|
||||
- [kigi-config 0.1.18](https://crates.io/crates/kigi-config)
|
||||
- [kigi-config-types 0.1.18](https://crates.io/crates/kigi-config-types)
|
||||
- [kigi-crash-handler 0.1.18](https://crates.io/crates/kigi-crash-handler)
|
||||
- [kigi-env 0.1.18](https://crates.io/crates/kigi-env)
|
||||
- [kigi-fast-worktree 0.1.18](https://crates.io/crates/kigi-fast-worktree)
|
||||
- [kigi-file-utils 0.1.18](https://crates.io/crates/kigi-file-utils)
|
||||
- [kigi-fsnotify 0.1.18](https://crates.io/crates/kigi-fsnotify)
|
||||
- [kigi-gix-status 0.1.18](https://crates.io/crates/kigi-gix-status)
|
||||
- [kigi-hooks 0.1.18](https://crates.io/crates/kigi-hooks)
|
||||
- [kigi-hooks-plugins-types 0.1.18](https://crates.io/crates/kigi-hooks-plugins-types)
|
||||
- [kigi-http 0.1.18](https://crates.io/crates/kigi-http)
|
||||
- [kigi-hunk-tracker 0.1.18](https://crates.io/crates/kigi-hunk-tracker)
|
||||
- [kigi-log 0.1.18](https://crates.io/crates/kigi-log)
|
||||
- [kigi-markdown 0.1.18](https://crates.io/crates/kigi-markdown)
|
||||
- [kigi-markdown-core 0.1.18](https://crates.io/crates/kigi-markdown-core)
|
||||
- [kigi-mcp 0.1.18](https://crates.io/crates/kigi-mcp)
|
||||
- [kigi-memory 0.1.18](https://crates.io/crates/kigi-memory)
|
||||
- [kigi-models 0.1.18](https://crates.io/crates/kigi-models)
|
||||
- [kigi-pager-minimal 0.1.18](https://crates.io/crates/kigi-pager-minimal)
|
||||
- [kigi-pager-pty-harness 0.1.18](https://crates.io/crates/kigi-pager-pty-harness)
|
||||
- [kigi-pager-render 0.1.18](https://crates.io/crates/kigi-pager-render)
|
||||
- [kigi-paths 0.1.18](https://crates.io/crates/kigi-paths)
|
||||
- [kigi-prompt-queue 0.1.18](https://crates.io/crates/kigi-prompt-queue)
|
||||
- [kigi-ratatui-inline 0.1.18](https://crates.io/crates/kigi-ratatui-inline)
|
||||
- [kigi-ratatui-textarea 0.1.18](https://crates.io/crates/kigi-ratatui-textarea)
|
||||
- [kigi-sampler 0.1.18](https://crates.io/crates/kigi-sampler)
|
||||
- [kigi-sampling-types 0.1.18](https://crates.io/crates/kigi-sampling-types)
|
||||
- [kigi-sandbox 0.1.18](https://crates.io/crates/kigi-sandbox)
|
||||
- [kigi-secrets 0.1.18](https://crates.io/crates/kigi-secrets)
|
||||
- [kigi-shared 0.0.0](https://crates.io/crates/kigi-shared)
|
||||
- [kigi-shell 0.1.15](https://crates.io/crates/kigi-shell)
|
||||
- [kigi-shell-base 0.1.15](https://crates.io/crates/kigi-shell-base)
|
||||
- [kigi-sqlite-journal 0.1.15](https://crates.io/crates/kigi-sqlite-journal)
|
||||
- [kigi-subagent-resolution 0.1.15](https://crates.io/crates/kigi-subagent-resolution)
|
||||
- [kigi-system-power 0.1.15](https://crates.io/crates/kigi-system-power)
|
||||
- [kigi-test-support 0.1.15](https://crates.io/crates/kigi-test-support)
|
||||
- [kigi-token-estimation 0.1.15](https://crates.io/crates/kigi-token-estimation)
|
||||
- [kigi-tools 0.1.15](https://crates.io/crates/kigi-tools)
|
||||
- [kigi-tools-api 0.1.15](https://crates.io/crates/kigi-tools-api)
|
||||
- [kigi-tracing-macros 0.1.15](https://crates.io/crates/kigi-tracing-macros)
|
||||
- [kigi-tty-utils 0.1.15](https://crates.io/crates/kigi-tty-utils)
|
||||
- [kigi-tui 0.1.15](https://crates.io/crates/kigi-tui)
|
||||
- [kigi-update 0.1.15](https://crates.io/crates/kigi-update)
|
||||
- [kigi-version 0.1.15](https://crates.io/crates/kigi-version)
|
||||
- [kigi-workspace 0.1.15](https://crates.io/crates/kigi-workspace)
|
||||
- [kigi-workspace-types 0.1.15](https://crates.io/crates/kigi-workspace-types)
|
||||
- [ptyctl 0.1.15](https://crates.io/crates/ptyctl)
|
||||
- [ptyctl-cli 0.1.15](https://crates.io/crates/ptyctl-cli)
|
||||
- [kigi-compaction 0.1.15](https://crates.io/crates/kigi-compaction)
|
||||
- [kigi-interjection-core 0.1.15](https://crates.io/crates/kigi-interjection-core)
|
||||
- [kigi-test-utils 0.1.15](https://crates.io/crates/kigi-test-utils)
|
||||
- [kigi-tool-protocol 0.1.15](https://crates.io/crates/kigi-tool-protocol)
|
||||
- [kigi-tool-runtime 0.1.15](https://crates.io/crates/kigi-tool-runtime)
|
||||
- [kigi-tool-types 0.1.15](https://crates.io/crates/kigi-tool-types)
|
||||
- [kigi-shell 0.1.18](https://crates.io/crates/kigi-shell)
|
||||
- [kigi-shell-base 0.1.18](https://crates.io/crates/kigi-shell-base)
|
||||
- [kigi-sqlite-journal 0.1.18](https://crates.io/crates/kigi-sqlite-journal)
|
||||
- [kigi-subagent-resolution 0.1.18](https://crates.io/crates/kigi-subagent-resolution)
|
||||
- [kigi-system-power 0.1.18](https://crates.io/crates/kigi-system-power)
|
||||
- [kigi-test-support 0.1.18](https://crates.io/crates/kigi-test-support)
|
||||
- [kigi-token-estimation 0.1.18](https://crates.io/crates/kigi-token-estimation)
|
||||
- [kigi-tools 0.1.18](https://crates.io/crates/kigi-tools)
|
||||
- [kigi-tools-api 0.1.18](https://crates.io/crates/kigi-tools-api)
|
||||
- [kigi-tracing-macros 0.1.18](https://crates.io/crates/kigi-tracing-macros)
|
||||
- [kigi-tty-utils 0.1.18](https://crates.io/crates/kigi-tty-utils)
|
||||
- [kigi-tui 0.1.18](https://crates.io/crates/kigi-tui)
|
||||
- [kigi-update 0.1.18](https://crates.io/crates/kigi-update)
|
||||
- [kigi-version 0.1.18](https://crates.io/crates/kigi-version)
|
||||
- [kigi-workspace 0.1.18](https://crates.io/crates/kigi-workspace)
|
||||
- [kigi-workspace-types 0.1.18](https://crates.io/crates/kigi-workspace-types)
|
||||
- [ptyctl 0.1.18](https://crates.io/crates/ptyctl)
|
||||
- [ptyctl-cli 0.1.18](https://crates.io/crates/ptyctl-cli)
|
||||
- [kigi-compaction 0.1.18](https://crates.io/crates/kigi-compaction)
|
||||
- [kigi-interjection-core 0.1.18](https://crates.io/crates/kigi-interjection-core)
|
||||
- [kigi-test-utils 0.1.18](https://crates.io/crates/kigi-test-utils)
|
||||
- [kigi-tool-protocol 0.1.18](https://crates.io/crates/kigi-tool-protocol)
|
||||
- [kigi-tool-runtime 0.1.18](https://crates.io/crates/kigi-tool-runtime)
|
||||
- [kigi-tool-types 0.1.18](https://crates.io/crates/kigi-tool-types)
|
||||
- [agent-client-protocol 0.10.4](https://github.com/agentclientprotocol/rust-sdk)
|
||||
- [allocator-api2 0.2.21](https://github.com/zakarumych/allocator-api2)
|
||||
- [anes 0.1.6](https://github.com/zrzka/anes-rs)
|
||||
@@ -16506,7 +16506,7 @@ Applies to:
|
||||
- [async-trait 0.1.89](https://github.com/dtolnay/async-trait)
|
||||
- [aws-config 1.9.0](https://github.com/smithy-lang/smithy-rs)
|
||||
- [aws-credential-types 1.3.0](https://github.com/smithy-lang/smithy-rs)
|
||||
- [aws-lc-sys 0.42.0](https://github.com/aws/aws-lc-rs)
|
||||
- [aws-lc-sys 0.45.0](https://github.com/aws/aws-lc-rs)
|
||||
- [aws-runtime 1.8.1](https://github.com/smithy-lang/smithy-rs)
|
||||
- [aws-sigv4 1.5.1](https://github.com/smithy-lang/smithy-rs)
|
||||
- [aws-smithy-async 1.3.0](https://github.com/smithy-lang/smithy-rs)
|
||||
@@ -16772,7 +16772,7 @@ limitations under the License.
|
||||
|
||||
Applies to:
|
||||
|
||||
- [kigi-mermaid 0.1.15](https://crates.io/crates/kigi-mermaid)
|
||||
- [kigi-mermaid 0.1.18](https://crates.io/crates/kigi-mermaid)
|
||||
|
||||
```
|
||||
Roboto-Regular.ttf
|
||||
@@ -17633,7 +17633,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Applies to:
|
||||
|
||||
- [alloc-stdlib 0.2.4](https://github.com/dropbox/rust-alloc-no-stdlib)
|
||||
- [aws-lc-sys 0.42.0](https://github.com/aws/aws-lc-rs)
|
||||
- [aws-lc-sys 0.45.0](https://github.com/aws/aws-lc-rs)
|
||||
- [curve25519-dalek 4.1.3](https://github.com/dalek-cryptography/curve25519-dalek/tree/main/curve25519-dalek)
|
||||
- [sha1_smol 1.0.1](https://github.com/mitsuhiko/sha1-smol)
|
||||
- [sigstore-trust-root 0.6.3](https://github.com/prefix-dev/sigstore-rust)
|
||||
@@ -18819,7 +18819,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
Applies to:
|
||||
|
||||
- [rustls-webpki 0.103.13](https://github.com/rustls/webpki)
|
||||
- [rustls-webpki 0.103.15](https://github.com/rustls/webpki)
|
||||
|
||||
```
|
||||
Except as otherwise noted, this project is licensed under the following
|
||||
@@ -18848,8 +18848,8 @@ third-party/chromium/LICENSE.
|
||||
|
||||
Applies to:
|
||||
|
||||
- [aws-lc-rs 1.17.1](https://github.com/aws/aws-lc-rs)
|
||||
- [aws-lc-sys 0.42.0](https://github.com/aws/aws-lc-rs)
|
||||
- [aws-lc-rs 1.18.1](https://github.com/aws/aws-lc-rs)
|
||||
- [aws-lc-sys 0.45.0](https://github.com/aws/aws-lc-rs)
|
||||
- [scraper 0.23.1](https://github.com/causal-agent/scraper)
|
||||
|
||||
```
|
||||
@@ -22846,7 +22846,7 @@ SOFTWARE.
|
||||
|
||||
Applies to:
|
||||
|
||||
- [aws-lc-sys 0.42.0](https://github.com/aws/aws-lc-rs)
|
||||
- [aws-lc-sys 0.45.0](https://github.com/aws/aws-lc-rs)
|
||||
|
||||
```
|
||||
The MIT License (MIT)
|
||||
|
||||
@@ -32,6 +32,9 @@ use rmcp::{
|
||||
|
||||
use crate::oauth_config::McpOAuthConfig;
|
||||
|
||||
mod cancel_aware;
|
||||
use cancel_aware::call_tool_cancel_aware;
|
||||
|
||||
use kigi_tools::types::{
|
||||
output::{MCPOutput, MCPOutputDetails, ToolOutput},
|
||||
tool::{ToolKind, ToolNamespace},
|
||||
@@ -1599,12 +1602,24 @@ impl McpErasedTool {
|
||||
let mut params = CallToolRequestParams::new(self.tool.name.clone());
|
||||
params.arguments = raw.as_object().cloned();
|
||||
|
||||
let result =
|
||||
tokio::time::timeout(timeout_duration, mcp_service.call_tool(params.clone())).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(call_result)) => Ok(call_result),
|
||||
Ok(Err(service_err))
|
||||
match call_tool_cancel_aware(&mcp_service, params.clone(), timeout_duration).await {
|
||||
Ok(call_result) => Ok(call_result),
|
||||
Err(ServiceError::Timeout { .. }) => {
|
||||
*is_timeout = true;
|
||||
// Reset for the next call but don't retry — a slow side-effecting tool must not run twice.
|
||||
if client.is_http() && !*reconnect_attempted {
|
||||
client.reset_transport().await;
|
||||
*reconnect_attempted = true;
|
||||
}
|
||||
Err(kigi_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
format!(
|
||||
"MCP tool '{}' timed out after {} seconds",
|
||||
self.tool.name, tool_timeout
|
||||
),
|
||||
))
|
||||
}
|
||||
Err(service_err)
|
||||
if should_recover_service_error(
|
||||
&service_err,
|
||||
client.is_http(),
|
||||
@@ -1623,25 +1638,10 @@ impl McpErasedTool {
|
||||
)
|
||||
.await
|
||||
}
|
||||
Ok(Err(e)) => Err(kigi_tool_runtime::ToolError::custom(
|
||||
Err(e) => Err(kigi_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
e.to_string(),
|
||||
)),
|
||||
Err(_) => {
|
||||
*is_timeout = true;
|
||||
// Reset for the next call but don't retry — a slow side-effecting tool must not run twice.
|
||||
if client.is_http() && !*reconnect_attempted {
|
||||
client.reset_transport().await;
|
||||
*reconnect_attempted = true;
|
||||
}
|
||||
Err(kigi_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
format!(
|
||||
"MCP tool '{}' timed out after {} seconds",
|
||||
self.tool.name, tool_timeout
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1692,13 +1692,9 @@ impl McpErasedTool {
|
||||
));
|
||||
}
|
||||
};
|
||||
match tokio::time::timeout(timeout_duration, mcp_service.call_tool(params)).await {
|
||||
Ok(Ok(call_result)) => Ok(call_result),
|
||||
Ok(Err(retry_err)) => Err(kigi_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
retry_err.to_string(),
|
||||
)),
|
||||
Err(_) => {
|
||||
match call_tool_cancel_aware(&mcp_service, params, timeout_duration).await {
|
||||
Ok(call_result) => Ok(call_result),
|
||||
Err(ServiceError::Timeout { .. }) => {
|
||||
*is_timeout = true;
|
||||
Err(kigi_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
@@ -1708,6 +1704,10 @@ impl McpErasedTool {
|
||||
),
|
||||
))
|
||||
}
|
||||
Err(retry_err) => Err(kigi_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
retry_err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6223,11 +6223,17 @@ mod tests {
|
||||
let mut reconnect = false;
|
||||
let mut is_timeout = false;
|
||||
let raw = serde_json::json!({});
|
||||
let started = std::time::Instant::now();
|
||||
let err = tool
|
||||
.try_call_tool(&client, &raw, &mut reconnect, &mut is_timeout, &ew)
|
||||
.await
|
||||
.expect_err("call must time out");
|
||||
|
||||
assert!(
|
||||
started.elapsed() < std::time::Duration::from_millis(2500),
|
||||
"cancel must not wait out the 3s hang: {:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
assert!(err.to_string().contains("timed out"), "got: {err}");
|
||||
assert!(is_timeout, "is_timeout must be set");
|
||||
assert!(reconnect, "timeout arm flags the reconnect after resetting");
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! `tools/call` that tells the server when the host stops waiting.
|
||||
|
||||
use rmcp::model::{
|
||||
CallToolRequest, CallToolRequestParams, CallToolResult, CancelledNotificationParam,
|
||||
ClientRequest, RequestId, ServerResult,
|
||||
};
|
||||
use rmcp::service::{Peer, PeerRequestOptions};
|
||||
use rmcp::{RoleClient, ServiceError};
|
||||
|
||||
use super::McpService;
|
||||
|
||||
/// rmcp 2.2.0's HTTP worker queues the cancel behind a hung POST, so the wait is bounded.
|
||||
const CANCEL_DELIVERY_BUDGET: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
|
||||
/// A timeout cancels inline, before the caller can reset the transport; a drop cancels from a task.
|
||||
pub(super) async fn call_tool_cancel_aware(
|
||||
service: &McpService,
|
||||
params: CallToolRequestParams,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<CallToolResult, ServiceError> {
|
||||
let handle = service
|
||||
.peer()
|
||||
.send_cancellable_request(
|
||||
ClientRequest::CallToolRequest(CallToolRequest::new(params)),
|
||||
PeerRequestOptions::no_options(),
|
||||
)
|
||||
.await?;
|
||||
let mut guard = CancelOnDrop {
|
||||
peer: service.peer().clone(),
|
||||
request_id: Some(handle.id.clone()),
|
||||
};
|
||||
let Ok(response) = tokio::time::timeout(timeout, handle.await_response()).await else {
|
||||
guard.cancel_within_budget().await;
|
||||
return Err(ServiceError::Timeout { timeout });
|
||||
};
|
||||
guard.request_id = None;
|
||||
match response? {
|
||||
ServerResult::CallToolResult(result) => Ok(result),
|
||||
_ => Err(ServiceError::UnexpectedResponse),
|
||||
}
|
||||
}
|
||||
|
||||
/// Armed while the request is in flight; disarmed once it settles.
|
||||
struct CancelOnDrop {
|
||||
peer: Peer<RoleClient>,
|
||||
request_id: Option<RequestId>,
|
||||
}
|
||||
|
||||
impl CancelOnDrop {
|
||||
async fn cancel_within_budget(&mut self) {
|
||||
let Some(request_id) = self.request_id.clone() else {
|
||||
return;
|
||||
};
|
||||
let delivery = notify_cancelled(self.peer.clone(), request_id.clone());
|
||||
if tokio::time::timeout(CANCEL_DELIVERY_BUDGET, delivery)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
?request_id,
|
||||
"notifications/cancelled still queued after budget"
|
||||
);
|
||||
}
|
||||
self.request_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CancelOnDrop {
|
||||
fn drop(&mut self) {
|
||||
let Some(request_id) = self.request_id.take() else {
|
||||
return;
|
||||
};
|
||||
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
||||
tracing::warn!(
|
||||
?request_id,
|
||||
"MCP call dropped outside a runtime; cancel not sent"
|
||||
);
|
||||
return;
|
||||
};
|
||||
runtime.spawn(notify_cancelled(self.peer.clone(), request_id));
|
||||
}
|
||||
}
|
||||
|
||||
async fn notify_cancelled(peer: Peer<RoleClient>, request_id: RequestId) {
|
||||
let params = CancelledNotificationParam::new(
|
||||
Some(request_id.clone()),
|
||||
Some("client cancelled".to_owned()),
|
||||
);
|
||||
if let Err(e) = peer.notify_cancelled(params).await {
|
||||
tracing::warn!(?request_id, error = %e, "notifications/cancelled not delivered");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rmcp::ServiceExt;
|
||||
use serde_json::Value;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
use super::super::{KigiClientHandler, McpClient};
|
||||
use super::*;
|
||||
|
||||
type Received = Arc<parking_lot::Mutex<Vec<Value>>>;
|
||||
|
||||
/// Duplex MCP server: answers `initialize`, records all, answers `tools/call` only when `reply`.
|
||||
async fn recording_service(reply: bool) -> (McpService, Received) {
|
||||
let received = Received::default();
|
||||
let (client_read, mut server_write) = tokio::io::duplex(64 * 1024);
|
||||
let (server_read, client_write) = tokio::io::duplex(64 * 1024);
|
||||
let sink = received.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(server_read).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let msg: Value = serde_json::from_str(&line).expect("client sends JSON");
|
||||
sink.lock().push(msg.clone());
|
||||
let result = match msg["method"].as_str() {
|
||||
Some("initialize") => serde_json::json!({
|
||||
"protocolVersion": msg["params"]["protocolVersion"],
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": { "name": "recording", "version": "0.0.0" },
|
||||
}),
|
||||
Some("tools/call") if reply => serde_json::json!({
|
||||
"content": [{ "type": "text", "text": "done" }],
|
||||
"isError": false,
|
||||
}),
|
||||
_ => continue,
|
||||
};
|
||||
let response =
|
||||
serde_json::json!({ "jsonrpc": "2.0", "id": msg["id"], "result": result });
|
||||
let encoded = format!("{response}\n");
|
||||
server_write
|
||||
.write_all(encoded.as_bytes())
|
||||
.await
|
||||
.expect("client reads replies");
|
||||
}
|
||||
});
|
||||
let handler = KigiClientHandler {
|
||||
info: McpClient::make_client_info("recording"),
|
||||
server_name: "recording".to_string(),
|
||||
notify_tx: Arc::default(),
|
||||
};
|
||||
let transport = rmcp::transport::async_rw::AsyncRwTransport::<RoleClient, _, _>::new(
|
||||
client_read,
|
||||
client_write,
|
||||
);
|
||||
let service = Arc::new(handler.serve(transport).await.expect("handshake"));
|
||||
(service, received)
|
||||
}
|
||||
|
||||
fn values_at(received: &Received, method: &str, pointer: &str) -> Vec<Value> {
|
||||
received
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|m| m["method"] == method)
|
||||
.map(|m| m.pointer(pointer).cloned().unwrap_or(Value::Null))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn call_ids(received: &Received) -> Vec<Value> {
|
||||
values_at(received, "tools/call", "/id")
|
||||
}
|
||||
|
||||
fn cancelled_ids(received: &Received) -> Vec<Value> {
|
||||
values_at(received, "notifications/cancelled", "/params/requestId")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropped_call_cancels_its_request() {
|
||||
let (service, received) = recording_service(false).await;
|
||||
let mut call = Box::pin(call_tool_cancel_aware(
|
||||
&service,
|
||||
CallToolRequestParams::new("slow"),
|
||||
Duration::from_secs(30),
|
||||
));
|
||||
let pending = tokio::time::timeout(Duration::from_millis(100), &mut call).await;
|
||||
assert!(pending.is_err(), "server never replies");
|
||||
drop(call);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let ids = call_ids(&received);
|
||||
assert_eq!(ids.len(), 1, "{ids:?}");
|
||||
assert_eq!(cancelled_ids(&received), ids);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timed_out_call_cancels_its_request_once() {
|
||||
let (service, received) = recording_service(false).await;
|
||||
let round = call_tool_cancel_aware(
|
||||
&service,
|
||||
CallToolRequestParams::new("slow"),
|
||||
Duration::from_millis(50),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(round, Err(ServiceError::Timeout { .. })),
|
||||
"{round:?}"
|
||||
);
|
||||
// The HTTP timeout arm resets the transport, which drops the last service handle.
|
||||
drop(service);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let ids = call_ids(&received);
|
||||
assert_eq!(ids.len(), 1, "{ids:?}");
|
||||
assert_eq!(cancelled_ids(&received), ids);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_call_sends_no_cancellation() {
|
||||
let (service, received) = recording_service(true).await;
|
||||
let result = call_tool_cancel_aware(
|
||||
&service,
|
||||
CallToolRequestParams::new("fast"),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await
|
||||
.expect("server replies");
|
||||
assert_eq!(result.is_error, Some(false));
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(cancelled_ids(&received), Vec::<Value>::new());
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"_meta":{"note":"filtered snapshot for kigi model-metadata enrichment; regenerate via scripts/gen_enrichment_snapshot.py","source":"https://models.dev/api.json (github.com/sst/models.dev, MIT)"},"alibaba-token-plan":{"models":{"MiniMax-M2.5":{"limit":{"context":196608,"input":196601,"output":24576},"modalities":{"input":["text"],"output":["text"]},"name":"MiniMax-M2.5","reasoning":true,"reasoning_options":[],"tool_call":true},"deepseek-v3.2":{"limit":{"context":131072,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V3.2","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"deepseek-v4-flash":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-pro":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Pro","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"glm-5":{"limit":{"context":202752,"output":16384},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.1":{"limit":{"context":202752,"output":128000},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.1","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.2":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.2","reasoning":true,"reasoning_options":[{"type":"effort","values":["high","max"]}],"tool_call":true},"kimi-k2.5":{"limit":{"context":262144,"output":32768},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.5","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.6":{"limit":{"context":262144,"output":16384},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.6","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.7-code":{"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.7 Code","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"qwen-image-2.0":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0","reasoning":false,"tool_call":false},"qwen-image-2.0-pro":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0 Pro","reasoning":false,"tool_call":false},"qwen3.6-flash":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":81920,"type":"budget_tokens"}],"tool_call":true},"qwen3.6-plus":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Plus","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":81920,"type":"budget_tokens"}],"tool_call":true},"qwen3.7-max":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"Qwen3.7 Max","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":262144,"type":"budget_tokens"}],"tool_call":true},"qwen3.7-plus":{"limit":{"context":1000000,"output":64000},"modalities":{"input":["text","image"],"output":["text"]},"name":"Qwen3.7 Plus","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":262144,"type":"budget_tokens"}],"tool_call":true},"qwen3.8-max-preview":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.8 Max Preview","reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","xhigh"]},{"max":262144,"min":0,"type":"budget_tokens"}],"tool_call":true},"wan2.7-image":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Wan2.7 Image","reasoning":false,"tool_call":false},"wan2.7-image-pro":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Wan2.7 Image Pro","reasoning":false,"tool_call":false}}},"alibaba-token-plan-cn":{"models":{"MiniMax-M2.5":{"limit":{"context":196608,"input":196601,"output":24576},"modalities":{"input":["text"],"output":["text"]},"name":"MiniMax-M2.5","reasoning":true,"reasoning_options":[],"tool_call":true},"deepseek-v3.2":{"limit":{"context":131072,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V3.2","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"deepseek-v4-flash":{"limit":{"context":1000000,"output":3Line truncated
|
||||
{"_meta":{"note":"filtered snapshot for kigi model-metadata enrichment; regenerate via scripts/gen_enrichment_snapshot.py","source":"https://models.dev/api.json (github.com/sst/models.dev, MIT)"},"alibaba-token-plan":{"models":{"MiniMax-M2.5":{"limit":{"context":196608,"input":196601,"output":32768},"modalities":{"input":["text"],"output":["text"]},"name":"MiniMax-M2.5","reasoning":true,"reasoning_options":[],"tool_call":true},"deepseek-v3.2":{"limit":{"context":131072,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V3.2","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"deepseek-v4-flash":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-flash-0731":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Flash 0731","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-pro":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Pro","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-pro-0813":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Pro 0813","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4.1-flash":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text","image"],"output":["text"]},"name":"DeepSeek V4.1 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"tool_call":true},"glm-5":{"limit":{"context":202752,"output":16384},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.1":{"limit":{"context":202752,"output":128000},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.1","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.2":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.2","reasoning":true,"reasoning_options":[{"type":"effort","values":["high","max"]}],"tool_call":true},"glm-5.3":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.3","reasoning":true,"reasoning_options":[{"type":"effort","values":["low","high","max"]}],"tool_call":true},"happyhorse-1.1-i2v":{"limit":{"context":0,"output":0},"modalities":{"input":["image","text"],"output":["video"]},"name":"HappyHorse 1.1 Image-to-Video","reasoning":false,"tool_call":false},"happyhorse-1.1-r2v":{"limit":{"context":0,"output":0},"modalities":{"input":["image","text"],"output":["video"]},"name":"HappyHorse 1.1 Reference-to-Video","reasoning":false,"tool_call":false},"happyhorse-1.1-t2v":{"limit":{"context":0,"output":0},"modalities":{"input":["text"],"output":["video"]},"name":"HappyHorse 1.1 Text-to-Video","reasoning":false,"tool_call":false},"kimi-k2.5":{"limit":{"context":262144,"output":98304},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.5","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.6":{"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.6","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.7-code":{"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.7 Code","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"qwen-image-2.0":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0","reasoning":false,"tool_call":false},"qwen-image-2.0-pro":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0 Pro","reasoning":false,"tool_call":false},"qwen3.6-flash":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":131072,"type":"budget_tokens"}],"tool_call":true},"qwen3.6-plus":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Plus","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":131072,"type":"budget_tokens"}],"tool_call":true},"qwen3.7-max":{"limit":{"context":1000000,Line truncated
|
||||
@@ -223,7 +223,7 @@ mod tests {
|
||||
"snapshot lost providers: {}",
|
||||
catalog.len()
|
||||
);
|
||||
let k3 = lookup(catalog, "kimi-for-coding", "k3").expect("k3 present");
|
||||
let k3 = lookup(catalog, "kimi-code-plan-cn", "k3").expect("k3 present");
|
||||
assert_eq!(k3.context, 1_048_576, "k3 context must match the live wire");
|
||||
assert_eq!(k3.efforts, ["low", "high", "max"]);
|
||||
assert!(k3.reasoning);
|
||||
@@ -232,6 +232,21 @@ mod tests {
|
||||
assert_eq!(opus.efforts, ["low", "medium", "high", "xhigh", "max"]);
|
||||
}
|
||||
|
||||
/// `restrict_to_enriched` drops a live listing id the enrichment catalog
|
||||
/// does not know, so the bundled snapshot must admit the current flagship
|
||||
/// models offline: grok-4.7 on xai (the `xai`/`xai-grok` rows) and
|
||||
/// gpt-6-astra on the API-key `openai` row.
|
||||
#[test]
|
||||
fn bundled_snapshot_admits_the_current_flagship_models() {
|
||||
let catalog = bundled_enrichment();
|
||||
let grok = lookup(catalog, "xai", "grok-4.7").expect("grok-4.7 present");
|
||||
assert!(grok.tool_call, "a non-tool-calling model is dropped");
|
||||
assert_eq!(grok.context, 500_000);
|
||||
let astra = lookup(catalog, "openai", "gpt-6-astra").expect("gpt-6-astra present");
|
||||
assert!(astra.tool_call);
|
||||
assert_eq!(astra.context, 1_050_000);
|
||||
}
|
||||
|
||||
/// Wire-served fields are never overwritten; absent fields are filled.
|
||||
#[test]
|
||||
fn enrich_fills_gaps_and_never_overwrites_wire() {
|
||||
|
||||
@@ -400,7 +400,7 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
|
||||
vendor: "Kimi",
|
||||
console_host: None,
|
||||
login_label: None,
|
||||
models_dev_id: Some("kimi-for-coding"),
|
||||
models_dev_id: Some("kimi-code-plan-cn"),
|
||||
wire_serves_metadata: true,
|
||||
wire_api: PlatformWireApi::ChatCompletions,
|
||||
listing: ListingDialect::OpenAi,
|
||||
@@ -987,7 +987,7 @@ const KIMI_CODING_SPEC: PlatformSpec = PlatformSpec {
|
||||
vendor: "Kimi",
|
||||
console_host: Some("www.kimi.com"),
|
||||
login_label: Some("Kimi For Coding (API key)"),
|
||||
models_dev_id: Some("kimi-for-coding"),
|
||||
models_dev_id: Some("kimi-code-plan-cn"),
|
||||
wire_serves_metadata: true,
|
||||
wire_api: PlatformWireApi::ChatCompletions,
|
||||
listing: ListingDialect::OpenAi,
|
||||
@@ -1570,9 +1570,10 @@ impl PlatformId {
|
||||
/// The compiled-in catalog for a platform that serves NO live `/models`
|
||||
/// listing (openai-codex), or `None` when the catalog comes from the wire.
|
||||
///
|
||||
/// openai-codex's 4 models are HARDCODED (read from the official Codex CLI's
|
||||
/// `models_cache.json`, the `visibility=="list"` AND `supported_in_api==true`
|
||||
/// set) because OpenAI exposes no stable public models endpoint for the
|
||||
/// openai-codex's 7 models are HARDCODED (read from the Codex backend
|
||||
/// catalog on 2026-09-22, the `visibility=="list"` AND
|
||||
/// `supported_in_api==true` set) because OpenAI exposes no stable public
|
||||
/// models endpoint for the
|
||||
/// ChatGPT Codex backend. Each entry carries context window + per-model
|
||||
/// selectable reasoning efforts (incl. the codex-only `xhigh`/`max`
|
||||
/// tiers), so the fetch path maps them through the SAME
|
||||
@@ -1606,14 +1607,33 @@ fn codex_wire_model(slug: &str, display_name: &str, efforts: &[&str], default: &
|
||||
}
|
||||
}
|
||||
|
||||
/// The HARDCODED openai-codex catalog: exactly the 4 `visibility=="list"` AND
|
||||
/// `supported_in_api==true` models from the Codex CLI model cache. The
|
||||
/// `gpt-5.3-codex-spark` (supported_in_api=false → not served by /responses),
|
||||
/// `gpt-5.4`, `gpt-5.4-mini`, and `codex-auto-review` (visibility="hide") models
|
||||
/// are deliberately EXCLUDED — they would list-but-not-work or are not
|
||||
/// user-facing (fail-fast: never advertise a model the backend rejects).
|
||||
/// The HARDCODED openai-codex catalog: exactly the 7 `visibility=="list"` AND
|
||||
/// `supported_in_api==true` models the Codex backend served on 2026-09-22, in
|
||||
/// its own priority order. `gpt-reserve` and `codex-auto-review`
|
||||
/// (visibility="hide") are deliberately EXCLUDED — they are not user-facing
|
||||
/// (fail-fast: never advertise a model the backend rejects). Several models
|
||||
/// also advertise an `ultra` tier above `max`; kigi has no such level, so it
|
||||
/// is not listed here.
|
||||
fn openai_codex_wire_models() -> Vec<WireModel> {
|
||||
vec![
|
||||
codex_wire_model(
|
||||
"gpt-6-astra",
|
||||
"GPT-6-Astra",
|
||||
&["low", "medium", "high", "xhigh", "max"],
|
||||
"medium",
|
||||
),
|
||||
codex_wire_model(
|
||||
"gpt-6-sol",
|
||||
"GPT-6-Sol",
|
||||
&["low", "medium", "high", "xhigh", "max"],
|
||||
"medium",
|
||||
),
|
||||
codex_wire_model(
|
||||
"gpt-6-luna",
|
||||
"GPT-6-Luna",
|
||||
&["low", "medium", "high", "xhigh", "max"],
|
||||
"medium",
|
||||
),
|
||||
codex_wire_model(
|
||||
"gpt-5.6-sol",
|
||||
"GPT-5.6-Sol",
|
||||
@@ -2653,29 +2673,32 @@ mod tests {
|
||||
assert_eq!(c.base_url(), "https://mock.codex/codex");
|
||||
}
|
||||
|
||||
/// The HARDCODED openai-codex catalog is exactly the 4 supported+listed
|
||||
/// The HARDCODED openai-codex catalog is exactly the 7 supported+listed
|
||||
/// models, keyed by slug, ctx 272000, each exposing its exact supported
|
||||
/// efforts (incl. the codex-only `xhigh`/`max` tiers). The
|
||||
/// list-but-broken / hidden models are absent. Every other platform serves
|
||||
/// NO hardcoded catalog (its models come from the live wire).
|
||||
/// efforts (incl. the codex-only `xhigh`/`max` tiers). The hidden models
|
||||
/// are absent. Every other platform serves NO hardcoded catalog (its
|
||||
/// models come from the live wire).
|
||||
#[test]
|
||||
fn openai_codex_hardcoded_catalog_is_the_four_supported_models() {
|
||||
fn openai_codex_hardcoded_catalog_is_the_seven_supported_models() {
|
||||
let catalog = PlatformId::OpenaiCodex
|
||||
.hardcoded_catalog()
|
||||
.expect("openai-codex serves a hardcoded catalog");
|
||||
let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"],
|
||||
"exactly the 4 visibility=list AND supported_in_api=true models"
|
||||
vec![
|
||||
"gpt-6-astra",
|
||||
"gpt-6-sol",
|
||||
"gpt-6-luna",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.6-terra",
|
||||
"gpt-5.6-luna",
|
||||
"gpt-5.5"
|
||||
],
|
||||
"exactly the 7 visibility=list AND supported_in_api=true models"
|
||||
);
|
||||
// Excluded: list-but-not-served + hidden models never appear.
|
||||
for absent in [
|
||||
"gpt-5.3-codex-spark",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"codex-auto-review",
|
||||
] {
|
||||
// Excluded: hidden models never appear.
|
||||
for absent in ["gpt-reserve", "codex-auto-review"] {
|
||||
assert!(
|
||||
!ids.contains(&absent),
|
||||
"{absent} must be excluded from the hardcoded catalog"
|
||||
@@ -2700,6 +2723,19 @@ mod tests {
|
||||
.valid_efforts
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
efforts("gpt-6-astra"),
|
||||
["low", "medium", "high", "xhigh", "max"],
|
||||
"`ultra` is advertised above `max` but kigi has no such level"
|
||||
);
|
||||
assert_eq!(
|
||||
efforts("gpt-6-sol"),
|
||||
["low", "medium", "high", "xhigh", "max"]
|
||||
);
|
||||
assert_eq!(
|
||||
efforts("gpt-6-luna"),
|
||||
["low", "medium", "high", "xhigh", "max"]
|
||||
);
|
||||
assert_eq!(
|
||||
efforts("gpt-5.6-sol"),
|
||||
["low", "medium", "high", "xhigh", "max"]
|
||||
@@ -2725,6 +2761,9 @@ mod tests {
|
||||
.default_effort
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(default("gpt-6-astra").as_deref(), Some("medium"));
|
||||
assert_eq!(default("gpt-6-sol").as_deref(), Some("medium"));
|
||||
assert_eq!(default("gpt-6-luna").as_deref(), Some("medium"));
|
||||
assert_eq!(default("gpt-5.6-sol").as_deref(), Some("low"));
|
||||
assert_eq!(default("gpt-5.6-terra").as_deref(), Some("medium"));
|
||||
// Only openai-codex has a hardcoded catalog; every wire platform is None.
|
||||
|
||||
@@ -375,7 +375,6 @@ impl TerminalContext {
|
||||
return Some("apple_terminal");
|
||||
}
|
||||
if self.is_vte_based() {
|
||||
// WHY: central helper replaces VTE duplication
|
||||
return Some("vte");
|
||||
}
|
||||
if self.brand == TerminalName::WindowsTerminal {
|
||||
@@ -429,74 +428,29 @@ impl TerminalContext {
|
||||
}
|
||||
|
||||
/// Whether the running terminal cannot distinguish `Shift+Enter` from
|
||||
/// bare `Enter` at the byte level, so the UI should advertise
|
||||
/// `Alt+Enter` for newline insertion instead.
|
||||
/// bare `Enter`, so the UI should advertise `Alt+Enter` (delivered as
|
||||
/// `ESC`+`CR`) for newline insertion instead.
|
||||
///
|
||||
/// Distinguishing `Shift+Enter` requires the Kitty keyboard protocol
|
||||
/// (KKP) to be negotiated. This returns `true` for the environments
|
||||
/// where the pager cannot rely on KKP for a usable `Shift+Enter`:
|
||||
/// Follows [`Self::kitty_skip_reason`] rather than a second brand list:
|
||||
/// when the protocol is not negotiated the modifier is lost, and the two
|
||||
/// drifted once already (VTE advertised a chord that never arrived — its
|
||||
/// `Return` keymap encodes only Alt, through 0.82.0 at least, and no VTE
|
||||
/// release carries the protocol). Two skips still deliver it — Apple
|
||||
/// Terminal drops the flags but `route_enter` polls CoreGraphics, and
|
||||
/// Windows console key records carry SHIFT with no protocol at all.
|
||||
///
|
||||
/// 1. **Legacy VTE** (GNOME Terminal, Ptyxis, kgx, Tilix, etc.) whose
|
||||
/// `VTE_VERSION` is below `8200` (VTE 0.82.0, the first release with
|
||||
/// KKP, merged in
|
||||
/// [MR !14](https://gitlab.gnome.org/GNOME/vte/-/merge_requests/14)).
|
||||
/// Also true when the brand is detected as VTE but `VTE_VERSION` is
|
||||
/// missing or unparseable — we conservatively assume old.
|
||||
/// 2. **VS Code's integrated terminal (xterm.js) and VS Code-family /
|
||||
/// xterm.js IDE forks**. xterm.js only partially implements KKP —
|
||||
/// it mis-encodes shifted printable keys — so the pager deliberately
|
||||
/// never negotiates KKP for them (see [`Self::kitty_skip_reason`]
|
||||
/// `== "vscode"` and [xterm.js#5823](https://github.com/xtermjs/xterm.js/issues/5823)).
|
||||
/// Without KKP, xterm.js sends a bare `CR` for `Shift+Enter`,
|
||||
/// byte-for-byte identical to `Enter`.
|
||||
/// 3. **Unidentified terminals with no multiplexer**, where the pager
|
||||
/// also skips KKP (no positive evidence of support — typically
|
||||
/// VS Code's xterm.js reached over SSH, where `TERM_PROGRAM` isn't
|
||||
/// forwarded and the brand falls back to `Unknown`).
|
||||
///
|
||||
/// In every case `Alt+Enter` (delivered as `ESC`+`CR`) is the reliable
|
||||
/// newline chord and is what the UI advertises.
|
||||
/// KNOWN GAP: WezTerm inside screen or an old tmux gets Alt+Enter as the
|
||||
/// advice while its own default binding spends that chord on
|
||||
/// ToggleFullScreen. Neither chord reaches kigi there; the multiplexer
|
||||
/// warning carries the real fix, and `\`+Enter still inserts a newline.
|
||||
pub fn shift_enter_unavailable(&self) -> bool {
|
||||
// WHY: central helper + version gating
|
||||
let is_vte = self.is_vte_based();
|
||||
if is_vte {
|
||||
return match self
|
||||
.vte_version
|
||||
.as_deref()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
{
|
||||
Some(ver) => ver < 8200,
|
||||
// Brand=Vte but no parseable version — conservative: assume old.
|
||||
None => true,
|
||||
};
|
||||
match self.kitty_skip_reason() {
|
||||
None | Some("apple_terminal") => false,
|
||||
// A bare ConHost is only optimistically refined to Windows
|
||||
// Terminal; it still advertises Alt+Enter.
|
||||
Some("windows_terminal") => self.env_brand.is_capability_unclassified(),
|
||||
Some(_) => true,
|
||||
}
|
||||
|
||||
// VS Code / xterm.js and its forks: KKP is never negotiated, so
|
||||
// Shift+Enter arrives as a bare CR indistinguishable from Enter.
|
||||
if matches!(
|
||||
self.brand,
|
||||
TerminalName::VsCode
|
||||
| TerminalName::Cursor
|
||||
| TerminalName::Windsurf
|
||||
| TerminalName::Zed
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unidentified / unclassified brand with no multiplexer: KKP is
|
||||
// skipped (see `kitty_skip_reason`). This is the common
|
||||
// VS Code-over-SSH shape (brand falls back to Unknown). On native
|
||||
// Windows the effective `brand` is refined to WindowsTerminal, so
|
||||
// consult `env_brand` — a bare ConHost is still env-Unknown and
|
||||
// must advertise Alt+Enter even though we optimistically treat it
|
||||
// as WT for capabilities.
|
||||
if self.env_brand.is_capability_unclassified()
|
||||
&& self.multiplexer == MultiplexerKind::Undetected
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// True when `Ctrl+.` cannot be delivered reliably as a shortcuts primary.
|
||||
|
||||
@@ -1443,7 +1443,7 @@ fn kitty_skip_vscode_over_tmux() {
|
||||
|
||||
#[test]
|
||||
fn kitty_skip_vte_version() {
|
||||
// VTE does not support Kitty keyboard protocol and crossterm's probe
|
||||
// No VTE release has the Kitty keyboard protocol and crossterm's probe
|
||||
// can false-positive on it. https://gitlab.gnome.org/GNOME/vte/-/issues/2601
|
||||
let ctx = TerminalContext {
|
||||
vte_version: Some("7402".to_owned()),
|
||||
@@ -1461,73 +1461,68 @@ fn kitty_skip_vte_brand() {
|
||||
assert_eq!(ctx.kitty_skip_reason(), Some("vte"));
|
||||
}
|
||||
|
||||
// shift_enter_unavailable: VTE version gating for Shift+Enter
|
||||
// shift_enter_unavailable: VTE never delivers Shift+Enter
|
||||
//
|
||||
// VTE 0.82.0 (= VTE_VERSION 8200) is the first release containing the
|
||||
// Kitty keyboard protocol; earlier versions cannot distinguish
|
||||
// Shift+Enter from bare Enter, so the UI should advertise Alt+Enter
|
||||
// for newline insertion instead.
|
||||
// No VTE release carries the Kitty keyboard protocol: at 0.82.0 the word
|
||||
// "kitty" appears nowhere in the parser or widget, and
|
||||
// `_vte_keymap_GDK_Return` encodes only Alt (`ESC CR`), so Shift+Enter is
|
||||
// the same bare CR as Enter. The UI advertises Alt+Enter instead.
|
||||
|
||||
#[test]
|
||||
fn shift_enter_unavailable_legacy_vte_version() {
|
||||
// VTE 0.64.2 (real user report) — well below the KKP cutoff.
|
||||
let ctx = TerminalContext {
|
||||
vte_version: Some("6402".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(ctx.shift_enter_unavailable());
|
||||
fn shift_enter_unavailable_on_every_vte() {
|
||||
for version in [Some("6402"), Some("8200"), Some("8401"), None] {
|
||||
let ctx = TerminalContext {
|
||||
brand: TerminalName::Vte,
|
||||
env_brand: TerminalName::Vte,
|
||||
vte_version: version.map(str::to_owned),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(ctx.kitty_skip_reason(), Some("vte"), "VTE {version:?}");
|
||||
assert!(ctx.shift_enter_unavailable(), "VTE {version:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_enter_unavailable_just_below_cutoff() {
|
||||
// VTE 0.81.99 — anything below 8200 must return true.
|
||||
let ctx = TerminalContext {
|
||||
vte_version: Some("8199".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(ctx.shift_enter_unavailable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_enter_available_modern_vte() {
|
||||
// VTE 0.82.0 — first release with KKP.
|
||||
let ctx = TerminalContext {
|
||||
vte_version: Some("8200".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!ctx.shift_enter_unavailable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_enter_available_future_vte() {
|
||||
// VTE 0.84.1 — well above the cutoff.
|
||||
let ctx = TerminalContext {
|
||||
vte_version: Some("8401".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!ctx.shift_enter_unavailable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_enter_unavailable_vte_brand_no_version() {
|
||||
// Brand detected as VTE but VTE_VERSION missing — conservative: old.
|
||||
let ctx = TerminalContext {
|
||||
brand: TerminalName::Vte,
|
||||
vte_version: None,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(ctx.shift_enter_unavailable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_enter_unavailable_unparseable_version() {
|
||||
// Garbage in VTE_VERSION — conservative: old.
|
||||
let ctx = TerminalContext {
|
||||
brand: TerminalName::Vte,
|
||||
vte_version: Some("not-a-number".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(ctx.shift_enter_unavailable());
|
||||
fn shift_enter_unavailable_follows_every_kitty_skip() {
|
||||
// A skipped protocol means the modifier never arrives, whatever the
|
||||
// reason — JediTerm, screen and a multiplexer that eats extended keys
|
||||
// included. Apple Terminal and Windows Terminal are the two skips with
|
||||
// another way in.
|
||||
for ctx in [
|
||||
TerminalContext {
|
||||
brand: TerminalName::JetBrains,
|
||||
env_brand: TerminalName::JetBrains,
|
||||
..Default::default()
|
||||
},
|
||||
TerminalContext {
|
||||
brand: TerminalName::Kitty,
|
||||
env_brand: TerminalName::Kitty,
|
||||
multiplexer: MultiplexerKind::Screen,
|
||||
..Default::default()
|
||||
},
|
||||
TerminalContext {
|
||||
brand: TerminalName::Kitty,
|
||||
env_brand: TerminalName::Kitty,
|
||||
multiplexer: MultiplexerKind::Tmux,
|
||||
tmux_version: Some("3.2".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
// A VTE carrying a recent VTE_VERSION is no different.
|
||||
TerminalContext {
|
||||
brand: TerminalName::Vte,
|
||||
env_brand: TerminalName::Vte,
|
||||
vte_version: Some("8200".to_owned()),
|
||||
multiplexer: MultiplexerKind::Screen,
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
assert!(ctx.kitty_skip_reason().is_some());
|
||||
assert!(
|
||||
ctx.shift_enter_unavailable(),
|
||||
"{:?} skips the protocol, so Shift+Enter cannot arrive",
|
||||
ctx.brand
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -267,6 +267,13 @@ struct WrapCache {
|
||||
lines: Vec<Range<usize>>,
|
||||
}
|
||||
|
||||
/// Whether a wrapped row's exclusive end is a cursor position on that row.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum RowEnd {
|
||||
HardBreak,
|
||||
SoftWrap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct TextAreaState {
|
||||
/// Index into wrapped lines of the first visible line.
|
||||
@@ -382,27 +389,13 @@ impl TextArea {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp a buffer position so it stays within a wrapped line's range
|
||||
/// `[line_start, line_end)`. Without this, `display_col_to_buffer_pos`
|
||||
/// can return `line_end` when the column exceeds the line's display
|
||||
/// width — and `line_end` equals the *next* wrapped line's start,
|
||||
/// which confuses `effective_scroll` into thinking the cursor hasn't
|
||||
/// actually moved to the target line.
|
||||
///
|
||||
/// Uses `self.text` to find the last valid char boundary inside the line
|
||||
/// so we never land in the middle of a multi-byte character.
|
||||
/// Clamp `pos` to the start of the last grapheme in `[line_start, line_end)`.
|
||||
fn clamp_to_line(&self, pos: usize, line_start: usize, line_end: usize) -> usize {
|
||||
if line_end > line_start {
|
||||
// Find the start of the last character in the line.
|
||||
let last_char_start = self.text[line_start..line_end]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map(|(i, _)| line_start + i)
|
||||
.unwrap_or(line_start);
|
||||
pos.min(last_char_start)
|
||||
} else {
|
||||
line_start
|
||||
}
|
||||
let last_grapheme_start = self.text[line_start..line_end]
|
||||
.grapheme_indices(true)
|
||||
.next_back()
|
||||
.map_or(line_start, |(i, _)| line_start + i);
|
||||
pos.min(last_grapheme_start)
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
@@ -1699,6 +1692,35 @@ impl TextArea {
|
||||
if idx == 0 { None } else { Some(idx - 1) }
|
||||
}
|
||||
|
||||
/// Soft-wrapped rows are byte-adjacent; a hard row leaves its newline between it and the next.
|
||||
fn row_with_end(lines: &[Range<usize>], idx: usize) -> Option<(Range<usize>, RowEnd)> {
|
||||
let row = lines.get(idx)?;
|
||||
let end_kind = match lines.get(idx + 1) {
|
||||
Some(next) if next.start == row.end => RowEnd::SoftWrap,
|
||||
_ => RowEnd::HardBreak,
|
||||
};
|
||||
Some((row.clone(), end_kind))
|
||||
}
|
||||
|
||||
fn clamp_to_row(&self, pos: usize, row: &Range<usize>, end_kind: RowEnd) -> usize {
|
||||
match end_kind {
|
||||
RowEnd::HardBreak => pos,
|
||||
RowEnd::SoftWrap => {
|
||||
let last_grapheme_start = self.clamp_to_line(pos, row.start, row.end);
|
||||
let Some(idx) = self.find_element_containing(last_grapheme_start) else {
|
||||
return last_grapheme_start;
|
||||
};
|
||||
let element_start = self.elements[idx].range.start;
|
||||
// A display-less element wraps as text; one that began on an earlier row keeps `pos`.
|
||||
if element_start < row.start {
|
||||
pos
|
||||
} else {
|
||||
element_start
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a display column to a buffer byte position on a given wrapped line.
|
||||
///
|
||||
/// Pure query — does not mutate any state. Handles elements (snapping to
|
||||
@@ -1791,13 +1813,12 @@ impl TextArea {
|
||||
|
||||
fn move_to_display_col_on_line(
|
||||
&mut self,
|
||||
line_start: usize,
|
||||
line_end: usize,
|
||||
row: Range<usize>,
|
||||
target_col: usize,
|
||||
end_kind: RowEnd,
|
||||
) {
|
||||
self.cursor_pos = self
|
||||
.display_col_to_buffer_pos(line_start, line_end, target_col)
|
||||
.0;
|
||||
let (pos, _hit_element) = self.display_col_to_buffer_pos(row.start, row.end, target_col);
|
||||
self.cursor_pos = self.clamp_to_row(pos, &row, end_kind);
|
||||
}
|
||||
|
||||
fn beginning_of_line(&self, pos: usize) -> usize {
|
||||
@@ -2652,14 +2673,10 @@ impl TextArea {
|
||||
let target_col = self.preferred_col.unwrap_or_else(|| {
|
||||
self.display_width_of_range(cur_range.start, self.cursor_pos)
|
||||
});
|
||||
if idx > 0 {
|
||||
let prev = &lines[idx - 1];
|
||||
let line_start = prev.start;
|
||||
let line_end = prev.end;
|
||||
Some((target_col, Some((line_start, line_end))))
|
||||
} else {
|
||||
Some((target_col, None))
|
||||
}
|
||||
let prev = idx
|
||||
.checked_sub(1)
|
||||
.and_then(|prev_idx| Self::row_with_end(lines, prev_idx));
|
||||
Some((target_col, prev))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -2669,11 +2686,11 @@ impl TextArea {
|
||||
} {
|
||||
// We had wrapping info. Apply movement accordingly.
|
||||
match maybe_line {
|
||||
Some((line_start, line_end)) => {
|
||||
Some((prev, end_kind)) => {
|
||||
if self.preferred_col.is_none() {
|
||||
self.preferred_col = Some(target_col);
|
||||
}
|
||||
self.move_to_display_col_on_line(line_start, line_end, target_col);
|
||||
self.move_to_display_col_on_line(prev, target_col, end_kind);
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
@@ -2697,7 +2714,11 @@ impl TextArea {
|
||||
};
|
||||
let prev_line_start = self.text[..prev_nl].rfind('\n').map(|i| i + 1).unwrap_or(0);
|
||||
let prev_line_end = prev_nl;
|
||||
self.move_to_display_col_on_line(prev_line_start, prev_line_end, target_col);
|
||||
self.move_to_display_col_on_line(
|
||||
prev_line_start..prev_line_end,
|
||||
target_col,
|
||||
RowEnd::HardBreak,
|
||||
);
|
||||
} else {
|
||||
self.cursor_pos = 0;
|
||||
self.preferred_col = None;
|
||||
@@ -2716,14 +2737,7 @@ impl TextArea {
|
||||
let target_col = self.preferred_col.unwrap_or_else(|| {
|
||||
self.display_width_of_range(cur_range.start, self.cursor_pos)
|
||||
});
|
||||
if idx + 1 < lines.len() {
|
||||
let next = &lines[idx + 1];
|
||||
let line_start = next.start;
|
||||
let line_end = next.end;
|
||||
Some((target_col, Some((line_start, line_end))))
|
||||
} else {
|
||||
Some((target_col, None))
|
||||
}
|
||||
Some((target_col, Self::row_with_end(lines, idx + 1)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -2732,11 +2746,11 @@ impl TextArea {
|
||||
}
|
||||
} {
|
||||
match move_to_last {
|
||||
Some((line_start, line_end)) => {
|
||||
Some((next, end_kind)) => {
|
||||
if self.preferred_col.is_none() {
|
||||
self.preferred_col = Some(target_col);
|
||||
}
|
||||
self.move_to_display_col_on_line(line_start, line_end, target_col);
|
||||
self.move_to_display_col_on_line(next, target_col, end_kind);
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
@@ -2766,7 +2780,11 @@ impl TextArea {
|
||||
.find('\n')
|
||||
.map(|i| i + next_line_start)
|
||||
.unwrap_or(self.text.len());
|
||||
self.move_to_display_col_on_line(next_line_start, next_line_end, target_col);
|
||||
self.move_to_display_col_on_line(
|
||||
next_line_start..next_line_end,
|
||||
target_col,
|
||||
RowEnd::HardBreak,
|
||||
);
|
||||
} else {
|
||||
self.cursor_pos = self.text.len();
|
||||
self.preferred_col = None;
|
||||
@@ -3899,6 +3917,9 @@ fn truncate_line_display(line: &Line<'static>, max_width: usize) -> Line<'static
|
||||
Line::from(new_spans)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod soft_wrap_nav_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -6293,6 +6314,7 @@ mod tests {
|
||||
assert_eq!(t.cursor(), "👍👍".len());
|
||||
}
|
||||
|
||||
/// Each ZWJ cluster fills a 4-col row; Up from the end lands on row 1's cluster start.
|
||||
#[test]
|
||||
fn wrapped_navigation_with_zwj_graphemes() {
|
||||
let grapheme = "👩\u{200D}💻";
|
||||
@@ -6306,7 +6328,7 @@ mod tests {
|
||||
assert!(pos_after_down >= grapheme.len() * 2);
|
||||
|
||||
t.move_cursor_up();
|
||||
assert_eq!(t.cursor(), grapheme.len() * 2);
|
||||
assert_eq!(t.cursor(), grapheme.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Up/Down across soft-wrapped rows: a soft row's exclusive end is the next row's start.
|
||||
|
||||
use rand::prelude::*;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
|
||||
use super::{ElementKind, TextArea};
|
||||
|
||||
fn ta_with(text: &str) -> TextArea {
|
||||
let mut t = TextArea::new();
|
||||
t.insert_str(text);
|
||||
t
|
||||
}
|
||||
|
||||
fn cursor_row(t: &TextArea, width: u16) -> usize {
|
||||
TextArea::wrapped_line_index_by_start(&t.wrapped_lines(width), t.cursor()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_from_wide_row_lands_on_last_char_of_narrower_soft_row() {
|
||||
let width = 8;
|
||||
let mut t = ta_with("aaa bb ccccccc");
|
||||
assert_eq!(vec![0..7, 7..14], *t.wrapped_lines(width));
|
||||
|
||||
t.move_cursor_up();
|
||||
assert_eq!(6, t.cursor());
|
||||
assert_eq!(0, cursor_row(&t, width));
|
||||
assert_eq!(Some((6, 0)), t.cursor_pos(Rect::new(0, 0, width, 5)));
|
||||
|
||||
t.move_cursor_down();
|
||||
assert_eq!(14, t.cursor());
|
||||
|
||||
t.move_cursor_up();
|
||||
assert_eq!(6, t.cursor());
|
||||
t.move_cursor_up();
|
||||
assert_eq!(0, t.cursor());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_onto_newline_terminated_row_lands_on_the_newline() {
|
||||
let mut t = ta_with("ab\ncdef");
|
||||
assert_eq!(vec![0..2, 3..7], *t.wrapped_lines(8));
|
||||
|
||||
t.move_cursor_up();
|
||||
assert_eq!(2, t.cursor());
|
||||
assert_eq!(0, cursor_row(&t, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_from_wide_row_lands_on_last_char_of_narrower_soft_row() {
|
||||
let mut t = ta_with("ccccccc aaa bb ddddddd");
|
||||
assert_eq!(vec![0..8, 8..15, 15..22], *t.wrapped_lines(8));
|
||||
t.set_cursor(7);
|
||||
|
||||
t.move_cursor_down();
|
||||
assert_eq!(14, t.cursor());
|
||||
assert_eq!(1, cursor_row(&t, 8));
|
||||
|
||||
t.move_cursor_down();
|
||||
assert_eq!(22, t.cursor());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_onto_soft_row_closed_by_element_lands_before_the_element() {
|
||||
let mut t = TextArea::new();
|
||||
t.insert_element("xyz", ElementKind(0), Some(Line::from("[ELEM]")));
|
||||
t.insert_str("longword");
|
||||
assert_eq!(vec![0..3, 3..11], *t.wrapped_lines(8));
|
||||
|
||||
t.move_cursor_up();
|
||||
assert_eq!(0, t.cursor());
|
||||
assert_eq!(0, cursor_row(&t, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_through_element_spanning_rows_reaches_the_end() {
|
||||
let mut t = TextArea::new();
|
||||
t.insert_element("abcdefgh", ElementKind(0), None);
|
||||
t.insert_str("z");
|
||||
assert_eq!(vec![0..3, 3..6, 6..9], *t.wrapped_lines(3));
|
||||
t.set_cursor(0);
|
||||
|
||||
t.move_cursor_down();
|
||||
assert_eq!(8, t.cursor());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_and_down_move_one_row_at_a_time() {
|
||||
let mut rng = StdRng::seed_from_u64(7);
|
||||
for _ in 0..500 {
|
||||
let words = rng.random_range(1..12);
|
||||
let text = (0..words)
|
||||
.map(|_| "x".repeat(rng.random_range(1..9)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let width = rng.random_range(3..12);
|
||||
let mut t = ta_with(&text);
|
||||
let rows = t.wrapped_lines(width).len();
|
||||
t.set_cursor(rng.random_range(0..=text.len()));
|
||||
|
||||
let mut row = cursor_row(&t, width);
|
||||
while row + 1 < rows {
|
||||
t.move_cursor_down();
|
||||
assert_eq!(
|
||||
row + 1,
|
||||
cursor_row(&t, width),
|
||||
"down in {text:?} at width {width}"
|
||||
);
|
||||
row += 1;
|
||||
}
|
||||
while row > 0 {
|
||||
t.move_cursor_up();
|
||||
assert_eq!(
|
||||
row - 1,
|
||||
cursor_row(&t, width),
|
||||
"up in {text:?} at width {width}"
|
||||
);
|
||||
row -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,7 +897,7 @@ impl CompactionsRemaining {
|
||||
}
|
||||
|
||||
/// Reasoning effort level. `None`/`Minimal` are omitted on the Anthropic Messages API.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ReasoningEffort {
|
||||
None,
|
||||
@@ -982,6 +982,16 @@ impl std::str::FromStr for ReasoningEffort {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for ReasoningEffort {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let raw = String::deserialize(deserializer)?;
|
||||
raw.parse().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical wire parse; remapped menu ids need a model catalog.
|
||||
pub fn parse_canonical_effort_token(token: &str) -> Option<ReasoningEffort> {
|
||||
token.parse().ok()
|
||||
@@ -1146,7 +1156,7 @@ pub const ANTHROPIC_OAUTH_BETA: &str = "claude-code-20250219,oauth-2025-04-20";
|
||||
|
||||
/// User-Agent kigi presents on the Claude-Code OAuth path (mirrors the
|
||||
/// official Claude Code CLI). OAuth-gated: unrelated to the default kigi UA.
|
||||
pub const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.75";
|
||||
pub const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.280";
|
||||
|
||||
/// System-prompt prefix REQUIRED on the Claude-Code OAuth Messages path: the
|
||||
/// OAuth token is Claude-Code-scoped, so Anthropic rejects the request unless
|
||||
@@ -1192,7 +1202,7 @@ pub const CODEX_OPENAI_BETA: &str = "responses=experimental";
|
||||
/// openai-codex-gated). The Codex backend does not strictly validate the UA
|
||||
/// string (Pi ships its own and it works), so this is a stable best-effort
|
||||
/// identity, not a pinned build.
|
||||
pub const CODEX_USER_AGENT: &str = "codex_cli_rs/0.104.0";
|
||||
pub const CODEX_USER_AGENT: &str = "codex_cli_rs/0.156.0";
|
||||
/// JWT payload claim namespace carrying the ChatGPT account id.
|
||||
const CODEX_JWT_AUTH_CLAIM: &str = "https://api.openai.com/auth";
|
||||
|
||||
@@ -1849,6 +1859,24 @@ mod tests {
|
||||
assert!(serde_json::from_str::<ReasoningEffort>("\"BOGUS\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_deserializes_mixed_case() {
|
||||
for (token, want) in [
|
||||
("\"High\"", ReasoningEffort::High),
|
||||
("\"XHIGH\"", ReasoningEffort::Xhigh),
|
||||
("\"mAx\"", ReasoningEffort::Max),
|
||||
] {
|
||||
let got: ReasoningEffort = serde_json::from_str(token).unwrap();
|
||||
assert_eq!(got, want, "deserialize {token}");
|
||||
}
|
||||
let err = serde_json::from_str::<ReasoningEffort>("\"Highest\"").unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("invalid reasoning effort"),
|
||||
"{err}"
|
||||
);
|
||||
assert!(serde_json::from_str::<ReasoningEffort>("7").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_from_str_max_and_xhigh_are_distinct() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -544,13 +544,16 @@ mod attachments_protocol {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
mod osascript_probe;
|
||||
|
||||
// macOS: subprocess-based clipboard (no AppKit linkage)
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::attachments_protocol::{FURL_MARKER, IMAGE_MARKER, parse_attachments_output};
|
||||
use super::osascript_probe::{ProbeTemps, osascript_attachments, read_probe_raster};
|
||||
use super::{ClipboardAttachments, ImageData};
|
||||
|
||||
// Fast pasteboard probes (NSPasteboard via lazy dlopen)
|
||||
@@ -759,153 +762,15 @@ mod platform {
|
||||
Ok(output.stdout)
|
||||
}
|
||||
|
||||
fn attachments_probe_temp_paths() -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf)
|
||||
{
|
||||
let temp_dir = std::env::temp_dir();
|
||||
(
|
||||
temp_dir.join("kigi-clipboard-probe.png"),
|
||||
temp_dir.join("kigi-clipboard-probe.tiff"),
|
||||
temp_dir.join("kigi-clipboard-probe.jpg"),
|
||||
)
|
||||
}
|
||||
|
||||
fn read_clipboard_image_from_class(
|
||||
class: &str,
|
||||
path_png: &std::path::Path,
|
||||
path_tiff: &std::path::Path,
|
||||
path_jpg: &std::path::Path,
|
||||
) -> anyhow::Result<Option<ImageData>> {
|
||||
let (temp_path, mime) = match class {
|
||||
"PNGf" => (path_png, "image/png"),
|
||||
"TIFF" => (path_tiff, "image/tiff"),
|
||||
"JPEG" => (path_jpg, "image/jpeg"),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let data = match std::fs::read(temp_path) {
|
||||
Ok(bytes) if !bytes.is_empty() => bytes,
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(temp_path);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(temp_path);
|
||||
return Err(anyhow::anyhow!("failed to read clipboard temp file: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
let _ = std::fs::remove_file(temp_path);
|
||||
Ok(Some(ImageData {
|
||||
data,
|
||||
mime_type: mime.to_owned(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn remove_attachment_probe_temps(
|
||||
path_png: &std::path::Path,
|
||||
path_tiff: &std::path::Path,
|
||||
path_jpg: &std::path::Path,
|
||||
) {
|
||||
let _ = std::fs::remove_file(path_png);
|
||||
let _ = std::fs::remove_file(path_tiff);
|
||||
let _ = std::fs::remove_file(path_jpg);
|
||||
}
|
||||
|
||||
fn attachments_osascript(
|
||||
path_png: &std::path::Path,
|
||||
path_tiff: &std::path::Path,
|
||||
path_jpg: &std::path::Path,
|
||||
) -> String {
|
||||
format!(
|
||||
"set furlOut to \"none\"\n\
|
||||
try\n\
|
||||
set urlList to the clipboard as list\n\
|
||||
set out to \"\"\n\
|
||||
repeat with u in urlList\n\
|
||||
try\n\
|
||||
set itemRef to contents of u as \u{00AB}class furl\u{00BB}\n\
|
||||
set out to out & POSIX path of itemRef & \"\\n\"\n\
|
||||
end try\n\
|
||||
end repeat\n\
|
||||
if out is not \"\" then\n\
|
||||
set furlOut to out\n\
|
||||
else\n\
|
||||
try\n\
|
||||
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
|
||||
set furlOut to POSIX path of urlRef\n\
|
||||
on error\n\
|
||||
end try\n\
|
||||
end if\n\
|
||||
on error\n\
|
||||
try\n\
|
||||
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
|
||||
set furlOut to POSIX path of urlRef\n\
|
||||
on error\n\
|
||||
end try\n\
|
||||
end try\n\
|
||||
set imageOut to \"NONE\"\n\
|
||||
if furlOut is \"none\" then\n\
|
||||
try\n\
|
||||
set imgData to the clipboard as \u{00AB}class PNGf\u{00BB}\n\
|
||||
set filePath to POSIX file \"{png}\" as text\n\
|
||||
set fRef to open for access file filePath with write permission\n\
|
||||
set eof of fRef to 0\n\
|
||||
write imgData to fRef\n\
|
||||
close access fRef\n\
|
||||
set imageOut to \"PNGf\"\n\
|
||||
on error\n\
|
||||
try\n\
|
||||
set imgData to the clipboard as \u{00AB}class TIFF\u{00BB}\n\
|
||||
set filePath to POSIX file \"{tiff}\" as text\n\
|
||||
set fRef to open for access file filePath with write permission\n\
|
||||
set eof of fRef to 0\n\
|
||||
write imgData to fRef\n\
|
||||
close access fRef\n\
|
||||
set imageOut to \"TIFF\"\n\
|
||||
on error\n\
|
||||
try\n\
|
||||
set imgData to the clipboard as \u{00AB}class JPEG\u{00BB}\n\
|
||||
set filePath to POSIX file \"{jpg}\" as text\n\
|
||||
set fRef to open for access file filePath with write permission\n\
|
||||
set eof of fRef to 0\n\
|
||||
write imgData to fRef\n\
|
||||
close access fRef\n\
|
||||
set imageOut to \"JPEG\"\n\
|
||||
on error\n\
|
||||
end try\n\
|
||||
end try\n\
|
||||
end try\n\
|
||||
end if\n\
|
||||
return \"{furl_marker}\" & linefeed & furlOut & linefeed & \"{image_marker}\" & linefeed & \"IMAGE:\" & imageOut",
|
||||
png = path_png.display(),
|
||||
tiff = path_tiff.display(),
|
||||
jpg = path_jpg.display(),
|
||||
furl_marker = FURL_MARKER,
|
||||
image_marker = IMAGE_MARKER,
|
||||
)
|
||||
}
|
||||
|
||||
fn run_attachments_osascript() -> anyhow::Result<String> {
|
||||
let (path_png, path_tiff, path_jpg) = attachments_probe_temp_paths();
|
||||
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
|
||||
|
||||
let script = attachments_osascript(&path_png, &path_tiff, &path_jpg);
|
||||
|
||||
fn run_osascript(script: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let mut cmd = Command::new("osascript");
|
||||
cmd.arg("-e")
|
||||
.arg(&script)
|
||||
.arg(script)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
kigi_tools::util::detach_std_command(&mut cmd);
|
||||
let stdout = match checked_command_stdout("osascript", cmd.output()) {
|
||||
Ok(stdout) => stdout,
|
||||
Err(error) => {
|
||||
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
Ok(String::from_utf8_lossy(&stdout).into_owned())
|
||||
checked_command_stdout("osascript", cmd.output())
|
||||
}
|
||||
|
||||
/// Unified furl-then-image pasteboard probe.
|
||||
@@ -923,28 +788,7 @@ mod platform {
|
||||
image: Some(image),
|
||||
});
|
||||
}
|
||||
let raw = run_attachments_osascript()?;
|
||||
if raw.trim().is_empty() {
|
||||
return Ok(ClipboardAttachments::default());
|
||||
}
|
||||
|
||||
let (path_png, path_tiff, path_jpg) = attachments_probe_temp_paths();
|
||||
let (file_urls, image_class) = parse_attachments_output(&raw);
|
||||
let image = if file_urls.is_some() {
|
||||
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
|
||||
None
|
||||
} else {
|
||||
match image_class {
|
||||
Some(class) => {
|
||||
read_clipboard_image_from_class(class, &path_png, &path_tiff, &path_jpg)?
|
||||
}
|
||||
None => {
|
||||
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(ClipboardAttachments { file_urls, image })
|
||||
osascript_attachments(ProbeTemps::new(), run_osascript)
|
||||
}
|
||||
|
||||
/// Read text via `pbpaste -Prefer txt`.
|
||||
@@ -1020,8 +864,8 @@ mod platform {
|
||||
return Ok(Some(image));
|
||||
}
|
||||
|
||||
let (path_png, path_tiff, path_jpg) = attachments_probe_temp_paths();
|
||||
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
|
||||
let temps = ProbeTemps::new()?;
|
||||
let (path_png, path_tiff, path_jpg) = temps.paths();
|
||||
|
||||
// Image-only AppleScript (ImageOnly paste route). Unicode guillemets
|
||||
// (\u{AB}/\u{BB}) are required for `«class …»` in `osascript -e`.
|
||||
@@ -1069,20 +913,13 @@ mod platform {
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
kigi_tools::util::detach_std_command(&mut cmd);
|
||||
let stdout = match checked_command_stdout("osascript", cmd.output()) {
|
||||
Ok(stdout) => stdout,
|
||||
Err(error) => {
|
||||
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let stdout = checked_command_stdout("osascript", cmd.output())?;
|
||||
let class = String::from_utf8_lossy(&stdout);
|
||||
let class = class.trim();
|
||||
if class == "none" {
|
||||
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
|
||||
return Ok(None);
|
||||
}
|
||||
read_clipboard_image_from_class(class, &path_png, &path_tiff, &path_jpg)
|
||||
read_probe_raster(class, &temps)
|
||||
}
|
||||
|
||||
/// Read file URLs from the macOS pasteboard via `osascript`.
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! The macOS `osascript` attachment probe: scratch files, script text, and the read flow.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::attachments_protocol::{FURL_MARKER, IMAGE_MARKER, parse_attachments_output};
|
||||
use super::{ClipboardAttachments, ImageData};
|
||||
|
||||
/// Private 0700 dir, removed on drop: a shared path let a concurrent kigi swap the raster.
|
||||
pub(crate) struct ProbeTemps(tempfile::TempDir);
|
||||
|
||||
impl ProbeTemps {
|
||||
pub(crate) fn new() -> anyhow::Result<Self> {
|
||||
let mut builder = tempfile::Builder::new();
|
||||
builder.prefix("kigi-clipboard-probe-");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
builder.permissions(std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
builder
|
||||
.tempdir()
|
||||
.map(Self)
|
||||
.map_err(|e| anyhow::anyhow!("failed to create clipboard probe dir: {e}"))
|
||||
}
|
||||
|
||||
/// PNG, TIFF, JPEG: the AppleScript writes the first class that coerces.
|
||||
pub(crate) fn paths(&self) -> (PathBuf, PathBuf, PathBuf) {
|
||||
(
|
||||
self.0.path().join("probe.png"),
|
||||
self.0.path().join("probe.tiff"),
|
||||
self.0.path().join("probe.jpg"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// File URLs first, a raster only when none are present; without `temps` the raster half is skipped.
|
||||
pub(crate) fn attachments_osascript(temps: Option<&ProbeTemps>) -> String {
|
||||
let image_probe = temps.map_or_else(String::new, raster_half);
|
||||
format!(
|
||||
"set furlOut to \"none\"\n\
|
||||
try\n\
|
||||
set urlList to the clipboard as list\n\
|
||||
set out to \"\"\n\
|
||||
repeat with u in urlList\n\
|
||||
try\n\
|
||||
set itemRef to contents of u as \u{00AB}class furl\u{00BB}\n\
|
||||
set out to out & POSIX path of itemRef & \"\\n\"\n\
|
||||
end try\n\
|
||||
end repeat\n\
|
||||
if out is not \"\" then\n\
|
||||
set furlOut to out\n\
|
||||
else\n\
|
||||
try\n\
|
||||
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
|
||||
set furlOut to POSIX path of urlRef\n\
|
||||
on error\n\
|
||||
end try\n\
|
||||
end if\n\
|
||||
on error\n\
|
||||
try\n\
|
||||
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
|
||||
set furlOut to POSIX path of urlRef\n\
|
||||
on error\n\
|
||||
end try\n\
|
||||
end try\n\
|
||||
set imageOut to \"NONE\"\n\
|
||||
{image_probe}\
|
||||
return \"{furl_marker}\" & linefeed & furlOut & linefeed & \"{image_marker}\" & linefeed & \"IMAGE:\" & imageOut",
|
||||
furl_marker = FURL_MARKER,
|
||||
image_marker = IMAGE_MARKER,
|
||||
)
|
||||
}
|
||||
|
||||
fn raster_half(temps: &ProbeTemps) -> String {
|
||||
let (path_png, path_tiff, path_jpg) = temps.paths();
|
||||
format!(
|
||||
"if furlOut is \"none\" then\n\
|
||||
try\n\
|
||||
set imgData to the clipboard as \u{00AB}class PNGf\u{00BB}\n\
|
||||
set filePath to POSIX file \"{png}\" as text\n\
|
||||
set fRef to open for access file filePath with write permission\n\
|
||||
set eof of fRef to 0\n\
|
||||
write imgData to fRef\n\
|
||||
close access fRef\n\
|
||||
set imageOut to \"PNGf\"\n\
|
||||
on error\n\
|
||||
try\n\
|
||||
set imgData to the clipboard as \u{00AB}class TIFF\u{00BB}\n\
|
||||
set filePath to POSIX file \"{tiff}\" as text\n\
|
||||
set fRef to open for access file filePath with write permission\n\
|
||||
set eof of fRef to 0\n\
|
||||
write imgData to fRef\n\
|
||||
close access fRef\n\
|
||||
set imageOut to \"TIFF\"\n\
|
||||
on error\n\
|
||||
try\n\
|
||||
set imgData to the clipboard as \u{00AB}class JPEG\u{00BB}\n\
|
||||
set filePath to POSIX file \"{jpg}\" as text\n\
|
||||
set fRef to open for access file filePath with write permission\n\
|
||||
set eof of fRef to 0\n\
|
||||
write imgData to fRef\n\
|
||||
close access fRef\n\
|
||||
set imageOut to \"JPEG\"\n\
|
||||
on error\n\
|
||||
end try\n\
|
||||
end try\n\
|
||||
end try\n\
|
||||
end if\n",
|
||||
png = path_png.display(),
|
||||
tiff = path_tiff.display(),
|
||||
jpg = path_jpg.display(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The class on stdout picks the file and MIME; an empty file means no raster.
|
||||
pub(crate) fn read_probe_raster(
|
||||
class: &str,
|
||||
temps: &ProbeTemps,
|
||||
) -> anyhow::Result<Option<ImageData>> {
|
||||
let (path_png, path_tiff, path_jpg) = temps.paths();
|
||||
let (temp_path, mime) = match class {
|
||||
"PNGf" => (path_png, "image/png"),
|
||||
"TIFF" => (path_tiff, "image/tiff"),
|
||||
"JPEG" => (path_jpg, "image/jpeg"),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let data = std::fs::read(&temp_path)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read clipboard temp file: {e}"))?;
|
||||
if data.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(ImageData {
|
||||
data,
|
||||
mime_type: mime.to_owned(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `run` executes a script and returns its stdout; a file paste needs no scratch dir.
|
||||
pub(crate) fn osascript_attachments(
|
||||
temps: anyhow::Result<ProbeTemps>,
|
||||
run: impl FnOnce(&str) -> anyhow::Result<Vec<u8>>,
|
||||
) -> anyhow::Result<ClipboardAttachments> {
|
||||
if let Err(error) = &temps {
|
||||
tracing::warn!(%error, "clipboard probe reads file URLs only");
|
||||
}
|
||||
let stdout = run(&attachments_osascript(temps.as_ref().ok()))?;
|
||||
let (file_urls, image_class) = parse_attachments_output(&String::from_utf8_lossy(&stdout));
|
||||
if file_urls.is_some() {
|
||||
return Ok(ClipboardAttachments {
|
||||
file_urls,
|
||||
image: None,
|
||||
});
|
||||
}
|
||||
let temps = temps?;
|
||||
let image = match image_class {
|
||||
Some(class) => read_probe_raster(class, &temps)?,
|
||||
None => None,
|
||||
};
|
||||
Ok(ClipboardAttachments {
|
||||
file_urls: None,
|
||||
image,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn attachments_stdout(furl: &str, image: &str) -> Vec<u8> {
|
||||
format!("{FURL_MARKER}\n{furl}\n{IMAGE_MARKER}\nIMAGE:{image}").into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_probes_never_share_a_path() {
|
||||
let first = ProbeTemps::new().unwrap();
|
||||
let second = ProbeTemps::new().unwrap();
|
||||
assert_ne!(first.paths().0, second.paths().0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_dir_goes_away_with_the_probe() {
|
||||
let temps = ProbeTemps::new().unwrap();
|
||||
let (png, tiff, jpg) = temps.paths();
|
||||
let dir = png.parent().unwrap().to_path_buf();
|
||||
assert_eq!(tiff.parent().unwrap(), dir);
|
||||
assert_eq!(jpg.parent().unwrap(), dir);
|
||||
std::fs::write(&png, b"raster").unwrap();
|
||||
|
||||
drop(temps);
|
||||
assert!(!dir.exists(), "{} must be removed", dir.display());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn scratch_dir_is_private() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temps = ProbeTemps::new().unwrap();
|
||||
let dir = temps.paths().0.parent().unwrap().to_path_buf();
|
||||
let mode = std::fs::metadata(dir).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reported_class_is_read_from_this_probes_file() {
|
||||
let temps = ProbeTemps::new().unwrap();
|
||||
let (_png, tiff, _jpg) = temps.paths();
|
||||
std::fs::write(&tiff, b"MM\x00\x2a").unwrap();
|
||||
let script_names_the_file = tiff.display().to_string();
|
||||
|
||||
let read = osascript_attachments(Ok(temps), |script| {
|
||||
assert!(script.contains(&script_names_the_file), "{script}");
|
||||
Ok(attachments_stdout("none", "TIFF"))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let image = read.image.expect("raster attached");
|
||||
assert_eq!(image.mime_type, "image/tiff");
|
||||
assert_eq!(image.data, b"MM\x00\x2a".to_vec());
|
||||
assert_eq!(read.file_urls, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_urls_survive_a_missing_scratch_dir() {
|
||||
let read = osascript_attachments(Err(anyhow::anyhow!("no scratch dir")), |script| {
|
||||
assert!(
|
||||
!script.contains("open for access"),
|
||||
"raster half must be skipped"
|
||||
);
|
||||
Ok(attachments_stdout("/tmp/a.txt\n", "NONE"))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(read.file_urls.as_deref(), Some("/tmp/a.txt"));
|
||||
assert_eq!(read.image, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_scratch_dir_and_no_file_url_is_an_error_not_an_empty_board() {
|
||||
let err = osascript_attachments(Err(anyhow::anyhow!("no scratch dir")), |_| {
|
||||
Ok(attachments_stdout("none", "NONE"))
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("no scratch dir"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -6707,7 +6707,10 @@ reasoning_effort = "low"
|
||||
assert_eq!(model.info.base_url, "https://inference.example.com/v1");
|
||||
}
|
||||
#[test]
|
||||
#[serial]
|
||||
fn e2e_default_model_with_session_routes_to_proxy() {
|
||||
// Hermetic: serial tests point KIGI_CODE_BASE_URL at wiremock.
|
||||
let _base = EnvGuard::unset(kigi_env::CODE_BASE_URL_ENV);
|
||||
let (_, models) = resolve_models_from_toml("", None);
|
||||
let model = models
|
||||
.get(BUNDLED_DEFAULT_KEY)
|
||||
@@ -6839,7 +6842,10 @@ reasoning_effort = "low"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[serial]
|
||||
fn e2e_duplicate_model_field_both_entries_survive() {
|
||||
// Hermetic: serial tests point KIGI_CODE_BASE_URL at wiremock.
|
||||
let _base = EnvGuard::unset(kigi_env::CODE_BASE_URL_ENV);
|
||||
let dm = crate::models::default_model();
|
||||
let (_, models) = resolve_models_from_toml(
|
||||
&format!(
|
||||
|
||||
@@ -367,7 +367,7 @@ mod tests {
|
||||
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, "http://127.0.0.1:1/api.json");
|
||||
let catalog = load_enrichment_catalog_at(&path);
|
||||
assert!(
|
||||
kigi_models::enrichment::lookup(&catalog, "kimi-for-coding", "k3").is_some(),
|
||||
kigi_models::enrichment::lookup(&catalog, "kimi-code-plan-cn", "k3").is_some(),
|
||||
"bundled snapshot must back a total refresh failure"
|
||||
);
|
||||
assert!(!path.exists(), "failed refresh must not write a cache");
|
||||
@@ -385,7 +385,7 @@ mod tests {
|
||||
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, token);
|
||||
assert!(refresh_url().is_none(), "token {token:?} must disable");
|
||||
let catalog = load_enrichment_catalog_at(&path);
|
||||
assert!(kigi_models::enrichment::lookup(&catalog, "kimi-for-coding", "k3").is_some());
|
||||
assert!(kigi_models::enrichment::lookup(&catalog, "kimi-code-plan-cn", "k3").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1473,7 +1473,7 @@ mod tests {
|
||||
|
||||
/// openai-codex fetch: the catalog is HARDCODED, so the fetch path
|
||||
/// short-circuits BEFORE any HTTP — there is NO mock `/models` server, yet
|
||||
/// the fetch returns exactly the 4 compiled-in models keyed
|
||||
/// the fetch returns exactly the 7 compiled-in models keyed
|
||||
/// `openai-codex/<slug>` on the Responses backend, ctx 272000, each exposing
|
||||
/// its exact reasoning efforts (incl. the codex-only `xhigh`/`max`).
|
||||
/// A BOGUS base URL confirms no live `/models` request is attempted (it would
|
||||
@@ -1510,20 +1510,18 @@ mod tests {
|
||||
.map(|m| m.id.as_deref().unwrap_or_default())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"openai-codex/gpt-6-astra",
|
||||
"openai-codex/gpt-6-sol",
|
||||
"openai-codex/gpt-6-luna",
|
||||
"openai-codex/gpt-5.6-sol",
|
||||
"openai-codex/gpt-5.6-terra",
|
||||
"openai-codex/gpt-5.6-luna",
|
||||
"openai-codex/gpt-5.5",
|
||||
],
|
||||
"exactly the 4 hardcoded models, keyed openai-codex/<slug>"
|
||||
"exactly the 7 hardcoded models, keyed openai-codex/<slug>"
|
||||
);
|
||||
// Excluded models never appear.
|
||||
for absent in [
|
||||
"openai-codex/gpt-5.3-codex-spark",
|
||||
"openai-codex/gpt-5.4",
|
||||
"openai-codex/gpt-5.4-mini",
|
||||
"openai-codex/codex-auto-review",
|
||||
] {
|
||||
for absent in ["openai-codex/gpt-reserve", "openai-codex/codex-auto-review"] {
|
||||
assert!(
|
||||
!result
|
||||
.models
|
||||
@@ -1532,7 +1530,14 @@ mod tests {
|
||||
"{absent} must be absent from the hardcoded catalog"
|
||||
);
|
||||
}
|
||||
let sol = &result.models[0];
|
||||
let astra = &result.models[0];
|
||||
assert_eq!(astra.name.as_deref(), Some("GPT-6-Astra"));
|
||||
assert_eq!(astra.context_window.get(), 272_000);
|
||||
let sol = result
|
||||
.models
|
||||
.iter()
|
||||
.find(|m| m.id.as_deref() == Some("openai-codex/gpt-5.6-sol"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sol.api_backend,
|
||||
crate::sampling::ApiBackend::Responses,
|
||||
|
||||
@@ -638,6 +638,7 @@ fn write_import_marker(config_path: &Path) -> anyhow::Result<()> {
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let config_path = &crate::util::config::config_write_dest(config_path)?;
|
||||
let tmp = config_path.with_extension("toml.tmp");
|
||||
// Best-effort cleanup of the .tmp file if either write or rename fails so
|
||||
// a failed marker write doesn't leave a stale artefact next to the real
|
||||
@@ -820,6 +821,7 @@ fn apply_items_to_config(config_path: &Path, items: &[ImportableItem]) -> anyhow
|
||||
|
||||
if count > 0 {
|
||||
let toml_str = toml::to_string_pretty(&root)?;
|
||||
let config_path = &crate::util::config::config_write_dest(config_path)?;
|
||||
let tmp = config_path.with_extension("toml.tmp");
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
|
||||
@@ -513,6 +513,7 @@ pub fn apply_at(plan: &KimiImportPlan, kigi_home: &Path) -> anyhow::Result<KimiA
|
||||
if applied.total_added() > 0 {
|
||||
// Atomic write: tmp + rename (same pattern as save_mcp_server_config).
|
||||
let toml_str = toml::to_string_pretty(&root)?;
|
||||
let config_path = crate::util::config::config_write_dest(&config_path)?;
|
||||
let tmp = config_path.with_extension("toml.tmp");
|
||||
if let Err(e) = std::fs::write(&tmp, &toml_str) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
|
||||
@@ -1020,6 +1020,7 @@ pub(super) async fn build_skill_information_for_refs(
|
||||
slash_skills: &[SkillInfo],
|
||||
session_id: &str,
|
||||
) -> Option<String> {
|
||||
use kigi_tools::implementations::skills::body_cap::cap_skill_body;
|
||||
use kigi_tools::implementations::skills::skill::{
|
||||
SkillRef, SubstitutionContext, apply_substitutions, build_skill_block,
|
||||
build_skill_information, load_skill_content,
|
||||
@@ -1034,6 +1035,13 @@ pub(super) async fn build_skill_information_for_refs(
|
||||
};
|
||||
match load_skill_content(info).await {
|
||||
Ok(mut content) => {
|
||||
if cap_skill_body(&mut content) {
|
||||
tracing::info!(
|
||||
skill = %sk.name,
|
||||
path = %info.path,
|
||||
"skill body truncated at read cap"
|
||||
);
|
||||
}
|
||||
let skill_dir = std::path::Path::new(&info.path)
|
||||
.parent()
|
||||
.and_then(|p| p.to_str());
|
||||
@@ -1465,6 +1473,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A 220 KB skill body stops at the read cap; the tail stays on disk.
|
||||
#[tokio::test]
|
||||
async fn build_skill_information_for_refs_caps_an_oversized_body() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("SKILL.md");
|
||||
let body = (1..=1100)
|
||||
.map(|n| format!("{n:05} {}", "x".repeat(194)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
std::fs::write(&path, &body).unwrap();
|
||||
|
||||
let mut skill = make_skill("big", true);
|
||||
skill.path = path.to_string_lossy().to_string();
|
||||
let skills = vec![skill];
|
||||
|
||||
let parsed =
|
||||
parse_skill_references("/big", &skills, all_gated()).expect("known skill must parse");
|
||||
let info = build_skill_information_for_refs(&parsed, &skills, "sid-1")
|
||||
.await
|
||||
.expect("skill body must load");
|
||||
assert!(info.contains("00001 "), "head must survive");
|
||||
assert!(!info.contains("01100 "), "tail must be cut");
|
||||
assert!(info.contains("Skill body truncated at the 25000-token cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_loop_annotates_block_with_compact_display_text() {
|
||||
let outcome = resolve(
|
||||
|
||||
@@ -359,6 +359,7 @@ pub async fn save_mcp_disabled_tools(server_name: &str, disabled_tools: &[String
|
||||
}
|
||||
|
||||
let toml_str = toml::to_string_pretty(&root)?;
|
||||
let path = super::config_write_dest(&path)?;
|
||||
let tmp = path.with_extension("toml.tmp");
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
@@ -413,6 +414,7 @@ pub async fn save_mcp_server_enabled(server_name: &str, enabled: bool) -> Result
|
||||
}
|
||||
|
||||
let toml_str = toml::to_string_pretty(&root)?;
|
||||
let path = super::config_write_dest(&path)?;
|
||||
let tmp = path.with_extension("toml.tmp");
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
@@ -472,6 +474,7 @@ pub async fn save_mcp_server_config_at(
|
||||
}
|
||||
|
||||
let toml_str = toml::to_string_pretty(&root)?;
|
||||
let path = super::config_write_dest(path)?;
|
||||
let tmp = path.with_extension("toml.tmp");
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
@@ -552,6 +555,7 @@ pub async fn delete_mcp_server_config_at(
|
||||
}
|
||||
|
||||
let toml_str = toml::to_string_pretty(&root)?;
|
||||
let path = super::config_write_dest(path)?;
|
||||
let tmp = path.with_extension("toml.tmp");
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
|
||||
@@ -10,6 +10,7 @@ mod resolve;
|
||||
mod settings_writes;
|
||||
mod tips;
|
||||
mod worktree;
|
||||
mod write_dest;
|
||||
|
||||
pub use campaigns::{
|
||||
load_effective_config, load_effective_config_disk_only, persist_models_default,
|
||||
@@ -30,3 +31,4 @@ pub use resolve::*;
|
||||
pub use settings_writes::*;
|
||||
pub use tips::*;
|
||||
pub use worktree::*;
|
||||
pub(crate) use write_dest::config_write_dest;
|
||||
@@ -52,6 +52,7 @@ pub async fn save_config(config: &Config) -> Result<()> {
|
||||
}
|
||||
|
||||
let toml_str = toml::to_string_pretty(&root)?;
|
||||
let path = super::config_write_dest(&path)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Where a config write lands when the path is a symlink.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The user's own config.toml keeps its symlink; every other path comes back unchanged.
|
||||
pub(crate) fn config_write_dest(path: &Path) -> io::Result<PathBuf> {
|
||||
// With no home, `kigi_home()` is `./.kigi`: the project's own tree, never trusted.
|
||||
let user_config = kigi_config::user_kigi_home().map(|home| home.join("config.toml"));
|
||||
write_dest(path, user_config.as_deref())
|
||||
}
|
||||
|
||||
/// A project config is repo-controlled: following its symlink would overwrite any file it names.
|
||||
fn write_dest(path: &Path, user_config: Option<&Path>) -> io::Result<PathBuf> {
|
||||
if user_config != Some(path) {
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) if meta.file_type().is_symlink() => dunce::canonicalize(path).map_err(|e| {
|
||||
let context = format!("config symlink {} does not resolve: {e}", path.display());
|
||||
io::Error::new(e.kind(), context)
|
||||
}),
|
||||
Ok(_) => Ok(path.to_path_buf()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(path.to_path_buf()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::config::{McpServerConfig, save_mcp_server_config_at};
|
||||
|
||||
fn symlinked_config(dir: &Path, referent_content: &str) -> (PathBuf, PathBuf) {
|
||||
let referent = dir.join("dotfiles.toml");
|
||||
std::fs::write(&referent, referent_content).unwrap();
|
||||
let link = dir.join("config.toml");
|
||||
std::os::unix::fs::symlink(&referent, &link).unwrap();
|
||||
(link, referent)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_config_symlink_resolves_to_its_referent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (link, referent) = symlinked_config(dir.path(), "");
|
||||
|
||||
let dest = write_dest(&link, Some(&link)).unwrap();
|
||||
assert_eq!(dest, dunce::canonicalize(&referent).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_and_missing_user_config_keep_their_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let regular = dir.path().join("config.toml");
|
||||
std::fs::write(®ular, "").unwrap();
|
||||
let missing = dir.path().join("absent.toml");
|
||||
|
||||
assert_eq!(write_dest(®ular, Some(®ular)).unwrap(), regular);
|
||||
assert_eq!(write_dest(&missing, Some(&missing)).unwrap(), missing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dangling_user_config_symlink_is_an_error_not_a_clobber() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let link = dir.path().join("config.toml");
|
||||
std::os::unix::fs::symlink(dir.path().join("gone.toml"), &link).unwrap();
|
||||
|
||||
let err = write_dest(&link, Some(&link)).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::NotFound);
|
||||
assert!(err.to_string().contains("does not resolve"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_config_symlink_is_never_followed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (link, _referent) = symlinked_config(dir.path(), "");
|
||||
let user_config = dir.path().join("home").join("config.toml");
|
||||
|
||||
assert_eq!(write_dest(&link, Some(&user_config)).unwrap(), link);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_followed_when_no_user_home_resolves() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (link, _referent) = symlinked_config(dir.path(), "");
|
||||
|
||||
assert_eq!(write_dest(&link, None).unwrap(), link);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_scope_save_replaces_the_link_and_spares_its_target() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (link, target) = symlinked_config(dir.path(), "victim = true\n");
|
||||
|
||||
let server: McpServerConfig =
|
||||
toml::from_str("command = \"echo\"\nargs = [\"hi\"]\n").unwrap();
|
||||
save_mcp_server_config_at(&link, "demo", &server)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let meta = std::fs::symlink_metadata(&link).unwrap();
|
||||
assert!(
|
||||
meta.file_type().is_file(),
|
||||
"the link is replaced, not followed"
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&target).unwrap(), "victim = true\n");
|
||||
let saved = std::fs::read_to_string(&link).unwrap();
|
||||
assert!(saved.contains("[mcp_servers.demo]"), "{saved}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! A symlinked `~/.kigi/config.toml` survives a save: the write lands on the referent.
|
||||
#![cfg(unix)]
|
||||
|
||||
use kigi_shell::util::config::{McpServerConfig, save_mcp_server_config};
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_config_symlink_survives_a_save() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let dotfiles = tempfile::tempdir().unwrap();
|
||||
// SAFETY: the only test in this binary; set before `kigi_home()` first runs.
|
||||
unsafe { std::env::set_var("KIGI_SHARE_DIR", home.path()) };
|
||||
|
||||
let referent = dotfiles.path().join("kigi.toml");
|
||||
std::fs::write(&referent, "[ui]\ntheme = \"dark\"\n").unwrap();
|
||||
let link = home.path().join("config.toml");
|
||||
std::os::unix::fs::symlink(&referent, &link).unwrap();
|
||||
|
||||
let server: McpServerConfig = toml::from_str("command = \"echo\"\nargs = [\"hi\"]\n").unwrap();
|
||||
save_mcp_server_config("demo", &server).await.unwrap();
|
||||
|
||||
let meta = std::fs::symlink_metadata(&link).unwrap();
|
||||
assert!(meta.file_type().is_symlink(), "the link must survive");
|
||||
let saved = std::fs::read_to_string(&referent).unwrap();
|
||||
assert!(saved.contains("[mcp_servers.demo]"), "{saved}");
|
||||
assert!(saved.contains("theme = \"dark\""), "{saved}");
|
||||
let leftovers: Vec<_> = std::fs::read_dir(home.path())
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name())
|
||||
.filter(|name| name != "config.toml")
|
||||
.collect();
|
||||
assert!(
|
||||
leftovers.is_empty(),
|
||||
"no tmp beside the link: {leftovers:?}"
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,12 @@ impl ReadFileVersion {
|
||||
}
|
||||
}
|
||||
pub(crate) const MAX_NUM_TOKENS: usize = 25_000;
|
||||
pub(crate) const MAX_NUM_BYTES: usize =
|
||||
MAX_NUM_TOKENS * kigi_token_estimation::BYTES_PER_TOKEN as usize;
|
||||
/// True when `text` is over the per-call token cap.
|
||||
pub(crate) fn exceeds_read_cap(text: &str) -> bool {
|
||||
crate::util::truncate::estimate_tokens(text) > MAX_NUM_TOKENS
|
||||
}
|
||||
pub const MAX_LINES_READ: usize = 1_000;
|
||||
pub use crate::implementations::read_file::{
|
||||
FileMetadata, PDF_MAX_PAGES_PER_READ, bytes_to_metadata, parse_page_range,
|
||||
@@ -434,22 +440,25 @@ pub(crate) async fn run_read_file(
|
||||
.map(|t| t.0.max_lines_read())
|
||||
.unwrap_or_else(|| TruncationConfig::default().max_lines_read())
|
||||
};
|
||||
let (effective_offset, effective_limit) = if is_skill_file {
|
||||
(None, None)
|
||||
} else {
|
||||
(
|
||||
input.offset,
|
||||
Some(input.limit.unwrap_or(usize::MAX).min(max_lines)),
|
||||
)
|
||||
// A skill file under the cap comes back whole; over it, it reads like any other file.
|
||||
let skill_full = is_skill_file
|
||||
.then(|| extract_file_content_lines(&file_content, None, None, total_lines))
|
||||
.filter(|full| !exceeds_read_cap(&full.content));
|
||||
let (extracted, stored_offset, stored_limit) = match skill_full {
|
||||
Some(full) => (full, None, None),
|
||||
None => (
|
||||
extract_file_content_lines(
|
||||
&file_content,
|
||||
input.offset,
|
||||
Some(input.limit.unwrap_or(usize::MAX).min(max_lines)),
|
||||
total_lines,
|
||||
),
|
||||
stored_read_offset(input.offset),
|
||||
input.limit,
|
||||
),
|
||||
};
|
||||
let extracted = extract_file_content_lines(
|
||||
&file_content,
|
||||
effective_offset,
|
||||
effective_limit,
|
||||
total_lines,
|
||||
);
|
||||
let token_count = crate::util::truncate::estimate_tokens(&extracted.content);
|
||||
if !is_skill_file && token_count > MAX_NUM_TOKENS {
|
||||
if exceeds_read_cap(&extracted.content) {
|
||||
let token_count = crate::util::truncate::estimate_tokens(&extracted.content);
|
||||
let (grep_name, execute_name);
|
||||
{
|
||||
let res = resources.lock().await;
|
||||
@@ -497,11 +506,6 @@ pub(crate) async fn run_read_file(
|
||||
};
|
||||
return Ok(ReadFileOutput::FileTooLarge(msg));
|
||||
}
|
||||
let (stored_offset, stored_limit) = if is_skill_file {
|
||||
(None, None)
|
||||
} else {
|
||||
(stored_read_offset(input.offset), input.limit)
|
||||
};
|
||||
if let Some(flag) = streamable_out {
|
||||
*flag = true;
|
||||
}
|
||||
@@ -1660,9 +1664,8 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
|
||||
other => panic!("Expected FileContent, got {:?}", other),
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn skill_file_skips_token_limit() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
/// 1100 lines of 200 bytes: about 55k tokens, over the 25k cap.
|
||||
fn write_oversized_skill(tmp: &TempDir) {
|
||||
let line = "x".repeat(200);
|
||||
let big_content = std::iter::repeat_n(line.as_str(), 1100)
|
||||
.collect::<Vec<_>>()
|
||||
@@ -1670,6 +1673,11 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
|
||||
let skill_dir = tmp.path().join("skills");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(skill_dir.join("SKILL.md"), &big_content).unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn skill_file_over_cap_returns_file_too_large() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
write_oversized_skill(&tmp);
|
||||
let tool = ReadFileTool;
|
||||
let mut resources = test_resources(tmp.path());
|
||||
resources.insert(TemplateRenderer::new(
|
||||
@@ -1687,11 +1695,36 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result, ReadFileOutput::FileContent(_)),
|
||||
"SKILL.md should not be truncated, got {:?}",
|
||||
matches!(result, ReadFileOutput::FileTooLarge(_)),
|
||||
"got {:?}",
|
||||
std::mem::discriminant(&result),
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn skill_file_over_cap_honors_offset_and_limit() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
write_oversized_skill(&tmp);
|
||||
let tool = ReadFileTool;
|
||||
let resources = test_resources(tmp.path());
|
||||
let input = ReadFileInput {
|
||||
path: "skills/SKILL.md".to_string(),
|
||||
offset: Some(3),
|
||||
limit: Some(2),
|
||||
pages: None,
|
||||
format: None,
|
||||
};
|
||||
let result = kigi_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input)
|
||||
.await
|
||||
.unwrap();
|
||||
match result {
|
||||
ReadFileOutput::FileContent(fc) => {
|
||||
assert_eq!((Some(3), Some(2)), (fc.offset, fc.limit));
|
||||
let line = "x".repeat(200);
|
||||
assert_eq!(format!("3→{line}\n{line}"), fc.content);
|
||||
}
|
||||
other => panic!("Expected FileContent, got {:?}", other),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn parse_single_page() {
|
||||
assert_eq!(parse_page_range("3", 10).unwrap(), vec![2]);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::implementations::skills::body_cap::cap_skill_body;
|
||||
use crate::implementations::skills::skill::{SkillOutput, extract_skill_body, format_skill_name};
|
||||
use crate::implementations::skills::types::SkillInfo;
|
||||
use crate::types::requirements::{Expr, ToolRequirement};
|
||||
@@ -279,7 +280,7 @@ impl kigi_tool_runtime::Tool for SkillTool {
|
||||
}
|
||||
};
|
||||
|
||||
let content = match load_skill_content(&skill).await {
|
||||
let mut content = match load_skill_content(&skill).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Ok(SkillOutput {
|
||||
@@ -292,6 +293,14 @@ impl kigi_tool_runtime::Tool for SkillTool {
|
||||
}
|
||||
};
|
||||
|
||||
if cap_skill_body(&mut content) {
|
||||
tracing::info!(
|
||||
skill = %skill.name,
|
||||
path = %skill.path,
|
||||
"skill body truncated at read cap"
|
||||
);
|
||||
}
|
||||
|
||||
let files = list_skill_files(&skill, 10).await;
|
||||
|
||||
let output = format_output(&skill, &content, &files);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Cap an injected skill body at the file-read token cap.
|
||||
|
||||
use crate::implementations::kigi::read_file::{MAX_NUM_BYTES, MAX_NUM_TOKENS, exceeds_read_cap};
|
||||
use crate::util::truncate::floor_char_boundary;
|
||||
|
||||
/// Cuts `body` on a line boundary under the read cap and appends a note; true when it cut.
|
||||
pub fn cap_skill_body(body: &mut String) -> bool {
|
||||
if !exceeds_read_cap(body) {
|
||||
return false;
|
||||
}
|
||||
let note = format!(
|
||||
"[Skill body truncated at the {MAX_NUM_TOKENS}-token cap. Read the rest of the \
|
||||
skill file with the file read tool using a line offset and limit.]"
|
||||
);
|
||||
let budget = MAX_NUM_BYTES.saturating_sub(note.len() + 2);
|
||||
let end = floor_char_boundary(body, budget);
|
||||
let cut = body[..end].rfind('\n').filter(|&i| i > 0).unwrap_or(end);
|
||||
body.truncate(cut);
|
||||
body.push_str("\n\n");
|
||||
body.push_str(¬e);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn leaves_body_under_cap_untouched() {
|
||||
let mut body = "# Small skill\n\nDo the thing.".to_owned();
|
||||
assert!(!cap_skill_body(&mut body));
|
||||
assert_eq!("# Small skill\n\nDo the thing.", body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuts_on_line_boundary_under_cap() {
|
||||
let mut body = (1..=1100)
|
||||
.map(|n| format!("{n:05} {}", "x".repeat(194)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
assert!(cap_skill_body(&mut body));
|
||||
assert!(!exceeds_read_cap(&body));
|
||||
assert!(!body.contains("01100 "));
|
||||
assert!(body.contains("offset"));
|
||||
let (head, _note) = body.rsplit_once("\n\n").expect("note separator");
|
||||
let last_line = head.rsplit('\n').next().expect("head has a line");
|
||||
assert_eq!(200, last_line.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_long_line_falls_back_to_char_cut() {
|
||||
let mut body = "é".repeat(60_000);
|
||||
|
||||
assert!(cap_skill_body(&mut body));
|
||||
assert!(!exceeds_read_cap(&body));
|
||||
assert!(body.starts_with("éé"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod body_cap;
|
||||
pub mod discovery;
|
||||
pub mod skill;
|
||||
pub mod types;
|
||||
@@ -8,8 +8,8 @@ const MAX_LINES_READ_DEFAULT: usize = 1_000;
|
||||
/// There is deliberately no per-line cap: clipping long lines silently
|
||||
/// corrupts single-line files (minified JSON, data dumps) with no way for
|
||||
/// the model to recover the clipped bytes. Non-skill reads are bounded by
|
||||
/// the whole-read `MAX_NUM_TOKENS` cap instead (skill files are exempt from
|
||||
/// all read limits by design). Other agent CLIs likewise apply no
|
||||
/// the whole-read `MAX_NUM_TOKENS` cap instead (a skill file under that cap
|
||||
/// comes back whole, offset and limit ignored). Other agent CLIs likewise apply no
|
||||
/// per-line cap. The wire field (`TruncationConfig.max_chars_per_line` in
|
||||
/// kigi-tools.proto) is deprecated and ignored.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
|
||||
@@ -96,16 +96,15 @@ impl SlashCommand for TerminalSetupCommand {
|
||||
|
||||
// Some terminals can't distinguish Shift+Enter from bare Enter at
|
||||
// the byte level because the Kitty keyboard protocol isn't
|
||||
// negotiated (VTE < 0.82, or VS Code's xterm.js which mis-encodes
|
||||
// shifted keys). Point users at Alt+Enter, which is reliably
|
||||
// delivered as ESC+CR. Suppressed when the WezTerm warning fired:
|
||||
// stock WezTerm binds Alt+Enter to ToggleFullScreen, so advertising
|
||||
// it would contradict that warning's `\`+Enter guidance.
|
||||
if ctx.shift_enter_unavailable() && !wezterm_kkp_off {
|
||||
// negotiated (VTE, VS Code's xterm.js, a multiplexer that eats
|
||||
// extended keys). Point users at Alt+Enter, which is reliably
|
||||
// delivered as ESC+CR. Never for WezTerm: it binds Alt+Enter to
|
||||
// ToggleFullScreen, so its own warning's `\`+Enter guidance stands.
|
||||
if ctx.shift_enter_unavailable() && !wezterm_kkp_off && ctx.brand != TerminalName::WezTerm {
|
||||
let detail = if ctx.vte_version.is_some() || ctx.brand == TerminalName::Vte {
|
||||
match ctx.vte_version.as_deref() {
|
||||
Some(v) => format!("VTE {v}; need >= 8200 for Shift+Enter"),
|
||||
None => "legacy VTE; need VTE >= 0.82 for Shift+Enter".to_owned(),
|
||||
Some(v) => format!("VTE {v}: no Kitty keyboard protocol"),
|
||||
None => "VTE: no Kitty keyboard protocol".to_owned(),
|
||||
}
|
||||
} else if matches!(
|
||||
ctx.brand,
|
||||
|
||||
@@ -24,7 +24,7 @@ TARGETS = [
|
||||
"anthropic", "azure", "openai", "deepseek", "nvidia", "google",
|
||||
"amazon-bedrock", "mistral", "groq", "cerebras", "cloudflare-ai-gateway",
|
||||
"xai", "togetherai", "fireworks-ai", "opencode", "opencode-go",
|
||||
"kimi-for-coding", "moonshotai", "moonshotai-cn", "minimax", "minimax-cn",
|
||||
"kimi-code-plan-cn", "moonshotai", "moonshotai-cn", "minimax", "minimax-cn",
|
||||
"alibaba-token-plan", "alibaba-token-plan-cn", "xiaomi",
|
||||
"xiaomi-token-plan-cn", "zai-coding-plan", "zhipuai-coding-plan",
|
||||
"github-copilot", "vercel",
|
||||
|
||||
Reference in new issue
Block a user