mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 17:56:14 -10:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e93b5dbf7 | ||
|
|
833573681a | ||
|
|
6bc820aa7b | ||
|
|
d66ad71532 | ||
|
|
a9e1aa81e1 | ||
|
|
d065467cd3 | ||
|
|
f12911d4bc | ||
|
|
5763eb2d80 | ||
|
|
6dcc51e591 | ||
|
|
0eab63c908 | ||
|
|
64a783b08c | ||
|
|
9c292092a2 | ||
|
|
060d38ce71 | ||
|
|
04fd3c01a7 | ||
|
|
dc15e82298 | ||
|
|
2d2450e9a7 | ||
|
|
cabfeefc4e | ||
|
|
0c1de25a04 |
No files matched your search
@@ -91,6 +91,14 @@ These classes drive multi-round reviews. Fix them before requesting review:
|
||||
final-component-only no-follow is insufficient. On multi-step setup, roll back
|
||||
only what this run created; never destroy pre-existing resources you did
|
||||
not create; never report success when cleanup or unlock failed.
|
||||
- **Normalize before matching:** Any transform that REMOVES bytes without
|
||||
leaving a gap is also a reassembler, so it has to run before whatever matches
|
||||
on the result. Redacting by shape and then stripping control bytes lets a
|
||||
credential split by a NUL, an ESC or a C1 byte pass the patterns as two
|
||||
fragments and be rejoined on the way out; comparing a path against a form
|
||||
produced by the same resolver the kernel just used makes a redirect agree with
|
||||
itself. Strip, decode, or canonicalize first, then match. The unsplit value
|
||||
passing is not evidence, so the test needs a split case.
|
||||
- **Atomic shared state:** Write a complete temporary file, then atomically
|
||||
replace the destination so concurrent readers never see a partial write.
|
||||
Exclusive create or a write lock alone is not enough for readers. Serialize
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -76,7 +76,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
||||
|
||||
### From source
|
||||
|
||||
Source builds require Go 1.26.5+.
|
||||
Source builds require Go 1.26.6+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -57,7 +57,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
||||
|
||||
### 从源码构建
|
||||
|
||||
源码构建需要 Go 1.26.5+。
|
||||
源码构建需要 Go 1.26.6+。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
|
||||
+83
-8
@@ -1,14 +1,30 @@
|
||||
# Installing Zero
|
||||
|
||||
Zero is distributed as:
|
||||
Pick whichever fits how you already install things. None of these is the
|
||||
blessed one.
|
||||
|
||||
- an npm package, `@gitlawb/zero`
|
||||
- release archives on GitHub Releases
|
||||
- source builds with Go 1.26.5+
|
||||
| Method | Command | Self-update |
|
||||
| --- | --- | --- |
|
||||
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
|
||||
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
|
||||
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
|
||||
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
|
||||
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
|
||||
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
|
||||
| Source | `go build -o zero ./cmd/zero` | rebuild |
|
||||
|
||||
The install scripts download a platform-specific release archive and require a
|
||||
published GitHub Release for the requested version. The npm package is
|
||||
self-contained: the platform binary installs from the npm registry.
|
||||
`zero upgrade` follows whatever owns the binary, with one exception it cannot
|
||||
detect. It delegates to npm for an npm install, refuses outright for a Homebrew
|
||||
keg and names `brew upgrade zero`, and replaces the binary in place for
|
||||
everything else — including a mise-managed one, which is the exception.
|
||||
|
||||
mise is the one case it cannot detect: a mise-managed binary is an ordinary
|
||||
standalone install on disk, so `zero upgrade` will replace it and leave mise
|
||||
describing a version that is no longer there. Use `mise upgrade` instead.
|
||||
|
||||
Release archives are the substrate for most of the above: the install scripts
|
||||
and the npm fallback both download a platform archive from a published GitHub
|
||||
Release and verify its checksum.
|
||||
|
||||
## npm
|
||||
|
||||
@@ -111,6 +127,45 @@ Defaults:
|
||||
- Version: latest GitHub release
|
||||
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
|
||||
|
||||
## mise
|
||||
|
||||
[mise](https://mise.jdx.dev/) installs Zero straight from the GitHub Release
|
||||
archives through its `ubi` backend, so there is nothing extra to publish and no
|
||||
registry in the middle:
|
||||
|
||||
```bash
|
||||
mise use -g ubi:Gitlawb/zero
|
||||
```
|
||||
|
||||
Pin a version the same way you would any other tool:
|
||||
|
||||
```bash
|
||||
mise use -g ubi:Gitlawb/[email protected]
|
||||
```
|
||||
|
||||
Updates come from `mise upgrade`. `zero upgrade` also works, because a
|
||||
mise-managed binary is an ordinary standalone install, but then mise's records
|
||||
describe a version that is no longer on disk. Prefer `mise upgrade`.
|
||||
|
||||
## go install
|
||||
|
||||
```bash
|
||||
go install github.com/Gitlawb/zero/cmd/zero@latest
|
||||
```
|
||||
|
||||
This builds from source, so it needs Go 1.26.6+ and it does not go through the
|
||||
release archives. Two consequences worth knowing before you pick it:
|
||||
|
||||
- On Linux you also need the sandbox helper, which is a separate binary and is
|
||||
not installed by this command. See
|
||||
[Sandbox Helpers For Source Builds](#sandbox-helpers-for-source-builds).
|
||||
Without it, native sandboxing is unavailable.
|
||||
- `zero upgrade` treats the result as a standalone install and will replace the
|
||||
binary with a release build rather than rebuilding from source. If you chose
|
||||
`go install` deliberately, rerun it instead.
|
||||
|
||||
macOS and Windows need no extra helper.
|
||||
|
||||
## From Source
|
||||
|
||||
```bash
|
||||
@@ -125,7 +180,7 @@ Build a local binary:
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
Source builds require Go 1.26.5+.
|
||||
Source builds require Go 1.26.6+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
@@ -242,3 +297,23 @@ zero upgrade
|
||||
```
|
||||
|
||||
See the [update guide](UPDATE.md) for update modes, flags, and platform details.
|
||||
|
||||
`zero upgrade` behaves differently depending on how Zero was installed, because
|
||||
overwriting a binary a package manager owns leaves that manager describing a
|
||||
version that is no longer there:
|
||||
|
||||
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
|
||||
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
|
||||
keg binary directly would be reverted by the next `brew upgrade` or
|
||||
`brew reinstall`.
|
||||
- **everything else**: downloads the verified release archive and replaces the
|
||||
binary in place.
|
||||
|
||||
Homebrew is detected by the binary living inside a Cellar keg that carries
|
||||
Homebrew's own `INSTALL_RECEIPT.json`. The keg shape alone is not enough: a
|
||||
directory tree you built yourself can have exactly that shape, and refusing to
|
||||
update an install Homebrew never touched would be a real cost for a made-up
|
||||
reason. The receipt is the thing only Homebrew writes.
|
||||
|
||||
An ordinary install under `/usr/local/bin` is left alone even on an Intel Mac
|
||||
where that is also the Homebrew prefix.
|
||||
@@ -70,3 +70,43 @@ Endpoint resolution order:
|
||||
Installer scripts download the matching release asset for the local platform and
|
||||
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
|
||||
instead of reinstalling.
|
||||
|
||||
## Windows recovery state (standalone installs)
|
||||
|
||||
This section describes Windows only. On Linux and macOS a standalone update
|
||||
renames the staged file directly over the executable path through the
|
||||
installation directory's file descriptor, so the replacement is atomic and no
|
||||
aside copy, marker, or recovery record is ever created — a failed update leaves
|
||||
the previous binary in place and nothing to resolve.
|
||||
|
||||
On Windows, a running executable cannot be replaced in place, so the previous
|
||||
binary is moved aside to `<binary>.zero-update-<random>.old` first. The updater
|
||||
records that exact file — bound to its filesystem identity, in per-user state
|
||||
outside the installation directory — and deletes only that recorded copy after
|
||||
the next update is verified in place. Backups it did not create are never
|
||||
removed, so a file such as `zero.exe.before-manual-patch.old` is left alone. A
|
||||
recorded copy that something else holds open (an on-access scanner, an editor)
|
||||
stays recorded and is removed by a later update instead.
|
||||
|
||||
An update **refuses to run** while unresolved recovery state exists beside the
|
||||
binary:
|
||||
|
||||
| State on disk | Meaning |
|
||||
|---|---|
|
||||
| `<binary>.old` (or `<binary>.<suffix>.old`) plus a `.keep` marker | A previous update could not restore the original binary. The `.old` file may be the last binary the updater verified; the installed one may be unverified. |
|
||||
| `<binary>.…old.<suffix>.recovery` | A previous update could not even write the marker, so it moved the last verified binary to that name. |
|
||||
| The binary is missing and one or more `*.old` files exist | The previous attempt was interrupted between moves. |
|
||||
|
||||
The refusal names the paths involved and the two moves that end the state:
|
||||
either move the recovery binary back over the executable path, or — if the
|
||||
installed binary is the one you want — delete the `.keep` marker (or the
|
||||
`.recovery` copy, once you have verified the installed binary) and update again.
|
||||
|
||||
This is deliberately fail-closed and differs from older releases, which deleted
|
||||
`<binary>.old` and proceeded. The trade-off is that anyone who can write in the
|
||||
installation directory can plant `<binary>.old` and `<binary>.old.keep` there
|
||||
and make every subsequent update refuse until an operator clears them. That is
|
||||
the safe direction: the alternative is an update that overwrites the only
|
||||
verified copy of the previous binary. If updates start refusing, inspect those
|
||||
files before removing them — an installation directory that a lower-privileged
|
||||
account can write to is itself worth fixing.
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.26.5
|
||||
go 1.26.6
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
@@ -11,16 +11,17 @@ require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/sys v0.47.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
|
||||
@@ -64,6 +64,10 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
|
||||
@@ -429,7 +429,7 @@ func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow}),
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
||||
trace: options.Trace,
|
||||
}
|
||||
return state
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
type contextPlannerConfig struct {
|
||||
contextWindow int
|
||||
promptCacheKey string
|
||||
serviceTier string
|
||||
promptParts systemPromptParts
|
||||
}
|
||||
|
||||
@@ -62,6 +63,7 @@ func (planner *contextPlanner) plan(messages []zeroruntime.Message, toolDefs []z
|
||||
Messages: requestMessages,
|
||||
Tools: planner.snapshotTools(toolDefs, fingerprint),
|
||||
ReasoningEffort: reasoningEffort,
|
||||
ServiceTier: planner.config.serviceTier,
|
||||
PromptCacheKey: planner.config.promptCacheKey,
|
||||
}
|
||||
breakdown := MeasureContext(request.Messages, request.Tools, planner.config.contextWindow)
|
||||
|
||||
@@ -25,6 +25,7 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
|
||||
planner := newContextPlanner(contextPlannerConfig{
|
||||
contextWindow: 128_000,
|
||||
promptCacheKey: "session-1",
|
||||
serviceTier: "priority",
|
||||
promptParts: systemPromptParts{prompt: "system", baseInstructions: "system"},
|
||||
})
|
||||
|
||||
@@ -33,6 +34,7 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
|
||||
Messages: copyMessages(messages),
|
||||
Tools: toolDefs,
|
||||
ReasoningEffort: "medium",
|
||||
ServiceTier: "priority",
|
||||
PromptCacheKey: "session-1",
|
||||
}
|
||||
if !reflect.DeepEqual(plan.Request, want) {
|
||||
|
||||
@@ -144,9 +144,10 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
|
||||
var switchedTo string
|
||||
switchCount := 0
|
||||
result, err := Run(context.Background(), "go", firstProvider, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
ServiceTier: "priority",
|
||||
MaxTurns: 4,
|
||||
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
|
||||
switchCount++
|
||||
switchedTo = modelID
|
||||
@@ -167,9 +168,15 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
|
||||
if len(firstProvider.requests) != 1 {
|
||||
t.Fatalf("expected first provider to handle exactly the escalation turn, got %d requests", len(firstProvider.requests))
|
||||
}
|
||||
if got := firstProvider.requests[0].ServiceTier; got != "priority" {
|
||||
t.Fatalf("initial request service tier = %q, want priority", got)
|
||||
}
|
||||
if len(secondProvider.requests) != 1 {
|
||||
t.Fatalf("expected second provider to handle the post-switch turn, got %d requests", len(secondProvider.requests))
|
||||
}
|
||||
if got := secondProvider.requests[0].ServiceTier; got != "" {
|
||||
t.Fatalf("post-switch request retained service tier %q", got)
|
||||
}
|
||||
if result.FinalAnswer != "answered on the upgraded model" {
|
||||
t.Fatalf("expected final answer from the swapped provider, got %q", result.FinalAnswer)
|
||||
}
|
||||
@@ -211,9 +218,10 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
|
||||
provider := &mockProvider{turns: escalateThenAnswerTurns("recovered")}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
ServiceTier: "priority",
|
||||
MaxTurns: 4,
|
||||
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
||||
return nil, errors.New("provider build blew up")
|
||||
},
|
||||
@@ -225,6 +233,9 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected the run to continue on the original provider, got %d requests", len(provider.requests))
|
||||
}
|
||||
if got := provider.requests[1].ServiceTier; got != "priority" {
|
||||
t.Fatalf("failed-switch request service tier = %q, want priority", got)
|
||||
}
|
||||
if result.FinalAnswer != "recovered" {
|
||||
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
planner := newContextPlanner(contextPlannerConfig{
|
||||
contextWindow: options.ContextWindow,
|
||||
promptCacheKey: options.SessionID,
|
||||
serviceTier: options.ServiceTier,
|
||||
promptParts: promptParts,
|
||||
})
|
||||
messages := zeroruntime.SeedMessagesWithImages(promptParts.prompt, prompt, options.Images)
|
||||
@@ -829,6 +830,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
_ = session.Prewarm(ctx)
|
||||
provider = sessionProvider{session: session}
|
||||
options.Model = turnRequestedModel
|
||||
// Service tiers are model capabilities. A successful escalation may
|
||||
// target a model that does not support the tier selected before the
|
||||
// switch, so let the destination use its default.
|
||||
options.ServiceTier = ""
|
||||
planner.config.serviceTier = ""
|
||||
options.Trace.Counter(trace.CounterModelSwitches, 1)
|
||||
// KNOWN LIMITATION (deferred): the compactor's context-window budget
|
||||
// is fixed at run start from options.ContextWindow and is NOT updated
|
||||
|
||||
@@ -325,6 +325,7 @@ type Options struct {
|
||||
ProviderName string
|
||||
Model string
|
||||
ReasoningEffort string
|
||||
ServiceTier string
|
||||
Cwd string
|
||||
SystemPrompt string
|
||||
// TransientSystemPrompt adds trusted runtime guidance for this run only.
|
||||
|
||||
@@ -29,10 +29,6 @@ func ParseTraceEventKeys(stdout string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
func TraceEventKeys(stdout string) []string {
|
||||
return ParseTraceEventKeys(stdout)
|
||||
}
|
||||
|
||||
func MissingTraceEvents(required []string, stdout string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, key := range ParseTraceEventKeys(stdout) {
|
||||
|
||||
@@ -39,3 +39,21 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
||||
func terminateProcess(pid int) error {
|
||||
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
|
||||
}
|
||||
|
||||
// terminateOwnedProcess terminates cmd's process group directly when its launch
|
||||
// configuration proves it was made a group leader. This avoids fragile Getpgid
|
||||
// rediscovery after an owned leader exits. Ordinary commands fall back to the
|
||||
// safe PID/tree path rather than assuming their PID is also a process-group ID.
|
||||
//
|
||||
// The Setpgid && Pgid == 0 guard only recognizes ConfigureChildProcessGroup's
|
||||
// own convention. A command made its own session leader via Setsid (as opposed
|
||||
// to Setpgid) is also its own process-group leader in practice, but takes the
|
||||
// slower rediscovery path here since Setsid isn't checked. Harmless today
|
||||
// because TerminateCommand has exactly one caller in this codebase; worth
|
||||
// covering explicitly if Setsid-configured commands start using this path too.
|
||||
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
|
||||
if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid && cmd.SysProcAttr.Pgid == 0 {
|
||||
return false, execution.TerminateProcessGroup(cmd.Process.Pid, terminationGracePeriod, terminationPollInterval)
|
||||
}
|
||||
return false, terminateProcess(cmd.Process.Pid)
|
||||
}
|
||||
@@ -147,11 +147,12 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
|
||||
t.Fatalf("terminateProcess: %v", err)
|
||||
}
|
||||
|
||||
// The forked child must be gone too (poll until reaped by init).
|
||||
// The forked child must no longer be running. An orphaned zombie is already
|
||||
// dead but may remain visible briefly until the platform's init reaps it.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
|
||||
// wrongly pass the test, so treat it as still-present and keep polling.
|
||||
for !errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
|
||||
// processStopped treats only ESRCH or an observed zombie state as stopped;
|
||||
// other errors (for example EPERM) remain live and keep polling.
|
||||
for !processStopped(childPID) {
|
||||
if time.Now().After(deadline) {
|
||||
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
||||
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
|
||||
@@ -159,3 +160,73 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandStopsUnconfiguredCommand(t *testing.T) {
|
||||
cmd := exec.Command("sleep", "30")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if cmd.ProcessState == nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
})
|
||||
|
||||
if err := TerminateCommand(cmd); err != nil {
|
||||
t.Fatalf("TerminateCommand: %v", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("unconfigured command was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandKillsChildAfterLeaderExits(t *testing.T) {
|
||||
grace, poll := terminationGracePeriod, terminationPollInterval
|
||||
terminationGracePeriod, terminationPollInterval = 2*time.Second, 20*time.Millisecond
|
||||
t.Cleanup(func() { terminationGracePeriod, terminationPollInterval = grace, poll })
|
||||
|
||||
// The leader exits immediately after launching the child. TerminateCommand
|
||||
// must capture and signal the process group before Wait reaps the leader and
|
||||
// makes its group identity unavailable.
|
||||
cmd := exec.Command("sh", "-c", "sleep 300 & echo $!; exit 0")
|
||||
ConfigureChildProcessGroup(cmd)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stdout pipe: %v", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
line, err := bufio.NewReader(stdout).ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read forked child pid: %v", err)
|
||||
}
|
||||
childPID, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil {
|
||||
t.Fatalf("parse forked child pid %q: %v", line, err)
|
||||
}
|
||||
|
||||
if err := TerminateCommand(cmd); err != nil {
|
||||
t.Fatalf("TerminateCommand: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for !processStopped(childPID) {
|
||||
if time.Now().After(deadline) {
|
||||
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
||||
t.Fatalf("forked child %d survived TerminateCommand", childPID)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func processStopped(pid int) bool {
|
||||
if errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH) {
|
||||
return true
|
||||
}
|
||||
state, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
|
||||
if err != nil {
|
||||
return errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH)
|
||||
}
|
||||
return strings.HasPrefix(strings.TrimSpace(string(state)), "Z")
|
||||
}
|
||||
@@ -3,11 +3,20 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// stillActiveExitCode is the documented GetExitCodeProcess value for a process
|
||||
// that has not terminated. x/sys/windows does not expose Windows' STILL_ACTIVE
|
||||
// constant.
|
||||
const stillActiveExitCode uint32 = 259
|
||||
|
||||
var terminateProcessForTest = terminateProcess
|
||||
|
||||
// ConfigureChildProcessGroup is a no-op on Windows: process-tree termination is
|
||||
// delegated to execution.TerminateProcessTree, so no launch-time process-group
|
||||
// setup is required (the POSIX build sets Setpgid here instead).
|
||||
@@ -16,3 +25,28 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup
|
||||
func terminateProcess(pid int) error {
|
||||
return execution.TerminateProcessTree(pid, 0, 0)
|
||||
}
|
||||
|
||||
// terminateOwnedProcess asks taskkill /T to terminate the tree rooted at cmd,
|
||||
// with KillProcessTree's direct Process.Kill fallback if taskkill fails. taskkill
|
||||
// can discover descendants only while the root PID still exists; unlike a POSIX
|
||||
// process group, a Windows tree has no independently addressable identity after
|
||||
// its root exits.
|
||||
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
|
||||
var (
|
||||
alreadyExited bool
|
||||
terminateErr error
|
||||
)
|
||||
err := cmd.Process.WithHandle(func(handle uintptr) {
|
||||
var exitCode uint32
|
||||
if windows.GetExitCodeProcess(windows.Handle(handle), &exitCode) == nil {
|
||||
alreadyExited = exitCode != stillActiveExitCode
|
||||
}
|
||||
// Keep the exact process identity pinned while the PID-based taskkill
|
||||
// operation runs, preventing the PID from being recycled underneath it.
|
||||
terminateErr = terminateProcessForTest(cmd.Process.Pid)
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("pin process %d for termination: %w", cmd.Process.Pid, err)
|
||||
}
|
||||
return alreadyExited, terminateErr
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//go:build windows
|
||||
|
||||
package background
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTerminateCommandReturnsTreeFailureAfterRootFallbackReaps(t *testing.T) {
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 300")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
treeErr := errors.New("taskkill /T failed")
|
||||
originalTerminateProcess := terminateProcessForTest
|
||||
terminateProcessForTest = func(pid int) error {
|
||||
if err := cmd.Process.Kill(); err != nil {
|
||||
return errors.Join(treeErr, err)
|
||||
}
|
||||
return treeErr
|
||||
}
|
||||
t.Cleanup(func() { terminateProcessForTest = originalTerminateProcess })
|
||||
|
||||
err := TerminateCommand(cmd)
|
||||
if !errors.Is(err, treeErr) {
|
||||
t.Fatalf("TerminateCommand error = %v, want tree failure", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("root process was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandReapsExitedLeader(t *testing.T) {
|
||||
cmd := exec.Command("cmd.exe", "/c", "exit", "0")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
// Let the child exit without calling Wait so cleanup exercises the Windows
|
||||
// taskkill/TerminateProcess race while it still owns the process handle.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// jatmn's #774 finding: taskkill /T racing an already-exited PID (and the
|
||||
// Process.Kill fallback) both fail here, but the reap below still succeeds —
|
||||
// the process-handle exit-code check proves the leader was already gone, so
|
||||
// TerminateCommand must not surface the expected kill-attempt error.
|
||||
if err := TerminateCommand(cmd); err != nil {
|
||||
t.Fatalf("TerminateCommand: %v, want nil (leader was already gone and got reaped)", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("exited process was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateCommandDeadLeaderCannotIdentifyDescendantTree(t *testing.T) {
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
|
||||
`$p = Start-Process powershell.exe -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 300' -PassThru; $p.Id`)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stdout pipe: %v", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
line, err := bufio.NewReader(stdout).ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read descendant pid: %v", err)
|
||||
}
|
||||
descendantPID, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil {
|
||||
t.Fatalf("parse descendant pid %q: %v", line, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = exec.Command("taskkill.exe", "/F", "/PID", strconv.Itoa(descendantPID)).Run()
|
||||
})
|
||||
|
||||
// Wait until the leader is observably exited but deliberately leave it
|
||||
// unreaped, matching the POSIX regression's ordering.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for processExistsOnWindows(cmd.Process.Pid) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("leader did not exit")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if err := TerminateCommand(cmd); err != nil {
|
||||
t.Fatalf("TerminateCommand: %v", err)
|
||||
}
|
||||
|
||||
// Windows has no persistent group ID analogous to POSIX: once the leader is
|
||||
// gone, taskkill /T cannot rediscover this descendant. Assert that platform
|
||||
// distinction rather than claiming TerminateCommand can provide it here.
|
||||
if !processExistsOnWindows(descendantPID) {
|
||||
t.Fatal("descendant unexpectedly stopped after its root exited")
|
||||
}
|
||||
}
|
||||
|
||||
func processExistsOnWindows(pid int) bool {
|
||||
return exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
|
||||
"Get-Process -Id "+strconv.Itoa(pid)+" -ErrorAction Stop | Out-Null").Run() == nil
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
const commandReapTimeout = 3 * time.Second
|
||||
|
||||
// TerminateProcess stops a background process by PID — on Windows its process
|
||||
// tree; on POSIX its whole process group when the PID leads its own group (the
|
||||
// invariant ConfigureChildProcessGroup establishes for processes started through
|
||||
@@ -12,3 +21,69 @@ package background
|
||||
func TerminateProcess(pid int) error {
|
||||
return terminateProcess(pid)
|
||||
}
|
||||
|
||||
// TerminateCommand stops a started command and reaps its leader. The caller must
|
||||
// have exclusive ownership of cmd: it must not have previously called Wait or
|
||||
// Process.Release, and no goroutine may call either concurrently. On POSIX it
|
||||
// stops the whole group when the command was configured as its leader; ordinary
|
||||
// commands safely fall back to PID/tree discovery. On Windows, success for a
|
||||
// leader that was already dead confirms only that the leader was reaped;
|
||||
// descendants may survive because Windows cannot rediscover a tree from a dead
|
||||
// root. `zero daemon start` needs this operation when readiness times out: it
|
||||
// launched the child, so it must both stop the tree and collect the leader.
|
||||
//
|
||||
// The order matters: the tree is signalled first, because Wait releases the
|
||||
// leader's PID and a later group lookup could then resolve to nothing (or, worse,
|
||||
// to a recycled PID). Termination stays in the shared execution lifecycle
|
||||
// primitives rather than introducing a second platform-specific killer.
|
||||
//
|
||||
// If the bounded reap times out, the Wait call already started by this function
|
||||
// continues in its goroutine. From that point onward the caller must not access
|
||||
// cmd at all, including ProcessState, because that would race with Wait.
|
||||
func TerminateCommand(cmd *exec.Cmd) error {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return errors.New("terminate command: process was never started")
|
||||
}
|
||||
leaderAlreadyExited, terminateErr := terminateOwnedProcess(cmd)
|
||||
reapErr := waitForTerminatedCommandWithin(cmd, commandReapTimeout)
|
||||
if reapErr != nil {
|
||||
if terminateErr != nil {
|
||||
return fmt.Errorf("%v (reap failed: %w)", terminateErr, reapErr)
|
||||
}
|
||||
return reapErr
|
||||
}
|
||||
if terminateErr != nil && !leaderAlreadyExited {
|
||||
return terminateErr
|
||||
}
|
||||
// Only discard a termination error when the platform demonstrated before
|
||||
// attempting termination that the leader had already exited. A successful
|
||||
// reap alone says nothing about whether a live descendant tree was stopped.
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyWaitError treats a non-zero exit status as success: a process being
|
||||
// terminated is expected to report one (or a signal), so the only interesting
|
||||
// failure is Wait itself not working.
|
||||
func classifyWaitError(waitErr error) error {
|
||||
var exitErr *exec.ExitError
|
||||
if waitErr != nil && !errors.As(waitErr, &exitErr) {
|
||||
return fmt.Errorf("reap process: %w", waitErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForTerminatedCommandWithin bounds the reap so a child that somehow survives
|
||||
// termination cannot hang the caller — the daemon-start cleanup path runs while a
|
||||
// user waits at the CLI. On timeout its Wait goroutine is intentionally left to
|
||||
// finish: exec.Cmd permits only one Wait call, and abandoning it without a reaper
|
||||
// would leak the child once it eventually exits.
|
||||
func waitForTerminatedCommandWithin(cmd *exec.Cmd, timeout time.Duration) error {
|
||||
waitDone := make(chan error, 1)
|
||||
go func() { waitDone <- cmd.Wait() }()
|
||||
select {
|
||||
case waitErr := <-waitDone:
|
||||
return classifyWaitError(waitErr)
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("process %d did not reap after termination", cmd.Process.Pid)
|
||||
}
|
||||
}
|
||||
+133
-37
@@ -64,38 +64,53 @@ type appDeps struct {
|
||||
// getenv reads a process environment variable (production: os.Getenv, set in
|
||||
// defaultAppDeps — deliberately NOT filled by fillAppDeps, so tests are hermetic
|
||||
// against ambient vars like ZERO_PROVIDER unless they inject it). nil ⇒ empty.
|
||||
getenv func(string) string
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
releaseWorktree func(context.Context, worktrees.Options, string) error
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
|
||||
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
|
||||
now func() time.Time
|
||||
getenv func(string) string
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
releaseWorktree func(context.Context, worktrees.Options, string) error
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
|
||||
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
|
||||
createBranch func(context.Context, zerogit.BranchOptions) (zerogit.BranchResult, error)
|
||||
isDefaultBranch func(context.Context, zerogit.DefaultBranchOptions) (bool, string, string, error)
|
||||
currentGitUser func(context.Context, string) string
|
||||
headCommitSubject func(context.Context, string) string
|
||||
commitsAhead func(context.Context, string, string, string) (int, error)
|
||||
isUnbornRemote func(context.Context, string, string) (bool, error)
|
||||
refreshTrackingRef func(context.Context, string, string, string) error
|
||||
branchUpstreamRemote func(context.Context, string, string) string
|
||||
branchUpstreamRemoteAndMerge func(context.Context, string, string) (string, string)
|
||||
resolveRemoteBranchTip func(context.Context, string, string, string) (string, error)
|
||||
remoteHasBranch func(context.Context, string, string, string) (bool, error)
|
||||
currentGitBranch func(context.Context, string) string
|
||||
currentBranchTip func(context.Context, string) string
|
||||
deleteBranch func(context.Context, string, string, string) error
|
||||
resetBranchRef func(context.Context, string, string, string, string) error
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type mcpToolRuntime interface {
|
||||
@@ -198,11 +213,52 @@ func defaultAppDeps() appDeps {
|
||||
commitChanges: zerogit.Commit,
|
||||
pushChanges: zerogit.Push,
|
||||
createPR: zerogit.CreatePR,
|
||||
runTUI: tui.Run,
|
||||
runEditor: openEditor,
|
||||
checkUpdate: update.Check,
|
||||
applyUpdate: update.Apply,
|
||||
now: time.Now,
|
||||
createBranch: zerogit.CreateBranch,
|
||||
isDefaultBranch: zerogit.IsDefaultBranch,
|
||||
currentGitUser: func(ctx context.Context, cwd string) string {
|
||||
return zerogit.CurrentGitUser(ctx, cwd, nil)
|
||||
},
|
||||
headCommitSubject: func(ctx context.Context, cwd string) string {
|
||||
return zerogit.HeadCommitSubject(ctx, cwd, nil)
|
||||
},
|
||||
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
|
||||
return zerogit.CommitsAhead(ctx, cwd, remote, branch, nil)
|
||||
},
|
||||
isUnbornRemote: func(ctx context.Context, cwd, remote string) (bool, error) {
|
||||
return zerogit.IsUnbornRemote(ctx, cwd, remote, nil)
|
||||
},
|
||||
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error {
|
||||
return zerogit.RefreshTrackingRef(ctx, cwd, remote, branch, nil)
|
||||
},
|
||||
branchUpstreamRemote: func(ctx context.Context, cwd, branch string) string {
|
||||
return zerogit.UpstreamRemote(ctx, cwd, branch, nil)
|
||||
},
|
||||
branchUpstreamRemoteAndMerge: func(ctx context.Context, cwd, branch string) (string, string) {
|
||||
return zerogit.UpstreamRemoteAndMergeBranch(ctx, cwd, branch, nil)
|
||||
},
|
||||
resolveRemoteBranchTip: func(ctx context.Context, cwd, remote, branch string) (string, error) {
|
||||
return zerogit.ResolveRemoteBranchTip(ctx, cwd, remote, branch, nil)
|
||||
},
|
||||
remoteHasBranch: func(ctx context.Context, cwd, remote, branch string) (bool, error) {
|
||||
return zerogit.RemoteHasBranch(ctx, cwd, remote, branch, nil)
|
||||
},
|
||||
currentGitBranch: func(ctx context.Context, cwd string) string {
|
||||
return zerogit.CurrentBranch(ctx, cwd, nil)
|
||||
},
|
||||
currentBranchTip: func(ctx context.Context, cwd string) string {
|
||||
return zerogit.CurrentBranchTip(ctx, cwd, nil)
|
||||
},
|
||||
deleteBranch: func(ctx context.Context, cwd, fallbackBranch, branchToDelete string) error {
|
||||
return zerogit.DeleteBranch(ctx, cwd, fallbackBranch, branchToDelete, nil)
|
||||
},
|
||||
resetBranchRef: func(ctx context.Context, cwd, branch, newTip, expectedOld string) error {
|
||||
return zerogit.ResetBranchRef(ctx, cwd, branch, newTip, nil, expectedOld)
|
||||
},
|
||||
runTUI: tui.Run,
|
||||
runEditor: openEditor,
|
||||
checkUpdate: update.Check,
|
||||
applyUpdate: update.Apply,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,6 +618,45 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.createPR == nil {
|
||||
deps.createPR = defaults.createPR
|
||||
}
|
||||
if deps.createBranch == nil {
|
||||
deps.createBranch = defaults.createBranch
|
||||
}
|
||||
if deps.isDefaultBranch == nil {
|
||||
deps.isDefaultBranch = defaults.isDefaultBranch
|
||||
}
|
||||
if deps.currentGitUser == nil {
|
||||
deps.currentGitUser = defaults.currentGitUser
|
||||
}
|
||||
if deps.headCommitSubject == nil {
|
||||
deps.headCommitSubject = defaults.headCommitSubject
|
||||
}
|
||||
if deps.commitsAhead == nil {
|
||||
deps.commitsAhead = defaults.commitsAhead
|
||||
}
|
||||
if deps.isUnbornRemote == nil {
|
||||
deps.isUnbornRemote = defaults.isUnbornRemote
|
||||
}
|
||||
if deps.refreshTrackingRef == nil {
|
||||
deps.refreshTrackingRef = defaults.refreshTrackingRef
|
||||
}
|
||||
if deps.branchUpstreamRemote == nil {
|
||||
deps.branchUpstreamRemote = defaults.branchUpstreamRemote
|
||||
}
|
||||
if deps.branchUpstreamRemoteAndMerge == nil {
|
||||
deps.branchUpstreamRemoteAndMerge = defaults.branchUpstreamRemoteAndMerge
|
||||
}
|
||||
if deps.remoteHasBranch == nil {
|
||||
deps.remoteHasBranch = defaults.remoteHasBranch
|
||||
}
|
||||
if deps.currentGitBranch == nil {
|
||||
deps.currentGitBranch = defaults.currentGitBranch
|
||||
}
|
||||
if deps.currentBranchTip == nil {
|
||||
deps.currentBranchTip = defaults.currentBranchTip
|
||||
}
|
||||
// resolveRemoteBranchTip, deleteBranch, and resetBranchRef stay nil when
|
||||
// unset so unit tests that mock createBranch without a real git tree do not
|
||||
// hit real git restore/delete commands.
|
||||
if deps.runTUI == nil {
|
||||
deps.runTUI = defaults.runTUI
|
||||
}
|
||||
@@ -842,6 +937,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
Version: version,
|
||||
Theme: theme,
|
||||
SavedTheme: resolved.Preferences.Theme,
|
||||
SavedPet: resolved.Preferences.Pet,
|
||||
UserConfigPath: userConfigPath,
|
||||
DoctorUserConfigPath: doctorUserConfigPath,
|
||||
ProjectConfigPath: projectConfigPath,
|
||||
|
||||
+52
-10
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -160,20 +161,61 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
background.ConfigureChildProcessGroup(cmd) // own process group: outlives this shell
|
||||
if err := cmd.Start(); err != nil {
|
||||
if err := startAndAwaitDaemonProcess(cmd, func() bool { return daemonReachable(paths) }, 5*time.Second, 25*time.Millisecond); err != nil {
|
||||
if errors.Is(err, errDaemonStartTimeout) {
|
||||
return writeAppError(stderr, err.Error()+"; see "+logPath, exitCrash)
|
||||
}
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
_ = cmd.Process.Release()
|
||||
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if daemonReachable(paths) {
|
||||
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
|
||||
return exitSuccess
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
var errDaemonStartTimeout = errors.New("daemon did not come up within timeout")
|
||||
|
||||
func startAndAwaitDaemonProcess(cmd *exec.Cmd, reachable func() bool, timeout time.Duration, pollInterval time.Duration) error {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAppError(stderr, "daemon did not come up within timeout; see "+logPath, exitCrash)
|
||||
if waitForDaemonReadiness(reachable, timeout, pollInterval) {
|
||||
if err := cmd.Process.Release(); err != nil {
|
||||
cleanupErr := terminateAndReapDaemonProcess(cmd)
|
||||
if cleanupErr != nil {
|
||||
return fmt.Errorf("release daemon process: %w (cleanup failed: %v)", err, cleanupErr)
|
||||
}
|
||||
return fmt.Errorf("release daemon process: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := terminateAndReapDaemonProcess(cmd); err != nil {
|
||||
return fmt.Errorf("%w; cleanup failed: %v", errDaemonStartTimeout, err)
|
||||
}
|
||||
return errDaemonStartTimeout
|
||||
}
|
||||
|
||||
func waitForDaemonReadiness(reachable func() bool, timeout time.Duration, pollInterval time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if reachable() {
|
||||
return true
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(min(pollInterval, remaining))
|
||||
}
|
||||
// The loop's last reachable() call already ran immediately before this one
|
||||
// (right after the final Sleep, with the same deadline check separating
|
||||
// them), so this adds no extra waiting time and is not a grace window for a
|
||||
// slow-starting daemon — it only re-checks the same instant in case
|
||||
// reachable() itself is non-deterministic. To tolerate a genuinely slow
|
||||
// start, raise the timeout passed to startAndAwaitDaemonProcess instead.
|
||||
return reachable()
|
||||
}
|
||||
|
||||
func terminateAndReapDaemonProcess(cmd *exec.Cmd) error {
|
||||
return background.TerminateCommand(cmd)
|
||||
}
|
||||
|
||||
func runDaemonStop(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
|
||||
@@ -2,8 +2,15 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/background"
|
||||
)
|
||||
|
||||
// isolateDaemonPaths points DefaultPaths at a temp dir so the test never touches
|
||||
@@ -121,3 +128,84 @@ func TestDaemonSubcommandsRejectExtraArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForDaemonReadinessChecksTimeoutBoundary(t *testing.T) {
|
||||
checks := 0
|
||||
ready := waitForDaemonReadiness(func() bool {
|
||||
checks++
|
||||
return checks == 2
|
||||
}, 0, time.Millisecond)
|
||||
if !ready {
|
||||
t.Fatal("daemon that became reachable at the timeout boundary was not detected")
|
||||
}
|
||||
if checks != 2 {
|
||||
t.Fatalf("reachability checks = %d, want initial and final checks", checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateAndReapDaemonProcess(t *testing.T) {
|
||||
cmd := daemonDetachedChildCommand("hang")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start helper process: %v", err)
|
||||
}
|
||||
|
||||
if err := terminateAndReapDaemonProcess(cmd); err != nil {
|
||||
t.Fatalf("terminate and reap helper process: %v", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatalf("helper process was not reaped: state=%v", cmd.ProcessState)
|
||||
}
|
||||
if cmd.ProcessState.Success() {
|
||||
t.Fatalf("helper process unexpectedly exited successfully: %v", cmd.ProcessState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAndAwaitDaemonProcessTimeoutTerminatesAndReaps(t *testing.T) {
|
||||
cmd := daemonDetachedChildCommand("hang")
|
||||
if err := startAndAwaitDaemonProcess(cmd, func() bool { return false }, 0, time.Millisecond); !errors.Is(err, errDaemonStartTimeout) {
|
||||
t.Fatalf("start and await error = %v, want timeout", err)
|
||||
}
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("timed-out helper process was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAndAwaitDaemonProcessReleasesReadyChild(t *testing.T) {
|
||||
marker := filepath.Join(t.TempDir(), "child-finished")
|
||||
cmd := daemonDetachedChildCommand("mark", "ZERO_TEST_DAEMON_CHILD_MARKER="+marker)
|
||||
if err := startAndAwaitDaemonProcess(cmd, func() bool { return true }, time.Second, time.Millisecond); err != nil {
|
||||
t.Fatalf("start and await ready helper: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("released helper process did not remain alive to write its marker")
|
||||
}
|
||||
|
||||
func daemonDetachedChildCommand(mode string, extraEnv ...string) *exec.Cmd {
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestDaemonDetachedChildProcess")
|
||||
cmd.Env = append(os.Environ(), "ZERO_TEST_DAEMON_DETACHED_CHILD="+mode)
|
||||
cmd.Env = append(cmd.Env, extraEnv...)
|
||||
background.ConfigureChildProcessGroup(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestDaemonDetachedChildProcess(t *testing.T) {
|
||||
switch os.Getenv("ZERO_TEST_DAEMON_DETACHED_CHILD") {
|
||||
case "":
|
||||
return
|
||||
case "mark":
|
||||
if err := os.WriteFile(os.Getenv("ZERO_TEST_DAEMON_CHILD_MARKER"), []byte("ready"), 0o600); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
for {
|
||||
time.Sleep(time.Hour)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,8 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
)
|
||||
|
||||
@@ -28,46 +23,5 @@ import (
|
||||
// ProviderProfile.OAuthLoginCandidates so the runtime resolver, the Codex account
|
||||
// resolver, and the onboarding presence check never diverge.
|
||||
func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
|
||||
candidates := profile.OAuthLoginCandidates()
|
||||
if len(candidates) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
return nil, ""
|
||||
}
|
||||
_, key, ok := oauth.FirstStored(store, candidates)
|
||||
if !ok {
|
||||
// No login under any candidate (or unreadable/invalid keys) → API-key
|
||||
// auth, no resolver.
|
||||
return nil, ""
|
||||
}
|
||||
manager, err := oauth.NewManager(oauth.ManagerOptions{
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
// Refreshing a token the user logged into (possibly a preset provider like
|
||||
// xAI) re-resolves that provider's OAuth config, which needs the preset.
|
||||
AllowPresets: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ""
|
||||
}
|
||||
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
|
||||
var token string
|
||||
var rerr error
|
||||
if forceRefresh {
|
||||
token, rerr = manager.Handle401(ctx, key)
|
||||
} else {
|
||||
token, rerr = manager.GetFresh(ctx, key)
|
||||
}
|
||||
if errors.Is(rerr, oauth.ErrNoToken) {
|
||||
// The login was removed since construction → fall back to the API key.
|
||||
return "", "", false, nil
|
||||
}
|
||||
if rerr != nil {
|
||||
return "", "", false, rerr
|
||||
}
|
||||
return "Authorization", "Bearer " + token, true, nil
|
||||
}
|
||||
return resolver, key
|
||||
return providers.OAuthLoginForProfile(profile)
|
||||
}
|
||||
@@ -73,6 +73,18 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
if description := strings.TrimSpace(model.Description); description != "" {
|
||||
entry["description"] = description
|
||||
}
|
||||
if len(model.ReasoningEfforts) > 0 {
|
||||
entry["reasoning_efforts"] = append([]string{}, model.ReasoningEfforts...)
|
||||
}
|
||||
if effort := strings.TrimSpace(model.DefaultReasoningEffort); effort != "" {
|
||||
entry["default_reasoning_effort"] = effort
|
||||
}
|
||||
if len(model.ServiceTiers) > 0 {
|
||||
entry["service_tiers"] = append([]string{}, model.ServiceTiers...)
|
||||
}
|
||||
if tier := strings.TrimSpace(model.DefaultServiceTier); tier != "" {
|
||||
entry["default_service_tier"] = tier
|
||||
}
|
||||
items = append(items, entry)
|
||||
}
|
||||
payload := map[string]any{
|
||||
|
||||
+2059
-15
File diff suppressed because it is too large.
Load diff
+538
-8
@@ -2,9 +2,12 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -602,18 +605,20 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
|
||||
}
|
||||
}
|
||||
if command != "commit" && options.message != "" {
|
||||
if command != "commit" && options.hasMessage {
|
||||
return options, false, execUsageError{"--message is only valid with `zero changes commit`"}
|
||||
}
|
||||
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
|
||||
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
|
||||
}
|
||||
if command == "commit" && options.hasMessage && options.auto {
|
||||
return options, false, execUsageError{"cannot specify both --message and --auto"}
|
||||
}
|
||||
if command != "commit" && command != "push" && options.dryRun {
|
||||
return options, false, execUsageError{"--dry-run is only valid with commit or push"}
|
||||
}
|
||||
// --auto on push/pr is the explicit opt-in for LLM branch naming (see
|
||||
// ensureFeatureBranch); on commit it opts into the LLM commit message.
|
||||
if command != "commit" && command != "push" && command != "pr" && options.auto {
|
||||
return options, false, execUsageError{"--auto is only valid with commit, push, or pr"}
|
||||
}
|
||||
if command != "inspect" && options.baseRef != "" {
|
||||
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
|
||||
}
|
||||
@@ -934,8 +939,7 @@ Flags:
|
||||
--fill Automatically populate PR title and body from commits
|
||||
--draft Create PR as a draft
|
||||
--yes Confirm pushing to a default/protected branch
|
||||
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
|
||||
--dry-run Preview commit metadata without mutating git state
|
||||
-a, --auto Use the LLM: commit generates the message, push/pr name the auto-created branch (sends the diff to the provider)
|
||||
--json Print JSON output
|
||||
-h, --help Show this help
|
||||
`)
|
||||
@@ -958,11 +962,31 @@ func runChangesPush(args []string, stdout io.Writer, stderr io.Writer, deps appD
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
branch, remote, created, err := ensureFeatureBranch(ctx, stdout, workspaceRoot, options.remote, featureBranchOptions{
|
||||
JSONMode: options.json,
|
||||
AllowDefaultBranch: options.yes,
|
||||
DryRun: options.dryRun,
|
||||
AutoNaming: options.auto,
|
||||
MaxDiffBytes: options.maxDiffBytes,
|
||||
}, deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
// context.Background() is deliberate: the 2-minute ctx above is the
|
||||
// feature-branch preflight (inspection, remote probes, branch creation).
|
||||
// The push itself must not be cancelled mid-transfer just because the
|
||||
// preflight budget elapsed; a large push can legitimately take longer.
|
||||
result, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
|
||||
Cwd: workspaceRoot,
|
||||
Remote: options.remote,
|
||||
Remote: firstNonEmptyString(options.remote, remote),
|
||||
Branch: branch,
|
||||
Force: options.force,
|
||||
DryRun: options.dryRun,
|
||||
RequireNewRemoteBranch: created,
|
||||
AllowPushDefaultBranch: options.yes,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1010,15 +1034,62 @@ func runChangesPR(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
branch, remote, created, err := ensureFeatureBranch(ctx, stdout, workspaceRoot, options.remote, featureBranchOptions{
|
||||
JSONMode: options.json,
|
||||
AllowDefaultBranch: options.yes,
|
||||
DryRun: false,
|
||||
AutoNaming: options.auto,
|
||||
MaxDiffBytes: options.maxDiffBytes,
|
||||
}, deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
// Resolve the target remote for the unborn-remote check. Explicit --remote wins,
|
||||
// followed by the remote resolved by ensureFeatureBranch. When --yes is set,
|
||||
// fall back to the branch upstream or "origin".
|
||||
targetRemote := strings.TrimSpace(options.remote)
|
||||
if targetRemote == "" {
|
||||
targetRemote = remote
|
||||
}
|
||||
if targetRemote == "" {
|
||||
currentBranch := ""
|
||||
if deps.currentGitBranch != nil {
|
||||
currentBranch = deps.currentGitBranch(context.Background(), workspaceRoot)
|
||||
}
|
||||
if deps.branchUpstreamRemote != nil && currentBranch != "" {
|
||||
targetRemote = deps.branchUpstreamRemote(context.Background(), workspaceRoot, currentBranch)
|
||||
}
|
||||
if targetRemote == "" {
|
||||
targetRemote = "origin"
|
||||
}
|
||||
}
|
||||
|
||||
// Probe errors are intentionally ignored so the flow can continue; if the
|
||||
// probe fails, the subsequent push or PR operation provides fail-closed behavior.
|
||||
if deps.isUnbornRemote != nil {
|
||||
if unborn, unbornErr := deps.isUnbornRemote(context.Background(), workspaceRoot, targetRemote); unbornErr == nil && unborn {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("cannot create pull request on unborn remote %s: push the initial default branch first", targetRemote))
|
||||
}
|
||||
}
|
||||
|
||||
if !options.json {
|
||||
if _, err := fmt.Fprintln(stdout, "Pushing current branch to set upstream..."); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
// context.Background() is deliberate here too: runChangesPR reuses the
|
||||
// 2-minute ctx for preflight (branch creation and remote probes), but the
|
||||
// push must not be cancelled mid-transfer if that budget elapsed.
|
||||
pushResult, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
|
||||
Cwd: workspaceRoot,
|
||||
Remote: options.remote,
|
||||
Remote: firstNonEmptyString(options.remote, remote),
|
||||
Branch: branch,
|
||||
Force: options.force,
|
||||
RequireNewRemoteBranch: created,
|
||||
AllowPushDefaultBranch: options.yes,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1100,3 +1171,462 @@ func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provide
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
type featureBranchOptions struct {
|
||||
JSONMode bool
|
||||
AllowDefaultBranch bool
|
||||
DryRun bool
|
||||
AutoNaming bool
|
||||
MaxDiffBytes int
|
||||
}
|
||||
|
||||
// branchPushPlan holds the resolved parameters for a push or PR workflow
|
||||
// before any branches are created or refs mutated. Separating plan resolution
|
||||
// from mutation allows tests to assert the resolution matrix directly without
|
||||
// touching a git repository.
|
||||
type branchPushPlan struct {
|
||||
ShouldBranch bool
|
||||
CurrentBranch string
|
||||
PushRemote string
|
||||
RequireNewRemoteBranch bool
|
||||
TrackingRemote string
|
||||
TrackingBranch string
|
||||
RestoreTip string
|
||||
PublishabilityBase string
|
||||
Summary zerogit.ChangeSummary
|
||||
Slug string
|
||||
}
|
||||
|
||||
// resolveBranchPushPlan resolves the branch, remotes, restore tip, and naming
|
||||
// slug for changes push / pr workflows. It performs all checks (clean working
|
||||
// tree, unborn remote detection, ahead count, restore tip resolution) but
|
||||
// executes no mutation (no branch creation, ref resets, or deletions).
|
||||
func resolveBranchPushPlan(ctx context.Context, stdout io.Writer, workspaceRoot string, requestedRemote string, opts featureBranchOptions, deps appDeps) (branchPushPlan, error) {
|
||||
if opts.AllowDefaultBranch || opts.DryRun {
|
||||
return branchPushPlan{
|
||||
ShouldBranch: false,
|
||||
PushRemote: strings.TrimSpace(requestedRemote),
|
||||
RequireNewRemoteBranch: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if deps.isDefaultBranch == nil {
|
||||
return branchPushPlan{}, fmt.Errorf("isDefaultBranch dependency missing")
|
||||
}
|
||||
isDefault, currentBranch, remote, err := deps.isDefaultBranch(ctx, zerogit.DefaultBranchOptions{Cwd: workspaceRoot, Remote: requestedRemote})
|
||||
if err != nil {
|
||||
return branchPushPlan{}, err
|
||||
}
|
||||
if !isDefault {
|
||||
// Whether this push needs the nonexistence lease is decided by one
|
||||
// live check against the destination remote, not by inference from
|
||||
// local git state (upstream config, a generated-branch marker):
|
||||
// every local signal that stood in for that check turned out to be
|
||||
// wrong in some ordering (inherited config from checkout -b, a
|
||||
// config write that failed after a successful push, a push aimed at
|
||||
// a different remote than the one local config describes). A branch
|
||||
// missing on the remote gets the nonexistence lease, protecting a
|
||||
// concurrent creator of the same name; a branch already there gets a
|
||||
// plain push, and git's own fast-forward check is the safety net —
|
||||
// if the remote history isn't ours, the push fails with a clear
|
||||
// non-fast-forward error instead of an attempted silent recovery.
|
||||
if deps.remoteHasBranch == nil {
|
||||
return branchPushPlan{
|
||||
ShouldBranch: false,
|
||||
CurrentBranch: currentBranch,
|
||||
PushRemote: remote,
|
||||
RequireNewRemoteBranch: true,
|
||||
}, nil
|
||||
}
|
||||
targetRemote := firstNonEmptyString(requestedRemote, remote)
|
||||
exists, existsErr := deps.remoteHasBranch(ctx, workspaceRoot, targetRemote, currentBranch)
|
||||
if existsErr != nil {
|
||||
return branchPushPlan{}, fmt.Errorf("cannot check whether %s already exists on remote %s: %w", currentBranch, targetRemote, existsErr)
|
||||
}
|
||||
return branchPushPlan{
|
||||
ShouldBranch: false,
|
||||
CurrentBranch: currentBranch,
|
||||
PushRemote: remote,
|
||||
RequireNewRemoteBranch: !exists,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateBranch and Push publish commits only. A dirty working tree would
|
||||
// leave uncommitted edits behind under a branch/PR that does not include
|
||||
// them, so refuse until the tree is clean (commit or stash first).
|
||||
workingTree, err := deps.inspectChanges(ctx, zerogit.InspectOptions{Cwd: workspaceRoot})
|
||||
if err != nil {
|
||||
return branchPushPlan{}, fmt.Errorf("failed to inspect working tree: %w", err)
|
||||
}
|
||||
if !workingTree.Clean {
|
||||
return branchPushPlan{}, fmt.Errorf("working tree has uncommitted changes; commit or stash them before pushing from the default branch")
|
||||
}
|
||||
|
||||
// Capture the source default branch's own upstream before refreshing or
|
||||
// leaving it. --remote selects the push destination; restore must not
|
||||
// rewrite local main to upstream/main when main still tracks origin/main
|
||||
// (fork setups), and refreshing must update the exact tracking ref that
|
||||
// will supply both the ahead count and the restore target.
|
||||
trackingRemote := remote
|
||||
trackingBranch := currentBranch
|
||||
if deps.branchUpstreamRemoteAndMerge != nil {
|
||||
if r, m := deps.branchUpstreamRemoteAndMerge(ctx, workspaceRoot, currentBranch); r != "" && m != "" {
|
||||
trackingRemote = r
|
||||
trackingBranch = m
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the local remote-tracking ref before trusting it: IsDefaultBranch
|
||||
// already contacted the remote for its symref check, but the tracking ref
|
||||
// commitsAhead reads from is only a local cache (written at clone or the
|
||||
// last fetch) and can sit behind the remote's live tip. Left stale, a real
|
||||
// publishable range could look like zero commits ahead, and the diff
|
||||
// derived below (which reuses this same ref as its base) would be stale
|
||||
// too. A failure here (offline, or a genuinely unborn remote with no such
|
||||
// ref to fetch yet) is folded into the same fail-closed path as an
|
||||
// unresolvable ahead count below.
|
||||
var fetchErr error
|
||||
if deps.refreshTrackingRef != nil {
|
||||
fetchErr = deps.refreshTrackingRef(ctx, workspaceRoot, trackingRemote, trackingBranch)
|
||||
}
|
||||
|
||||
// The ahead check gates on the source upstream (trackingRemote), but the
|
||||
// feature branch is pushed to `pushRemote`, which can be a different
|
||||
// destination (--remote on a fork). Refuse to auto-branch onto an unborn
|
||||
// destination: without an initial default branch the new feature branch
|
||||
// becomes the destination's first ref and its HEAD is left pointing at it.
|
||||
// This is checked once, here, against the push destination, before resolving
|
||||
// commit tips or creating any branches.
|
||||
pushRemote := firstNonEmptyString(requestedRemote, remote)
|
||||
if deps.isUnbornRemote != nil {
|
||||
if unbornDest, unbornDestErr := deps.isUnbornRemote(ctx, workspaceRoot, pushRemote); unbornDestErr == nil && unbornDest {
|
||||
return branchPushPlan{}, fmt.Errorf("remote %s has no branches yet; push the initial default branch first with --yes (`zero changes push --yes`), then use auto-branching for subsequent work", pushRemote)
|
||||
}
|
||||
}
|
||||
|
||||
// Branching off the default branch only makes sense when HEAD carries a
|
||||
// commit that is not already on the remote default branch. A clean,
|
||||
// up-to-date default branch would otherwise publish a feature branch at the
|
||||
// exact default tip. If the ahead count cannot be determined (for example
|
||||
// the remote-tracking ref was never fetched), fail rather than guess. When
|
||||
// the remote is confirmed unborn, refuse auto-branching entirely: the
|
||||
// initial default branch must be established first (changes push --yes)
|
||||
// so the remote has a real HEAD before any feature branch is published.
|
||||
ahead, aheadErr := deps.commitsAhead(ctx, workspaceRoot, trackingRemote, trackingBranch)
|
||||
if fetchErr != nil || aheadErr != nil {
|
||||
var unbornRemote bool
|
||||
var unbornErr error
|
||||
if deps.isUnbornRemote != nil {
|
||||
unbornRemote, unbornErr = deps.isUnbornRemote(ctx, workspaceRoot, trackingRemote)
|
||||
}
|
||||
if unbornErr == nil && unbornRemote {
|
||||
return branchPushPlan{}, fmt.Errorf("remote %s has no branches yet; push the initial default branch first with --yes (`zero changes push --yes`), then use auto-branching for subsequent work", trackingRemote)
|
||||
}
|
||||
cause := aheadErr
|
||||
if cause == nil {
|
||||
cause = fetchErr
|
||||
}
|
||||
return branchPushPlan{}, fmt.Errorf("cannot determine whether HEAD is ahead of %s/%s: %w; fetch the remote tracking branch first", trackingRemote, trackingBranch, cause)
|
||||
}
|
||||
if ahead == 0 {
|
||||
return branchPushPlan{}, fmt.Errorf("no changes to publish: HEAD is not ahead of %s/%s; commit your work before pushing", trackingRemote, trackingBranch)
|
||||
}
|
||||
|
||||
// Resolve the restore tip to a concrete commit before creating any branch.
|
||||
// It must never be a bare local branch name: resolving "main" yields the
|
||||
// very commit the feature branch is about to own, so restore becomes a
|
||||
// silent no-op and the publishable commits stay on the default branch. A
|
||||
// named remote resolves through its just-refreshed remote-tracking ref; a
|
||||
// direct URL or path asks the remote itself. Fail closed rather than
|
||||
// substitute a local branch name.
|
||||
restoreTip := ""
|
||||
if deps.resolveRemoteBranchTip != nil {
|
||||
tip, tipErr := deps.resolveRemoteBranchTip(ctx, workspaceRoot, trackingRemote, trackingBranch)
|
||||
if tipErr != nil {
|
||||
return branchPushPlan{}, fmt.Errorf("cannot resolve restore tip for %s/%s: %w", trackingRemote, trackingBranch, tipErr)
|
||||
}
|
||||
restoreTip = tip
|
||||
}
|
||||
if restoreTip == "" {
|
||||
restoreTip = trackingRemote + "/" + trackingBranch
|
||||
}
|
||||
|
||||
// Name the branch (and, with --auto, send the provider) from what HEAD is
|
||||
// actually ahead of the resolved remote branch by, using the same ref
|
||||
// commitsAhead just checked. A working-tree snapshot can describe edits a
|
||||
// commit-only push will never include.
|
||||
baseRef := restoreTip
|
||||
summary, err := deps.inspectChanges(ctx, zerogit.InspectOptions{Cwd: workspaceRoot, BaseRef: baseRef, MaxDiffBytes: opts.MaxDiffBytes})
|
||||
if err != nil {
|
||||
return branchPushPlan{}, fmt.Errorf("failed to inspect changes: %w", err)
|
||||
}
|
||||
|
||||
slug := fallbackBranchSlug(summary)
|
||||
if len(summary.Files) == 0 {
|
||||
// An empty commit (or one whose only content the diff omits) leaves
|
||||
// nothing to derive a slug from; name the branch from the commit
|
||||
// subject instead.
|
||||
if subject := deps.headCommitSubject(ctx, workspaceRoot); subject != "" {
|
||||
slug = zerogit.SlugifyBranchComponent(subject)
|
||||
}
|
||||
}
|
||||
if opts.AutoNaming && strings.TrimSpace(summary.Diff) != "" {
|
||||
llmSlug := ""
|
||||
if resolved, cfgErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); cfgErr == nil && config.HasProviderProfile(resolved.Provider) {
|
||||
if provider, provErr := deps.newProvider(resolved.Provider); provErr == nil {
|
||||
if !opts.JSONMode {
|
||||
fmt.Fprintln(stdout, "Generating branch name using LLM...")
|
||||
}
|
||||
genCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
generated, genErr := generateAutoBranchSlug(genCtx, provider, resolved.Provider.Model, redactChangeSummary(summary))
|
||||
cancel()
|
||||
if genErr == nil && generated != "" {
|
||||
llmSlug = generated
|
||||
}
|
||||
}
|
||||
}
|
||||
if llmSlug != "" {
|
||||
slug = llmSlug
|
||||
} else if !opts.JSONMode {
|
||||
// Cover resolveConfig failures, missing provider profiles,
|
||||
// newProvider errors, generation errors, and empty results so the
|
||||
// user is not left guessing after "Generating..." or silence.
|
||||
fmt.Fprintln(stdout, "LLM branch naming unavailable; using deterministic name.")
|
||||
}
|
||||
}
|
||||
|
||||
return branchPushPlan{
|
||||
ShouldBranch: true,
|
||||
CurrentBranch: currentBranch,
|
||||
PushRemote: pushRemote,
|
||||
RequireNewRemoteBranch: true,
|
||||
TrackingRemote: trackingRemote,
|
||||
TrackingBranch: trackingBranch,
|
||||
RestoreTip: restoreTip,
|
||||
PublishabilityBase: baseRef,
|
||||
Summary: summary,
|
||||
Slug: slug,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureFeatureBranch is the branch-naming step `zero changes push`/`pr` run
|
||||
// before pushing: pushing straight to the default branch is refused deeper in
|
||||
// zerogit.Push, so rather than surface that as a dead end, create and switch
|
||||
// to a conventionally named "<user>/<slug>" branch first. It returns the
|
||||
// branch push/pr should target, or "" to mean "current HEAD branch, unchanged"
|
||||
// (zerogit.Push already treats an empty Branch that way), plus the remote the
|
||||
// preflight resolved (requestedRemote, then the original branch's configured
|
||||
// upstream, then "origin"), plus whether Push should require that branch not
|
||||
// already exist on that remote. Callers must pass the remote to Push: a
|
||||
// freshly created branch has no tracking configuration, so Push's own
|
||||
// fallback would silently retarget "origin" even when the work came from a
|
||||
// branch tracking a different remote. Callers must also pass that third value
|
||||
// through as Push's RequireNewRemoteBranch: CreateBranch's own
|
||||
// remote-collision probe runs before this returns, and closing that race
|
||||
// requires Push's push itself to assert the destination is still new. That
|
||||
// requirement also carries across a retry: if the current branch is already
|
||||
// non-default (this call didn't create it) but has not been successfully
|
||||
// published to the exact target remote under the same branch name, keep the
|
||||
// nonexistence lease. branch.<name>.remote alone is not enough: with
|
||||
// branch.autoSetupMerge=inherit, checkout -b copies remote=origin from the
|
||||
// source branch before any push. After a successful create, the original
|
||||
// default branch ref is moved back to its own upstream tip (not necessarily
|
||||
// the push destination) so the feature branch exclusively owns the
|
||||
// publishable commits; a failed restore rolls the feature branch back.
|
||||
// allowDefaultBranch (the --yes flag) and dryRun both opt out via the ""
|
||||
// branch / false return, leaving Push's own guard/preview behavior on the
|
||||
// default branch unaffected.
|
||||
//
|
||||
// autoNaming gates the LLM naming path (--auto): these commands were
|
||||
// git-only, and sending the change diff to a configured provider on every
|
||||
// default-branch push would silently export source code nobody asked to
|
||||
// share. Without the opt-in the name comes from deterministic local
|
||||
// information only. maxDiffBytes caps the committed-range diff Inspect
|
||||
// returns, so a user who passed --diff-bytes to bound the proprietary source
|
||||
// sent for LLM naming has that cap honored here just as the commit path does.
|
||||
// The working tree must be clean and HEAD must be ahead of the resolved
|
||||
// remote branch; both are verified before any branch is created. The
|
||||
// inspectChanges, commitsAhead, headCommitSubject, currentGitUser, and
|
||||
// createBranch dependencies are required on the default-branch path and are
|
||||
// called without nil guards; fillAppDeps populates all of them. The
|
||||
// refreshTrackingRef, resolveRemoteBranchTip, isUnbornRemote, resetBranchRef,
|
||||
// and deleteBranch dependencies are optional and are skipped when nil.
|
||||
func ensureFeatureBranch(ctx context.Context, stdout io.Writer, workspaceRoot string, requestedRemote string, opts featureBranchOptions, deps appDeps) (string, string, bool, error) {
|
||||
plan, err := resolveBranchPushPlan(ctx, stdout, workspaceRoot, requestedRemote, opts, deps)
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
if !plan.ShouldBranch {
|
||||
return plan.CurrentBranch, plan.PushRemote, plan.RequireNewRemoteBranch, nil
|
||||
}
|
||||
|
||||
expectedRestoreTip := ""
|
||||
if deps.currentBranchTip != nil {
|
||||
expectedRestoreTip = deps.currentBranchTip(ctx, workspaceRoot)
|
||||
}
|
||||
|
||||
name := zerogit.BuildBranchName(deps.currentGitUser(ctx, workspaceRoot), plan.Slug)
|
||||
result, err := deps.createBranch(ctx, zerogit.BranchOptions{Cwd: workspaceRoot, Name: name, Remote: plan.PushRemote})
|
||||
if err != nil {
|
||||
return "", "", false, fmt.Errorf("failed to create branch: %w", err)
|
||||
}
|
||||
// The normal flow commits on the default branch first, then creates the
|
||||
// feature branch at the same HEAD. Without moving the original default
|
||||
// ref back, local main keeps the pre-squash commits and diverges from
|
||||
// its upstream after a squash-merge (and a later pull/push can re-publish
|
||||
// them). Once the feature branch owns those commits, restore the default
|
||||
// branch to the source branch's own upstream tip captured above.
|
||||
// Failure-safe: if the restore fails, delete the feature branch and
|
||||
// check the default branch back out so the tree is not left half-moved.
|
||||
if deps.resetBranchRef != nil {
|
||||
if err := deps.resetBranchRef(ctx, workspaceRoot, plan.CurrentBranch, plan.RestoreTip, expectedRestoreTip); err != nil {
|
||||
// A compare-and-swap conflict means the default branch moved
|
||||
// while we were branching, so restoreTip is no longer the tip we
|
||||
// meant to restore. The rollback below deletes the branch that
|
||||
// now exclusively owns the user's commits, which would destroy
|
||||
// work to recover from a race we can simply report. Keep the
|
||||
// branch and let the user reconcile the default branch.
|
||||
if errors.Is(err, zerogit.ErrCompareAndSwapConflict) {
|
||||
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w; generated branch %s was preserved because the default branch changed concurrently", plan.CurrentBranch, plan.RestoreTip, err, result.Branch)
|
||||
}
|
||||
var rollbackErr error
|
||||
if deps.deleteBranch != nil {
|
||||
rollbackErr = deps.deleteBranch(ctx, workspaceRoot, plan.CurrentBranch, result.Branch)
|
||||
}
|
||||
if rollbackErr != nil {
|
||||
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w; rollback of %s also failed: %v", plan.CurrentBranch, plan.RestoreTip, err, result.Branch, rollbackErr)
|
||||
}
|
||||
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w", plan.CurrentBranch, plan.RestoreTip, err)
|
||||
}
|
||||
}
|
||||
if !opts.JSONMode {
|
||||
fmt.Fprintf(stdout, "Created branch %s (was on %s)\n", result.Branch, plan.CurrentBranch)
|
||||
}
|
||||
return result.Branch, plan.PushRemote, true, nil
|
||||
}
|
||||
|
||||
// fallbackBranchSlug derives a deterministic branch-name slug from a change
|
||||
// summary without calling an LLM, so ensureFeatureBranch still works when no
|
||||
// provider is configured.
|
||||
func fallbackBranchSlug(summary zerogit.ChangeSummary) string {
|
||||
switch len(summary.Files) {
|
||||
case 0:
|
||||
return "changes"
|
||||
case 1:
|
||||
// Git paths are always slash-separated regardless of platform, so
|
||||
// path.Base (not filepath.Base) extracts the filename portably.
|
||||
return zerogit.SlugifyBranchComponent(path.Base(summary.Files[0].Path))
|
||||
default:
|
||||
return fmt.Sprintf("update-%d-files", len(summary.Files))
|
||||
}
|
||||
}
|
||||
|
||||
// generateAutoBranchSlug asks the model for a short kebab-case slug
|
||||
// describing the diff, mirroring generateAutoCommitMessage's prompt shape.
|
||||
func generateAutoBranchSlug(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
|
||||
var promptBuilder strings.Builder
|
||||
promptBuilder.WriteString("Analyze the following git diff and generate a short git branch name slug for it.\n")
|
||||
promptBuilder.WriteString("The slug must be 2 to 5 lowercase words separated by hyphens (kebab-case), using only letters, digits, and hyphens, with no prefix like \"feature/\" or \"fix/\" and no surrounding quotes.\n")
|
||||
promptBuilder.WriteString("Output ONLY the raw slug text, nothing else.\n\n")
|
||||
promptBuilder.WriteString("Git Diff:\n")
|
||||
promptBuilder.WriteString(summary.Diff)
|
||||
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()},
|
||||
},
|
||||
}
|
||||
stream, err := provider.StreamCompletion(ctx, request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
collected := zeroruntime.CollectStream(ctx, stream)
|
||||
if collected.Error != "" {
|
||||
return "", fmt.Errorf("%s", collected.Error)
|
||||
}
|
||||
|
||||
slug := zerogit.SlugifyBranchComponent(extractBranchSlug(collected.Text))
|
||||
if slug == "" {
|
||||
return "", fmt.Errorf("provider returned empty branch slug")
|
||||
}
|
||||
return slug, nil
|
||||
}
|
||||
|
||||
// slugLineRe matches a line that already reads as a kebab-case slug (letters,
|
||||
// digits, and internal single hyphens only). extractBranchSlug prefers such a
|
||||
// line so a preamble sentence is never mistaken for the slug.
|
||||
var slugLineRe = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
|
||||
|
||||
func isPreambleText(s string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(s))
|
||||
if strings.HasSuffix(lower, ":") || strings.HasSuffix(lower, ".") || strings.HasSuffix(lower, "!") || strings.HasSuffix(lower, "?") {
|
||||
return true
|
||||
}
|
||||
preambles := []string{
|
||||
"here is", "here's", "below is", "below are", "sure", "certainly",
|
||||
"i suggest", "suggested branch", "branch name", "recommended branch",
|
||||
"how about", "you could use",
|
||||
}
|
||||
for _, p := range preambles {
|
||||
if strings.HasPrefix(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractBranchSlug pulls the intended slug out of a model response that didn't
|
||||
// follow the "output only the raw slug" instruction exactly. It drops Markdown
|
||||
// code-fence lines, then prefers a line that already looks like a kebab-case
|
||||
// slug: that skips a leading preamble such as "Here is a suggested branch
|
||||
// name:" in favor of the "add-login-page" line that follows it. One-word
|
||||
// acknowledgements that happen to match the slug regex ("Sure", "Certainly")
|
||||
// are filtered as preamble before the early return, so "Sure\nadd login page"
|
||||
// yields the real suggestion. Inline labeled forms ("Branch name: add-login-page")
|
||||
// still work: the preamble check runs on the extracted value after the label
|
||||
// is stripped. If no line is strictly kebab-cased, it prefers a plausible
|
||||
// non-preamble line so plain multi-word replies still slugify correctly.
|
||||
func extractBranchSlug(text string) string {
|
||||
var firstLine string
|
||||
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "```") {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimSpace(strings.Trim(line, `"'`))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
candidate := line
|
||||
if idx := strings.Index(line, ":"); idx != -1 {
|
||||
after := strings.TrimSpace(strings.Trim(line[idx+1:], `"'`))
|
||||
if after != "" {
|
||||
candidate = after
|
||||
}
|
||||
}
|
||||
// Models often terminate a suggestion with sentence punctuation;
|
||||
// strip it so "add login page." is recovered as the slug rather than
|
||||
// rejected as preamble (isPreambleText treats a trailing '.' as a
|
||||
// full sentence). True preamble lines still match the prefix list.
|
||||
candidate = strings.TrimSpace(strings.TrimRight(candidate, ".!?"))
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Preamble classification runs before accepting a slug-shaped line so
|
||||
// "Sure" does not win over a later "add-login-page". The check is on
|
||||
// candidate (post label-strip) so "Branch name: add-login-page" still
|
||||
// returns the extracted slug.
|
||||
if slugLineRe.MatchString(candidate) && !isPreambleText(candidate) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
if firstLine == "" && !isPreambleText(candidate) {
|
||||
firstLine = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return firstLine
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type jsonObjectSpan struct {
|
||||
start int
|
||||
end int
|
||||
members []jsonMemberSpan
|
||||
}
|
||||
|
||||
type jsonMemberSpan struct {
|
||||
key string
|
||||
leadingStart int
|
||||
keyStart int
|
||||
valueStart int
|
||||
valueEnd int
|
||||
commaAfter int
|
||||
}
|
||||
|
||||
// setPetPreferenceJSON changes only preferences.pet. It keeps the original
|
||||
// bytes for every unrelated member so choosing a companion does not reorder or
|
||||
// reformat the user's configuration file.
|
||||
func setPetPreferenceJSON(data []byte, pet string) ([]byte, error) {
|
||||
rootStart := skipJSONSpace(data, 0)
|
||||
root, err := parseJSONObject(data, rootStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preferencesIndex := lastJSONMember(root.members, "preferences")
|
||||
if preferencesIndex < 0 {
|
||||
if pet == "" {
|
||||
return data, nil
|
||||
}
|
||||
encodedPet, err := json.Marshal(pet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode pet preference: %w", err)
|
||||
}
|
||||
return insertJSONMember(data, root, "preferences", petJSONObject(encodedPet)), nil
|
||||
}
|
||||
|
||||
preferenceMember := root.members[preferencesIndex]
|
||||
preferenceValue := bytes.TrimSpace(data[preferenceMember.valueStart:preferenceMember.valueEnd])
|
||||
if bytes.Equal(preferenceValue, []byte("null")) {
|
||||
if pet == "" {
|
||||
if lastJSONMember(root.members[:preferencesIndex], "preferences") >= 0 {
|
||||
return data, nil
|
||||
}
|
||||
return removeJSONMember(data, root, preferencesIndex), nil
|
||||
}
|
||||
encodedPet, err := json.Marshal(pet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode pet preference: %w", err)
|
||||
}
|
||||
return replaceJSONRange(data, preferenceMember.valueStart, preferenceMember.valueEnd, petJSONObject(encodedPet)), nil
|
||||
}
|
||||
preferences, err := parseJSONObject(data, preferenceMember.valueStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("preferences must be a JSON object: %w", err)
|
||||
}
|
||||
petIndex := lastJSONMember(preferences.members, "pet")
|
||||
if pet != "" {
|
||||
encodedPet, err := json.Marshal(pet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode pet preference: %w", err)
|
||||
}
|
||||
if petIndex >= 0 {
|
||||
member := preferences.members[petIndex]
|
||||
return replaceJSONRange(data, member.valueStart, member.valueEnd, encodedPet), nil
|
||||
}
|
||||
return insertJSONMember(data, preferences, "pet", encodedPet), nil
|
||||
}
|
||||
|
||||
for petIndex >= 0 {
|
||||
data = removeJSONMember(data, preferences, petIndex)
|
||||
root, err = parseJSONObject(data, rootStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preferencesIndex = lastJSONMember(root.members, "preferences")
|
||||
if preferencesIndex < 0 {
|
||||
return data, nil
|
||||
}
|
||||
preferenceMember = root.members[preferencesIndex]
|
||||
preferences, err = parseJSONObject(data, preferenceMember.valueStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
petIndex = lastJSONMember(preferences.members, "pet")
|
||||
}
|
||||
if len(preferences.members) == 0 {
|
||||
if lastJSONMember(root.members[:preferencesIndex], "preferences") >= 0 {
|
||||
return data, nil
|
||||
}
|
||||
return removeJSONMember(data, root, preferencesIndex), nil
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func parseJSONObject(data []byte, start int) (jsonObjectSpan, error) {
|
||||
if start < 0 || start >= len(data) || data[start] != '{' {
|
||||
return jsonObjectSpan{}, fmt.Errorf("expected JSON object")
|
||||
}
|
||||
object := jsonObjectSpan{start: start}
|
||||
leadingStart := start + 1
|
||||
for {
|
||||
i := skipJSONSpace(data, leadingStart)
|
||||
if i >= len(data) {
|
||||
return jsonObjectSpan{}, fmt.Errorf("unterminated JSON object")
|
||||
}
|
||||
if data[i] == '}' {
|
||||
object.end = i
|
||||
return object, nil
|
||||
}
|
||||
if data[i] != '"' {
|
||||
return jsonObjectSpan{}, fmt.Errorf("expected JSON object key")
|
||||
}
|
||||
keyStart := i
|
||||
keyEnd, err := scanJSONString(data, i)
|
||||
if err != nil {
|
||||
return jsonObjectSpan{}, err
|
||||
}
|
||||
var key string
|
||||
if err := json.Unmarshal(data[keyStart:keyEnd], &key); err != nil {
|
||||
return jsonObjectSpan{}, err
|
||||
}
|
||||
i = skipJSONSpace(data, keyEnd)
|
||||
if i >= len(data) || data[i] != ':' {
|
||||
return jsonObjectSpan{}, fmt.Errorf("expected colon after JSON object key")
|
||||
}
|
||||
valueStart := skipJSONSpace(data, i+1)
|
||||
valueEnd, err := scanJSONValue(data, valueStart)
|
||||
if err != nil {
|
||||
return jsonObjectSpan{}, err
|
||||
}
|
||||
i = skipJSONSpace(data, valueEnd)
|
||||
member := jsonMemberSpan{
|
||||
key: key,
|
||||
leadingStart: leadingStart,
|
||||
keyStart: keyStart,
|
||||
valueStart: valueStart,
|
||||
valueEnd: valueEnd,
|
||||
commaAfter: -1,
|
||||
}
|
||||
if i < len(data) && data[i] == ',' {
|
||||
member.commaAfter = i
|
||||
object.members = append(object.members, member)
|
||||
leadingStart = i + 1
|
||||
continue
|
||||
}
|
||||
if i >= len(data) || data[i] != '}' {
|
||||
return jsonObjectSpan{}, fmt.Errorf("expected comma or end of JSON object")
|
||||
}
|
||||
object.members = append(object.members, member)
|
||||
object.end = i
|
||||
return object, nil
|
||||
}
|
||||
}
|
||||
|
||||
func scanJSONString(data []byte, start int) (int, error) {
|
||||
for i := start + 1; i < len(data); i++ {
|
||||
switch data[i] {
|
||||
case '\\':
|
||||
i++
|
||||
case '"':
|
||||
return i + 1, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("unterminated JSON string")
|
||||
}
|
||||
|
||||
func scanJSONValue(data []byte, start int) (int, error) {
|
||||
if start >= len(data) {
|
||||
return 0, fmt.Errorf("missing JSON value")
|
||||
}
|
||||
if data[start] == '"' {
|
||||
return scanJSONString(data, start)
|
||||
}
|
||||
if data[start] == '{' || data[start] == '[' {
|
||||
stack := []byte{data[start]}
|
||||
for i := start + 1; i < len(data); i++ {
|
||||
if data[i] == '"' {
|
||||
end, err := scanJSONString(data, i)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
i = end - 1
|
||||
continue
|
||||
}
|
||||
switch data[i] {
|
||||
case '{', '[':
|
||||
stack = append(stack, data[i])
|
||||
case '}', ']':
|
||||
open := stack[len(stack)-1]
|
||||
if (open == '{' && data[i] != '}') || (open == '[' && data[i] != ']') {
|
||||
return 0, fmt.Errorf("mismatched JSON delimiter")
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
if len(stack) == 0 {
|
||||
return i + 1, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("unterminated JSON value")
|
||||
}
|
||||
for i := start; i < len(data); i++ {
|
||||
switch data[i] {
|
||||
case ',', '}', ']', ' ', '\t', '\r', '\n':
|
||||
if i == start {
|
||||
return 0, fmt.Errorf("missing JSON value")
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func skipJSONSpace(data []byte, start int) int {
|
||||
for start < len(data) {
|
||||
switch data[start] {
|
||||
case ' ', '\t', '\r', '\n':
|
||||
start++
|
||||
default:
|
||||
return start
|
||||
}
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
func lastJSONMember(members []jsonMemberSpan, key string) int {
|
||||
for i := len(members) - 1; i >= 0; i-- {
|
||||
if members[i].key == key {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func petJSONObject(encodedPet []byte) []byte {
|
||||
result := append([]byte(`{"pet":`), encodedPet...)
|
||||
return append(result, '}')
|
||||
}
|
||||
|
||||
func replaceJSONRange(data []byte, start, end int, replacement []byte) []byte {
|
||||
result := make([]byte, 0, len(data)-(end-start)+len(replacement))
|
||||
result = append(result, data[:start]...)
|
||||
result = append(result, replacement...)
|
||||
return append(result, data[end:]...)
|
||||
}
|
||||
|
||||
func removeJSONMember(data []byte, object jsonObjectSpan, index int) []byte {
|
||||
member := object.members[index]
|
||||
start, end := member.leadingStart, member.valueEnd
|
||||
if member.commaAfter >= 0 {
|
||||
end = member.commaAfter + 1
|
||||
} else if index > 0 {
|
||||
start = object.members[index-1].commaAfter
|
||||
}
|
||||
return replaceJSONRange(data, start, end, nil)
|
||||
}
|
||||
|
||||
func insertJSONMember(data []byte, object jsonObjectSpan, key string, value []byte) []byte {
|
||||
encodedKey, _ := json.Marshal(key)
|
||||
entry := append(append(encodedKey, ':', ' '), value...)
|
||||
if len(object.members) == 0 {
|
||||
interior := data[object.start+1 : object.end]
|
||||
if newline := bytes.LastIndexByte(interior, '\n'); newline >= 0 {
|
||||
closingIndent := interior[newline+1:]
|
||||
indentUnit := []byte(" ")
|
||||
if bytes.Contains(closingIndent, []byte("\t")) {
|
||||
indentUnit = []byte("\t")
|
||||
}
|
||||
lineBreak := []byte("\n")
|
||||
if newline > 0 && interior[newline-1] == '\r' {
|
||||
lineBreak = []byte("\r\n")
|
||||
}
|
||||
insert := append(append(append(lineBreak, closingIndent...), indentUnit...), entry...)
|
||||
return replaceJSONRange(data, object.start+1, object.start+1, insert)
|
||||
}
|
||||
return replaceJSONRange(data, object.end, object.end, entry)
|
||||
}
|
||||
last := object.members[len(object.members)-1]
|
||||
separator := []byte(", ")
|
||||
if bytes.Contains(data[last.valueEnd:object.end], []byte("\n")) {
|
||||
lineStart := bytes.LastIndexByte(data[:last.keyStart], '\n') + 1
|
||||
indent := data[lineStart:last.keyStart]
|
||||
separator = append([]byte(",\n"), indent...)
|
||||
}
|
||||
insert := append(separator, entry...)
|
||||
return replaceJSONRange(data, last.valueEnd, last.valueEnd, insert)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetPetPreservesUnrelatedConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
original := FileConfig{
|
||||
ActiveProvider: "test",
|
||||
Providers: []ProviderProfile{{Name: "test", Model: "example"}},
|
||||
Preferences: PreferencesConfig{Theme: "dracula"},
|
||||
}
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := SetPet(path, "boba")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Preferences.Pet != "boba" || updated.Preferences.Theme != "dracula" || updated.ActiveProvider != "test" || len(updated.Providers) != 1 {
|
||||
t.Fatalf("SetPet lost config fields: %#v", updated)
|
||||
}
|
||||
reloadedData, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var reloaded FileConfig
|
||||
if err := json.Unmarshal(reloadedData, &reloaded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.Preferences.Pet != "boba" || reloaded.Preferences.Theme != "dracula" || reloaded.ActiveProvider != "test" || len(reloaded.Providers) != 1 {
|
||||
t.Fatalf("persisted config lost fields: %#v", reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPetRejectsInvalidInputs(t *testing.T) {
|
||||
if _, err := SetPet(" ", "boba"); err == nil {
|
||||
t.Fatal("SetPet accepted a blank config path")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"preferences":`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetPet(path, "boba"); err == nil || !strings.Contains(err.Error(), "invalid config JSON") {
|
||||
t.Fatalf("SetPet malformed JSON error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPetCreatesNestedConfigAndClearsEmptyPreferences(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "zero", "config.json")
|
||||
if _, err := SetPet(path, " boba "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var preferences struct {
|
||||
Pet string `json:"pet"`
|
||||
}
|
||||
if err := json.Unmarshal(root["preferences"], &preferences); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preferences.Pet != "boba" {
|
||||
t.Fatalf("persisted pet = %q, want %q", preferences.Pet, "boba")
|
||||
}
|
||||
root["future"] = json.RawMessage(`{"enabled":true}`)
|
||||
encoded, err := json.Marshal(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, encoded, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetPet(path, " "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root = nil
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := root["preferences"]; ok {
|
||||
t.Fatalf("empty preferences were retained: %s", data)
|
||||
}
|
||||
var future struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(root["future"], &future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !future.Enabled {
|
||||
t.Fatalf("unrelated future member was lost: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPetPreservesUnknownJSONMembers(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
original := []byte(`{
|
||||
"activeProvider": "test",
|
||||
"futureTopLevel": {"enabled": true},
|
||||
"preferences": {
|
||||
"theme": "dracula",
|
||||
"futurePreference": {"mode": "extended"}
|
||||
}
|
||||
}`)
|
||||
if err := os.WriteFile(path, original, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetPet(path, "boba"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var futureTopLevel struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(root["futureTopLevel"], &futureTopLevel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !futureTopLevel.Enabled {
|
||||
t.Fatalf("unknown top-level member = %s", root["futureTopLevel"])
|
||||
}
|
||||
var preferences map[string]json.RawMessage
|
||||
if err := json.Unmarshal(root["preferences"], &preferences); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var futurePreference struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := json.Unmarshal(preferences["futurePreference"], &futurePreference); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if futurePreference.Mode != "extended" {
|
||||
t.Fatalf("unknown preference member = %s", preferences["futurePreference"])
|
||||
}
|
||||
if got := string(preferences["pet"]); got != `"boba"` {
|
||||
t.Fatalf("pet preference = %s, want boba", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPetPreservesExistingJSONLayoutAndMemberOrder(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
original := []byte("{\n\t\"zFuture\": {\"enabled\":true},\n\t\"preferences\": {\n\t\t\"theme\" : \"dracula\",\n\t\t\"pet\" : \"old\",\n\t\t\"futurePreference\": {\"mode\":\"extended\"}\n\t},\n\t\"activeProvider\": \"test\"\n}\n")
|
||||
if err := os.WriteFile(path, original, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetPet(path, "boba"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := strings.Replace(string(original), `"old"`, `"boba"`, 1)
|
||||
if string(got) != want {
|
||||
t.Fatalf("SetPet reformatted or reordered config:\n--- got ---\n%s--- want ---\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPetMinimallyEditsExistingJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
original string
|
||||
pet string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "add pet to existing preferences",
|
||||
original: "{\n \"zFuture\": {\"text\":\"comma, brace } and \\\"quote\\\"\"},\n \"preferences\": {\n \"theme\": \"dracula\"\n },\n \"activeProvider\": \"test\"\n}\n",
|
||||
pet: "boba",
|
||||
want: "{\n \"zFuture\": {\"text\":\"comma, brace } and \\\"quote\\\"\"},\n \"preferences\": {\n \"theme\": \"dracula\",\n \"pet\": \"boba\"\n },\n \"activeProvider\": \"test\"\n}\n",
|
||||
},
|
||||
{
|
||||
name: "add preferences without reordering root",
|
||||
original: "{\n \"zFuture\": true,\n \"activeProvider\": \"test\"\n}\n",
|
||||
pet: "boba",
|
||||
want: "{\n \"zFuture\": true,\n \"activeProvider\": \"test\",\n \"preferences\": {\"pet\":\"boba\"}\n}\n",
|
||||
},
|
||||
{
|
||||
name: "remove pet but retain preferences",
|
||||
original: "{\n \"preferences\": {\n \"theme\": \"dracula\",\n \"pet\": \"boba\",\n \"future\": true\n },\n \"zFuture\": true\n}\n",
|
||||
pet: "",
|
||||
want: "{\n \"preferences\": {\n \"theme\": \"dracula\",\n \"future\": true\n },\n \"zFuture\": true\n}\n",
|
||||
},
|
||||
{
|
||||
name: "remove empty preferences member",
|
||||
original: "{\n \"zFuture\": true,\n \"preferences\": {\n \"pet\": \"boba\"\n },\n \"activeProvider\": \"test\"\n}\n",
|
||||
pet: "",
|
||||
want: "{\n \"zFuture\": true,\n \"activeProvider\": \"test\"\n}\n",
|
||||
},
|
||||
{
|
||||
name: "do not expose an older duplicate preference",
|
||||
original: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":{\"pet\":\"dog\"}}\n",
|
||||
pet: "",
|
||||
want: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":{}}\n",
|
||||
},
|
||||
{
|
||||
name: "do not expose an older duplicate behind null",
|
||||
original: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":null}\n",
|
||||
pet: "",
|
||||
want: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":null}\n",
|
||||
},
|
||||
{
|
||||
name: "preserve multiline empty preferences layout",
|
||||
original: "{\n \"preferences\": {\n },\n \"activeProvider\": \"test\"\n}\n",
|
||||
pet: "boba",
|
||||
want: "{\n \"preferences\": {\n \"pet\": \"boba\"\n },\n \"activeProvider\": \"test\"\n}\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(test.original), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetPet(path, test.pet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != test.want {
|
||||
t.Fatalf("SetPet output:\n--- got ---\n%s--- want ---\n%s", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -273,6 +273,9 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
if strings.TrimSpace(src.Preferences.Theme) != "" {
|
||||
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
|
||||
}
|
||||
if strings.TrimSpace(src.Preferences.Pet) != "" {
|
||||
dst.Preferences.Pet = strings.TrimSpace(src.Preferences.Pet)
|
||||
}
|
||||
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
|
||||
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
|
||||
mergeSTTConfig(&dst.STT, src.STT)
|
||||
|
||||
@@ -168,6 +168,22 @@ func TestResolveLoadsRecentModelsFromUserConfigOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLoadsPetFromUserConfigOnly(t *testing.T) {
|
||||
userPath := writeConfig(t, `{"preferences":{"pet":" boba "}}`)
|
||||
projectPath := writeConfig(t, `{"preferences":{"pet":"project-pet"}}`)
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
Env: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.Preferences.Pet != "boba" {
|
||||
t.Fatalf("Preferences.Pet = %q, want user preference boba", resolved.Preferences.Pet)
|
||||
}
|
||||
}
|
||||
|
||||
// recentModels must never be merged from project config (same posture as
|
||||
// favoriteModels): a project setting it with no corresponding user setting
|
||||
// should leave the resolved value empty, not pick up the project's list.
|
||||
|
||||
@@ -112,6 +112,9 @@ type PreferencesConfig struct {
|
||||
// name (e.g. "dracula"). Applied at startup below the --theme flag and
|
||||
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
|
||||
Theme string `json:"theme,omitempty"`
|
||||
// Pet is the terminal companion selected through /pets. Empty leaves pets
|
||||
// off until the user chooses one; "disabled" is the explicit off state.
|
||||
Pet string `json:"pet,omitempty"`
|
||||
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
|
||||
// the user turned idle recaps off. A *bool is its own tri-state, so no
|
||||
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
|
||||
|
||||
@@ -591,6 +591,35 @@ func SetTheme(path string, theme string) (FileConfig, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetPet persists only the terminal-pet preference while preserving every
|
||||
// unrelated user setting through the config writer's atomic replace path.
|
||||
func SetPet(path string, pet string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
data := []byte("{}")
|
||||
if existing, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(existing, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
data = existing
|
||||
} else if !os.IsNotExist(err) {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
pet = strings.TrimSpace(pet)
|
||||
cfg.Preferences.Pet = pet
|
||||
data, err := setPetPreferenceJSON(data, pet)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
if err := writeConfigData(path, data); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetSTTModel persists the dictation model and its provider, mirroring
|
||||
// SetTheme (read-modify-atomic-write). provider must be one of the known STT
|
||||
// provider kinds; a local provider stores the model as stt.localModelPath,
|
||||
@@ -736,17 +765,23 @@ func NormalizeRecentModels(entries []RecentModelEntry) []RecentModelEntry {
|
||||
}
|
||||
|
||||
func writeConfigFile(path string, cfg FileConfig) error {
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode config JSON: %w", err)
|
||||
}
|
||||
return writeConfigData(path, data)
|
||||
}
|
||||
|
||||
func writeConfigData(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create config directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode config JSON: %w", err)
|
||||
if len(data) == 0 || data[len(data)-1] != '\n' {
|
||||
data = append(data, '\n')
|
||||
}
|
||||
data = append(data, '\n')
|
||||
// Write-to-temp + rename: an in-place write interrupted mid-way (crash,
|
||||
// disk full) would leave the user's only config truncated or corrupt.
|
||||
tmp, err := os.CreateTemp(dir, ".zero-config-*.tmp")
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fileStore(t *testing.T, dir string) *Store {
|
||||
t.Helper()
|
||||
store, err := New(Options{Dir: dir, Storage: "file"})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// THE REPRODUCTION, kept as the regression. Before the lock this reliably kept
|
||||
// 1 of 100: every writer read the same map, added its own provider, and the
|
||||
// last rename published a file missing all the others.
|
||||
func TestConcurrentSetKeepsEveryKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := fileStore(t, dir)
|
||||
|
||||
const n = 100
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
if err := store.Set(fmt.Sprintf("p%03d", i), fmt.Sprintf("k%03d", i)); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.Providers()
|
||||
if err != nil {
|
||||
t.Fatalf("Providers: %v", err)
|
||||
}
|
||||
if len(got) != n {
|
||||
t.Fatalf("kept %d of %d concurrent writes", len(got), n)
|
||||
}
|
||||
// ...and every value is the one its own writer wrote, not a neighbour's.
|
||||
for i := 0; i < n; i++ {
|
||||
key, ok, err := store.Get(fmt.Sprintf("p%03d", i))
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if !ok || key != fmt.Sprintf("k%03d", i) {
|
||||
t.Fatalf("p%03d = %q (present=%v); a writer's own value must survive", i, key, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE RACES SET, and an unlocked delete loses the other writer's key.
|
||||
//
|
||||
// A first version of this only checked that keys nobody deleted survived — and
|
||||
// every reader sees those, so it passed with the lock removed from Delete. The
|
||||
// case that actually breaks is a Delete whose stale read predates a concurrent
|
||||
// Set: it writes back a map that never contained the new key, and the Set is
|
||||
// gone. So the assertion is on the SETS surviving, not on the bystanders.
|
||||
func TestADeleteCannotClobberAConcurrentSet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := fileStore(t, dir)
|
||||
|
||||
// Throwaway keys for the deleters to remove, written first so a delete
|
||||
// always has something to do.
|
||||
const churn = 40
|
||||
for i := 0; i < churn; i++ {
|
||||
if err := store.Set(fmt.Sprintf("churn%02d", i), "v"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
const adds = 60
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < adds; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_ = store.Set(fmt.Sprintf("added%02d", i), "v")
|
||||
}(i)
|
||||
}
|
||||
for i := 0; i < churn; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, _ = store.Delete(fmt.Sprintf("churn%02d", i))
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
got, err := store.Providers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
survivors := map[string]bool{}
|
||||
for _, name := range got {
|
||||
survivors[name] = true
|
||||
}
|
||||
missing := []string{}
|
||||
for i := 0; i < adds; i++ {
|
||||
name := fmt.Sprintf("added%02d", i)
|
||||
if !survivors[name] {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("%d of %d concurrent Set calls were clobbered by a racing Delete: %v",
|
||||
len(missing), adds, missing)
|
||||
}
|
||||
// ...and every churn key really was removed, so the deletes were not
|
||||
// no-ops that would make the check above vacuous.
|
||||
for i := 0; i < churn; i++ {
|
||||
if survivors[fmt.Sprintf("churn%02d", i)] {
|
||||
t.Fatalf("churn%02d survived; the deletes did nothing and this test proves nothing", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ACROSS PROCESSES, which is the case that matters: a plan running with
|
||||
// max_workers > 1 spawns child PROCESSES, and an in-process mutex would not
|
||||
// have helped any of them. The race detector cannot see this one either — it is
|
||||
// two OS processes — so it is driven for real.
|
||||
func TestConcurrentSetAcrossProcesses(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("spawns processes")
|
||||
}
|
||||
helper := os.Getenv("ZERO_CREDSTORE_HELPER_DIR")
|
||||
if helper != "" {
|
||||
// Child mode: write our slice of the keys and exit.
|
||||
store, err := New(Options{Dir: helper, Storage: "file"})
|
||||
if err != nil {
|
||||
os.Exit(3)
|
||||
}
|
||||
prefix := os.Getenv("ZERO_CREDSTORE_HELPER_PREFIX")
|
||||
for i := 0; i < 25; i++ {
|
||||
if err := store.Set(fmt.Sprintf("%s%02d", prefix, i), "v"); err != nil {
|
||||
os.Exit(4)
|
||||
}
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
const children = 4
|
||||
var wg sync.WaitGroup
|
||||
failures := make(chan string, children)
|
||||
for c := 0; c < children; c++ {
|
||||
wg.Add(1)
|
||||
go func(c int) {
|
||||
defer wg.Done()
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestConcurrentSetAcrossProcesses", "-test.v")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"ZERO_CREDSTORE_HELPER_DIR="+dir,
|
||||
fmt.Sprintf("ZERO_CREDSTORE_HELPER_PREFIX=c%d_", c),
|
||||
)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
failures <- fmt.Sprintf("child %d: %v\n%s", c, err, out)
|
||||
}
|
||||
}(c)
|
||||
}
|
||||
wg.Wait()
|
||||
close(failures)
|
||||
for failure := range failures {
|
||||
t.Fatal(failure)
|
||||
}
|
||||
|
||||
store := fileStore(t, dir)
|
||||
got, err := store.Providers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != children*25 {
|
||||
t.Fatalf("kept %d of %d keys written by %d concurrent processes", len(got), children*25, children)
|
||||
}
|
||||
}
|
||||
|
||||
// The lock lives BESIDE the data file, never on it. write publishes by rename,
|
||||
// so a lock taken on the data file would be attached to an inode the next
|
||||
// writer has already replaced — every writer would appear to hold it.
|
||||
func TestTheLockIsNotTheDataFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := fileStore(t, dir)
|
||||
if store.lockPath() == store.file {
|
||||
t.Fatal("the lock is the data file; rename would carry it away")
|
||||
}
|
||||
if !strings.HasPrefix(filepath.Base(store.lockPath()), filepath.Base(store.file)) {
|
||||
t.Fatalf("lock %q is not beside the data file %q", store.lockPath(), store.file)
|
||||
}
|
||||
if err := store.Set("p", "k"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(store.lockPath()); err != nil {
|
||||
t.Fatalf("the lock file was not created: %v", err)
|
||||
}
|
||||
// ...and it survives a write, since the rename replaces only the data file.
|
||||
if err := store.Set("q", "k"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(store.lockPath()); err != nil {
|
||||
t.Fatalf("the lock file did not survive a write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The lock is a security boundary, so the path where it CANNOT be taken needs
|
||||
// coverage too: an operation that could not lock must report that, never proceed
|
||||
// unserialized and never report success. Replacing the credential directory with
|
||||
// a regular file makes the lock's MkdirAll fail on every platform.
|
||||
func TestOperationsFailWhenTheLockCannotBeAcquired(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
dir := filepath.Join(base, "creds")
|
||||
store := fileStore(t, dir)
|
||||
// Where the store expects its directory, put a regular file.
|
||||
if err := os.WriteFile(dir, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := store.Set("openai", "sk-test"); err == nil {
|
||||
t.Error("Set reported success although the lock could not be acquired")
|
||||
}
|
||||
if _, err := store.Delete("openai"); err == nil {
|
||||
t.Error("Delete reported success although the lock could not be acquired")
|
||||
}
|
||||
if _, _, err := store.Get("openai"); err == nil {
|
||||
t.Error("Get reported success although the lock could not be acquired")
|
||||
}
|
||||
if _, err := store.Providers(); err == nil {
|
||||
t.Error("Providers reported success although the lock could not be acquired")
|
||||
}
|
||||
// And nothing may have been written through the failed path.
|
||||
if raw, err := os.ReadFile(dir); err != nil || string(raw) != "not a directory" {
|
||||
t.Errorf("the blocking file was modified: %q (err %v)", raw, err)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ package credstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -125,14 +126,27 @@ func (s *Store) Backend() string { return s.backend }
|
||||
func (s *Store) Encrypted() bool { return s.backend == "keyring" || s.backend == "encrypted-file" }
|
||||
|
||||
// Set stores (or overwrites) the API key for provider.
|
||||
func (s *Store) Set(provider, key string) error {
|
||||
func (s *Store) Set(provider, key string) (err error) {
|
||||
provider = normalizeProvider(provider)
|
||||
if provider == "" {
|
||||
return fmt.Errorf("credstore: provider is required")
|
||||
}
|
||||
if s.backend == "keyring" {
|
||||
// The OS keyring serializes its own writes; the file lock guards the
|
||||
// file backends only.
|
||||
return s.kr.Set(keyringService, keyringPrefix+provider, key)
|
||||
}
|
||||
// READ AND WRITE UNDER ONE LOCK. Separately locking each half would still
|
||||
// lose keys: two writers would each read the same map, each add their own
|
||||
// provider, and the second rename would publish a file missing the first.
|
||||
// Measured before this existed: 1 of 100 concurrent Set calls survived.
|
||||
release, err := s.acquireFileLock(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result instead
|
||||
// of masking the write error that actually explains what went wrong.
|
||||
defer func() { err = errors.Join(err, release()) }()
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -142,7 +156,7 @@ func (s *Store) Set(provider, key string) error {
|
||||
}
|
||||
|
||||
// Get returns the API key for provider and whether one is stored.
|
||||
func (s *Store) Get(provider string) (string, bool, error) {
|
||||
func (s *Store) Get(provider string) (value string, found bool, err error) {
|
||||
provider = normalizeProvider(provider)
|
||||
if provider == "" {
|
||||
return "", false, nil
|
||||
@@ -150,6 +164,15 @@ func (s *Store) Get(provider string) (string, bool, error) {
|
||||
if s.backend == "keyring" {
|
||||
return s.kr.Get(keyringService, keyringPrefix+provider)
|
||||
}
|
||||
// A shared lock, so a read serializes against a writer's publish. On unix
|
||||
// the rename is atomic and this only prevents reading an empty file mid-swap;
|
||||
// on Windows an unsynchronized reader holding the file open would block the
|
||||
// writer's rename outright, so readers must coordinate through the same lock.
|
||||
release, err := s.acquireFileLock(false)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
defer func() { err = errors.Join(err, release()) }()
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
@@ -159,7 +182,7 @@ func (s *Store) Get(provider string) (string, bool, error) {
|
||||
}
|
||||
|
||||
// Delete removes the API key for provider, reporting whether one existed.
|
||||
func (s *Store) Delete(provider string) (bool, error) {
|
||||
func (s *Store) Delete(provider string) (existed bool, err error) {
|
||||
provider = normalizeProvider(provider)
|
||||
if provider == "" {
|
||||
return false, nil
|
||||
@@ -167,6 +190,13 @@ func (s *Store) Delete(provider string) (bool, error) {
|
||||
if s.backend == "keyring" {
|
||||
return s.kr.Delete(keyringService, keyringPrefix+provider)
|
||||
}
|
||||
// The same lock as Set: a delete is a read-modify-write too, and one racing
|
||||
// a set would otherwise resurrect or erase an unrelated provider.
|
||||
release, err := s.acquireFileLock(true)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { err = errors.Join(err, release()) }()
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -179,17 +209,22 @@ func (s *Store) Delete(provider string) (bool, error) {
|
||||
}
|
||||
|
||||
// Providers lists providers with a stored key (sorted), for display/audit.
|
||||
func (s *Store) Providers() ([]string, error) {
|
||||
func (s *Store) Providers() (names []string, err error) {
|
||||
if s.backend == "keyring" {
|
||||
// The OS keyring has no enumerate-by-prefix in our minimal surface; callers
|
||||
// that need a list pass known provider names to Get. Return empty here.
|
||||
return nil, nil
|
||||
}
|
||||
release, err := s.acquireFileLock(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { err = errors.Join(err, release()) }()
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(data))
|
||||
names = make([]string, 0, len(data))
|
||||
for name := range data {
|
||||
names = append(names, name)
|
||||
}
|
||||
@@ -264,6 +299,15 @@ func (s *Store) write(data map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// lockPath is the advisory lock guarding a read-modify-write of the credential
|
||||
// file. Beside the data file, never the data file itself — write publishes by
|
||||
// rename and would carry the lock away with the old inode.
|
||||
func (s *Store) lockPath() string { return s.file + ".lock" }
|
||||
|
||||
// filepathDir is filepath.Dir, named locally so the two platform lock files can
|
||||
// share it without either importing path/filepath for one call.
|
||||
func filepathDir(path string) string { return filepath.Dir(path) }
|
||||
|
||||
func normalizeProvider(provider string) string {
|
||||
return strings.ToLower(strings.TrimSpace(provider))
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build !windows
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// acquireFileLock takes an exclusive advisory lock (flock) so a read-modify-write
|
||||
// of the credential file is serialized against every other one — across
|
||||
// processes AND across goroutines, since flock is held per open file
|
||||
// description and two opens in one process contend exactly as two processes do.
|
||||
//
|
||||
// THE LOCK FILE IS SEPARATE FROM THE DATA FILE, and that is not tidiness. write
|
||||
// publishes by os.Rename, which replaces the inode; a lock taken on the data
|
||||
// file would be attached to an inode that the next writer has already replaced,
|
||||
// so every writer would appear to hold it. The lock lives on a file nothing
|
||||
// renames.
|
||||
func (s *Store) acquireFileLock(exclusive bool) (func() error, error) {
|
||||
path := s.lockPath()
|
||||
if err := os.MkdirAll(filepathDir(path), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("credstore: lock dir: %w", err)
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("credstore: open lock: %w", err)
|
||||
}
|
||||
// Writers take LOCK_EX; readers take LOCK_SH so they run concurrently with
|
||||
// each other but still serialize against a writer's publish (see Get).
|
||||
how := unix.LOCK_SH
|
||||
if exclusive {
|
||||
how = unix.LOCK_EX
|
||||
}
|
||||
if err := unix.Flock(int(file.Fd()), how); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("credstore: lock: %w", err)
|
||||
}
|
||||
return func() error {
|
||||
// Close alone drops the flock, so the explicit unlock is belt-and-braces —
|
||||
// but its failure is reported rather than swallowed, because a cleanup that
|
||||
// did not complete must not be indistinguishable from one that did. The
|
||||
// callers join this into their result, so it annotates a successful write
|
||||
// instead of masking it.
|
||||
unlockErr := unix.Flock(int(file.Fd()), unix.LOCK_UN)
|
||||
closeErr := file.Close()
|
||||
if err := errors.Join(unlockErr, closeErr); err != nil {
|
||||
return fmt.Errorf("credstore: release lock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build windows
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// acquireFileLock takes an exclusive OS lock (LockFileEx) so a read-modify-write
|
||||
// of the credential file is serialized, matching the flock behaviour on unix.
|
||||
//
|
||||
// The lock file is SEPARATE from the data file for the same reason: write
|
||||
// publishes by rename, and a lock on the renamed file would be attached to
|
||||
// something the next writer has already replaced.
|
||||
func (s *Store) acquireFileLock(exclusive bool) (func() error, error) {
|
||||
path := s.lockPath()
|
||||
if err := os.MkdirAll(filepathDir(path), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("credstore: lock dir: %w", err)
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("credstore: open lock: %w", err)
|
||||
}
|
||||
handle := windows.Handle(file.Fd())
|
||||
overlapped := new(windows.Overlapped)
|
||||
// A fixed 1-byte region, blocking (no LOCKFILE_FAIL_IMMEDIATELY) so a
|
||||
// waiter queues rather than failing the operation. Writers pass
|
||||
// LOCKFILE_EXCLUSIVE_LOCK; readers omit it (a shared lock) so they still
|
||||
// serialize against a writer's publish — which matters more on Windows,
|
||||
// where an unsynchronized reader holding the file open blocks the rename.
|
||||
flags := uint32(0)
|
||||
if exclusive {
|
||||
flags = windows.LOCKFILE_EXCLUSIVE_LOCK
|
||||
}
|
||||
if err := windows.LockFileEx(handle, flags, 0, 1, 0, overlapped); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("credstore: lock: %w", err)
|
||||
}
|
||||
return func() error {
|
||||
// Reported rather than swallowed, for the same reason as the unix side: a
|
||||
// cleanup that did not complete must not look identical to one that did.
|
||||
// It matters more here — on Windows the handle staying open is what blocks
|
||||
// the next writer's rename, so a failed release has a visible consequence.
|
||||
unlockErr := windows.UnlockFileEx(handle, 0, 1, 0, overlapped)
|
||||
closeErr := file.Close()
|
||||
if err := errors.Join(unlockErr, closeErr); err != nil {
|
||||
return fmt.Errorf("credstore: release lock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
@@ -60,7 +61,15 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
|
||||
HTTPHeader: http.Header{"Authorization": {"Token " + d.cfg.APIKey}},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connecting to Deepgram: %w", err)
|
||||
// Preserve the context.Canceled sentinel (it carries no key) so an
|
||||
// Esc-abort during dial still matches the UI's errors.Is check. Key
|
||||
// on ctx.Err() itself rather than unwrapping err: a cancellation can
|
||||
// surface as a plain transport error (e.g. "closed network
|
||||
// connection") rather than context.Canceled directly.
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
return "", fmt.Errorf("connecting to Deepgram: %s", providerio.Redact(err.Error(), d.cfg.APIKey))
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
@@ -68,15 +77,26 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
|
||||
// so no conversion (unlike sherpa-onnx, which wants float32).
|
||||
writeErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
for chunk := range chunks {
|
||||
if err := conn.Write(ctx, websocket.MessageBinary, chunk); err != nil {
|
||||
writeErrCh <- err
|
||||
// Select on ctx.Done() while waiting for the next chunk so a cancelled
|
||||
// session does not leave this goroutine blocked on an open idle channel.
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
writeErrCh <- ctx.Err()
|
||||
return
|
||||
case chunk, ok := <-chunks:
|
||||
if !ok {
|
||||
// CloseStream flushes any buffered audio and returns final
|
||||
// results before Deepgram closes the socket.
|
||||
writeErrCh <- conn.Write(ctx, websocket.MessageText, []byte(`{"type":"CloseStream"}`))
|
||||
return
|
||||
}
|
||||
if err := conn.Write(ctx, websocket.MessageBinary, chunk); err != nil {
|
||||
writeErrCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// CloseStream flushes any buffered audio and returns final results before
|
||||
// Deepgram closes the socket.
|
||||
writeErrCh <- conn.Write(ctx, websocket.MessageText, []byte(`{"type":"CloseStream"}`))
|
||||
}()
|
||||
|
||||
// Deepgram results are per-utterance segments, not cumulative: accumulate the
|
||||
@@ -100,7 +120,18 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
|
||||
}
|
||||
default:
|
||||
}
|
||||
return compose(), fmt.Errorf("Deepgram stream error: %w", err) //nolint:staticcheck // Preserve established user-facing error text.
|
||||
// A user abort cancels the streaming context; the UI matches it
|
||||
// with errors.Is(err, context.Canceled), so return the sentinel
|
||||
// itself (it carries no key) instead of a flat redacted string.
|
||||
// Key on ctx.Err() rather than unwrapping err: the writeErrCh
|
||||
// swap above can replace err with a plain transport error (e.g.
|
||||
// "closed network connection") that doesn't itself unwrap to
|
||||
// context.Canceled even though the cancellation is what caused it.
|
||||
if ctx.Err() != nil {
|
||||
return compose(), ctx.Err()
|
||||
}
|
||||
//nolint:staticcheck // Preserve established user-facing error text.
|
||||
return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey))
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// Non-cancellation dial failure: handshake returns 101 with an Upgrade header
|
||||
// equal to the API key so the dial error embeds the key and redaction must strip it.
|
||||
func TestDeepgramDialFailureRedactsKey(t *testing.T) {
|
||||
const key = "sk-test-dial-key-abcdef"
|
||||
baseURL := dialFailServerWithUpgrade(t, key)
|
||||
|
||||
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: key, BaseURL: baseURL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte)
|
||||
close(chunks)
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, nil)
|
||||
if ferr == nil {
|
||||
t.Fatal("expected dial failure")
|
||||
}
|
||||
if errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("dial failure should not be context.Canceled: %v", ferr)
|
||||
}
|
||||
if strings.Contains(ferr.Error(), key) {
|
||||
t.Errorf("API key leaked from dial failure: %v", ferr)
|
||||
}
|
||||
if !strings.Contains(ferr.Error(), "connecting to Deepgram") {
|
||||
t.Errorf("expected dial-path error prefix, got: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// dialFailServerWithUpgrade returns a ws URL whose handshake responds 101 with
|
||||
// Upgrade set to upgradeValue (not "websocket"), so websocket.Dial fails with
|
||||
// that value in the error string. Hijacks and closes immediately to avoid the
|
||||
// library's 3s body-drain timeout on failed dials.
|
||||
func dialFailServerWithUpgrade(t *testing.T, upgradeValue string) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijack not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
conn, bufrw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_, _ = bufrw.WriteString("HTTP/1.1 101 Switching Protocols\r\n")
|
||||
_, _ = bufrw.WriteString("Connection: Upgrade\r\n")
|
||||
_, _ = bufrw.WriteString("Upgrade: " + upgradeValue + "\r\n\r\n")
|
||||
_ = bufrw.Flush()
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
}
|
||||
|
||||
func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) {
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
// Drain the client's audio frames, then wait for CloseStream so the
|
||||
// client has flushed its writes before we reject the connection. Closing
|
||||
// immediately (before CloseStream) risks the client failing on a write
|
||||
// error and never observing this API-key-bearing close reason.
|
||||
for {
|
||||
typ, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key")
|
||||
})
|
||||
|
||||
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte, 1)
|
||||
chunks <- make([]byte, 320)
|
||||
close(chunks)
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
|
||||
if ferr == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if strings.Contains(ferr.Error(), "sk-test-key") {
|
||||
t.Errorf("API key leaked: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) {
|
||||
firstFrame := make(chan struct{})
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
// Hold the connection open, never answering, so the client blocks in
|
||||
// Read until its context is cancelled (the Esc-abort path).
|
||||
var once sync.Once
|
||||
for {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
once.Do(func() { close(firstFrame) })
|
||||
}
|
||||
})
|
||||
|
||||
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
chunks := make(chan []byte, 1)
|
||||
defer close(chunks)
|
||||
chunks <- make([]byte, 320)
|
||||
// The channel stays open: the session is live when the user aborts.
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
|
||||
errCh <- ferr
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-firstFrame:
|
||||
cancel()
|
||||
ferr := <-errCh
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr)
|
||||
}
|
||||
case ferr := <-errCh:
|
||||
t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc can cancel while the WebSocket dial is still pending. The dial error is
|
||||
// redacted with %s, so we must return ctx.Err() rather than a flat string.
|
||||
func TestDeepgramStreamTranscribeDialCancelKeepsSentinel(t *testing.T) {
|
||||
tr, err := NewDeepgramTranscriber(DeepgramConfig{
|
||||
APIKey: "sk-test-key",
|
||||
BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
chunks := make(chan []byte)
|
||||
defer close(chunks)
|
||||
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc right after accept can race the first write or the first Read; both
|
||||
// setup/write redaction sites must still yield context.Canceled.
|
||||
func TestDeepgramStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) {
|
||||
accepted := make(chan struct{})
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
close(accepted)
|
||||
for {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
chunks := make(chan []byte, 1)
|
||||
defer close(chunks)
|
||||
// No frames yet: the writer blocks on chunks or the reader blocks on Read.
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
|
||||
errCh <- ferr
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-accepted:
|
||||
cancel()
|
||||
ferr := <-errCh
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr)
|
||||
}
|
||||
case ferr := <-errCh:
|
||||
t.Fatalf("StreamTranscribe failed before accept: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeepgramStreamErrorRedactsHeaderShapedKey exercises the same stream-error
|
||||
// redaction path as TestDeepgramStreamTranscribeErrorRedaction, with a close
|
||||
// reason that embeds the key next to an X-Api-Key-shaped header label.
|
||||
func TestDeepgramStreamErrorRedactsHeaderShapedKey(t *testing.T) {
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
for {
|
||||
typ, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Close(websocket.StatusPolicyViolation, "X-Api-Key: sk-custom-secret-key-1234567890 failed")
|
||||
})
|
||||
|
||||
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-custom-secret-key-1234567890", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte, 1)
|
||||
chunks <- make([]byte, 320)
|
||||
close(chunks)
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
|
||||
if ferr == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if strings.Contains(ferr.Error(), "sk-custom-secret-key-1234567890") {
|
||||
t.Errorf("Custom header API key leaked: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepgramSinglePassRedaction(t *testing.T) {
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
for {
|
||||
typ, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Close(websocket.StatusPolicyViolation, "error with sk-key1234567890abcdef1234")
|
||||
})
|
||||
|
||||
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-key1234567890abcdef1234", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte, 1)
|
||||
chunks <- make([]byte, 320)
|
||||
close(chunks)
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
|
||||
if ferr == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
got := ferr.Error()
|
||||
if strings.Contains(got, "sk-key1234567890abcdef1234") {
|
||||
t.Fatalf("API key leaked into stream error: %q", got)
|
||||
}
|
||||
// One key occurrence must produce exactly one redaction marker (no double wrap).
|
||||
if strings.Count(got, "[REDACTED]") != 1 {
|
||||
t.Fatalf("redaction markers in %q: want exactly 1 [REDACTED]", got)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,10 @@ type OpenAIRealtimeConfig struct {
|
||||
Model string // default "gpt-4o-transcribe"
|
||||
// BaseURL overrides the wss endpoint (tests point it at a fake server).
|
||||
BaseURL string
|
||||
// writeErrInjector, when set, is consulted before each JSON websocket write.
|
||||
// Used by same-package tests to force controllable write failures that embed
|
||||
// secrets; production callers leave it nil.
|
||||
writeErrInjector func() error
|
||||
}
|
||||
|
||||
type openAIRealtimeTranscriber struct {
|
||||
@@ -59,7 +64,15 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connecting to OpenAI Realtime: %w", err)
|
||||
// Preserve the context.Canceled sentinel (it carries no key) so an
|
||||
// Esc-abort during dial still matches the UI's errors.Is check. Key
|
||||
// on ctx.Err() itself rather than unwrapping err: a cancellation can
|
||||
// surface as a plain transport error (e.g. "closed network
|
||||
// connection") rather than context.Canceled directly.
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
return "", fmt.Errorf("connecting to OpenAI Realtime: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
@@ -73,24 +86,53 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := writeJSON(ctx, conn, sessionUpdate); err != nil {
|
||||
return "", fmt.Errorf("configuring OpenAI Realtime session: %w", err)
|
||||
if err := o.writeJSON(ctx, conn, sessionUpdate); err != nil {
|
||||
// Same cancellation short-circuit as the dial above: an Esc-abort
|
||||
// while the session update is in flight must stay context.Canceled.
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
return "", fmt.Errorf("configuring OpenAI Realtime session: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
|
||||
}
|
||||
|
||||
writeErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
for chunk := range chunks {
|
||||
appendMsg := map[string]any{
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": base64.StdEncoding.EncodeToString(chunk),
|
||||
}
|
||||
if err := writeJSON(ctx, conn, appendMsg); err != nil {
|
||||
writeErrCh <- err
|
||||
// Select on ctx.Done() while waiting for the next chunk so a cancelled
|
||||
// session does not leave this goroutine blocked on an open idle channel.
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
writeErrCh <- ctx.Err()
|
||||
return
|
||||
case chunk, ok := <-chunks:
|
||||
if !ok {
|
||||
// Commit the buffered audio to force final transcription.
|
||||
if err := o.writeJSON(ctx, conn, map[string]any{"type": "input_audio_buffer.commit"}); err != nil {
|
||||
writeErrCh <- err
|
||||
// Unblock the reader: without a close, conn.Read stays
|
||||
// parked on a silent peer and the error is never drained
|
||||
// from writeErrCh. CloseNow aborts instead of waiting on
|
||||
// the close handshake, which a silent peer never answers.
|
||||
conn.CloseNow()
|
||||
return
|
||||
}
|
||||
writeErrCh <- nil
|
||||
return
|
||||
}
|
||||
appendMsg := map[string]any{
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": base64.StdEncoding.EncodeToString(chunk),
|
||||
}
|
||||
if err := o.writeJSON(ctx, conn, appendMsg); err != nil {
|
||||
writeErrCh <- err
|
||||
// Same unblocking close as the commit path: a server that
|
||||
// goes silent after session setup would otherwise leave the
|
||||
// reader blocked in conn.Read with the error undrained.
|
||||
conn.CloseNow()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Commit the buffered audio to force final transcription of the utterance.
|
||||
writeErrCh <- writeJSON(ctx, conn, map[string]any{"type": "input_audio_buffer.commit"})
|
||||
}()
|
||||
|
||||
// OpenAI deltas are incremental (append), unlike Deepgram/sherpa's cumulative
|
||||
@@ -113,7 +155,17 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
}
|
||||
default:
|
||||
}
|
||||
return compose(), fmt.Errorf("OpenAI Realtime stream error: %w", err)
|
||||
// A user abort cancels the streaming context; the UI matches it
|
||||
// with errors.Is(err, context.Canceled), so return the sentinel
|
||||
// itself (it carries no key) instead of a flat redacted string.
|
||||
// Key on ctx.Err() rather than unwrapping err: the writeErrCh
|
||||
// swap above can replace err with a plain transport error (e.g.
|
||||
// "closed network connection") that doesn't itself unwrap to
|
||||
// context.Canceled even though the cancellation is what caused it.
|
||||
if ctx.Err() != nil {
|
||||
return compose(), ctx.Err()
|
||||
}
|
||||
return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
@@ -125,6 +177,12 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
if onPartial != nil {
|
||||
onPartial(compose(), false)
|
||||
}
|
||||
// onPartial may deliver the partial that makes the TUI cancel
|
||||
// (Esc). Observe that before the next Read, which can already
|
||||
// have a racing OpenAI error frame buffered.
|
||||
if ctx.Err() != nil {
|
||||
return compose(), ctx.Err()
|
||||
}
|
||||
case realtimeCompleted:
|
||||
// A completed item replaces the in-progress delta buffer with the
|
||||
// server's authoritative transcript for that segment.
|
||||
@@ -138,6 +196,9 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
if onPartial != nil {
|
||||
onPartial(compose(), true)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return compose(), ctx.Err()
|
||||
}
|
||||
// Once we've committed the buffer (user stopped) and the server has
|
||||
// returned a completed transcription, the utterance is done.
|
||||
if committed {
|
||||
@@ -147,7 +208,15 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
case realtimeCommitted:
|
||||
committed = true
|
||||
case realtimeError:
|
||||
return compose(), fmt.Errorf("OpenAI Realtime error: %s", evt.text)
|
||||
// Esc can race an incoming error event: conn.Read may already have
|
||||
// the OpenAI error in hand by the time cancellation lands. Key on
|
||||
// ctx.Err() first, same as every other return point in this loop,
|
||||
// so a cancel that wins the race still surfaces as context.Canceled
|
||||
// instead of a spurious redacted failure alongside it.
|
||||
if ctx.Err() != nil {
|
||||
return compose(), ctx.Err()
|
||||
}
|
||||
return compose(), fmt.Errorf("OpenAI Realtime error: %s", providerio.Redact(evt.text, o.cfg.APIKey))
|
||||
}
|
||||
// The writer signals commit completion out of band; observe it so a
|
||||
// stop with no further audio still flips `committed`.
|
||||
@@ -155,6 +224,10 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
case werr := <-writeErrCh:
|
||||
if werr == nil {
|
||||
committed = true
|
||||
} else if ctx.Err() != nil {
|
||||
return compose(), ctx.Err()
|
||||
} else {
|
||||
return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(werr.Error(), o.cfg.APIKey))
|
||||
}
|
||||
default:
|
||||
}
|
||||
@@ -201,6 +274,15 @@ func parseRealtimeEvent(data []byte) realtimeEvent {
|
||||
return realtimeEvent{kind: realtimeOther}
|
||||
}
|
||||
|
||||
func (o *openAIRealtimeTranscriber) writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
|
||||
if o.cfg.writeErrInjector != nil {
|
||||
if err := o.cfg.writeErrInjector(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writeJSON(ctx, conn, v)
|
||||
}
|
||||
|
||||
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// Non-cancellation dial failure: handshake returns 101 with an Upgrade header
|
||||
// equal to the API key so the dial error embeds the key and redaction must strip it.
|
||||
func TestOpenAIRealtimeDialFailureRedactsKey(t *testing.T) {
|
||||
const key = "sk-test-dial-key-abcdef"
|
||||
baseURL := dialFailServerWithUpgrade(t, key)
|
||||
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: key, BaseURL: baseURL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte)
|
||||
close(chunks)
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, nil)
|
||||
if ferr == nil {
|
||||
t.Fatal("expected dial failure")
|
||||
}
|
||||
if errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("dial failure should not be context.Canceled: %v", ferr)
|
||||
}
|
||||
if strings.Contains(ferr.Error(), key) {
|
||||
t.Errorf("API key leaked from dial failure: %v", ferr)
|
||||
}
|
||||
if !strings.Contains(ferr.Error(), "connecting to OpenAI Realtime") {
|
||||
t.Errorf("expected dial-path error prefix, got: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Session-configuration write fails after dial: inject a write error that embeds
|
||||
// the API key (peer close cannot reach this path reliably — the session write
|
||||
// always lands in the kernel buffer first) and assert redaction.
|
||||
func TestOpenAIRealtimeSessionConfigFailureRedactsKey(t *testing.T) {
|
||||
const key = "sk-test-session-key-abcdef"
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
// Hold open; the client fails on the injected session write before reading.
|
||||
<-ctx.Done()
|
||||
})
|
||||
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
|
||||
APIKey: key,
|
||||
BaseURL: url,
|
||||
writeErrInjector: func() error {
|
||||
return errors.New("invalid API key " + key)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte)
|
||||
close(chunks)
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, nil)
|
||||
if ferr == nil {
|
||||
t.Fatal("expected session-config failure")
|
||||
}
|
||||
if errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("session-config failure should not be context.Canceled: %v", ferr)
|
||||
}
|
||||
if strings.Contains(ferr.Error(), key) {
|
||||
t.Errorf("API key leaked from session-config failure: %v", ferr)
|
||||
}
|
||||
if !strings.Contains(ferr.Error(), "configuring OpenAI Realtime session") {
|
||||
t.Errorf("expected session-config error prefix, got: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Asynchronous writer failure: session update succeeds, then the audio writer
|
||||
// injects a key-bearing write error. The server keeps the connection open and
|
||||
// emits a delta so the reader loop observes writeErrCh (not a peer close).
|
||||
func TestOpenAIRealtimeWriterFailureRedactsKey(t *testing.T) {
|
||||
const key = "sk-test-writer-key-abcdef"
|
||||
sessionDone := make(chan struct{})
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
close(sessionDone)
|
||||
// Keep reading and emit a delta so the client processes an event and
|
||||
// drains writeErrCh while the connection is still open.
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(
|
||||
`{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`,
|
||||
))
|
||||
for {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
var writes atomic.Int32
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
|
||||
APIKey: key,
|
||||
BaseURL: url,
|
||||
writeErrInjector: func() error {
|
||||
// First write is session.update; fail subsequent audio/commit writes.
|
||||
if writes.Add(1) == 1 {
|
||||
return nil
|
||||
}
|
||||
return errors.New("invalid API key " + key)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte, 1)
|
||||
chunks <- make([]byte, 480)
|
||||
// Leave open after the first frame so the writer hits the injected error
|
||||
// on that append rather than a clean commit.
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
|
||||
done <- ferr
|
||||
}()
|
||||
|
||||
var ferr error
|
||||
select {
|
||||
case ferr = <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
close(chunks)
|
||||
t.Fatal("StreamTranscribe did not complete after writer failure")
|
||||
}
|
||||
close(chunks)
|
||||
|
||||
if ferr == nil {
|
||||
t.Fatal("expected writer failure")
|
||||
}
|
||||
if errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("writer failure should not be context.Canceled: %v", ferr)
|
||||
}
|
||||
if strings.Contains(ferr.Error(), key) {
|
||||
t.Errorf("API key leaked from writer failure: %v", ferr)
|
||||
}
|
||||
if !strings.Contains(ferr.Error(), "OpenAI Realtime stream error") {
|
||||
t.Errorf("expected stream/writer error prefix, got: %v", ferr)
|
||||
}
|
||||
select {
|
||||
case <-sessionDone:
|
||||
default:
|
||||
// Session may race the injected audio write; prefix assertion above is enough.
|
||||
}
|
||||
}
|
||||
|
||||
// Writer failure with a silent server: the peer never sends a frame after
|
||||
// session setup, so the reader is parked in conn.Read. The writer must close
|
||||
// the connection on failure or the writeErrCh error is never observed and
|
||||
// StreamTranscribe blocks forever.
|
||||
func TestOpenAIRealtimeWriterFailureUnblocksSilentServer(t *testing.T) {
|
||||
const key = "sk-test-silent-writer-key-abcdef"
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
// Hold the connection open without ever writing a frame.
|
||||
<-ctx.Done()
|
||||
})
|
||||
|
||||
var writes atomic.Int32
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
|
||||
APIKey: key,
|
||||
BaseURL: url,
|
||||
writeErrInjector: func() error {
|
||||
// First write is session.update; fail the subsequent append write.
|
||||
if writes.Add(1) == 1 {
|
||||
return nil
|
||||
}
|
||||
return errors.New("invalid API key " + key)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
chunks := make(chan []byte, 1)
|
||||
chunks <- make([]byte, 480)
|
||||
// Leave the channel open: the writer must fail on the append, not the commit.
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, func(string, bool) {})
|
||||
done <- ferr
|
||||
}()
|
||||
|
||||
select {
|
||||
case ferr := <-done:
|
||||
if ferr == nil {
|
||||
t.Fatal("expected writer failure")
|
||||
}
|
||||
if errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("writer failure should not be context.Canceled: %v", ferr)
|
||||
}
|
||||
if strings.Contains(ferr.Error(), key) {
|
||||
t.Errorf("API key leaked from writer failure: %v", ferr)
|
||||
}
|
||||
if !strings.Contains(ferr.Error(), "OpenAI Realtime stream error") {
|
||||
t.Errorf("expected stream/writer error prefix, got: %v", ferr)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
close(chunks)
|
||||
t.Fatal("StreamTranscribe did not unblock after writer failure with a silent server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) {
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
defer c.Close(websocket.StatusNormalClosure, "")
|
||||
for {
|
||||
typ, _, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
}
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`))
|
||||
}
|
||||
})
|
||||
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte, 1)
|
||||
chunks <- make([]byte, 480)
|
||||
close(chunks)
|
||||
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
|
||||
if ferr == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if strings.Contains(ferr.Error(), "sk-test-key") {
|
||||
t.Errorf("API key leaked: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) {
|
||||
firstFrame := make(chan struct{})
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
// Hold the connection open, never answering, so the client blocks in
|
||||
// Read until its context is cancelled (the Esc-abort path).
|
||||
var once sync.Once
|
||||
for {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
once.Do(func() { close(firstFrame) })
|
||||
}
|
||||
})
|
||||
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
chunks := make(chan []byte, 1)
|
||||
defer close(chunks)
|
||||
chunks <- make([]byte, 480)
|
||||
// The channel stays open: the session is live when the user aborts.
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
|
||||
errCh <- ferr
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-firstFrame:
|
||||
cancel()
|
||||
ferr := <-errCh
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr)
|
||||
}
|
||||
case ferr := <-errCh:
|
||||
t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc can cancel while the WebSocket dial is still pending. The dial error is
|
||||
// redacted with %s, so we must return ctx.Err() rather than a flat string.
|
||||
func TestOpenAIRealtimeStreamTranscribeDialCancelKeepsSentinel(t *testing.T) {
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
|
||||
APIKey: "sk-test-key",
|
||||
BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
chunks := make(chan []byte)
|
||||
defer close(chunks)
|
||||
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc right after accept can hit the session-update write or the first Read;
|
||||
// both redaction sites must still yield context.Canceled.
|
||||
func TestOpenAIRealtimeStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) {
|
||||
accepted := make(chan struct{})
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
close(accepted)
|
||||
// Do not read: leave the client in session-update write or first Read.
|
||||
<-ctx.Done()
|
||||
})
|
||||
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
chunks := make(chan []byte, 1)
|
||||
defer close(chunks)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
|
||||
errCh <- ferr
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-accepted:
|
||||
cancel()
|
||||
ferr := <-errCh
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr)
|
||||
}
|
||||
case ferr := <-errCh:
|
||||
t.Fatalf("StreamTranscribe failed before accept: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel while the writer is blocked on more chunks. Whichever path observes
|
||||
// the cancellation first (the blocked conn.Read or the writeErrCh drain) must
|
||||
// return context.Canceled rather than a redacted flat string.
|
||||
func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) {
|
||||
sessionReceived := make(chan struct{})
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
close(sessionReceived)
|
||||
for {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
chunks := make(chan []byte, 1)
|
||||
chunks <- make([]byte, 480)
|
||||
defer close(chunks)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
|
||||
errCh <- ferr
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-sessionReceived:
|
||||
cancel()
|
||||
ferr := <-errCh
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("write-path cancel lost the context.Canceled sentinel: %v", ferr)
|
||||
}
|
||||
case ferr := <-errCh:
|
||||
t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc can race an incoming OpenAI error event. conn.Read may already have
|
||||
// the error frame in hand by the time the cancellation lands. The server
|
||||
// fires a delta immediately followed by the error, back to back. Cancel from
|
||||
// inside the delta callback (same path the TUI uses when a partial triggers
|
||||
// Esc handling) so the cancel is observed before the next event is processed.
|
||||
// The returned error must still be context.Canceled, not the redacted OpenAI
|
||||
// error.
|
||||
func TestOpenAIRealtimeStreamTranscribeErrorRaceKeepsSentinel(t *testing.T) {
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(
|
||||
`{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`,
|
||||
))
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(
|
||||
`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`,
|
||||
))
|
||||
<-ctx.Done()
|
||||
})
|
||||
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
chunks := make(chan []byte, 1)
|
||||
defer close(chunks)
|
||||
chunks <- make([]byte, 480)
|
||||
var once sync.Once
|
||||
_, ferr := tr.StreamTranscribe(ctx, chunks, func(string, bool) {
|
||||
// Cancel synchronously from the partial callback, before the
|
||||
// stream loop continues to the already-buffered error frame.
|
||||
once.Do(cancel)
|
||||
})
|
||||
if !errors.Is(ferr, context.Canceled) {
|
||||
t.Fatalf("StreamTranscribe error = %v, want context.Canceled (cancel must win over a racing OpenAI error event)", ferr)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
@@ -38,12 +40,45 @@ func KillProcessTree(pid int) error {
|
||||
// TerminateProcessTree requests graceful termination, then force-kills the
|
||||
// process tree after grace. Callers retain their distinct persistence models;
|
||||
// this function owns only the OS lifecycle primitive.
|
||||
//
|
||||
// The signal target is REDISCOVERED here via processSignalTarget(pid), which
|
||||
// asks the OS for pid's current group. A caller that already knows pid was
|
||||
// configured as its own group leader at launch (e.g. via ConfigureProcessGroup)
|
||||
// should use TerminateProcessGroup instead — see its doc comment for why this
|
||||
// rediscovery is fragile once the leader may have already exited.
|
||||
func TerminateProcessTree(pid int, grace, poll time.Duration) error {
|
||||
target, err := processSignalTarget(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
|
||||
return terminateTarget(pid, target, grace, poll)
|
||||
}
|
||||
|
||||
// TerminateProcessGroup is like TerminateProcessTree, but for a caller that
|
||||
// already knows pid is (or was, at launch) its own process-group leader — e.g.
|
||||
// a command started after ConfigureProcessGroup(cmd), which sets Setpgid so
|
||||
// cmd.Process.Pid always leads its own group at the moment it is signalled.
|
||||
//
|
||||
// TerminateProcessTree instead rediscovers the group via syscall.Getpgid(pid)
|
||||
// at call time. On Darwin, that lookup can return ESRCH once an unreaped group
|
||||
// leader has already exited, even though live descendants remain in the group
|
||||
// it configured — TerminateProcessTree would then silently fall back to
|
||||
// signalling only the (already dead) leader PID and leave those descendants
|
||||
// running. Skipping rediscovery in favor of the launch-time invariant avoids
|
||||
// that: the negative PID is used directly, regardless of whether the leader is
|
||||
// still alive to be looked up.
|
||||
func TerminateProcessGroup(pid int, grace, poll time.Duration) error {
|
||||
if pid <= 1 {
|
||||
return fmt.Errorf("refusing to signal invalid pid %d", pid)
|
||||
}
|
||||
return terminateTarget(pid, -pid, grace, poll)
|
||||
}
|
||||
|
||||
// terminateTarget signals target (an individual PID or, negative, a process
|
||||
// group) with SIGTERM then, after grace, SIGKILL. reportPid identifies the
|
||||
// original process in error messages regardless of which form target takes.
|
||||
func terminateTarget(reportPid, target int, grace, poll time.Duration) error {
|
||||
alive := func() bool { return signalTargetRunning(target) }
|
||||
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
return nil
|
||||
@@ -77,11 +112,91 @@ func TerminateProcessTree(pid int, grace, poll time.Duration) error {
|
||||
time.Sleep(poll)
|
||||
}
|
||||
if alive() {
|
||||
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
|
||||
return fmt.Errorf("process %d did not exit after SIGKILL", reportPid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// signalTargetRunning reports whether the signal target still has a process that
|
||||
// is actually RUNNING, as opposed to one waiting to be reaped.
|
||||
//
|
||||
// kill(2) with signal 0 succeeds for a zombie: the PID still exists until its
|
||||
// parent collects it. That matters because the target here is usually a process
|
||||
// GROUP, and a terminated leader's child is briefly a zombie before init/launchd
|
||||
// reaps it. Treating that window as "still alive" made termination sit through
|
||||
// both grace periods, SIGKILL an already-dead group, and then report
|
||||
// "did not exit after SIGKILL" for a tree that had in fact stopped — turning a
|
||||
// successful cleanup into a spurious failure (and a flaky one, since it depends on
|
||||
// reap timing).
|
||||
//
|
||||
// Zombie detection goes through ps, which is available on every Unix we target and
|
||||
// avoids a /proc dependency Darwin does not have. It is only consulted when the
|
||||
// cheap kill check says something is still there, so the common path costs nothing
|
||||
// extra. If ps cannot be run or its output cannot be parsed, the conservative
|
||||
// pre-existing answer (the kill result) stands.
|
||||
func signalTargetRunning(target int) bool {
|
||||
if syscall.Kill(target, syscall.Signal(0)) != nil {
|
||||
return false
|
||||
}
|
||||
// A negative target is a process GROUP: check whether any member is
|
||||
// non-zombie. A positive target is an individual PID that is NOT its own
|
||||
// group leader (processSignalTarget's fallback) — its actual PGID differs
|
||||
// from its own PID, so matching rows by PGID here would look for a group
|
||||
// that doesn't exist and always report "unknown" (conservatively alive).
|
||||
// Check its own PID's state instead.
|
||||
var running, ok bool
|
||||
if target < 0 {
|
||||
running, ok = processGroupHasRunningMember(-target)
|
||||
} else {
|
||||
running, ok = processIsRunning(target)
|
||||
}
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return running
|
||||
}
|
||||
|
||||
// processGroupHasRunningMember reports whether any member of pgid is in a state
|
||||
// other than zombie. ok is false when the group's states could not be determined.
|
||||
func processGroupHasRunningMember(pgid int) (running bool, ok bool) {
|
||||
return processTableStateMatches(func(_, rowPgid int) bool { return rowPgid == pgid })
|
||||
}
|
||||
|
||||
// processIsRunning reports whether pid itself is in a state other than zombie.
|
||||
// ok is false when pid's state could not be determined.
|
||||
func processIsRunning(pid int) (running bool, ok bool) {
|
||||
return processTableStateMatches(func(rowPid, _ int) bool { return rowPid == pid })
|
||||
}
|
||||
|
||||
// processTableStateMatches scans the process table once, via ps, and reports
|
||||
// whether any row satisfying match is non-zombie, and whether at least one
|
||||
// matching row was found at all (ok). No rows found means either the process
|
||||
// (or group) genuinely doesn't exist (a race with exit) or ps's output was
|
||||
// unparseable/restricted; either way, the caller should not claim knowledge.
|
||||
func processTableStateMatches(match func(pid, pgid int) bool) (running bool, ok bool) {
|
||||
output, err := exec.Command("ps", "-A", "-o", "pid=,pgid=,stat=").Output()
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
found := false
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
rowPid, errPid := strconv.Atoi(fields[0])
|
||||
rowPgid, errPgid := strconv.Atoi(fields[1])
|
||||
if errPid != nil || errPgid != nil || !match(rowPid, rowPgid) {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !strings.HasPrefix(fields[2], "Z") {
|
||||
return true, true
|
||||
}
|
||||
}
|
||||
return false, found
|
||||
}
|
||||
|
||||
func processSignalTarget(pid int) (int, error) {
|
||||
if pid <= 1 {
|
||||
return 0, fmt.Errorf("refusing to signal invalid pid %d", pid)
|
||||
@@ -91,10 +206,14 @@ func processSignalTarget(pid int) (int, error) {
|
||||
if pgid == pid {
|
||||
target = -pid
|
||||
}
|
||||
} else if errors.Is(err, syscall.ESRCH) {
|
||||
// Preserve the individual target; the signal call below treats ESRCH as
|
||||
// already gone, which is a successful lifecycle outcome.
|
||||
return pid, nil
|
||||
} else {
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
// Preserve the individual target; the signal call below treats ESRCH as
|
||||
// already gone, which is a successful lifecycle outcome.
|
||||
return pid, nil
|
||||
}
|
||||
// Conservatively retain the individual PID after other lookup failures;
|
||||
// guessing and signalling a process group could affect unrelated processes.
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//go:build !windows
|
||||
|
||||
package execution
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestTerminateProcessTreeTreatsZombieGroupAsExited is the regression test for
|
||||
// counting unreaped processes as alive: kill(2) with signal 0 succeeds for a
|
||||
// zombie, so a group whose only remaining member is waiting to be reaped used to
|
||||
// sit through both grace periods, take a pointless SIGKILL, and then be reported
|
||||
// as "did not exit after SIGKILL" — a spurious cleanup failure whose timing
|
||||
// depended on when the reaper ran.
|
||||
func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
|
||||
cmd := exec.Command("sh", "-c", "exit 0")
|
||||
ConfigureProcessGroup(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
// Deliberately do NOT Wait: the child stays a zombie in its own group, which
|
||||
// is exactly the state a terminated tree passes through.
|
||||
t.Cleanup(func() { _ = cmd.Wait() })
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if zombie, ok := processIsZombie(pid); ok && zombie {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if syscall.Kill(-pid, syscall.Signal(0)) != nil {
|
||||
t.Skip("the zombie group is no longer signalable; cannot exercise the case")
|
||||
}
|
||||
|
||||
if signalTargetRunning(-pid) {
|
||||
t.Fatal("a group holding only a zombie must not count as running")
|
||||
}
|
||||
if err := TerminateProcessTree(pid, 50*time.Millisecond, 10*time.Millisecond); err != nil {
|
||||
t.Fatalf("TerminateProcessTree on an already-exited tree: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminateProcessTreeStopsRunningGroup is the positive control: a group with
|
||||
// a live member must still be seen as running and then actually stopped, so the
|
||||
// zombie tolerance above cannot degrade into ignoring real processes.
|
||||
func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
|
||||
cmd := exec.Command("sh", "-c", "sleep 30")
|
||||
ConfigureProcessGroup(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
defer func() { _ = cmd.Wait() }()
|
||||
|
||||
if !signalTargetRunning(-pid) {
|
||||
t.Fatal("a group with a live member must count as running")
|
||||
}
|
||||
if err := TerminateProcessTree(pid, time.Second, 10*time.Millisecond); err != nil {
|
||||
t.Fatalf("TerminateProcessTree: %v", err)
|
||||
}
|
||||
if running, ok := processGroupHasRunningMember(pid); ok && running {
|
||||
t.Fatal("the group still has a running member after termination")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignalTargetRunningTreatsZombieIndividualPIDAsExited is the regression
|
||||
// test for jatmn's second #774 finding: signalTargetRunning's zombie check
|
||||
// matched the individual-PID fallback target (a process that is NOT its own
|
||||
// group leader — processSignalTarget's positive-PID case) against process-table
|
||||
// rows by PGID. That target's actual PGID differs from its own PID by
|
||||
// definition (that's exactly why processSignalTarget fell back to the
|
||||
// individual PID instead of a negative group target), so no row ever matched,
|
||||
// "unknown" resulted every time, and signalTargetRunning conservatively (and
|
||||
// incorrectly) treated a zombie individual-PID target as still running.
|
||||
func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
|
||||
cmd := exec.Command("sh", "-c", "exit 0")
|
||||
// Deliberately do NOT call ConfigureProcessGroup: the child inherits this
|
||||
// test process's group, so it is not its own leader and processSignalTarget
|
||||
// falls back to the individual (positive) PID.
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
t.Cleanup(func() { _ = cmd.Wait() })
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if zombie, ok := processIsZombie(pid); ok && zombie {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
target, err := processSignalTarget(pid)
|
||||
if err != nil {
|
||||
t.Fatalf("processSignalTarget: %v", err)
|
||||
}
|
||||
if target != pid {
|
||||
t.Skip("child unexpectedly became its own group leader; cannot exercise the individual-PID fallback")
|
||||
}
|
||||
if signalTargetRunning(target) {
|
||||
t.Fatal("a zombie individual-PID target must not count as running")
|
||||
}
|
||||
}
|
||||
|
||||
// processIsZombie reports a process's zombie state via ps. ok is false when the
|
||||
// state could not be read.
|
||||
func processIsZombie(pid int) (zombie bool, ok bool) {
|
||||
output, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
state := string(output)
|
||||
for len(state) > 0 && (state[0] == ' ' || state[0] == '\n' || state[0] == '\t') {
|
||||
state = state[1:]
|
||||
}
|
||||
if state == "" {
|
||||
return false, false
|
||||
}
|
||||
return state[0] == 'Z', true
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -12,10 +14,34 @@ import (
|
||||
|
||||
func ConfigureProcessGroup(cmd *exec.Cmd) {}
|
||||
|
||||
// KillProcessTree asks taskkill /T to kill pid and descendants it can still
|
||||
// discover from that live root. If taskkill fails, it falls back to killing pid
|
||||
// alone but still returns the tree error because descendants may have survived.
|
||||
// Neither path can recover descendants after the root has exited.
|
||||
func KillProcessTree(pid int) error {
|
||||
if err := exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
|
||||
return killProcessTree(pid, runTaskkill, killRootProcess)
|
||||
}
|
||||
|
||||
func killProcessTree(pid int, killTree, killRoot func(int) error) error {
|
||||
treeErr := killTree(pid)
|
||||
if treeErr == nil {
|
||||
return nil
|
||||
}
|
||||
rootErr := killRoot(pid)
|
||||
if rootErr != nil {
|
||||
return errors.Join(
|
||||
fmt.Errorf("kill process tree rooted at %d: %w", pid, treeErr),
|
||||
fmt.Errorf("kill root process %d: %w", pid, rootErr),
|
||||
)
|
||||
}
|
||||
return fmt.Errorf("kill process tree rooted at %d (root process killed directly): %w", pid, treeErr)
|
||||
}
|
||||
|
||||
func runTaskkill(pid int) error {
|
||||
return exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
|
||||
}
|
||||
|
||||
func killRootProcess(pid int) error {
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//go:build windows
|
||||
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKillProcessTreeRetainsTreeErrorWhenRootFallbackSucceeds(t *testing.T) {
|
||||
treeErr := errors.New("taskkill failed")
|
||||
err := killProcessTree(42, func(int) error { return treeErr }, func(int) error { return nil })
|
||||
if !errors.Is(err, treeErr) {
|
||||
t.Fatalf("killProcessTree error = %v, want tree error", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "root process killed directly") {
|
||||
t.Fatalf("killProcessTree error = %q, want fallback context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKillProcessTreeJoinsTreeAndRootErrors(t *testing.T) {
|
||||
treeErr := errors.New("taskkill failed")
|
||||
rootErr := errors.New("root kill failed")
|
||||
err := killProcessTree(42, func(int) error { return treeErr }, func(int) error { return rootErr })
|
||||
if !errors.Is(err, treeErr) || !errors.Is(err, rootErr) {
|
||||
t.Fatalf("killProcessTree error = %v, want both failures", err)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@ const (
|
||||
// newer Anthropic models). Defined so a capability catalog that lists "max"
|
||||
// has a constant to map to; no curated model lists it yet.
|
||||
ReasoningEffortMax ReasoningEffort = "max"
|
||||
// ReasoningEffortUltra is advertised by models whose provider exposes an
|
||||
// additional tier above max.
|
||||
ReasoningEffortUltra ReasoningEffort = "ultra"
|
||||
)
|
||||
|
||||
type ModelCapability string
|
||||
@@ -410,7 +413,7 @@ func ValidRuntimeProviderKind(provider ProviderKind) bool {
|
||||
|
||||
func ValidReasoningEffort(effort ReasoningEffort) bool {
|
||||
switch effort {
|
||||
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax:
|
||||
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax, ReasoningEffortUltra:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Package pathjail confines file operations to a directory tree.
|
||||
//
|
||||
// It exists because the obvious guard does not work. Checking a path with
|
||||
// Lstat and then handing the same string to MkdirAll, CreateTemp, Rename or
|
||||
// Remove is two independent resolutions with a gap between them, and every
|
||||
// operation after the check re-resolves the ANCESTORS as well. A store that
|
||||
// verified only its own directory and file was therefore writable outside its
|
||||
// tree whenever any component above them was a link: the check passed, and the
|
||||
// syscall that followed went somewhere else.
|
||||
//
|
||||
// On Windows the same guard failed for a second, independent reason. A junction
|
||||
// is a reparse point but NOT a symlink, so os.Lstat reports it as ModeIrregular
|
||||
// with ModeSymlink clear, and a guard testing only ModeSymlink returns "not a
|
||||
// link" when pointed straight at one. Junctions also need no privilege to
|
||||
// create, unlike Windows symlinks, so this is reachable by exactly the
|
||||
// unprivileged user a workspace guard is meant to contain.
|
||||
//
|
||||
// The fix for both is to stop naming things by path. Open a handle on the root
|
||||
// once and address everything below it relative to that handle, so the kernel
|
||||
// resolves each component against an object that cannot be swapped underneath.
|
||||
// os.Root is that handle: it refuses any component that would escape, including
|
||||
// a Windows reparse point, and it is the same idea the sandbox uses for ACL
|
||||
// materialization.
|
||||
package pathjail
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrEscapes reports a target that is not inside the root it was confined to.
|
||||
// Returned before anything is opened, so a caller cannot act on the path first.
|
||||
var ErrEscapes = errors.New("path escapes its root")
|
||||
|
||||
// ErrReparse reports a link or reparse point where a real file or directory was
|
||||
// required.
|
||||
var ErrReparse = errors.New("refusing to write through a link")
|
||||
|
||||
// Open confines dir to root and returns a handle on root plus dir expressed
|
||||
// relative to it. The caller closes the handle.
|
||||
//
|
||||
// root is resolved normally, because it is the boundary the caller chose and
|
||||
// trusts (a workspace the operator opened, say, which may legitimately sit under
|
||||
// a link). Everything BELOW it is resolved against the returned handle and
|
||||
// cannot escape.
|
||||
func Open(root, dir string) (*os.Root, string, error) {
|
||||
// Only a genuinely EMPTY value is treated as missing; every other spelling is
|
||||
// used verbatim. Testing TrimSpace(...) == "" instead would retarget the very
|
||||
// paths this guards: " " is a legal directory name on unix, so trimming would
|
||||
// reject it as "no root" or silently swap dir for root, which is the same
|
||||
// retargeting bug as trimming "root " down to "root".
|
||||
if root == "" {
|
||||
return nil, "", fmt.Errorf("%w: no root to confine %q to", ErrEscapes, dir)
|
||||
}
|
||||
if dir == "" {
|
||||
dir = root
|
||||
}
|
||||
// Rel before Open, so an escaping target is refused without opening
|
||||
// anything. filepath.Rel is lexical, which is the right test here: it says
|
||||
// what the caller ASKED for, and the handle enforces what actually happens.
|
||||
relative, err := filepath.Rel(root, dir)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: %q against %q: %v", ErrEscapes, dir, root, err)
|
||||
}
|
||||
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return nil, "", fmt.Errorf("%w: %q is outside %q", ErrEscapes, dir, root)
|
||||
}
|
||||
// The root is created if it is missing, by ordinary pathname, because it is
|
||||
// the boundary the caller declared rather than anything being confined. The
|
||||
// stores this replaced called MkdirAll on the whole tree, so this creates
|
||||
// strictly less by pathname than before, and everything BELOW root is opened
|
||||
// relative to the handle.
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
return nil, "", fmt.Errorf("create %s: %w", root, err)
|
||||
}
|
||||
handle, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("open %s: %w", root, err)
|
||||
}
|
||||
return handle, relative, nil
|
||||
}
|
||||
|
||||
// RefuseReparse fails when relative names a link or reparse point.
|
||||
//
|
||||
// Absent is fine: a name that does not exist yet is what a create is for.
|
||||
//
|
||||
// This is belt to os.Root's braces. The handle already refuses to traverse a
|
||||
// reparse point, so this exists to give the FINAL component a clear error
|
||||
// instead of an "escapes from parent" one, and to keep the refusal explicit at
|
||||
// the point a reader looks for it.
|
||||
//
|
||||
// ModeIrregular is tested alongside ModeSymlink deliberately: that is what a
|
||||
// Windows junction reports, and a guard checking only ModeSymlink lets one
|
||||
// straight through.
|
||||
func RefuseReparse(handle *os.Root, relative string) error {
|
||||
// A trailing separator makes os.Root.Lstat resolve the terminal component on
|
||||
// Windows (doInRootAlwaysResolveTerminalSlash), so a junction named with a
|
||||
// trailing slash would be stat'd as its target and wave through as "not a
|
||||
// link". Strip it so the final component itself is the thing inspected.
|
||||
//
|
||||
// os.IsPathSeparator rather than a literal `/\` set, because backslash is a
|
||||
// separator only on Windows: on unix it is an ordinary filename character, and
|
||||
// trimming it would inspect "linked" when asked about a symlink genuinely
|
||||
// named `linked\` — reporting "not a link" for one that is.
|
||||
if trimmed := trimTrailingSeparators(relative); trimmed != "" {
|
||||
relative = trimmed
|
||||
}
|
||||
info, err := handle.Lstat(relative)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("inspect %s: %w", relative, err)
|
||||
}
|
||||
if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {
|
||||
return fmt.Errorf("%w: %s", ErrReparse, relative)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// trimTrailingSeparators drops the path separators at the end of a name, using
|
||||
// the ACTIVE platform's definition of one: `/` and `\` on Windows, `/` only on
|
||||
// unix, where a backslash is a legal character in a filename.
|
||||
func trimTrailingSeparators(path string) string {
|
||||
end := len(path)
|
||||
for end > 0 && os.IsPathSeparator(path[end-1]) {
|
||||
end--
|
||||
}
|
||||
return path[:end]
|
||||
}
|
||||
|
||||
// randomBytes fills b with cryptographic randomness. A variable rather than a
|
||||
// direct crypto/rand call so a test can force the name collision that exercises
|
||||
// the O_EXCL retry below — the branch that matters, and one a real random source
|
||||
// will not reach on demand.
|
||||
var randomBytes = rand.Read
|
||||
|
||||
// CreateTemp makes an O_EXCL temp file named prefix.<random>suffix inside dir,
|
||||
// relative to handle, and returns it with its relative path.
|
||||
//
|
||||
// O_EXCL rather than a fixed name: a link planted at a predictable temp path
|
||||
// between the check and the create is the same race this package exists to
|
||||
// close, and O_EXCL makes the kernel refuse it rather than us.
|
||||
func CreateTemp(handle *os.Root, dir, prefix, suffix string) (*os.File, string, error) {
|
||||
// prefix and suffix are NAME fragments, not path fragments. A separator or a
|
||||
// "."/".." in either would relocate the file out of dir — a prefix of
|
||||
// "../escaped", or a suffix of ".tmp/../evil" that also erases the random
|
||||
// component and with it the O_EXCL unpredictability the file relies on.
|
||||
// Reject them up front so the file lands where dir promises.
|
||||
for _, fragment := range []string{prefix, suffix} {
|
||||
if strings.ContainsAny(fragment, `/\`) || fragment == "." || fragment == ".." {
|
||||
return nil, "", fmt.Errorf("%w: temporary name fragment %q must be a single path component", ErrEscapes, fragment)
|
||||
}
|
||||
}
|
||||
for attempt := 0; attempt < 10; attempt++ {
|
||||
noise := make([]byte, 8)
|
||||
if _, err := randomBytes(noise); err != nil {
|
||||
return nil, "", fmt.Errorf("generate a temporary name: %w", err)
|
||||
}
|
||||
relative := filepath.Join(dir, prefix+"."+hex.EncodeToString(noise)+suffix)
|
||||
file, err := handle.OpenFile(relative, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err == nil {
|
||||
return file, relative, nil
|
||||
}
|
||||
if errors.Is(err, fs.ErrExist) {
|
||||
continue
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
return nil, "", errors.New("could not create a temporary file with an unused name")
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package pathjail
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// linkDir points link at target, using whatever kind of link this platform lets
|
||||
// an unprivileged process create.
|
||||
//
|
||||
// On Windows that is a JUNCTION, not a symlink, and the difference is the whole
|
||||
// reason this package exists twice over. A symlink needs
|
||||
// SeCreateSymbolicLinkPrivilege, which an ordinary account does not hold, so a
|
||||
// test written with os.Symlink silently skips on Windows and proves nothing
|
||||
// there. A junction needs no privilege at all, so it is both the reachable
|
||||
// attack and the only one that can be tested.
|
||||
func linkDir(t *testing.T, target, link string) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil {
|
||||
t.Skipf("cannot create a junction: %v %s", err, out)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("cannot create a symlink: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The reported defect: a link in an ANCESTOR of the target, not at the target
|
||||
// itself. Every guard that checked only the final directory and file passed
|
||||
// this, because the components it checked were genuinely not links; the ones
|
||||
// the syscall then traversed were.
|
||||
func TestAnAncestorLinkCannotRedirectAWrite(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
outside := filepath.Join(base, "outside")
|
||||
workspace := filepath.Join(base, "workspace")
|
||||
for _, dir := range []string{outside, workspace} {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
linkDir(t, outside, filepath.Join(workspace, ".zero"))
|
||||
|
||||
handle, relative, err := Open(workspace, filepath.Join(workspace, ".zero", "store"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open should confine, not refuse outright: %v", err)
|
||||
}
|
||||
defer handle.Close()
|
||||
|
||||
if err := handle.MkdirAll(relative, 0o700); err == nil {
|
||||
t.Fatal("created a directory through a linked ancestor, so a write can still leave the workspace")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "store")); !os.IsNotExist(err) {
|
||||
t.Errorf("something was created outside the workspace, stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RefuseReparse has to catch a junction, which is the case os.ModeSymlink alone
|
||||
// misses. Asserted through the exported helper rather than by reading the mode,
|
||||
// so the test fails if the predicate is ever narrowed back.
|
||||
func TestRefuseReparseCatchesAJunctionNotJustASymlink(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
target := filepath.Join(base, "target")
|
||||
inside := filepath.Join(base, "inside")
|
||||
for _, dir := range []string{target, inside} {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
linkDir(t, target, filepath.Join(inside, "linked"))
|
||||
|
||||
handle, relative, err := Open(inside, inside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer handle.Close()
|
||||
|
||||
err = RefuseReparse(handle, filepath.Join(relative, "linked"))
|
||||
if !errors.Is(err, ErrReparse) {
|
||||
t.Fatalf("RefuseReparse(link) = %v, want ErrReparse; on Windows a junction reports ModeIrregular, so a ModeSymlink-only check returns nil here", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A name that does not exist yet is what a create is for, so it must not be
|
||||
// refused. Without this the guard above would be satisfied by refusing
|
||||
// everything.
|
||||
func TestRefuseReparseAllowsAnAbsentName(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
handle, relative, err := Open(base, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer handle.Close()
|
||||
if err := RefuseReparse(handle, filepath.Join(relative, "not-there.md")); err != nil {
|
||||
t.Errorf("an absent name was refused: %v", err)
|
||||
}
|
||||
if err := RefuseReparse(handle, relative); err != nil {
|
||||
t.Errorf("an ordinary directory was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Open refuses a target outside its root before opening anything, so a caller
|
||||
// cannot act on the path first.
|
||||
func TestOpenRefusesATargetOutsideTheRoot(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
inside := filepath.Join(base, "inside")
|
||||
if err := os.MkdirAll(inside, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, target := range []string{
|
||||
filepath.Join(base, "sibling"),
|
||||
filepath.Join(inside, "..", "..", "escape"),
|
||||
} {
|
||||
if _, _, err := Open(inside, target); !errors.Is(err, ErrEscapes) {
|
||||
t.Errorf("Open(%q, %q) = %v, want ErrEscapes", inside, target, err)
|
||||
}
|
||||
}
|
||||
if _, _, err := Open("", filepath.Join(base, "anything")); !errors.Is(err, ErrEscapes) {
|
||||
t.Error("an empty root was accepted; a boundary is not optional")
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTemp must use O_EXCL and an unpredictable name, so a link planted at a
|
||||
// guessable temp path is refused by the kernel rather than followed.
|
||||
func TestCreateTempIsExclusiveAndUnpredictable(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
handle, relative, err := Open(base, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer handle.Close()
|
||||
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 8; i++ {
|
||||
file, name, err := CreateTemp(handle, relative, "note", ".tmp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file.Close()
|
||||
if seen[name] {
|
||||
t.Fatalf("CreateTemp reused the name %q, so two concurrent writes would stomp each other", name)
|
||||
}
|
||||
seen[name] = true
|
||||
if filepath.Ext(name) != ".tmp" {
|
||||
t.Errorf("temp name %q lost its suffix, so a listing may pick it up as real content", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A legitimate directory whose name carries a space must be confined as spelled.
|
||||
// Trimming it would open a different filesystem object.
|
||||
//
|
||||
// A LEADING space, deliberately: Win32 normalizes a component's trailing spaces
|
||||
// and dots away, so "root " and "root" are one object there and a trailing-space
|
||||
// case cannot express the bug on Windows. A leading space survives on every
|
||||
// platform, so this runs everywhere rather than being gated to unix.
|
||||
// The path is RELATIVE, under t.Chdir, so the leading space sits at the start of
|
||||
// the whole string. An absolute filepath.Join(base, " root") would put the space
|
||||
// in the middle, where TrimSpace cannot reach it — the test would then pass
|
||||
// against the very guard it exists to catch, on every platform.
|
||||
func TestOpenPreservesSpaceInPaths(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
t.Chdir(base)
|
||||
const spaced = " root" // the leading space is part of the name
|
||||
|
||||
handle, relative, err := Open(spaced, spaced)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer handle.Close()
|
||||
|
||||
// Write through the handle, then confirm the bytes landed under the spelling
|
||||
// the caller asked for — the property that actually matters, and one
|
||||
// handle.Name() alone would not prove.
|
||||
file, _, err := CreateTemp(handle, relative, "probe", ".tmp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := file.Name()
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, spaced, filepath.Base(name))); err != nil {
|
||||
t.Errorf("a file created through the handle is not under %q: %v — the leading space was trimmed away", spaced, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, "root")); err == nil {
|
||||
t.Error(`a trimmed "root" was created instead of the requested " root"`)
|
||||
}
|
||||
}
|
||||
|
||||
// A whitespace-only name is a legal directory on unix, so it must be confined as
|
||||
// given rather than treated as "no root" or silently swapped for the root.
|
||||
func TestOpenAcceptsAWhitespaceOnlyComponent(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Win32 normalizes a whitespace-only component away; the name is not expressible there")
|
||||
}
|
||||
// The path must be whitespace-only IN FULL for this to bite, so it has to be
|
||||
// relative: an absolute path ending in " " still has a non-empty TrimSpace and
|
||||
// would sail past the old guard, making the test prove nothing.
|
||||
t.Chdir(t.TempDir())
|
||||
handle, _, err := Open(" ", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("a whitespace-only path was rejected as missing: %v", err)
|
||||
}
|
||||
defer handle.Close()
|
||||
if _, err := os.Stat(" "); err != nil {
|
||||
t.Errorf("the whitespace-only directory was not created as spelled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// prefix and suffix are name fragments. A separator or traversal in either must
|
||||
// be refused, or the file lands outside dir and (for a suffix erasing the random
|
||||
// component) loses the O_EXCL unpredictability it depends on.
|
||||
func TestCreateTempRejectsPathFragmentsThatEscapeTheDir(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
inside := filepath.Join(base, "inside")
|
||||
if err := os.MkdirAll(inside, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// root is base, dir is the subdirectory: a "../escaped" fragment lands in
|
||||
// base — still inside the os.Root, so os.Root does NOT catch it — but outside
|
||||
// the dir CreateTemp promised. This is the case the reviewers reproduced.
|
||||
handle, relative, err := Open(base, inside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer handle.Close()
|
||||
cases := []struct{ prefix, suffix string }{
|
||||
{"../escaped", ".tmp"},
|
||||
{`..\sibling`, ".tmp"},
|
||||
{"note", ".tmp/../evil"},
|
||||
{"note", ".."},
|
||||
{"..", ".tmp"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, _, err := CreateTemp(handle, relative, c.prefix, c.suffix); !errors.Is(err, ErrEscapes) {
|
||||
t.Errorf("CreateTemp(prefix=%q, suffix=%q) = %v, want ErrEscapes", c.prefix, c.suffix, err)
|
||||
}
|
||||
}
|
||||
// Nothing may have leaked into base above the promised dir.
|
||||
entries, _ := os.ReadDir(base)
|
||||
for _, entry := range entries {
|
||||
if entry.Name() != "inside" {
|
||||
t.Errorf("a temp fragment escaped into the parent: %q", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A junction named with a trailing separator must still be refused. On Windows
|
||||
// os.Root.Lstat resolves the terminal component when the name ends in a
|
||||
// separator, so without trimming it the link is stat'd as its target and waved
|
||||
// through.
|
||||
func TestRefuseReparseCatchesALinkNamedWithATrailingSeparator(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
target := filepath.Join(base, "target")
|
||||
inside := filepath.Join(base, "inside")
|
||||
for _, dir := range []string{target, inside} {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
linkDir(t, target, filepath.Join(inside, "linked"))
|
||||
handle, relative, err := Open(inside, inside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer handle.Close()
|
||||
name := filepath.Join(relative, "linked") + string(filepath.Separator)
|
||||
if err := RefuseReparse(handle, name); !errors.Is(err, ErrReparse) {
|
||||
t.Fatalf("RefuseReparse(%q) = %v, want ErrReparse", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The O_EXCL retry is the branch that protects an existing file, and a real
|
||||
// random source will not collide on demand — so force it. The first two draws
|
||||
// return the same bytes: the name is pre-created, CreateTemp must refuse to
|
||||
// reuse it, leave its contents alone, and come back with a different name.
|
||||
func TestCreateTempRetriesWithoutClobberingAnExistingName(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
handle, relative, err := Open(base, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer handle.Close()
|
||||
|
||||
draws := [][]byte{
|
||||
{1, 1, 1, 1, 1, 1, 1, 1}, // collides with the pre-created file
|
||||
{1, 1, 1, 1, 1, 1, 1, 1}, // and again, so the retry must keep trying
|
||||
{2, 2, 2, 2, 2, 2, 2, 2}, // finally a free name
|
||||
}
|
||||
previous := randomBytes
|
||||
t.Cleanup(func() { randomBytes = previous })
|
||||
call := 0
|
||||
randomBytes = func(b []byte) (int, error) {
|
||||
copy(b, draws[min(call, len(draws)-1)])
|
||||
call++
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
taken := filepath.Join(base, "note."+hex.EncodeToString(draws[0])+".tmp")
|
||||
if err := os.WriteFile(taken, []byte("do not clobber me"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
file, name, err := CreateTemp(handle, relative, "note", ".tmp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if filepath.Base(name) == filepath.Base(taken) {
|
||||
t.Fatalf("CreateTemp returned the name that was already taken (%q), so O_EXCL did not protect it", name)
|
||||
}
|
||||
kept, err := os.ReadFile(taken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(kept) != "do not clobber me" {
|
||||
t.Errorf("the pre-existing file was overwritten: %q", kept)
|
||||
}
|
||||
if call < 2 {
|
||||
t.Errorf("the random source was drawn %d time(s); the collision path never ran", call)
|
||||
}
|
||||
}
|
||||
|
||||
// On unix a backslash is an ordinary filename character, not a separator, so a
|
||||
// link genuinely named `linked\` must still be refused. Trimming backslashes
|
||||
// unconditionally would inspect "linked" instead and report "not a link" for one
|
||||
// that is.
|
||||
func TestRefuseReparseKeepsABackslashInAUnixName(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip(`backslash is a separator on Windows, so this name is not expressible there`)
|
||||
}
|
||||
base := t.TempDir()
|
||||
target := filepath.Join(base, "target")
|
||||
inside := filepath.Join(base, "inside")
|
||||
for _, dir := range []string{target, inside} {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
linkDir(t, target, filepath.Join(inside, `linked\`))
|
||||
|
||||
handle, relative, err := Open(inside, inside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer handle.Close()
|
||||
|
||||
name := filepath.Join(relative, `linked\`)
|
||||
if err := RefuseReparse(handle, name); !errors.Is(err, ErrReparse) {
|
||||
t.Fatalf("RefuseReparse(%q) = %v, want ErrReparse — the backslash is part of the name here, not a separator", name, err)
|
||||
}
|
||||
}
|
||||
@@ -8,17 +8,21 @@ import (
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
ID string
|
||||
Description string
|
||||
ContextWindow int
|
||||
ToolCall bool
|
||||
Reasoning bool
|
||||
InputModalities []string
|
||||
OutputModalities []string
|
||||
InputCost float64
|
||||
OutputCost float64
|
||||
Tags []string
|
||||
Source string
|
||||
ID string
|
||||
Description string
|
||||
ContextWindow int
|
||||
ToolCall bool
|
||||
Reasoning bool
|
||||
ReasoningEfforts []string
|
||||
DefaultReasoningEffort string
|
||||
ServiceTiers []string
|
||||
DefaultServiceTier string
|
||||
InputModalities []string
|
||||
OutputModalities []string
|
||||
InputCost float64
|
||||
OutputCost float64
|
||||
Tags []string
|
||||
Source string
|
||||
}
|
||||
|
||||
const minimaxModelSource = "https://platform.minimax.io/docs/api-reference/api-overview"
|
||||
@@ -270,6 +274,8 @@ func dedupeModels(defaultModel string, models []Model) []Model {
|
||||
}
|
||||
model.InputModalities = append([]string{}, model.InputModalities...)
|
||||
model.OutputModalities = append([]string{}, model.OutputModalities...)
|
||||
model.ReasoningEfforts = append([]string{}, model.ReasoningEfforts...)
|
||||
model.ServiceTiers = append([]string{}, model.ServiceTiers...)
|
||||
model.Tags = append([]string{}, model.Tags...)
|
||||
seen[model.ID] = true
|
||||
result = append(result, model)
|
||||
|
||||
@@ -122,6 +122,25 @@ func TestModelsDoNotAliasMutableCatalogState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeModelsDoesNotAliasCapabilitySlices(t *testing.T) {
|
||||
models := []Model{{
|
||||
ID: "reasoner",
|
||||
ReasoningEfforts: []string{"low", "high"},
|
||||
ServiceTiers: []string{"priority"},
|
||||
}}
|
||||
first := dedupeModels("reasoner", models)
|
||||
first[0].ReasoningEfforts[0] = "mutated"
|
||||
first[0].ServiceTiers[0] = "mutated"
|
||||
|
||||
second := dedupeModels("reasoner", models)
|
||||
if got, want := second[0].ReasoningEfforts[0], "low"; got != want {
|
||||
t.Fatalf("reasoning efforts alias source state: got %q, want %q", got, want)
|
||||
}
|
||||
if got, want := second[0].ServiceTiers[0], "priority"; got != want {
|
||||
t.Fatalf("service tiers alias source state: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func modelIDs(models []Model) []string {
|
||||
ids := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
|
||||
@@ -230,6 +230,11 @@ type remoteModel struct {
|
||||
Tools bool `json:"tools"`
|
||||
// ReasoningRaw accepts both a boolean (models.dev) and OpenRouter's object form.
|
||||
ReasoningRaw json.RawMessage `json:"reasoning"`
|
||||
ReasoningEfforts []string `json:"reasoning_efforts"`
|
||||
DefaultReasoning string `json:"default_reasoning_effort"`
|
||||
ServiceTiers []string `json:"service_tiers"`
|
||||
AdditionalSpeed []string `json:"additional_speed_tiers"`
|
||||
DefaultServiceTier string `json:"default_service_tier"`
|
||||
Free bool `json:"free"`
|
||||
IsFree bool `json:"is_free"`
|
||||
InputCost float64 `json:"input_cost"`
|
||||
@@ -309,6 +314,8 @@ func (model remoteModel) toModel(key string, source string) Model {
|
||||
containsFold(model.SupportedParameters, "reasoning") ||
|
||||
containsFold(model.SupportedParameters, "reasoning_effort") ||
|
||||
containsFold(model.SupportedParameters, "include_reasoning")
|
||||
reasoningEfforts := model.supportedReasoningEfforts()
|
||||
serviceTiers := normalizedServiceTiers(model.ServiceTiers, model.AdditionalSpeed)
|
||||
|
||||
// Prefer the short display name for aggregator catalogs (OpenRouter's
|
||||
// description field is multi-sentence marketing copy). models.dev and
|
||||
@@ -319,20 +326,56 @@ func (model remoteModel) toModel(key string, source string) Model {
|
||||
}
|
||||
|
||||
return Model{
|
||||
ID: strings.TrimSpace(id),
|
||||
Description: description,
|
||||
ContextWindow: contextWindow,
|
||||
ToolCall: toolCall,
|
||||
Reasoning: reasoning,
|
||||
InputModalities: inputModalities,
|
||||
OutputModalities: outputModalities,
|
||||
InputCost: inputCost,
|
||||
OutputCost: outputCost,
|
||||
Tags: cleanStrings(model.Tags),
|
||||
Source: source,
|
||||
ID: strings.TrimSpace(id),
|
||||
Description: description,
|
||||
ContextWindow: contextWindow,
|
||||
ToolCall: toolCall,
|
||||
Reasoning: reasoning,
|
||||
ReasoningEfforts: reasoningEfforts,
|
||||
DefaultReasoningEffort: strings.TrimSpace(model.DefaultReasoning),
|
||||
ServiceTiers: serviceTiers,
|
||||
DefaultServiceTier: normalizeServiceTier(model.DefaultServiceTier),
|
||||
InputModalities: inputModalities,
|
||||
OutputModalities: outputModalities,
|
||||
InputCost: inputCost,
|
||||
OutputCost: outputCost,
|
||||
Tags: cleanStrings(model.Tags),
|
||||
Source: source,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedServiceTiers(tiers []string, legacy []string) []string {
|
||||
values := append(append([]string{}, tiers...), legacy...)
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = normalizeServiceTier(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeServiceTier(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "fast" {
|
||||
return "priority"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (model remoteModel) supportedReasoningEfforts() []string {
|
||||
efforts := append([]string{}, model.ReasoningEfforts...)
|
||||
var info remoteReasoningInfo
|
||||
if err := json.Unmarshal(bytes.TrimSpace(model.ReasoningRaw), &info); err == nil {
|
||||
efforts = append(efforts, info.Supported...)
|
||||
}
|
||||
return cleanStrings(efforts)
|
||||
}
|
||||
|
||||
func (model remoteModel) supportsReasoning() bool {
|
||||
raw := bytes.TrimSpace(model.ReasoningRaw)
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
|
||||
@@ -19,20 +19,27 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
)
|
||||
|
||||
const anthropicVersion = "2023-06-01"
|
||||
const (
|
||||
anthropicVersion = "2023-06-01"
|
||||
chatGPTModelsProtocolVersion = "0.146.0"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
ID string
|
||||
Description string
|
||||
ContextWindow int
|
||||
ToolCall bool
|
||||
Reasoning bool
|
||||
InputModalities []string
|
||||
OutputModalities []string
|
||||
InputCost float64
|
||||
OutputCost float64
|
||||
Tags []string
|
||||
Source string
|
||||
ID string
|
||||
Description string
|
||||
ContextWindow int
|
||||
ToolCall bool
|
||||
Reasoning bool
|
||||
ReasoningEfforts []string
|
||||
DefaultReasoningEffort string
|
||||
ServiceTiers []string
|
||||
DefaultServiceTier string
|
||||
InputModalities []string
|
||||
OutputModalities []string
|
||||
InputCost float64
|
||||
OutputCost float64
|
||||
Tags []string
|
||||
Source string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
@@ -43,6 +50,7 @@ type Options struct {
|
||||
OAuthResolver providerio.TokenResolver
|
||||
CodexAccountResolver openai.CodexAccountResolver
|
||||
UserAgent string
|
||||
ClientVersion string
|
||||
}
|
||||
|
||||
func DiscoverCatalog(ctx context.Context, provider providercatalog.Descriptor, profile config.ProviderProfile, options Options) ([]Model, error) {
|
||||
@@ -51,6 +59,7 @@ func DiscoverCatalog(ctx context.Context, provider providercatalog.Descriptor, p
|
||||
// without credentials so the picker stays current before a key is entered.
|
||||
canProbeProvider := modelDiscoveryAllowed(profile) && (!provider.RequiresAuth ||
|
||||
discoveryHasCredential(profile) ||
|
||||
options.OAuthResolver != nil ||
|
||||
publicLiveCatalogProvider(provider, profile))
|
||||
if canProbeProvider {
|
||||
liveModels, liveErr := Discover(ctx, profile, options)
|
||||
@@ -204,6 +213,18 @@ func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, o
|
||||
}
|
||||
var configure func(*http.Request)
|
||||
if providercatalog.NormalizeID(profile.CatalogID) == "chatgpt" {
|
||||
parsed, parseErr := url.Parse(endpoint)
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("parse ChatGPT models endpoint: %w", parseErr)
|
||||
}
|
||||
clientVersion := strings.TrimSpace(options.ClientVersion)
|
||||
if clientVersion == "" {
|
||||
clientVersion = chatGPTModelsProtocolVersion
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("client_version", clientVersion)
|
||||
parsed.RawQuery = query.Encode()
|
||||
endpoint = parsed.String()
|
||||
configure = func(request *http.Request) {
|
||||
account := ""
|
||||
if options.CodexAccountResolver != nil {
|
||||
@@ -326,24 +347,39 @@ func anthropicModelsEndpoint(baseURL string) (string, error) {
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
type modelsResponseItem struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Visibility string `json:"visibility"`
|
||||
ContextWindow int `json:"context_window"`
|
||||
ContextWindowAlt int `json:"contextWindow"`
|
||||
ContextLength int `json:"context_length"`
|
||||
MaxContextLength int `json:"max_context_length"`
|
||||
Free bool `json:"free"`
|
||||
IsFree bool `json:"is_free"`
|
||||
ToolCall bool `json:"tool_call"`
|
||||
ToolCallCamel bool `json:"toolCall"`
|
||||
Tools bool `json:"tools"`
|
||||
Reasoning bool `json:"reasoning"`
|
||||
ReasoningEfforts []string `json:"reasoning_efforts"`
|
||||
DefaultReasoning string `json:"default_reasoning_level"`
|
||||
SupportedReasoning []struct {
|
||||
Effort string `json:"effort"`
|
||||
} `json:"supported_reasoning_levels"`
|
||||
ServiceTiers []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"service_tiers"`
|
||||
AdditionalSpeedTiers []string `json:"additional_speed_tiers"`
|
||||
DefaultServiceTier string `json:"default_service_tier"`
|
||||
SupportedParameters []string `json:"supported_parameters"`
|
||||
}
|
||||
|
||||
type modelsResponse struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ContextWindow int `json:"context_window"`
|
||||
ContextWindowAlt int `json:"contextWindow"`
|
||||
ContextLength int `json:"context_length"`
|
||||
MaxContextLength int `json:"max_context_length"`
|
||||
Free bool `json:"free"`
|
||||
IsFree bool `json:"is_free"`
|
||||
ToolCall bool `json:"tool_call"`
|
||||
ToolCallCamel bool `json:"toolCall"`
|
||||
Tools bool `json:"tools"`
|
||||
Reasoning bool `json:"reasoning"`
|
||||
SupportedParameters []string `json:"supported_parameters"`
|
||||
} `json:"data"`
|
||||
Data []modelsResponseItem `json:"data"`
|
||||
Models []modelsResponseItem `json:"models"`
|
||||
}
|
||||
|
||||
func parseModelsResponse(body []byte) ([]Model, error) {
|
||||
@@ -352,12 +388,21 @@ func parseModelsResponse(body []byte) ([]Model, error) {
|
||||
return nil, fmt.Errorf("decode models response: %w", err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
models := make([]Model, 0, len(payload.Data))
|
||||
for _, item := range payload.Data {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
items := payload.Data
|
||||
if len(items) == 0 {
|
||||
items = payload.Models
|
||||
}
|
||||
models := make([]Model, 0, len(items))
|
||||
for _, item := range items {
|
||||
id := firstNonEmptyDiscovery(item.ID, item.Slug)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
// The ChatGPT catalog includes hidden/internal entries alongside picker
|
||||
// models. Generic OpenAI-compatible responses omit visibility entirely.
|
||||
if visibility := strings.ToLower(strings.TrimSpace(item.Visibility)); visibility != "" && visibility != "list" {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
description := firstNonEmptyDiscovery(
|
||||
item.DisplayName,
|
||||
@@ -381,13 +426,19 @@ func parseModelsResponse(body []byte) ([]Model, error) {
|
||||
discoveryContainsFold(item.SupportedParameters, "reasoning") ||
|
||||
discoveryContainsFold(item.SupportedParameters, "reasoning_effort") ||
|
||||
discoveryContainsFold(item.SupportedParameters, "include_reasoning")
|
||||
efforts := reasoningEfforts(item)
|
||||
serviceTiers := modelServiceTiers(item)
|
||||
models = append(models, Model{
|
||||
ID: id,
|
||||
Description: description,
|
||||
ContextWindow: contextWindow,
|
||||
ToolCall: toolCall,
|
||||
Reasoning: reasoning,
|
||||
Source: "live",
|
||||
ID: id,
|
||||
Description: description,
|
||||
ContextWindow: contextWindow,
|
||||
ToolCall: toolCall,
|
||||
Reasoning: reasoning || len(efforts) > 0,
|
||||
ReasoningEfforts: efforts,
|
||||
DefaultReasoningEffort: strings.TrimSpace(item.DefaultReasoning),
|
||||
ServiceTiers: serviceTiers,
|
||||
DefaultServiceTier: normalizeServiceTier(item.DefaultServiceTier),
|
||||
Source: "live",
|
||||
})
|
||||
}
|
||||
sort.SliceStable(models, func(i, j int) bool {
|
||||
@@ -399,6 +450,50 @@ func parseModelsResponse(body []byte) ([]Model, error) {
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func modelServiceTiers(item modelsResponseItem) []string {
|
||||
values := append([]string{}, item.AdditionalSpeedTiers...)
|
||||
for _, tier := range item.ServiceTiers {
|
||||
values = append(values, tier.ID)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = normalizeServiceTier(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeServiceTier(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "fast" {
|
||||
return "priority"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func reasoningEfforts(item modelsResponseItem) []string {
|
||||
efforts := append([]string{}, item.ReasoningEfforts...)
|
||||
for _, level := range item.SupportedReasoning {
|
||||
efforts = append(efforts, level.Effort)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(efforts))
|
||||
for _, effort := range efforts {
|
||||
effort = strings.ToLower(strings.TrimSpace(effort))
|
||||
if effort == "" || seen[effort] {
|
||||
continue
|
||||
}
|
||||
seen[effort] = true
|
||||
result = append(result, effort)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fetchCatalogModels(ctx context.Context, provider providercatalog.Descriptor, options Options) ([]Model, error) {
|
||||
models, err := providermodelcatalog.FetchRemote(ctx, provider, providermodelcatalog.FetchOptions{
|
||||
HTTPClient: options.HTTPClient,
|
||||
@@ -416,17 +511,21 @@ func modelsFromCatalog(models []providermodelcatalog.Model) []Model {
|
||||
result := make([]Model, 0, len(models))
|
||||
for _, model := range models {
|
||||
result = append(result, Model{
|
||||
ID: model.ID,
|
||||
Description: model.Description,
|
||||
ContextWindow: model.ContextWindow,
|
||||
ToolCall: model.ToolCall,
|
||||
Reasoning: model.Reasoning,
|
||||
InputModalities: append([]string{}, model.InputModalities...),
|
||||
OutputModalities: append([]string{}, model.OutputModalities...),
|
||||
InputCost: model.InputCost,
|
||||
OutputCost: model.OutputCost,
|
||||
Tags: append([]string{}, model.Tags...),
|
||||
Source: model.Source,
|
||||
ID: model.ID,
|
||||
Description: model.Description,
|
||||
ContextWindow: model.ContextWindow,
|
||||
ToolCall: model.ToolCall,
|
||||
Reasoning: model.Reasoning,
|
||||
ReasoningEfforts: append([]string{}, model.ReasoningEfforts...),
|
||||
DefaultReasoningEffort: model.DefaultReasoningEffort,
|
||||
ServiceTiers: append([]string{}, model.ServiceTiers...),
|
||||
DefaultServiceTier: model.DefaultServiceTier,
|
||||
InputModalities: append([]string{}, model.InputModalities...),
|
||||
OutputModalities: append([]string{}, model.OutputModalities...),
|
||||
InputCost: model.InputCost,
|
||||
OutputCost: model.OutputCost,
|
||||
Tags: append([]string{}, model.Tags...),
|
||||
Source: model.Source,
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -440,7 +539,8 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
|
||||
hasCatalog := len(byID) > 0
|
||||
// Aggregators publish the live list as the source of truth. Keep live-only
|
||||
// ids even when a remote catalog also loaded, instead of intersecting.
|
||||
preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID)
|
||||
preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID) ||
|
||||
providercatalog.NormalizeID(provider.ID) == "chatgpt"
|
||||
result := make([]Model, 0, len(liveModels))
|
||||
for _, live := range liveModels {
|
||||
if catalog, ok := byID[live.ID]; ok {
|
||||
@@ -458,6 +558,21 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
|
||||
if strings.TrimSpace(catalog.Description) == "" && strings.TrimSpace(live.Description) != "" {
|
||||
catalog.Description = live.Description
|
||||
}
|
||||
if len(live.ReasoningEfforts) > 0 {
|
||||
catalog.ReasoningEfforts = append([]string{}, live.ReasoningEfforts...)
|
||||
}
|
||||
if live.DefaultReasoningEffort != "" {
|
||||
catalog.DefaultReasoningEffort = live.DefaultReasoningEffort
|
||||
}
|
||||
if live.Reasoning || len(live.ReasoningEfforts) > 0 || live.DefaultReasoningEffort != "" {
|
||||
catalog.Reasoning = true
|
||||
}
|
||||
if len(live.ServiceTiers) > 0 {
|
||||
catalog.ServiceTiers = append([]string{}, live.ServiceTiers...)
|
||||
}
|
||||
if live.DefaultServiceTier != "" {
|
||||
catalog.DefaultServiceTier = live.DefaultServiceTier
|
||||
}
|
||||
catalog.Source = firstDiscoverySource(catalog.Source, "live")
|
||||
result = append(result, catalog)
|
||||
continue
|
||||
@@ -526,17 +641,21 @@ func liveModelAllowedWithoutCatalog(provider providercatalog.Descriptor, id stri
|
||||
|
||||
func catalogModelFromDiscovery(model Model) providermodelcatalog.Model {
|
||||
return providermodelcatalog.Model{
|
||||
ID: model.ID,
|
||||
Description: model.Description,
|
||||
ContextWindow: model.ContextWindow,
|
||||
ToolCall: model.ToolCall,
|
||||
Reasoning: model.Reasoning,
|
||||
InputModalities: append([]string{}, model.InputModalities...),
|
||||
OutputModalities: append([]string{}, model.OutputModalities...),
|
||||
InputCost: model.InputCost,
|
||||
OutputCost: model.OutputCost,
|
||||
Tags: append([]string{}, model.Tags...),
|
||||
Source: model.Source,
|
||||
ID: model.ID,
|
||||
Description: model.Description,
|
||||
ContextWindow: model.ContextWindow,
|
||||
ToolCall: model.ToolCall,
|
||||
Reasoning: model.Reasoning,
|
||||
ReasoningEfforts: append([]string{}, model.ReasoningEfforts...),
|
||||
DefaultReasoningEffort: model.DefaultReasoningEffort,
|
||||
ServiceTiers: append([]string{}, model.ServiceTiers...),
|
||||
DefaultServiceTier: model.DefaultServiceTier,
|
||||
InputModalities: append([]string{}, model.InputModalities...),
|
||||
OutputModalities: append([]string{}, model.OutputModalities...),
|
||||
InputCost: model.InputCost,
|
||||
OutputCost: model.OutputCost,
|
||||
Tags: append([]string{}, model.Tags...),
|
||||
Source: model.Source,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,90 @@ func TestParseModelsResponseCapturesContextAndFree(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelsResponseSupportsChatGPTCatalog(t *testing.T) {
|
||||
models, err := parseModelsResponse([]byte(`{
|
||||
"models": [
|
||||
{"slug":"gpt-5.6-sol","display_name":"GPT-5.6 Sol","description":"Frontier coding model","visibility":"list","context_window":400000,"default_reasoning_level":"high","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"}],"service_tiers":[{"id":"priority"}],"default_service_tier":"standard"},
|
||||
{"slug":"gpt-5.6-terra","display_name":"GPT-5.6 Terra","visibility":"list"},
|
||||
{"slug":"internal-router","display_name":"Internal","visibility":"hide"},
|
||||
{"slug":"server-only","display_name":"Server only","visibility":"none"}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseModelsResponse: %v", err)
|
||||
}
|
||||
if got, want := modelIDs(models), []string{"gpt-5.6-sol", "gpt-5.6-terra"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("models = %#v, want visible ChatGPT models %#v", got, want)
|
||||
}
|
||||
if models[0].Description != "GPT-5.6 Sol" || models[0].ContextWindow != 400000 {
|
||||
t.Fatalf("ChatGPT metadata = %#v", models[0])
|
||||
}
|
||||
if got := strings.Join(models[0].ReasoningEfforts, ","); got != "low,medium,high,xhigh" {
|
||||
t.Fatalf("reasoning efforts = %q", got)
|
||||
}
|
||||
if models[0].DefaultReasoningEffort != "high" || !models[0].Reasoning {
|
||||
t.Fatalf("reasoning metadata = %#v", models[0])
|
||||
}
|
||||
if got := strings.Join(models[0].ServiceTiers, ","); got != "priority" || models[0].DefaultServiceTier != "standard" {
|
||||
t.Fatalf("service tier metadata = %#v", models[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelsResponseNormalizesLegacyFastTier(t *testing.T) {
|
||||
models, err := parseModelsResponse([]byte(`{"data":[{"id":"gpt-test","additional_speed_tiers":["fast","priority"]}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseModelsResponse: %v", err)
|
||||
}
|
||||
if got := strings.Join(models[0].ServiceTiers, ","); got != "priority" {
|
||||
t.Fatalf("service tiers = %q, want priority", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeChatGPTModelsKeepsLiveOnlyEntries(t *testing.T) {
|
||||
models := mergeLiveModels(
|
||||
providercatalog.Descriptor{ID: "chatgpt"},
|
||||
[]Model{
|
||||
{ID: "gpt-5.5", ReasoningEfforts: []string{"low", "high"}, ServiceTiers: []string{"priority"}},
|
||||
{ID: "gpt-5.6-sol", ReasoningEfforts: []string{"low", "ultra"}},
|
||||
},
|
||||
[]Model{{ID: "gpt-5.5", Description: "fallback"}},
|
||||
)
|
||||
if got, want := strings.Join(modelIDs(models), ","), "gpt-5.5,gpt-5.6-sol"; got != want {
|
||||
t.Fatalf("models = %q, want %q", got, want)
|
||||
}
|
||||
if got := strings.Join(models[0].ReasoningEfforts, ","); got != "low,high" {
|
||||
t.Fatalf("merged reasoning efforts = %q", got)
|
||||
}
|
||||
if got := strings.Join(models[0].ServiceTiers, ","); got != "priority" {
|
||||
t.Fatalf("merged service tiers = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
|
||||
models := mergeLiveModels(
|
||||
providercatalog.Descriptor{ID: "chatgpt"},
|
||||
[]Model{{
|
||||
ID: "gpt-live",
|
||||
Reasoning: true,
|
||||
DefaultReasoningEffort: "high",
|
||||
DefaultServiceTier: "priority",
|
||||
}},
|
||||
[]Model{{
|
||||
ID: "gpt-live",
|
||||
Reasoning: false,
|
||||
DefaultReasoningEffort: "low",
|
||||
DefaultServiceTier: "standard",
|
||||
}},
|
||||
)
|
||||
if len(models) != 1 {
|
||||
t.Fatalf("merged models = %#v, want one model", models)
|
||||
}
|
||||
got := models[0]
|
||||
if !got.Reasoning || got.DefaultReasoningEffort != "high" || got.DefaultServiceTier != "priority" {
|
||||
t.Fatalf("merged live metadata = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) {
|
||||
// Catalog and live endpoints return distinct payloads so the merge must keep
|
||||
// live-only ids that are absent from the remote catalog response.
|
||||
@@ -206,6 +290,9 @@ func TestDiscoverChatGPTModelsUsesOAuthAndCodexHeaders(t *testing.T) {
|
||||
if got := r.URL.Path; got != "/backend-api/codex/models" {
|
||||
t.Errorf("path = %q, want Codex models endpoint", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("client_version"); got != chatGPTModelsProtocolVersion {
|
||||
t.Errorf("client_version = %q, want %q", got, chatGPTModelsProtocolVersion)
|
||||
}
|
||||
wantToken := "Bearer old-token"
|
||||
wantAccount := "old-account"
|
||||
if requests == 2 {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
)
|
||||
|
||||
// OAuthLoginForProfile resolves the OAuth login used by profile once and
|
||||
// returns both its bearer resolver and credential-store key. Keeping the key
|
||||
// alongside the resolver lets auxiliary requests derive account-scoped headers
|
||||
// from the same login that supplies the bearer.
|
||||
func OAuthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
|
||||
candidates := profile.OAuthLoginCandidates()
|
||||
if len(candidates) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
return nil, ""
|
||||
}
|
||||
_, key, ok := oauth.FirstStored(store, candidates)
|
||||
if !ok {
|
||||
return nil, ""
|
||||
}
|
||||
manager, err := oauth.NewManager(oauth.ManagerOptions{
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
AllowPresets: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ""
|
||||
}
|
||||
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
|
||||
var token string
|
||||
var resolveErr error
|
||||
if forceRefresh {
|
||||
token, resolveErr = manager.Handle401(ctx, key)
|
||||
} else {
|
||||
token, resolveErr = manager.GetFresh(ctx, key)
|
||||
}
|
||||
if errors.Is(resolveErr, oauth.ErrNoToken) {
|
||||
return "", "", false, nil
|
||||
}
|
||||
if resolveErr != nil {
|
||||
return "", "", false, resolveErr
|
||||
}
|
||||
return "Authorization", "Bearer " + token, true, nil
|
||||
}
|
||||
return resolver, key
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
func TestOAuthLoginForProfileBindsBearerAndAccountToSameLogin(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("oauth store: %v", err)
|
||||
}
|
||||
key := oauth.ProviderKey("chatgpt")
|
||||
if err := store.Save(key, oauth.Token{
|
||||
AccessToken: "subscription-token",
|
||||
Account: "account-42",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("save OAuth token: %v", err)
|
||||
}
|
||||
|
||||
resolver, loginKey := OAuthLoginForProfile(config.ProviderProfile{Name: "codex", CatalogID: "chatgpt"})
|
||||
if resolver == nil || loginKey != key {
|
||||
t.Fatalf("OAuth login = (%v, %q), want resolver bound to %q", resolver != nil, loginKey, key)
|
||||
}
|
||||
header, value, ok, err := resolver(context.Background(), false)
|
||||
if err != nil || !ok || header != "Authorization" || value != "Bearer subscription-token" {
|
||||
t.Fatalf("bearer resolution = (%q, %q, %v, %v)", header, value, ok, err)
|
||||
}
|
||||
account, ok, err := CodexAccountResolverForLogin(loginKey)(context.Background())
|
||||
if err != nil || !ok || account != "account-42" {
|
||||
t.Fatalf("account resolution = (%q, %v, %v)", account, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthLoginForProfileReturnsNoResolverWithoutUsableLogin(t *testing.T) {
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", filepath.Join(t.TempDir(), "oauth-tokens.json"))
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
profile config.ProviderProfile
|
||||
}{
|
||||
{
|
||||
name: "profile with its own credential has no OAuth candidates",
|
||||
profile: config.ProviderProfile{Name: "openai", CatalogID: "openai", APIKey: "sk-configured"},
|
||||
},
|
||||
{
|
||||
name: "candidate has no stored login",
|
||||
profile: config.ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
resolver, key := OAuthLoginForProfile(test.profile)
|
||||
if resolver != nil || key != "" {
|
||||
t.Fatalf("OAuthLoginForProfile() = (%v, %q), want (nil, empty)", resolver != nil, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ type responsesRequest struct {
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
Tools []responsesTool `json:"tools,omitempty"`
|
||||
Reasoning *responsesReasoning `json:"reasoning,omitempty"`
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
}
|
||||
|
||||
// responsesReasoning carries the reasoning controls for the Responses API. The
|
||||
@@ -265,6 +266,9 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
|
||||
// long thinking phase shows live progress instead of looking hung.
|
||||
req.Reasoning = &responsesReasoning{Effort: effort, Summary: "auto"}
|
||||
}
|
||||
if tier := openAIServiceTier(request.ServiceTier); tier != "" {
|
||||
req.ServiceTier = tier
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -492,6 +492,49 @@ func TestCodexProviderForwardsReasoningEffort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderNormalizesServiceTier(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "priority", input: "priority", want: "priority"},
|
||||
{name: "flex", input: "flex", want: "flex"},
|
||||
{name: "unsupported omitted", input: "standard", want: ""},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var rec codexRequest
|
||||
srv := newCodexTestServer(t, &rec)
|
||||
defer srv.Close()
|
||||
|
||||
provider, err := NewCodexProvider(CodexOptions{
|
||||
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5-codex"},
|
||||
AccountID: "acc-x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodexProvider: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
|
||||
ServiceTier: test.input,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion: %v", err)
|
||||
}
|
||||
drainCodexEvents(t, stream)
|
||||
if test.want == "" {
|
||||
if _, ok := rec.body["service_tier"]; ok {
|
||||
t.Fatalf("body.service_tier = %#v, want omitted", rec.body["service_tier"])
|
||||
}
|
||||
return
|
||||
}
|
||||
if got := rec.body["service_tier"]; got != test.want {
|
||||
t.Fatalf("body.service_tier = %#v, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderStreamsReasoningSummaryDeltas(t *testing.T) {
|
||||
// reasoning_summary_text deltas must surface as StreamEventReasoning (live
|
||||
// "thinking"), in order, alongside the normal text output. Without this a long
|
||||
|
||||
@@ -467,6 +467,9 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
|
||||
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
|
||||
mapped.ReasoningEffort = effort
|
||||
}
|
||||
if tier := openAIServiceTier(request.ServiceTier); tier != "" {
|
||||
mapped.ServiceTier = tier
|
||||
}
|
||||
// prompt_cache_key is a documented OpenAI parameter for server-side prefix
|
||||
// cache routing. Official OpenAI accepts it; many openai-compatible
|
||||
// gateways (NVIDIA NIM, strict local proxies) reject unknown fields with a
|
||||
@@ -505,7 +508,16 @@ func promptCacheKeyDisabled() bool {
|
||||
// is dropped rather than risking a 400 on an unrecognized enum.
|
||||
func openAIReasoningEffort(requested string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(requested)) {
|
||||
case "minimal", "low", "medium", "high":
|
||||
case "minimal", "low", "medium", "high", "xhigh", "max", "ultra":
|
||||
return strings.ToLower(strings.TrimSpace(requested))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func openAIServiceTier(requested string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(requested)) {
|
||||
case "priority", "flex":
|
||||
return strings.ToLower(strings.TrimSpace(requested))
|
||||
default:
|
||||
return ""
|
||||
|
||||
@@ -860,6 +860,51 @@ func TestStreamCompletionSendsReasoningEffort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionNormalizesServiceTier(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "priority", input: "priority", want: "priority"},
|
||||
{name: "flex with surrounding whitespace", input: " FlEx ", want: "flex"},
|
||||
{name: "unsupported omitted", input: "standard", want: ""},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeSSE(w, `[DONE]`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, err := New(Options{BaseURL: server.URL + "/", Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
|
||||
ServiceTier: test.input,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
if test.want == "" {
|
||||
if _, ok := gotBody["service_tier"]; ok {
|
||||
t.Fatalf("service_tier = %#v, want omitted", gotBody["service_tier"])
|
||||
}
|
||||
return
|
||||
}
|
||||
if got := gotBody["service_tier"]; got != test.want {
|
||||
t.Fatalf("service_tier = %#v, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionOmitsReasoningEffortWhenUnsetOrInvalid(t *testing.T) {
|
||||
for _, effort := range []string{"", "none", "bogus"} {
|
||||
var gotBody map[string]any
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package openai
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOpenAIReasoningEffortPreservesKnownProviderTiers(t *testing.T) {
|
||||
for _, effort := range []string{"minimal", "low", "medium", "high", "xhigh", "max", "ultra"} {
|
||||
if got := openAIReasoningEffort(effort); got != effort {
|
||||
t.Fatalf("openAIReasoningEffort(%q) = %q", effort, got)
|
||||
}
|
||||
}
|
||||
if got := openAIReasoningEffort("unknown"); got != "" {
|
||||
t.Fatalf("unknown effort = %q, want omitted", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIServiceTierNormalizesKnownValues(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: "priority", want: "priority"},
|
||||
{input: "flex", want: "flex"},
|
||||
{input: " PRIORITY ", want: "priority"},
|
||||
{input: "FlEx", want: "flex"},
|
||||
{input: "", want: ""},
|
||||
{input: "standard", want: ""},
|
||||
} {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
if got := openAIServiceTier(test.input); got != test.want {
|
||||
t.Fatalf("openAIServiceTier(%q) = %q, want %q", test.input, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -153,8 +153,8 @@ func (s *turnSession) observePrefix(ctx context.Context, request zeroruntime.Com
|
||||
// computeFingerprint digests the wire-affecting request parameters: the
|
||||
// session's model and max-tokens (fixed at construction — CompletionRequest
|
||||
// carries no model field), the normalized reasoning effort as it would appear
|
||||
// on the wire, the prompt-cache key (constant within a session, and part of
|
||||
// the request the provider serializes), and an order-preserving digest of the
|
||||
// on the wire, the normalized service tier, the prompt-cache key (constant
|
||||
// within a session, and part of the request the provider serializes), and an order-preserving digest of the
|
||||
// advertised tools (request serialization preserves tool order, so a reorder
|
||||
// changes the wire bytes and counts as drift). Messages are excluded — they
|
||||
// grow every turn; this fingerprints request parameters, not conversation
|
||||
@@ -175,6 +175,8 @@ func (s *turnSession) computeFingerprint(request zeroruntime.CompletionRequest)
|
||||
builder.WriteByte('|')
|
||||
builder.WriteString(openAIReasoningEffort(request.ReasoningEffort))
|
||||
builder.WriteByte('|')
|
||||
builder.WriteString(openAIServiceTier(request.ServiceTier))
|
||||
builder.WriteByte('|')
|
||||
builder.WriteString(request.PromptCacheKey)
|
||||
builder.WriteByte('|')
|
||||
builder.WriteString(wireToolsDigest(request.Tools))
|
||||
|
||||
@@ -231,11 +231,18 @@ func TestTurnSessionFingerprintDriftOnToolsAndEffort(t *testing.T) {
|
||||
// Unrecognized efforts normalize to omitted — same as empty.
|
||||
droppedEffort := session.computeFingerprint(zeroruntime.CompletionRequest{
|
||||
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
|
||||
ReasoningEffort: "xhigh",
|
||||
ReasoningEffort: "bogus",
|
||||
})
|
||||
if base != droppedEffort {
|
||||
t.Fatal("an effort the wire would omit must fingerprint like no effort")
|
||||
}
|
||||
changedTier := session.computeFingerprint(zeroruntime.CompletionRequest{
|
||||
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
|
||||
ServiceTier: "priority",
|
||||
})
|
||||
if base == changedTier {
|
||||
t.Fatal("fingerprint did not drift on a changed service tier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnSessionFingerprintDriftOnToolDescription(t *testing.T) {
|
||||
|
||||
@@ -6,6 +6,7 @@ type chatCompletionRequest struct {
|
||||
Tools []toolDefinition `json:"tools,omitempty"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
StreamOptions *streamOptions `json:"stream_options,omitempty"`
|
||||
// PromptCacheKey asks the backend to route the request to a replica that
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package providerio
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ScanSSEData parses Server-Sent Event data fields from a streaming response.
|
||||
func ScanSSEData(reader io.Reader, handle func(data string) bool) error {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 0, 4096), maxSSELineBytes)
|
||||
return scanSSEPayloads(scanner, handle, nil)
|
||||
}
|
||||
@@ -105,7 +105,7 @@ Review.`)
|
||||
_, err = ParseMarkdown(`---
|
||||
name: reviewer
|
||||
description: Reviews code
|
||||
reasoningEffort: ULTRA
|
||||
reasoningEffort: IMPOSSIBLE
|
||||
---
|
||||
Review.`)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown reasoning effort") {
|
||||
|
||||
@@ -0,0 +1,828 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/installtxn"
|
||||
)
|
||||
|
||||
const (
|
||||
manifestCacheAge = 10 * time.Minute
|
||||
rankingCacheAge = 10 * time.Minute
|
||||
maxManifestBytes = 2 << 20
|
||||
maxRankingBytes = 2 << 20
|
||||
maxPreviewBytes = 2 << 20
|
||||
maxSpriteBytes = 16 << 20
|
||||
maxPetJSONBytes = 1 << 20
|
||||
maxImageSide = 16384
|
||||
maxImagePixels = 50_000_000
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
RootDir string
|
||||
ManifestURL string
|
||||
RankingURL string
|
||||
HTTPClient *http.Client
|
||||
TrustedHosts map[string]bool
|
||||
// TrustedAssetHosts is narrower than TrustedHosts: catalog redirects may use
|
||||
// petdex.dev, while image and metadata downloads must stay on the asset host.
|
||||
TrustedAssetHosts map[string]bool
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type compactManifest struct {
|
||||
Version int `json:"v"`
|
||||
AssetBase string `json:"assetBase"`
|
||||
Fields []string `json:"fields"`
|
||||
Pets []json.RawMessage `json:"pets"`
|
||||
}
|
||||
|
||||
type installedMetadata struct {
|
||||
Entry
|
||||
InstalledAt time.Time `json:"installedAt"`
|
||||
}
|
||||
|
||||
type petMetadata struct {
|
||||
SpriteVersion int `json:"spriteVersionNumber"`
|
||||
Animations map[string]petAnimationMetadata `json:"animations"`
|
||||
Interactions struct {
|
||||
Click struct {
|
||||
Animations []string `json:"animations"`
|
||||
} `json:"click"`
|
||||
} `json:"interactions"`
|
||||
}
|
||||
|
||||
type petAnimationMetadata struct {
|
||||
SourceRow json.RawMessage `json:"sourceRow"`
|
||||
SourceRowIndex *int `json:"sourceRowIndex"`
|
||||
FrameCount int `json:"frameCount"`
|
||||
TimingMS []int `json:"timingMs"`
|
||||
Playback string `json:"playback"`
|
||||
Loop bool `json:"loop"`
|
||||
}
|
||||
|
||||
func NewClient(rootDir string) *Client {
|
||||
client := &Client{
|
||||
RootDir: rootDir,
|
||||
ManifestURL: DefaultManifestURL,
|
||||
RankingURL: DefaultRankingURL,
|
||||
TrustedHosts: map[string]bool{
|
||||
"petdex.dev": true,
|
||||
TrustedAssetHost: true,
|
||||
},
|
||||
TrustedAssetHosts: map[string]bool{TrustedAssetHost: true},
|
||||
Now: time.Now,
|
||||
}
|
||||
client.HTTPClient = &http.Client{Timeout: 20 * time.Second}
|
||||
return client
|
||||
}
|
||||
|
||||
func (c *Client) Catalog(ctx context.Context) ([]Entry, error) {
|
||||
remote, remoteErr := c.remoteCatalog(ctx)
|
||||
local, localErr := c.localCatalog()
|
||||
entries := mergeEntries(local, remote)
|
||||
if len(entries) > 0 {
|
||||
rankingCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
|
||||
ranking, _ := c.installedRanking(rankingCtx)
|
||||
cancel()
|
||||
sortEntriesByRanking(entries, ranking)
|
||||
return entries, nil
|
||||
}
|
||||
return nil, errors.Join(remoteErr, localErr)
|
||||
}
|
||||
|
||||
func (c *Client) InstalledEntries() ([]Entry, error) {
|
||||
return c.localCatalog()
|
||||
}
|
||||
|
||||
type rankingPayload struct {
|
||||
Pets []struct {
|
||||
Slug string `json:"slug"`
|
||||
} `json:"pets"`
|
||||
}
|
||||
|
||||
func (c *Client) installedRanking(ctx context.Context) ([]string, error) {
|
||||
cachePath := filepath.Join(c.cacheDir(), "ranking-installed.json")
|
||||
if info, err := os.Stat(cachePath); err == nil && c.now().Sub(info.ModTime()) < rankingCacheAge {
|
||||
if data, readErr := os.ReadFile(cachePath); readErr == nil {
|
||||
if ranking, decodeErr := decodeRanking(data); decodeErr == nil {
|
||||
return ranking, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
data, err := c.fetch(ctx, c.RankingURL, maxRankingBytes)
|
||||
if err == nil {
|
||||
ranking, decodeErr := decodeRanking(data)
|
||||
if decodeErr == nil {
|
||||
_ = c.cacheFile(cachePath, data)
|
||||
return ranking, nil
|
||||
}
|
||||
err = decodeErr
|
||||
}
|
||||
if cached, readErr := os.ReadFile(cachePath); readErr == nil {
|
||||
if ranking, decodeErr := decodeRanking(cached); decodeErr == nil {
|
||||
return ranking, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("load pet ranking: %w", err)
|
||||
}
|
||||
|
||||
func decodeRanking(data []byte) ([]string, error) {
|
||||
var payload rankingPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return nil, fmt.Errorf("decode pet ranking: %w", err)
|
||||
}
|
||||
ranking := make([]string, 0, len(payload.Pets))
|
||||
seen := make(map[string]bool, len(payload.Pets))
|
||||
for _, pet := range payload.Pets {
|
||||
slug := strings.TrimSpace(pet.Slug)
|
||||
if validateSlug(slug) != nil || seen[slug] {
|
||||
continue
|
||||
}
|
||||
seen[slug] = true
|
||||
ranking = append(ranking, slug)
|
||||
}
|
||||
if len(ranking) == 0 {
|
||||
return nil, fmt.Errorf("pet ranking is empty")
|
||||
}
|
||||
return ranking, nil
|
||||
}
|
||||
|
||||
func (c *Client) Preview(ctx context.Context, entry Entry) (*Animation, error) {
|
||||
if entry.Local {
|
||||
if animation, err := c.LoadInstalled(entry.Slug); err == nil {
|
||||
return animation, nil
|
||||
}
|
||||
}
|
||||
if err := validateSlug(entry.Slug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cachePath := filepath.Join(c.previewDir(), previewCacheName(entry))
|
||||
if data, err := os.ReadFile(cachePath); err == nil {
|
||||
if animation, err := decodePreview(data); err == nil {
|
||||
return animation, nil
|
||||
}
|
||||
}
|
||||
assetBase, err := c.trustedAssetBase(entry.AssetBase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previewURL := assetBase + "/pets/" + url.PathEscape(entry.Slug) + "/preview.webp"
|
||||
data, err := c.fetchAsset(ctx, previewURL, maxPreviewBytes)
|
||||
if err == nil {
|
||||
animation, decodeErr := decodePreview(data)
|
||||
if decodeErr == nil {
|
||||
_ = c.cacheFile(cachePath, data)
|
||||
return animation, nil
|
||||
}
|
||||
err = decodeErr
|
||||
}
|
||||
thumbnailURL := assetBase + "/pets/" + url.PathEscape(entry.Slug) + "/thumb.webp"
|
||||
thumbnail, thumbnailErr := c.fetchAsset(ctx, thumbnailURL, maxPreviewBytes)
|
||||
if thumbnailErr != nil {
|
||||
return nil, errors.Join(err, thumbnailErr)
|
||||
}
|
||||
imageValue, decodeErr := decodeImage(thumbnail)
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
return ThumbnailAnimation(imageValue)
|
||||
}
|
||||
|
||||
func (c *Client) Install(ctx context.Context, entry Entry) (*Animation, error) {
|
||||
if err := validateSlug(entry.Slug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry.Local {
|
||||
return c.LoadInstalled(entry.Slug)
|
||||
}
|
||||
spriteURL, err := c.trustedAssetURL(entry.SpritesheetURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid spritesheet URL: %w", err)
|
||||
}
|
||||
spriteData, err := c.fetchAsset(ctx, spriteURL, maxSpriteBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download spritesheet: %w", err)
|
||||
}
|
||||
sheet, err := decodeImage(spriteData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode spritesheet: %w", err)
|
||||
}
|
||||
var petJSON []byte
|
||||
var animationTracks map[State]atlasTrack
|
||||
var document petMetadata
|
||||
if strings.TrimSpace(entry.PetJSONURL) != "" {
|
||||
petURL, urlErr := c.trustedAssetURL(entry.PetJSONURL)
|
||||
if urlErr != nil {
|
||||
return nil, fmt.Errorf("invalid pet metadata URL: %w", urlErr)
|
||||
}
|
||||
petJSON, err = c.fetchAsset(ctx, petURL, maxPetJSONBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download pet metadata: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(petJSON, &document); err != nil {
|
||||
return nil, fmt.Errorf("invalid pet metadata: %w", err)
|
||||
}
|
||||
if document.SpriteVersion != 0 {
|
||||
if document.SpriteVersion != 1 && document.SpriteVersion != 2 {
|
||||
return nil, fmt.Errorf("invalid pet metadata: unsupported sprite version %d", document.SpriteVersion)
|
||||
}
|
||||
entry.SpriteVersion = document.SpriteVersion
|
||||
}
|
||||
animationTracks, err = document.atlasTracks()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pet metadata: %w", err)
|
||||
}
|
||||
}
|
||||
animation, err := atlasAnimation(sheet, entry.SpriteVersion, animationTracks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
animation.setClickAnimations(document.Interactions.Click.Animations)
|
||||
|
||||
root := c.installedDir()
|
||||
stage, cleanup, err := installtxn.StageDir(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cleanup()
|
||||
if err := os.MkdirAll(stage, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create pet staging directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(stage, "spritesheet.webp"), spriteData, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("stage spritesheet: %w", err)
|
||||
}
|
||||
if len(petJSON) > 0 {
|
||||
if err := os.WriteFile(filepath.Join(stage, "pet.json"), petJSON, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("stage pet metadata: %w", err)
|
||||
}
|
||||
}
|
||||
metadata, err := json.MarshalIndent(installedMetadata{Entry: entry, InstalledAt: c.now()}, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(stage, "source.json"), append(metadata, '\n'), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("stage pet source: %w", err)
|
||||
}
|
||||
unlock, err := installtxn.Lock(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer unlock()
|
||||
target := filepath.Join(root, entry.Slug)
|
||||
if err := installtxn.CommitDir(target, stage, func() error { return nil }); err != nil {
|
||||
return nil, fmt.Errorf("install pet: %w", err)
|
||||
}
|
||||
return animation, nil
|
||||
}
|
||||
|
||||
func (c *Client) LoadInstalled(slug string) (*Animation, error) {
|
||||
if err := validateSlug(slug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := filepath.Join(c.installedDir(), slug)
|
||||
entry, err := c.InstalledEntry(slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spriteData, err := os.ReadFile(filepath.Join(dir, "spritesheet.webp"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read installed spritesheet: %w", err)
|
||||
}
|
||||
sheet, err := decodeImage(spriteData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var animationTracks map[State]atlasTrack
|
||||
var document petMetadata
|
||||
if petJSON, readErr := os.ReadFile(filepath.Join(dir, "pet.json")); readErr == nil {
|
||||
if decodeErr := json.Unmarshal(petJSON, &document); decodeErr != nil {
|
||||
return nil, fmt.Errorf("read installed pet metadata: %w", decodeErr)
|
||||
}
|
||||
if document.SpriteVersion != 0 {
|
||||
entry.SpriteVersion = document.SpriteVersion
|
||||
}
|
||||
animationTracks, err = document.atlasTracks()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read installed pet metadata: %w", err)
|
||||
}
|
||||
} else if !errors.Is(readErr, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("read installed pet metadata: %w", readErr)
|
||||
}
|
||||
animation, err := atlasAnimation(sheet, entry.SpriteVersion, animationTracks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
animation.setClickAnimations(document.Interactions.Click.Animations)
|
||||
return animation, nil
|
||||
}
|
||||
|
||||
func (m petMetadata) atlasTracks() (map[State]atlasTrack, error) {
|
||||
names := make([]string, 0, len(m.Animations))
|
||||
for name := range m.Animations {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
tracks := make(map[State]atlasTrack)
|
||||
for _, name := range names {
|
||||
spec := m.Animations[name]
|
||||
state := State(strings.ToLower(strings.TrimSpace(name)))
|
||||
if state == "" {
|
||||
return nil, fmt.Errorf("animation name is empty")
|
||||
}
|
||||
if spec.FrameCount < 1 || spec.FrameCount > atlasColumns {
|
||||
return nil, fmt.Errorf("animation %q has invalid frame count %d", name, spec.FrameCount)
|
||||
}
|
||||
if len(spec.TimingMS) != 0 && len(spec.TimingMS) != spec.FrameCount {
|
||||
return nil, fmt.Errorf("animation %q has %d timings for %d frames", name, len(spec.TimingMS), spec.FrameCount)
|
||||
}
|
||||
row, err := spec.rowIndex()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("animation %q: %w", name, err)
|
||||
}
|
||||
// Idle must always cycle. Treating an authored idle track as one-shot
|
||||
// leaves the companion frozen on its final frame indefinitely.
|
||||
loop := state == Idle || spec.Loop || strings.EqualFold(strings.TrimSpace(spec.Playback), "loop")
|
||||
track := atlasTrack{row: row, count: spec.FrameCount, loop: loop, fallbackIdle: !loop}
|
||||
if len(spec.TimingMS) > 0 {
|
||||
track.durations = make([]time.Duration, len(spec.TimingMS))
|
||||
for index, milliseconds := range spec.TimingMS {
|
||||
if milliseconds < 16 || milliseconds > 10_000 {
|
||||
return nil, fmt.Errorf("animation %q has invalid frame timing %dms", name, milliseconds)
|
||||
}
|
||||
track.durations[index] = time.Duration(milliseconds) * time.Millisecond
|
||||
}
|
||||
}
|
||||
tracks[state] = track
|
||||
}
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
func (m petAnimationMetadata) rowIndex() (int, error) {
|
||||
if m.SourceRowIndex != nil {
|
||||
return *m.SourceRowIndex, nil
|
||||
}
|
||||
if len(m.SourceRow) == 0 || string(m.SourceRow) == "null" {
|
||||
return 0, fmt.Errorf("source row is missing")
|
||||
}
|
||||
var numeric int
|
||||
if err := json.Unmarshal(m.SourceRow, &numeric); err == nil {
|
||||
return numeric, nil
|
||||
}
|
||||
var name string
|
||||
if err := json.Unmarshal(m.SourceRow, &name); err != nil {
|
||||
return 0, fmt.Errorf("source row is invalid")
|
||||
}
|
||||
rows := map[string]int{
|
||||
"idle": 0, "running-right": 1, "running-left": 2, "waving": 3,
|
||||
"jumping": 4, "failed": 5, "waiting": 6, "running": 7, "review": 8,
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(name))
|
||||
if row, ok := rows[normalized]; ok {
|
||||
return row, nil
|
||||
}
|
||||
if row, err := strconv.Atoi(normalized); err == nil {
|
||||
return row, nil
|
||||
}
|
||||
return 0, fmt.Errorf("unknown source row %q", name)
|
||||
}
|
||||
|
||||
func (c *Client) InstalledEntry(slug string) (Entry, error) {
|
||||
if err := validateSlug(slug); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
metadataData, err := os.ReadFile(filepath.Join(c.installedDir(), slug, "source.json"))
|
||||
if err != nil {
|
||||
return Entry{}, fmt.Errorf("read installed pet: %w", err)
|
||||
}
|
||||
var metadata installedMetadata
|
||||
if err := json.Unmarshal(metadataData, &metadata); err != nil {
|
||||
return Entry{}, fmt.Errorf("read installed pet metadata: %w", err)
|
||||
}
|
||||
if metadata.Slug != slug {
|
||||
return Entry{}, fmt.Errorf("installed pet metadata does not match %q", slug)
|
||||
}
|
||||
metadata.Local = true
|
||||
return metadata.Entry, nil
|
||||
}
|
||||
|
||||
func (c *Client) remoteCatalog(ctx context.Context) ([]Entry, error) {
|
||||
cachePath := filepath.Join(c.cacheDir(), "petdex-v2.json")
|
||||
if info, err := os.Stat(cachePath); err == nil && c.now().Sub(info.ModTime()) < manifestCacheAge {
|
||||
if data, readErr := os.ReadFile(cachePath); readErr == nil {
|
||||
if entries, decodeErr := c.decodeCatalog(data); decodeErr == nil {
|
||||
return entries, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
data, err := c.fetch(ctx, c.ManifestURL, maxManifestBytes)
|
||||
if err == nil {
|
||||
entries, decodeErr := c.decodeCatalog(data)
|
||||
if decodeErr == nil {
|
||||
_ = c.cacheFile(cachePath, data)
|
||||
return entries, nil
|
||||
}
|
||||
err = decodeErr
|
||||
}
|
||||
if cached, readErr := os.ReadFile(cachePath); readErr == nil {
|
||||
if entries, decodeErr := c.decodeCatalog(cached); decodeErr == nil {
|
||||
return entries, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("load pet catalog: %w", err)
|
||||
}
|
||||
|
||||
func (c *Client) decodeCatalog(data []byte) ([]Entry, error) {
|
||||
var manifest compactManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("decode pet catalog: %w", err)
|
||||
}
|
||||
if manifest.Version != 2 {
|
||||
return nil, fmt.Errorf("unsupported pet catalog version %d", manifest.Version)
|
||||
}
|
||||
assetBase, err := c.trustedAssetBase(manifest.AssetBase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wanted := map[string]int{}
|
||||
for index, field := range manifest.Fields {
|
||||
wanted[field] = index
|
||||
}
|
||||
required := []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "spriteVersionNumber"}
|
||||
for _, field := range required {
|
||||
if _, ok := wanted[field]; !ok {
|
||||
return nil, fmt.Errorf("pet catalog is missing %q", field)
|
||||
}
|
||||
}
|
||||
entries := make([]Entry, 0, len(manifest.Pets))
|
||||
seen := map[string]bool{}
|
||||
var firstRowError error
|
||||
for _, rawRow := range manifest.Pets {
|
||||
entry, rowErr := func() (Entry, error) {
|
||||
var row []json.RawMessage
|
||||
if err := json.Unmarshal(rawRow, &row); err != nil {
|
||||
return Entry{}, fmt.Errorf("decode pet catalog row: %w", err)
|
||||
}
|
||||
value := func(field string, target any) error {
|
||||
index := wanted[field]
|
||||
if index >= len(row) {
|
||||
return fmt.Errorf("pet catalog row is missing %q", field)
|
||||
}
|
||||
return json.Unmarshal(row[index], target)
|
||||
}
|
||||
var entry Entry
|
||||
if err := value("slug", &entry.Slug); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if err := validateSlug(entry.Slug); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if err := value("displayName", &entry.DisplayName); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if err := value("kind", &entry.Kind); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
// submittedBy is nullable in the public manifest; null intentionally maps
|
||||
// to an empty creator label.
|
||||
_ = value("submittedBy", &entry.SubmittedBy)
|
||||
if err := value("spritesheet", &entry.SpritesheetURL); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if err := value("petJson", &entry.PetJSONURL); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if err := value("spriteVersionNumber", &entry.SpriteVersion); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if entry.SpriteVersion != 1 && entry.SpriteVersion != 2 {
|
||||
return Entry{}, fmt.Errorf("pet %q has unsupported sprite version %d", entry.Slug, entry.SpriteVersion)
|
||||
}
|
||||
entry.AssetBase = assetBase
|
||||
resolved, err := c.resolveAssetURL(assetBase, entry.SpritesheetURL)
|
||||
if err != nil {
|
||||
return Entry{}, fmt.Errorf("pet %q: %w", entry.Slug, err)
|
||||
}
|
||||
entry.SpritesheetURL = resolved
|
||||
if strings.TrimSpace(entry.PetJSONURL) != "" {
|
||||
resolved, err = c.resolveAssetURL(assetBase, entry.PetJSONURL)
|
||||
if err != nil {
|
||||
return Entry{}, fmt.Errorf("pet %q: %w", entry.Slug, err)
|
||||
}
|
||||
entry.PetJSONURL = resolved
|
||||
}
|
||||
return entry, nil
|
||||
}()
|
||||
if rowErr != nil {
|
||||
if firstRowError == nil {
|
||||
firstRowError = rowErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if seen[entry.Slug] {
|
||||
continue
|
||||
}
|
||||
seen[entry.Slug] = true
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if len(entries) == 0 && firstRowError != nil {
|
||||
return nil, firstRowError
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return strings.ToLower(entries[i].Label()) < strings.ToLower(entries[j].Label()) })
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (c *Client) localCatalog() ([]Entry, error) {
|
||||
dirs, err := os.ReadDir(c.installedDir())
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]Entry, 0, len(dirs))
|
||||
for _, dir := range dirs {
|
||||
if !dir.IsDir() || validateSlug(dir.Name()) != nil {
|
||||
continue
|
||||
}
|
||||
entry, readErr := c.InstalledEntry(dir.Name())
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetch(ctx context.Context, address string, limit int64) ([]byte, error) {
|
||||
return c.fetchLimit(ctx, address, limit, false)
|
||||
}
|
||||
|
||||
func (c *Client) fetchAsset(ctx context.Context, address string, limit int64) ([]byte, error) {
|
||||
return c.fetchLimit(ctx, address, limit, true)
|
||||
}
|
||||
|
||||
func (c *Client) fetchLimit(ctx context.Context, address string, limit int64, assetOnly bool) ([]byte, error) {
|
||||
if _, err := c.trustedURL(address); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if assetOnly {
|
||||
if _, err := c.trustedAssetURL(address); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseClient := c.httpClient()
|
||||
requestClient := *baseClient
|
||||
requestClient.CheckRedirect = func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
if assetOnly {
|
||||
_, err := c.trustedAssetURL(request.URL.String())
|
||||
return err
|
||||
}
|
||||
_, err := c.trustedURL(request.URL.String())
|
||||
return err
|
||||
}
|
||||
response, err := requestClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if _, err := c.trustedURL(response.Request.URL.String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if assetOnly {
|
||||
if _, err := c.trustedAssetURL(response.Request.URL.String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("request returned %s", response.Status)
|
||||
}
|
||||
reader := io.LimitReader(response.Body, limit+1)
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, fmt.Errorf("download exceeds %d-byte limit", limit)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *Client) trustedAssetBase(raw string) (string, error) {
|
||||
parsed, err := c.trustedURL(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid asset base: %w", err)
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
|
||||
return "", fmt.Errorf("asset base must not contain a path, query, or fragment")
|
||||
}
|
||||
if !c.assetHosts()[strings.ToLower(parsed.Hostname())] {
|
||||
return "", fmt.Errorf("untrusted asset host %q", parsed.Hostname())
|
||||
}
|
||||
return strings.TrimSuffix(parsed.String(), "/"), nil
|
||||
}
|
||||
|
||||
func (c *Client) trustedAssetURL(raw string) (string, error) {
|
||||
parsed, err := c.trustedURL(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !c.assetHosts()[strings.ToLower(parsed.Hostname())] {
|
||||
return "", fmt.Errorf("untrusted asset host %q", parsed.Hostname())
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", fmt.Errorf("asset URL must not contain a query or fragment")
|
||||
}
|
||||
cleaned := path.Clean(parsed.Path)
|
||||
escaped := strings.ToLower(parsed.EscapedPath())
|
||||
if !allowedAssetPath(cleaned) || cleaned != parsed.Path || strings.Contains(escaped, "%2f") || strings.Contains(escaped, "%5c") || strings.Contains(parsed.Path, "\\") {
|
||||
return "", fmt.Errorf("asset path is outside the supported catalog roots")
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func (c *Client) resolveAssetURL(assetBase, reference string) (string, error) {
|
||||
reference = strings.TrimSpace(reference)
|
||||
if reference == "" {
|
||||
return "", fmt.Errorf("asset path is empty")
|
||||
}
|
||||
if parsed, err := url.Parse(reference); err == nil && parsed.IsAbs() {
|
||||
return c.trustedAssetURL(reference)
|
||||
}
|
||||
parsed, err := url.Parse(reference)
|
||||
if err != nil || parsed.Host != "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", fmt.Errorf("invalid relative asset path")
|
||||
}
|
||||
cleaned := path.Clean("/" + strings.TrimPrefix(parsed.Path, "/"))
|
||||
if !allowedAssetPath(cleaned) || strings.Contains(parsed.Path, "\\") {
|
||||
return "", fmt.Errorf("asset path is outside the supported catalog roots")
|
||||
}
|
||||
return c.trustedAssetURL(strings.TrimSuffix(assetBase, "/") + cleaned)
|
||||
}
|
||||
|
||||
func allowedAssetPath(value string) bool {
|
||||
return strings.HasPrefix(value, "/pets/") || strings.HasPrefix(value, "/community/") || strings.HasPrefix(value, "/curated/")
|
||||
}
|
||||
|
||||
func (c *Client) assetHosts() map[string]bool {
|
||||
if c.TrustedAssetHosts != nil {
|
||||
return c.TrustedAssetHosts
|
||||
}
|
||||
return map[string]bool{TrustedAssetHost: true}
|
||||
}
|
||||
|
||||
func (c *Client) trustedURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme != "https" || parsed.User != nil || parsed.Hostname() == "" {
|
||||
return nil, fmt.Errorf("URL must use HTTPS without credentials")
|
||||
}
|
||||
hosts := c.TrustedHosts
|
||||
if hosts == nil {
|
||||
hosts = map[string]bool{"petdex.dev": true, TrustedAssetHost: true}
|
||||
}
|
||||
if !hosts[strings.ToLower(parsed.Hostname())] {
|
||||
return nil, fmt.Errorf("untrusted host %q", parsed.Hostname())
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func decodePreview(data []byte) (*Animation, error) {
|
||||
imageValue, err := decodeImage(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return PreviewAnimation(imageValue)
|
||||
}
|
||||
|
||||
func previewCacheName(entry Entry) string {
|
||||
digest := sha256.Sum256([]byte(entry.SpritesheetURL))
|
||||
return fmt.Sprintf("%s-%x.webp", entry.Slug, digest[:6])
|
||||
}
|
||||
|
||||
func decodeImage(data []byte) (image.Image, error) {
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode image header: %w", err)
|
||||
}
|
||||
if config.Width < 1 || config.Height < 1 || config.Width > maxImageSide || config.Height > maxImageSide || int64(config.Width)*int64(config.Height) > maxImagePixels {
|
||||
return nil, fmt.Errorf("image dimensions %dx%d exceed limits", config.Width, config.Height)
|
||||
}
|
||||
imageValue, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode image: %w", err)
|
||||
}
|
||||
return imageValue, nil
|
||||
}
|
||||
|
||||
func (c *Client) cacheFile(target string, data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return installtxn.WriteFileAtomically(target, data, 0o600)
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
return client
|
||||
}
|
||||
|
||||
func (c *Client) now() time.Time {
|
||||
if c.Now != nil {
|
||||
return c.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (c *Client) cacheDir() string { return filepath.Join(c.RootDir, "pets", "cache") }
|
||||
func (c *Client) previewDir() string { return filepath.Join(c.cacheDir(), "previews") }
|
||||
func (c *Client) installedDir() string { return filepath.Join(c.RootDir, "pets", "installed") }
|
||||
|
||||
func validateSlug(slug string) error {
|
||||
if slug == "" || len(slug) > 128 {
|
||||
return fmt.Errorf("invalid pet slug %q", slug)
|
||||
}
|
||||
for index, value := range slug {
|
||||
if (value >= 'a' && value <= 'z') || (value >= '0' && value <= '9') || (index > 0 && (value == '-' || value == '_')) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("invalid pet slug %q", slug)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeEntries(local, remote []Entry) []Entry {
|
||||
bySlug := make(map[string]Entry, len(local)+len(remote))
|
||||
for _, entry := range remote {
|
||||
bySlug[entry.Slug] = entry
|
||||
}
|
||||
for _, entry := range local {
|
||||
bySlug[entry.Slug] = entry
|
||||
}
|
||||
entries := make([]Entry, 0, len(bySlug))
|
||||
for _, entry := range bySlug {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].Local != entries[j].Local {
|
||||
return entries[i].Local
|
||||
}
|
||||
return strings.ToLower(entries[i].Label()) < strings.ToLower(entries[j].Label())
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func sortEntriesByRanking(entries []Entry, ranking []string) {
|
||||
rank := make(map[string]int, len(ranking))
|
||||
for index, slug := range ranking {
|
||||
rank[slug] = index
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
left, right := entries[i], entries[j]
|
||||
if left.Local != right.Local {
|
||||
return left.Local
|
||||
}
|
||||
if left.Local {
|
||||
return strings.ToLower(left.Label()) < strings.ToLower(right.Label())
|
||||
}
|
||||
leftRank, leftRanked := rank[left.Slug]
|
||||
rightRank, rightRanked := rank[right.Slug]
|
||||
if leftRanked != rightRanked {
|
||||
return leftRanked
|
||||
}
|
||||
if leftRanked {
|
||||
return leftRank < rightRank
|
||||
}
|
||||
return strings.ToLower(left.Label()) < strings.ToLower(right.Label())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCatalogPreviewInstallAndOfflineReload(t *testing.T) {
|
||||
preview := encodedPNG(t, 24*previewFrameCount, 26)
|
||||
atlas := encodedPNG(t, 24*atlasColumns, 26*11)
|
||||
var assetBase atomic.Pointer[string]
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/manifest":
|
||||
base := assetBase.Load()
|
||||
if base == nil {
|
||||
http.Error(writer, "server not ready", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
manifest := compactManifest{
|
||||
Version: 2,
|
||||
AssetBase: *base,
|
||||
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "zip", "spriteVersionNumber"},
|
||||
}
|
||||
row := []any{"boba", "Boba", "animal", "tester", "pets/boba/sprite.webp", "pets/boba/petjson.json", "pets/boba/archive.zip", 2}
|
||||
encoded, _ := json.Marshal(row)
|
||||
manifest.Pets = []json.RawMessage{encoded}
|
||||
_ = json.NewEncoder(writer).Encode(manifest)
|
||||
case "/pets/boba/preview.webp":
|
||||
_, _ = writer.Write(preview)
|
||||
case "/pets/boba/sprite.webp":
|
||||
_, _ = writer.Write(atlas)
|
||||
case "/pets/boba/petjson.json":
|
||||
_, _ = writer.Write([]byte(`{"displayName":"Boba","description":"A calm blue-screen gremlin."}`))
|
||||
case "/ranking":
|
||||
_, _ = writer.Write([]byte(`{"pets":[{"slug":"boba"}],"nextCursor":null}`))
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
serverURL := server.URL
|
||||
assetBase.Store(&serverURL)
|
||||
defer server.Close()
|
||||
|
||||
root := t.TempDir()
|
||||
client := testClient(t, root, server)
|
||||
entries, err := client.Catalog(context.Background())
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("Catalog() = %#v, %v", entries, err)
|
||||
}
|
||||
entry := entries[0]
|
||||
if entry.Slug != "boba" || entry.SpriteVersion != 2 || entry.AssetBase != server.URL {
|
||||
t.Fatalf("unexpected catalog entry: %#v", entry)
|
||||
}
|
||||
if entry.SpritesheetURL != server.URL+"/pets/boba/sprite.webp" {
|
||||
t.Fatalf("relative spritesheet was not resolved: %q", entry.SpritesheetURL)
|
||||
}
|
||||
previewAnimation, err := client.Preview(context.Background(), entry)
|
||||
if err != nil || previewAnimation.Frame(Idle, 5) == nil {
|
||||
t.Fatalf("Preview() animation=%v err=%v", previewAnimation, err)
|
||||
}
|
||||
installed, err := client.Install(context.Background(), entry)
|
||||
if err != nil || installed.Frame(Running, 3) == nil {
|
||||
t.Fatalf("Install() animation=%v err=%v", installed, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "pets", "installed", "boba", "source.json")); err != nil {
|
||||
t.Fatalf("installed source marker: %v", err)
|
||||
}
|
||||
server.Close()
|
||||
offlineEntries, err := client.Catalog(context.Background())
|
||||
if err != nil || len(offlineEntries) != 1 || !offlineEntries[0].Local {
|
||||
t.Fatalf("offline Catalog() = %#v, %v", offlineEntries, err)
|
||||
}
|
||||
if _, err := client.LoadInstalled("boba"); err != nil {
|
||||
t.Fatalf("LoadInstalled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallUsesPetMetadataSpriteVersionWhenManifestIsStale(t *testing.T) {
|
||||
atlas := encodedPNG(t, 24*atlasColumns, 26*11)
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/pets/luffy/sprite.webp":
|
||||
_, _ = writer.Write(atlas)
|
||||
case "/pets/luffy/petjson.json":
|
||||
_, _ = writer.Write([]byte(`{
|
||||
"spriteVersionNumber":2,
|
||||
"atlasRows":11,
|
||||
"interactions":{"click":{"animations":["bajrang-gun"],"mode":"cycle"}},
|
||||
"animations":{"bajrang-gun":{"sourceRowIndex":3,"frameCount":8,"timingMs":[100,110,120,130,140,150,160,320]}}
|
||||
}`))
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := testClient(t, t.TempDir(), server)
|
||||
entry := Entry{
|
||||
Slug: "luffy",
|
||||
DisplayName: "Luffy Gear 5",
|
||||
SpritesheetURL: server.URL + "/pets/luffy/sprite.webp",
|
||||
PetJSONURL: server.URL + "/pets/luffy/petjson.json",
|
||||
SpriteVersion: 1,
|
||||
}
|
||||
animation, err := client.Install(context.Background(), entry)
|
||||
if err != nil {
|
||||
t.Fatalf("Install() with corrected pet metadata: %v", err)
|
||||
}
|
||||
if animation.Frame(Review, 0) == nil {
|
||||
t.Fatal("Install() did not load the version 2 review row")
|
||||
}
|
||||
customState := State("bajrang-gun")
|
||||
if _, key := animation.frame(customState, 7); key.index != 7 {
|
||||
t.Fatalf("custom waving final frame index = %d, want 7", key.index)
|
||||
}
|
||||
if got := animation.FrameDelay(customState, 7); got != 320*time.Millisecond {
|
||||
t.Fatalf("custom waving final frame delay = %s, want 320ms", got)
|
||||
}
|
||||
if got := animation.PrimaryDuration(customState); got != 1230*time.Millisecond {
|
||||
t.Fatalf("custom waving duration = %s, want unchanged 1.23s", got)
|
||||
}
|
||||
if got := animation.FrameDelay(customState, 8); got != 1680*time.Millisecond {
|
||||
t.Fatalf("custom one-shot fallback delay = %s, want first idle frame at 1.68s", got)
|
||||
}
|
||||
if got, ok := animation.ClickAnimation(0); !ok || got != customState {
|
||||
t.Fatalf("click animation = %q, %v; want %q", got, ok, customState)
|
||||
}
|
||||
installed, err := client.InstalledEntry(entry.Slug)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed.SpriteVersion != 2 {
|
||||
t.Fatalf("persisted sprite version = %d, want 2", installed.SpriteVersion)
|
||||
}
|
||||
if _, err := client.LoadInstalled(entry.Slug); err != nil {
|
||||
t.Fatalf("LoadInstalled() with corrected sprite version: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetMetadataAllowsSeveralNamedAnimationsOnOneRow(t *testing.T) {
|
||||
row := 0
|
||||
document := petMetadata{Animations: map[string]petAnimationMetadata{
|
||||
"blink": {SourceRowIndex: &row, FrameCount: 4, TimingMS: []int{100, 100, 100, 100}},
|
||||
"idle": {SourceRow: json.RawMessage(`"idle"`), FrameCount: 6, TimingMS: []int{200, 200, 200, 200, 200, 400}},
|
||||
}}
|
||||
tracks, err := document.atlasTracks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := tracks[Idle].count; got != 6 {
|
||||
t.Fatalf("idle frame count = %d, want six", got)
|
||||
}
|
||||
if got := tracks[State("blink")].count; got != 4 {
|
||||
t.Fatalf("blink frame count = %d, want four", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetMetadataAlwaysLoopsIdleAnimation(t *testing.T) {
|
||||
row := 0
|
||||
document := petMetadata{Animations: map[string]petAnimationMetadata{
|
||||
"idle": {
|
||||
SourceRowIndex: &row,
|
||||
FrameCount: 2,
|
||||
TimingMS: []int{100, 200},
|
||||
Playback: "once",
|
||||
},
|
||||
}}
|
||||
tracks, err := document.atlasTracks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idle := tracks[Idle]
|
||||
if !idle.loop || idle.fallbackIdle {
|
||||
t.Fatalf("idle track = %#v, want looping without fallback", idle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetMetadataAcceptsNamedAndNumericSourceRows(t *testing.T) {
|
||||
document := petMetadata{Animations: map[string]petAnimationMetadata{
|
||||
"dash": {SourceRow: json.RawMessage(`"running-right"`), FrameCount: 8},
|
||||
"skill": {SourceRow: json.RawMessage(`3`), FrameCount: 8},
|
||||
}}
|
||||
tracks, err := document.atlasTracks()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tracks[State("dash")].row != 1 || tracks[State("skill")].row != 3 {
|
||||
t.Fatalf("normalized rows = dash:%d skill:%d", tracks[State("dash")].row, tracks[State("skill")].row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRejectsUntrustedAssetHost(t *testing.T) {
|
||||
client := NewClient(t.TempDir())
|
||||
manifest := compactManifest{
|
||||
Version: 2,
|
||||
AssetBase: "https://evil.example",
|
||||
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "spriteVersionNumber"},
|
||||
}
|
||||
row, _ := json.Marshal([]any{"boba", "Boba", "animal", nil, "https://evil.example/pets/boba/sprite.webp", "", 2})
|
||||
manifest.Pets = []json.RawMessage{row}
|
||||
data, _ := json.Marshal(manifest)
|
||||
if _, err := client.decodeCatalog(data); err == nil {
|
||||
t.Fatal("decodeCatalog accepted an untrusted asset host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogSkipsMalformedRowWhenValidRowsRemain(t *testing.T) {
|
||||
client := NewClient(t.TempDir())
|
||||
manifest := compactManifest{
|
||||
Version: 2,
|
||||
AssetBase: "https://assets.petdex.dev",
|
||||
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "spriteVersionNumber"},
|
||||
}
|
||||
invalid, _ := json.Marshal([]any{"broken slug", "Broken", "creature", "tester", "pets/broken/sprite.webp", "", 2})
|
||||
valid, _ := json.Marshal([]any{"boba", "Boba", "creature", "tester", "pets/boba/sprite.webp", "", 2})
|
||||
manifest.Pets = []json.RawMessage{invalid, valid}
|
||||
data, _ := json.Marshal(manifest)
|
||||
|
||||
entries, err := client.decodeCatalog(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := entrySlugs(entries); !slices.Equal(got, []string{"boba"}) {
|
||||
t.Fatalf("catalog entries = %v, want [boba]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogOrdersRankedPetsBeforeAlphabeticalRemainder(t *testing.T) {
|
||||
manifest := compactManifest{
|
||||
Version: 2,
|
||||
AssetBase: "",
|
||||
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "zip", "spriteVersionNumber"},
|
||||
}
|
||||
for _, row := range [][]any{
|
||||
{"alpha", "Alpha", "creature", "tester", "pets/alpha/sprite.webp", "pets/alpha/pet.json", nil, 1},
|
||||
{"beta", "Beta", "creature", "tester", "pets/beta/sprite.webp", "pets/beta/pet.json", nil, 1},
|
||||
{"zeta", "Zeta", "creature", "tester", "pets/zeta/sprite.webp", "pets/zeta/pet.json", nil, 1},
|
||||
} {
|
||||
encoded, _ := json.Marshal(row)
|
||||
manifest.Pets = append(manifest.Pets, encoded)
|
||||
}
|
||||
var rankingFails atomic.Bool
|
||||
var assetBase atomic.Pointer[string]
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/manifest":
|
||||
base := assetBase.Load()
|
||||
if base == nil {
|
||||
http.Error(writer, "server not ready", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
manifest.AssetBase = *base
|
||||
_ = json.NewEncoder(writer).Encode(manifest)
|
||||
case "/ranking":
|
||||
if rankingFails.Load() {
|
||||
http.Error(writer, "unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, _ = writer.Write([]byte(`{"pets":[{"slug":"zeta"},{"slug":"alpha"}],"nextCursor":60}`))
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
serverURL := server.URL
|
||||
assetBase.Store(&serverURL)
|
||||
defer server.Close()
|
||||
|
||||
client := testClient(t, t.TempDir(), server)
|
||||
entries, err := client.Catalog(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := entrySlugs(entries); !slices.Equal(got, []string{"zeta", "alpha", "beta"}) {
|
||||
t.Fatalf("ranked catalog = %v", got)
|
||||
}
|
||||
|
||||
rankingFails.Store(true)
|
||||
fallback := testClient(t, t.TempDir(), server)
|
||||
entries, err = fallback.Catalog(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := entrySlugs(entries); !slices.Equal(got, []string{"alpha", "beta", "zeta"}) {
|
||||
t.Fatalf("fallback catalog = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetPathAndSlugValidation(t *testing.T) {
|
||||
client := NewClient(t.TempDir())
|
||||
for _, raw := range []string{
|
||||
"https://assets.petdex.dev/other/sprite.webp",
|
||||
"https://assets.petdex.dev/pets/%2e%2e/secret",
|
||||
"https://[email protected]/pets/boba/sprite.webp",
|
||||
} {
|
||||
if _, err := client.trustedAssetURL(raw); err == nil {
|
||||
t.Errorf("trustedAssetURL(%q) unexpectedly succeeded", raw)
|
||||
}
|
||||
}
|
||||
for _, slug := range []string{"../boba", "/boba", "Boba", "boba/other", ""} {
|
||||
if err := validateSlug(slug); err == nil {
|
||||
t.Errorf("validateSlug(%q) unexpectedly succeeded", slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetRedirectToUntrustedHostIsRejectedBeforeFollowing(t *testing.T) {
|
||||
redirect := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
http.Redirect(writer, request, "https://example.com/pets/boba/sprite.webp", http.StatusFound)
|
||||
}))
|
||||
defer redirect.Close()
|
||||
parsed, _ := url.Parse(redirect.URL)
|
||||
client := NewClient(t.TempDir())
|
||||
client.HTTPClient = redirect.Client()
|
||||
client.TrustedHosts = map[string]bool{parsed.Hostname(): true}
|
||||
client.TrustedAssetHosts = map[string]bool{parsed.Hostname(): true}
|
||||
if _, err := client.fetchAsset(context.Background(), redirect.URL+"/pets/boba/sprite.webp", maxSpriteBytes); err == nil {
|
||||
t.Fatal("fetchAsset followed a redirect to an untrusted host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAssetURLRejectsPathsOutsideCatalogRoots(t *testing.T) {
|
||||
client := NewClient(t.TempDir())
|
||||
for _, reference := range []string{"../secret.png", "/other/sprite.webp", `pets\\boba\\sprite.webp`} {
|
||||
if _, err := client.resolveAssetURL("https://assets.petdex.dev", reference); err == nil {
|
||||
t.Errorf("resolveAssetURL(%q) unexpectedly succeeded", reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAssetRejectsOversizedDownload(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write([]byte("12345"))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := testClient(t, t.TempDir(), server)
|
||||
if _, err := client.fetchAsset(context.Background(), server.URL+"/pets/boba/sprite.webp", 4); err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("oversized fetch error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeImageRejectsOversizedDimensions(t *testing.T) {
|
||||
data := encodedPNG(t, maxImageSide+1, 1)
|
||||
if _, err := decodeImage(data); err == nil || !strings.Contains(err.Error(), "dimensions") {
|
||||
t.Fatalf("oversized image error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlasAnimationRejectsWrongGeometry(t *testing.T) {
|
||||
bad := image.NewNRGBA(image.Rect(0, 0, 192, 285))
|
||||
if _, err := AtlasAnimation(bad, 2); err == nil {
|
||||
t.Fatal("AtlasAnimation accepted a non-grid image")
|
||||
}
|
||||
if _, err := AtlasAnimation(image.NewNRGBA(image.Rect(0, 0, 192, 286)), 3); err == nil {
|
||||
t.Fatal("AtlasAnimation accepted an unknown sprite version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlasAnimationUsesPerStateFrameCounts(t *testing.T) {
|
||||
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
|
||||
for column := 0; column < 6; column++ {
|
||||
atlas.SetNRGBA(column*24, 0, color.NRGBA{R: 255, A: 255})
|
||||
}
|
||||
animation, err := AtlasAnimation(atlas, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Idle has six frames: phase 6 wraps to frame zero instead of entering the
|
||||
// two unused atlas columns, which are commonly transparent.
|
||||
_, _, _, alpha := animation.Frame(Idle, 6).At(0, 0).RGBA()
|
||||
if alpha == 0 {
|
||||
t.Fatal("idle phase 6 selected an unused transparent atlas column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlasAnimationRunningRepeatsThreeTimesThenLoopsIdle(t *testing.T) {
|
||||
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
|
||||
for x := 0; x < 6*24; x++ {
|
||||
for y := 0; y < 26; y++ {
|
||||
atlas.SetNRGBA(x, y, color.NRGBA{R: 255, A: 255})
|
||||
atlas.SetNRGBA(x, 7*26+y, color.NRGBA{B: 255, A: 255})
|
||||
}
|
||||
}
|
||||
animation, err := AtlasAnimation(atlas, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The six-frame running row is authored to play three times. The next
|
||||
// frame is idle, and subsequent phases loop only the appended idle row.
|
||||
running := color.NRGBAModel.Convert(animation.Frame(Running, 17).At(0, 0)).(color.NRGBA)
|
||||
settled := color.NRGBAModel.Convert(animation.Frame(Running, 18).At(0, 0)).(color.NRGBA)
|
||||
looped := color.NRGBAModel.Convert(animation.Frame(Running, 24).At(0, 0)).(color.NRGBA)
|
||||
if running.B != 255 || settled.R != 255 || looped.R != 255 {
|
||||
t.Fatalf("running transition colors = running:%#v settled:%#v looped:%#v", running, settled, looped)
|
||||
}
|
||||
if got := animation.FrameDelay(Running, 18); got != 1680*time.Millisecond {
|
||||
t.Fatalf("first fallback idle delay = %s, want 1.68s", got)
|
||||
}
|
||||
if got := animation.PrimaryDuration(Waving); got != 2100*time.Millisecond {
|
||||
t.Fatalf("waving primary duration = %s, want 2.1s", got)
|
||||
}
|
||||
if got := animation.PrimaryDuration(Jumping); got != 2520*time.Millisecond {
|
||||
t.Fatalf("jumping primary duration = %s, want 2.52s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlasAnimationSuppliesDefaultTimingForMetadataWithoutTiming(t *testing.T) {
|
||||
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
|
||||
animation, err := atlasAnimation(atlas, 1, map[State]atlasTrack{
|
||||
State("dash"): {row: 1, count: 3, fallbackIdle: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := animation.FrameDelay(State("dash"), 0); got <= 0 {
|
||||
t.Fatalf("metadata animation delay = %s, want a positive default", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlasAnimationIncludesInteractiveStates(t *testing.T) {
|
||||
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
|
||||
animation, err := AtlasAnimation(atlas, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, state := range []State{MoveRight, MoveLeft, Waving, Jumping} {
|
||||
if frame := animation.Frame(state, 0); frame == nil {
|
||||
t.Fatalf("interactive state %q has no frame", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testClient(t *testing.T, root string, server *httptest.Server) *Client {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
client := NewClient(root)
|
||||
client.ManifestURL = server.URL + "/manifest"
|
||||
client.RankingURL = server.URL + "/ranking"
|
||||
client.HTTPClient = server.Client()
|
||||
client.TrustedHosts = map[string]bool{host: true}
|
||||
client.TrustedAssetHosts = map[string]bool{host: true}
|
||||
return client
|
||||
}
|
||||
|
||||
func entrySlugs(entries []Entry) []string {
|
||||
result := make([]string, len(entries))
|
||||
for index, entry := range entries {
|
||||
result[index] = entry.Slug
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func encodedPNG(t *testing.T, width, height int) []byte {
|
||||
t.Helper()
|
||||
value := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
value.SetNRGBA(x, y, color.NRGBA{R: uint8(x), G: uint8(y), B: 180, A: 255})
|
||||
}
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := png.Encode(&output, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return output.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/installtxn"
|
||||
)
|
||||
|
||||
const kittyChunkSize = 4096
|
||||
|
||||
type ImageProtocol uint8
|
||||
|
||||
const (
|
||||
ImageProtocolNone ImageProtocol = iota
|
||||
ImageProtocolKitty
|
||||
ImageProtocolKittyLocalFile
|
||||
ImageProtocolSixel
|
||||
)
|
||||
|
||||
type ImageSupport struct {
|
||||
Protocol ImageProtocol
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (s ImageSupport) Supported() bool {
|
||||
return s.Protocol != ImageProtocolNone
|
||||
}
|
||||
|
||||
func DetectImageSupport(getenv func(string) string) ImageSupport {
|
||||
if strings.TrimSpace(getenv("TMUX")) != "" || strings.TrimSpace(getenv("TMUX_PANE")) != "" {
|
||||
return ImageSupport{Reason: "Terminal companions are disabled in tmux because terminal images are not reliably pane-local."}
|
||||
}
|
||||
if strings.TrimSpace(getenv("ZELLIJ")) != "" || strings.TrimSpace(getenv("ZELLIJ_SESSION_NAME")) != "" || strings.TrimSpace(getenv("ZELLIJ_VERSION")) != "" {
|
||||
return ImageSupport{Reason: "Terminal companions are disabled in Zellij because terminal images are not reliably pane-local."}
|
||||
}
|
||||
if strings.TrimSpace(getenv("KITTY_WINDOW_ID")) != "" || strings.TrimSpace(getenv("WEZTERM_EXECUTABLE")) != "" || strings.TrimSpace(getenv("WEZTERM_VERSION")) != "" {
|
||||
return ImageSupport{Protocol: ImageProtocolKitty}
|
||||
}
|
||||
term := strings.ToLower(getenv("TERM"))
|
||||
program := strings.ToLower(getenv("TERM_PROGRAM"))
|
||||
if strings.Contains(program, "iterm") {
|
||||
if dottedVersionAtLeast(getenv("TERM_PROGRAM_VERSION"), 3, 6, 0) {
|
||||
return ImageSupport{Protocol: ImageProtocolKittyLocalFile}
|
||||
}
|
||||
return ImageSupport{Reason: "Terminal companions require iTerm2 3.6 or newer."}
|
||||
}
|
||||
if strings.Contains(term, "ghostty") || strings.Contains(program, "ghostty") || strings.TrimSpace(getenv("GHOSTTY_RESOURCES_DIR")) != "" ||
|
||||
strings.Contains(term, "kitty") || strings.Contains(program, "kitty") || strings.Contains(term, "wezterm") || strings.Contains(program, "wezterm") {
|
||||
return ImageSupport{Protocol: ImageProtocolKitty}
|
||||
}
|
||||
if strings.TrimSpace(getenv("WT_SESSION")) != "" || strings.Contains(term, "sixel") || strings.Contains(term, "mlterm") || strings.Contains(term, "foot") {
|
||||
return ImageSupport{Protocol: ImageProtocolSixel}
|
||||
}
|
||||
return ImageSupport{Reason: "Terminal companions need Kitty graphics or Sixel image support."}
|
||||
}
|
||||
|
||||
type ImageDraw struct {
|
||||
ID uint32
|
||||
Animation *Animation
|
||||
State State
|
||||
Phase int
|
||||
X int
|
||||
Y int
|
||||
OffsetX int
|
||||
OffsetY int
|
||||
Columns int
|
||||
Rows int
|
||||
HeightPixels int
|
||||
}
|
||||
|
||||
type imageDrawKey struct {
|
||||
protocol ImageProtocol
|
||||
id uint32
|
||||
animation *Animation
|
||||
frame frameCacheKey
|
||||
x int
|
||||
y int
|
||||
offsetX int
|
||||
offsetY int
|
||||
columns int
|
||||
rows int
|
||||
height int
|
||||
}
|
||||
|
||||
type ImageRenderer struct {
|
||||
mu sync.Mutex
|
||||
support ImageSupport
|
||||
cache string
|
||||
desired *ImageDraw
|
||||
last *imageDrawKey
|
||||
}
|
||||
|
||||
func NewImageRenderer(support ImageSupport) *ImageRenderer {
|
||||
return &ImageRenderer{support: support}
|
||||
}
|
||||
|
||||
func NewImageRendererWithCache(support ImageSupport, cacheDir string) *ImageRenderer {
|
||||
return &ImageRenderer{support: support, cache: strings.TrimSpace(cacheDir)}
|
||||
}
|
||||
|
||||
func (r *ImageRenderer) Support() ImageSupport {
|
||||
if r == nil {
|
||||
return ImageSupport{Reason: "Terminal image rendering is unavailable."}
|
||||
}
|
||||
return r.support
|
||||
}
|
||||
|
||||
func (r *ImageRenderer) Set(draw *ImageDraw) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if draw == nil {
|
||||
r.desired = nil
|
||||
return
|
||||
}
|
||||
copyValue := *draw
|
||||
r.desired = ©Value
|
||||
}
|
||||
|
||||
func (r *ImageRenderer) Invalidate() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.last = nil
|
||||
}
|
||||
|
||||
func (r *ImageRenderer) Render(writer io.Writer) error {
|
||||
if r == nil || writer == nil {
|
||||
return nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.support.Supported() {
|
||||
return nil
|
||||
}
|
||||
|
||||
var desiredKey *imageDrawKey
|
||||
var pngBytes []byte
|
||||
if r.desired != nil && r.desired.Animation != nil {
|
||||
var frameKey frameCacheKey
|
||||
var err error
|
||||
pngBytes, frameKey, err = r.desired.Animation.framePNG(r.desired.State, r.desired.Phase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
desiredKey = &imageDrawKey{
|
||||
protocol: r.support.Protocol, id: r.desired.ID, animation: r.desired.Animation, frame: frameKey,
|
||||
x: r.desired.X, y: r.desired.Y, offsetX: r.desired.OffsetX, offsetY: r.desired.OffsetY,
|
||||
columns: r.desired.Columns, rows: r.desired.Rows,
|
||||
height: r.desired.HeightPixels,
|
||||
}
|
||||
}
|
||||
if imageKeysEqual(r.last, desiredKey) {
|
||||
return nil
|
||||
}
|
||||
if kittyImageContentEqual(r.last, desiredKey) {
|
||||
if _, err := fmt.Fprintf(writer, "\x1b[s\x1b[%d;%dH%s\x1b[u", desiredKey.y+1, desiredKey.x+1, kittyPlace(desiredKey.id, desiredKey.columns, desiredKey.rows, desiredKey.offsetX, desiredKey.offsetY)); err != nil {
|
||||
return err
|
||||
}
|
||||
copyKey := *desiredKey
|
||||
r.last = ©Key
|
||||
return nil
|
||||
}
|
||||
if r.last != nil {
|
||||
if err := clearRenderedImage(writer, *r.last); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if desiredKey == nil {
|
||||
r.last = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(writer, "\x1b[s\x1b[%d;%dH", desiredKey.y+1, desiredKey.x+1); err != nil {
|
||||
return err
|
||||
}
|
||||
switch r.support.Protocol {
|
||||
case ImageProtocolKitty:
|
||||
if _, err := io.WriteString(writer, kittyTransmitPNG(pngBytes, desiredKey.id, desiredKey.columns, desiredKey.rows, desiredKey.offsetX, desiredKey.offsetY)); err != nil {
|
||||
return err
|
||||
}
|
||||
case ImageProtocolKittyLocalFile:
|
||||
path, err := cachePNGFrame(r.cache, pngBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(writer, kittyTransmitPNGFile(path, desiredKey.id, desiredKey.columns, desiredKey.rows, desiredKey.offsetX, desiredKey.offsetY)); err != nil {
|
||||
return err
|
||||
}
|
||||
case ImageProtocolSixel:
|
||||
frame := r.desired.Animation.Frame(r.desired.State, r.desired.Phase)
|
||||
sixel, err := renderSixel(frame, r.desired.HeightPixels)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write(sixel); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if _, err := io.WriteString(writer, "\x1b[u"); err != nil {
|
||||
return err
|
||||
}
|
||||
copyKey := *desiredKey
|
||||
r.last = ©Key
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ImageRenderer) Clear(writer io.Writer) error {
|
||||
r.Set(nil)
|
||||
return r.Render(writer)
|
||||
}
|
||||
|
||||
// DeleteImages removes known application-owned Kitty image placements even when
|
||||
// this renderer no longer considers them active. This is intended for process
|
||||
// shutdown and recovery from a previous interrupted render.
|
||||
func (r *ImageRenderer) DeleteImages(writer io.Writer, ids ...uint32) error {
|
||||
if r == nil || writer == nil {
|
||||
return nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.desired = nil
|
||||
r.last = nil
|
||||
if r.support.Protocol != ImageProtocolKitty && r.support.Protocol != ImageProtocolKittyLocalFile {
|
||||
return nil
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := io.WriteString(writer, kittyDelete(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func imageKeysEqual(left, right *imageDrawKey) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == right
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func kittyImageContentEqual(left, right *imageDrawKey) bool {
|
||||
if left == nil || right == nil || left.protocol != right.protocol {
|
||||
return false
|
||||
}
|
||||
if left.protocol != ImageProtocolKitty && left.protocol != ImageProtocolKittyLocalFile {
|
||||
return false
|
||||
}
|
||||
leftValue, rightValue := *left, *right
|
||||
leftValue.x, leftValue.y = 0, 0
|
||||
leftValue.offsetX, leftValue.offsetY = 0, 0
|
||||
rightValue.x, rightValue.y = 0, 0
|
||||
rightValue.offsetX, rightValue.offsetY = 0, 0
|
||||
return leftValue == rightValue
|
||||
}
|
||||
|
||||
func kittyDelete(id uint32) string {
|
||||
return fmt.Sprintf("\x1b_Ga=d,d=I,i=%d,q=2;\x1b\\", id)
|
||||
}
|
||||
|
||||
func clearRenderedImage(writer io.Writer, key imageDrawKey) (err error) {
|
||||
if key.protocol == ImageProtocolKitty || key.protocol == ImageProtocolKittyLocalFile {
|
||||
_, err := io.WriteString(writer, kittyDelete(key.id))
|
||||
return err
|
||||
}
|
||||
if key.protocol != ImageProtocolSixel {
|
||||
return nil
|
||||
}
|
||||
// Sixel is the only protocol here that erases by PAINTING CHARACTERS. Kitty
|
||||
// hands the terminal an id and the placement goes away; a sixel is already in
|
||||
// the cell grid, so the only way to remove it is to write over it. That makes
|
||||
// this the one path where the caller's SGR state matters, and it was not
|
||||
// being normalised.
|
||||
//
|
||||
// The spaces below are ordinary characters, so they carry whatever background
|
||||
// the TUI last set. Every erase therefore stamped a filled block in the
|
||||
// ambient colour, and since a drag erases and repaints on every pixel, that
|
||||
// block was continuously refreshed over the interface. It stayed invisible on
|
||||
// a terminal whose ambient background matched its theme, which is why it
|
||||
// never appeared on the Kitty-protocol terminals — ghostty, kitty, WezTerm,
|
||||
// iTerm — that do not reach this code at all. Windows Terminal is the only
|
||||
// mainstream terminal that lands here.
|
||||
//
|
||||
// DECSC/DECRC rather than CSI s/u: this now has to put back the SGR state as
|
||||
// well as the cursor, and only DECSC saves attributes. Restoring them matters
|
||||
// because the erase is emitted mid-frame, between styled writes the TUI has
|
||||
// already started.
|
||||
if _, err = io.WriteString(writer, "\x1b7\x1b[0m"); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if _, restoreErr := io.WriteString(writer, "\x1b8"); err == nil {
|
||||
err = restoreErr
|
||||
}
|
||||
}()
|
||||
for row := 0; row < key.rows; row++ {
|
||||
if _, err = fmt.Fprintf(writer, "\x1b[%d;%dH%s", key.y+row+1, key.x+1, strings.Repeat(" ", key.columns)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func kittyTransmitPNGFile(path string, id uint32, columns, rows, offsetX, offsetY int) string {
|
||||
payload := base64.StdEncoding.EncodeToString([]byte(path))
|
||||
return fmt.Sprintf("\x1b_Ga=T,t=f,f=100,c=%d,r=%d%s,q=2,C=1,i=%d,p=%d;%s\x1b\\", columns, rows, kittyOffsetControl(offsetX, offsetY), id, id, payload)
|
||||
}
|
||||
|
||||
func kittyPlace(id uint32, columns, rows, offsetX, offsetY int) string {
|
||||
return fmt.Sprintf("\x1b_Ga=p,i=%d,p=%d,c=%d,r=%d%s,q=2,C=1;\x1b\\", id, id, columns, rows, kittyOffsetControl(offsetX, offsetY))
|
||||
}
|
||||
|
||||
func kittyOffsetControl(offsetX, offsetY int) string {
|
||||
if offsetX == 0 && offsetY == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(",X=%d,Y=%d", offsetX, offsetY)
|
||||
}
|
||||
|
||||
func cachePNGFrame(cacheDir string, pngBytes []byte) (string, error) {
|
||||
if cacheDir == "" {
|
||||
return "", fmt.Errorf("terminal pet image cache is unavailable")
|
||||
}
|
||||
if err := os.MkdirAll(cacheDir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("create terminal pet image cache: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(pngBytes)
|
||||
path := filepath.Join(cacheDir, fmt.Sprintf("%x.png", digest))
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("inspect cached terminal pet frame: %w", err)
|
||||
}
|
||||
if err := installtxn.WriteFileAtomically(path, pngBytes, 0o600); err != nil {
|
||||
return "", fmt.Errorf("cache terminal pet frame: %w", err)
|
||||
}
|
||||
}
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve cached terminal pet frame: %w", err)
|
||||
}
|
||||
return absolute, nil
|
||||
}
|
||||
|
||||
func dottedVersionAtLeast(value string, major, minor, patch int) bool {
|
||||
parts := strings.Split(strings.TrimSpace(value), ".")
|
||||
if len(parts) == 0 || len(parts) > 3 {
|
||||
return false
|
||||
}
|
||||
parsed := [3]int{}
|
||||
for index, part := range parts {
|
||||
digits := strings.TrimLeftFunc(part, func(value rune) bool { return value < '0' || value > '9' })
|
||||
end := 0
|
||||
for end < len(digits) && digits[end] >= '0' && digits[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
if end == 0 {
|
||||
return false
|
||||
}
|
||||
number, err := strconv.Atoi(digits[:end])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
parsed[index] = number
|
||||
}
|
||||
want := [3]int{major, minor, patch}
|
||||
for index := range parsed {
|
||||
if parsed[index] != want[index] {
|
||||
return parsed[index] > want[index]
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func renderSixel(frame image.Image, heightPixels int) ([]byte, error) {
|
||||
if frame == nil || frame.Bounds().Empty() {
|
||||
return nil, fmt.Errorf("pet animation has no frame to encode")
|
||||
}
|
||||
if heightPixels < 1 {
|
||||
heightPixels = 75
|
||||
}
|
||||
bounds := frame.Bounds()
|
||||
widthPixels := max(1, bounds.Dx()*heightPixels/bounds.Dy())
|
||||
scaled := image.NewNRGBA(image.Rect(0, 0, widthPixels, heightPixels))
|
||||
xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), frame, bounds, xdraw.Over, nil)
|
||||
return encodeSixel(scaled)
|
||||
}
|
||||
|
||||
func kittyTransmitPNG(pngBytes []byte, id uint32, columns, rows, offsetX, offsetY int) string {
|
||||
payload := base64.StdEncoding.EncodeToString(pngBytes)
|
||||
var output strings.Builder
|
||||
for offset := 0; offset < len(payload); offset += kittyChunkSize {
|
||||
end := min(offset+kittyChunkSize, len(payload))
|
||||
more := 0
|
||||
if end < len(payload) {
|
||||
more = 1
|
||||
}
|
||||
if offset == 0 {
|
||||
fmt.Fprintf(&output, "\x1b_Ga=T,t=d,f=100,c=%d,r=%d%s,q=2,C=1,i=%d,p=%d,m=%d;%s\x1b\\", columns, rows, kittyOffsetControl(offsetX, offsetY), id, id, more, payload[offset:end])
|
||||
} else {
|
||||
fmt.Fprintf(&output, "\x1b_Gm=%d;%s\x1b\\", more, payload[offset:end])
|
||||
}
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"image/color"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectImageSupportUsesKittyGraphicsInGhostty(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"TERM": "xterm-ghostty",
|
||||
"TERM_PROGRAM": "ghostty",
|
||||
}
|
||||
support := DetectImageSupport(func(name string) string { return env[name] })
|
||||
if support.Protocol != ImageProtocolKitty {
|
||||
t.Fatalf("Ghostty protocol = %v, want Kitty", support.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectImageSupportRejectsTmuxBeforeTerminalProtocol(t *testing.T) {
|
||||
env := map[string]string{"TMUX": "/tmp/tmux-1000/default,1,0", "TERM": "xterm-ghostty"}
|
||||
support := DetectImageSupport(func(name string) string { return env[name] })
|
||||
if support.Supported() || !strings.Contains(strings.ToLower(support.Reason), "tmux") {
|
||||
t.Fatalf("tmux support = %#v, want unsupported tmux reason", support)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectImageSupportUsesSixelInWindowsTerminal(t *testing.T) {
|
||||
env := map[string]string{"WT_SESSION": "session-id", "TERM": "xterm-256color"}
|
||||
support := DetectImageSupport(func(name string) string { return env[name] })
|
||||
if support.Protocol != ImageProtocolSixel {
|
||||
t.Fatalf("Windows Terminal protocol = %v, want Sixel", support.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectImageSupportUsesLocalKittyFilesInNewIterm(t *testing.T) {
|
||||
env := map[string]string{"TERM_PROGRAM": "iTerm.app", "TERM_PROGRAM_VERSION": "3.6.1"}
|
||||
support := DetectImageSupport(func(name string) string { return env[name] })
|
||||
if support.Protocol != ImageProtocolKittyLocalFile {
|
||||
t.Fatalf("iTerm2 protocol = %v, want Kitty local file", support.Protocol)
|
||||
}
|
||||
env["TERM_PROGRAM_VERSION"] = "3.5.9"
|
||||
support = DetectImageSupport(func(name string) string { return env[name] })
|
||||
if support.Supported() || !strings.Contains(support.Reason, "3.6") {
|
||||
t.Fatalf("old iTerm2 support = %#v, want version error", support)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageRendererTransmitsPNGAndDeletesItWhenCleared(t *testing.T) {
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 2, 2))
|
||||
frame.SetNRGBA(0, 0, color.NRGBA{R: 240, G: 80, B: 120, A: 255})
|
||||
animation, err := ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := NewImageRenderer(ImageSupport{Protocol: ImageProtocolKitty})
|
||||
renderer.Set(&ImageDraw{
|
||||
ID: 0xC0DF,
|
||||
Animation: animation,
|
||||
State: Idle,
|
||||
Columns: 11,
|
||||
Rows: 6,
|
||||
X: 20,
|
||||
Y: 10,
|
||||
})
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := renderer.Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := output.String()
|
||||
for _, want := range []string{"\x1b[11;21H", "_Ga=T,t=d,f=100,c=11,r=6,q=2,C=1,i=49375,p=49375", "iVBOR"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("Kitty output does not contain %q: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
renderer.Set(nil)
|
||||
if err := renderer.Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := output.String(); !strings.Contains(got, "_Ga=d,d=I,i=49375,q=2") {
|
||||
t.Fatalf("clear output did not delete image: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageRendererMovesExistingKittyPlacementWithoutRetransmitting(t *testing.T) {
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 2, 2))
|
||||
frame.SetNRGBA(0, 0, color.NRGBA{R: 240, A: 255})
|
||||
animation, err := ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := NewImageRenderer(ImageSupport{Protocol: ImageProtocolKitty})
|
||||
draw := &ImageDraw{ID: 31, Animation: animation, State: Idle, Columns: 4, Rows: 3, X: 2, Y: 3}
|
||||
renderer.Set(draw)
|
||||
var output bytes.Buffer
|
||||
if err := renderer.Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
draw.X, draw.Y = 18, 9
|
||||
draw.OffsetX, draw.OffsetY = 3, 7
|
||||
renderer.Set(draw)
|
||||
if err := renderer.Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := output.String()
|
||||
if !strings.Contains(got, "\x1b[10;19H") || !strings.Contains(got, "_Ga=p,i=31,p=31,c=4,r=3,X=3,Y=7,q=2,C=1;") {
|
||||
t.Fatalf("placement-only move missing: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "a=T") || strings.Contains(got, "iVBOR") || strings.Contains(got, "a=d") {
|
||||
t.Fatalf("placement-only move retransmitted or deleted image data: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageRendererEmitsSixelForSixelTerminal(t *testing.T) {
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 2, 2))
|
||||
frame.SetNRGBA(0, 0, color.NRGBA{R: 255, A: 255})
|
||||
animation, err := ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := NewImageRenderer(ImageSupport{Protocol: ImageProtocolSixel})
|
||||
renderer.Set(&ImageDraw{ID: 9, Animation: animation, State: Idle, Columns: 4, Rows: 3, HeightPixels: 12})
|
||||
var output bytes.Buffer
|
||||
if err := renderer.Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := output.String()
|
||||
if !strings.Contains(got, "\x1bP9;1;0q") || !strings.HasSuffix(got, "\x1b\\\x1b[u") {
|
||||
t.Fatalf("Sixel output is missing its image payload: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "_Ga=T") {
|
||||
t.Fatalf("Sixel renderer emitted Kitty graphics: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageRendererUsesLocalPNGForIterm(t *testing.T) {
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
frame.SetNRGBA(0, 0, color.NRGBA{G: 255, A: 255})
|
||||
animation, err := ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := NewImageRendererWithCache(ImageSupport{Protocol: ImageProtocolKittyLocalFile}, t.TempDir())
|
||||
renderer.Set(&ImageDraw{ID: 11, Animation: animation, State: Idle, Columns: 4, Rows: 3})
|
||||
var output bytes.Buffer
|
||||
if err := renderer.Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := output.String()
|
||||
prefix := "_Ga=T,t=f,f=100,c=4,r=3,q=2,C=1,i=11,p=11;"
|
||||
start := strings.Index(got, prefix)
|
||||
if start < 0 {
|
||||
t.Fatalf("local-file command missing: %q", got)
|
||||
}
|
||||
payload := got[start+len(prefix):]
|
||||
payload = strings.SplitN(payload, "\x1b\\", 2)[0]
|
||||
pathBytes, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("decode local-file payload: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(string(pathBytes)); err != nil {
|
||||
t.Fatalf("cached PNG path is unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DisabledID = "disabled"
|
||||
DefaultManifestURL = "https://petdex.dev/api/manifest/v2"
|
||||
DefaultRankingURL = "https://petdex.dev/api/pets/search?sort=installed&limit=60&includeMeta=0"
|
||||
TrustedAssetHost = "assets.petdex.dev"
|
||||
previewFrameCount = 6
|
||||
atlasColumns = 8
|
||||
)
|
||||
|
||||
type State string
|
||||
|
||||
const (
|
||||
Idle State = "idle"
|
||||
MoveRight State = "running-right"
|
||||
MoveLeft State = "running-left"
|
||||
Waving State = "waving"
|
||||
Jumping State = "jumping"
|
||||
Running State = "running"
|
||||
Waiting State = "waiting"
|
||||
Review State = "review"
|
||||
Failed State = "failed"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Slug string `json:"slug"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
SubmittedBy string `json:"submittedBy,omitempty"`
|
||||
SpritesheetURL string `json:"spritesheet"`
|
||||
PetJSONURL string `json:"petJson,omitempty"`
|
||||
AssetBase string `json:"assetBase,omitempty"`
|
||||
SpriteVersion int `json:"spriteVersionNumber"`
|
||||
Local bool `json:"-"`
|
||||
}
|
||||
|
||||
func (e Entry) Label() string {
|
||||
if name := strings.TrimSpace(e.DisplayName); name != "" {
|
||||
return name
|
||||
}
|
||||
return e.Slug
|
||||
}
|
||||
|
||||
type Animation struct {
|
||||
frames map[State][]image.Image
|
||||
durations map[State][]time.Duration
|
||||
loopStarts map[State]int
|
||||
clickAnimations []State
|
||||
pngMu sync.Mutex
|
||||
pngCache map[frameCacheKey][]byte
|
||||
}
|
||||
|
||||
type atlasTrack struct {
|
||||
row int
|
||||
count int
|
||||
durations []time.Duration
|
||||
repeat int
|
||||
loop bool
|
||||
fallbackIdle bool
|
||||
}
|
||||
|
||||
type frameCacheKey struct {
|
||||
state State
|
||||
index int
|
||||
}
|
||||
|
||||
func (a *Animation) Frame(state State, phase int) image.Image {
|
||||
frame, _ := a.frame(state, phase)
|
||||
return frame
|
||||
}
|
||||
|
||||
func (a *Animation) FrameDelay(state State, phase int) time.Duration {
|
||||
if a == nil {
|
||||
return 0
|
||||
}
|
||||
frames := a.frames[state]
|
||||
if len(frames) == 0 {
|
||||
state = Idle
|
||||
frames = a.frames[state]
|
||||
}
|
||||
if len(frames) == 0 {
|
||||
return 0
|
||||
}
|
||||
durations := a.durations[state]
|
||||
if len(durations) != len(frames) {
|
||||
return 0
|
||||
}
|
||||
return durations[a.frameIndex(state, phase, len(frames))]
|
||||
}
|
||||
|
||||
// PrimaryDuration reports how long a non-idle action plays before it settles
|
||||
// into its fallback loop. A zero duration means the state loops from its first
|
||||
// frame or has no configured playback boundary.
|
||||
func (a *Animation) PrimaryDuration(state State) time.Duration {
|
||||
if a == nil {
|
||||
return 0
|
||||
}
|
||||
loopStart, ok := a.loopStarts[state]
|
||||
durations := a.durations[state]
|
||||
if !ok || loopStart == 0 || loopStart > len(durations) || len(durations) == 0 {
|
||||
return 0
|
||||
}
|
||||
if loopStart < 0 {
|
||||
loopStart = len(durations)
|
||||
}
|
||||
var total time.Duration
|
||||
for _, duration := range durations[:loopStart] {
|
||||
total += duration
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// ClickDuration reports the duration of an authored click action.
|
||||
// The boolean is false for ordinary activity states and built-in interactions.
|
||||
func (a *Animation) ClickDuration(state State) (time.Duration, bool) {
|
||||
if a == nil {
|
||||
return 0, false
|
||||
}
|
||||
isClick := false
|
||||
for _, candidate := range a.clickAnimations {
|
||||
if candidate == state {
|
||||
isClick = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isClick {
|
||||
return 0, false
|
||||
}
|
||||
return a.PrimaryDuration(state), true
|
||||
}
|
||||
|
||||
func (a *Animation) ClickAnimation(index int) (State, bool) {
|
||||
if a == nil || len(a.clickAnimations) == 0 {
|
||||
return "", false
|
||||
}
|
||||
if index < 0 {
|
||||
index = -index
|
||||
}
|
||||
return a.clickAnimations[index%len(a.clickAnimations)], true
|
||||
}
|
||||
|
||||
func (a *Animation) setClickAnimations(names []string) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.clickAnimations = a.clickAnimations[:0]
|
||||
seen := make(map[State]bool, len(names))
|
||||
for _, name := range names {
|
||||
state := State(strings.ToLower(strings.TrimSpace(name)))
|
||||
if state == "" || seen[state] || len(a.frames[state]) == 0 {
|
||||
continue
|
||||
}
|
||||
seen[state] = true
|
||||
a.clickAnimations = append(a.clickAnimations, state)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Animation) frame(state State, phase int) (image.Image, frameCacheKey) {
|
||||
if a == nil {
|
||||
return nil, frameCacheKey{}
|
||||
}
|
||||
frames := a.frames[state]
|
||||
if len(frames) == 0 {
|
||||
state = Idle
|
||||
frames = a.frames[Idle]
|
||||
}
|
||||
if len(frames) == 0 {
|
||||
return nil, frameCacheKey{}
|
||||
}
|
||||
index := a.frameIndex(state, phase, len(frames))
|
||||
return frames[index], frameCacheKey{state: state, index: index}
|
||||
}
|
||||
|
||||
func (a *Animation) frameIndex(state State, phase, frameCount int) int {
|
||||
if frameCount < 1 {
|
||||
return 0
|
||||
}
|
||||
if phase < 0 {
|
||||
phase = -phase
|
||||
}
|
||||
loopStart, configured := a.loopStarts[state]
|
||||
if !configured {
|
||||
return phase % frameCount
|
||||
}
|
||||
if phase < frameCount {
|
||||
return phase
|
||||
}
|
||||
if loopStart >= 0 && loopStart < frameCount {
|
||||
return loopStart + (phase-loopStart)%(frameCount-loopStart)
|
||||
}
|
||||
return frameCount - 1
|
||||
}
|
||||
|
||||
func (a *Animation) framePNG(state State, phase int) ([]byte, frameCacheKey, error) {
|
||||
frame, key := a.frame(state, phase)
|
||||
if frame == nil {
|
||||
return nil, key, fmt.Errorf("pet animation has no frames")
|
||||
}
|
||||
a.pngMu.Lock()
|
||||
defer a.pngMu.Unlock()
|
||||
if cached := a.pngCache[key]; len(cached) > 0 {
|
||||
return cached, key, nil
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, frame); err != nil {
|
||||
return nil, key, fmt.Errorf("encode pet frame: %w", err)
|
||||
}
|
||||
if a.pngCache == nil {
|
||||
a.pngCache = make(map[frameCacheKey][]byte)
|
||||
}
|
||||
value := append([]byte(nil), encoded.Bytes()...)
|
||||
a.pngCache[key] = value
|
||||
return value, key, nil
|
||||
}
|
||||
|
||||
func PreviewAnimation(sheet image.Image) (*Animation, error) {
|
||||
if sheet == nil {
|
||||
return nil, fmt.Errorf("preview image is empty")
|
||||
}
|
||||
bounds := sheet.Bounds()
|
||||
if bounds.Dx() < previewFrameCount || bounds.Dx()%previewFrameCount != 0 || bounds.Dy() < 1 {
|
||||
return nil, fmt.Errorf("preview must contain %d equal-width frames", previewFrameCount)
|
||||
}
|
||||
frames := splitRow(sheet, previewFrameCount, 0, bounds.Dy())
|
||||
return &Animation{
|
||||
frames: map[State][]image.Image{Idle: frames},
|
||||
durations: map[State][]time.Duration{Idle: repeatedDurations(len(frames), 140*time.Millisecond)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ThumbnailAnimation(imageValue image.Image) (*Animation, error) {
|
||||
if imageValue == nil || imageValue.Bounds().Empty() {
|
||||
return nil, fmt.Errorf("thumbnail image is empty")
|
||||
}
|
||||
return &Animation{frames: map[State][]image.Image{Idle: {imageValue}}}, nil
|
||||
}
|
||||
|
||||
func AtlasAnimation(sheet image.Image, spriteVersion int) (*Animation, error) {
|
||||
return atlasAnimation(sheet, spriteVersion, nil)
|
||||
}
|
||||
|
||||
func atlasAnimation(sheet image.Image, spriteVersion int, overrides map[State]atlasTrack) (*Animation, error) {
|
||||
if sheet == nil {
|
||||
return nil, fmt.Errorf("spritesheet is empty")
|
||||
}
|
||||
rows := 9
|
||||
if spriteVersion == 2 {
|
||||
rows = 11
|
||||
} else if spriteVersion != 0 && spriteVersion != 1 {
|
||||
return nil, fmt.Errorf("unsupported sprite version %d", spriteVersion)
|
||||
}
|
||||
bounds := sheet.Bounds()
|
||||
if bounds.Dx()%atlasColumns != 0 || bounds.Dy()%rows != 0 {
|
||||
return nil, fmt.Errorf("spritesheet must be an 8x%d grid", rows)
|
||||
}
|
||||
frameWidth := bounds.Dx() / atlasColumns
|
||||
frameHeight := bounds.Dy() / rows
|
||||
if frameWidth < 1 || frameHeight < 1 || frameWidth*208 != frameHeight*192 {
|
||||
return nil, fmt.Errorf("spritesheet frames must use the 192x208 aspect ratio")
|
||||
}
|
||||
stateRows := map[State]atlasTrack{
|
||||
Idle: {row: 0, count: 6, durations: durations(1680, 660, 660, 840, 840, 1920), loop: true},
|
||||
MoveRight: {row: 1, count: 8, durations: finalFrameDurations(8, 120, 220), repeat: 3, fallbackIdle: true},
|
||||
MoveLeft: {row: 2, count: 8, durations: finalFrameDurations(8, 120, 220), repeat: 3, fallbackIdle: true},
|
||||
Waving: {row: 3, count: 4, durations: finalFrameDurations(4, 140, 280), repeat: 3, fallbackIdle: true},
|
||||
Jumping: {row: 4, count: 5, durations: finalFrameDurations(5, 140, 280), repeat: 3, fallbackIdle: true},
|
||||
Failed: {row: 5, count: 8, durations: finalFrameDurations(8, 140, 240), repeat: 3, fallbackIdle: true},
|
||||
Waiting: {row: 6, count: 6, durations: finalFrameDurations(6, 150, 260), repeat: 3, fallbackIdle: true},
|
||||
Running: {row: 7, count: 6, durations: finalFrameDurations(6, 120, 220), repeat: 3, fallbackIdle: true},
|
||||
Review: {row: 8, count: 6, durations: finalFrameDurations(6, 150, 280), repeat: 3, fallbackIdle: true},
|
||||
}
|
||||
for state, override := range overrides {
|
||||
stateRows[state] = override
|
||||
}
|
||||
rawFrames := make(map[State][]image.Image, len(stateRows))
|
||||
for state, spec := range stateRows {
|
||||
if spec.row < 0 || spec.row >= rows || spec.count < 1 || spec.count > atlasColumns {
|
||||
return nil, fmt.Errorf("invalid %s animation geometry", state)
|
||||
}
|
||||
if len(spec.durations) == 0 {
|
||||
spec.durations = repeatedDurations(spec.count, 180*time.Millisecond)
|
||||
stateRows[state] = spec
|
||||
} else if len(spec.durations) != spec.count {
|
||||
return nil, fmt.Errorf("%s animation has %d durations for %d frames", state, len(spec.durations), spec.count)
|
||||
}
|
||||
rawFrames[state] = splitRow(sheet, atlasColumns, spec.row*frameHeight, frameHeight)[:spec.count]
|
||||
}
|
||||
idleFrames := rawFrames[Idle]
|
||||
idleDurations := stateRows[Idle].durations
|
||||
frames := make(map[State][]image.Image, len(stateRows))
|
||||
frameDurations := make(map[State][]time.Duration, len(stateRows))
|
||||
loopStarts := make(map[State]int, len(stateRows))
|
||||
for state, spec := range stateRows {
|
||||
repeat := max(1, spec.repeat)
|
||||
for range repeat {
|
||||
frames[state] = append(frames[state], rawFrames[state]...)
|
||||
if len(spec.durations) == spec.count {
|
||||
frameDurations[state] = append(frameDurations[state], spec.durations...)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case spec.fallbackIdle && state != Idle:
|
||||
loopStarts[state] = len(frames[state])
|
||||
frames[state] = append(frames[state], idleFrames...)
|
||||
frameDurations[state] = append(frameDurations[state], idleDurations...)
|
||||
case spec.loop:
|
||||
loopStarts[state] = 0
|
||||
default:
|
||||
loopStarts[state] = -1
|
||||
}
|
||||
}
|
||||
return &Animation{frames: frames, durations: frameDurations, loopStarts: loopStarts}, nil
|
||||
}
|
||||
|
||||
func durations(milliseconds ...int) []time.Duration {
|
||||
result := make([]time.Duration, len(milliseconds))
|
||||
for index, value := range milliseconds {
|
||||
result[index] = time.Duration(value) * time.Millisecond
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func repeatedDurations(count int, duration time.Duration) []time.Duration {
|
||||
result := make([]time.Duration, count)
|
||||
for index := range result {
|
||||
result[index] = duration
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func finalFrameDurations(count, regularMilliseconds, finalMilliseconds int) []time.Duration {
|
||||
result := repeatedDurations(count, time.Duration(regularMilliseconds)*time.Millisecond)
|
||||
if len(result) > 0 {
|
||||
result[len(result)-1] = time.Duration(finalMilliseconds) * time.Millisecond
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func splitRow(sheet image.Image, count, y, height int) []image.Image {
|
||||
bounds := sheet.Bounds()
|
||||
width := bounds.Dx() / count
|
||||
frames := make([]image.Image, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
frames = append(frames, cropImage{
|
||||
source: sheet,
|
||||
bounds: image.Rect(0, 0, width, height),
|
||||
offset: image.Pt(bounds.Min.X+index*width, bounds.Min.Y+y),
|
||||
})
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
type cropImage struct {
|
||||
source image.Image
|
||||
bounds image.Rectangle
|
||||
offset image.Point
|
||||
}
|
||||
|
||||
func (c cropImage) ColorModel() color.Model { return c.source.ColorModel() }
|
||||
func (c cropImage) Bounds() image.Rectangle { return c.bounds }
|
||||
func (c cropImage) At(x, y int) color.Color {
|
||||
return c.source.At(x+c.offset.X, y+c.offset.Y)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClickDurationEndsOnceAnimationWithoutGenericHold(t *testing.T) {
|
||||
action := State("custom-action")
|
||||
animation := &Animation{
|
||||
durations: map[State][]time.Duration{
|
||||
action: {200 * time.Millisecond, 100 * time.Millisecond, 300 * time.Millisecond, 200 * time.Millisecond},
|
||||
},
|
||||
loopStarts: map[State]int{action: -1},
|
||||
clickAnimations: []State{action},
|
||||
}
|
||||
|
||||
if got := animation.PrimaryDuration(action); got != 800*time.Millisecond {
|
||||
t.Fatalf("once-action duration = %s, want 800ms", got)
|
||||
}
|
||||
if got, ok := animation.ClickDuration(action); !ok || got != 800*time.Millisecond {
|
||||
t.Fatalf("click duration = %s, %t; want 800ms, true", got, ok)
|
||||
}
|
||||
if got, ok := animation.ClickDuration(Running); ok || got != 0 {
|
||||
t.Fatalf("ordinary activity duration = %s, %t; want 0, false", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtlasAnimationRejectsMismatchedOverrideDurations(t *testing.T) {
|
||||
sheet := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
|
||||
_, err := atlasAnimation(sheet, 1, map[State]atlasTrack{
|
||||
Idle: {row: 0, count: 2, durations: []time.Duration{100 * time.Millisecond}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("atlasAnimation accepted mismatched override durations")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const sixelBandHeight = 6
|
||||
|
||||
func encodeSixel(source image.Image) ([]byte, error) {
|
||||
if source == nil || source.Bounds().Empty() {
|
||||
return nil, fmt.Errorf("sixel image dimensions must be non-zero")
|
||||
}
|
||||
bounds := source.Bounds()
|
||||
width, height := bounds.Dx(), bounds.Dy()
|
||||
pixels := make([]int16, width*height)
|
||||
for index := range pixels {
|
||||
pixels[index] = -1
|
||||
}
|
||||
used := make(map[uint8]struct{})
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
red, green, blue, alpha := source.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
|
||||
if alpha>>8 < 128 {
|
||||
continue
|
||||
}
|
||||
index := uint8((red>>13)<<5 | (green>>13)<<2 | blue>>14)
|
||||
pixels[y*width+x] = int16(index)
|
||||
used[index] = struct{}{}
|
||||
}
|
||||
}
|
||||
colors := make([]int, 0, len(used))
|
||||
for index := range used {
|
||||
colors = append(colors, int(index))
|
||||
}
|
||||
sort.Ints(colors)
|
||||
|
||||
var output strings.Builder
|
||||
output.WriteString("\x1bP9;1;0q")
|
||||
fmt.Fprintf(&output, "\"1;1;%d;%d", width, height)
|
||||
for _, index := range colors {
|
||||
red := ((index >> 5) & 7) * 100 / 7
|
||||
green := ((index >> 2) & 7) * 100 / 7
|
||||
blue := (index & 3) * 100 / 3
|
||||
fmt.Fprintf(&output, "#%d;2;%d;%d;%d", index, red, green, blue)
|
||||
}
|
||||
for bandTop := 0; bandTop < height; bandTop += sixelBandHeight {
|
||||
active := activeSixelColors(pixels, width, height, bandTop, colors)
|
||||
for colorPosition, colorIndex := range active {
|
||||
fmt.Fprintf(&output, "#%d", colorIndex)
|
||||
for x := 0; x < width; x++ {
|
||||
mask := byte(0)
|
||||
for bit := 0; bit < sixelBandHeight && bandTop+bit < height; bit++ {
|
||||
if pixels[(bandTop+bit)*width+x] == int16(colorIndex) {
|
||||
mask |= 1 << bit
|
||||
}
|
||||
}
|
||||
output.WriteByte('?' + mask)
|
||||
}
|
||||
if colorPosition+1 < len(active) {
|
||||
output.WriteByte('$')
|
||||
}
|
||||
}
|
||||
if bandTop+sixelBandHeight < height {
|
||||
if len(active) > 0 {
|
||||
output.WriteByte('$')
|
||||
}
|
||||
output.WriteByte('-')
|
||||
}
|
||||
}
|
||||
output.WriteString("\x1b\\")
|
||||
return []byte(output.String()), nil
|
||||
}
|
||||
|
||||
func activeSixelColors(pixels []int16, width, height, bandTop int, colors []int) []int {
|
||||
activeSet := make(map[int16]struct{})
|
||||
for y := bandTop; y < min(height, bandTop+sixelBandHeight); y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
if color := pixels[y*width+x]; color >= 0 {
|
||||
activeSet[color] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
active := make([]int, 0, len(activeSet))
|
||||
for _, color := range colors {
|
||||
if _, ok := activeSet[int16(color)]; ok {
|
||||
active = append(active, color)
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package terminalpet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A sixel erase paints characters, so it must not inherit the caller's colours.
|
||||
//
|
||||
// This is the black rectangle reported on Windows Terminal. The erase writes
|
||||
// literal spaces over the cells the image occupied, and those spaces are drawn
|
||||
// with whatever SGR was last set; without a reset every erase stamps a filled
|
||||
// block in the ambient background. A drag erases and repaints on every pixel, so
|
||||
// the block is continuously refreshed over the interface.
|
||||
//
|
||||
// It never showed on ghostty, kitty, WezTerm or iTerm because those resolve to
|
||||
// the Kitty protocol and delete by image id, never reaching this path. Windows
|
||||
// Terminal is the only mainstream terminal that does, which is why the platform
|
||||
// looked at fault when the real cause is an untested code path.
|
||||
func TestSixelEraseDoesNotPaintWithTheCallersColours(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
key := imageDrawKey{protocol: ImageProtocolSixel, x: 40, y: 2, columns: 6, rows: 3}
|
||||
if err := clearRenderedImage(&out, key); err != nil {
|
||||
t.Fatalf("clearRenderedImage: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
|
||||
reset := strings.Index(got, "\x1b[0m")
|
||||
if reset < 0 {
|
||||
t.Fatalf("the erase never resets SGR, so it paints a block in the ambient background: %q", got)
|
||||
}
|
||||
// Before the first space, not merely present somewhere.
|
||||
if firstSpace := strings.Index(got, strings.Repeat(" ", key.columns)); firstSpace >= 0 && reset > firstSpace {
|
||||
t.Errorf("SGR is reset only after the first row is painted, so that row still carries the caller's colours: %q", got)
|
||||
}
|
||||
// DECSC/DECRC, because only those restore the attributes this now clobbers.
|
||||
// CSI s/u save the cursor alone and would leave the TUI drawing in the reset
|
||||
// state for the remainder of the frame.
|
||||
if !strings.HasPrefix(got, "\x1b7") {
|
||||
t.Errorf("erase must open with DECSC to save cursor AND attributes, got %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "\x1b8") {
|
||||
t.Errorf("erase must close with DECRC to restore them, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The erase still covers exactly the cells the image claimed, or the fix above
|
||||
// would be satisfied by an erase that does nothing.
|
||||
func TestSixelEraseStillCoversEveryClaimedCell(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
key := imageDrawKey{protocol: ImageProtocolSixel, x: 40, y: 2, columns: 6, rows: 3}
|
||||
if err := clearRenderedImage(&out, key); err != nil {
|
||||
t.Fatalf("clearRenderedImage: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for row := 0; row < key.rows; row++ {
|
||||
position := fmt.Sprintf("\x1b[%d;%dH", key.y+row+1, key.x+1)
|
||||
erase := position + strings.Repeat(" ", key.columns)
|
||||
if !strings.Contains(got, erase) {
|
||||
t.Fatalf("erase does not position row %d at %q: %q", row, position, got)
|
||||
}
|
||||
}
|
||||
if strings.Count(got, strings.Repeat(" ", key.columns)) != key.rows {
|
||||
t.Errorf("erase covered %d rows of %d columns, want %d rows: %q",
|
||||
strings.Count(got, strings.Repeat(" ", key.columns)), key.columns, key.rows, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSixelEraseRestoresTerminalStateAfterRowWriteFailure(t *testing.T) {
|
||||
rowErr := errors.New("row write failed")
|
||||
writer := &failOnceWriter{failOnWrite: 2, err: rowErr}
|
||||
key := imageDrawKey{protocol: ImageProtocolSixel, x: 4, y: 2, columns: 3, rows: 2}
|
||||
|
||||
err := clearRenderedImage(writer, key)
|
||||
if !errors.Is(err, rowErr) {
|
||||
t.Fatalf("clearRenderedImage error = %v, want primary row error", err)
|
||||
}
|
||||
if !strings.HasSuffix(writer.String(), "\x1b8") {
|
||||
t.Fatalf("terminal state was not restored after row failure: %q", writer.String())
|
||||
}
|
||||
}
|
||||
|
||||
type failOnceWriter struct {
|
||||
bytes.Buffer
|
||||
writes int
|
||||
failOnWrite int
|
||||
err error
|
||||
}
|
||||
|
||||
func (w *failOnceWriter) Write(value []byte) (int, error) {
|
||||
w.writes++
|
||||
if w.writes == w.failOnWrite {
|
||||
return 0, w.err
|
||||
}
|
||||
return w.Buffer.Write(value)
|
||||
}
|
||||
|
||||
// Kitty must NOT gain the character-painting erase: it deletes by image id, and
|
||||
// writing spaces there would erase cells the terminal is about to redraw itself.
|
||||
func TestKittyEraseStillDeletesByImageID(t *testing.T) {
|
||||
for _, protocol := range []ImageProtocol{ImageProtocolKitty, ImageProtocolKittyLocalFile} {
|
||||
var out bytes.Buffer
|
||||
if err := clearRenderedImage(&out, imageDrawKey{protocol: protocol, id: 7, columns: 6, rows: 3}); err != nil {
|
||||
t.Fatalf("clearRenderedImage: %v", err)
|
||||
}
|
||||
if got := out.String(); strings.Contains(got, " ") || !strings.Contains(got, "\x1b_G") {
|
||||
t.Errorf("protocol %v should delete by id, not paint cells: %q", protocol, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,11 +162,3 @@ func ValidateCapabilities(name string, caps ToolCapabilities) []string {
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// ValidateToolCapabilities validates CapabilitiesOf(tool) for a registered tool.
|
||||
func ValidateToolCapabilities(tool Tool) []string {
|
||||
if tool == nil {
|
||||
return []string{"<nil tool>"}
|
||||
}
|
||||
return ValidateCapabilities(tool.Name(), CapabilitiesOf(tool))
|
||||
}
|
||||
@@ -78,14 +78,16 @@ func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenCon
|
||||
if !ok {
|
||||
return writtenContent
|
||||
}
|
||||
if _, err := exec.LookPath(command[0]); err != nil {
|
||||
binaryPath, err := exec.LookPath(command[0])
|
||||
if err != nil {
|
||||
return writtenContent
|
||||
}
|
||||
formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout)
|
||||
defer cancel()
|
||||
arguments := append(append([]string(nil), command[1:]...), absolutePath)
|
||||
formatter := exec.CommandContext(formatCtx, command[0], arguments...)
|
||||
formatter := exec.CommandContext(formatCtx, binaryPath, arguments...)
|
||||
formatter.Dir = filepath.Dir(absolutePath)
|
||||
formatter.Stdin = strings.NewReader("")
|
||||
if err := formatter.Run(); err != nil {
|
||||
return writtenContent
|
||||
}
|
||||
|
||||
@@ -102,3 +102,17 @@ func TestFormatOnWriteSkipsUnknownExtensions(t *testing.T) {
|
||||
t.Fatalf("unknown extension must pass through: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOnWriteFormatterLookupFailure(t *testing.T) {
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
targetPath := filepath.Join(t.TempDir(), "a.go")
|
||||
uglyContent := "package a\n\nfunc A( ) { }\n"
|
||||
if err := os.WriteFile(targetPath, []byte(uglyContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := maybeFormatWrittenFile(context.Background(), targetPath, uglyContent)
|
||||
if content != uglyContent {
|
||||
t.Fatalf("missing formatter must return written content, got %q", content)
|
||||
}
|
||||
}
|
||||
@@ -57,9 +57,10 @@ func pasteFromClipboardCmd() tea.Cmd {
|
||||
// shared by the terminal bracketed-paste handler (tea.PasteMsg) and the
|
||||
// right-click paste (clipboardReadMsg) so a bracketed paste and a right-click
|
||||
// paste behave identically. Surfaces with no editable text field (a permission/
|
||||
// spec prompt, the MCP manager, an open picker, the detailed transcript) swallow
|
||||
// the paste. The session rename editor accepts text only; empty content is a
|
||||
// no-op there rather than an image probe.
|
||||
// spec prompt, the MCP manager, or the detailed transcript) swallow the paste.
|
||||
// Searchable pickers consume pasted text through the same filtering path as
|
||||
// typed characters. The session rename editor accepts text only; empty content
|
||||
// is a no-op there rather than an image probe.
|
||||
func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
|
||||
// A paste is a deliberate action, same as a keypress or click — it means
|
||||
// the user moved on to something else, so it disarms a stale Esc
|
||||
@@ -101,7 +102,18 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
|
||||
if m.providerWizard != nil {
|
||||
return m.handleProviderWizardPaste(content)
|
||||
}
|
||||
if m.transcriptDetailed || m.pendingSpecReview != nil || m.pendingPermission != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil {
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.picker.appendQuery([]rune(sanitizeComposerInput(content)))
|
||||
m.previewSelectedTheme()
|
||||
if m.picker.kind == pickerPet {
|
||||
return m.schedulePetPreview()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.transcriptDetailed || m.pendingSpecReview != nil || m.pendingPermission != nil || m.mcpAddWizard != nil || m.mcpManager != nil {
|
||||
return m, nil
|
||||
}
|
||||
// A drag-dropped image/PDF arrives as a (backslash-escaped) file path. Attach
|
||||
|
||||
@@ -121,3 +121,59 @@ func TestToolResultRowExposesClickToggle(t *testing.T) {
|
||||
t.Fatalf("tool result visible body/footer must stay selectable, got %#v", selectable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlwaysExpandedToolResultHeaderIsNotToggleable(t *testing.T) {
|
||||
m := transcriptViewTestModel()
|
||||
for _, tool := range []string{"edit_file", "apply_patch", "write_file"} {
|
||||
row := transcriptRow{
|
||||
kind: rowToolResult,
|
||||
id: "diff",
|
||||
tool: tool,
|
||||
status: tools.StatusOK,
|
||||
detail: "--- a/example.go\n+++ b/example.go\n@@ -1 +1 @@\n-old\n+new",
|
||||
}
|
||||
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
|
||||
if len(selectable) == 0 {
|
||||
t.Fatalf("%s: expected a visible diff result row", tool)
|
||||
}
|
||||
if selectable[0].toggle {
|
||||
t.Fatalf("%s header must not advertise a collapse toggle: %#v", tool, selectable[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultOutputCannotCreateToggle(t *testing.T) {
|
||||
m := transcriptViewTestModel()
|
||||
row := transcriptRow{
|
||||
kind: rowToolResult,
|
||||
id: "custom",
|
||||
tool: "custom_tool",
|
||||
status: tools.StatusOK,
|
||||
detail: "the file literally says: click to expand",
|
||||
}
|
||||
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
|
||||
if len(selectable) == 0 {
|
||||
t.Fatal("expected a visible tool result row")
|
||||
}
|
||||
if selectable[0].toggle {
|
||||
t.Fatalf("tool-output text must not create a header toggle: %#v", selectable[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapsedExploreToolResultHeaderIsToggleable(t *testing.T) {
|
||||
m := transcriptViewTestModel()
|
||||
row := transcriptRow{
|
||||
kind: rowToolResult,
|
||||
id: "read",
|
||||
tool: "read_file",
|
||||
status: tools.StatusOK,
|
||||
detail: numberedLines(cardBodyMaxLines + 5),
|
||||
}
|
||||
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
|
||||
if len(selectable) == 0 {
|
||||
t.Fatal("expected a visible explore result row")
|
||||
}
|
||||
if !selectable[0].toggle {
|
||||
t.Fatalf("collapsed explore card header must remain toggleable: %#v", selectable[0])
|
||||
}
|
||||
}
|
||||
@@ -456,6 +456,7 @@ func (m model) handleModelCommand(args string) (model, string) {
|
||||
// Keep sub-agent child processes on the same provider we just switched to.
|
||||
config.SetActiveProviderEnv(nextProfile.Name)
|
||||
m.modelName = target.modelID
|
||||
m.serviceTier = ""
|
||||
// Record the outgoing pair too, not just the destination: otherwise the
|
||||
// model a session started on (never itself the target of a recordRecentModel
|
||||
// call) would silently drop out of "Recent" the moment you switch away from
|
||||
@@ -559,6 +560,7 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
|
||||
m.providerProfile = target
|
||||
m.providerName = target.Name
|
||||
m.modelName = target.Model
|
||||
m.serviceTier = ""
|
||||
// An active profile's effort fill is per-model: re-derive it for the
|
||||
// destination, exactly like handleModelCommand does. No generic
|
||||
// unsupported-drop here: cross-provider targets are often custom models
|
||||
|
||||
@@ -33,6 +33,7 @@ const (
|
||||
commandCompact
|
||||
commandRewind
|
||||
commandEffort
|
||||
commandFast
|
||||
commandStyle
|
||||
commandTheme
|
||||
commandTranscript
|
||||
@@ -53,6 +54,7 @@ const (
|
||||
commandGoal
|
||||
commandVoice
|
||||
commandSTTModel
|
||||
commandPets
|
||||
commandUnknown
|
||||
)
|
||||
|
||||
@@ -270,11 +272,18 @@ var commandDefinitions = []commandDefinition{
|
||||
},
|
||||
{
|
||||
name: "/effort",
|
||||
usage: "/effort [list|low|medium|high|auto]",
|
||||
usage: "/effort [list|level|auto]",
|
||||
group: commandGroupModel,
|
||||
description: "Show or set reasoning effort for supported models.",
|
||||
kind: commandEffort,
|
||||
},
|
||||
{
|
||||
name: "/fast",
|
||||
usage: "/fast",
|
||||
group: commandGroupModel,
|
||||
description: "Toggle fast mode for supported ChatGPT subscription models.",
|
||||
kind: commandFast,
|
||||
},
|
||||
{
|
||||
name: "/style",
|
||||
usage: "/style [list|balanced|concise|explanatory|review]",
|
||||
@@ -353,6 +362,14 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Show available commands.",
|
||||
kind: commandHelp,
|
||||
},
|
||||
{
|
||||
name: "/pets",
|
||||
aliases: []string{"/pet"},
|
||||
usage: "/pets [name|off]",
|
||||
group: commandGroupMeta,
|
||||
description: "Choose, preview, or hide a terminal companion.",
|
||||
kind: commandPets,
|
||||
},
|
||||
{
|
||||
name: "/doctor",
|
||||
aliases: []string{"/health"},
|
||||
|
||||
@@ -47,7 +47,8 @@ func TestFormatCommandHelpLinesGroupsCommandsByStableOrder(t *testing.T) {
|
||||
"model:",
|
||||
" /provider [add|status] - Manage providers: activate, add, edit, delete.",
|
||||
" /model [list|id] - Show or switch the active model.",
|
||||
" /effort [list|low|medium|high|auto] - Show or set reasoning effort for supported models.",
|
||||
" /effort [list|level|auto] - Show or set reasoning effort for supported models.",
|
||||
" /fast - Toggle fast mode for supported ChatGPT subscription models.",
|
||||
"session:",
|
||||
" /plan [status|on|off] - Show plan status, or enter/exit read-only planning mode.",
|
||||
"runtime:",
|
||||
|
||||
@@ -61,6 +61,7 @@ type dictationController struct {
|
||||
browseVariants []dictation.ModelVariant
|
||||
|
||||
// in-flight session state
|
||||
sessionID int64
|
||||
streaming bool
|
||||
recorder Recorder
|
||||
transcriber Transcriber
|
||||
@@ -101,13 +102,17 @@ type dictationController struct {
|
||||
}
|
||||
|
||||
// dictationStartedMsg reports the outcome of Recorder.Start().
|
||||
// sessionID must match the dictation controller's current session so a late
|
||||
// start result after cancel/reset cannot reset a newer recording.
|
||||
type dictationStartedMsg struct {
|
||||
err error
|
||||
sessionID int64
|
||||
err error
|
||||
}
|
||||
|
||||
// dictationTranscribedMsg carries the final transcript of a batch (or a
|
||||
// completed streaming) recording.
|
||||
type dictationTranscribedMsg struct {
|
||||
sessionID int64
|
||||
text string
|
||||
err error
|
||||
submit bool
|
||||
@@ -125,6 +130,7 @@ func newDictationController(opts Options) dictationController {
|
||||
platform: dictation.DetectPlatform(),
|
||||
downloadRoot: opts.STTDownloadRoot,
|
||||
userConfigPath: opts.UserConfigPath,
|
||||
sessionID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +268,7 @@ func (m model) startDictation() (model, tea.Cmd) {
|
||||
if streaming {
|
||||
return m.startStreamingDictation()
|
||||
}
|
||||
return m, startBatchRecordingCmd(rec)
|
||||
return m, startBatchRecordingCmd(m.dictation.sessionID, rec)
|
||||
}
|
||||
|
||||
// stopDictation ends a recording. For batch it triggers Stop()+Transcribe(); for
|
||||
@@ -276,7 +282,7 @@ func (m model) stopDictation() (model, tea.Cmd) {
|
||||
}
|
||||
return m, nil // final text arrives via the streaming command already running
|
||||
}
|
||||
return m, transcribeBatchCmd(m.dictation.ctx, m.dictation.recorder, m.dictation.transcriber, m.dictation.cfg.AutoSubmitEnabled())
|
||||
return m, transcribeBatchCmd(m.dictation.sessionID, m.dictation.ctx, m.dictation.recorder, m.dictation.transcriber, m.dictation.cfg.AutoSubmitEnabled())
|
||||
}
|
||||
|
||||
// cancelDictation aborts an in-flight recording without transcribing. Bound to
|
||||
@@ -311,6 +317,7 @@ func (d dictationController) maxDuration() time.Duration {
|
||||
}
|
||||
|
||||
func (d *dictationController) reset() {
|
||||
d.sessionID++
|
||||
d.phase = dictIdle
|
||||
d.recorder = nil
|
||||
d.transcriber = nil
|
||||
@@ -326,31 +333,36 @@ func (d *dictationController) reset() {
|
||||
|
||||
// startBatchRecordingCmd starts the record-to-file capture off the UI goroutine
|
||||
// (Start spawns a subprocess and may briefly block or fail on a missing tool).
|
||||
func startBatchRecordingCmd(rec Recorder) tea.Cmd {
|
||||
func startBatchRecordingCmd(sessionID int64, rec Recorder) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return dictationStartedMsg{err: rec.Start()}
|
||||
return dictationStartedMsg{sessionID: sessionID, err: rec.Start()}
|
||||
}
|
||||
}
|
||||
|
||||
// transcribeBatchCmd stops the recording, transcribes the audio, and reports the
|
||||
// final text. Runs off the UI goroutine — Stop waits on the capture tool and
|
||||
// Transcribe does a network round-trip or a local exec.
|
||||
func transcribeBatchCmd(ctx context.Context, rec Recorder, transcriber Transcriber, submit bool) tea.Cmd {
|
||||
func transcribeBatchCmd(sessionID int64, ctx context.Context, rec Recorder, transcriber Transcriber, submit bool) tea.Cmd {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return func() tea.Msg {
|
||||
audio, err := rec.Stop()
|
||||
if err != nil {
|
||||
return dictationTranscribedMsg{err: err}
|
||||
return dictationTranscribedMsg{sessionID: sessionID, err: err}
|
||||
}
|
||||
text, err := transcriber.Transcribe(ctx, audio)
|
||||
return dictationTranscribedMsg{text: text, err: err, submit: submit}
|
||||
return dictationTranscribedMsg{sessionID: sessionID, text: text, err: err, submit: submit}
|
||||
}
|
||||
}
|
||||
|
||||
// handleDictationStarted transitions to recording (or reports a start failure).
|
||||
func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd) {
|
||||
if msg.sessionID != m.dictation.sessionID {
|
||||
// Stale startup from a cancelled session must not reset or re-arm a
|
||||
// newer recording that already owns the controller.
|
||||
return m, nil
|
||||
}
|
||||
if msg.err != nil {
|
||||
m = m.discardDictationRegion()
|
||||
m.dictation.reset()
|
||||
@@ -375,6 +387,9 @@ func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd)
|
||||
// handleDictationTranscribed inserts the final transcript into the composer (or
|
||||
// submits it when stt.autoSubmit is on), then returns to idle.
|
||||
func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Model, tea.Cmd) {
|
||||
if msg.sessionID != m.dictation.sessionID {
|
||||
return m, nil
|
||||
}
|
||||
streaming := m.dictation.streaming || msg.streaming
|
||||
m = m.commitDictationRegion()
|
||||
// A streaming session can end via a transcriber error (not just a user stop), so
|
||||
@@ -392,7 +407,17 @@ func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Mode
|
||||
}
|
||||
m.dictation.reset()
|
||||
|
||||
if msg.err != nil && !errors.Is(msg.err, context.Canceled) {
|
||||
if errors.Is(msg.err, context.Canceled) {
|
||||
// cancelDictation already discarded the live region, reset the
|
||||
// session, and posted "Dictation cancelled." A streaming transcriber
|
||||
// can still race a buffered event past that cancel and report back a
|
||||
// nonempty compose()+context.Canceled; treat that as terminal here,
|
||||
// before the auto-submit branch below, so Esc can never fall through
|
||||
// to msg.submit and fire the composer's restored pre-existing text.
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if msg.err != nil {
|
||||
// A cloud auth failure (missing/invalid key) is fixable in place: reopen the
|
||||
// API-key prompt for the current provider so the user can paste a key and
|
||||
// retry, instead of hitting a dead-end "run zero auth" line.
|
||||
|
||||
@@ -14,8 +14,9 @@ import (
|
||||
// mechanism agent text deltas use (§6). text is the cumulative best transcript
|
||||
// so far (not a delta); final marks a settled segment.
|
||||
type sttPartialMsg struct {
|
||||
text string
|
||||
final bool
|
||||
sessionID int64
|
||||
text string
|
||||
final bool
|
||||
}
|
||||
|
||||
// startStreamingDictation begins continuous capture and launches the streaming
|
||||
@@ -38,6 +39,7 @@ func (m model) startStreamingDictation() (model, tea.Cmd) {
|
||||
}
|
||||
transcriber := m.dictation.transcriber
|
||||
submit := m.dictation.cfg.AutoSubmitEnabled()
|
||||
sessionID := m.dictation.sessionID
|
||||
|
||||
// Tap the audio: compute a mic level per chunk for the live waveform, then
|
||||
// forward the chunk to the transcriber. A small buffer keeps the tap from
|
||||
@@ -63,11 +65,11 @@ func (m model) startStreamingDictation() (model, tea.Cmd) {
|
||||
streamCmd := func() tea.Msg {
|
||||
onPartial := func(text string, final bool) {
|
||||
if sink != nil {
|
||||
sink(sttPartialMsg{text: text, final: final})
|
||||
sink(sttPartialMsg{sessionID: sessionID, text: text, final: final})
|
||||
}
|
||||
}
|
||||
text, err := transcriber.StreamTranscribe(ctx, tapped, onPartial)
|
||||
return dictationTranscribedMsg{text: text, err: err, submit: submit, streaming: true}
|
||||
return dictationTranscribedMsg{sessionID: sessionID, text: text, err: err, submit: submit, streaming: true}
|
||||
}
|
||||
// Streaming drives the waveform from real levels (no synthetic tick needed).
|
||||
return m, streamCmd
|
||||
@@ -77,6 +79,9 @@ func (m model) startStreamingDictation() (model, tea.Cmd) {
|
||||
// composer, replacing the previously-rendered live region wholesale so the text
|
||||
// builds up in place as the user keeps talking.
|
||||
func (m model) handleDictationPartial(msg sttPartialMsg) model {
|
||||
if msg.sessionID != m.dictation.sessionID {
|
||||
return m
|
||||
}
|
||||
// Ignore stragglers that arrive after the session ended (cancel/final).
|
||||
if m.dictation.phase != dictRecording && m.dictation.phase != dictTranscribing {
|
||||
return m
|
||||
|
||||
@@ -86,9 +86,10 @@ func TestDictationNonAuthErrorDoesNotPrompt(t *testing.T) {
|
||||
|
||||
func TestDictationStartedFailureResets(t *testing.T) {
|
||||
m := model{}
|
||||
m.dictation.sessionID = 1
|
||||
m.dictation.phase = dictStarting
|
||||
m.dictation.streaming = false
|
||||
got, _ := m.handleDictationStarted(dictationStartedMsg{err: errors.New("mic busy")})
|
||||
got, _ := m.handleDictationStarted(dictationStartedMsg{sessionID: 1, err: errors.New("mic busy")})
|
||||
if got.dictation.active() {
|
||||
t.Error("a start failure should reset to idle")
|
||||
}
|
||||
@@ -99,13 +100,48 @@ func TestDictationStartedFailureResets(t *testing.T) {
|
||||
|
||||
func TestDictationStartedArmsRecording(t *testing.T) {
|
||||
m := model{}
|
||||
m.dictation.sessionID = 1
|
||||
m.dictation.phase = dictStarting
|
||||
got, _ := m.handleDictationStarted(dictationStartedMsg{})
|
||||
got, _ := m.handleDictationStarted(dictationStartedMsg{sessionID: 1})
|
||||
if got.dictation.phase != dictRecording {
|
||||
t.Error("a successful start should advance to recording")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaleDictationStartedIgnoredWhileLive pins the session gate on batch
|
||||
// startup messages. A late Start() result after cancel must not reset() a
|
||||
// newer recording (success would leave dictStarting with no recorder; failure
|
||||
// would kill the live session).
|
||||
func TestStaleDictationStartedIgnoredWhileLive(t *testing.T) {
|
||||
// Active recording on session 2.
|
||||
m := model{}
|
||||
m.dictation.sessionID = 2
|
||||
m.dictation.phase = dictRecording
|
||||
m.dictation.streaming = true
|
||||
m.setComposerState(composerState{text: "live session text", cursor: len("live session text")})
|
||||
|
||||
// Stale failure from session 1 must not reset the live controller.
|
||||
afterFail, _ := m.handleDictationStarted(dictationStartedMsg{
|
||||
sessionID: 1,
|
||||
err: errors.New("mic busy from previous start"),
|
||||
})
|
||||
if afterFail.dictation.phase != dictRecording || afterFail.dictation.sessionID != 2 {
|
||||
t.Fatalf("stale start failure: phase=%v session=%d, want recording/2", afterFail.dictation.phase, afterFail.dictation.sessionID)
|
||||
}
|
||||
if transcriptHasText(afterFail, "mic busy from previous start") {
|
||||
t.Fatal("stale start failure must not post an error notice")
|
||||
}
|
||||
|
||||
// Stale success from session 1 must not leave the live session stuck mid-start.
|
||||
m2 := model{}
|
||||
m2.dictation.sessionID = 2
|
||||
m2.dictation.phase = dictRecording
|
||||
afterOK, _ := m2.handleDictationStarted(dictationStartedMsg{sessionID: 1})
|
||||
if afterOK.dictation.phase != dictRecording || afterOK.dictation.sessionID != 2 {
|
||||
t.Fatalf("stale start success: phase=%v session=%d, want recording/2", afterOK.dictation.phase, afterOK.dictation.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationUnavailableShowsHint(t *testing.T) {
|
||||
m := model{} // no build factory
|
||||
next, _ := m.toggleDictation()
|
||||
@@ -136,6 +172,69 @@ func TestDictationStreamingPartialReplacesRegion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationCanceledStreamRaceDoesNotAutoSubmit(t *testing.T) {
|
||||
// Esc can race an already-buffered realtime event: cancelDictation discards
|
||||
// the live region and resets state synchronously, but the streaming
|
||||
// goroutine's dictationTranscribedMsg (a nonempty compose() alongside
|
||||
// context.Canceled) can still arrive afterward. With stt.autoSubmit on,
|
||||
// that must never fall through to msg.submit and fire the composer's
|
||||
// restored pre-existing text.
|
||||
// Matching sessionID is required so this hits the context.Canceled branch
|
||||
// rather than only the stale-session gate (session 0 after cancel would be
|
||||
// dropped before that branch runs).
|
||||
m := model{}
|
||||
m.dictation.sessionID = 1
|
||||
m.setComposerState(composerState{text: "existing prompt", cursor: len("existing prompt")})
|
||||
m.dictation.phase = dictRecording
|
||||
m.dictation.streaming = true
|
||||
|
||||
m = m.handleDictationPartial(sttPartialMsg{sessionID: 1, text: "half-formed transcript"})
|
||||
if m.composer.text != "existing prompt half-formed transcript" {
|
||||
t.Fatalf("partial did not render into composer: %q", m.composer.text)
|
||||
}
|
||||
|
||||
m, _ = m.cancelDictation()
|
||||
if m.composer.text != "existing prompt" {
|
||||
t.Fatalf("cancel should restore the pre-existing composer text, got %q", m.composer.text)
|
||||
}
|
||||
|
||||
// The already-buffered event's message arrives after the cancel above.
|
||||
next, cmd := m.handleDictationTranscribed(dictationTranscribedMsg{
|
||||
sessionID: m.dictation.sessionID,
|
||||
text: "half-formed transcript",
|
||||
err: context.Canceled,
|
||||
submit: true,
|
||||
streaming: true,
|
||||
})
|
||||
got := next.(model)
|
||||
if got.composer.text != "existing prompt" {
|
||||
t.Errorf("a raced cancellation must not auto-submit the restored composer text, got %q", got.composer.text)
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Error("a raced cancellation must not return a submit command")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaleDictationPartialIgnoredWhileLive pins the session-ID gate on
|
||||
// handleDictationPartial while a new session is still recording. Cancel-then-
|
||||
// stale cases are already covered by the phase guard; this case needs a live
|
||||
// phase so only the session check drops the ghost partial.
|
||||
func TestStaleDictationPartialIgnoredWhileLive(t *testing.T) {
|
||||
m := model{}
|
||||
m.dictation.sessionID = 2
|
||||
m.dictation.phase = dictRecording
|
||||
m.dictation.streaming = true
|
||||
m.setComposerState(composerState{text: "user typed this", cursor: len("user typed this")})
|
||||
|
||||
got := m.handleDictationPartial(sttPartialMsg{
|
||||
sessionID: 1,
|
||||
text: "ghost text from session 1",
|
||||
})
|
||||
if got.composer.text != "user typed this" {
|
||||
t.Fatalf("stale partial while live: got %q, want %q", got.composer.text, "user typed this")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationCommitKeepsStreamedText(t *testing.T) {
|
||||
m := model{}
|
||||
m.setComposerState(composerState{text: "", cursor: 0})
|
||||
@@ -302,3 +401,83 @@ func TestCurrentModelLabel(t *testing.T) {
|
||||
t.Errorf("no-model label = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleDictationCompletionIgnoredAfterCancel(t *testing.T) {
|
||||
m := model{}
|
||||
m.setComposerState(composerState{text: "original composer text", cursor: 22})
|
||||
m.dictation.sessionID = 1
|
||||
m.dictation.phase = dictRecording
|
||||
|
||||
m, _ = m.cancelDictation()
|
||||
|
||||
if m.composer.text != "original composer text" {
|
||||
t.Fatalf("composer text = %q, want 'original composer text'", m.composer.text)
|
||||
}
|
||||
|
||||
// Stale partial message from session 1 must be ignored
|
||||
stalePartial := sttPartialMsg{
|
||||
sessionID: 1,
|
||||
text: "stale partial that should be ignored",
|
||||
}
|
||||
mPartial := m.handleDictationPartial(stalePartial)
|
||||
if mPartial.composer.text != "original composer text" {
|
||||
t.Fatalf("composer text changed to %q after stale partial", mPartial.composer.text)
|
||||
}
|
||||
|
||||
staleMsg := dictationTranscribedMsg{
|
||||
sessionID: 1,
|
||||
text: "stale text that should be ignored",
|
||||
submit: true,
|
||||
streaming: true,
|
||||
}
|
||||
afterStale, cmd := m.handleDictationTranscribed(staleMsg)
|
||||
got := afterStale.(model)
|
||||
|
||||
if got.composer.text != "original composer text" {
|
||||
t.Fatalf("composer text changed to %q after stale completion", got.composer.text)
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatalf("expected no command for stale completion, got %v", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleDictationSessionZeroIgnoredAfterCancel(t *testing.T) {
|
||||
m := model{}
|
||||
m.setComposerState(composerState{text: "initial composer text", cursor: 21})
|
||||
m.dictation.sessionID = 0
|
||||
m.dictation.phase = dictRecording
|
||||
|
||||
m, _ = m.cancelDictation()
|
||||
|
||||
// Session 0 was reset to session 1
|
||||
if m.dictation.sessionID != 1 {
|
||||
t.Fatalf("dictation.sessionID = %d after cancel, want 1", m.dictation.sessionID)
|
||||
}
|
||||
|
||||
// Stale partial from session 0 must be ignored
|
||||
stalePartial := sttPartialMsg{
|
||||
sessionID: 0,
|
||||
text: "stale partial from session 0",
|
||||
}
|
||||
mPartial := m.handleDictationPartial(stalePartial)
|
||||
if mPartial.composer.text != "initial composer text" {
|
||||
t.Fatalf("composer text changed to %q after stale session 0 partial", mPartial.composer.text)
|
||||
}
|
||||
|
||||
// Stale completion from session 0 must be ignored
|
||||
staleMsg := dictationTranscribedMsg{
|
||||
sessionID: 0,
|
||||
text: "stale completion from session 0",
|
||||
submit: true,
|
||||
streaming: true,
|
||||
}
|
||||
afterStale, cmd := m.handleDictationTranscribed(staleMsg)
|
||||
got := afterStale.(model)
|
||||
|
||||
if got.composer.text != "initial composer text" {
|
||||
t.Fatalf("composer text changed to %q after stale session 0 completion", got.composer.text)
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatalf("expected no command for stale session 0 completion, got %v", cmd)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package tui
|
||||
|
||||
import "strings"
|
||||
|
||||
// Visible unchanged lines kept at each edge of a collapsed diff section.
|
||||
const diffViewerContextLines = 3
|
||||
|
||||
// diffViewerLine is a source diff line or a synthetic collapsed-context row.
|
||||
// rawIndex keeps rendering anchored to the original diff so line-pair and
|
||||
// syntax-highlighting logic remain correct after context compaction.
|
||||
type diffViewerLine struct {
|
||||
text string
|
||||
rawIndex int
|
||||
hiddenContext int
|
||||
}
|
||||
|
||||
// compactDiffViewerContext returns the display sequence for a unified diff.
|
||||
// Long contiguous unchanged runs keep their leading and trailing context while
|
||||
// one synthetic line accounts for the elided middle. Structural and changed
|
||||
// rows always remain intact.
|
||||
func compactDiffViewerContext(raw []string) []diffViewerLine {
|
||||
lines := make([]diffViewerLine, 0, len(raw))
|
||||
hunk := diffHunkState{}
|
||||
for index := 0; index < len(raw); {
|
||||
if parsed, ok := parseDiffHunkHeader(raw[index]); ok {
|
||||
lines = append(lines, diffViewerLine{text: raw[index], rawIndex: index})
|
||||
hunk = parsed
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if !hunk.active() || !strings.HasPrefix(raw[index], " ") {
|
||||
lines = append(lines, diffViewerLine{text: raw[index], rawIndex: index})
|
||||
if hunk.active() && isDiffHunkBodyLine(raw[index]) {
|
||||
hunk.consume(raw[index])
|
||||
}
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
start := index
|
||||
for index < len(raw) && hunk.active() && strings.HasPrefix(raw[index], " ") {
|
||||
hunk.consume(raw[index])
|
||||
index++
|
||||
}
|
||||
count := index - start
|
||||
// Collapsing seven lines would replace one readable line with one marker,
|
||||
// leaving the display equally tall. Only collapse when it removes space.
|
||||
if count <= diffViewerContextLines*2+1 {
|
||||
for i := start; i < index; i++ {
|
||||
lines = append(lines, diffViewerLine{text: raw[i], rawIndex: i})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for i := start; i < start+diffViewerContextLines; i++ {
|
||||
lines = append(lines, diffViewerLine{text: raw[i], rawIndex: i})
|
||||
}
|
||||
lines = append(lines, diffViewerLine{rawIndex: -1, hiddenContext: count - diffViewerContextLines*2})
|
||||
for i := index - diffViewerContextLines; i < index; i++ {
|
||||
lines = append(lines, diffViewerLine{text: raw[i], rawIndex: i})
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
func requireDiffViewerTrueColor(t *testing.T) {
|
||||
t.Helper()
|
||||
oldProfile := lipgloss.Writer.Profile
|
||||
lipgloss.Writer.Profile = colorprofile.TrueColor
|
||||
t.Cleanup(func() {
|
||||
lipgloss.Writer.Profile = oldProfile
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiffViewerUsesHunkHeaderForLineNumbers(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"--- a/internal/tui/view.go",
|
||||
"+++ b/internal/tui/view.go",
|
||||
"@@ -40,3 +40,4 @@ func renderView() {",
|
||||
" context",
|
||||
"-old line",
|
||||
"+new line",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 90, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if strings.Contains(got, "@@") || !strings.Contains(got, " 41 − old line") || !strings.Contains(got, " 41 + new line") {
|
||||
t.Fatalf("diff viewer did not apply the hunk range correctly:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerCollapsesLongUnchangedContext(t *testing.T) {
|
||||
lines := []string{
|
||||
"--- a/x.go",
|
||||
"+++ b/x.go",
|
||||
"@@ -1,12 +1,12 @@",
|
||||
}
|
||||
for i := 1; i <= 12; i++ {
|
||||
lines = append(lines, " context "+string(rune('a'+i-1)))
|
||||
}
|
||||
diff := strings.Join(lines, "\n")
|
||||
|
||||
body := diffCardBody(diff, 80, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, "… 6 unchanged lines") {
|
||||
t.Fatalf("diff viewer did not collapse long unchanged context:\n%s", got)
|
||||
}
|
||||
for _, want := range []string{"context a", "context b", "context c", "context j", "context k", "context l"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("diff viewer should preserve contextual edge %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsSevenUnchangedContextLines(t *testing.T) {
|
||||
lines := []string{
|
||||
"--- a/x.go",
|
||||
"+++ b/x.go",
|
||||
"@@ -1,7 +1,7 @@",
|
||||
}
|
||||
for i := 1; i <= diffViewerContextLines*2+1; i++ {
|
||||
lines = append(lines, " context "+string(rune('a'+i-1)))
|
||||
}
|
||||
|
||||
body := diffCardBody(strings.Join(lines, "\n"), 80, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if strings.Contains(got, "unchanged lines") {
|
||||
t.Fatalf("seven unchanged context lines should remain visible:\n%s", got)
|
||||
}
|
||||
for _, want := range []string{"context a", "context d", "context g"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("diff viewer omitted %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsLineNumbersAfterCollapsedContext(t *testing.T) {
|
||||
lines := []string{
|
||||
"--- a/x.go",
|
||||
"+++ b/x.go",
|
||||
"@@ -1,13 +1,13 @@",
|
||||
}
|
||||
for i := 1; i <= 12; i++ {
|
||||
lines = append(lines, " context "+string(rune('a'+i-1)))
|
||||
}
|
||||
lines = append(lines, "-old value", "+new value")
|
||||
|
||||
body := diffCardBody(strings.Join(lines, "\n"), 80, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, " 13 − old value") || !strings.Contains(got, " 13 + new value") {
|
||||
t.Fatalf("line numbers drifted after collapsed context:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSyntheticContextHasNoSourceIndex(t *testing.T) {
|
||||
raw := make([]string, 0, diffViewerContextLines*2+2)
|
||||
for index := 0; index < diffViewerContextLines*2+2; index++ {
|
||||
raw = append(raw, " context")
|
||||
}
|
||||
for _, line := range compactDiffViewerContext(raw) {
|
||||
if line.hiddenContext > 0 && line.rawIndex != -1 {
|
||||
t.Fatalf("synthetic collapsed context rawIndex = %d, want -1", line.rawIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerNearRewriteDeletionSurvives(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"--- a/notes.log",
|
||||
"+++ b/notes.log",
|
||||
"@@ -1 +1 @@",
|
||||
"-alpha bravo charlie delta",
|
||||
"+zulu yankee xray whiskey",
|
||||
}, "\n")
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, "alpha bravo charlie delta") {
|
||||
t.Fatalf("near-rewrite deletion disappeared:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerRendersStyledDeletionOnce(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1 +1 @@",
|
||||
"-func value() string { return \"old\" }",
|
||||
"+func value() string { return \"new\" }",
|
||||
}, "\n")
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if count := strings.Count(got, "func value() string { return \"old\" }"); count != 1 {
|
||||
t.Fatalf("styled deleted row count = %d, want 1:\n%s", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerDoesNotFoldPreambleContext(t *testing.T) {
|
||||
raw := []string{
|
||||
"commit deadbeef",
|
||||
"Author: Example",
|
||||
"",
|
||||
}
|
||||
for index := 1; index <= 10; index++ {
|
||||
raw = append(raw, fmt.Sprintf(" commit message line %d", index))
|
||||
}
|
||||
raw = append(raw,
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1,1 +1,1 @@",
|
||||
"-old value",
|
||||
"+new value",
|
||||
)
|
||||
|
||||
compacted := compactDiffViewerContext(raw)
|
||||
for _, line := range compacted {
|
||||
if line.hiddenContext > 0 {
|
||||
t.Fatalf("preamble context was folded: %#v", compacted)
|
||||
}
|
||||
}
|
||||
if len(compacted) != len(raw) {
|
||||
t.Fatalf("compacted preamble length = %d, want %d", len(compacted), len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsHeaderLikeHunkContent(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1 +1 @@",
|
||||
"--- removed",
|
||||
"+++ added",
|
||||
}, "\n")
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
for _, want := range []string{"-- removed", "++ added"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("diff viewer lost header-like hunk content %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
meta := diffCardMetadata(diff)
|
||||
if meta.adds != 1 || meta.dels != 1 {
|
||||
t.Fatalf("diff metadata counts = +%d -%d, want +1 -1", meta.adds, meta.dels)
|
||||
}
|
||||
if _, ok := highlightedDiffLines(strings.Split(diff, "\n"), meta)[3]; !ok {
|
||||
t.Fatal("deleted header-like hunk content was not syntax highlighted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSourcePathStripsOnlyOneDiffPrefix(t *testing.T) {
|
||||
if got, want := diffViewerSourcePath("--- a/b/c.go"), "b/c.go"; got != want {
|
||||
t.Fatalf("diffViewerSourcePath = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSyntaxHighlightsBothChangedSides(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1 +1 @@",
|
||||
"-func greet(name string) string { return \"old\" }",
|
||||
"+func greet(name string) string { return \"new\" }",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
joined := strings.Join(body.lines, "\n")
|
||||
// `func` uses Zero's accent and string literals use its green token style.
|
||||
if !strings.Contains(joined, "202;255;63") || !strings.Contains(joined, "93;209;164") {
|
||||
t.Fatalf("diff viewer did not retain Go syntax colors in changed rows:\n%s", joined)
|
||||
}
|
||||
// The exact changed string literal keeps the brighter word-diff background.
|
||||
if !strings.Contains(joined, "46;101;77") || !strings.Contains(joined, "80;45;48") {
|
||||
t.Fatalf("diff viewer lost word-level changed-span emphasis:\n%s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSyntaxHighlightsUnchangedHunkContext(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1,3 +1,3 @@",
|
||||
" func greet() string {",
|
||||
"-\treturn \"old\"",
|
||||
"+\treturn \"new\"",
|
||||
" }",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
for _, line := range body.lines {
|
||||
if strings.Contains(plainRender(t, line), "func greet() string {") {
|
||||
if !strings.Contains(line, "202;255;63") {
|
||||
t.Fatalf("unchanged source context was not syntax-highlighted: %q", line)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("diff viewer omitted unchanged source context")
|
||||
}
|
||||
|
||||
func TestDiffViewerPreservesSyntaxStateAcrossHunkLines(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.py",
|
||||
"+++ b/example.py",
|
||||
"@@ -1,3 +1,4 @@",
|
||||
" def message():",
|
||||
"- return \"\"\"old\"\"\"",
|
||||
"+ return \"\"\"new",
|
||||
"+continued\"\"\"",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
for _, line := range body.lines {
|
||||
if strings.Contains(plainRender(t, line), "continued") {
|
||||
if !strings.Contains(line, "93;209;164") {
|
||||
t.Fatalf("multiline string lost syntax state across hunk rows: %q", line)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("diff viewer omitted multiline source row")
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsNonHunkDiffFallback(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"+func greet() string { return \"new\" }",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, "func greet() string { return \"new\" }") {
|
||||
t.Fatalf("diff viewer lost content without a hunk header:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsRenameMetadataWithoutHunks(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"diff --git a/old-name.go b/new-name.go",
|
||||
"similarity index 100%",
|
||||
"rename from old-name.go",
|
||||
"rename to new-name.go",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, "rename from old-name.go") || !strings.Contains(got, "rename to new-name.go") {
|
||||
t.Fatalf("rename-only diff lost its meaningful metadata:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsModeMetadataWithoutHunks(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"diff --git a/tool.sh b/tool.sh",
|
||||
"old mode 100644",
|
||||
"new mode 100755",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, "old mode 100644") || !strings.Contains(got, "new mode 100755") {
|
||||
t.Fatalf("mode-only diff lost its meaningful metadata:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerFallsBackForOversizedSourceLine(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
longLine := strings.Repeat("x", diffViewerHighlightMaxLineBytes)
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1 +1 @@",
|
||||
"+" + longLine,
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
joined := strings.Join(body.lines, "\n")
|
||||
if strings.Contains(joined, "202;255;63") {
|
||||
t.Fatalf("oversized diff line should use the plain diff fallback: %q", joined)
|
||||
}
|
||||
if !strings.Contains(plainRender(t, joined), strings.Repeat("x", 10)) {
|
||||
t.Fatalf("plain fallback lost oversized source line: %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSyntaxHighlighterKeepsSpans(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
styled, ok := highlightCodeForPathWithSpans(
|
||||
[]string{"func greet(name string) string { return \"new\" }"},
|
||||
"example.go",
|
||||
200,
|
||||
zeroTheme.addLine.GetBackground(),
|
||||
[]highlightSpan{{line: 0, start: 39, end: 44, background: zeroTheme.addLineWord.GetBackground()}},
|
||||
)
|
||||
if !ok || len(styled) != 1 {
|
||||
t.Fatalf("highlightCodeForPathWithSpans = %#v, %v", styled, ok)
|
||||
}
|
||||
if !strings.Contains(styled[0], "46;101;77") {
|
||||
t.Fatalf("highlighted span lost its word-diff background: %q", styled[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerHighlightsEachFileWithItsOwnPath(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
rawLines := []string{
|
||||
"diff --git a/example.go b/example.go",
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1 +1 @@",
|
||||
"-func old() {}",
|
||||
"+func new() {}",
|
||||
"diff --git a/example.py b/example.py",
|
||||
"--- a/example.py",
|
||||
"+++ b/example.py",
|
||||
"@@ -1 +1 @@",
|
||||
"-def old(): pass",
|
||||
"+def new(): pass",
|
||||
}
|
||||
|
||||
highlighted := highlightedDiffLines(rawLines, diffCardMetadata(strings.Join(rawLines, "\n")))
|
||||
if len(highlighted) != 4 {
|
||||
t.Fatalf("highlighted line count = %d, want 4: %#v", len(highlighted), highlighted)
|
||||
}
|
||||
for _, index := range []int{4, 5, 10, 11} {
|
||||
if !strings.Contains(highlighted[index], "202;255;63") {
|
||||
t.Fatalf("source row %d lost syntax highlighting: %q", index, highlighted[index])
|
||||
}
|
||||
}
|
||||
for _, header := range []int{2, 3, 8, 9} {
|
||||
if _, ok := highlighted[header]; ok {
|
||||
t.Fatalf("file header at row %d entered syntax highlighter: %q", header, highlighted[header])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerHighlightBudgetSpansHunks(t *testing.T) {
|
||||
hunkLines := diffViewerHighlightMaxLines/2 + 1
|
||||
rawLines := []string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
fmt.Sprintf("@@ -1,%d +1,%d @@", hunkLines, hunkLines),
|
||||
}
|
||||
for i := 0; i < hunkLines; i++ {
|
||||
rawLines = append(rawLines, " context")
|
||||
}
|
||||
rawLines = append(rawLines, fmt.Sprintf("@@ -6000,%d +6000,%d @@", hunkLines, hunkLines))
|
||||
for i := 0; i < hunkLines; i++ {
|
||||
rawLines = append(rawLines, " context")
|
||||
}
|
||||
if highlighted := highlightedDiffLines(rawLines, diffCardMetadata(strings.Join(rawLines, "\n"))); highlighted != nil {
|
||||
t.Fatalf("diff exceeding the aggregate highlight budget should use the plain fallback: %d highlighted rows", len(highlighted))
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,7 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -104,20 +102,6 @@ func (s planPanelState) height(width int, now time.Time) int {
|
||||
return 2
|
||||
}
|
||||
|
||||
func GetLocalDiffStats(baseBranch string) (additions int, deletions int, err error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), prCommandTimeout)
|
||||
defer cancel()
|
||||
return getLocalDiffStats(ctx, cwd, baseBranch, defaultPRCommandRunner)
|
||||
}
|
||||
|
||||
func WatchPRState(service *PrService, onChange func(PrState)) func() {
|
||||
return WatchPRStateContext(context.Background(), service, onChange)
|
||||
}
|
||||
|
||||
func (c *staticRenderCache) retainedCharacters() int {
|
||||
if c == nil {
|
||||
return 0
|
||||
@@ -244,7 +228,8 @@ func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, wi
|
||||
}
|
||||
|
||||
func (m model) renderSelectableToolResultRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow)
|
||||
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
|
||||
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow, opts)
|
||||
}
|
||||
|
||||
func (m model) transcriptBody(width int, emptyOverlay string) (string, []transcriptSelectableLine) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
)
|
||||
|
||||
func TestFastCommandTogglesOnlyAdvertisedTier(t *testing.T) {
|
||||
m := model{
|
||||
modelName: "gpt-fast",
|
||||
providerName: "ChatGPT",
|
||||
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
|
||||
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
|
||||
"chatgpt": {{ID: "gpt-fast", ServiceTiers: []string{"priority"}}},
|
||||
},
|
||||
}
|
||||
m, _ = m.handleFastCommand("")
|
||||
if got := m.activeServiceTier(); got != "priority" {
|
||||
t.Fatalf("active service tier = %q, want priority", got)
|
||||
}
|
||||
m, _ = m.handleFastCommand("")
|
||||
if got := m.activeServiceTier(); got != "" {
|
||||
t.Fatalf("active service tier = %q after second toggle, want off", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFastCommandRejectsUnsupportedModel(t *testing.T) {
|
||||
m := model{
|
||||
modelName: "gpt-standard",
|
||||
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
|
||||
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
|
||||
"chatgpt": {{ID: "gpt-standard"}},
|
||||
},
|
||||
serviceTier: "priority",
|
||||
}
|
||||
next, text := m.handleFastCommand("")
|
||||
if next.activeServiceTier() != "" || next.serviceTier != "" {
|
||||
t.Fatalf("unsupported model retained fast tier: %#v", next.serviceTier)
|
||||
}
|
||||
if !strings.Contains(text, "ChatGPT subscription") {
|
||||
t.Fatalf("unsupported response = %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFastCommandRejectsMeteredPriorityTier(t *testing.T) {
|
||||
m := model{
|
||||
modelName: "gpt-metered",
|
||||
providerProfile: config.ProviderProfile{CatalogID: "openai"},
|
||||
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
|
||||
"openai": {{ID: "gpt-metered", ServiceTiers: []string{"priority"}}},
|
||||
},
|
||||
serviceTier: "priority",
|
||||
}
|
||||
|
||||
next, text := m.handleFastCommand("")
|
||||
if got := next.activeServiceTier(); got != "" {
|
||||
t.Fatalf("metered profile forwarded service tier %q", got)
|
||||
}
|
||||
if next.serviceTier != "" {
|
||||
t.Fatalf("metered profile retained service tier %q", next.serviceTier)
|
||||
}
|
||||
if !strings.Contains(text, "ChatGPT subscription") {
|
||||
t.Fatalf("metered response = %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFastCommandDoesNotChangeDuringRun(t *testing.T) {
|
||||
m := model{
|
||||
pending: true,
|
||||
modelName: "gpt-fast",
|
||||
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
|
||||
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
|
||||
"chatgpt": {{ID: "gpt-fast", ServiceTiers: []string{"priority"}}},
|
||||
},
|
||||
}
|
||||
next, text := m.handleFastCommand("")
|
||||
if next.serviceTier != "" || !strings.Contains(text, "Finish or stop") {
|
||||
t.Fatalf("pending fast mutation = tier %q text %q", next.serviceTier, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFastCommandDoesNotInterpretArguments(t *testing.T) {
|
||||
m := model{
|
||||
modelName: "gpt-fast",
|
||||
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
|
||||
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
|
||||
"chatgpt": {{ID: "gpt-fast", ServiceTiers: []string{"priority"}}},
|
||||
},
|
||||
}
|
||||
next, text := m.handleFastCommand("status")
|
||||
if next.serviceTier != "" || !strings.Contains(text, "Use /fast to toggle") {
|
||||
t.Fatalf("argument changed fast state: tier %q text %q", next.serviceTier, text)
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,8 @@ func (m model) moveModalSelection(delta int) (model, tea.Cmd, bool) {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil, true
|
||||
}
|
||||
m.pickerMoved(delta)
|
||||
return m, nil, true
|
||||
m, cmd := m.pickerMoved(delta)
|
||||
return m, cmd, true
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
m.moveSuggestion(delta)
|
||||
|
||||
+176
-10
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
uv "github.com/charmbracelet/ultraviolet"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -33,6 +35,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
"github.com/Gitlawb/zero/internal/usercommands"
|
||||
@@ -138,6 +141,7 @@ type model struct {
|
||||
permissionModeBeforePlan agent.PermissionMode
|
||||
selfCorrectTests bool
|
||||
reasoningEffort modelregistry.ReasoningEffort
|
||||
serviceTier string
|
||||
// Active execution profile (set by /profile; applies to the NEXT run).
|
||||
// The displaced/applied pairs let a switch or /profile balanced restore
|
||||
// exactly what the profile replaced while leaving later manual overrides
|
||||
@@ -155,6 +159,48 @@ type model struct {
|
||||
execProfileEffortTouched bool
|
||||
execProfileSelfCorrectTouched bool
|
||||
responseStyle string
|
||||
petClient *terminalpet.Client
|
||||
petRenderer *terminalpet.ImageRenderer
|
||||
petEntries map[string]terminalpet.Entry
|
||||
petID string
|
||||
petName string
|
||||
petAnimation *terminalpet.Animation
|
||||
petPreview *terminalpet.Animation
|
||||
petPreviewSlug string
|
||||
petPreviewError string
|
||||
petPreviewLoading bool
|
||||
petPreviewSeq uint64
|
||||
petPreviewCancel context.CancelFunc
|
||||
petRequestedSlug string
|
||||
petPhase int
|
||||
petTickSeq uint64
|
||||
petPlaybackState terminalpet.State
|
||||
petClickAnimationIndex int
|
||||
petOutcome terminalpet.State
|
||||
petOutcomeAt time.Time
|
||||
petLayoutRendering bool
|
||||
petPositionSet bool
|
||||
petPositionX int
|
||||
petPositionY int
|
||||
petDragActive bool
|
||||
petDragMoved bool
|
||||
petDragStartedDocked bool
|
||||
petDragOffsetX int
|
||||
petDragOffsetY int
|
||||
petDragTargetX int
|
||||
petDragTargetY int
|
||||
petDragTargetOffsetX int
|
||||
petDragTargetOffsetY int
|
||||
petPositionOffsetX int
|
||||
petPositionOffsetY int
|
||||
petCellPixelWidth int
|
||||
petCellPixelHeight int
|
||||
petPixelDrag bool
|
||||
petPixelAnchorSet bool
|
||||
petDragOffsetPixelX int
|
||||
petDragOffsetPixelY int
|
||||
petDragState terminalpet.State
|
||||
petLastClickAt time.Time
|
||||
keyBindings keyBindings
|
||||
themeMode themeMode // palette preference: auto (default), dark, light
|
||||
hasDarkBg bool // last terminal background-detection result (auto mode)
|
||||
@@ -928,6 +974,8 @@ func newModel(ctx context.Context, options Options) model {
|
||||
permissionMode: permissionMode,
|
||||
reasoningEffort: options.ReasoningEffort,
|
||||
responseStyle: defaultedResponseStyle(options.ResponseStyle),
|
||||
petEntries: map[string]terminalpet.Entry{},
|
||||
petID: strings.TrimSpace(options.SavedPet),
|
||||
keyBindings: resolvedKeyBindings,
|
||||
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
|
||||
hasDarkBg: true,
|
||||
@@ -951,6 +999,22 @@ func newModel(ctx context.Context, options Options) model {
|
||||
setupSave: options.Setup.Save,
|
||||
dictation: newDictationController(options),
|
||||
}
|
||||
petRoot := userConfigDir
|
||||
if strings.TrimSpace(options.UserConfigPath) != "" {
|
||||
petRoot = filepath.Dir(options.UserConfigPath)
|
||||
}
|
||||
if strings.TrimSpace(petRoot) != "" {
|
||||
m.petClient = terminalpet.NewClient(petRoot)
|
||||
if m.petID != "" && m.petID != terminalpet.DisabledID {
|
||||
if animation, loadErr := m.petClient.LoadInstalled(m.petID); loadErr == nil {
|
||||
m.petAnimation = animation
|
||||
m.petName = m.petID
|
||||
if entry, entryErr := m.petClient.InstalledEntry(m.petID); entryErr == nil {
|
||||
m.petName = entry.Label()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Apply an explicit theme immediately; auto stays on the dark default until
|
||||
// Init's terminal background probe resolves it (see Init / BackgroundColorMsg).
|
||||
if m.themeMode != themeAuto {
|
||||
@@ -1033,6 +1097,17 @@ func composerBlinkCmd() tea.Cmd {
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd()}
|
||||
if m.petAnimation != nil && !m.reducedMotion {
|
||||
cmds = append(cmds, petTickCmd(m.petTickSeq, m.petFrameDelay()))
|
||||
}
|
||||
// Every image protocol wants this, not just the ones that support pixel
|
||||
// dragging. Sixel needs it most: it erases itself by writing over cells, so
|
||||
// without the pixels-per-cell figure it cannot know how many cells it
|
||||
// covered, and falls back to constants describing the reserved area rather
|
||||
// than the image.
|
||||
if m.petCellMetricsWanted() {
|
||||
cmds = append(cmds, tea.Raw(ansi.WindowOp(16)))
|
||||
}
|
||||
// Bubble Tea documents an initial WindowSizeMsg as delivered automatically
|
||||
// on program start, so m.height/m.width are normally set before the first
|
||||
// render. But that's the terminal proactively pushing a size — if it's
|
||||
@@ -1234,6 +1309,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return next, cmd
|
||||
}
|
||||
switch msg := msg.(type) {
|
||||
case uv.CellSizeEvent:
|
||||
if msg.Width > 0 && msg.Height > 0 {
|
||||
m.petCellPixelWidth = msg.Width
|
||||
m.petCellPixelHeight = msg.Height
|
||||
}
|
||||
return m, nil
|
||||
case peerMessageMsg:
|
||||
admitted := m.canAcceptPeerMessage(msg.message)
|
||||
if msg.admit != nil {
|
||||
@@ -1396,6 +1477,19 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyPressMsg:
|
||||
if m.petDragActive {
|
||||
pixelDrag := m.petPixelDrag
|
||||
if !keyIs(msg, tea.KeyEsc) && !keyCtrl(msg, 'c') {
|
||||
return m, nil
|
||||
}
|
||||
m.cancelPetDrag()
|
||||
m.lastKeyTime = time.Time{}
|
||||
m.burstCount = 0
|
||||
if pixelDrag {
|
||||
return m, petPixelMouseDisableCmd()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
// Paste-detection timing trackers. MUST run before any early return
|
||||
// so burst counting stays accurate regardless of which branch fires.
|
||||
now := m.now()
|
||||
@@ -1607,6 +1701,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// committed palette since Esc dismisses without choosing.
|
||||
m.restoreCommittedTheme()
|
||||
}
|
||||
if m.picker.kind == pickerPet {
|
||||
m.cancelPetPreview()
|
||||
}
|
||||
m.picker = nil
|
||||
return m, nil
|
||||
}
|
||||
@@ -1835,6 +1932,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Editing the filter changes which row is highlighted; keep the
|
||||
// theme preview in sync with it (no-op for other pickers).
|
||||
m.previewSelectedTheme()
|
||||
if m.picker.kind == pickerPet {
|
||||
return m.schedulePetPreview()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
// On an empty composer, Backspace removes the last attachment chip
|
||||
@@ -2057,6 +2157,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Filtering changes the highlighted row; keep the theme preview in
|
||||
// sync with it (no-op for other pickers).
|
||||
m.previewSelectedTheme()
|
||||
if m.picker.kind == pickerPet {
|
||||
return m.schedulePetPreview()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -2085,12 +2188,22 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case tea.BlurMsg:
|
||||
var petMouseCmd tea.Cmd
|
||||
if m.petDragActive {
|
||||
pixelDrag := m.petPixelDrag
|
||||
m.cancelPetDrag()
|
||||
m.lastKeyTime = time.Time{}
|
||||
m.burstCount = 0
|
||||
if pixelDrag {
|
||||
petMouseCmd = petPixelMouseDisableCmd()
|
||||
}
|
||||
}
|
||||
m.terminalFocused = false
|
||||
m.composerCursorVisible = false
|
||||
if m.notifier != nil {
|
||||
m.notifier.SetFocused(false)
|
||||
}
|
||||
return m, nil
|
||||
return m, petMouseCmd
|
||||
case toolCallStreamStartMsg:
|
||||
if msg.runID != m.activeRunID {
|
||||
return m, nil
|
||||
@@ -2261,6 +2374,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case tea.WindowSizeMsg:
|
||||
m.resizeFreePetPosition(m.width, m.height, msg.Width, msg.Height)
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
// A resize re-wraps content at a new width, shifting every row's bodyY;
|
||||
@@ -2419,6 +2533,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block
|
||||
if msg.err != nil {
|
||||
m.petOutcome = terminalpet.Failed
|
||||
} else {
|
||||
m.petOutcome = terminalpet.Review
|
||||
}
|
||||
m.petOutcomeAt = m.now()
|
||||
m.pending = false
|
||||
m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling
|
||||
// A newline-triggered redraw deferred by the stream-clear throttle
|
||||
@@ -2807,6 +2927,34 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.applySetupOAuthDeviceCode(msg)
|
||||
case modelPickerModelsDiscoveredMsg:
|
||||
return m.applyModelPickerModelsDiscovered(msg), nil
|
||||
case petCatalogLoadedMsg:
|
||||
return m.applyPetCatalog(msg)
|
||||
case petPreviewDebounceMsg:
|
||||
return m.startPetPreview(msg)
|
||||
case petPreviewLoadedMsg:
|
||||
m = m.applyPetPreview(msg)
|
||||
if m.petPreview != nil && m.petAnimation == nil && !m.reducedMotion {
|
||||
m.petTickSeq++
|
||||
return m, petTickCmd(m.petTickSeq, m.petFrameDelay())
|
||||
}
|
||||
return m, nil
|
||||
case petInstalledMsg:
|
||||
return m.applyPetInstall(msg)
|
||||
case petTickMsg:
|
||||
if msg.seq != m.petTickSeq {
|
||||
return m, nil
|
||||
}
|
||||
if (m.petAnimation == nil && (m.picker == nil || m.petPreview == nil)) || m.reducedMotion {
|
||||
return m, nil
|
||||
}
|
||||
_, state := m.petPlayback()
|
||||
if state != m.petPlaybackState {
|
||||
m.petPlaybackState = state
|
||||
m.petPhase = 0
|
||||
} else {
|
||||
m.petPhase++
|
||||
}
|
||||
return m, petTickCmd(m.petTickSeq, m.petFrameDelay())
|
||||
case ollamaContextWindowDiscoveredMsg:
|
||||
if msg.err == nil && msg.contextWindow > 0 {
|
||||
if m.ollamaContextWindowByModel == nil {
|
||||
@@ -2836,6 +2984,9 @@ func (m model) View() tea.View {
|
||||
} else {
|
||||
content = m.detailedTranscriptView()
|
||||
}
|
||||
if m.petRenderer != nil {
|
||||
m.petRenderer.Set(m.petImageDraw(content))
|
||||
}
|
||||
|
||||
view := tea.NewView(content)
|
||||
view.AltScreen = m.altScreen
|
||||
@@ -2908,6 +3059,9 @@ func (m model) transcriptView() string {
|
||||
if m.sidebarActive() && !m.subchat.active {
|
||||
return m.twoColumnTranscriptView()
|
||||
}
|
||||
if m.petLayoutActive() {
|
||||
return m.floatingPetTranscriptView()
|
||||
}
|
||||
|
||||
width := chatWidth(m.width)
|
||||
|
||||
@@ -3012,7 +3166,6 @@ func (m model) twoColumnTranscriptView() string {
|
||||
|
||||
header := m.pinnedTitleBar(width)
|
||||
chatBlock := viewLines(m.scrollableTranscriptItemsView(header, bodyItems, footer, width, overlayForViewport))
|
||||
|
||||
sidebar := m.renderContextSidebar(sidebarW, len(chatBlock))
|
||||
rows := joinColumns(chatBlock, sidebar, chatW, sidebarW)
|
||||
return strings.Join(rows, "\n")
|
||||
@@ -3040,7 +3193,7 @@ func (m model) footerView(width int) string {
|
||||
if m.renamePrompt != nil {
|
||||
footer.WriteString(m.sessionRenamePromptView(width))
|
||||
footer.WriteString("\n")
|
||||
footer.WriteString(m.statusLine(width))
|
||||
footer.WriteString(m.footerStatusLine(width))
|
||||
return footer.String()
|
||||
}
|
||||
// While an ask-user questionnaire is active it REPLACES the composer box (the
|
||||
@@ -3049,7 +3202,7 @@ func (m model) footerView(width int) string {
|
||||
if m.pendingAskUser != nil {
|
||||
footer.WriteString(renderAskUserQuestionnaire(*m.pendingAskUser, m.input.Value(), width))
|
||||
footer.WriteString("\n")
|
||||
footer.WriteString(m.statusLine(width))
|
||||
footer.WriteString(m.footerStatusLine(width))
|
||||
return footer.String()
|
||||
}
|
||||
// A focused permission prompt owns the keyboard: its options (and the feedback
|
||||
@@ -3059,7 +3212,7 @@ func (m model) footerView(width int) string {
|
||||
// input from echoing in two places once "tell Zero what to do differently"
|
||||
// opens the on-card feedback field.
|
||||
if m.pendingPermission != nil {
|
||||
footer.WriteString(m.statusLine(width))
|
||||
footer.WriteString(m.footerStatusLine(width))
|
||||
return footer.String()
|
||||
}
|
||||
// Pinned plan panel: sits directly above the composer so it stays visible
|
||||
@@ -3100,7 +3253,7 @@ func (m model) footerView(width int) string {
|
||||
footer.WriteString(hint)
|
||||
}
|
||||
footer.WriteString("\n")
|
||||
footer.WriteString(m.statusLine(width))
|
||||
footer.WriteString(m.footerStatusLine(width))
|
||||
return footer.String()
|
||||
}
|
||||
|
||||
@@ -4124,23 +4277,26 @@ func (m model) composerBox(width int) string {
|
||||
if width < 8 {
|
||||
return fitStyledLine(m.composerLine(width), width)
|
||||
}
|
||||
innerWidth := maxInt(1, width-4)
|
||||
reserved := m.petComposerReservedColumns(width)
|
||||
boxWidth := width - reserved
|
||||
innerWidth := maxInt(1, boxWidth-4)
|
||||
content := m.composerLine(innerWidth)
|
||||
lines := strings.Split(content, "\n")
|
||||
rightPad := strings.Repeat(" ", reserved)
|
||||
|
||||
rendered := make([]string, 0, len(lines)+3)
|
||||
rendered = append(rendered, zeroTheme.lineStrong.Render("╭"+strings.Repeat("─", width-2)+"╮"))
|
||||
rendered = append(rendered, zeroTheme.lineStrong.Render("╭"+strings.Repeat("─", boxWidth-2)+"╮")+rightPad)
|
||||
// Attachment chips ([Image #1] …) render INSIDE the box, above the input line,
|
||||
// instead of as a separate row above the box.
|
||||
if chips := renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments); chips != "" {
|
||||
fitted := fitStyledLine(zeroTheme.muted.Render(chips), innerWidth)
|
||||
pad := strings.Repeat(" ", maxInt(0, innerWidth-lipgloss.Width(fitted)))
|
||||
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │"))
|
||||
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │")+rightPad)
|
||||
}
|
||||
for _, line := range lines {
|
||||
fitted := fitStyledLine(line, innerWidth)
|
||||
pad := strings.Repeat(" ", maxInt(0, innerWidth-lipgloss.Width(fitted)))
|
||||
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │"))
|
||||
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │")+rightPad)
|
||||
}
|
||||
rendered = append(rendered, m.composerDividerLine(width))
|
||||
return strings.Join(rendered, "\n")
|
||||
@@ -4347,6 +4503,8 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case pickerSTTDownload:
|
||||
return m.handleSTTDownloadSelection(item.Value)
|
||||
case pickerPet:
|
||||
return m.installPet(item.Value)
|
||||
case pickerTheme:
|
||||
// The hovered palette is already live from the preview; handleThemeCommand
|
||||
// records the choice (m.themeMode) and re-applies it, and reports the switch.
|
||||
@@ -4503,6 +4661,8 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
return m.handleLoopCommand(command.text)
|
||||
case commandGoal:
|
||||
return m.handleGoalCommand(command.text)
|
||||
case commandPets:
|
||||
return m.handlePetsCommand(command.text)
|
||||
case commandExit:
|
||||
// Closing the session stops its foreground loops mid-task; warn once so a
|
||||
// token-spending loop isn't ended by reflex.
|
||||
@@ -4681,6 +4841,11 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
m, text = m.handleEffortCommand(command.text)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m, nil
|
||||
case commandFast:
|
||||
text := ""
|
||||
m, text = m.handleFastCommand(command.text)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m, nil
|
||||
case commandStyle:
|
||||
text := ""
|
||||
m, text = m.handleStyleCommand(command.text)
|
||||
@@ -5249,6 +5414,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
options.ProviderName = m.providerName
|
||||
options.Model = m.modelName
|
||||
options.ReasoningEffort = string(m.reasoningEffort)
|
||||
options.ServiceTier = m.activeServiceTier()
|
||||
options.ResponseStyle = m.responseStyle
|
||||
options.Cwd = m.cwd
|
||||
options.Images = images
|
||||
|
||||
@@ -198,6 +198,7 @@ func TestParseCommand(t *testing.T) {
|
||||
{input: "/plan off", kind: commandPlan, text: "off"},
|
||||
{input: "/compact", kind: commandCompact},
|
||||
{input: "/effort high", kind: commandEffort, text: "high"},
|
||||
{input: "/fast", kind: commandFast},
|
||||
{input: "/style concise", kind: commandStyle, text: "concise"},
|
||||
{input: "/debug-mode", kind: commandDebug},
|
||||
{input: "hello zero", kind: commandPrompt, text: "hello zero"},
|
||||
|
||||
@@ -151,6 +151,9 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if mouseRightPress(msg) {
|
||||
return m, pasteFromClipboardCmd()
|
||||
}
|
||||
if next, cmd, handled := m.handlePetMouse(msg); handled {
|
||||
return next, cmd
|
||||
}
|
||||
if mouseLeftPress(msg) {
|
||||
switch {
|
||||
case m.providerWizard != nil:
|
||||
@@ -187,6 +190,9 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
return m.choosePicker()
|
||||
}
|
||||
m.lastMouseSelection = target
|
||||
if m.picker.kind == pickerPet {
|
||||
return m.schedulePetPreview()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
case m.suggestionsActive():
|
||||
@@ -233,8 +239,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(-1)
|
||||
return m, nil
|
||||
return m.pickerMoved(-1)
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
m.moveSuggestion(-1)
|
||||
@@ -265,8 +270,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(1)
|
||||
return m, nil
|
||||
return m.pickerMoved(1)
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
m.moveSuggestion(1)
|
||||
|
||||
@@ -14,15 +14,29 @@ func mouseEventFilter() func(tea.Model, tea.Msg) tea.Msg {
|
||||
|
||||
func newMouseEventFilter(now func() time.Time, minInterval time.Duration) func(tea.Model, tea.Msg) tea.Msg {
|
||||
var last time.Time
|
||||
return func(_ tea.Model, msg tea.Msg) tea.Msg {
|
||||
return func(current tea.Model, msg tea.Msg) tea.Msg {
|
||||
switch msg.(type) {
|
||||
case tea.MouseWheelMsg, tea.MouseMotionMsg:
|
||||
current := now()
|
||||
if !last.IsZero() && current.Sub(last) < minInterval {
|
||||
if _, motion := msg.(tea.MouseMotionMsg); motion && petDragActive(current) {
|
||||
return msg
|
||||
}
|
||||
timestamp := now()
|
||||
if !last.IsZero() && timestamp.Sub(last) < minInterval {
|
||||
return nil
|
||||
}
|
||||
last = current
|
||||
last = timestamp
|
||||
}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
func petDragActive(current tea.Model) bool {
|
||||
switch current := current.(type) {
|
||||
case model:
|
||||
return current.petDragActive
|
||||
case *model:
|
||||
return current != nil && current.petDragActive
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -51,3 +51,22 @@ func TestMouseEventFilterDoesNotThrottleKeyboard(t *testing.T) {
|
||||
t.Fatal("keyboard events should not touch the mouse throttle clock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMouseEventFilterDoesNotThrottlePetDrag(t *testing.T) {
|
||||
clockReads := 0
|
||||
filter := newMouseEventFilter(func() time.Time {
|
||||
clockReads++
|
||||
return time.Unix(0, 0)
|
||||
}, mouseEventThrottleInterval)
|
||||
m := model{petDragActive: true}
|
||||
|
||||
if got := filter(m, tea.MouseMotionMsg(tea.Mouse{Button: tea.MouseLeft, X: 1, Y: 1})); got == nil {
|
||||
t.Fatal("first drag event should pass through")
|
||||
}
|
||||
if got := filter(m, tea.MouseMotionMsg(tea.Mouse{Button: tea.MouseLeft, X: 2, Y: 1})); got == nil {
|
||||
t.Fatal("every pet-drag event should pass through for zero-gap tracking")
|
||||
}
|
||||
if clockReads != 0 {
|
||||
t.Fatalf("pet drag should bypass the throttle clock, read it %d times", clockReads)
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,10 @@ type Options struct {
|
||||
// SavedTheme is the theme persisted in user config (Preferences.Theme). Applied
|
||||
// at startup below --theme and ZERO_THEME, so a /theme choice survives restart.
|
||||
SavedTheme string
|
||||
UserAgent string
|
||||
// SavedPet is the persisted terminal companion id. Empty means no pet has
|
||||
// been selected yet; "disabled" records an explicit opt-out.
|
||||
SavedPet string
|
||||
UserAgent string
|
||||
|
||||
// Notify configures completion / awaiting-input notifications.
|
||||
Notify config.NotifyConfig
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
)
|
||||
|
||||
const (
|
||||
terminalSyncStart = "\x1b[?2026h"
|
||||
terminalSyncEnd = "\x1b[?2026l"
|
||||
)
|
||||
|
||||
type terminalOutputFile interface {
|
||||
io.ReadWriteCloser
|
||||
Fd() uintptr
|
||||
}
|
||||
|
||||
type petImageOutput struct {
|
||||
output terminalOutputFile
|
||||
renderer *terminalpet.ImageRenderer
|
||||
renderDisabled bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newPetImageOutput(output terminalOutputFile, renderer *terminalpet.ImageRenderer) *petImageOutput {
|
||||
return &petImageOutput{output: output, renderer: renderer}
|
||||
}
|
||||
|
||||
func (o *petImageOutput) Read(value []byte) (int, error) {
|
||||
return o.output.Read(value)
|
||||
}
|
||||
|
||||
func (o *petImageOutput) Write(value []byte) (int, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
leavingAltScreen := bytes.Contains(value, []byte("\x1b[?1049l"))
|
||||
kittyImage := o.renderer.Support().Protocol == terminalpet.ImageProtocolKitty ||
|
||||
o.renderer.Support().Protocol == terminalpet.ImageProtocolKittyLocalFile
|
||||
if leavingAltScreen && !kittyImage {
|
||||
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if leavingAltScreen {
|
||||
written, err := o.output.Write(value)
|
||||
if err != nil {
|
||||
return written, err
|
||||
}
|
||||
if kittyImage {
|
||||
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
|
||||
return written, err
|
||||
}
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
if bytes.Contains(value, []byte(ansi.EraseEntireScreen)) ||
|
||||
bytes.Contains(value, []byte(ansi.SetModeAltScreenSaveCursor)) {
|
||||
o.renderer.Invalidate()
|
||||
}
|
||||
|
||||
var imageUpdate bytes.Buffer
|
||||
if !o.renderDisabled {
|
||||
if err := o.renderer.Render(&imageUpdate); err != nil {
|
||||
// Companions are decorative. Disable them for this output session after
|
||||
// a renderer failure instead of terminating the interactive shell.
|
||||
o.renderDisabled = true
|
||||
imageUpdate.Reset()
|
||||
}
|
||||
}
|
||||
if imageUpdate.Len() == 0 {
|
||||
return writeChecked(o.output, value)
|
||||
}
|
||||
|
||||
// Bubble Tea encloses supported terminal frames in synchronized-output
|
||||
// markers. Keep the pet placement inside that same transaction so the
|
||||
// terminal never presents the text frame and image movement separately.
|
||||
syncStart := bytes.LastIndex(value, []byte(terminalSyncStart))
|
||||
syncEnd := bytes.LastIndex(value, []byte(terminalSyncEnd))
|
||||
if syncEnd >= 0 && syncEnd > syncStart {
|
||||
var frame bytes.Buffer
|
||||
frame.Grow(len(value) + imageUpdate.Len())
|
||||
frame.Write(value[:syncEnd])
|
||||
frame.Write(imageUpdate.Bytes())
|
||||
frame.Write(value[syncEnd:])
|
||||
written, err := o.output.Write(frame.Bytes())
|
||||
consumed := originalBytesWritten(written, syncEnd, imageUpdate.Len(), len(value))
|
||||
if err == nil && written != frame.Len() {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if err != nil {
|
||||
return consumed, err
|
||||
}
|
||||
return len(value), nil
|
||||
}
|
||||
if syncStart > syncEnd {
|
||||
written, writeErr := writeChecked(o.output, value)
|
||||
if writeErr != nil {
|
||||
return written, writeErr
|
||||
}
|
||||
_, imageErr := writeChecked(o.output, imageUpdate.Bytes())
|
||||
return written, imageErr
|
||||
}
|
||||
|
||||
if _, err := io.WriteString(o.output, terminalSyncStart); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
written, writeErr := writeChecked(o.output, value)
|
||||
if writeErr == nil {
|
||||
_, writeErr = writeChecked(o.output, imageUpdate.Bytes())
|
||||
}
|
||||
_, endErr := io.WriteString(o.output, terminalSyncEnd)
|
||||
if writeErr != nil {
|
||||
return written, writeErr
|
||||
}
|
||||
return written, endErr
|
||||
}
|
||||
|
||||
func writeChecked(writer io.Writer, value []byte) (int, error) {
|
||||
written, err := writer.Write(value)
|
||||
if err == nil && written != len(value) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
return written, err
|
||||
}
|
||||
|
||||
// originalBytesWritten maps progress through an expanded synchronized frame
|
||||
// back to bytes consumed from the caller's original buffer. Bytes belonging
|
||||
// to the injected image update must never be reported as caller bytes.
|
||||
func originalBytesWritten(written, prefixLength, injectedLength, originalLength int) int {
|
||||
if written <= 0 {
|
||||
return 0
|
||||
}
|
||||
if written <= prefixLength {
|
||||
return written
|
||||
}
|
||||
consumed := prefixLength
|
||||
suffixWritten := written - prefixLength - injectedLength
|
||||
if suffixWritten > 0 {
|
||||
consumed += min(suffixWritten, originalLength-prefixLength)
|
||||
}
|
||||
return min(consumed, originalLength)
|
||||
}
|
||||
|
||||
func (o *petImageOutput) writeImageUpdate(render func(io.Writer) error) error {
|
||||
var update bytes.Buffer
|
||||
if err := render(&update); err != nil {
|
||||
return err
|
||||
}
|
||||
if update.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := io.WriteString(o.output, terminalSyncStart); err != nil {
|
||||
return err
|
||||
}
|
||||
_, writeErr := o.output.Write(update.Bytes())
|
||||
_, endErr := io.WriteString(o.output, terminalSyncEnd)
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
return endErr
|
||||
}
|
||||
|
||||
func (o *petImageOutput) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *petImageOutput) Fd() uintptr {
|
||||
return o.output.Fd()
|
||||
}
|
||||
|
||||
func (o *petImageOutput) clearImage() error {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
return o.writeImageUpdate(func(writer io.Writer) error {
|
||||
if _, err := io.WriteString(writer, ansi.ResetModeMouseExtSgrPixel); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := o.renderer.Clear(writer); err != nil {
|
||||
return err
|
||||
}
|
||||
return o.renderer.DeleteImages(writer, petAmbientImageID, petPreviewImageID)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
type shortTerminalOutput struct {
|
||||
limit int
|
||||
err error
|
||||
}
|
||||
|
||||
func (o *shortTerminalOutput) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
func (o *shortTerminalOutput) Close() error { return nil }
|
||||
func (o *shortTerminalOutput) Fd() uintptr { return 0 }
|
||||
func (o *shortTerminalOutput) Write(value []byte) (int, error) {
|
||||
return min(o.limit, len(value)), o.err
|
||||
}
|
||||
|
||||
func TestPetImageOutputAppendsScheduledImageAfterFrame(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
frame.SetNRGBA(0, 0, color.NRGBA{R: 255, A: 255})
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
if _, err := output.Write([]byte("bubbletea-frame")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
if !strings.HasPrefix(got, terminalSyncStart+"bubbletea-frame") || !strings.Contains(got, "_Ga=T,t=d,f=100,c=4,r=3") {
|
||||
t.Fatalf("output did not append image after frame: %q", got)
|
||||
}
|
||||
syncStart := strings.Index(got, "\x1b[?2026h")
|
||||
frameAt := strings.Index(got, "bubbletea-frame")
|
||||
imageAt := strings.Index(got, "_Ga=T,t=d,f=100,c=4,r=3")
|
||||
syncEnd := strings.LastIndex(got, "\x1b[?2026l")
|
||||
if syncStart < 0 || frameAt < syncStart || imageAt < frameAt || syncEnd < imageAt {
|
||||
t.Fatalf("frame and image update were not synchronized together: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputKeepsImageInsideBubbleTeaSynchronizedFrame(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
bubbleTeaFrame := terminalSyncStart + "streamed-text-frame" + terminalSyncEnd
|
||||
if _, err := output.Write([]byte(bubbleTeaFrame)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
if count := strings.Count(got, terminalSyncStart); count != 1 {
|
||||
t.Fatalf("got %d synchronized-output starts, want 1: %q", count, got)
|
||||
}
|
||||
if count := strings.Count(got, terminalSyncEnd); count != 1 {
|
||||
t.Fatalf("got %d synchronized-output ends, want 1: %q", count, got)
|
||||
}
|
||||
frameAt := strings.Index(got, "streamed-text-frame")
|
||||
imageAt := strings.Index(got, "_Ga=T,t=d,f=100,c=4,r=3")
|
||||
syncEnd := strings.Index(got, terminalSyncEnd)
|
||||
if frameAt < 0 || imageAt < frameAt || syncEnd < imageAt {
|
||||
t.Fatalf("pet update was presented outside the Bubble Tea frame: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputMapsPartialSynchronizedWriteToOriginalBytes(t *testing.T) {
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
bubbleTeaFrame := terminalSyncStart + "streamed-text-frame" + terminalSyncEnd
|
||||
prefixLength := strings.Index(bubbleTeaFrame, terminalSyncEnd)
|
||||
wantErr := errors.New("partial terminal write")
|
||||
sink := &shortTerminalOutput{limit: prefixLength + 3, err: wantErr}
|
||||
|
||||
written, err := newPetImageOutput(sink, renderer).Write([]byte(bubbleTeaFrame))
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Write error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if written != prefixLength {
|
||||
t.Fatalf("Write reported %d original bytes, want %d", written, prefixLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputReportsNilErrorShortWrite(t *testing.T) {
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
bubbleTeaFrame := terminalSyncStart + "streamed-text-frame" + terminalSyncEnd
|
||||
prefixLength := strings.Index(bubbleTeaFrame, terminalSyncEnd)
|
||||
sink := &shortTerminalOutput{limit: prefixLength + 3}
|
||||
|
||||
written, err := newPetImageOutput(sink, renderer).Write([]byte(bubbleTeaFrame))
|
||||
if !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("Write error = %v, want %v", err, io.ErrShortWrite)
|
||||
}
|
||||
if written != prefixLength {
|
||||
t.Fatalf("Write reported %d original bytes, want %d", written, prefixLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputKeepsDraggedPlacementInsideStreamingFrame(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
draw := terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3}
|
||||
renderer.Set(&draw)
|
||||
output := newPetImageOutput(file, renderer)
|
||||
if _, err := output.Write([]byte(terminalSyncStart + "initial-frame" + terminalSyncEnd)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start, err := file.Seek(0, io.SeekCurrent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draw.X = 8
|
||||
draw.Y = 6
|
||||
renderer.Set(&draw)
|
||||
if _, err := output.Write([]byte(terminalSyncStart + "streamed-delta" + terminalSyncEnd)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data[start:])
|
||||
if count := strings.Count(got, terminalSyncStart); count != 1 {
|
||||
t.Fatalf("got %d synchronized-output starts, want 1: %q", count, got)
|
||||
}
|
||||
if count := strings.Count(got, terminalSyncEnd); count != 1 {
|
||||
t.Fatalf("got %d synchronized-output ends, want 1: %q", count, got)
|
||||
}
|
||||
textAt := strings.Index(got, "streamed-delta")
|
||||
moveAt := strings.Index(got, "\x1b[7;9H\x1b_Ga=p")
|
||||
syncEnd := strings.Index(got, terminalSyncEnd)
|
||||
if textAt < 0 || moveAt < textAt || syncEnd < moveAt {
|
||||
t.Fatalf("dragged pet placement was presented outside the streaming frame: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "_Ga=T") {
|
||||
t.Fatalf("dragging retransmitted image data instead of moving its placement: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputFlushesMovedPlacementWithoutTextChange(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
draw := terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3}
|
||||
renderer.Set(&draw)
|
||||
output := newPetImageOutput(file, renderer)
|
||||
if _, err := output.Write([]byte(terminalSyncStart + "initial-frame" + terminalSyncEnd)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start, err := file.Seek(0, io.SeekCurrent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draw.X = 8
|
||||
draw.Y = 6
|
||||
renderer.Set(&draw)
|
||||
if _, err := output.Write([]byte(terminalSyncStart + terminalSyncEnd)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data[start:])
|
||||
moveAt := strings.Index(got, "\x1b[7;9H\x1b_Ga=p")
|
||||
syncEnd := strings.Index(got, terminalSyncEnd)
|
||||
if moveAt < 0 || syncEnd < moveAt {
|
||||
t.Fatalf("idle drag flush did not place the pet inside its empty frame: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "_Ga=T") {
|
||||
t.Fatalf("idle drag flush retransmitted image data: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputRetransmitsAfterFullScreenClear(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
if _, err := output.Write([]byte(terminalSyncStart + "initial-frame" + terminalSyncEnd)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start, err := file.Seek(0, io.SeekCurrent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clearFrame := terminalSyncStart + ansi.EraseEntireScreen + "resized-frame" + terminalSyncEnd
|
||||
if _, err := output.Write([]byte(clearFrame)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data[start:])
|
||||
clearAt := strings.Index(got, ansi.EraseEntireScreen)
|
||||
transmitAt := strings.Index(got, "_Ga=T,t=d,f=100,c=4,r=3")
|
||||
syncEnd := strings.Index(got, terminalSyncEnd)
|
||||
if clearAt < 0 || transmitAt < clearAt || syncEnd < transmitAt {
|
||||
t.Fatalf("pet was not restored inside the full-screen redraw: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputClearsKittyImageAfterLeavingAltScreen(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
if _, err := output.Write([]byte("frame")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := output.Write([]byte("\x1b[?1049l")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
deleteAt := strings.LastIndex(got, "_Ga=d,d=I,i=7,q=2")
|
||||
exitAt := strings.LastIndex(got, "\x1b[?1049l")
|
||||
if deleteAt < 0 || exitAt < 0 || deleteAt < exitAt {
|
||||
t.Fatalf("Kitty image was not cleared after alt-screen exit: %q", got)
|
||||
}
|
||||
syncStart := strings.LastIndex(got[:deleteAt], "\x1b[?2026h")
|
||||
syncEnd := strings.Index(got[deleteAt:], "\x1b[?2026l")
|
||||
if syncStart < exitAt || syncEnd < 0 {
|
||||
t.Fatalf("Kitty image cleanup was not synchronized after alt-screen exit: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputFinalCleanupRepeatsAllKittyDeletes(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
if _, err := output.Write([]byte("frame")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := output.Write([]byte("\x1b[?1049l")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupStart, err := file.Seek(0, io.SeekCurrent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := output.clearImage(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanup := string(data[cleanupStart:])
|
||||
if !strings.Contains(cleanup, ansi.ResetModeMouseExtSgrPixel) {
|
||||
t.Fatalf("final cleanup did not reset pixel mouse mode: %q", cleanup)
|
||||
}
|
||||
for _, id := range []uint32{petAmbientImageID, petPreviewImageID} {
|
||||
want := fmt.Sprintf("_Ga=d,d=I,i=%d,q=2", id)
|
||||
if !strings.Contains(cleanup, want) {
|
||||
t.Errorf("final cleanup did not repeat Kitty delete for image %d: %q", id, cleanup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputClearsSixelBeforeLeavingAltScreen(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
animation, err := terminalpet.ThumbnailAnimation(frame)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3, HeightPixels: 12})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
if _, err := output.Write([]byte("frame")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := output.Write([]byte("\x1b[?1049l")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
clearAt := strings.LastIndex(got, "\x1b[4;3H ")
|
||||
exitAt := strings.LastIndex(got, "\x1b[?1049l")
|
||||
if clearAt < 0 || exitAt < 0 || clearAt > exitAt {
|
||||
t.Fatalf("Sixel cell area was not cleared before alt-screen exit: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputDoesNotCloseAnExistingSynchronizedFrame(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
if _, err := newPetImageOutput(file, renderer).Write([]byte(terminalSyncStart + "partial-frame")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
if strings.Count(got, terminalSyncStart) != 1 || strings.Contains(got, terminalSyncEnd) {
|
||||
t.Fatalf("pet output changed caller-owned synchronized frame boundaries: %q", got)
|
||||
}
|
||||
if strings.Index(got, "_Ga=T") < strings.Index(got, "partial-frame") {
|
||||
t.Fatalf("pet image was not appended to the open frame: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputReportsUnsynchronizedShortWrite(t *testing.T) {
|
||||
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
sink := &shortTerminalOutput{limit: len(terminalSyncStart)}
|
||||
written, err := newPetImageOutput(sink, renderer).Write([]byte("long-frame-payload"))
|
||||
if !errors.Is(err, io.ErrShortWrite) || written != len(terminalSyncStart) {
|
||||
t.Fatalf("Write() = %d, %v; want %d, %v", written, err, len(terminalSyncStart), io.ErrShortWrite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputKeepsTextAliveAfterRendererFailure(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKittyLocalFile})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
for _, frame := range []string{"first-frame", "second-frame"} {
|
||||
if written, err := output.Write([]byte(frame)); err != nil || written != len(frame) {
|
||||
t.Fatalf("Write(%q) = %d, %v", frame, written, err)
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(data); got != "first-framesecond-frame" {
|
||||
t.Fatalf("text output after renderer failure = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetImageOutputSerializesWriteAndCleanup(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
renderer.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
output := newPetImageOutput(file, renderer)
|
||||
var wait sync.WaitGroup
|
||||
errorsSeen := make(chan error, 9)
|
||||
for range 8 {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
_, err := output.Write([]byte("frame"))
|
||||
errorsSeen <- err
|
||||
}()
|
||||
}
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
errorsSeen <- output.clearImage()
|
||||
}()
|
||||
wait.Wait()
|
||||
close(errorsSeen)
|
||||
for err := range errorsSeen {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
)
|
||||
|
||||
// The erase rectangle has to describe what was PAINTED, not the reserved area.
|
||||
//
|
||||
// Measured on Windows Terminal 1.24, which answers CSI 16 t with a 20x10 cell.
|
||||
// A companion is rendered at preferredHeight (75px), so it covers
|
||||
// ceil(75/20) = 4 rows, while petImageRows is 5. The erase used the constant and
|
||||
// blanked a fifth row of live interface every time the companion moved, which is
|
||||
// the broken border and the eaten characters reported on Windows.
|
||||
//
|
||||
// Sixel is the only protocol that erases by writing over cells, so it is the
|
||||
// only one that needs this; the Kitty case below pins that it is left alone.
|
||||
func TestSixelEraseFootprintMatchesWhatWasPainted(t *testing.T) {
|
||||
m := model{
|
||||
petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel}),
|
||||
petCellPixelWidth: 10,
|
||||
petCellPixelHeight: 20,
|
||||
}
|
||||
columns, rows := m.petImageCells(nil, terminalpet.Idle, 0, 75)
|
||||
if rows != 4 {
|
||||
t.Errorf("rows = %d, want 4 for a 75px sprite in a 20px cell; erasing %d would blank live interface", rows, petImageRows)
|
||||
}
|
||||
if rows > petImageRows || columns > petImageColumns {
|
||||
t.Errorf("footprint %dx%d exceeds the reserved %dx%d area", columns, rows, petImageColumns, petImageRows)
|
||||
}
|
||||
}
|
||||
|
||||
// Kitty passes Columns/Rows to the terminal as the placement REQUEST and the
|
||||
// terminal owns the region, so the constants are correct there and must not be
|
||||
// recomputed.
|
||||
func TestKittyKeepsTheRequestedPlacementSize(t *testing.T) {
|
||||
m := model{
|
||||
petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
|
||||
petCellPixelWidth: 10,
|
||||
petCellPixelHeight: 20,
|
||||
}
|
||||
columns, rows := m.petImageCells(nil, terminalpet.Idle, 0, 75)
|
||||
if columns != petImageColumns || rows != petImageRows {
|
||||
t.Errorf("Kitty placement = %dx%d, want the requested %dx%d", columns, rows, petImageColumns, petImageRows)
|
||||
}
|
||||
}
|
||||
|
||||
// Without metrics there is nothing to compute from, so it must fall back to the
|
||||
// constants rather than to zero, which would erase nothing and leave the
|
||||
// companion smeared across the screen.
|
||||
func TestFootprintFallsBackToTheReservedAreaWithoutMetrics(t *testing.T) {
|
||||
m := model{petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel})}
|
||||
columns, rows := m.petImageCells(nil, terminalpet.Idle, 0, 75)
|
||||
if columns != petImageColumns || rows != petImageRows {
|
||||
t.Errorf("fallback = %dx%d, want the reserved %dx%d", columns, rows, petImageColumns, petImageRows)
|
||||
}
|
||||
}
|
||||
|
||||
// The request has to be sent for a sixel terminal, or the metrics never arrive
|
||||
// and every computation above silently uses the fallback. This is the actual
|
||||
// defect: the reply was never asked for, not unsupported.
|
||||
func TestCellMetricsAreRequestedForEveryImageProtocol(t *testing.T) {
|
||||
for _, protocol := range []terminalpet.ImageProtocol{
|
||||
terminalpet.ImageProtocolSixel,
|
||||
terminalpet.ImageProtocolKitty,
|
||||
terminalpet.ImageProtocolKittyLocalFile,
|
||||
} {
|
||||
m := model{petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: protocol})}
|
||||
if !m.petCellMetricsWanted() {
|
||||
t.Errorf("protocol %v does not ask the terminal for its cell size, so the reply never arrives", protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+19
-3
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
)
|
||||
|
||||
// pickerKind identifies which command a picker selection feeds back into.
|
||||
@@ -27,6 +28,7 @@ const (
|
||||
pickerSkill
|
||||
pickerSTTModel
|
||||
pickerSTTDownload
|
||||
pickerPet
|
||||
)
|
||||
|
||||
// pickerItem is one selectable row: Label is shown, Value is passed to the
|
||||
@@ -406,8 +408,9 @@ func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descri
|
||||
needOAuth := key == "" && descriptor.OAuth && !descriptor.OAuthMintsKey
|
||||
discover := m.discoverProviderModels
|
||||
if discover == nil {
|
||||
discoveryOptions := m.modelPickerDiscoveryOptions(authed)
|
||||
discover = func(ctx context.Context, p config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return providermodeldiscovery.DiscoverCatalog(ctx, descriptor, p, providermodeldiscovery.Options{})
|
||||
return providermodeldiscovery.DiscoverCatalog(ctx, descriptor, p, discoveryOptions)
|
||||
}
|
||||
}
|
||||
providerID := descriptor.ID
|
||||
@@ -425,6 +428,15 @@ func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descri
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) modelPickerDiscoveryOptions(profile config.ProviderProfile) providermodeldiscovery.Options {
|
||||
oauthResolver, loginKey := providers.OAuthLoginForProfile(profile)
|
||||
return providermodeldiscovery.Options{
|
||||
OAuthResolver: oauthResolver,
|
||||
CodexAccountResolver: providers.CodexAccountResolverForLogin(loginKey),
|
||||
UserAgent: m.userAgent,
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) modelPickerIsLoading() bool {
|
||||
return m.picker != nil && m.picker.kind == pickerModel && m.modelPickerLoading
|
||||
}
|
||||
@@ -1029,10 +1041,14 @@ func (m model) newThemePicker() *commandPicker {
|
||||
// repaints the UI in the hovered palette. Safe to call with no picker open. Callers
|
||||
// mutate through m.picker (a pointer) and the global theme, so the value receiver
|
||||
// is fine.
|
||||
func (m model) pickerMoved(delta int) {
|
||||
func (m model) pickerMoved(delta int) (model, tea.Cmd) {
|
||||
if m.picker == nil {
|
||||
return
|
||||
return m, nil
|
||||
}
|
||||
m.picker.move(delta)
|
||||
m.previewSelectedTheme()
|
||||
if m.picker.kind == pickerPet {
|
||||
return m.schedulePetPreview()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user