Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 330b10fe8e docs(readme): clarify /loop runs a prompt or custom command, not built-ins 2026-07-06 08:59:06 +05:30
Vasanthdev2004 ac25352e12 fix(tui): address /loop review round 2 — ticker re-arm, self-paced command, resume guard
- stopLoop re-arms the poll ticker after removing one loop, so stopping one of
  several no longer strands the rest with no tick scheduled (jatmn).
- a bare self-paced /command is rejected with a pointer to the interval form:
  self-paced drives a free-form goal via the playbook + LOOP: control line, which
  a custom command's expanded template never carries, so it would silently
  degrade to adaptive polling (jatmn).
- /resume only tears loops down on an actual session-id change; /resume latest or
  /resume <currentID> can resolve to the already-active session, whose loops are
  its own, not a "previous" session's (CodeRabbit).
- tests: skipped-behind-modal loop stays scheduled, footer carries the next-wake
  time, the confirmed /clear actually wipes the transcript, plus multi-loop stop
  keeping the ticker alive and self-paced-/command rejection.
2026-07-06 08:53:04 +05:30
Vasanthdev2004 de7f0b6a5b fix(tui): finish /loop v1 — real self-paced completion, session scope, leave gate
Response to the review. The self-paced path was the real gap: it advertised
model-chosen completion but nothing ever wrote the done/next-wake fields, so it
was just adaptive polling dressed up. Replaced that with an actual control
channel — each self-paced iteration runs a short maintenance playbook and ends
its reply with a control line (LOOP: DONE / LOOP: CONTINUE [interval]) that
advanceLoop parses to stop the loop or pick the next wake. The two dead model
fields are gone; done and next-wake now come from the model's output.

Rest of the review:
- loops stop on a session switch (/new, /resume) instead of firing the previous
  conversation's prompt into the new one
- validateLoopTarget resolves custom /commands up front and rejects unknowns, so
  a mistyped `/loop 5m /nope` isn't scheduled and then re-sent as literal text
- a one-shot confirm gates /clear and /quit while loops are active
- age-based auto-expire (24h) next to the iteration cap
- the two CodeRabbit test nits (HasPrefix footer check, bare `/loop stop` on a
  single loop)

Hardening from a self-review pass over the above:
- normalize CRLF and allow a trailing note on the control line, so
  `LOOP: DONE — all clean` and \r\n replies still stop the loop
- disarm the leave-confirm before the queue/exit early returns, so an interposed
  prompt can't leave it falsely armed
- a loop no longer fires behind an open picker/modal; a run launched behind the
  /resume picker would otherwise complete into whatever session you switch to
2026-07-06 08:53:04 +05:30
Vasanthdev2004 3eafaf584d fix(tui): stop looping on repeated failure + clear loop tag on cancel
Addresses CodeRabbit review on #502:
- advanceLoop now counts consecutive failed iterations (loopMaxFailures=3) and
  stops the loop, so a loop whose turns keep erroring no longer churns to the
  iteration cap (the doom guard only sees identical non-empty answers).
- cancelRun clears activeLoopID and re-arms the interrupted loop for its next
  cadence, so a cancelled iteration is not left running forever and the next
  unrelated turn is not misattributed as that loop's completion.
2026-07-06 08:53:04 +05:30
Vasanthdev2004 9cfdb2fecf docs(readme): add /loop to the slash-command table 2026-07-06 08:53:04 +05:30
Vasanthdev2004 4ffbf4e0fe test(tui): drive /loop end-to-end through Update (submit + stale tick) 2026-07-06 08:53:04 +05:30
Vasanthdev2004 440d4df367 feat(tui): /loop self-paced adaptive cadence + footer summary + /clear note
Third slice of #501.

- Self-paced loops now use an adaptive cadence (2m early -> 30m heartbeat as
  iterations accrue), with the loopNextWake channel kept as an override for a
  future explicit model signal.
- Persistent footer segment "↻ N loops · next 3:05pm" while loops are active.
- /clear notes that loops keep running (it wipes the transcript, not the session)
  so a loop never silently fires into a cleared screen.
- Tests for adaptive cadence + footer summary.
2026-07-06 08:53:04 +05:30
Vasanthdev2004 4bc9a97176 feat(tui): wire /loop into the turn lifecycle — dispatch, idle tick, continuation
Second slice of #501. Adds the controller and wires it in:

- /loop command registered (commands.go) and dispatched to handleLoopCommand.
- Loop state on the model (loops, activeLoopID, loopSeq/loopTicking poll guards,
  loopNextWake/loopDone self-pace channel).
- An idle poll tick (loopTickMsg, ~1s) fires the earliest due loop only when the
  session is idle — mirrors the launchQueuedMessageIfReady guard so loops and a
  user's queued prompt never collide; a self-perpetuating tea.Tick that stops when
  no loops remain.
- The agentResponseMsg completion seam advances a loop iteration (schedule next
  wake for fixed, model delay for self-paced) with doom-loop, iteration-cap, and
  done stop conditions, restarting the ticker if other loops remain.
- /loop list|stop|stop all, footer summary + list rendering, and a guard that only
  a prompt or custom /command may loop (not a built-in).

Controller tests cover start/advance/self-paced-delay/done/doom/cap/stop/busy.
2026-07-06 08:53:04 +05:30
Vasanthdev2004 6d7d3946c8 feat(tui): /loop foundation — command parsing, loop state, safety bounds
First slice of the /loop feature (#501): the self-contained core with no TUI
wiring yet. parseLoopCommand handles the leading-interval, trailing-"every",
bare-self-paced, and list/stop forms; loopState models one session-scoped loop
with doom-loop + iteration-cap fields; interval/self-pace clamps and duration
formatting round it out, with unit tests. Because Zero drives loops off a Go
timer rather than a cron backend, arbitrary intervals (90s, 7m) are supported
with no whitelist limitation.
2026-07-06 08:52:13 +05:30
110 changed files with 148 additions and 12623 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{".":"0.2.0"}
{".":"0.1.0"}
-65
View File
@@ -6,71 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.2.0](https://github.com/Gitlawb/zero/compare/v0.1.0...v0.2.0) (2026-07-06)
### Features
* add --auto flag for LLM-generated commit messages ([#423](https://github.com/Gitlawb/zero/issues/423)) ([b0abde7](https://github.com/Gitlawb/zero/commit/b0abde7d0697e808480cd59d69a6f4d0c6320475))
* add zero changes push and pr subcommands, and extra repo-info metrics ([#391](https://github.com/Gitlawb/zero/issues/391)) ([2312abe](https://github.com/Gitlawb/zero/commit/2312abe5ddd95f4c6ef373cfb61cc03092f48cdd))
* agent quality, caching, retry, and tooling upgrades ([#506](https://github.com/Gitlawb/zero/issues/506)) ([3c81fea](https://github.com/Gitlawb/zero/commit/3c81fea22873ee3df7fc97b10cb4f77792706c4b))
* **agent:** curb over-engineering the solution in the editing discipline ([#517](https://github.com/Gitlawb/zero/issues/517)) ([f4c998a](https://github.com/Gitlawb/zero/commit/f4c998ac30c4f07ff313a2d706791e857293be49)), closes [#516](https://github.com/Gitlawb/zero/issues/516)
* **agent:** inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt ([#475](https://github.com/Gitlawb/zero/issues/475)) ([7b10aab](https://github.com/Gitlawb/zero/commit/7b10aab74bf14a01166b2cea22deab79bba9850b))
* **openai:** forward prompt_cache_key for server-side prefix cache routing ([#515](https://github.com/Gitlawb/zero/issues/515)) ([87e7e69](https://github.com/Gitlawb/zero/commit/87e7e69afd18b5539579856f3a61c6a95bc445ae))
* **providers:** add `zero providers models` to discover a provider's models ([#386](https://github.com/Gitlawb/zero/issues/386)) ([0bc8074](https://github.com/Gitlawb/zero/commit/0bc8074c97b0310e4a9d70c3f967003ee5e8a59f))
* **providers:** add KiloCode and OpenCode provider support ([#388](https://github.com/Gitlawb/zero/issues/388)) ([b1ccb6d](https://github.com/Gitlawb/zero/commit/b1ccb6d9c1875377f5e5ea81a1304edd1e41ab4f))
* **providers:** add Meituan LongCat catalog preset ([#424](https://github.com/Gitlawb/zero/issues/424)) ([b4275e3](https://github.com/Gitlawb/zero/commit/b4275e350472b2490212bf814709819d354c1216))
* **providers:** split minimax zai into intl cn ([#398](https://github.com/Gitlawb/zero/issues/398)) ([aaad4d2](https://github.com/Gitlawb/zero/commit/aaad4d271270f41af837b6f3b60ae80beba0c645))
* require manual approval before npm publish + drop release-as pin ([#369](https://github.com/Gitlawb/zero/issues/369)) ([bd89a1f](https://github.com/Gitlawb/zero/commit/bd89a1f451643c1b65ec803070abc7b116631ebe))
* **sandbox:** unelevated Windows fallback tier instead of prompts-only degrade ([#427](https://github.com/Gitlawb/zero/issues/427)) ([b9ddd6f](https://github.com/Gitlawb/zero/commit/b9ddd6f42138312a1fee8d8bb67c46c8eb1dea2f))
* support shift enter for composer newlines ([#462](https://github.com/Gitlawb/zero/issues/462)) ([daf65e0](https://github.com/Gitlawb/zero/commit/daf65e0af9a040314d4ab337b0ad59c55416b7bc))
* **tui:** /loop — repeat a prompt or command on an interval or self-paced ([#502](https://github.com/Gitlawb/zero/issues/502)) ([387fe67](https://github.com/Gitlawb/zero/commit/387fe67ee7cd81317c9c969f5906a4437080fea3))
* **tui:** add search/filter to provider picker in setup wizard ([#400](https://github.com/Gitlawb/zero/issues/400)) ([2fcea71](https://github.com/Gitlawb/zero/commit/2fcea71778d23e050c93409c471aef45b68c1621))
* **update:** add zero upgrade command to apply self-updates ([#461](https://github.com/Gitlawb/zero/issues/461)) ([5f36349](https://github.com/Gitlawb/zero/commit/5f36349c1884e81fa9bc66bb5fe813b627e897b7))
### Bug Fixes
* **action:** keep provider key scoped to zero step ([#448](https://github.com/Gitlawb/zero/issues/448)) ([407a927](https://github.com/Gitlawb/zero/commit/407a92739ff508cba32d2c12b3f36f0efcdd54c3))
* add android platform support for Termux npm install ([#455](https://github.com/Gitlawb/zero/issues/455)) ([9bd93c6](https://github.com/Gitlawb/zero/commit/9bd93c62f8d57fb74057284aa66a1b6e1429dcdd)), closes [#449](https://github.com/Gitlawb/zero/issues/449)
* **agent:** reject a malformed additional_permissions payload before prompting ([#453](https://github.com/Gitlawb/zero/issues/453)) ([e4f760e](https://github.com/Gitlawb/zero/commit/e4f760ee8bd57299cd2fcb37e8e23130037c2607))
* allow non-TLS connections to private-network provider endpoints ([#444](https://github.com/Gitlawb/zero/issues/444)) ([1d86384](https://github.com/Gitlawb/zero/commit/1d8638466ca31517eb9db2b9353d3dce1cbeeabc))
* **auth:** route zero auth login chatgpt to the dedicated ChatGPT flow ([#443](https://github.com/Gitlawb/zero/issues/443)) ([305a62c](https://github.com/Gitlawb/zero/commit/305a62c954ca6cec00bc58d5398f933415156aff))
* **config:** fall back to a usable saved provider instead of forcing full re-onboarding ([#410](https://github.com/Gitlawb/zero/issues/410)) ([c60ad87](https://github.com/Gitlawb/zero/commit/c60ad8729f79bb841114d352ee2d2fe29d5d0e41))
* **config:** let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible ([#497](https://github.com/Gitlawb/zero/issues/497)) ([30dd7c3](https://github.com/Gitlawb/zero/commit/30dd7c3112ad22d42fa12b5addd4e38f4beda42a)), closes [#479](https://github.com/Gitlawb/zero/issues/479)
* **config:** unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors ([#385](https://github.com/Gitlawb/zero/issues/385)) ([72eed06](https://github.com/Gitlawb/zero/commit/72eed06b4f94c43d75d31fe54a58d2f566de059e))
* **config:** use ~/.config on macOS and enter setup when no provider ([#371](https://github.com/Gitlawb/zero/issues/371)) ([#372](https://github.com/Gitlawb/zero/issues/372)) ([027a8f2](https://github.com/Gitlawb/zero/commit/027a8f2768b17b89f5c8270887f156e2ccda69ea))
* **docs:** rename AGENTS.MD &gt; AGENTS.md ([#438](https://github.com/Gitlawb/zero/issues/438)) ([4266baf](https://github.com/Gitlawb/zero/commit/4266baf222df583ed2078b776687f12d496475b5))
* **gemini:** strip unsupported JSON Schema fields from tool declarations ([#374](https://github.com/Gitlawb/zero/issues/374)) ([39e7100](https://github.com/Gitlawb/zero/commit/39e7100674150144a1152e3110c64c7cf0321d64)), closes [#373](https://github.com/Gitlawb/zero/issues/373)
* **install:** persist install dir to user PATH on Windows ([#407](https://github.com/Gitlawb/zero/issues/407)) ([bdb1b0e](https://github.com/Gitlawb/zero/commit/bdb1b0ecd15859b1712a6037d296dace7f9c3c3f))
* **mcp:** block cross-origin credential redirects ([#396](https://github.com/Gitlawb/zero/issues/396)) ([f915f70](https://github.com/Gitlawb/zero/commit/f915f70e5a3096e2419fa8d961a0f84a626fa4a9))
* **oauth:** treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile ([#445](https://github.com/Gitlawb/zero/issues/445)) ([d05e914](https://github.com/Gitlawb/zero/commit/d05e9148a7f79f67d1d3c31fca2775f21fbd331e))
* **openai:** handle Ollama reasoning stream deltas ([#486](https://github.com/Gitlawb/zero/issues/486)) ([f6c0606](https://github.com/Gitlawb/zero/commit/f6c060631e18e082dda24cc4dc0903c31c2120d6))
* preserve conversation context in exec prompts ([#460](https://github.com/Gitlawb/zero/issues/460)) ([949ee43](https://github.com/Gitlawb/zero/commit/949ee43f71e5cb7fab4695c5cb7b442fe4ecfbf7))
* **provider-wizard:** allow multiple custom OpenAI-compatible providers ([#403](https://github.com/Gitlawb/zero/issues/403)) ([3fbbd28](https://github.com/Gitlawb/zero/commit/3fbbd28e4c586822cc4312c86232d94befe56e87))
* **sandbox:** fix nested pipe creation under the Windows restricted token ([#456](https://github.com/Gitlawb/zero/issues/456)) ([563a6db](https://github.com/Gitlawb/zero/commit/563a6dbe91e65d5daeefd7626e8a77e30a6d8fb2))
* **sandbox:** gate /tmp test assertions on GOOS, not path existence ([#426](https://github.com/Gitlawb/zero/issues/426)) ([f653dca](https://github.com/Gitlawb/zero/commit/f653dcac363fb69ad7be5b35e6e0fa6d2bce476d))
* **sandbox:** self-heal a corrupt unelevated setup marker ([#437](https://github.com/Gitlawb/zero/issues/437)) ([8d0c5fe](https://github.com/Gitlawb/zero/commit/8d0c5feccb8bdbfb015df0508aa6e3bcbd1fd0e8))
* **specialist:** cap max specialist nesting depth ([#491](https://github.com/Gitlawb/zero/issues/491)) ([177442c](https://github.com/Gitlawb/zero/commit/177442cfe4015bd8df04cc9894f98b468ee796d4))
* Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs ([#509](https://github.com/Gitlawb/zero/issues/509)) ([0f69d99](https://github.com/Gitlawb/zero/commit/0f69d995e9b586b774f66c066b21abab5e03024a))
* **tools:** Block MSYS coreutils under Windows sandbox ([#476](https://github.com/Gitlawb/zero/issues/476)) ([81aad58](https://github.com/Gitlawb/zero/commit/81aad58d97839e51c068e2f08907618991fdc3fb))
* **tools:** CRLF line ending mismatch in edit_file tool on Windows ([#378](https://github.com/Gitlawb/zero/issues/378)) ([33dc7ae](https://github.com/Gitlawb/zero/commit/33dc7ae2cc82c5389675531e1416856dae7151ce))
* **tools:** fix cmd.exe /S/C corrupting commands with embedded quotes ([#465](https://github.com/Gitlawb/zero/issues/465)) ([190241b](https://github.com/Gitlawb/zero/commit/190241bd593f43211b766e0b13c8e89802d4bb37))
* **tools:** flag piped POSIX utilities before running on Windows ([#412](https://github.com/Gitlawb/zero/issues/412)) ([5658a36](https://github.com/Gitlawb/zero/commit/5658a366274fc59a9d5336b06a21019c9c25cbf1))
* **tools:** make grep and glob respect run cancellation ([#464](https://github.com/Gitlawb/zero/issues/464)) ([ba6c026](https://github.com/Gitlawb/zero/commit/ba6c0264697b7d7ed479f6e782fba9700a481e3d))
* **tools:** require permission before web_search requests ([#382](https://github.com/Gitlawb/zero/issues/382)) ([960db96](https://github.com/Gitlawb/zero/commit/960db9660e4e31dc588fe8f7d6f116ff5e225566))
* **tui:** compose help overlay through the viewport overlay pipeline ([#421](https://github.com/Gitlawb/zero/issues/421)) ([5b2b4de](https://github.com/Gitlawb/zero/commit/5b2b4dea1aaf9e0f68baa25e97e83296fb17b1a2))
* **tui:** keep the profile name on /model switch so the stored key resolves ([#441](https://github.com/Gitlawb/zero/issues/441)) ([9134148](https://github.com/Gitlawb/zero/commit/9134148f4df3e4e556fba6c2f8babfdf6fcfeee1)), closes [#440](https://github.com/Gitlawb/zero/issues/440)
* **tui:** resolve every permission request so the agent can't deadlock ([#397](https://github.com/Gitlawb/zero/issues/397)) ([952788f](https://github.com/Gitlawb/zero/commit/952788f72d32957659fe004521fcc8372b9ba9b4))
* **tui:** show an M suffix for million-scale token counts ([#457](https://github.com/Gitlawb/zero/issues/457)) ([0562e3b](https://github.com/Gitlawb/zero/commit/0562e3bef7df2328610a48a1e81632a8da4aec64))
* **tui:** title /model rows by model name, not the catalog description ([#395](https://github.com/Gitlawb/zero/issues/395)) ([cdf9d83](https://github.com/Gitlawb/zero/commit/cdf9d839ae57a729f292f36f7c5b0c67b41b288d))
### Performance Improvements
* cache TUI model registry ([#496](https://github.com/Gitlawb/zero/issues/496)) ([e7d88b4](https://github.com/Gitlawb/zero/commit/e7d88b4b518049733da25a8447c00144bd1da518))
* universal tool-output ceiling with spill + async post-edit diagnostics ([#518](https://github.com/Gitlawb/zero/issues/518)) ([95ccd5b](https://github.com/Gitlawb/zero/commit/95ccd5bc327f6fb464ff0239f7229de789f578dc))
## 0.1.0 (2026-07-02)
-1
View File
@@ -14,7 +14,6 @@ require (
github.com/charmbracelet/colorprofile v0.4.3
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/sys v0.46.0
mvdan.cc/sh/v3 v3.13.1
-2
View File
@@ -32,8 +32,6 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
+21 -54
View File
@@ -5,7 +5,6 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"log"
"strings"
"sync"
@@ -160,14 +159,8 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
// Load history BEFORE publishing the session so no concurrent prompt observes
// a half-initialized session (registerSession sets history under the lock and
// reuses an already-live session rather than orphaning its in-flight turn).
history, historyErr := a.loadHistory(meta.SessionID)
history := a.loadHistory(meta.SessionID)
sess := a.registerSession(meta.SessionID, root, history)
a.warnPersistence(
&notifier{conn: a.conn, sessionID: sess.id},
"load session history",
"Could not load session history. The session is open, but earlier turns may be missing until storage recovers.",
historyErr,
)
return LoadSessionResult{
Modes: a.modeState(sess),
}, nil
@@ -260,14 +253,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
if stopErr != nil {
return "", RPCError(codeInternalError, stopErr.Error())
}
if err := a.persistTurn(sess, userText, result.FinalAnswer); err != nil {
a.warnPersistence(
note,
"save session history",
"Could not save session history. This turn is available in memory, but future resume may miss it until storage recovers.",
err,
)
}
a.persistTurn(sess, userText, result.FinalAnswer)
return reason, nil
}
@@ -400,34 +386,29 @@ func (a *Agent) modeState(s *acpSession) *SessionModeState {
// ---- persistence + continuity ----
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) {
if a.deps.Store != nil {
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "user", "content": user},
})
if assistant != "" {
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "assistant", "content": assistant},
})
}
}
sess.appendHistory(turnRecord{user: user, assistant: assistant})
}
func (a *Agent) loadHistory(sessionID string) []turnRecord {
if a.deps.Store == nil {
return nil
}
events := []sessions.AppendEventInput{
{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "user", "content": user},
},
}
if assistant != "" {
events = append(events, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "assistant", "content": assistant},
})
}
_, err := a.deps.Store.AppendEvents(sess.id, events)
return err
}
func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
if a.deps.Store == nil {
return nil, nil
}
events, err := a.deps.Store.ReadEvents(sessionID)
if err != nil {
return nil, err
return nil
}
var records []turnRecord
var pendingUser string
@@ -463,21 +444,7 @@ func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
if havePending {
records = append(records, turnRecord{user: pendingUser})
}
return records, nil
}
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error) {
if err == nil {
return
}
sessionID := ""
if note != nil {
sessionID = note.sessionID
}
log.Printf("zero acp: failed to %s for session %s: %v", action, sessionID, err)
if note != nil {
note.text("\n\n[zero warning] " + message + "\n")
}
return records
}
// buildPrompt prepends prior conversation as context, since agent.Run drives a
+1 -72
View File
@@ -5,8 +5,6 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -244,77 +242,8 @@ func TestACPRejectsInvalidCwd(t *testing.T) {
}
}
func TestACPPromptWarnsWhenTurnPersistenceFails(t *testing.T) {
deps := testDeps(t)
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
metadataPath := filepath.Join(deps.Store.RootDir, newRes.SessionID, sessions.MetadataFile)
if err := os.Remove(metadataPath); err != nil {
t.Fatalf("remove metadata: %v", err)
}
var promptRes PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: newRes.SessionID,
Prompt: []ContentBlock{TextBlock("hi")},
}, &promptRes); err != nil {
t.Fatalf("session/prompt: %v", err)
}
if promptRes.StopReason != StopEndTurn {
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
}
got := drainTextUntil(t, h.updates, func(text string) bool {
return strings.Contains(text, "Hello from ZERO") &&
strings.Contains(text, "Could not save session history")
})
if !strings.Contains(got, "Could not save session history") {
t.Fatalf("streamed text = %q, want persistence warning", got)
}
}
func TestACPLoadWarnsWhenHistoryReadFails(t *testing.T) {
deps := testDeps(t)
cwd := t.TempDir()
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: cwd})
if err != nil {
t.Fatalf("create session: %v", err)
}
eventsPath := filepath.Join(deps.Store.RootDir, meta.SessionID, sessions.EventsFile)
if err := os.WriteFile(eventsPath, []byte("{bad json}\n"), 0o600); err != nil {
t.Fatalf("write corrupt events: %v", err)
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load: %v", err)
}
got := drainTextUntil(t, h.updates, func(text string) bool {
return strings.Contains(text, "Could not load session history")
})
if !strings.Contains(got, "Could not load session history") {
t.Fatalf("streamed text = %q, want load warning", got)
}
}
// drainText collects streamed chunks for a short window and concatenates them.
func drainText(t *testing.T, ch <-chan string) string {
t.Helper()
return drainTextUntil(t, ch, func(text string) bool {
return strings.Contains(text, "Hello from ZERO")
})
}
func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) string {
t.Helper()
var b strings.Builder
deadline := time.After(2 * time.Second)
@@ -322,7 +251,7 @@ func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) stri
select {
case s := <-ch:
b.WriteString(s)
if done(b.String()) {
if strings.Contains(b.String(), "Hello from ZERO") {
return b.String()
}
case <-deadline:
+2 -4
View File
@@ -721,8 +721,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Content: nudge,
})
}
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
result.FinalAnswer = answer
result.FinishReason = finishReason
result.Messages = copyMessages(finalMessages)
@@ -742,7 +741,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
return result, nil
}
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, options Options) (string, []zeroruntime.Message, string) {
finalMessages := copyMessages(messages)
finalMessages = append(finalMessages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
@@ -753,7 +752,6 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
// transient hiccup doesn't drop the final summary (AUDIT-L1).
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
Messages: copyMessages(finalMessages),
Tools: toolDefs,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}, reconnectNoticeFor(options))
+3 -3
View File
@@ -2768,11 +2768,11 @@ func TestRunRequestsFinalAnswerAfterMaxTurns(t *testing.T) {
t.Fatalf("expected final answer from finalization turn, got %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected finalization request after max turns, got %d requests", len(provider.requests))
t.Fatalf("expected final no-tools request after max turns, got %d requests", len(provider.requests))
}
finalRequest := provider.requests[1]
if toolDefinitionByName(finalRequest.Tools, "read_file") == nil {
t.Fatalf("finalization request must keep prior tools exposed for strict provider replay, got %#v", finalRequest.Tools)
if len(finalRequest.Tools) != 0 {
t.Fatalf("finalization request must not advertise tools, got %#v", finalRequest.Tools)
}
lastMessage := finalRequest.Messages[len(finalRequest.Messages)-1]
if lastMessage.Role != zeroruntime.MessageRoleUser || !strings.Contains(lastMessage.Content, "tool-turn limit") {
+3 -20
View File
@@ -729,19 +729,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
Scope: scope,
})
lastKnownMCPConfig := mcpConfig
sttServerManager := newDictationServerManager(resolved.STT)
// Keep STT downloads in the SAME config tree the rest of the TUI uses. Deriving
// from userConfigPath (rather than config.UserConfigDir()) matters when the config
// root is overridden — e.g. in tests or a custom ZERO config dir — so the two
// don't diverge. userConfigPath points at .../zero/config.json, so its dir is the
// zero config dir.
sttDownloadRoot := ""
if userConfigPath != "" {
sttDownloadRoot = filepath.Join(filepath.Dir(userConfigPath), "stt")
}
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
Version: version,
Theme: theme,
SavedTheme: resolved.Preferences.Theme,
UserConfigPath: userConfigPath,
@@ -802,15 +791,9 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
merged, _ := plugins.MergedSkillsLoaded(deps.skillsDir(), pluginActivation.skillRoots)
return merged
}),
PermissionMode: permissionMode,
Notify: resolved.Notify,
KeyBindings: resolved.KeyBindings,
STT: resolved.STT,
BuildDictationTranscriber: newDictationTranscriberFactory(resolved, userConfigPath, sttServerManager),
ShutdownDictationServer: sttServerManager.Shutdown,
STTDownloadRoot: sttDownloadRoot,
STTKeyStatus: newSTTKeyStatus(resolved, userConfigPath),
SaveSTTKey: newSaveSTTKey(userConfigPath),
PermissionMode: permissionMode,
Notify: resolved.Notify,
KeyBindings: resolved.KeyBindings,
Setup: tui.SetupOptions{
Visible: setupVisible,
Required: needsSetup,
+2 -45
View File
@@ -11,46 +11,10 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/provideroauth"
"github.com/Gitlawb/zero/internal/redaction"
)
// ensureLoginProviderProfile makes a freshly stored OAuth login visible as a
// provider: without a profile in config.json the login shows up nowhere — not in
// `zero providers list`, not in the TUI picker — and `zero providers use <name>`
// fails "not found", so the user's working active provider looks broken while
// the new login looks lost. Returns user-facing guidance lines ("" when the
// login has no catalog entry to scaffold from). Best-effort by design: the
// token is already stored, so a profile-write failure degrades to a warning
// line instead of failing a login that succeeded.
func ensureLoginProviderProfile(deps appDeps, provider string) string {
if _, ok := providercatalog.Get(provider); !ok {
// Custom OAuth server without a catalog entry — no endpoint/model defaults
// to scaffold a profile from; the user wires their own profile.
return ""
}
configPath, err := deps.userConfigPath()
if err != nil {
return "warning: login saved, but no provider profile was written: " + err.Error()
}
ensured, err := config.EnsureCatalogProvider(configPath, provider)
if err != nil {
return "warning: login saved, but no provider profile was written: " + err.Error()
}
active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name))
switch {
case ensured.Created && active:
return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name)
case ensured.Created:
return fmt.Sprintf("Added provider %q to your config; the active provider is still %q.\nSwitch with: zero providers use %s", ensured.Name, ensured.Active, ensured.Name)
case active:
return fmt.Sprintf("Provider %q is configured and active.", ensured.Name)
default:
return fmt.Sprintf("Provider %q is already configured.\nSwitch with: zero providers use %s", ensured.Name, ensured.Name)
}
}
// runAuth dispatches `zero auth <command>` for provider OAuth login. It is
// additive and independent of `zero mcp oauth` (MCP server auth), which is
// unchanged.
@@ -174,10 +138,8 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
if _, err := fmt.Fprint(stdout, statuses); err != nil {
return exitCrash
}
if line := ensureLoginProviderProfile(deps, "chatgpt"); line != "" {
if _, err := fmt.Fprintf(stdout, "\n%s\n", line); err != nil {
return exitCrash
}
if _, err := fmt.Fprint(stdout, "\nUse it with zero, e.g.:\n zero --provider chatgpt --model gpt-5.5\n"); err != nil {
return exitCrash
}
return exitSuccess
}
@@ -369,11 +331,6 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if _, err := fmt.Fprintf(stdout, "Logged in to %s.\n%s\n", parsed.positional[0], oauth.FormatStatuses([]oauth.Status{status})); err != nil {
return exitCrash
}
if line := ensureLoginProviderProfile(deps, provider); line != "" {
if _, err := fmt.Fprintln(stdout, line); err != nil {
return exitCrash
}
}
return exitSuccess
}
+1 -85
View File
@@ -2,24 +2,18 @@ package cli
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
// withAuthStore points the provider OAuth store at a temp file for the test,
// pinning the file backend so an inherited ZERO_OAUTH_STORAGE=keyring can't
// ignore the temp path and hit the OS keychain.
// withAuthStore points the provider OAuth store at a temp file for the test.
func withAuthStore(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
t.Setenv("ZERO_OAUTH_STORAGE", "file")
return path
}
@@ -203,81 +197,3 @@ func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
}
}
func TestEnsureLoginProviderProfileAddsProviderWithoutStealingActive(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{"activeProvider":"opengateway","providers":[{"name":"opengateway","provider_kind":"openai-compatible","baseURL":"https://gateway.example.com/v1","apiKeyStored":true,"model":"some-model"}]}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
line := ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, `Added provider "chatgpt"`) {
t.Fatalf("expected added-provider guidance, got %q", line)
}
if !strings.Contains(line, "zero providers use chatgpt") {
t.Fatalf("expected switch hint, got %q", line)
}
cfg := readCLIConfigFixture(t, configPath)
if cfg.ActiveProvider != "opengateway" {
t.Fatalf("active provider changed to %q", cfg.ActiveProvider)
}
names := make([]string, 0, len(cfg.Providers))
for _, provider := range cfg.Providers {
names = append(names, provider.Name)
}
if len(cfg.Providers) != 2 || cfg.Providers[1].CatalogID != "chatgpt" {
t.Fatalf("expected chatgpt profile appended, got %v", names)
}
// A second login must be a no-op with switch guidance, not a duplicate.
line = ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, "already configured") {
t.Fatalf("expected already-configured guidance, got %q", line)
}
cfg = readCLIConfigFixture(t, configPath)
if len(cfg.Providers) != 2 {
t.Fatalf("repeat login duplicated the profile: %d providers", len(cfg.Providers))
}
}
func TestEnsureLoginProviderProfileActivatesOnFreshConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
line := ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, "set it active") {
t.Fatalf("fresh config should adopt the login as active, got %q", line)
}
cfg := readCLIConfigFixture(t, configPath)
if cfg.ActiveProvider != "chatgpt" {
t.Fatalf("active provider = %q, want chatgpt", cfg.ActiveProvider)
}
}
func TestEnsureLoginProviderProfileSkipsNonCatalogProviders(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
if line := ensureLoginProviderProfile(deps, "my-custom-oauth-server"); line != "" {
t.Fatalf("custom OAuth server must not scaffold a profile, got %q", line)
}
if _, err := os.Stat(configPath); err == nil {
t.Fatalf("config must not be created for a non-catalog login")
}
}
func readCLIConfigFixture(t *testing.T, path string) config.FileConfig {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg config.FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
return cfg
}
+2 -33
View File
@@ -75,12 +75,6 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if command == "use" {
return runProvidersUse(args, stdout, stderr, deps)
}
if command == "remove" || command == "rm" {
return runProvidersRemove(args, stdout, stderr, deps)
}
if command == "rename" {
return runProvidersRename(args, stdout, stderr, deps)
}
if command == "setup" {
return runProvidersSetup(args, stdout, stderr, deps)
}
@@ -252,20 +246,6 @@ func parseCommandCenterArgs(args []string, allowModelFilters bool, allowProvider
func summarizeConfig(resolved config.ResolvedConfig) configSummary {
summary := zerocommands.ConfigSnapshotFromResolved(resolved)
// Mark keyless profiles that authenticate via a stored OAuth login (e.g.
// ChatGPT) so the list shows "oauth login" instead of "api key: not set" —
// the same candidate matching the runtime resolver uses.
logins := oauthLoggedInProviders()
if len(logins) > 0 {
for index := range summary.Providers {
for _, profile := range resolved.Providers {
if profile.Name == summary.Providers[index].Name {
summary.Providers[index].OAuthLogin = providerHasOAuthLogin(profile, logins)
break
}
}
}
}
sort.SliceStable(summary.Providers, func(i int, j int) bool {
if summary.Providers[i].Active != summary.Providers[j].Active {
return summary.Providers[i].Active
@@ -343,7 +323,7 @@ func formatProviderSummaries(command string, providers []providerSummary) string
"model: "+displayCLIValue(provider.Model, "none"),
"api model: "+displayCLIValue(provider.APIModel, "unknown"),
"base url: "+displayCLIValue(provider.BaseURL, "default"),
"api key: "+providerCredentialState(provider),
"api key: "+apiKeyState(provider.APIKeySet),
)
if provider.Message != "" {
lines = append(lines, "status: "+provider.Status+" - "+provider.Message)
@@ -360,7 +340,7 @@ func formatProviderLine(provider providerSummary) string {
if provider.Active {
marker = "*"
}
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), providerCredentialState(provider))
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), apiKeyState(provider.APIKeySet))
if provider.Message != "" {
line += " (" + provider.Status + ": " + provider.Message + ")"
}
@@ -433,15 +413,6 @@ func apiKeyState(set bool) string {
return "not set"
}
// providerCredentialState renders the credential column: a configured key wins,
// then a stored OAuth login, then "not set".
func providerCredentialState(provider providerSummary) string {
if !provider.APIKeySet && provider.OAuthLogin {
return "oauth login"
}
return apiKeyState(provider.APIKeySet)
}
func displayCLIValue(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
@@ -495,8 +466,6 @@ func writeProvidersHelp(w io.Writer) error {
zero providers add <catalog-id> [flags]
zero providers check [name] [flags]
zero providers use <name> [flags]
zero providers remove <name> [flags]
zero providers rename <old> <new> [flags]
zero providers setup <catalog-id> [flags]
zero providers detect [flags]
zero providers models [name] [flags]
-55
View File
@@ -11,7 +11,6 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/zerocommands"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -836,57 +835,3 @@ func (commandCenterProvider) StreamCompletion(context.Context, zeroruntime.Compl
close(ch)
return ch, nil
}
func TestProviderCredentialStateShowsOAuthLogin(t *testing.T) {
if got := providerCredentialState(providerSummary{APIKeySet: true}); got != "set" {
t.Fatalf("keyed provider = %q, want set", got)
}
if got := providerCredentialState(providerSummary{OAuthLogin: true}); got != "oauth login" {
t.Fatalf("token-login provider = %q, want oauth login", got)
}
if got := providerCredentialState(providerSummary{}); got != "not set" {
t.Fatalf("credential-less provider = %q, want not set", got)
}
}
// TestProvidersListMarksOAuthLoginProviders: a keyless profile whose credential
// is a stored OAuth login (the shape `zero auth chatgpt` now writes) must render
// as "oauth login", not as a broken "api key: not set" entry.
func TestProvidersListMarksOAuthLoginProviders(t *testing.T) {
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
t.Fatalf("save token: %v", err)
}
resolved := config.ResolvedConfig{
ActiveProvider: "opengateway",
Providers: []config.ProviderProfile{
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", APIKeyStored: true, Model: "some-model"},
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
},
}
summary := summarizeConfig(resolved)
byName := map[string]providerSummary{}
for _, provider := range summary.Providers {
byName[provider.Name] = provider
}
if !byName["opengateway"].APIKeySet {
t.Fatalf("stored-key provider must report a credential: %+v", byName["opengateway"])
}
if byName["opengateway"].OAuthLogin {
t.Fatalf("key-authed provider must not claim the OAuth login: %+v", byName["opengateway"])
}
if !byName["chatgpt"].OAuthLogin || byName["chatgpt"].APIKeySet {
t.Fatalf("chatgpt must be marked oauth-login and keyless: %+v", byName["chatgpt"])
}
rendered := formatProviderSummaries("list", summary.Providers)
if !strings.Contains(rendered, "oauth login") {
t.Fatalf("list should render the oauth login state, got:\n%s", rendered)
}
}
-248
View File
@@ -1,248 +0,0 @@
package cli
import (
"fmt"
neturl "net/url"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/dictation"
"github.com/Gitlawb/zero/internal/tui"
)
// Default batch cloud models (§8). Overridable via stt.model.
const (
defaultGroqSTTModel = "whisper-large-v3-turbo"
defaultOpenAISTTModel = "whisper-1"
groqSTTBaseURL = "https://api.groq.com/openai/v1"
)
// newDictationTranscriberFactory returns the closure the TUI calls to build a
// transcriber when a recording starts. It lives here, not in the TUI, because it
// resolves provider API keys (credstore + env + configured providers) — logic
// that belongs with the rest of the CLI's credential handling.
//
// serverMgr is the shared, session-long sherpa-onnx streaming server; it is
// spawned lazily on the first local-streaming recording and torn down on exit.
//
// preferStreaming asks for the streaming backend; the returned bool reports
// whether streaming was actually built (false → the caller uses the batch
// pipeline). A misconfigured streaming backend surfaces its own SetupError.
func newDictationTranscriberFactory(resolved config.ResolvedConfig, userConfigPath string, serverMgr *dictation.ServerManager) func(config.STTConfig, bool) (tui.Transcriber, bool, error) {
return func(stt config.STTConfig, preferStreaming bool) (tui.Transcriber, bool, error) {
// stt is passed in (not captured) so a mid-session change — e.g. the F9
// auto-download writing localModelPath — takes effect on the next call.
if serverMgr != nil {
serverMgr.SetModelPath(stt.LocalModelPath)
}
if preferStreaming {
t, err := buildStreamingTranscriber(stt, resolved, userConfigPath, serverMgr)
switch {
case err == nil && t != nil:
return t, true, nil
case err != nil && stt.STTStreamProvider() != config.STTProviderLocal:
// An explicitly-chosen cloud streaming backend that failed to build
// (e.g. a missing key) should surface its own error, not silently
// fall back to batch — the user asked for that backend specifically.
return nil, true, err
}
// The default local streaming backend isn't set up: fall through to the
// batch pipeline so a cloud-batch provider (or a batch-only setup) still
// works instead of erroring on a streaming server the user never asked for.
}
t, err := buildBatchTranscriber(stt, resolved, userConfigPath)
return t, false, err
}
}
// buildStreamingTranscriber constructs the configured streaming backend, or
// returns (nil, nil) when streaming is not applicable so the caller falls back
// to batch.
func buildStreamingTranscriber(stt config.STTConfig, resolved config.ResolvedConfig, userConfigPath string, serverMgr *dictation.ServerManager) (dictation.Transcriber, error) {
switch stt.STTStreamProvider() {
case config.STTProviderDeepgram:
key := resolveSTTKey(resolved, userConfigPath, dictation.StreamProviderDeepgram, "DEEPGRAM_API_KEY")
return dictation.NewDeepgramTranscriber(dictation.DeepgramConfig{
APIKey: key,
Model: stt.StreamModel,
Language: stt.Language,
})
case config.STTProviderOpenAI:
key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderOpenAI, "OPENAI_API_KEY")
return dictation.NewOpenAIRealtimeTranscriber(dictation.OpenAIRealtimeConfig{
APIKey: key,
Model: stt.StreamModel,
})
default: // local streaming via the warm sherpa-onnx server
// STTStreamProvider() defaults to "local" whenever streamProvider is unset —
// including when the user picked a CLOUD batch provider (Groq/OpenAI) and
// never touched streaming. Don't silently stream through the local sherpa
// server in that case: local streaming is only appropriate when it was
// explicitly requested (streamProvider: "local") OR the batch provider is
// itself local. Otherwise report "not applicable" so the factory falls back
// to the cloud batch pipeline the user actually chose.
localWanted := stt.StreamProvider == config.STTProviderLocal || stt.STTProvider() == config.STTProviderLocal
if !localWanted {
return nil, nil
}
// The local streaming transcriber validates the model only lazily (at
// connect time), so gate on the model path here — with none set, report
// "not applicable" (nil, nil) so the factory falls back to the batch
// pipeline instead of committing to a server that can't start.
if serverMgr == nil || strings.TrimSpace(stt.LocalModelPath) == "" {
return nil, nil
}
return dictation.NewLocalStreamingTranscriber(serverMgr), nil
}
}
// newDictationServerManager builds the warm sherpa-onnx streaming server manager
// from config. Construction is side-effect free — the server spawns lazily on
// the first local-streaming recording — so it is always safe to create.
func newDictationServerManager(stt config.STTConfig) *dictation.ServerManager {
return dictation.NewServerManager(dictation.ServerConfig{
Binary: stt.LocalServerBinary,
ModelPath: stt.LocalModelPath,
Port: stt.LocalServerPort,
NumThreads: stt.NumThreads,
})
}
// buildBatchTranscriber constructs the configured batch transcriber, resolving
// credentials for the cloud providers.
func buildBatchTranscriber(stt config.STTConfig, resolved config.ResolvedConfig, userConfigPath string) (dictation.Transcriber, error) {
switch stt.STTProvider() {
case config.STTProviderGroq:
key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderGroq, "GROQ_API_KEY")
model := stt.Model
if model == "" {
model = defaultGroqSTTModel
}
return dictation.NewCloudTranscriber(dictation.CloudConfig{
Provider: dictation.ProviderGroq,
BaseURL: groqSTTBaseURL,
APIKey: key,
Model: model,
Language: stt.Language,
})
case config.STTProviderOpenAI:
key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderOpenAI, "OPENAI_API_KEY")
model := stt.Model
if model == "" {
model = defaultOpenAISTTModel
}
return dictation.NewCloudTranscriber(dictation.CloudConfig{
Provider: dictation.ProviderOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: key,
Model: model,
Language: stt.Language,
})
default: // local
return dictation.NewLocalTranscriber(dictation.LocalConfig{
Binary: stt.LocalBinary,
ModelPath: stt.LocalModelPath,
NumThreads: stt.NumThreads,
})
}
}
// sttEnvVar returns the conventional environment variable a cloud STT provider's
// key is read from.
func sttEnvVar(provider string) string {
switch provider {
case dictation.ProviderGroq:
return "GROQ_API_KEY"
case dictation.ProviderOpenAI: // == StreamProviderOpenAI ("openai")
return "OPENAI_API_KEY"
case dictation.StreamProviderDeepgram:
return "DEEPGRAM_API_KEY"
}
return ""
}
// newSTTKeyStatus returns a predicate reporting whether a cloud STT provider
// already has a resolvable API key (configured provider, credstore, or env).
func newSTTKeyStatus(resolved config.ResolvedConfig, userConfigPath string) func(string) bool {
return func(provider string) bool {
return resolveSTTKey(resolved, userConfigPath, provider, sttEnvVar(provider)) != ""
}
}
// newSaveSTTKey returns a function that stores a cloud STT provider's API key in
// the encrypted credential store, so the inline prompt can persist it.
func newSaveSTTKey(userConfigPath string) func(string, string) error {
return func(provider, key string) error {
if strings.TrimSpace(userConfigPath) == "" {
return fmt.Errorf("no config path to store the key")
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath))
if err != nil {
return err
}
return store.Set(provider, strings.TrimSpace(key))
}
}
// resolveSTTKey finds an API key for an STT cloud provider, reusing Zero's
// existing credentials (§2 — "zero new credential UI"). Precedence: a matching
// configured provider's resolved key, then the encrypted credential store, then
// the provider's conventional environment variable.
func resolveSTTKey(resolved config.ResolvedConfig, userConfigPath, provider, envVar string) string {
if key := keyFromConfiguredProviders(resolved, provider); key != "" {
return key
}
if userConfigPath != "" {
if store, err := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath)); err == nil {
if key, ok, _ := store.Get(provider); ok && strings.TrimSpace(key) != "" {
return strings.TrimSpace(key)
}
}
}
return strings.TrimSpace(os.Getenv(envVar))
}
// keyFromConfiguredProviders returns the resolved API key of a configured
// provider that belongs to the given cloud provider (matched by catalog id,
// provider name/kind, or base-URL host), or "" when none is configured.
func keyFromConfiguredProviders(resolved config.ResolvedConfig, provider string) string {
provider = strings.ToLower(provider)
profiles := append([]config.ProviderProfile{resolved.Provider}, resolved.Providers...)
for _, p := range profiles {
if strings.TrimSpace(p.APIKey) == "" {
continue
}
if providerMatches(p, provider) {
return strings.TrimSpace(p.APIKey)
}
}
return ""
}
func providerMatches(p config.ProviderProfile, provider string) bool {
provider = strings.ToLower(provider)
// Match on stable identifiers (catalog id, provider name/kind, profile
// name) rather than raw BaseURL substring matching: a Groq profile's
// BaseURL (api.groq.com/openai/v1) contains "openai", so a substring
// check can hand the wrong key to an OpenAI STT lookup and fail auth.
for _, f := range []string{
strings.ToLower(p.CatalogID),
strings.ToLower(p.Provider),
strings.ToLower(string(p.ProviderKind)),
strings.ToLower(p.Name),
} {
if f != "" && strings.Contains(f, provider) {
return true
}
}
// BaseURL: compare the parsed host only, not a raw substring on the
// full URL (which lets "openai" match api.groq.com/openai/v1).
if u, err := neturl.Parse(p.BaseURL); err == nil && u.Host != "" {
if host := strings.ToLower(u.Host); strings.Contains(host, provider) {
return true
}
}
return false
}
-96
View File
@@ -1,96 +0,0 @@
package cli
import (
"errors"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/dictation"
)
func TestFactoryFallsBackToBatchWhenLocalStreamingNotConfigured(t *testing.T) {
t.Setenv("GROQ_API_KEY", "gsk-test")
resolved := config.ResolvedConfig{STT: config.STTConfig{
Provider: config.STTProviderGroq, // cloud batch
// StreamProvider unset → defaults to local, which has no model configured.
}}
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(resolved.STT))
tr, streaming, err := factory(resolved.STT, true) // prefer streaming
if err != nil {
t.Fatalf("expected batch fallback, got error: %v", err)
}
if streaming {
t.Error("should have fallen back to batch (streaming=false)")
}
if tr == nil {
t.Error("expected a Groq batch transcriber")
}
}
func TestFactoryCloudProviderIgnoresLeftoverLocalModel(t *testing.T) {
// Regression: pick local once (LocalModelPath gets set), then switch to a
// cloud batch provider. streamProvider stays unset (defaults to "local"), so
// the streaming factory must NOT fire up the sherpa server on the leftover
// path — it should fall back to the cloud batch pipeline the user chose.
t.Setenv("GROQ_API_KEY", "gsk-test")
stt := config.STTConfig{
Provider: config.STTProviderGroq, // cloud batch, chosen now
LocalModelPath: "/leftover/model/dir", // set by an earlier local selection
}
resolved := config.ResolvedConfig{STT: stt}
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(stt))
tr, streaming, err := factory(stt, true) // prefer streaming
if err != nil {
t.Fatalf("expected cloud batch fallback, got error: %v", err)
}
if streaming {
t.Error("a cloud provider must not stream through the local sherpa server")
}
if tr == nil {
t.Error("expected a Groq batch transcriber")
}
}
func TestFactorySurfacesExplicitCloudStreamingError(t *testing.T) {
// Deepgram chosen explicitly but no key resolvable → surface the setup error.
resolved := config.ResolvedConfig{STT: config.STTConfig{
StreamProvider: config.STTProviderDeepgram,
}}
factory := newDictationTranscriberFactory(resolved, "", nil)
_, streaming, err := factory(resolved.STT, true)
if err == nil {
t.Fatal("expected a setup error for missing Deepgram key")
}
if !streaming {
t.Error("an explicit cloud streaming choice should report streaming=true even on error")
}
var setupErr *dictation.SetupError
if !errors.As(err, &setupErr) {
t.Errorf("want *SetupError, got %v", err)
}
}
func TestFactoryLocalBatchSetupErrorWhenNoModel(t *testing.T) {
resolved := config.ResolvedConfig{STT: config.STTConfig{Provider: config.STTProviderLocal}}
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(resolved.STT))
_, _, err := factory(resolved.STT, true)
var setupErr *dictation.SetupError
if !errors.As(err, &setupErr) {
t.Fatalf("want *SetupError guiding local model setup, got %v", err)
}
}
func TestResolveSTTKeyPrefersConfiguredProvider(t *testing.T) {
resolved := config.ResolvedConfig{
Providers: []config.ProviderProfile{
{Name: "groq", CatalogID: "groq", BaseURL: "https://api.groq.com/openai/v1", APIKey: "gsk-configured"},
},
}
if got := resolveSTTKey(resolved, "", dictation.ProviderGroq, "GROQ_API_KEY"); got != "gsk-configured" {
t.Errorf("key = %q, want the configured provider's key", got)
}
}
-141
View File
@@ -3,7 +3,6 @@ package cli
import (
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"unicode"
@@ -307,143 +306,3 @@ func providerCommandArg(value string) string {
}
return value
}
type providerNamesOptions struct {
names []string
json bool
}
func parseProviderNamesArgs(args []string, want int, usage string) (providerNamesOptions, bool, error) {
options := providerNamesOptions{}
for _, arg := range args {
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
default:
options.names = append(options.names, arg)
}
}
if len(options.names) != want {
return options, false, execUsageError{usage}
}
return options, false, nil
}
// runProvidersRemove deletes a saved provider profile and its stored API key.
// The OAuth token (if any) is kept — logins outlive profiles so re-adding the
// provider needs no new browser round-trip; `zero auth logout <name>` removes it.
func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderNamesArgs(args, 1, "usage: zero providers remove <name>")
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
configPath, err := deps.userConfigPath()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
name := options.names[0]
cfg, err := config.RemoveProvider(configPath, name)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
// Delete the key from the store BESIDE the config being edited — the same
// store setup/rename write to — not the default-path store, so a
// non-default config path cannot leave the encrypted key behind.
keyRemoved, keyErr := removeStoredProviderKeyAt(configPath, name)
if options.json {
payload := map[string]any{
"removed": name,
"keyRemoved": keyRemoved,
"activeProvider": cfg.ActiveProvider,
"configPath": configPath,
}
if keyErr != nil {
// A lingering secret must not read as a clean removal.
payload["keyError"] = keyErr.Error()
}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "Removed provider %s\n", name); err != nil {
return exitCrash
}
if keyErr != nil {
if _, err := fmt.Fprintf(stderr, "warning: its stored API key could not be deleted and remains in the credential store: %v\n", keyErr); err != nil {
return exitCrash
}
} else if keyRemoved {
if _, err := fmt.Fprintln(stdout, "Deleted its stored API key."); err != nil {
return exitCrash
}
}
if active := strings.TrimSpace(cfg.ActiveProvider); active != "" {
if _, err := fmt.Fprintf(stdout, "Active provider: %s\n", active); err != nil {
return exitCrash
}
} else {
if _, err := fmt.Fprintln(stdout, "No providers remain — run zero setup to add one."); err != nil {
return exitCrash
}
}
return exitSuccess
}
// removeStoredProviderKeyAt deletes a provider's API key from the credential
// store co-located with configPath (the store SecureProviderProfile captured
// it into and RenameProvider migrates within).
func removeStoredProviderKeyAt(configPath string, provider string) (bool, error) {
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
return false, err
}
return store.Delete(provider)
}
// runProvidersRename renames a saved provider profile, migrating its stored
// API key and the activeProvider pointer along with it (config.RenameProvider).
func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderNamesArgs(args, 2, "usage: zero providers rename <old> <new>")
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
configPath, err := deps.userConfigPath()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
cfg, err := config.RenameProvider(configPath, options.names[0], options.names[1])
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, map[string]any{
"renamed": map[string]string{"from": options.names[0], "to": options.names[1]},
"activeProvider": cfg.ActiveProvider,
"configPath": configPath,
}); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "Renamed provider %s to %s\n", options.names[0], options.names[1]); err != nil {
return exitCrash
}
return exitSuccess
}
-45
View File
@@ -234,48 +234,3 @@ func writeProviderOnboardingConfig(t *testing.T, path string, cfg config.FileCon
t.Fatalf("write config: %v", err)
}
}
// TestRunProvidersRemoveDeletesKeyBesideConfig: the stored key must be deleted
// from the credential store CO-LOCATED with the config being edited (where
// SecureProviderProfile captured it), not the default-path store.
func TestRunProvidersRemoveDeletesKeyBesideConfig(t *testing.T) {
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
seed := `{"activeProvider":"gw","providers":[{"name":"gw","provider_kind":"openai-compatible","baseURL":"https://gw.example.com/v1","apiKeyStored":true,"model":"m1"},{"name":"other","provider_kind":"openai-compatible","baseURL":"https://o.example.com/v1","model":"m2"}]}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
store, err := config.ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("gw", "sk-secret"); err != nil {
t.Fatalf("seed key: %v", err)
}
var stdout, stderr bytes.Buffer
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
if code := runWithDeps([]string{"providers", "remove", "gw", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("remove failed: code=%d stderr=%s", code, stderr.String())
}
var payload struct {
Removed string `json:"removed"`
KeyRemoved bool `json:"keyRemoved"`
KeyError string `json:"keyError"`
ActiveProvider string `json:"activeProvider"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if payload.Removed != "gw" || !payload.KeyRemoved || payload.KeyError != "" {
t.Fatalf("unexpected payload: %+v", payload)
}
if payload.ActiveProvider != "other" {
t.Fatalf("active must hand off, got %q", payload.ActiveProvider)
}
if _, ok, _ := store.Get("gw"); ok {
t.Fatalf("stored key must be deleted from the store beside the config")
}
}
+1 -5
View File
@@ -457,11 +457,7 @@ func selectProviderForCheck(resolved config.ResolvedConfig, name string) (config
}
func validateProviderRuntimeReady(profile config.ProviderProfile) error {
// A stored OAuth login (e.g. `zero auth chatgpt`) is a credential too: a
// keyless token-login profile must pass the readiness check instead of being
// told to set an API key it will never have.
hasCredential := providerProfileHasCredential(profile) ||
providerHasOAuthLogin(profile, oauthLoggedInProviders())
hasCredential := providerProfileHasCredential(profile)
if profile.CatalogID != "" {
descriptor, err := providercatalog.Require(profile.CatalogID)
if err != nil {
+7 -14
View File
@@ -322,9 +322,7 @@ func setupRequired(resolved config.ResolvedConfig) bool {
// the candidate set is permissive (profile name + catalog ID).
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
for _, name := range profile.OAuthLoginCandidates() {
// Store keys are normalized to lower case (oauth.ProviderKey), so the
// presence check must compare the same way.
if oauthLogins[strings.ToLower(strings.TrimSpace(name))] {
if oauthLogins[name] {
return true
}
}
@@ -346,19 +344,18 @@ func oauthLoggedInProviders() map[string]bool {
}
for _, status := range statuses {
if status.HasToken {
out[strings.ToLower(strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider))] = true
out[strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider)] = true
}
}
return out
}
// firstUsableProvider returns the saved provider best suited to run without
// onboarding: the first usable (inline credential, stored OAuth login, or
// no-auth/local) non-local provider, else the first usable local one. It lets
// the CLI fall back to an already-configured login when the active provider
// happens to lack a credential, instead of re-running onboarding every launch.
// onboarding: the first usable (inline credential present, or no-auth/local)
// non-local provider, else the first usable local one. It lets the CLI fall back
// to an already-configured login when the active provider happens to lack a
// credential, instead of re-running onboarding every launch.
func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderProfile, bool) {
logins := oauthLoggedInProviders()
var localFallback config.ProviderProfile
haveLocal := false
for _, profile := range providers {
@@ -374,11 +371,7 @@ func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderPro
continue
}
}
// A stored OAuth login (e.g. `zero auth login xai`) is a credential too, even
// when the profile has no inline key / env var — mirrors setupRequired and
// usableSavedProviders so this fallback doesn't force onboarding for a
// provider the user is already authenticated with.
if _, missing := setupMissingCredentialEnv(profile); missing && !providerHasOAuthLogin(profile, logins) {
if _, missing := setupMissingCredentialEnv(profile); missing {
continue
}
if providerProfileIsLocal(profile) {
-62
View File
@@ -2,14 +2,11 @@ package cli
import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // usable but local
{Name: "moonshot", CatalogID: "moonshot", BaseURL: "https://api.moonshot.ai/v1", APIKey: "k"}, // usable, remote
@@ -22,7 +19,6 @@ func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
}
func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // not usable
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // local, usable
@@ -34,7 +30,6 @@ func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
}
func TestFirstUsableProviderNoneUsable(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "openai", CatalogID: "openai", APIKeyEnv: "OPENAI_API_KEY"},
@@ -47,10 +42,6 @@ func TestFirstUsableProviderNoneUsable(t *testing.T) {
// A keyless local proxy (chatgpt-proxy, RequiresAuth=false) is usable without a
// credential, so it can serve as a fallback rather than forcing onboarding.
func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
// Isolate the OAuth token store: on a developer machine with a real xai
// login, the env-only xai profile would count as usable and win over the
// local proxy this test is about.
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "chatgpt", CatalogID: "chatgpt-proxy", BaseURL: "http://localhost:10531/v1"},
@@ -65,7 +56,6 @@ func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
// BaseURL has no endpoint, so it must be skipped rather than selected as a
// fallback that fails at first use. A stale CatalogID with a BaseURL still works.
func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "ghost", CatalogID: "no-such-catalog-entry", APIKey: "k"}, // unusable: no endpoint
{Name: "custom", CatalogID: "no-such-catalog-entry", BaseURL: "https://api.custom.test/v1", APIKey: "k"},
@@ -76,29 +66,6 @@ func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T)
}
}
// An OAuth-only provider (no inline key, no env var) must be selectable as a
// fallback, matching setupRequired/usableSavedProviders — otherwise a fully
// authenticated user gets forced back into onboarding when activeProvider
// goes stale.
func TestFirstUsableProviderRecognizesOAuthLogin(t *testing.T) {
withAuthStore(t)
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed token: %v", err)
}
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // no inline key/env, but logged in via OAuth
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "xai" {
t.Fatalf("want OAuth-logged-in provider (xai), got %q ok=%v", got.Name, ok)
}
}
func TestProviderProfileIsLocal(t *testing.T) {
cases := []struct {
name string
@@ -122,32 +89,3 @@ func TestProviderProfileIsLocal(t *testing.T) {
})
}
}
// TestFirstUsableProviderAcceptsOAuthLoginProfile: a keyless catalog profile
// whose credential is a stored OAuth login (the shape the login flows persist)
// must be usable during active-pointer recovery — the same rule setupRequired
// and usableSavedProviders apply — instead of falling through to onboarding.
func TestFirstUsableProviderAcceptsOAuthLoginProfile(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "chatgpt", CatalogID: "chatgpt", BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
}
// No login stored: still not usable.
if got, ok := firstUsableProvider(providers); ok {
t.Fatalf("keyless profile without a login must not be usable, got %q", got.Name)
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
t.Fatalf("save token: %v", err)
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "chatgpt" {
t.Fatalf("want OAuth-login profile to be usable, got %q ok=%v", got.Name, ok)
}
}
-91
View File
@@ -127,9 +127,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
return ResolvedConfig{}, fmt.Errorf("invalid notify.focusMode %q: expected unfocused, always, or focused", focusMode)
}
}
if err := validateSTTConfig(cfg.STT); err != nil {
return ResolvedConfig{}, err
}
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
if err != nil {
@@ -153,7 +150,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
LocalControl: cfg.LocalControl,
STT: cfg.STT,
}, nil
}
@@ -235,7 +231,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
}
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
mergeSTTConfig(&dst.STT, src.STT)
}
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
@@ -686,7 +681,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
}
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
mergeKeyBindings(&cfg.KeyBindings, overrides.KeyBindings)
mergeSTTConfig(&cfg.STT, overrides.STT)
for _, provider := range overrides.Providers {
mergeProvider(cfg, provider)
}
@@ -740,91 +734,6 @@ func mergeKeyBindings(dst *KeyBindingsConfig, src KeyBindingsConfig) {
}
}
// mergeSTTConfig overlays any set field of src onto dst. Each field carries its
// own "unset" sentinel (empty string, 0, or nil *bool), matching how the other
// section mergers detect intent.
func mergeSTTConfig(dst *STTConfig, src STTConfig) {
if src.Provider != "" {
dst.Provider = src.Provider
}
if src.StreamProvider != "" {
dst.StreamProvider = src.StreamProvider
}
if src.Streaming != nil {
dst.Streaming = src.Streaming
}
if src.Model != "" {
dst.Model = src.Model
}
if src.StreamModel != "" {
dst.StreamModel = src.StreamModel
}
if src.LocalModelPath != "" {
dst.LocalModelPath = src.LocalModelPath
}
if src.LocalBinary != "" {
dst.LocalBinary = src.LocalBinary
}
if src.LocalServerBinary != "" {
dst.LocalServerBinary = src.LocalServerBinary
}
if src.LocalServerPort != 0 {
dst.LocalServerPort = src.LocalServerPort
}
if src.EngineVersion != "" {
dst.EngineVersion = src.EngineVersion
}
if src.NumThreads != 0 {
dst.NumThreads = src.NumThreads
}
if src.Language != "" {
dst.Language = src.Language
}
if src.MaxDurationSeconds != 0 {
dst.MaxDurationSeconds = src.MaxDurationSeconds
}
if src.SilenceAutoStop != nil {
dst.SilenceAutoStop = src.SilenceAutoStop
}
if src.AutoSubmit != nil {
dst.AutoSubmit = src.AutoSubmit
}
if src.WindowsAudioDevice != "" {
dst.WindowsAudioDevice = src.WindowsAudioDevice
}
}
// validateSTTConfig rejects unknown provider/streamProvider values and a
// negative maxDurationSeconds at load time — a clear startup error naming the
// bad value and the valid options, never a silent fallback the user did not ask
// for (§11a).
func validateSTTConfig(cfg STTConfig) error {
if cfg.Provider != "" {
switch cfg.Provider {
case STTProviderLocal, STTProviderGroq, STTProviderOpenAI:
default:
return fmt.Errorf("invalid stt.provider %q: expected local, groq, or openai", cfg.Provider)
}
}
if cfg.StreamProvider != "" {
switch cfg.StreamProvider {
case STTProviderLocal, STTProviderDeepgram, STTProviderOpenAI:
default:
return fmt.Errorf("invalid stt.streamProvider %q: expected local, deepgram, or openai", cfg.StreamProvider)
}
}
if cfg.MaxDurationSeconds < 0 {
return fmt.Errorf("invalid stt.maxDurationSeconds %d: must be >= 0 (0 uses the default)", cfg.MaxDurationSeconds)
}
if cfg.NumThreads < 0 {
return fmt.Errorf("invalid stt.numThreads %d: must be >= 0 (0 uses the engine default)", cfg.NumThreads)
}
if cfg.LocalServerPort < 0 || cfg.LocalServerPort > 65535 {
return fmt.Errorf("invalid stt.localServerPort %d: must be between 1 and 65535 (0 uses the default)", cfg.LocalServerPort)
}
return nil
}
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
if len(src.Servers) == 0 {
return
-139
View File
@@ -1,139 +0,0 @@
package config
import (
"os"
"strings"
"testing"
)
func TestSetSTTLocalEngineResetsStreamProvider(t *testing.T) {
dir := t.TempDir()
path := dir + "/config.json"
// Pre-seed a cloud streaming provider — the situation the fix guards against.
if err := os.WriteFile(path, []byte(`{"stt":{"streamProvider":"deepgram"}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := SetSTTLocalEngine(path, "sherpa-onnx-offline", "sherpa-onnx-online-websocket-server", "/models/kroko", true)
if err != nil {
t.Fatalf("SetSTTLocalEngine: %v", err)
}
// Switching to a local model must point BOTH pipelines at the local engine, or
// the leftover cloud streamProvider keeps the live transcript on the cloud.
if cfg.STT.Provider != STTProviderLocal || cfg.STT.StreamProvider != STTProviderLocal {
t.Errorf("both providers should be local, got provider=%q streamProvider=%q", cfg.STT.Provider, cfg.STT.StreamProvider)
}
}
func TestValidateSTTConfigRejectsUnknownProvider(t *testing.T) {
_, issues := ValidateBytes([]byte(`{"stt":{"provider":"assemblyai"}}`))
if len(issues) == 0 {
t.Fatal("expected an issue for unknown stt.provider")
}
if !strings.Contains(issues[0].Message, "assemblyai") || !strings.Contains(issues[0].Message, "local, groq, or openai") {
t.Errorf("issue message = %q, want it to name the bad value and valid options", issues[0].Message)
}
}
func TestValidateSTTConfigRejectsUnknownStreamProvider(t *testing.T) {
_, issues := ValidateBytes([]byte(`{"stt":{"streamProvider":"groq"}}`))
if len(issues) == 0 {
t.Fatal("expected an issue: groq has no streaming product, not a valid streamProvider")
}
if !strings.Contains(issues[0].Message, "deepgram") {
t.Errorf("issue message = %q", issues[0].Message)
}
}
func TestValidateSTTConfigAcceptsValid(t *testing.T) {
_, issues := ValidateBytes([]byte(`{"stt":{"provider":"groq","streamProvider":"deepgram","maxDurationSeconds":120}}`))
if len(issues) != 0 {
t.Fatalf("unexpected issues: %+v", issues)
}
}
func TestValidateSTTConfigRejectsNegativeDuration(t *testing.T) {
_, issues := ValidateBytes([]byte(`{"stt":{"maxDurationSeconds":-5}}`))
if len(issues) == 0 {
t.Fatal("expected an issue for negative maxDurationSeconds")
}
}
func TestSTTConfigDefaults(t *testing.T) {
cfg := STTConfig{}
if cfg.STTProvider() != STTProviderLocal {
t.Errorf("default provider = %q, want local", cfg.STTProvider())
}
if cfg.STTStreamProvider() != STTProviderLocal {
t.Errorf("default streamProvider = %q, want local", cfg.STTStreamProvider())
}
if !cfg.StreamingEnabled() {
t.Error("streaming should default on")
}
if !cfg.SilenceAutoStopEnabled() {
t.Error("silence auto-stop should default on")
}
if cfg.AutoSubmitEnabled() {
t.Error("auto-submit should default off (insert-for-review is the safety net)")
}
if !cfg.Empty() {
t.Error("zero-value STTConfig should be Empty")
}
}
func TestSTTConfigTriStateBools(t *testing.T) {
cfg, issues := ValidateBytes([]byte(`{"stt":{"autoSubmit":true,"streaming":false,"silenceAutoStop":false}}`))
if len(issues) != 0 {
t.Fatalf("unexpected issues: %+v", issues)
}
if !cfg.STT.AutoSubmitEnabled() {
t.Error("autoSubmit:true should be enabled")
}
if cfg.STT.StreamingEnabled() {
t.Error("streaming:false should be disabled")
}
if cfg.STT.SilenceAutoStopEnabled() {
t.Error("silenceAutoStop:false should be disabled")
}
}
func TestSTTConfigRoundTripsThroughMarshal(t *testing.T) {
cfg, _ := ValidateBytes([]byte(`{"stt":{"provider":"groq","model":"whisper-large-v3-turbo","localServerPort":6007}}`))
data, err := cfg.MarshalJSON()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `"stt"`) {
t.Errorf("marshaled config dropped stt section: %s", data)
}
reparsed, issues := ValidateBytes(data)
if len(issues) != 0 {
t.Fatalf("re-parse issues: %+v", issues)
}
if reparsed.STT.Provider != STTProviderGroq || reparsed.STT.Model != "whisper-large-v3-turbo" || reparsed.STT.LocalServerPort != 6007 {
t.Errorf("round-trip lost fields: %+v", reparsed.STT)
}
}
func TestSetSTTModelPersists(t *testing.T) {
dir := t.TempDir()
path := dir + "/config.json"
// Cloud provider stores under stt.model.
if _, err := SetSTTModel(path, STTProviderGroq, "whisper-large-v3-turbo"); err != nil {
t.Fatalf("SetSTTModel: %v", err)
}
cfg, issues := ValidateFile(path)
if len(issues) != 0 {
t.Fatalf("issues: %+v", issues)
}
if cfg.STT.Provider != STTProviderGroq || cfg.STT.Model != "whisper-large-v3-turbo" {
t.Errorf("persisted STT = %+v", cfg.STT)
}
// Local provider stores under stt.localModelPath.
if _, err := SetSTTModel(path, STTProviderLocal, "/models/moonshine"); err != nil {
t.Fatalf("SetSTTModel local: %v", err)
}
cfg, _ = ValidateFile(path)
if cfg.STT.LocalModelPath != "/models/moonshine" {
t.Errorf("local model path = %q", cfg.STT.LocalModelPath)
}
}
-118
View File
@@ -131,115 +131,6 @@ type KeyBindingsConfig struct {
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
}
// STTProviderKind is the batch (or streaming) transcription backend selector.
// Validated at config load time against the known set, so a typo fails loudly
// with the valid options rather than silently falling back to a default.
type STTProviderKind string
const (
STTProviderLocal STTProviderKind = "local"
STTProviderGroq STTProviderKind = "groq"
STTProviderOpenAI STTProviderKind = "openai"
STTProviderDeepgram STTProviderKind = "deepgram"
)
// STTConfig configures speech-to-text dictation. All fields are optional; empty
// values take the documented defaults. Booleans that need a real tri-state
// (distinguishing "unset" from "false") use *bool, matching PreferencesConfig.Recaps.
type STTConfig struct {
// Provider is the batch transcription backend: "local" (sherpa-onnx-offline,
// the default), "groq", or "openai". Termux and the fallback everywhere use
// this path.
Provider STTProviderKind `json:"provider,omitempty"`
// StreamProvider is the streaming backend on desktop: "local" (sherpa-onnx
// websocket server, default), "deepgram", or "openai" (Realtime).
StreamProvider STTProviderKind `json:"streamProvider,omitempty"`
// Streaming enables the live-transcript pipeline on platforms that support
// it (desktop). Defaults to on; set false to always use the batch pipeline.
Streaming *bool `json:"streaming,omitempty"`
// Model overrides the cloud batch model (e.g. "whisper-large-v3-turbo" for
// Groq, "whisper-1" for OpenAI). Empty uses the provider default.
Model string `json:"model,omitempty"`
// StreamModel overrides the cloud streaming model (Deepgram/OpenAI Realtime).
StreamModel string `json:"streamModel,omitempty"`
// LocalModelPath is the sherpa-onnx model directory for local transcription
// (batch and streaming). Required to use a local provider.
LocalModelPath string `json:"localModelPath,omitempty"`
// LocalBinary overrides the offline binary name/path (default
// "sherpa-onnx-offline"); LocalServerBinary overrides the streaming server
// (default "sherpa-onnx-online-websocket-server"). Both looked up on PATH.
LocalBinary string `json:"localBinary,omitempty"`
LocalServerBinary string `json:"localServerBinary,omitempty"`
// LocalServerPort is the localhost port for the sherpa-onnx websocket server
// (default 6006).
LocalServerPort int `json:"localServerPort,omitempty"`
// EngineVersion selects the sherpa-onnx release the auto-download fetches
// ("" → a pinned known-good default; "latest" or any release tag also works,
// so a newer engine needs no Zero update). Verified against the release's
// published SHA256 digest either way.
EngineVersion string `json:"engineVersion,omitempty"`
// NumThreads sets the local engine's thread count (0 = engine default).
NumThreads int `json:"numThreads,omitempty"`
// Language optionally constrains recognition (ISO-639-1, e.g. "en"). Empty =
// auto-detect.
Language string `json:"language,omitempty"`
// MaxDurationSeconds is the hard runaway-recording cap on every platform
// (0 = default 300s).
MaxDurationSeconds int `json:"maxDurationSeconds,omitempty"`
// SilenceAutoStop ends a recording ~2s after the signal goes quiet, as a
// backstop for a forgotten manual stop (not VAD-triggered start). Defaults on.
SilenceAutoStop *bool `json:"silenceAutoStop,omitempty"`
// AutoSubmit fires the transcript at the agent instead of inserting it for
// review. Defaults OFF — insert-for-review is the safety net for a misheard
// prompt (§3).
AutoSubmit *bool `json:"autoSubmit,omitempty"`
// WindowsAudioDevice names the dshow capture device (auto-detected when empty).
WindowsAudioDevice string `json:"windowsAudioDevice,omitempty"`
}
// STTProvider returns the configured batch provider, defaulting to local.
func (c STTConfig) STTProvider() STTProviderKind {
if c.Provider == "" {
return STTProviderLocal
}
return c.Provider
}
// STTStreamProvider returns the configured streaming provider, defaulting to local.
func (c STTConfig) STTStreamProvider() STTProviderKind {
if c.StreamProvider == "" {
return STTProviderLocal
}
return c.StreamProvider
}
// StreamingEnabled reports whether the live pipeline is on. Unset defaults ON.
func (c STTConfig) StreamingEnabled() bool {
return c.Streaming == nil || *c.Streaming
}
// SilenceAutoStopEnabled reports whether trailing-silence auto-stop is on.
// Unset defaults ON.
func (c STTConfig) SilenceAutoStopEnabled() bool {
return c.SilenceAutoStop == nil || *c.SilenceAutoStop
}
// AutoSubmitEnabled reports whether transcripts are auto-fired. Unset defaults OFF.
func (c STTConfig) AutoSubmitEnabled() bool {
return c.AutoSubmit != nil && *c.AutoSubmit
}
// Empty reports whether the STT config carries no user-set values (so it can be
// omitted from a marshaled config).
func (c STTConfig) Empty() bool {
return c.Provider == "" && c.StreamProvider == "" && c.Streaming == nil &&
c.Model == "" && c.StreamModel == "" && c.LocalModelPath == "" &&
c.LocalBinary == "" && c.LocalServerBinary == "" && c.LocalServerPort == 0 &&
c.EngineVersion == "" && c.NumThreads == 0 && c.Language == "" &&
c.MaxDurationSeconds == 0 && c.SilenceAutoStop == nil && c.AutoSubmit == nil &&
c.WindowsAudioDevice == ""
}
// LocalControlConfig controls local browser/desktop/terminal automation helpers.
// Helpers are discovered lazily by the tool that needs them; no setup command or
// background probe is required during startup.
@@ -336,7 +227,6 @@ type FileConfig struct {
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl LocalControlConfig `json:"localControl,omitempty"`
STT STTConfig `json:"stt,omitempty"`
}
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
@@ -352,7 +242,6 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
STT *STTConfig `json:"stt,omitempty"`
}
raw := rawConfig{
ActiveProvider: cfg.ActiveProvider,
@@ -369,9 +258,6 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
if !cfg.LocalControl.Empty() {
raw.LocalControl = &cfg.LocalControl
}
if !cfg.STT.Empty() {
raw.STT = &cfg.STT
}
return json.Marshal(raw)
}
@@ -394,7 +280,6 @@ type Overrides struct {
Tools ToolsConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
STT STTConfig
}
type ResolvedConfig struct {
@@ -410,7 +295,6 @@ type ResolvedConfig struct {
Preferences PreferencesConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
STT STTConfig
}
type MCPConfig struct {
@@ -458,7 +342,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
Preferences PreferencesConfig `json:"preferences"`
KeyBindings KeyBindingsConfig `json:"keybindings"`
LocalControl LocalControlConfig `json:"localControl"`
STT STTConfig `json:"stt"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
}
@@ -486,7 +369,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
cfg.Preferences = raw.Preferences
cfg.KeyBindings = raw.KeyBindings
cfg.LocalControl = raw.LocalControl
cfg.STT = raw.STT
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
cfg.MCP.Servers = map[string]MCPServerConfig{}
}
-3
View File
@@ -54,8 +54,5 @@ func validateSemantics(cfg FileConfig) []Issue {
// normalizeProviders already redacts secrets via providerError.
return []Issue{{FieldPath: "providers", Message: err.Error()}}
}
if err := validateSTTConfig(cfg.STT); err != nil {
return []Issue{{FieldPath: "stt", Message: err.Error()}}
}
return nil
}
-462
View File
@@ -7,8 +7,6 @@ import (
"path/filepath"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/providercatalog"
)
func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileConfig, error) {
@@ -31,20 +29,6 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC
}
mergeProvider(&cfg, profile)
// mergeProfile deliberately ignores APIKeyStored — during resolve-time
// layering a project config must not be able to claim the user's stored
// keys. This user-config WRITE path re-applies the marker: capturing a key
// via SecureProviderProfile onto a previously env/no-key profile must
// persist apiKeyStored, or the secret sits in the credential store while
// every ApplyStoredAPIKey gate skips it (PR #560 review).
if profile.APIKeyStored {
for index := range cfg.Providers {
if cfg.Providers[index].Name == profile.Name {
cfg.Providers[index].APIKeyStored = true
break
}
}
}
if setActive || strings.TrimSpace(cfg.ActiveProvider) == "" {
cfg.ActiveProvider = profile.Name
}
@@ -55,62 +39,6 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC
return cfg, nil
}
// EnsuredProvider reports the outcome of EnsureCatalogProvider: the profile name
// that serves the catalog entry, whether it was newly created, and which provider
// is active after the call (unchanged unless it was blank).
type EnsuredProvider struct {
Name string
Created bool
Active string
}
// EnsureCatalogProvider guarantees a provider profile exists in the config at
// path for the given catalog entry. OAuth login flows call this right after
// storing a token: a login is only reachable from the provider list and
// `zero providers use` when a profile exists, but a login must never replace or
// deactivate the user's current active provider — so an existing profile whose
// Name or CatalogID already matches is left completely untouched (its name,
// credentials, and model are the user's), and a created profile is NOT marked
// active unless no provider was active at all.
func EnsureCatalogProvider(path string, catalogID string) (EnsuredProvider, error) {
path = strings.TrimSpace(path)
if path == "" {
return EnsuredProvider{}, fmt.Errorf("config path is required")
}
descriptor, err := providercatalog.Require(catalogID)
if err != nil {
return EnsuredProvider{}, err
}
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return EnsuredProvider{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return EnsuredProvider{}, fmt.Errorf("read config %s: %w", path, err)
}
for _, provider := range cfg.Providers {
if strings.EqualFold(strings.TrimSpace(provider.CatalogID), descriptor.ID) ||
strings.EqualFold(strings.TrimSpace(provider.Name), descriptor.ID) {
return EnsuredProvider{Name: provider.Name, Active: cfg.ActiveProvider}, nil
}
}
profile := ProviderProfile{
Name: descriptor.ID,
ProviderKind: providerKindForCatalogTransport(descriptor.Transport),
CatalogID: descriptor.ID,
BaseURL: descriptor.DefaultBaseURL,
Model: descriptor.DefaultModel,
}
written, err := UpsertProvider(path, profile, false)
if err != nil {
return EnsuredProvider{}, err
}
return EnsuredProvider{Name: profile.Name, Created: true, Active: written.ActiveProvider}, nil
}
func SetActiveProvider(path string, name string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
@@ -144,296 +72,6 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
// RemoveProvider deletes the named provider profile from the config at path.
// When the removed profile was active, activeProvider hands off to the first
// remaining provider (or clears when none remain) so the config never points at
// a profile that no longer exists. The caller owns cleaning up the credential
// store entry — config stays pure of secret I/O on the read path, and remove
// keeps that symmetry by only touching config.json.
func RemoveProvider(path string, name string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
name = strings.TrimSpace(name)
if name == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
index := -1
for i, provider := range cfg.Providers {
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
index = i
break
}
}
if index < 0 {
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
removed := cfg.Providers[index]
cfg.Providers = append(cfg.Providers[:index], cfg.Providers[index+1:]...)
if strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(removed.Name)) {
cfg.ActiveProvider = ""
if len(cfg.Providers) > 0 {
cfg.ActiveProvider = cfg.Providers[0].Name
}
}
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
// RenameProvider renames a provider profile, keeping everything keyed by the
// profile name consistent: the activeProvider pointer follows the rename, and a
// key in the encrypted credential store (APIKeyStored) is migrated to the new
// name BEFORE the config is rewritten — the store write must succeed first so a
// failed migration never strands the config pointing at a key that no longer
// resolves. OAuth tokens are deliberately not migrated: the runtime's login
// candidates fall back to the profile's CatalogID, which every OAuth-capable
// catalog profile carries, so a rename keeps the login reachable.
func RenameProvider(path string, oldName string, newName string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
oldName = strings.TrimSpace(oldName)
newName = strings.TrimSpace(newName)
if oldName == "" || newName == "" {
return FileConfig{}, fmt.Errorf("provider names are required")
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
index := -1
for i, provider := range cfg.Providers {
providerName := strings.TrimSpace(provider.Name)
if strings.EqualFold(providerName, oldName) {
index = i
continue
}
if strings.EqualFold(providerName, newName) {
return FileConfig{}, fmt.Errorf("provider %q already exists", newName)
}
}
if index < 0 {
return FileConfig{}, fmt.Errorf("provider %q not found", oldName)
}
if strings.EqualFold(oldName, newName) && cfg.Providers[index].Name == newName {
return cfg, nil
}
previousName := cfg.Providers[index].Name
keyMigrated := false
if cfg.Providers[index].APIKeyStored {
if err := migrateStoredProviderKey(path, previousName, newName); err != nil {
return FileConfig{}, fmt.Errorf("migrate stored key for %q: %w", oldName, err)
}
keyMigrated = true
}
if strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(previousName)) {
cfg.ActiveProvider = newName
}
cfg.Providers[index].Name = newName
if err := writeConfigFile(path, cfg); err != nil {
if keyMigrated {
// Compensate best-effort: config.json still names the OLD profile, so
// move the key back where that config can find it — otherwise a failed
// rewrite strands the key under a name no profile carries.
_ = migrateStoredProviderKey(path, newName, previousName)
}
return FileConfig{}, err
}
return cfg, nil
}
// ProviderEdit is a field-level edit of one saved provider, applied by
// EditProvider in a single atomic write. Name is the CURRENT profile name
// (matched case-insensitively); NewName renames (case-only renames included).
// Empty BaseURL/Model/APIKey mean "leave unchanged"; Description is applied
// VERBATIM (the editor always knows the full desired text, so clearing works).
type ProviderEdit struct {
Name string
NewName string
BaseURL string
Model string
APIKey string
APIKeyStored bool
Description string
}
// EditProvider applies a provider edit in ONE read-modify-write: rename
// (activeProvider follows; a stored key migrates, with a best-effort rollback
// if the config write fails), field updates, the stored-key marker, and the
// verbatim description. A single write keeps the operation atomic — the
// previous rename+upsert+describe sequence could fail halfway and leave
// config.json renamed while every in-memory consumer still held the old name —
// and, unlike UpsertProvider's exact-name merge, the case-insensitive match
// here makes a case-only rename (groq -> Groq) an in-place update instead of
// an appended duplicate profile.
func EditProvider(path string, edit ProviderEdit) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
oldName := strings.TrimSpace(edit.Name)
if oldName == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
newName := strings.TrimSpace(edit.NewName)
if newName == "" {
newName = oldName
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
index := -1
for i, provider := range cfg.Providers {
providerName := strings.TrimSpace(provider.Name)
if strings.EqualFold(providerName, oldName) {
index = i
continue
}
if strings.EqualFold(providerName, newName) {
return FileConfig{}, fmt.Errorf("provider %q already exists", newName)
}
}
if index < 0 {
return FileConfig{}, fmt.Errorf("provider %q not found", oldName)
}
previousName := cfg.Providers[index].Name
renamed := previousName != newName
keyMigrated := false
// A rename moves the stored key along: either the profile's existing entry,
// or a replacement key the caller just captured — the contract is that a
// captured key is stored under the CURRENT name before EditProvider runs, so
// one migration covers both. migrateStoredProviderKey no-ops on case-only
// renames (the store normalizes names), so it cannot delete the key it just
// moved.
if renamed && (cfg.Providers[index].APIKeyStored || edit.APIKeyStored) {
if err := migrateStoredProviderKey(path, previousName, newName); err != nil {
return FileConfig{}, fmt.Errorf("migrate stored key for %q: %w", oldName, err)
}
keyMigrated = true
}
if renamed && strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(previousName)) {
cfg.ActiveProvider = newName
}
profile := &cfg.Providers[index]
profile.Name = newName
if baseURL := strings.TrimSpace(edit.BaseURL); baseURL != "" {
profile.BaseURL = baseURL
}
if model := strings.TrimSpace(edit.Model); model != "" {
profile.Model = model
}
if apiKey := strings.TrimSpace(edit.APIKey); apiKey != "" {
profile.APIKey = apiKey
}
if edit.APIKeyStored {
profile.APIKeyStored = true
}
profile.Description = strings.TrimSpace(edit.Description)
if err := writeConfigFile(path, cfg); err != nil {
if keyMigrated {
// Compensate best-effort: config.json still names the OLD profile, so
// move the key back where that config can find it.
_ = migrateStoredProviderKey(path, newName, previousName)
}
return FileConfig{}, err
}
return cfg, nil
}
// SetProviderDescription sets a provider's description VERBATIM — including to
// empty. The generic UpsertProvider merge treats empty fields as "leave
// unchanged", so clearing a description needs this dedicated setter.
func SetProviderDescription(path string, name string, description string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
name = strings.TrimSpace(name)
if name == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
for index := range cfg.Providers {
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), name) {
cfg.Providers[index].Description = strings.TrimSpace(description)
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
}
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
// migrateStoredProviderKey moves a credential-store entry to a new provider
// name: write-new-then-delete-old, so an interruption can leave a duplicate but
// never a missing key. A missing source entry is a no-op (the marker may be
// stale); only a failed WRITE aborts the rename.
func migrateStoredProviderKey(configPath string, oldName string, newName string) error {
// The store normalizes names case-insensitively, so a case-only rename
// (groq -> Groq) targets ONE entry: Set(new) rewrites it in place and
// Delete(old) would then remove the key that was just "moved". Nothing to
// migrate — the existing entry already serves the new name.
if strings.EqualFold(strings.TrimSpace(oldName), strings.TrimSpace(newName)) {
return nil
}
store, err := ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
return err
}
key, ok, err := store.Get(oldName)
if err != nil {
return err
}
if !ok || strings.TrimSpace(key) == "" {
return nil
}
if err := store.Set(newName, key); err != nil {
return err
}
_, _ = store.Delete(oldName)
return nil
}
func SetProviderModel(path string, name string, model string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
@@ -538,106 +176,6 @@ func SetTheme(path string, theme string) (FileConfig, error) {
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,
// otherwise as stt.model. A blank model clears the stored value for that slot.
func SetSTTModel(path string, provider STTProviderKind, model string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
if provider != "" {
cfg.STT.Provider = provider
}
model = strings.TrimSpace(model)
if cfg.STT.STTProvider() == STTProviderLocal {
cfg.STT.LocalModelPath = model
} else {
cfg.STT.Model = model
}
if err := validateSTTConfig(cfg.STT); err != nil {
return FileConfig{}, err
}
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
// SetSTTLocalEngine persists the paths of an auto-downloaded local engine +
// model and switches dictation to the local provider, mirroring SetTheme
// (read-modify-atomic-write). streaming selects the pipeline matching the
// downloaded model (a streaming transducer vs a batch model). Called after a
// download completes.
func SetSTTLocalEngine(path, binary, serverBinary, modelPath string, streaming bool) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg.STT.Provider = STTProviderLocal
// Point BOTH pipelines at the local engine. Without resetting StreamProvider, a
// previously-chosen cloud value (deepgram/openai) would still win in
// buildStreamingTranscriber, so the live transcript would keep hitting the cloud
// after the user switched to a downloaded local model.
cfg.STT.StreamProvider = STTProviderLocal
cfg.STT.LocalBinary = strings.TrimSpace(binary)
cfg.STT.LocalServerBinary = strings.TrimSpace(serverBinary)
cfg.STT.LocalModelPath = strings.TrimSpace(modelPath)
// Match the pipeline to the downloaded model: a streaming transducer drives
// the websocket server for a live transcript; a batch model uses the offline
// binary.
s := streaming
cfg.STT.Streaming = &s
if err := validateSTTConfig(cfg.STT); err != nil {
return FileConfig{}, err
}
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
// SetSTTProvider persists just the dictation batch provider, mirroring SetTheme.
func SetSTTProvider(path string, provider STTProviderKind) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg.STT.Provider = provider
if err := validateSTTConfig(cfg.STT); err != nil {
return FileConfig{}, err
}
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
func normalizeFavoriteModels(models []string) []string {
seen := map[string]bool{}
favorites := make([]string, 0, len(models))
-543
View File
@@ -5,7 +5,6 @@ import (
"errors"
"io/fs"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
@@ -386,545 +385,3 @@ func readConfigFixture(t *testing.T, path string) FileConfig {
}
return cfg
}
func TestEnsureCatalogProviderCreatesProfileWithoutStealingActive(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "opengateway",
Providers: []ProviderProfile{
{
Name: "opengateway",
ProviderKind: ProviderKindOpenAICompatible,
BaseURL: "https://gateway.example.com/v1",
APIKeyStored: true,
Model: "some-model",
},
},
}, 0o600)
ensured, err := EnsureCatalogProvider(path, "chatgpt")
if err != nil {
t.Fatalf("EnsureCatalogProvider: %v", err)
}
if !ensured.Created {
t.Fatalf("expected profile to be created")
}
if ensured.Name != "chatgpt" {
t.Fatalf("expected profile name chatgpt, got %q", ensured.Name)
}
if ensured.Active != "opengateway" {
t.Fatalf("active provider must stay opengateway, got %q", ensured.Active)
}
cfg := readConfigFixture(t, path)
if cfg.ActiveProvider != "opengateway" {
t.Fatalf("persisted active provider changed to %q", cfg.ActiveProvider)
}
if len(cfg.Providers) != 2 {
t.Fatalf("expected 2 providers, got %d", len(cfg.Providers))
}
chatgpt := cfg.Providers[1]
if chatgpt.Name != "chatgpt" || chatgpt.CatalogID != "chatgpt" {
t.Fatalf("unexpected created profile: %+v", chatgpt)
}
if chatgpt.Model == "" || chatgpt.BaseURL == "" {
t.Fatalf("created profile must carry catalog defaults, got %+v", chatgpt)
}
if chatgpt.APIKey != "" || chatgpt.APIKeyStored {
t.Fatalf("OAuth-created profile must stay keyless, got %+v", chatgpt)
}
}
func TestEnsureCatalogProviderLeavesExistingProfileUntouched(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
original := FileConfig{
ActiveProvider: "opengateway",
Providers: []ProviderProfile{
{Name: "opengateway", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
// Renamed profile that already serves the chatgpt catalog entry.
{Name: "codex", CatalogID: "chatgpt", Model: "gpt-5.5"},
},
}
data := writeConfigFixture(t, path, original, 0o600)
ensured, err := EnsureCatalogProvider(path, "chatgpt")
if err != nil {
t.Fatalf("EnsureCatalogProvider: %v", err)
}
if ensured.Created {
t.Fatalf("existing profile must not be recreated")
}
if ensured.Name != "codex" {
t.Fatalf("expected existing profile name codex, got %q", ensured.Name)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(after) != string(data) {
t.Fatalf("config rewritten for a no-op ensure:\nbefore: %s\nafter: %s", data, after)
}
}
func TestEnsureCatalogProviderActivatesOnEmptyConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
ensured, err := EnsureCatalogProvider(path, "chatgpt")
if err != nil {
t.Fatalf("EnsureCatalogProvider: %v", err)
}
if !ensured.Created {
t.Fatalf("expected profile to be created")
}
if ensured.Active != "chatgpt" {
t.Fatalf("blank active must adopt the new provider, got %q", ensured.Active)
}
}
func TestEnsureCatalogProviderRejectsUnknownCatalogID(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
if _, err := EnsureCatalogProvider(path, "no-such-provider"); err == nil {
t.Fatalf("expected unknown catalog id to error")
}
if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("config must not be written for an unknown catalog id")
}
}
func TestRemoveProviderDeletesAndHandsOffActive(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "beta",
Providers: []ProviderProfile{
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
},
}, 0o600)
cfg, err := RemoveProvider(path, " BETA ")
if err != nil {
t.Fatalf("RemoveProvider() error = %v", err)
}
if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "alpha" {
t.Fatalf("expected only alpha to remain, got %+v", cfg.Providers)
}
if cfg.ActiveProvider != "alpha" {
t.Fatalf("active must hand off to the first remaining provider, got %q", cfg.ActiveProvider)
}
persisted := readConfigFixture(t, path)
if len(persisted.Providers) != 1 || persisted.ActiveProvider != "alpha" {
t.Fatalf("persisted config wrong: %+v", persisted)
}
// Removing the last provider clears the active pointer entirely.
cfg, err = RemoveProvider(path, "alpha")
if err != nil {
t.Fatalf("RemoveProvider(last) error = %v", err)
}
if len(cfg.Providers) != 0 || cfg.ActiveProvider != "" {
t.Fatalf("expected empty providers and no active, got %+v", cfg)
}
}
func TestRemoveProviderKeepsActiveWhenOtherRemoved(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "alpha",
Providers: []ProviderProfile{
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
},
}, 0o600)
cfg, err := RemoveProvider(path, "beta")
if err != nil {
t.Fatalf("RemoveProvider() error = %v", err)
}
if cfg.ActiveProvider != "alpha" {
t.Fatalf("active provider must be untouched, got %q", cfg.ActiveProvider)
}
}
func TestRemoveProviderRejectsUnknownWithoutRewriting(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
before := writeConfigFixture(t, path, FileConfig{
ActiveProvider: "alpha",
Providers: []ProviderProfile{{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"}},
}, 0o600)
if _, err := RemoveProvider(path, "ghost"); err == nil {
t.Fatal("RemoveProvider() error = nil, want not-found error")
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(after) != string(before) {
t.Fatalf("config was rewritten for unknown provider")
}
}
func TestRenameProviderFollowsActiveAndMigratesStoredKey(t *testing.T) {
dir := t.TempDir()
// Force the file credential backend so the test never touches the real OS
// keyring regardless of platform.
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "oldname",
Providers: []ProviderProfile{
{Name: "oldname", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", APIKeyStored: true, Model: "m1"},
{Name: "other", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("oldname", "sk-secret"); err != nil {
t.Fatalf("seed store: %v", err)
}
cfg, err := RenameProvider(path, "oldname", "newname")
if err != nil {
t.Fatalf("RenameProvider() error = %v", err)
}
if cfg.ActiveProvider != "newname" {
t.Fatalf("active must follow the rename, got %q", cfg.ActiveProvider)
}
if cfg.Providers[0].Name != "newname" || !cfg.Providers[0].APIKeyStored {
t.Fatalf("renamed profile wrong: %+v", cfg.Providers[0])
}
if cfg.Providers[1].Name != "other" {
t.Fatalf("unrelated profile changed: %+v", cfg.Providers[1])
}
key, ok, err := store.Get("newname")
if err != nil || !ok || key != "sk-secret" {
t.Fatalf("stored key must migrate to the new name, got key=%q ok=%v err=%v", key, ok, err)
}
if _, ok, _ := store.Get("oldname"); ok {
t.Fatalf("old credential entry must be deleted after migration")
}
}
func TestRenameProviderRejectsCollisionAndUnknown(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
before := writeConfigFixture(t, path, FileConfig{
ActiveProvider: "alpha",
Providers: []ProviderProfile{
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
},
}, 0o600)
if _, err := RenameProvider(path, "alpha", "BETA"); err == nil {
t.Fatal("rename onto an existing name must fail")
}
if _, err := RenameProvider(path, "ghost", "gamma"); err == nil {
t.Fatal("renaming an unknown provider must fail")
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(after) != string(before) {
t.Fatalf("config was rewritten by a rejected rename")
}
}
func TestUpsertProviderPreservesStoredKeyMarkerOnExistingProfile(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
// An env-keyed profile with NO stored-key marker — the shape a provider has
// before its key is captured into the credential store.
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "groq",
Providers: []ProviderProfile{
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyEnv: "GROQ_API_KEY", Model: "m1"},
},
}, 0o600)
// The manager/setup edit paths persist a SecureProviderProfile-shaped
// profile: key already in the store, marker set, inline key cleared.
cfg, err := UpsertProvider(path, ProviderProfile{Name: "groq", APIKeyStored: true}, false)
if err != nil {
t.Fatalf("UpsertProvider() error = %v", err)
}
if !cfg.Providers[0].APIKeyStored {
t.Fatalf("APIKeyStored marker must survive the merge, got %+v", cfg.Providers[0])
}
if cfg.Providers[0].APIKeyEnv != "GROQ_API_KEY" || cfg.Providers[0].BaseURL == "" {
t.Fatalf("other fields must be preserved: %+v", cfg.Providers[0])
}
persisted := readConfigFixture(t, path)
if !persisted.Providers[0].APIKeyStored {
t.Fatalf("marker not persisted to disk: %+v", persisted.Providers[0])
}
}
func TestSetProviderDescriptionSetsAndClears(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "alpha",
Providers: []ProviderProfile{
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1", Description: "old text"},
},
}, 0o600)
cfg, err := SetProviderDescription(path, " ALPHA ", "new text")
if err != nil {
t.Fatalf("SetProviderDescription() error = %v", err)
}
if cfg.Providers[0].Description != "new text" {
t.Fatalf("description not set: %+v", cfg.Providers[0])
}
// Clearing must persist too — the reason this setter exists (UpsertProvider's
// merge treats an empty description as "leave unchanged").
cfg, err = SetProviderDescription(path, "alpha", " ")
if err != nil {
t.Fatalf("SetProviderDescription(clear) error = %v", err)
}
if cfg.Providers[0].Description != "" {
t.Fatalf("description not cleared: %+v", cfg.Providers[0])
}
persisted := readConfigFixture(t, path)
if persisted.Providers[0].Description != "" {
t.Fatalf("cleared description not persisted: %+v", persisted.Providers[0])
}
if _, err := SetProviderDescription(path, "ghost", "x"); err == nil {
t.Fatal("unknown provider must error")
}
}
func TestRenameProviderRollsBackKeyMigrationWhenConfigWriteFails(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("uses chflags uchg to force the config write to fail; macOS only")
}
dir := t.TempDir()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "oldname",
Providers: []ProviderProfile{
{Name: "oldname", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", APIKeyStored: true, Model: "m1"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("oldname", "sk-secret"); err != nil {
t.Fatalf("seed store: %v", err)
}
// Immutable flag: temp-file creation and store writes in the directory keep
// working, but the final rename over config.json fails — the exact window
// where a migrated key would otherwise strand under the new name.
if out, err := exec.Command("chflags", "uchg", path).CombinedOutput(); err != nil {
t.Skipf("chflags uchg unavailable: %v (%s)", err, out)
}
t.Cleanup(func() { _ = exec.Command("chflags", "nouchg", path).Run() })
if _, err := RenameProvider(path, "oldname", "newname"); err == nil {
t.Fatal("expected the config write to fail under the immutable flag")
}
key, ok, err := store.Get("oldname")
if err != nil || !ok || key != "sk-secret" {
t.Fatalf("key must be rolled back to the old name, got key=%q ok=%v err=%v", key, ok, err)
}
if _, ok, _ := store.Get("newname"); ok {
t.Fatalf("rolled-back migration must not leave a key under the new name")
}
}
// TestRenameProviderCaseOnlyKeepsStoredKey: the credential store normalizes
// names case-insensitively, so a case-only rename targets ONE store entry —
// migrating would Set and then Delete the same key, losing it (PR #560 review).
func TestRenameProviderCaseOnlyKeepsStoredKey(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "groq",
Providers: []ProviderProfile{
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyStored: true, Model: "m1"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("groq", "sk-secret"); err != nil {
t.Fatalf("seed store: %v", err)
}
cfg, err := RenameProvider(path, "groq", "Groq")
if err != nil {
t.Fatalf("RenameProvider() error = %v", err)
}
if cfg.Providers[0].Name != "Groq" || cfg.ActiveProvider != "Groq" {
t.Fatalf("case-only rename must still apply to config: %+v", cfg)
}
// The store is case-insensitive: the key must remain retrievable under the
// new casing (same entry), not deleted by a same-entry "migration".
if key, ok, err := store.Get("Groq"); err != nil || !ok || key != "sk-secret" {
t.Fatalf("stored key lost on case-only rename: key=%q ok=%v err=%v", key, ok, err)
}
}
func TestEditProviderAppliesRenameFieldsAndDescriptionAtomically(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "groq",
Providers: []ProviderProfile{
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyStored: true, Model: "m1", Description: "old text"},
{Name: "other", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://o.example.com/v1", Model: "m2"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("groq", "sk-old"); err != nil {
t.Fatalf("seed store: %v", err)
}
cfg, err := EditProvider(path, ProviderEdit{
Name: "groq",
NewName: "grok-main",
Model: "m1-pro",
Description: "", // cleared — must persist verbatim
})
if err != nil {
t.Fatalf("EditProvider() error = %v", err)
}
edited := cfg.Providers[0]
if edited.Name != "grok-main" || edited.Model != "m1-pro" || edited.Description != "" {
t.Fatalf("edit not applied: %+v", edited)
}
if edited.BaseURL != "https://api.groq.com/openai/v1" || !edited.APIKeyStored {
t.Fatalf("untouched fields must survive: %+v", edited)
}
if cfg.ActiveProvider != "grok-main" {
t.Fatalf("active must follow the rename, got %q", cfg.ActiveProvider)
}
if key, ok, _ := store.Get("grok-main"); !ok || key != "sk-old" {
t.Fatalf("stored key must migrate with the rename, got %q ok=%v", key, ok)
}
if len(cfg.Providers) != 2 || cfg.Providers[1].Name != "other" {
t.Fatalf("unrelated profile changed: %+v", cfg.Providers)
}
}
// TestEditProviderCaseOnlyRenameUpdatesInPlace: the manager previously skipped
// RenameProvider on case-insensitively-equal names and fell into UpsertProvider,
// whose case-SENSITIVE merge appended a duplicate profile. EditProvider matches
// case-insensitively, so a case-only rename is an in-place update and the store
// entry (case-normalized) survives.
func TestEditProviderCaseOnlyRenameUpdatesInPlace(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "groq",
Providers: []ProviderProfile{
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyStored: true, Model: "m1"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("groq", "sk-secret"); err != nil {
t.Fatalf("seed store: %v", err)
}
cfg, err := EditProvider(path, ProviderEdit{Name: "groq", NewName: "Groq"})
if err != nil {
t.Fatalf("EditProvider() error = %v", err)
}
if len(cfg.Providers) != 1 {
t.Fatalf("case-only rename must not duplicate the profile: %+v", cfg.Providers)
}
if cfg.Providers[0].Name != "Groq" || !cfg.Providers[0].APIKeyStored {
t.Fatalf("in-place update wrong: %+v", cfg.Providers[0])
}
if cfg.Providers[0].BaseURL != "https://api.groq.com/openai/v1" {
t.Fatalf("fields must survive a case-only rename: %+v", cfg.Providers[0])
}
if cfg.ActiveProvider != "Groq" {
t.Fatalf("active must follow, got %q", cfg.ActiveProvider)
}
if key, ok, _ := store.Get("Groq"); !ok || key != "sk-secret" {
t.Fatalf("stored key lost on case-only rename: %q ok=%v", key, ok)
}
}
// TestEditProviderRenameMigratesFreshlyCapturedKey: replacing the key AND
// renaming in one edit — the caller captures under the CURRENT name and the
// rename migration moves it, so the new key lands under the new name.
func TestEditProviderRenameMigratesFreshlyCapturedKey(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "gw",
Providers: []ProviderProfile{
{Name: "gw", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://gw.example.com/v1", APIKeyEnv: "GW_KEY", Model: "m1"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
// The caller's contract: a replacement key is stored under the CURRENT name
// before EditProvider runs (what SecureProviderProfile does).
if err := store.Set("gw", "sk-new"); err != nil {
t.Fatalf("seed store: %v", err)
}
cfg, err := EditProvider(path, ProviderEdit{Name: "gw", NewName: "gateway", APIKeyStored: true})
if err != nil {
t.Fatalf("EditProvider() error = %v", err)
}
if !cfg.Providers[0].APIKeyStored || cfg.Providers[0].Name != "gateway" {
t.Fatalf("marker/rename wrong: %+v", cfg.Providers[0])
}
if key, ok, _ := store.Get("gateway"); !ok || key != "sk-new" {
t.Fatalf("captured key must migrate to the new name, got %q ok=%v", key, ok)
}
if _, ok, _ := store.Get("gw"); ok {
t.Fatalf("old entry must be cleaned up after migration")
}
}
func TestEditProviderRejectsCollisionAndUnknown(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
before := writeConfigFixture(t, path, FileConfig{
ActiveProvider: "alpha",
Providers: []ProviderProfile{
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
},
}, 0o600)
if _, err := EditProvider(path, ProviderEdit{Name: "alpha", NewName: "BETA"}); err == nil {
t.Fatal("rename onto an existing name must fail")
}
if _, err := EditProvider(path, ProviderEdit{Name: "ghost", Model: "x"}); err == nil {
t.Fatal("editing an unknown provider must fail")
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(after) != string(before) {
t.Fatalf("config was rewritten by a rejected edit")
}
}
-104
View File
@@ -1,104 +0,0 @@
// Package dictation implements speech-to-text capture and transcription for
// the composer: press a key, talk, and the transcript lands in the input for
// review — never auto-fired at the agent.
//
// Two pipelines share a keybinding and a composer-insertion target:
//
// - Batch: record to a temp WAV file (Recorder.Start/Stop), then transcribe
// in one shot (Transcriber.Transcribe) — a local sherpa-onnx-offline exec
// or a single HTTPS POST to Groq/OpenAI. The only pipeline on Termux.
// - Streaming: capture raw PCM from the mic tool's stdout
// (Recorder.StartStreaming), feed 50ms chunks to a websocket
// (Transcriber.StreamTranscribe), and render partial transcripts live.
//
// Every OS integration shells out via exec.Command with discrete argv — never
// a shell string, never CGO — matching internal/imageinput/clipboard.go. The
// external binaries (arecord/sox/ffmpeg/termux-microphone-record, sherpa-onnx)
// are looked up on PATH and degrade to a clear setup message when missing,
// matching internal/lsp's philosophy for language servers.
package dictation
import (
"bytes"
"fmt"
)
// Batch transcription providers (config value stt.provider).
const (
ProviderLocal = "local"
ProviderGroq = "groq"
ProviderOpenAI = "openai"
)
// Streaming transcription providers (config value stt.streamProvider).
const (
StreamProviderLocal = "local"
StreamProviderDeepgram = "deepgram"
StreamProviderOpenAI = "openai"
)
// BatchProviders lists valid stt.provider values, for config validation.
func BatchProviders() []string {
return []string{ProviderLocal, ProviderGroq, ProviderOpenAI}
}
// StreamProviders lists valid stt.streamProvider values, for config validation.
func StreamProviders() []string {
return []string{StreamProviderLocal, StreamProviderDeepgram, StreamProviderOpenAI}
}
// AudioFormat identifies a recorded container format, sniffed from the bytes —
// desktop recorders produce WAV, Termux's recorder produces M4A/AAC.
type AudioFormat string
const (
FormatWAV AudioFormat = "wav"
FormatM4A AudioFormat = "m4a"
FormatUnknown AudioFormat = ""
)
// SniffFormat detects the audio container from magic bytes. WAV is
// "RIFF....WAVE"; MP4-family (Termux's .m4a) has "ftyp" at offset 4.
func SniffFormat(data []byte) AudioFormat {
if len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WAVE")) {
return FormatWAV
}
if len(data) >= 8 && bytes.Equal(data[4:8], []byte("ftyp")) {
return FormatM4A
}
return FormatUnknown
}
// FileName returns a transcription-upload filename for the format.
func (f AudioFormat) FileName() string {
switch f {
case FormatM4A:
return "audio.m4a"
default:
return "audio.wav"
}
}
// SetupError reports a missing external tool with install guidance. Missing
// binaries are an expected condition (the user hasn't set dictation up yet),
// not a bug — callers show the hint instead of a raw exec error.
type SetupError struct {
Tool string
Hint string
}
func (e *SetupError) Error() string {
return fmt.Sprintf("%s not found — %s", e.Tool, e.Hint)
}
// AuthError reports that a cloud provider rejected a transcription request for
// authentication reasons (HTTP 401/403) — a missing or invalid API key. It is a
// fixable credential problem, so callers (the TUI) can offer an inline key
// prompt for Provider instead of a dead-end error. Message is the already-
// classified, secret-redacted user-facing text.
type AuthError struct {
Provider string
Message string
}
func (e *AuthError) Error() string { return e.Message }
-893
View File
@@ -1,893 +0,0 @@
package dictation
import (
"archive/tar"
"compress/bzip2"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
)
// Auto-download of the local engine + a default model (opt-in, behind a confirm
// in the F9 flow). Assets come from the official k2-fsa/sherpa-onnx GitHub
// releases and are verified against a SHA256 digest before use — the same trust
// boundary as installing them yourself, automated. Auto-downloading a native
// executable is a real trust decision, which is why it is opt-in and verified.
//
// Rather than freeze a single hardcoded version, the downloader resolves the
// release ASSETS via the GitHub API at download time and verifies each download
// against the API's per-asset digest — so a newer sherpa-onnx release (or
// "latest") works with no code change. A known-good version is still the DEFAULT
// (reproducible, smoke-tested), and for that default the resolved digest is
// cross-checked against a pinned value so a silently-changed release is caught.
// DefaultSherpaVersion is the pinned, smoke-tested release used unless
// stt.engineVersion overrides it. "latest" or any tag also works.
const DefaultSherpaVersion = "v1.13.3"
const (
sherpaRepo = "k2-fsa/sherpa-onnx"
modelReleaseTag = "asr-models" // rolling release the ASR models live in
modelAssetName = "sherpa-onnx-moonshine-tiny-en-int8.tar.bz2"
defaultAPIBase = "https://api.github.com"
)
// engineAssetSuffix maps GOOS-GOARCH to the version-independent suffix of the
// sherpa-onnx executable bundle for that platform. Matching by suffix (not a
// full version-embedded name) is what lets any release resolve. The bundles are
// self-contained (RPATH $ORIGIN/../lib), so the extracted bin/ executables run
// with no library-path setup.
var engineAssetSuffix = map[string]string{
"linux-amd64": "linux-x64-shared-no-tts.tar.bz2",
"darwin-arm64": "osx-arm64-shared-no-tts.tar.bz2",
"darwin-amd64": "osx-x64-shared-no-tts.tar.bz2",
"windows-amd64": "win-x64-shared-MT-Release-no-tts.tar.bz2",
}
// pinnedEngineDigest cross-checks the DefaultSherpaVersion engine assets (a
// silently-changed pinned release is refused). Empty for non-default versions,
// which are verified against the API digest only.
var pinnedEngineDigest = map[string]string{
"linux-amd64": "e5cbfd96f3666c25d2272d468e337764fce6a4d50ed074e4b8e5021bfd7fefc1",
"darwin-arm64": "ea73233bc250019ccf1f8a16feb29460efcd3425b50cac16980bcb76281d60bf",
"darwin-amd64": "563423887d63d8a831c473e0ab6815139fe2e7a3482936716c2a128071dd217f",
"windows-amd64": "343c6c39cb4fe1880a9fb1abc8401d5174629ccbb147fca1fd53c440b2664570",
}
// pinnedModelDigest cross-checks the default Moonshine-tiny int8 model.
const pinnedModelDigest = "d5fe6ec4334fef36255b2a4010412cad4c007e33103fec62fb5d17cad88086f2"
// ModelVariant is a downloadable model the /stt-model picker offers, with a
// pinned SHA256 (the model release predates GitHub's per-asset digests, so these
// must be pinned). Batch variants use the offline binary; the streaming variant
// (a transducer) drives the websocket server for a live transcript.
type ModelVariant struct {
ID string
Label string
Description string
Bytes int64
AssetName string
Digest string
DirName string
Streaming bool
// Recommended marks a curated, checksum-pinned, validated model (shown first).
Recommended bool
}
// ModelVariants returns the curated English models the download picker offers.
// This is a hand-picked shortlist (each pinned by SHA256 since the model release
// predates GitHub's per-asset digests), NOT the whole sherpa-onnx model zoo —
// many more models (other languages, sizes, and streaming/batch families) work
// via a manual stt.localModelPath. Streaming (live) options come first.
func ModelVariants() []ModelVariant {
return []ModelVariant{
// Live / streaming — transcript builds up as you speak.
// DirName MUST match what ListModels derives from the asset ("model-"+id, id =
// asset minus "sherpa-onnx-" prefix and ".tar.bz2" suffix, dates included) so a
// model extracts to the same directory whether picked from the curated shortlist
// or the full browse list — otherwise installed-detection disagrees between the
// two views and the same model can be downloaded twice. Recommended matches the
// full list too (recommendedModelIDs marks every curated variant), so the ★ and
// ordering don't change when the background fetch swaps the curated list out.
{
ID: "streaming-zipformer-en-kroko", Label: "Kroko (newest, fastest)",
Description: "smallest live model", Bytes: 54 << 20,
AssetName: "sherpa-onnx-streaming-zipformer-en-kroko-2025-08-06.tar.bz2",
Digest: "c8676e5ff9ac2a85296e53ee0fd4d5fb1db6770e7a7647166eeafe349ade6834",
DirName: "model-streaming-zipformer-en-kroko-2025-08-06",
Streaming: true,
Recommended: true,
},
{
ID: "streaming-zipformer-20m", Label: "Zipformer 20M",
Description: "small, well-tested", Bytes: 121 << 20,
AssetName: "sherpa-onnx-streaming-zipformer-en-20M-2023-02-17.tar.bz2",
Digest: "9c559283e8498d3fe95913c79ca1cb454bb26281ac2b102b41306c7d752765d9",
DirName: "model-streaming-zipformer-en-20M-2023-02-17",
Streaming: true,
},
{
ID: "streaming-zipformer", Label: "Zipformer",
Description: "larger, more accurate", Bytes: 297 << 20,
AssetName: "sherpa-onnx-streaming-zipformer-en-2023-06-26.tar.bz2",
Digest: "639e25b578e9e997131402199419c13a941f8e4e198e2da1ce57dbf5cf401282",
DirName: "model-streaming-zipformer-en-2023-06-26",
Streaming: true,
},
{
ID: "nemo-streaming-fast-conformer-transducer-en-80ms-int8", Label: "NeMo streaming (fast)",
Description: "low-latency English", Bytes: 98 << 20,
AssetName: "sherpa-onnx-nemo-streaming-fast-conformer-transducer-en-80ms-int8.tar.bz2",
Digest: "7bd33a914e93370a1ba9c2066d9e841bdcad8613fa2a00537c1ae15d851a14d8",
DirName: "model-nemo-streaming-fast-conformer-transducer-en-80ms-int8",
Streaming: true,
Recommended: true,
},
// Batch — transcribes once when you stop.
{
ID: "moonshine-tiny", Label: "Moonshine tiny",
Description: "fast, smallest", Bytes: 103 << 20,
AssetName: "sherpa-onnx-moonshine-tiny-en-int8.tar.bz2",
Digest: pinnedModelDigest,
DirName: "model-moonshine-tiny-en-int8",
},
{
ID: "moonshine-base", Label: "Moonshine base",
Description: "more accurate, larger", Bytes: 239 << 20,
AssetName: "sherpa-onnx-moonshine-base-en-int8.tar.bz2",
Digest: "21870cecaa2e44e4e2bf63e02d1072bed183ccd10284871353bd9d24dad14e5e",
DirName: "model-moonshine-base-en-int8",
Recommended: true,
},
{
ID: "sense-voice-zh-en-ja-ko-yue-int8-2025-09-09", Label: "SenseVoice",
Description: "multilingual, accurate", Bytes: 158 << 20,
AssetName: "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09.tar.bz2",
Digest: "7305f7905bfcf77fa0b39388a313f3da35c68d971661a65475b56fb2162c8e63",
DirName: "model-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09",
Recommended: true,
},
{
// Heavy (~1 GB) but the best transcription quality — recommended when
// accuracy matters more than speed/size. The asr-models release publishes
// no digest for it, so (like every browse-listed model) it downloads with
// TLS-only verification.
ID: "whisper-large-v3", Label: "Whisper large v3",
Description: "best accuracy, heavy", Bytes: 1019 << 20,
AssetName: "sherpa-onnx-whisper-large-v3.tar.bz2",
DirName: "model-whisper-large-v3",
Recommended: true,
},
}
}
// recommendedModelIDs marks the curated variants flagged Recommended so the full
// browse list stars (and surfaces) exactly the ones the curated shortlist does —
// keyed off the same Recommended flag so the two views never disagree.
var recommendedModelIDs = func() map[string]bool {
m := map[string]bool{}
for _, v := range ModelVariants() {
if v.Recommended {
m[v.AssetName] = true
}
}
return m
}()
// pinnedModelDigests maps a model asset name to its pinned SHA256 (the curated
// variants), so browse-listed models we happen to know get verified too.
var pinnedModelDigests = func() map[string]string {
m := map[string]string{}
for _, v := range ModelVariants() {
m[v.AssetName] = v.Digest
}
return m
}()
// curatedLabels maps a curated model's asset name to its friendly label, so the
// recommended rows read nicely in the full browse list too.
var curatedLabels = func() map[string]string {
m := map[string]string{}
for _, v := range ModelVariants() {
m[v.AssetName] = v.Label
}
return m
}()
// browseLabel turns a raw asset id into a shorter display label: it drops the
// redundant "-int8"/quantization noise and the "streaming-" prefix (the group
// header already says live vs batch).
func browseLabel(id string) string {
label := strings.TrimPrefix(id, "streaming-")
for _, drop := range []string{"-int8", ".int8", "-quantized", "-fp16"} {
label = strings.ReplaceAll(label, drop, "")
}
return label
}
// ListModels fetches every transcription model in the sherpa-onnx asr-models
// release and returns them as variants (the curated ones flagged Recommended and
// carrying a pinned digest; the rest with an empty digest, downloaded with
// TLS-only verification since that release predates GitHub's per-asset digests).
// Non-ASR assets (VAD, TTS, punctuation, speaker/keyword/…): filtered out.
func ListModels(ctx context.Context, client *http.Client, apiBase string) ([]ModelVariant, error) {
if client == nil {
client = http.DefaultClient
}
if apiBase == "" {
apiBase = defaultAPIBase
}
// Resolve the release id, then page its assets (a release can have hundreds).
relURL := fmt.Sprintf("%s/repos/%s/releases/tags/%s", strings.TrimRight(apiBase, "/"), sherpaRepo, modelReleaseTag)
var rel struct {
ID int64 `json:"id"`
Assets []githubAsset
}
if err := getJSON(ctx, client, relURL, &rel); err != nil {
return nil, fmt.Errorf("listing dictation models: %w", err)
}
assets := rel.Assets
// If the inline assets look capped, page the assets endpoint for the rest.
for page := 1; len(assets)%100 == 0 && len(assets) > 0; page++ {
var more []githubAsset
u := fmt.Sprintf("%s/repos/%s/releases/%d/assets?per_page=100&page=%d", strings.TrimRight(apiBase, "/"), sherpaRepo, rel.ID, page+1)
if err := getJSON(ctx, client, u, &more); err != nil || len(more) == 0 {
break
}
assets = append(assets, more...)
if len(more) < 100 {
break
}
}
seen := map[string]bool{}
var out []ModelVariant
for _, a := range assets {
if seen[a.Name] || !isTranscriptionModelAsset(a.Name) {
continue
}
seen[a.Name] = true
id := strings.TrimSuffix(strings.TrimPrefix(a.Name, "sherpa-onnx-"), ".tar.bz2")
digest := strings.TrimPrefix(a.Digest, "sha256:")
if digest == "" {
digest = pinnedModelDigests[a.Name] // may still be empty (browse/unverified)
}
label := browseLabel(id)
desc := modelFamily(a.Name) // readable language, e.g. "French"
if nice, ok := curatedLabels[a.Name]; ok {
label = nice
desc = curatedDescriptions[a.Name] // curated blurb ("fast, smallest")
}
out = append(out, ModelVariant{
ID: id,
Label: label,
Description: desc,
Bytes: a.Size,
AssetName: a.Name,
Digest: digest,
DirName: "model-" + id,
Streaming: strings.Contains(strings.ToLower(a.Name), "streaming-"),
Recommended: recommendedModelIDs[a.Name],
})
}
return out, nil
}
type githubAsset struct {
Name string `json:"name"`
Digest string `json:"digest"`
Size int64 `json:"size"`
}
func getJSON(ctx context.Context, client *http.Client, url string, v any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GitHub API HTTP %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(v)
}
// isTranscriptionModelAsset reports whether an asset is a speech-to-text model
// (as opposed to a VAD/TTS/punctuation/speaker/keyword/enhancement tool).
func isTranscriptionModelAsset(name string) bool {
n := strings.ToLower(name)
if !strings.HasPrefix(n, "sherpa-onnx-") || !strings.HasSuffix(n, ".tar.bz2") {
return false
}
for _, bad := range []string{
"-vad", "vad-", "tts", "punct", "speaker", "diariz", "kws", "keyword",
"audio-tagging", "denois", "source-separation", "speech-enhancement",
"spoken-language", "language-identification", "-lid", "melo", "kokoro",
"vits", "matcha", "piper", "wespeaker",
// Rockchip NPU (RKNN) builds for specific ARM boards — not CPU/ONNX, so
// the standard sherpa-onnx binary can't run them.
"rknn", "rk3566", "rk3568", "rk3562", "rk3588", "rk3576", "rk3528", "rk3399",
} {
if strings.Contains(n, bad) {
return false
}
}
for _, fam := range []string{
"zipformer", "moonshine", "whisper", "paraformer", "sense-voice",
"sensevoice", "conformer", "nemo", "canary", "parakeet", "fire-red",
"fireredasr", "dolphin", "telespeech", "wenet", "omnilingual", "funasr",
"medasr", "cohere", "qwen", "citrinet", "transducer", "-ctc-", "zipvoice",
} {
if strings.Contains(n, fam) {
return true
}
}
return false
}
// langNames maps ISO-ish language tokens in a model name to readable names.
var langNames = map[string]string{
"en": "English", "zh": "Chinese", "yue": "Cantonese", "fr": "French",
"de": "German", "es": "Spanish", "ru": "Russian", "ja": "Japanese",
"ko": "Korean", "bn": "Bengali", "ar": "Arabic", "hi": "Hindi",
"pt": "Portuguese", "nl": "Dutch", "vi": "Vietnamese", "th": "Thai",
"uk": "Ukrainian", "fa": "Persian", "pl": "Polish", "tr": "Turkish",
"cs": "Czech", "sv": "Swedish", "fil": "Filipino", "tl": "Tagalog",
"fi": "Finnish", "el": "Greek", "he": "Hebrew", "ro": "Romanian",
}
// modelFamily is a short readable descriptor for a model, favoring the language
// (the most useful thing not already obvious from the group header) and falling
// back to the family. E.g. "French", "Chinese", "Multilingual".
func modelFamily(name string) string {
n := strings.ToLower(name)
if strings.Contains(n, "multi") || strings.Contains(n, "bilingual") ||
strings.Contains(n, "trilingual") || strings.Contains(n, "polyglot") ||
strings.Contains(n, "omnilingual") {
return "Multilingual"
}
var langs []string
seen := map[string]bool{}
add := func(name string) {
if name != "" && !seen[name] {
seen[name] = true
langs = append(langs, name)
}
}
for _, tok := range strings.Split(strings.TrimSuffix(n, ".tar.bz2"), "-") {
add(langNames[tok])
}
// Full language words spelled out in some names (e.g. "-cantonese-").
for word, name := range map[string]string{
"cantonese": "Cantonese", "mandarin": "Chinese", "english": "English",
"chinese": "Chinese", "korean": "Korean", "japanese": "Japanese",
"russian": "Russian", "arabic": "Arabic", "spanish": "Spanish",
"french": "French", "german": "German", "vietnamese": "Vietnamese",
"thai": "Thai", "farsi": "Persian", "persian": "Persian",
"portuguese": "Portuguese", "italian": "Italian", "dutch": "Dutch",
"ukrainian": "Ukrainian", "polish": "Polish", "turkish": "Turkish",
} {
if strings.Contains(n, word) {
add(name)
}
}
switch {
case len(langs) == 0:
return ""
case len(langs) > 2:
return "Multilingual"
default:
return strings.Join(langs, " + ")
}
}
// curatedDescriptions maps a curated model's asset name to its quality blurb.
var curatedDescriptions = func() map[string]string {
m := map[string]string{}
for _, v := range ModelVariants() {
m[v.AssetName] = v.Description
}
return m
}()
// EngineComponents identifies the resolved local-engine paths.
type EngineComponents struct {
BinaryPath string // extracted sherpa-onnx-offline
ServerPath string // extracted sherpa-onnx-online-websocket-server
ModelPath string // extracted model directory
}
// DownloadOptions configures EnsureLocalEngine.
type DownloadOptions struct {
// DestRoot is where extracted engines/models live (e.g. ~/.config/zero/stt).
DestRoot string
// EngineVersion selects the sherpa-onnx release tag ("" → DefaultSherpaVersion;
// "latest" and any tag also work).
EngineVersion string
// HTTPClient is injectable for tests; nil uses a plain client.
HTTPClient *http.Client
// APIBase overrides the GitHub API base (tests). "" → api.github.com.
APIBase string
// Model selection (default: the Moonshine-tiny int8 batch model). Set these
// from a ModelVariant to download a different model.
ModelAssetName string
ModelPinnedDigest string
ModelDirName string
// ModelLabel is the friendly model name shown in progress ("Zipformer 20M").
ModelLabel string
// Progress receives live status strings for the UI, including a percentage
// while downloading (e.g. "Engine 45% · 57/126 MB").
Progress func(status string)
// platformKey overrides runtime detection (tests).
platformKey string
// skipPinned disables the pinned-digest cross-check (tests, which use fixture
// assets); the API-digest verification still runs.
skipPinned bool
}
// AutoDownloadSupported reports whether the current platform has a known engine
// asset (so the F9 flow can offer the download). Termux/Android and unusual
// arches install manually or use a cloud provider.
func AutoDownloadSupported() bool {
_, ok := engineAssetSuffix[platformKey()]
return ok
}
func platformKey() string { return runtime.GOOS + "-" + runtime.GOARCH }
// EnsureLocalEngine downloads (if absent) and verifies the sherpa-onnx engine and
// the default model into DestRoot, returning the resolved paths. Idempotent: an
// already-extracted, still-present engine/model is reused without re-downloading.
// Every archive is SHA256-verified against the GitHub API digest before extraction.
func EnsureLocalEngine(ctx context.Context, opts DownloadOptions) (EngineComponents, error) {
if strings.TrimSpace(opts.DestRoot) == "" {
return EngineComponents{}, fmt.Errorf("dictation download: DestRoot is required")
}
key := opts.platformKey
if key == "" {
key = platformKey()
}
suffix, ok := engineAssetSuffix[key]
if !ok {
return EngineComponents{}, &SetupError{
Tool: "sherpa-onnx auto-download",
Hint: fmt.Sprintf("no prebuilt engine is known for %s — install sherpa-onnx manually or use a cloud provider (see docs/dictation.md)", key),
}
}
version := strings.TrimSpace(opts.EngineVersion)
if version == "" {
version = DefaultSherpaVersion
}
progress := opts.Progress
if progress == nil {
progress = func(string) {}
}
client := opts.HTTPClient
if client == nil {
client = http.DefaultClient
}
apiBase := opts.APIBase
if apiBase == "" {
apiBase = defaultAPIBase
}
engineDir := filepath.Join(opts.DestRoot, "engine-"+version+"-"+key)
targetWindows := strings.HasPrefix(key, "windows-")
// Resolve through the tarball's flattened subdir so an ALREADY-extracted
// engine is found and not needlessly re-downloaded (the idempotency check).
binPath, serverPath := resolveEnginePaths(engineDir, targetWindows)
if !fileExists(binPath) {
pinned := ""
if version == DefaultSherpaVersion && !opts.skipPinned {
pinned = pinnedEngineDigest[key]
}
asset, err := resolveAsset(ctx, client, apiBase, version, "sherpa-onnx-", suffix)
if err != nil {
return EngineComponents{}, err
}
if err := downloadVerifyExtract(ctx, client, asset, pinned, false, "Engine", engineDir, progress); err != nil {
return EngineComponents{}, err
}
binPath, serverPath = resolveEnginePaths(engineDir, targetWindows)
}
if !fileExists(binPath) {
return EngineComponents{}, fmt.Errorf("dictation download: engine binary not found after extraction in %s", engineDir)
}
modelName := opts.ModelAssetName
if modelName == "" {
modelName = modelAssetName
}
modelDirName := opts.ModelDirName
if modelDirName == "" {
modelDirName = "model-moonshine-tiny-en-int8"
}
modelDir := filepath.Join(opts.DestRoot, modelDirName)
if !dirHasModel(modelDir) {
asset, err := resolveAsset(ctx, client, apiBase, modelReleaseTag, modelName, "")
if err != nil {
return EngineComponents{}, err
}
modelPinned := opts.ModelPinnedDigest
// Only fall back to the built-in digest for the DEFAULT model — a different
// (browse-listed) model must never be checked against the default's digest.
if modelPinned == "" && opts.ModelAssetName == "" {
modelPinned = pinnedModelDigest
}
if opts.skipPinned {
modelPinned = ""
}
modelLabel := opts.ModelLabel
if modelLabel == "" {
modelLabel = "Model"
}
// Models are data files from the official release: verify against a digest
// when we have one (curated/pinned or API-provided), else allow a TLS-only
// download (the model release predates GitHub's per-asset digests). The
// engine BINARY above never allows this — a native executable is always
// digest-verified.
if err := downloadVerifyExtract(ctx, client, asset, modelPinned, true, modelLabel, modelDir, progress); err != nil {
return EngineComponents{}, err
}
}
resolvedModel := modelDir
if !hasTokensFile(resolvedModel) {
if child, err := flattenSingleChild(modelDir); err == nil {
resolvedModel = child
}
}
if !hasTokensFile(resolvedModel) {
return EngineComponents{}, fmt.Errorf("dictation download: model files not found after extraction in %s", modelDir)
}
return EngineComponents{BinaryPath: binPath, ServerPath: serverPath, ModelPath: resolvedModel}, nil
}
type resolvedAsset struct {
url string
sha256 string
size int64
name string
}
// resolveAsset queries the GitHub release for tag and returns the asset whose
// name has the given prefix and suffix, plus its SHA256 digest and size.
func resolveAsset(ctx context.Context, client *http.Client, apiBase, tag, namePrefix, nameSuffix string) (resolvedAsset, error) {
tagPath := "tags/" + tag
if tag == "" || tag == "latest" {
tagPath = "latest"
}
url := fmt.Sprintf("%s/repos/%s/releases/%s", strings.TrimRight(apiBase, "/"), sherpaRepo, tagPath)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return resolvedAsset{}, err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
return resolvedAsset{}, fmt.Errorf("resolving sherpa-onnx release %s: %w", tag, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return resolvedAsset{}, fmt.Errorf("resolving sherpa-onnx release %s: GitHub API HTTP %d", tag, resp.StatusCode)
}
var release struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
DownloadURL string `json:"browser_download_url"`
Digest string `json:"digest"`
Size int64 `json:"size"`
} `json:"assets"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return resolvedAsset{}, fmt.Errorf("parsing sherpa-onnx release %s: %w", tag, err)
}
for _, a := range release.Assets {
if !strings.HasPrefix(a.Name, namePrefix) || !strings.HasSuffix(a.Name, nameSuffix) {
continue
}
// The API digest may be empty for older assets (GitHub only populates it for
// assets uploaded after ~2024 — the ASR models predate that). An empty
// digest is fine here; downloadVerifyExtract requires the API digest OR a
// pinned one and refuses only when neither exists.
digest := strings.TrimPrefix(a.Digest, "sha256:")
return resolvedAsset{url: a.DownloadURL, sha256: digest, size: a.Size, name: a.Name}, nil
}
return resolvedAsset{}, &SetupError{
Tool: "sherpa-onnx auto-download",
Hint: fmt.Sprintf("release %s has no asset matching %s*%s — try a different stt.engineVersion or install manually", tag, namePrefix, nameSuffix),
}
}
// downloadVerifyExtract fetches the asset to a temp file, verifies its SHA256
// (against the API digest, and — when pinned is set — a cross-check that the
// resolved digest equals the audited value), and extracts the tar.bz2. A
// mismatch aborts before anything is extracted or run.
func downloadVerifyExtract(ctx context.Context, client *http.Client, asset resolvedAsset, pinned string, allowUnverified bool, label, destDir string, progress func(string)) error {
// Determine the digest to verify against: the API digest and the pinned digest
// must agree when both exist; at least one must exist unless allowUnverified
// (models are data files from the official release — TLS-only is acceptable;
// a native executable never sets allowUnverified).
expected := asset.sha256
if pinned != "" {
if expected != "" && !strings.EqualFold(expected, pinned) {
return fmt.Errorf("dictation download: %s digest %s does not match the pinned known-good value %s — refusing (the release may have changed)", asset.name, asset.sha256, pinned)
}
expected = pinned
}
if expected == "" && !allowUnverified {
return fmt.Errorf("dictation download: no SHA256 available for %s (neither the GitHub API nor a pinned value) — refusing an unverifiable download", asset.name)
}
// Ensure the parent of destDir exists; the final destDir is created by
// the atomic rename at the end of the function.
if err := os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil {
return err
}
// Stage the extraction into a sibling temp dir and only swap it into
// destDir on success. An interrupted run that left a truncated engine
// binary in destDir would otherwise pass fileExists() on the next start
// and break the local recorder silently.
stageDir, err := os.MkdirTemp(filepath.Dir(destDir), filepath.Base(destDir)+".stage-*")
if err != nil {
return err
}
cleanupStage := true
defer func() {
if cleanupStage {
_ = os.RemoveAll(stageDir)
}
}()
tmp, err := os.CreateTemp(filepath.Dir(destDir), "stt-dl-*.tar.bz2")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.url, nil)
if err != nil {
tmp.Close()
return err
}
resp, err := client.Do(req)
if err != nil {
tmp.Close()
return fmt.Errorf("downloading %s: %w", asset.name, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
tmp.Close()
return fmt.Errorf("downloading %s: HTTP %d", asset.name, resp.StatusCode)
}
hasher := sha256.New()
total := asset.size
if total <= 0 {
total = resp.ContentLength
}
src := io.Reader(resp.Body)
if total > 0 {
src = &progressReader{r: resp.Body, total: total, label: label, report: progress, lastPct: -1}
}
if _, err := io.Copy(io.MultiWriter(tmp, hasher), src); err != nil {
tmp.Close()
return fmt.Errorf("downloading %s: %w", asset.name, err)
}
if err := tmp.Close(); err != nil {
return err
}
got := hex.EncodeToString(hasher.Sum(nil))
if expected != "" && !strings.EqualFold(got, expected) {
return fmt.Errorf("dictation download: checksum mismatch for %s (want %s, got %s) — refusing to extract", asset.name, expected, got)
}
if err := extractTarBz2(tmpPath, stageDir, "Extracting "+label, progress); err != nil {
return err
}
// Remove any pre-existing destDir so Rename is atomic on the same
// filesystem (os.Rename refuses to overwrite a non-empty dir).
if err := os.RemoveAll(destDir); err != nil {
return fmt.Errorf("clearing previous %s install: %w", label, err)
}
if err := os.Rename(stageDir, destDir); err != nil {
return fmt.Errorf("promoting staged %s: %w", label, err)
}
cleanupStage = false
return nil
}
// progressReader reports download progress as a percentage on each whole-percent
// change, formatted "<label> NN% · X/Y MB".
type progressReader struct {
r io.Reader
total int64
read int64
label string
report func(string)
lastPct int
}
func (p *progressReader) Read(b []byte) (int, error) {
n, err := p.r.Read(b)
p.read += int64(n)
if p.total > 0 {
pct := int(p.read * 100 / p.total)
if pct != p.lastPct {
p.lastPct = pct
p.report(fmt.Sprintf("%s %d%% · %d/%d MB", p.label, pct, p.read>>20, p.total>>20))
}
}
return n, err
}
// enginePaths builds the engine executable paths. The ".exe" suffix follows the
// TARGET platform (the one the engine was downloaded for), not the host — they
// match in production (you download your own platform), but keying off the target
// keeps resolution correct when the two differ (e.g. a cross-platform test).
func enginePaths(engineDir string, targetWindows bool) (bin, server string) {
ext := ""
if targetWindows {
ext = ".exe"
}
return filepath.Join(engineDir, "bin", "sherpa-onnx-offline"+ext),
filepath.Join(engineDir, "bin", "sherpa-onnx-online-websocket-server"+ext)
}
// resolveEnginePaths finds the executables, flattening the tarball's single
// top-level directory when present.
func resolveEnginePaths(engineDir string, targetWindows bool) (bin, server string) {
bin, server = enginePaths(engineDir, targetWindows)
if fileExists(bin) {
return bin, server
}
if child, err := flattenSingleChild(engineDir); err == nil && child != engineDir {
return enginePaths(child, targetWindows)
}
return bin, server
}
// extractTarBz2 unpacks a bzip2-compressed tar into destDir, guarding against
// path-traversal entries. It reports extraction progress by how much of the
// (compressed) archive it has consumed — the same MB scale as the download, so
// "Extracting … 50% · 520/1041 MB" lines up with the download that preceded it.
func extractTarBz2(archivePath, destDir, label string, progress func(string)) error {
f, err := os.Open(archivePath)
if err != nil {
return err
}
defer f.Close()
var src io.Reader = f
if progress != nil {
if info, statErr := f.Stat(); statErr == nil && info.Size() > 0 {
src = &progressReader{r: f, total: info.Size(), label: label, report: progress, lastPct: -1}
}
}
tr := tar.NewReader(bzip2.NewReader(src))
destAbs, err := filepath.Abs(destDir)
if err != nil {
return err
}
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
target := filepath.Join(destDir, hdr.Name)
absTarget, err := filepath.Abs(target)
if err != nil {
return err
}
if absTarget != destAbs && !strings.HasPrefix(absTarget, destAbs+string(os.PathSeparator)) {
return fmt.Errorf("dictation download: archive entry %q escapes destination", hdr.Name)
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode)&0o777)
if err != nil {
return err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
}
}
}
// flattenSingleChild returns the sole subdirectory of dir (the tarball's
// top-level folder) when dir contains exactly one directory entry.
func flattenSingleChild(dir string) (string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
var dirs []string
for _, e := range entries {
if e.IsDir() {
dirs = append(dirs, e.Name())
}
}
if len(dirs) == 1 {
return filepath.Join(dir, dirs[0]), nil
}
return dir, nil
}
// ModelDownloaded reports whether a model variant is already extracted under
// destRoot (so the picker can mark it as installed). dirName is the variant's
// DirName.
func ModelDownloaded(destRoot, dirName string) bool {
if destRoot == "" || dirName == "" {
return false
}
return dirHasModel(filepath.Join(destRoot, dirName))
}
// EngineDownloaded reports whether the shared sherpa-onnx engine is already on
// disk for this platform (one engine serves every model), so the picker can drop
// the engine's size from a model's download total once it's been fetched.
func EngineDownloaded(destRoot, version string) bool {
if destRoot == "" {
return false
}
if version == "" {
version = DefaultSherpaVersion
}
engineDir := filepath.Join(destRoot, "engine-"+version+"-"+platformKey())
bin, _ := resolveEnginePaths(engineDir, runtime.GOOS == "windows")
return fileExists(bin)
}
func dirHasModel(dir string) bool {
if hasTokensFile(dir) {
return true
}
if child, err := flattenSingleChild(dir); err == nil && child != dir {
return hasTokensFile(child)
}
return false
}
// hasTokensFile reports whether dir holds a sherpa tokens file — usually
// "tokens.txt", but some families (Whisper) prefix it as "<model>-tokens.txt".
func hasTokensFile(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), "tokens.txt") {
return true
}
}
return false
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
-52
View File
@@ -1,52 +0,0 @@
package dictation
import (
"context"
"os"
"path/filepath"
"testing"
)
// TestEnsureLocalEngineRealDownload exercises the full auto-download against the
// REAL k2-fsa/sherpa-onnx GitHub API (resolve → verify digest → extract → run).
// It downloads ~130MB, so it is opt-in via ZERO_STT_DOWNLOAD_TEST=1.
//
// ZERO_STT_DOWNLOAD_TEST=1 go test ./internal/dictation/ -run RealDownload -v -timeout 10m
func TestEnsureLocalEngineRealDownload(t *testing.T) {
if os.Getenv("ZERO_STT_DOWNLOAD_TEST") == "" {
t.Skip("set ZERO_STT_DOWNLOAD_TEST=1 to run the real ~130MB download smoke test")
}
dest := t.TempDir()
comp, err := EnsureLocalEngine(context.Background(), DownloadOptions{
DestRoot: dest,
Progress: func(s string) { t.Logf("stage: %s", s) },
})
if err != nil {
t.Fatalf("EnsureLocalEngine: %v", err)
}
if !fileExists(comp.BinaryPath) || !fileExists(filepath.Join(comp.ModelPath, "tokens.txt")) {
t.Fatalf("missing components: %+v", comp)
}
// The downloaded engine + model must actually transcribe.
wav := filepath.Join(comp.ModelPath, "test_wavs", "0.wav")
if !fileExists(wav) {
t.Skipf("no bundled test wav at %s", wav)
}
audio, err := os.ReadFile(wav)
if err != nil {
t.Fatal(err)
}
tr, err := NewLocalTranscriber(LocalConfig{Binary: comp.BinaryPath, ModelPath: comp.ModelPath})
if err != nil {
t.Fatal(err)
}
text, err := tr.Transcribe(context.Background(), audio)
if err != nil {
t.Fatalf("Transcribe with downloaded engine: %v", err)
}
if text == "" {
t.Fatal("downloaded engine returned empty transcript")
}
t.Logf("downloaded-engine transcript: %q", text)
}
-245
View File
@@ -1,245 +0,0 @@
package dictation
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// Tiny tar.bz2 fixtures (generated in-repo): the engine has top/bin/sherpa-onnx-
// offline + server; the model has top/tokens.txt.
const (
engineTarB64 = "QlpoOTFBWSZTWW+fEfAAAW1/hMGRAIBAA/+AAgAgRH9v3+AAAQCoMAEYAYA00NGjEZANABoYYA00NGjEZANABoYIpE0TRoTGkA2poGgxqepl1H6w5anW3tY2OggUi1AW/d29pO0GLUoOtnQsrGFNowIEh0cImtQ9gyHqYH7unm+/wZSLXsBuBDDCfHiahCsGEgGL9/pUG+RQZo76xlaDIGKWDErPKuyhXYOlzJMG3iNa56kiluctzUqgZ2A1AGwgcB3jtB5PB7pYYlDHH8OvOznM6B6MHPRqa7sTE1vwDBc0LjmG4ah5F+YYaXHTZpGmcemWW1yMh1adhdrB6A3nAQP8XckU4UJBvnxHwA=="
engineSHA = "97b5cb6a417705860f8940e1bf9ffec8cc60e8666a0d228cd60f1de7607d503d"
modelTarB64 = "QlpoOTFBWSZTWTtAoX4AANp/hMOQBIBAAf+AAAIQBHoJ3mAAAQAIMAC5sG1SNMhNqGmQwIyeKEagam1DQ0AAABFRT0T0mg0aeoDQHpH7Nzrs24wsbVW8hMQHClNI0LuLhKMGY4sEQi6N2qQWP1id8o1KtprGd4hll4uQkMGA1nFMXAwohEZOnIMIPQKQl2k0GbSGwkC0lJb2IWOQ57ETYbmxFGspaRp1QyT6q4sKj855st7GzOGj5eIwmWt6wRajEQl/F3JFOFCQO0Chfg=="
modelSHA = "16c6b1dcb40f6b21c0de70bc1a8edab95375e4de90dac7c076fd2cc7a34fc59a"
)
// fakeReleaseServer serves the GitHub release API + asset bytes for the engine
// and model, exercising resolveAsset + downloadVerifyExtract against a real HTTP
// round-trip (with the real coder/websocket-free download path).
func fakeReleaseServer(t *testing.T, engineDigest, modelDigest string) *httptest.Server {
t.Helper()
engineBytes, _ := base64.StdEncoding.DecodeString(engineTarB64)
modelBytes, _ := base64.StdEncoding.DecodeString(modelTarB64)
mux := http.NewServeMux()
base := ""
release := func(assetName, digest, dlPath string) string {
body, _ := json.Marshal(map[string]any{
"tag_name": "test",
"assets": []map[string]any{{
"name": assetName,
"browser_download_url": base + dlPath,
"digest": "sha256:" + digest,
"size": 123,
}},
})
return string(body)
}
var srv *httptest.Server
// Any release tag under this prefix resolves: asr-models → the model, every
// other tag → the engine (so the default-version pinned-check test works too).
mux.HandleFunc("/repos/k2-fsa/sherpa-onnx/releases/tags/", func(w http.ResponseWriter, r *http.Request) {
base = srv.URL
if strings.HasSuffix(r.URL.Path, "/asr-models") {
fmt.Fprint(w, release(modelAssetName, modelDigest, "/model.tar.bz2"))
return
}
fmt.Fprint(w, release("sherpa-onnx-test-linux-x64-shared-no-tts.tar.bz2", engineDigest, "/engine.tar.bz2"))
})
mux.HandleFunc("/engine.tar.bz2", func(w http.ResponseWriter, r *http.Request) { w.Write(engineBytes) })
mux.HandleFunc("/model.tar.bz2", func(w http.ResponseWriter, r *http.Request) { w.Write(modelBytes) })
srv = httptest.NewServer(mux)
base = srv.URL
t.Cleanup(srv.Close)
return srv
}
func TestResolveEnginePathsHonorsTargetPlatform(t *testing.T) {
// The engine binary name follows the TARGET platform, not the host: a Windows
// engine has ".exe", a Linux one doesn't. Resolution must find each regardless
// of the OS the test runs on (this is what makes the cross-platform download
// test pass on Windows CI).
for _, tc := range []struct {
name string
target bool
binName string
}{
{"linux target", false, "sherpa-onnx-offline"},
{"windows target", true, "sherpa-onnx-offline.exe"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(binDir, tc.binName), []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
bin, _ := resolveEnginePaths(dir, tc.target)
if !fileExists(bin) {
t.Errorf("resolveEnginePaths(target-windows=%v) = %q; not found", tc.target, bin)
}
if !strings.HasSuffix(bin, tc.binName) {
t.Errorf("bin = %q, want suffix %q", bin, tc.binName)
}
})
}
}
func TestEnsureLocalEngineDownloadsAndExtracts(t *testing.T) {
srv := fakeReleaseServer(t, engineSHA, modelSHA)
dest := t.TempDir()
var stages []string
comp, err := EnsureLocalEngine(context.Background(), DownloadOptions{
DestRoot: dest,
EngineVersion: "test",
APIBase: srv.URL,
platformKey: "linux-amd64",
skipPinned: true,
Progress: func(s string) { stages = append(stages, s) },
})
if err != nil {
t.Fatalf("EnsureLocalEngine: %v", err)
}
if !fileExists(comp.BinaryPath) || !strings.HasSuffix(comp.BinaryPath, "sherpa-onnx-offline") {
t.Errorf("binary path wrong: %q", comp.BinaryPath)
}
if !fileExists(comp.ServerPath) {
t.Errorf("server path missing: %q", comp.ServerPath)
}
if !fileExists(filepath.Join(comp.ModelPath, "tokens.txt")) {
t.Errorf("model tokens.txt missing under %q", comp.ModelPath)
}
// The extracted binary keeps its exec bit — Unix only; Windows has no
// executable permission bit, so os.FileMode never reports 0o100 there.
if runtime.GOOS != "windows" {
if info, err := os.Stat(comp.BinaryPath); err == nil && info.Mode()&0o100 == 0 {
t.Error("engine binary should be executable")
}
}
if len(stages) == 0 {
t.Error("expected progress stages")
}
// Idempotent: a second call reuses the extracted engine + model and downloads
// NOTHING (no progress stages) — the fix for re-downloading an already-present
// engine whose binary lives in the tarball's flattened subdir.
var stages2 []string
comp2, err := EnsureLocalEngine(context.Background(), DownloadOptions{
DestRoot: dest, EngineVersion: "test", APIBase: srv.URL, platformKey: "linux-amd64", skipPinned: true,
Progress: func(s string) { stages2 = append(stages2, s) },
})
if err != nil || comp2.BinaryPath != comp.BinaryPath {
t.Errorf("second call should reuse: %v / %q vs %q", err, comp2.BinaryPath, comp.BinaryPath)
}
if len(stages2) != 0 {
t.Errorf("a fully-cached second call must not download/extract anything, got stages: %v", stages2)
}
}
func TestEnsureLocalEngineRejectsChecksumMismatch(t *testing.T) {
// Serve the engine with a wrong digest → verification must refuse to extract.
srv := fakeReleaseServer(t, strings.Repeat("0", 64), modelSHA)
_, err := EnsureLocalEngine(context.Background(), DownloadOptions{
DestRoot: t.TempDir(), EngineVersion: "test", APIBase: srv.URL, platformKey: "linux-amd64", skipPinned: true,
})
if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("expected checksum-mismatch error, got %v", err)
}
}
func TestEnsureLocalEnginePinnedCrossCheckRefusesChangedRelease(t *testing.T) {
// Default version + a digest that doesn't match the pinned value → refuse,
// even though the download itself would verify against the API digest.
srv := fakeReleaseServer(t, engineSHA, modelSHA)
_, err := EnsureLocalEngine(context.Background(), DownloadOptions{
DestRoot: t.TempDir(),
EngineVersion: DefaultSherpaVersion, // triggers the pinned cross-check
APIBase: srv.URL,
platformKey: "linux-amd64",
// skipPinned false: the pinned digest for linux-amd64 != engineSHA fixture.
})
if err == nil || !strings.Contains(err.Error(), "pinned known-good") {
t.Fatalf("expected pinned cross-check refusal, got %v", err)
}
}
func TestResolveAssetMissingDigestAllowed(t *testing.T) {
// An older asset with no API digest resolves fine (verification against a
// pinned value happens later); resolveAsset no longer refuses here.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"assets": []map[string]any{{"name": "x-linux-x64-shared-no-tts.tar.bz2", "browser_download_url": "http://x", "digest": ""}},
})
}))
defer srv.Close()
asset, err := resolveAsset(context.Background(), http.DefaultClient, srv.URL, "test", "x-", "linux-x64-shared-no-tts.tar.bz2")
if err != nil {
t.Fatalf("resolveAsset should tolerate a missing digest, got %v", err)
}
if asset.sha256 != "" {
t.Errorf("expected empty digest, got %q", asset.sha256)
}
}
func TestUnverifiableDownloadRefused(t *testing.T) {
// No API digest AND no pinned digest → refuse to download/extract.
srv := fakeReleaseServer(t, "", "") // both assets served with empty digest
_, err := EnsureLocalEngine(context.Background(), DownloadOptions{
DestRoot: t.TempDir(), EngineVersion: "test", APIBase: srv.URL, platformKey: "linux-amd64", skipPinned: true,
})
if err == nil || !strings.Contains(err.Error(), "unverifiable") {
t.Fatalf("expected refusal of an unverifiable download, got %v", err)
}
}
func TestProgressReaderReportsPercent(t *testing.T) {
data := make([]byte, 1000)
var last string
pr := &progressReader{r: bytesReader(data), total: 1000, label: "Engine", report: func(s string) { last = s }, lastPct: -1}
buf := make([]byte, 100)
for {
if _, err := pr.Read(buf); err != nil {
break
}
}
if !strings.Contains(last, "Engine") || !strings.Contains(last, "100%") {
t.Errorf("expected a final 'Engine 100%%' report, got %q", last)
}
}
func bytesReader(b []byte) *sliceReader { return &sliceReader{b: b} }
type sliceReader struct {
b []byte
pos int
}
func (s *sliceReader) Read(p []byte) (int, error) {
if s.pos >= len(s.b) {
return 0, io.EOF
}
n := copy(p, s.b[s.pos:])
s.pos += n
return n, nil
}
func TestUnsupportedPlatformSetupError(t *testing.T) {
_, err := EnsureLocalEngine(context.Background(), DownloadOptions{DestRoot: t.TempDir(), platformKey: "plan9-mips"})
var setupErr *SetupError
if !errors.As(err, &setupErr) {
t.Fatalf("want *SetupError for unsupported platform, got %v", err)
}
}
-67
View File
@@ -1,67 +0,0 @@
package dictation
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestListModelsFiltersAndFlags(t *testing.T) {
assets := []map[string]any{
{"name": "sherpa-onnx-streaming-zipformer-en-2023-06-26.tar.bz2", "size": 1 << 20, "digest": "sha256:abc"},
{"name": "sherpa-onnx-whisper-tiny.en.tar.bz2", "size": 2 << 20},
{"name": "sherpa-onnx-moonshine-tiny-en-int8.tar.bz2", "size": 3 << 20}, // curated but not flagged recommended
{"name": "sherpa-onnx-streaming-zipformer-en-kroko-2025-08-06.tar.bz2", "size": 1 << 20}, // curated → recommended
{"name": "sherpa-onnx-paraformer-zh-2024-03-09.tar.bz2", "size": 4 << 20},
// Non-ASR tools that MUST be filtered out:
{"name": "sherpa-onnx-vad-silero.tar.bz2", "size": 1},
{"name": "vits-piper-en_US-amy.tar.bz2", "size": 1},
{"name": "sherpa-onnx-punct-en.tar.bz2", "size": 1},
{"name": "sherpa-onnx-kws-zipformer.tar.bz2", "size": 1},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{"id": 1, "assets": assets})
}))
defer srv.Close()
vs, err := ListModels(context.Background(), http.DefaultClient, srv.URL)
if err != nil {
t.Fatal(err)
}
if len(vs) != 5 {
t.Fatalf("expected 5 transcription models (tools filtered), got %d", len(vs))
}
byID := map[string]ModelVariant{}
for _, v := range vs {
byID[v.ID] = v
}
if !byID["streaming-zipformer-en-2023-06-26"].Streaming {
t.Error("streaming model should be flagged Streaming")
}
if byID["whisper-tiny.en"].Streaming {
t.Error("whisper is batch, not streaming")
}
if !byID["streaming-zipformer-en-kroko-2025-08-06"].Recommended {
t.Error("a curated model flagged Recommended should be Recommended")
}
if byID["moonshine-tiny-en-int8"].Recommended {
t.Error("moonshine tiny is curated but no longer flagged Recommended (no star)")
}
if byID["paraformer-zh-2024-03-09"].Recommended {
t.Error("an uncurated model should not be Recommended")
}
// A curated model with no API digest still gets its pinned digest.
if byID["moonshine-tiny-en-int8"].Digest == "" {
t.Error("curated model should carry a pinned digest")
}
// An uncurated model with no API digest has an empty digest (TLS-only download).
if byID["whisper-tiny.en"].Digest != "" {
t.Error("uncurated model without an API digest should have an empty digest")
}
// The API-provided digest is used when present.
if byID["streaming-zipformer-en-2023-06-26"].Digest != "abc" {
t.Errorf("expected the API digest, got %q", byID["streaming-zipformer-en-2023-06-26"].Digest)
}
}
-40
View File
@@ -1,40 +0,0 @@
package dictation
import (
"context"
"os"
"testing"
)
// TestLocalTranscribeRealBinary exercises the full local batch path against a
// REAL sherpa-onnx-offline binary and model — the manual smoke test §14 calls
// out as not CI-able. Skipped unless both env vars point at a real install:
//
// ZERO_STT_TEST_BINARY=/path/to/sherpa-onnx-offline \
// ZERO_STT_TEST_MODEL=/path/to/model-dir \
// ZERO_STT_TEST_WAV=/path/to/16k-mono.wav \
// go test ./internal/dictation/ -run TestLocalTranscribeRealBinary -v
func TestLocalTranscribeRealBinary(t *testing.T) {
binary := os.Getenv("ZERO_STT_TEST_BINARY")
model := os.Getenv("ZERO_STT_TEST_MODEL")
wav := os.Getenv("ZERO_STT_TEST_WAV")
if binary == "" || model == "" || wav == "" {
t.Skip("set ZERO_STT_TEST_BINARY, ZERO_STT_TEST_MODEL, ZERO_STT_TEST_WAV to run the real-engine smoke test")
}
audio, err := os.ReadFile(wav)
if err != nil {
t.Fatalf("read wav: %v", err)
}
tr, err := NewLocalTranscriber(LocalConfig{Binary: binary, ModelPath: model})
if err != nil {
t.Fatalf("NewLocalTranscriber: %v", err)
}
text, err := tr.Transcribe(context.Background(), audio)
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if text == "" {
t.Fatal("real engine returned an empty transcript")
}
t.Logf("transcript: %q", text)
}
-24
View File
@@ -1,24 +0,0 @@
//go:build !windows
package dictation
import (
"fmt"
"os"
"syscall"
)
// checkAudioDirOwner rejects a dictation temp directory not owned by the
// current user — on a shared /tmp another user could have pre-created the
// path and would then control its lifetime. Mirrors
// internal/tools/spill_owner_unix.go.
func checkAudioDirOwner(info os.FileInfo) error {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return nil
}
if int(stat.Uid) != os.Geteuid() {
return fmt.Errorf("dictation temp directory is owned by uid %d, not the current user", stat.Uid)
}
return nil
}
-12
View File
@@ -1,12 +0,0 @@
//go:build windows
package dictation
import "os"
// checkAudioDirOwner is a no-op on Windows: %TEMP% is per-user by default and
// there is no portable uid to compare. The Lstat symlink/dir check in
// audioSpillDir still applies. Mirrors internal/tools/spill_owner_windows.go.
func checkAudioDirOwner(os.FileInfo) error {
return nil
}
-581
View File
@@ -1,581 +0,0 @@
package dictation
import (
"context"
"errors"
"fmt"
"math"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
// Recorder captures microphone audio. Start/Stop is the batch path (used on
// Termux, and as the fallback everywhere); StartStreaming is the desktop-only
// continuous-PCM path.
type Recorder interface {
Start() error
// Stop ends a batch recording and returns the recorded audio bytes (WAV on
// desktop, M4A on Termux — SniffFormat distinguishes them).
Stop() ([]byte, error)
// StartStreaming begins continuous capture of raw little-endian PCM16 mono
// at the configured sample rate. Chunks arrive on the channel in ~50ms
// units; the channel closes when capture ends (stop called, max duration
// hit, or trailing-silence auto-stop). stop is idempotent.
StartStreaming() (chunks <-chan []byte, stop func() error, err error)
}
// Platform selects the audio-capture toolchain (§9 of the design doc).
type Platform string
const (
PlatformLinux Platform = "linux"
PlatformDarwin Platform = "darwin"
PlatformWindows Platform = "windows"
PlatformTermux Platform = "termux"
PlatformUnsupported Platform = ""
)
// DetectPlatform picks the capture toolchain for the current host. Termux is
// detected by its environment (it reports GOOS=linux/android): TERMUX_VERSION
// is exported to every Termux shell, and PREFIX carries the app package path.
func DetectPlatform() Platform {
if os.Getenv("TERMUX_VERSION") != "" || strings.Contains(os.Getenv("PREFIX"), "com.termux") {
return PlatformTermux
}
switch runtime.GOOS {
case "linux":
return PlatformLinux
case "darwin":
return PlatformDarwin
case "windows":
return PlatformWindows
}
return PlatformUnsupported
}
// StreamingSupported reports whether the platform's capture tool can write raw
// PCM to stdout. Termux's termux-microphone-record requires a regular output
// file — a hard tool limitation, so Termux is batch-only permanently.
func (p Platform) StreamingSupported() bool {
switch p {
case PlatformLinux, PlatformDarwin, PlatformWindows:
return true
}
return false
}
const (
// DefaultSampleRate is what the local engine and Deepgram consume.
// OpenAI Realtime's pcm16 requires 24kHz; its transcriber constructs the
// recorder accordingly.
DefaultSampleRate = 16000
// DefaultMaxDuration is the hard runaway-recording cap (§12), applied on
// every platform, batch and streaming. Overridable via
// stt.maxDurationSeconds.
DefaultMaxDuration = 5 * time.Minute
// Trailing-silence auto-stop (§9): stop ~2s after the signal falls below a
// 3% amplitude threshold — SoX's own `silence 1 0.1 3% 1 2.0 3%` numbers.
silenceThresholdPct = 3
silenceHold = 2 * time.Second
chunkInterval = 50 * time.Millisecond
stopGrace = 3 * time.Second
)
// RecorderOptions configures a Recorder. Zero values pick sensible defaults.
type RecorderOptions struct {
Platform Platform
SampleRate int
MaxDuration time.Duration
SilenceAutoStop bool
// WindowsAudioDevice is the dshow capture device name; auto-detected from
// `ffmpeg -list_devices` when empty.
WindowsAudioDevice string
// Test seams (unexported): fake the OS boundary, mirroring how
// internal/lsp/manager_test.go stubs serverStarter.
starter processStarter
runOutput commandOutputRunner
tempDir func() (string, error)
now func() time.Time
}
func (o RecorderOptions) withDefaults() RecorderOptions {
if o.Platform == PlatformUnsupported {
o.Platform = DetectPlatform()
}
if o.SampleRate <= 0 {
o.SampleRate = DefaultSampleRate
}
if o.MaxDuration <= 0 {
o.MaxDuration = DefaultMaxDuration
}
if o.starter == nil {
o.starter = startProcess
}
if o.runOutput == nil {
o.runOutput = runCommandOutput
}
if o.tempDir == nil {
o.tempDir = audioSpillDir
}
if o.now == nil {
o.now = time.Now
}
return o
}
// NewRecorder builds the platform recorder. Construction is cheap and
// side-effect free; tools are only exec'd on Start/StartStreaming.
func NewRecorder(opts RecorderOptions) Recorder {
o := opts.withDefaults()
return &recorder{opts: o}
}
type recorder struct {
opts RecorderOptions
mu sync.Mutex
proc processHandle
filePath string
recording bool
}
var errAlreadyRecording = errors.New("a recording is already in progress")
// Start begins a batch (record-to-file) capture.
func (r *recorder) Start() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.recording {
return errAlreadyRecording
}
if r.opts.Platform == PlatformUnsupported {
return fmt.Errorf("dictation is not supported on %s", runtime.GOOS)
}
dir, err := r.opts.tempDir()
if err != nil {
return fmt.Errorf("preparing dictation temp dir: %w", err)
}
ext := ".wav"
if r.opts.Platform == PlatformTermux {
ext = ".m4a"
}
tmp, err := os.CreateTemp(dir, "rec-*"+ext)
if err != nil {
return fmt.Errorf("creating dictation temp file: %w", err)
}
path := tmp.Name()
_ = tmp.Close()
if r.opts.Platform == PlatformTermux {
// termux-microphone-record starts the recording in the Termux:API
// companion app and exits immediately; there is no long-lived process
// to hold. Stop is a separate `-q` invocation.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := r.opts.runOutput(ctx, "termux-microphone-record",
"-l", strconv.Itoa(maxSeconds(r.opts.MaxDuration)), "-f", path)
if err != nil {
_ = os.Remove(path)
return startError("termux-microphone-record", r.opts.Platform, err, out)
}
r.filePath = path
r.recording = true
return nil
}
spec, err := batchCommand(r.opts, path)
if err != nil {
_ = os.Remove(path)
return err
}
proc, _, err := r.opts.starter(spec)
if err != nil {
_ = os.Remove(path)
return startError(spec.name, r.opts.Platform, err, nil)
}
r.proc = proc
r.filePath = path
r.recording = true
return nil
}
// Stop ends a batch capture, reads the recorded file, deletes it, and returns
// the audio bytes.
func (r *recorder) Stop() ([]byte, error) {
r.mu.Lock()
defer r.mu.Unlock()
if !r.recording {
return nil, errors.New("no recording in progress")
}
proc, path := r.proc, r.filePath
// Termux: Start's termux-microphone-record branch leaves r.proc nil because
// there's no long-lived process to hold (Stop is a separate `-q` invocation
// via runOutput). Treat that as legitimate; only reject a nil proc that
// came from StartStreaming (which owns its process via the returned
// closure).
if proc == nil && r.opts.Platform != PlatformTermux {
return nil, errors.New("recording was started with StartStreaming; call the stop closure returned from StartStreaming instead of Stop()")
}
r.proc, r.filePath, r.recording = nil, "", false
defer os.Remove(path)
if r.opts.Platform == PlatformTermux {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if out, err := r.opts.runOutput(ctx, "termux-microphone-record", "-q"); err != nil {
return nil, fmt.Errorf("stopping termux recording: %w (%s)", err, strings.TrimSpace(string(out)))
}
return readRecordingWithRetry(path, r.opts.now)
}
if err := proc.StopGracefully(); err != nil {
_ = proc.Kill()
}
// A signal-terminated capture tool reports a non-zero exit — expected, not
// an error. The recorded file's contents are the real success signal.
_ = waitWithTimeout(proc, stopGrace)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading recorded audio: %w", err)
}
if len(data) == 0 {
return nil, errors.New("no audio was captured (is the microphone available?)")
}
return data, nil
}
// readRecordingWithRetry reads the Termux recording, retrying briefly: the
// companion app finalizes the file asynchronously after `-q` returns.
func readRecordingWithRetry(path string, now func() time.Time) ([]byte, error) {
deadline := now().Add(2 * time.Second)
for {
data, err := os.ReadFile(path)
if err == nil && len(data) > 0 {
return data, nil
}
if now().After(deadline) {
if err != nil {
return nil, fmt.Errorf("reading recorded audio: %w", err)
}
return nil, errors.New("no audio was captured (check Termux:API app and microphone permission)")
}
time.Sleep(50 * time.Millisecond)
}
}
// batchCommand builds the record-to-file argv for desktop platforms (§9).
// The max-duration cap is enforced by the tool itself so a runaway recording
// ends even if Zero never calls Stop.
func batchCommand(opts RecorderOptions, path string) (commandSpec, error) {
rate := strconv.Itoa(opts.SampleRate)
maxSec := strconv.Itoa(maxSeconds(opts.MaxDuration))
switch opts.Platform {
case PlatformLinux:
return commandSpec{
name: "arecord",
args: []string{"-q", "-f", "S16_LE", "-r", rate, "-c", "1", "-t", "wav", "-d", maxSec, path},
}, nil
case PlatformDarwin:
args := []string{"-q", "-d", "-r", rate, "-c", "1", "-b", "16", "-e", "signed-integer", path, "trim", "0", maxSec}
if opts.SilenceAutoStop {
// SoX's built-in trailing-silence auto-stop: end ~2s after the
// signal falls below 3% amplitude. A backstop for a forgotten
// manual stop, not VAD — recording still starts on an explicit key.
args = append(args, "silence", "1", "0.1", "3%", "1", "2.0", "3%")
}
return commandSpec{name: "sox", args: args}, nil
case PlatformWindows:
device := opts.WindowsAudioDevice
if device == "" {
detected, err := detectWindowsAudioDevice(opts.runOutput)
if err != nil {
return commandSpec{}, err
}
device = detected
}
return commandSpec{
name: "ffmpeg",
args: []string{"-hide_banner", "-loglevel", "error", "-f", "dshow", "-i", "audio=" + device,
"-ac", "1", "-ar", rate, "-c:a", "pcm_s16le", "-t", maxSec, "-y", path},
stopViaStdin: "q",
}, nil
}
return commandSpec{}, fmt.Errorf("no batch capture tool for platform %q", opts.Platform)
}
// streamCommand builds the raw-PCM-to-stdout argv for desktop platforms (§9).
// Same tools as batch, invoked with their stdout flag — Termux has no stdout
// mode, which is why it is batch-only.
func streamCommand(opts RecorderOptions) (commandSpec, error) {
rate := strconv.Itoa(opts.SampleRate)
switch opts.Platform {
case PlatformLinux:
return commandSpec{
name: "arecord",
args: []string{"-q", "-t", "raw", "-f", "S16_LE", "-r", rate, "-c", "1", "-"},
wantStdout: true,
}, nil
case PlatformDarwin:
return commandSpec{
name: "sox",
args: []string{"-q", "-d", "-t", "raw", "-r", rate, "-e", "signed", "-b", "16", "-c", "1", "-"},
wantStdout: true,
}, nil
case PlatformWindows:
device := opts.WindowsAudioDevice
if device == "" {
detected, err := detectWindowsAudioDevice(opts.runOutput)
if err != nil {
return commandSpec{}, err
}
device = detected
}
return commandSpec{
name: "ffmpeg",
args: []string{"-hide_banner", "-loglevel", "error", "-f", "dshow", "-i", "audio=" + device,
"-ac", "1", "-ar", rate, "-f", "s16le", "-"},
wantStdout: true,
stopViaStdin: "q",
}, nil
}
return commandSpec{}, fmt.Errorf("streaming capture is not supported on platform %q", opts.Platform)
}
// StartStreaming begins continuous PCM capture with the §12 safety nets: a
// hard max-duration cap and Go-side trailing-silence auto-stop (the streaming
// analog of SoX's silence effect — we see the samples, so we measure them).
func (r *recorder) StartStreaming() (<-chan []byte, func() error, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.recording {
return nil, nil, errAlreadyRecording
}
spec, err := streamCommand(r.opts)
if err != nil {
return nil, nil, err
}
proc, stdout, err := r.opts.starter(spec)
if err != nil {
return nil, nil, startError(spec.name, r.opts.Platform, err, nil)
}
if stdout == nil {
_ = proc.Kill()
return nil, nil, errors.New("capture process has no stdout pipe")
}
r.recording = true
chunkBytes := chunkSizeBytes(r.opts.SampleRate)
chunks := make(chan []byte, 8)
done := make(chan struct{})
var stopOnce sync.Once
stop := func() error {
stopOnce.Do(func() {
close(done)
if err := proc.StopGracefully(); err != nil {
_ = proc.Kill()
}
_ = waitWithTimeout(proc, stopGrace)
_ = stdout.Close()
r.mu.Lock()
r.recording = false
r.mu.Unlock()
})
return nil
}
// Max-duration cap: streaming has no tool-side -d/-t equivalent that also
// flushes stdout cleanly everywhere, so the cap runs on our side of the pipe.
capTimer := time.AfterFunc(r.opts.MaxDuration, func() { _ = stop() })
go func() {
defer close(chunks)
defer capTimer.Stop()
// Tear down on ANY exit — including the capture process ending on its own
// (EOF/crash), which the loop below returns on without going through an
// explicit stop(). stop() is idempotent (sync.Once), so this is safe even
// when the session was already stopped externally; it clears r.recording and
// closes proc/stdout so a self-terminating device doesn't wedge the recorder.
defer func() { _ = stop() }()
threshold := int16(32767 * silenceThresholdPct / 100)
silentChunks, speechSeen := 0, false
silenceLimit := int(silenceHold / chunkInterval)
for {
buf := make([]byte, chunkBytes)
n, err := readFullOrEOF(stdout, buf)
if n > 0 {
chunk := buf[:n]
if r.opts.SilenceAutoStop {
if chunkPeak(chunk) >= threshold {
speechSeen = true
silentChunks = 0
} else if speechSeen {
silentChunks++
if silentChunks >= silenceLimit {
go func() { _ = stop() }()
}
}
}
select {
case chunks <- chunk:
case <-done:
return
}
}
if err != nil {
return
}
}
}()
return chunks, stop, nil
}
// chunkSizeBytes is 50ms of PCM16 mono at the given rate — small chunks mean
// faster time-to-first-partial (§6a).
func chunkSizeBytes(sampleRate int) int {
return sampleRate * 2 / int(time.Second/chunkInterval)
}
// readFullOrEOF fills buf from r, returning what it read and any terminal
// error. A short final read (pipe closing mid-chunk) still returns its bytes.
func readFullOrEOF(r interface{ Read([]byte) (int, error) }, buf []byte) (int, error) {
total := 0
for total < len(buf) {
n, err := r.Read(buf[total:])
total += n
if err != nil {
return total, err
}
}
return total, nil
}
// chunkPeak returns the loudest absolute sample in a little-endian PCM16 chunk.
func chunkPeak(chunk []byte) int16 {
var peak int16
for i := 0; i+1 < len(chunk); i += 2 {
s := int16(uint16(chunk[i]) | uint16(chunk[i+1])<<8)
if s == -32768 {
return 32767
}
if s < 0 {
s = -s
}
if s > peak {
peak = s
}
}
return peak
}
// levelFloorDB and levelCeilDB bound the RMS→meter mapping. Speech RMS is small
// (roughly -45 dBFS quiet to -15 dBFS loud), so a LINEAR scale barely moves the
// bars — a dB (logarithmic) scale, like real audio meters, is what makes normal
// speech span the meter.
const (
levelFloorDB = -50.0 // ≈ near-silence → empty
levelCeilDB = -15.0 // loud speech → full
)
// ChunkLevel returns a perceptual 0..1 loudness for a little-endian PCM16 chunk,
// driving the live recording waveform from the actual microphone input. It uses
// an RMS→dBFS→linear mapping over [levelFloorDB, levelCeilDB].
func ChunkLevel(chunk []byte) float64 {
var sumSq float64
n := 0
for i := 0; i+1 < len(chunk); i += 2 {
s := int16(uint16(chunk[i]) | uint16(chunk[i+1])<<8)
f := float64(s) / 32768.0
sumSq += f * f
n++
}
if n == 0 {
return 0
}
rms := math.Sqrt(sumSq / float64(n))
if rms <= 0 {
return 0
}
db := 20 * math.Log10(rms) // dBFS (negative)
level := (db - levelFloorDB) / (levelCeilDB - levelFloorDB)
if level < 0 {
level = 0
}
if level > 1 {
level = 1
}
return level
}
func maxSeconds(d time.Duration) int {
sec := int(d / time.Second)
if sec < 1 {
sec = 1
}
return sec
}
// detectWindowsAudioDevice picks the first dshow audio capture device from
// `ffmpeg -list_devices`. dshow has no "default" alias, so a concrete device
// name is required; stt.windowsAudioDevice overrides the auto-pick.
func detectWindowsAudioDevice(runOutput commandOutputRunner) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, _ := runOutput(ctx, "ffmpeg", "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy")
// Listing always "fails" (dummy isn't a real input); the device table is
// in the output regardless. Lines look like: [dshow @ ...] "Mic name" (audio)
for _, line := range strings.Split(string(out), "\n") {
if !strings.Contains(line, "(audio)") {
continue
}
start := strings.Index(line, `"`)
if start < 0 {
continue
}
rest := line[start+1:]
end := strings.Index(rest, `"`)
if end <= 0 {
continue
}
return rest[:end], nil
}
return "", &SetupError{
Tool: "ffmpeg",
Hint: "no dshow audio capture device found — set stt.windowsAudioDevice to your microphone's name (list with: ffmpeg -list_devices true -f dshow -i dummy)",
}
}
// startError maps a failed capture-tool launch to setup guidance: a missing
// binary is an expected unconfigured state (matching internal/lsp's
// missing-server philosophy), not an internal error.
func startError(tool string, platform Platform, err error, output []byte) error {
if errors.Is(err, exec.ErrNotFound) {
return &SetupError{Tool: tool, Hint: installHint(platform)}
}
if len(output) > 0 {
return fmt.Errorf("starting %s: %w (%s)", tool, err, strings.TrimSpace(string(output)))
}
return fmt.Errorf("starting %s: %w", tool, err)
}
func installHint(platform Platform) string {
switch platform {
case PlatformLinux:
return "install alsa-utils to enable dictation (e.g. sudo dnf install alsa-utils / sudo apt install alsa-utils)"
case PlatformDarwin:
return "install sox to enable dictation (brew install sox)"
case PlatformWindows:
return "install ffmpeg and ensure it is on PATH to enable dictation"
case PlatformTermux:
return "run `pkg install termux-api` and install the Termux:API companion app to enable dictation"
}
return "dictation is not supported on this platform"
}
-501
View File
@@ -1,501 +0,0 @@
package dictation
import (
"bytes"
"context"
"encoding/binary"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
"time"
)
// fakeProcess is a processHandle whose stdout is driven from an in-memory
// buffer, mirroring how internal/lsp/manager_test.go stubs its subprocess over
// in-memory pipes rather than spawning a real one.
type fakeProcess struct {
stopped bool
killed bool
waitErr error
stopErr error
closeOnce sync.Once
done chan struct{}
}
func newFakeProcess() *fakeProcess { return &fakeProcess{done: make(chan struct{})} }
func (f *fakeProcess) StopGracefully() error {
f.stopped = true
f.closeOnce.Do(func() { close(f.done) })
return f.stopErr
}
func (f *fakeProcess) Wait() error {
<-f.done
return f.waitErr
}
func (f *fakeProcess) Kill() error {
f.killed = true
f.closeOnce.Do(func() { close(f.done) })
return nil
}
// captured records the last spec a starter was asked to launch, so tests can
// assert the exact discrete argv per platform.
type captured struct {
spec commandSpec
}
// fileWritingStarter simulates a record-to-file batch tool: on Start it writes
// fixture bytes to the output path (last positional arg for arecord/sox, or the
// -y target for ffmpeg) and returns a process that "records" until stopped.
func fileWritingStarter(t *testing.T, cap *captured, fixture []byte) processStarter {
t.Helper()
return func(spec commandSpec) (processHandle, io.ReadCloser, error) {
cap.spec = spec
path := outputPath(spec)
if path == "" {
t.Fatalf("could not determine output path from argv: %v", spec.args)
}
if err := os.WriteFile(path, fixture, 0o600); err != nil {
t.Fatalf("fixture write: %v", err)
}
return newFakeProcess(), nil, nil
}
}
func outputPath(spec commandSpec) string {
for i, a := range spec.args {
if a == "-y" && i+1 < len(spec.args) {
return spec.args[i+1]
}
}
// arecord/sox: the path is the last arg (sox appends trim/silence effects
// after it, so scan from the front for the first existing-dir target).
last := spec.args[len(spec.args)-1]
if filepath.IsAbs(last) {
return last
}
for _, a := range spec.args {
if filepath.IsAbs(a) && filepath.Ext(a) != "" {
return a
}
}
return ""
}
func wavFixture() []byte {
// Minimal valid RIFF/WAVE header + a little data, enough for SniffFormat.
var b bytes.Buffer
b.WriteString("RIFF")
binary.Write(&b, binary.LittleEndian, uint32(36))
b.WriteString("WAVE")
b.WriteString("fmt ")
binary.Write(&b, binary.LittleEndian, uint32(16))
b.Write(make([]byte, 16))
b.WriteString("data")
binary.Write(&b, binary.LittleEndian, uint32(4))
b.Write([]byte{1, 2, 3, 4})
return b.Bytes()
}
func testTempDir(t *testing.T) func() (string, error) {
dir := t.TempDir()
return func() (string, error) { return dir, nil }
}
func TestBatchRecordLinuxArgv(t *testing.T) {
cap := &captured{}
fixture := wavFixture()
r := NewRecorder(RecorderOptions{
Platform: PlatformLinux,
starter: fileWritingStarter(t, cap, fixture),
tempDir: testTempDir(t),
})
if err := r.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
got, err := r.Stop()
if err != nil {
t.Fatalf("Stop: %v", err)
}
if !bytes.Equal(got, fixture) {
t.Fatalf("audio mismatch: got %d bytes", len(got))
}
if cap.spec.name != "arecord" {
t.Errorf("tool = %q, want arecord", cap.spec.name)
}
assertArgs(t, cap.spec.args, "-f", "S16_LE")
assertArgs(t, cap.spec.args, "-r", "16000")
assertArgs(t, cap.spec.args, "-c", "1")
}
func TestBatchRecordDarwinArgv(t *testing.T) {
cap := &captured{}
r := NewRecorder(RecorderOptions{
Platform: PlatformDarwin,
SilenceAutoStop: true,
starter: fileWritingStarter(t, cap, wavFixture()),
tempDir: testTempDir(t),
})
if err := r.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
if _, err := r.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
if cap.spec.name != "sox" {
t.Errorf("tool = %q, want sox", cap.spec.name)
}
if !containsSubseq(cap.spec.args, []string{"silence", "1", "0.1", "3%", "1", "2.0", "3%"}) {
t.Errorf("expected SoX silence auto-stop effect in argv: %v", cap.spec.args)
}
}
func TestBatchRecordWindowsArgv(t *testing.T) {
cap := &captured{}
r := NewRecorder(RecorderOptions{
Platform: PlatformWindows,
WindowsAudioDevice: "Microphone (Realtek)",
starter: fileWritingStarter(t, cap, wavFixture()),
tempDir: testTempDir(t),
})
if err := r.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
if _, err := r.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
if cap.spec.name != "ffmpeg" {
t.Errorf("tool = %q, want ffmpeg", cap.spec.name)
}
assertArgs(t, cap.spec.args, "-i", "audio=Microphone (Realtek)")
if cap.spec.stopViaStdin != "q" {
t.Errorf("ffmpeg should stop via stdin 'q', got %q", cap.spec.stopViaStdin)
}
}
func TestBatchRecordTermuxUsesCommandRunner(t *testing.T) {
dir := t.TempDir()
var calls [][]string
runOutput := func(_ context.Context, name string, args ...string) ([]byte, error) {
calls = append(calls, append([]string{name}, args...))
if len(args) > 0 && args[0] == "-l" {
// The -f target is the last arg; write the fixture there.
path := args[len(args)-1]
_ = os.WriteFile(path, m4aFixture(), 0o600)
}
return nil, nil
}
r := NewRecorder(RecorderOptions{
Platform: PlatformTermux,
starter: func(commandSpec) (processHandle, io.ReadCloser, error) {
t.Fatal("termux must not use the process starter")
return nil, nil, nil
},
runOutput: runOutput,
tempDir: func() (string, error) { return dir, nil },
})
if err := r.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
got, err := r.Stop()
if err != nil {
t.Fatalf("Stop: %v", err)
}
if SniffFormat(got) != FormatM4A {
t.Errorf("expected m4a audio, got format %q", SniffFormat(got))
}
if len(calls) != 2 || calls[0][0] != "termux-microphone-record" || calls[1][len(calls[1])-1] != "-q" {
t.Errorf("unexpected termux calls: %v", calls)
}
}
func TestStartMissingBinaryReturnsSetupError(t *testing.T) {
r := NewRecorder(RecorderOptions{
Platform: PlatformLinux,
starter: func(commandSpec) (processHandle, io.ReadCloser, error) {
return nil, nil, exec.ErrNotFound
},
tempDir: testTempDir(t),
})
err := r.Start()
var setupErr *SetupError
if !errors.As(err, &setupErr) {
t.Fatalf("want *SetupError for missing binary, got %v", err)
}
if setupErr.Tool != "arecord" {
t.Errorf("SetupError.Tool = %q, want arecord", setupErr.Tool)
}
}
func TestDoubleStartRejected(t *testing.T) {
r := NewRecorder(RecorderOptions{
Platform: PlatformLinux,
starter: fileWritingStarter(t, &captured{}, wavFixture()),
tempDir: testTempDir(t),
})
if err := r.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
if err := r.Start(); !errors.Is(err, errAlreadyRecording) {
t.Errorf("second Start should be rejected, got %v", err)
}
}
func TestStreamingChunksAndStop(t *testing.T) {
// 300ms of PCM16 @16kHz = 6 chunks of 50ms. Feed a fixed buffer, then EOF.
pcm := make([]byte, chunkSizeBytes(16000)*6)
starter := func(spec commandSpec) (processHandle, io.ReadCloser, error) {
if !spec.wantStdout {
t.Fatal("streaming spec must request stdout")
}
return newFakeProcess(), io.NopCloser(bytes.NewReader(pcm)), nil
}
r := NewRecorder(RecorderOptions{
Platform: PlatformLinux,
starter: starter,
tempDir: testTempDir(t),
})
chunks, stop, err := r.StartStreaming()
if err != nil {
t.Fatalf("StartStreaming: %v", err)
}
var total int
for c := range chunks {
total += len(c)
}
_ = stop()
if total != len(pcm) {
t.Errorf("streamed %d bytes, want %d", total, len(pcm))
}
}
func TestStreamingStopIdempotent(t *testing.T) {
// A blocking reader so the capture goroutine parks until we stop it.
pr, pw := io.Pipe()
defer pw.Close()
starter := func(commandSpec) (processHandle, io.ReadCloser, error) {
return newFakeProcess(), pr, nil
}
r := NewRecorder(RecorderOptions{Platform: PlatformLinux, starter: starter, tempDir: testTempDir(t)})
chunks, stop, err := r.StartStreaming()
if err != nil {
t.Fatalf("StartStreaming: %v", err)
}
if err := stop(); err != nil {
t.Fatalf("stop: %v", err)
}
if err := stop(); err != nil {
t.Fatalf("second stop should be a no-op, got %v", err)
}
// Draining the channel must terminate now that we've stopped.
drained := make(chan struct{})
go func() {
for range chunks {
}
close(drained)
}()
select {
case <-drained:
case <-time.After(2 * time.Second):
t.Fatal("chunks channel did not close after stop")
}
}
func TestBatchMaxDurationCapInArgv(t *testing.T) {
cap := &captured{}
r := NewRecorder(RecorderOptions{
Platform: PlatformLinux,
MaxDuration: 90 * time.Second,
starter: fileWritingStarter(t, cap, wavFixture()),
tempDir: testTempDir(t),
})
if err := r.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
if _, err := r.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
// arecord's -d bounds a runaway recording even if Stop is never called.
assertArgs(t, cap.spec.args, "-d", "90")
}
func TestRecordedTempFileDeletedAfterStop(t *testing.T) {
dir := t.TempDir()
cap := &captured{}
r := NewRecorder(RecorderOptions{
Platform: PlatformLinux,
starter: fileWritingStarter(t, cap, wavFixture()),
tempDir: func() (string, error) { return dir, nil },
})
if err := r.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
if _, err := r.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
entries, _ := os.ReadDir(dir)
if len(entries) != 0 {
t.Errorf("recorded audio should be deleted after Stop; found %d files", len(entries))
}
}
func TestStreamingSilenceAutoStop(t *testing.T) {
// Speech (loud) then a run of silence longer than silenceHold must auto-stop.
rate := 16000
chunk := chunkSizeBytes(rate)
loud := make([]byte, chunk)
for i := 0; i+1 < len(loud); i += 2 {
loud[i], loud[i+1] = 0x00, 0x40 // +16384, well above threshold
}
silence := make([]byte, chunk) // zeros
var buf []byte
buf = append(buf, loud...)
silentChunks := int(silenceHold/chunkInterval) + 3
for i := 0; i < silentChunks; i++ {
buf = append(buf, silence...)
}
starter := func(commandSpec) (processHandle, io.ReadCloser, error) {
return newFakeProcess(), io.NopCloser(bytes.NewReader(buf)), nil
}
r := NewRecorder(RecorderOptions{
Platform: PlatformLinux,
SampleRate: rate,
SilenceAutoStop: true,
starter: starter,
tempDir: testTempDir(t),
})
chunks, stop, err := r.StartStreaming()
if err != nil {
t.Fatalf("StartStreaming: %v", err)
}
defer stop()
// Draining must terminate on its own once silence auto-stop fires.
drained := make(chan struct{})
go func() {
for range chunks {
}
close(drained)
}()
select {
case <-drained:
case <-time.After(3 * time.Second):
t.Fatal("silence auto-stop did not end the stream")
}
}
func TestChunkPeak(t *testing.T) {
loud := []byte{0x00, 0x40} // +16384
if got := chunkPeak(loud); got != 16384 {
t.Errorf("peak = %d, want 16384", got)
}
quiet := []byte{0x10, 0x00} // +16
if got := chunkPeak(quiet); got != 16 {
t.Errorf("peak = %d, want 16", got)
}
min := []byte{0x00, 0x80} // -32768 -> clamped to 32767
if got := chunkPeak(min); got != 32767 {
t.Errorf("peak = %d, want 32767 (clamp)", got)
}
}
func TestDetectWindowsAudioDevice(t *testing.T) {
listing := `[dshow @ 0000] DirectShow audio devices
[dshow @ 0000] "Microphone (USB Audio)" (audio)
[dshow @ 0000] Alternative name "@device_cm_..."`
runOutput := func(context.Context, string, ...string) ([]byte, error) { return []byte(listing), nil }
got, err := detectWindowsAudioDevice(runOutput)
if err != nil {
t.Fatalf("detect: %v", err)
}
if got != "Microphone (USB Audio)" {
t.Errorf("device = %q", got)
}
}
func TestSniffFormat(t *testing.T) {
if SniffFormat(wavFixture()) != FormatWAV {
t.Error("wav not detected")
}
if SniffFormat(m4aFixture()) != FormatM4A {
t.Error("m4a not detected")
}
if SniffFormat([]byte("nope")) != FormatUnknown {
t.Error("unknown not detected")
}
}
func m4aFixture() []byte {
b := make([]byte, 16)
copy(b[4:], []byte("ftyp"))
copy(b[8:], []byte("M4A "))
return b
}
func assertArgs(t *testing.T, args []string, want ...string) {
t.Helper()
if !containsSubseq(args, want) {
t.Errorf("argv %v missing subsequence %v", args, want)
}
}
func containsSubseq(hay, needle []string) bool {
if len(needle) == 0 {
return true
}
for i := 0; i+len(needle) <= len(hay); i++ {
if equalSlice(hay[i:i+len(needle)], needle) {
return true
}
}
return false
}
func equalSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestChunkLevel(t *testing.T) {
// Silence → ~0.
silence := make([]byte, 640)
if lvl := ChunkLevel(silence); lvl > 0.01 {
t.Errorf("silence level = %f, want ~0", lvl)
}
// A loud full-scale tone → clamped to 1.
loud := make([]byte, 640)
for i := 0; i+1 < len(loud); i += 2 {
loud[i], loud[i+1] = 0xFF, 0x7F // +32767
}
if lvl := ChunkLevel(loud); lvl < 0.99 {
t.Errorf("full-scale level = %f, want ~1", lvl)
}
// A normal speech-level signal (~-30 dBFS) should land mid-meter, not pinned
// to the bottom (the whole point of the dB mapping).
mid := make([]byte, 640)
for i := 0; i+1 < len(mid); i += 2 {
mid[i], mid[i+1] = byte(1000&0xFF), byte(1000>>8) // ≈ -30 dBFS
}
if lvl := ChunkLevel(mid); lvl < 0.4 || lvl > 0.75 {
t.Errorf("normal-speech level = %f, want a visible mid-range value", lvl)
}
// A quiet signal (~-45 dBFS) should be low but non-zero.
quiet := make([]byte, 640)
for i := 0; i+1 < len(quiet); i += 2 {
quiet[i], quiet[i+1] = byte(180&0xFF), byte(180>>8)
}
if lvl := ChunkLevel(quiet); lvl <= 0 || lvl > 0.35 {
t.Errorf("quiet level = %f, want a small non-zero value", lvl)
}
}
-128
View File
@@ -1,128 +0,0 @@
package dictation
import (
"bytes"
"context"
"io"
"os"
"os/exec"
"time"
)
// commandSpec describes one capture-process invocation. Argv is always
// discrete — never a shell string (the same invariant documented in
// internal/imageinput/clipboard.go).
type commandSpec struct {
name string
args []string
// wantStdout wires up a stdout pipe (streaming capture reads raw PCM).
wantStdout bool
// stopViaStdin, when non-empty, makes graceful stop write this to the
// process's stdin instead of signaling — ffmpeg finalizes cleanly on "q",
// and Windows has no os.Interrupt delivery anyway.
stopViaStdin string
}
// processHandle is a started capture process. Injectable via processStarter so
// unit tests fake OS-boundary behavior, mirroring internal/lsp's serverStarter
// seam (this package's clipboard.go-style shell-outs get the test seam that
// file lacks).
type processHandle interface {
// StopGracefully asks the tool to finish and flush output (SIGINT, or the
// spec's stdin stop text). The recorder falls back to Kill on timeout.
StopGracefully() error
Wait() error
Kill() error
}
// processStarter launches a capture process, returning its handle and — when
// the spec asks for it — its stdout pipe.
type processStarter func(spec commandSpec) (processHandle, io.ReadCloser, error)
// commandOutputRunner runs a short one-shot command to completion and returns
// its combined output. Used for helper invocations that aren't recordings
// (Termux start/stop, Windows ffmpeg device listing) and the offline
// transcription exec — ctx lets a caller cancel/timeout a wedged process.
type commandOutputRunner func(ctx context.Context, name string, args ...string) ([]byte, error)
func startProcess(spec commandSpec) (processHandle, io.ReadCloser, error) {
cmd := exec.Command(spec.name, spec.args...)
var stdout io.ReadCloser
if spec.wantStdout {
pipe, err := cmd.StdoutPipe()
if err != nil {
return nil, nil, err
}
stdout = pipe
}
var stdin io.WriteCloser
if spec.stopViaStdin != "" {
pipe, err := cmd.StdinPipe()
if err != nil {
return nil, nil, err
}
stdin = pipe
}
if err := cmd.Start(); err != nil {
// StdoutPipe/StdinPipe allocate fds the runtime only reclaims via
// Wait; on Start failure the process is never established, so close
// any pipes we already opened to avoid leaking them.
if stdout != nil {
_ = stdout.Close()
}
if stdin != nil {
_ = stdin.Close()
}
return nil, nil, err
}
return &realProcess{cmd: cmd, stdin: stdin, stopText: spec.stopViaStdin}, stdout, nil
}
type realProcess struct {
cmd *exec.Cmd
stdin io.WriteCloser
stopText string
}
func (p *realProcess) StopGracefully() error {
if p.stopText != "" && p.stdin != nil {
_, err := io.WriteString(p.stdin, p.stopText)
closeErr := p.stdin.Close()
if err != nil {
return err
}
return closeErr
}
// os.Interrupt is how arecord/sox finalize their WAV header. Unsupported
// on Windows, but the only Windows tool (ffmpeg) stops via stdin above.
return p.cmd.Process.Signal(os.Interrupt)
}
func (p *realProcess) Wait() error { return p.cmd.Wait() }
func (p *realProcess) Kill() error { return p.cmd.Process.Kill() }
func runCommandOutput(ctx context.Context, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
return out.Bytes(), err
}
// waitWithTimeout waits for the process, killing it if it ignores the graceful
// stop for longer than the grace window. Capture tools exit on SIGINT/"q"
// almost instantly; a hang here means a wedged device driver, and a leaked
// recording process holds the microphone open.
func waitWithTimeout(proc processHandle, grace time.Duration) error {
done := make(chan error, 1)
go func() { done <- proc.Wait() }()
select {
case err := <-done:
return err
case <-time.After(grace):
_ = proc.Kill()
return <-done
}
}
-255
View File
@@ -1,255 +0,0 @@
package dictation
import (
"context"
"errors"
"fmt"
"net"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// DefaultServerPort is the localhost port the sherpa-onnx streaming server binds.
const DefaultServerPort = 6006
// ServerConfig configures the warm sherpa-onnx streaming server (§6a).
type ServerConfig struct {
// Binary is the streaming server executable (default
// "sherpa-onnx-online-websocket-server"); looked up on PATH.
Binary string
// ModelPath is the sherpa-onnx streaming model directory (transducer:
// encoder/decoder/joiner + tokens).
ModelPath string
Port int
NumThreads int
// Test seams.
starter processStarter
healthCheck func(ctx context.Context, port int) error
aliveCheck func(port int) bool
now func() time.Time
}
// ServerManager owns one long-lived sherpa-onnx streaming server, spawned lazily
// on first streaming use and kept warm for the session — a websocket server with
// ~1-2s startup latency can't be respawned per utterance (§6a). Its lifecycle
// mirrors internal/lsp's Manager: lazy start, health-checked reuse, one restart
// on a crashed process, torn down on exit. Safe for concurrent use.
type ServerManager struct {
cfg ServerConfig
mu sync.Mutex
proc processHandle
url string
}
// NewServerManager builds a manager. Construction is side-effect free; the
// server is only spawned on the first EnsureRunning.
func NewServerManager(cfg ServerConfig) *ServerManager {
if cfg.Binary == "" {
cfg.Binary = "sherpa-onnx-online-websocket-server"
}
if cfg.Port == 0 {
cfg.Port = DefaultServerPort
}
if cfg.starter == nil {
cfg.starter = startProcess
}
if cfg.healthCheck == nil {
cfg.healthCheck = dialHealthCheck
}
if cfg.aliveCheck == nil {
cfg.aliveCheck = func(port int) bool { return portListening(port) }
}
if cfg.now == nil {
cfg.now = time.Now
}
return &ServerManager{cfg: cfg}
}
// EnsureRunning returns the websocket URL of a live server, starting one if
// needed. A previously-started server whose process has since died is reaped and
// replaced (mirroring the LSP manager's crashed-session eviction).
func (m *ServerManager) EnsureRunning(ctx context.Context) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.proc != nil {
if m.processAlive() {
return m.url, nil
}
// The cached server died; reap it so a fresh one starts below.
_ = m.proc.Kill()
m.proc = nil
m.url = ""
}
spec, err := onlineServerCommand(m.cfg)
if err != nil {
return "", err
}
proc, _, err := m.cfg.starter(spec)
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
return "", &SetupError{
Tool: m.cfg.Binary,
Hint: "install sherpa-onnx and put its online-websocket-server binary on PATH (see docs/dictation.md)",
}
}
return "", fmt.Errorf("starting sherpa-onnx server: %w", err)
}
// Health-check the port before handing back a URL — the server takes ~1-2s to
// bind, and a client connecting too early would fail (§6a).
if err := m.cfg.healthCheck(ctx, m.cfg.Port); err != nil {
_ = proc.Kill()
return "", fmt.Errorf("sherpa-onnx server did not become ready: %w", err)
}
m.proc = proc
m.url = fmt.Sprintf("ws://127.0.0.1:%d", m.cfg.Port)
return m.url, nil
}
// processAlive reports whether the tracked server is still reachable. Probing
// the port (rather than the process handle) reflects real reachability and is
// the seam tests replace.
func (m *ServerManager) processAlive() bool {
return m.cfg.aliveCheck(m.cfg.Port)
}
// portListening reports whether something accepts TCP connections on the port.
func portListening(port int) bool {
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 200*time.Millisecond)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// Shutdown stops the running server. Called alongside the LSP manager's shutdown
// on exit — a leaked warm server holds a port and CPU.
func (m *ServerManager) Shutdown(ctx context.Context) error {
m.mu.Lock()
proc := m.proc
m.proc = nil
m.url = ""
m.mu.Unlock()
if proc == nil {
return nil
}
if err := proc.StopGracefully(); err != nil {
_ = proc.Kill()
}
done := make(chan error, 1)
go func() { done <- waitWithTimeout(proc, stopGrace) }()
select {
case err := <-done:
return err
case <-ctx.Done():
_ = proc.Kill()
return ctx.Err()
}
}
// URL returns the current server URL without starting one ("" when not running).
func (m *ServerManager) URL() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.url
}
// SetModelPath updates the model directory the server launches with, for a
// mid-session config change (e.g. an F9 auto-download). It takes effect the next
// time the server (re)starts; a running server is left as-is.
func (m *ServerManager) SetModelPath(path string) {
m.mu.Lock()
defer m.mu.Unlock()
if path != "" {
m.cfg.ModelPath = path
}
}
// onlineServerCommand builds the streaming-server argv from the model directory.
// The online server uses a streaming transducer (encoder/decoder/joiner) plus
// tokens — the shape the sherpa-onnx docs' example uses (§6a).
func onlineServerCommand(cfg ServerConfig) (commandSpec, error) {
if strings.TrimSpace(cfg.ModelPath) == "" {
return commandSpec{}, &SetupError{
Tool: "local STT streaming model",
Hint: "set stt.localModelPath to a sherpa-onnx streaming model directory (see docs/dictation.md)",
}
}
files, err := onnxFilesIn(cfg.ModelPath)
if err != nil {
return commandSpec{}, &SetupError{Tool: "local STT streaming model", Hint: err.Error()}
}
tokens := findTokensFile(files)
// A streaming model ships encoder/decoder/joiner, often BOTH an int8 and an
// fp32 copy of each. Pick a CONSISTENT set — all int8 or all fp32 — because
// mixing (int8 encoder + fp32 decoder) is invalid. Prefer int8 (smaller,
// faster) when a full int8 set is present.
set := consistentQuantizedSet(files, "encoder", "decoder", "joiner")
var encoder, decoder, joiner string
if set != nil {
encoder, decoder, joiner = set[0], set[1], set[2]
}
if tokens == "" || encoder == "" || decoder == "" || joiner == "" {
return commandSpec{}, &SetupError{
Tool: "local STT streaming model",
Hint: fmt.Sprintf("streaming needs a transducer model (encoder/decoder/joiner .onnx + tokens.txt) in %q; see docs/dictation.md", cfg.ModelPath),
}
}
args := []string{
"--port=" + strconv.Itoa(cfg.Port),
"--tokens=" + tokens,
"--encoder=" + encoder,
"--decoder=" + decoder,
"--joiner=" + joiner,
"--decoding-method=greedy_search",
}
if cfg.NumThreads > 0 {
args = append(args, "--num-threads="+strconv.Itoa(cfg.NumThreads))
}
return commandSpec{name: cfg.Binary, args: args}, nil
}
func onnxFilesIn(dir string) (map[string]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("stt.localModelPath %q is not readable: %v", dir, err)
}
files := map[string]string{}
for _, e := range entries {
if e.IsDir() {
continue
}
files[strings.ToLower(e.Name())] = dir + string(os.PathSeparator) + e.Name()
}
return files, nil
}
// dialHealthCheck polls the server's TCP port until it accepts a connection or
// the deadline passes.
func dialHealthCheck(ctx context.Context, port int) error {
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
deadline := time.Now().Add(5 * time.Second)
for {
if ctx.Err() != nil {
return ctx.Err()
}
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
if err == nil {
_ = conn.Close()
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("port %d not listening after 5s", port)
}
time.Sleep(100 * time.Millisecond)
}
}
-189
View File
@@ -1,189 +0,0 @@
package dictation
import (
"context"
"encoding/binary"
"errors"
"io"
"math"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
)
func streamingModelDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
for _, f := range []string{"encoder.onnx", "decoder.onnx", "joiner.onnx", "tokens.txt"} {
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
return dir
}
func TestServerManagerLazyStartAndReuse(t *testing.T) {
var startCount int
var mu sync.Mutex
starter := func(spec commandSpec) (processHandle, io.ReadCloser, error) {
mu.Lock()
startCount++
mu.Unlock()
if spec.name != "sherpa-onnx-online-websocket-server" {
t.Errorf("binary = %q", spec.name)
}
return newFakeProcess(), nil, nil
}
alive := true
m := NewServerManager(ServerConfig{
ModelPath: streamingModelDir(t),
Port: 6006,
starter: starter,
healthCheck: func(context.Context, int) error { return nil },
aliveCheck: func(int) bool { return alive },
})
url, err := m.EnsureRunning(context.Background())
if err != nil {
t.Fatalf("EnsureRunning: %v", err)
}
if url != "ws://127.0.0.1:6006" {
t.Errorf("url = %q", url)
}
// Second call reuses the warm server (alive) — no new start.
if _, err := m.EnsureRunning(context.Background()); err != nil {
t.Fatalf("EnsureRunning reuse: %v", err)
}
if startCount != 1 {
t.Errorf("expected 1 start (warm reuse), got %d", startCount)
}
}
func TestServerManagerRestartsCrashedServer(t *testing.T) {
var startCount int
starter := func(commandSpec) (processHandle, io.ReadCloser, error) {
startCount++
return newFakeProcess(), nil, nil
}
alive := true
m := NewServerManager(ServerConfig{
ModelPath: streamingModelDir(t),
starter: starter,
healthCheck: func(context.Context, int) error { return nil },
aliveCheck: func(int) bool { return alive },
})
if _, err := m.EnsureRunning(context.Background()); err != nil {
t.Fatal(err)
}
alive = false // simulate a crashed server
if _, err := m.EnsureRunning(context.Background()); err != nil {
t.Fatal(err)
}
if startCount != 2 {
t.Errorf("expected a restart (2 starts), got %d", startCount)
}
}
func TestServerManagerMissingBinaryIsSetupError(t *testing.T) {
m := NewServerManager(ServerConfig{
ModelPath: streamingModelDir(t),
starter: func(commandSpec) (processHandle, io.ReadCloser, error) { return nil, nil, exec.ErrNotFound },
})
_, err := m.EnsureRunning(context.Background())
var setupErr *SetupError
if !errors.As(err, &setupErr) {
t.Fatalf("want *SetupError, got %v", err)
}
}
func TestServerManagerMissingModelIsSetupError(t *testing.T) {
m := NewServerManager(ServerConfig{
ModelPath: t.TempDir(), // empty dir, no onnx/tokens
starter: func(commandSpec) (processHandle, io.ReadCloser, error) { return newFakeProcess(), nil, nil },
})
_, err := m.EnsureRunning(context.Background())
var setupErr *SetupError
if !errors.As(err, &setupErr) {
t.Fatalf("want *SetupError for missing streaming model, got %v", err)
}
}
func TestServerManagerShutdown(t *testing.T) {
proc := newFakeProcess()
m := NewServerManager(ServerConfig{
ModelPath: streamingModelDir(t),
starter: func(commandSpec) (processHandle, io.ReadCloser, error) { return proc, nil, nil },
healthCheck: func(context.Context, int) error { return nil },
aliveCheck: func(int) bool { return true },
})
if _, err := m.EnsureRunning(context.Background()); err != nil {
t.Fatal(err)
}
if err := m.Shutdown(context.Background()); err != nil {
t.Fatalf("Shutdown: %v", err)
}
if !proc.stopped && !proc.killed {
t.Error("Shutdown should stop the server process")
}
if m.URL() != "" {
t.Error("URL should be empty after shutdown")
}
}
func TestOnlineServerCommandBuildsTransducerArgs(t *testing.T) {
spec, err := onlineServerCommand(ServerConfig{Binary: "sherpa-onnx-online-websocket-server", ModelPath: streamingModelDir(t), Port: 6007})
if err != nil {
t.Fatal(err)
}
joined := ""
for _, a := range spec.args {
joined += a + " "
}
for _, want := range []string{"--port=6007", "--tokens=", "--encoder=", "--decoder=", "--joiner=", "--decoding-method=greedy_search"} {
if !contains(joined, want) {
t.Errorf("missing %q in args: %s", want, joined)
}
}
}
func TestPCM16ToFloat32LE(t *testing.T) {
// int16 max (32767) and min (-32768), little-endian.
pcm := []byte{0xFF, 0x7F, 0x00, 0x80}
out := pcm16ToFloat32LE(pcm)
if len(out) != 8 {
t.Fatalf("expected 8 bytes (2 float32), got %d", len(out))
}
f0 := math.Float32frombits(binary.LittleEndian.Uint32(out[0:4]))
f1 := math.Float32frombits(binary.LittleEndian.Uint32(out[4:8]))
if f0 < 0.99 || f0 > 1.0 {
t.Errorf("32767 → %f, want ~1.0", f0)
}
if f1 != -1.0 {
t.Errorf("-32768 → %f, want -1.0", f1)
}
}
func TestParseSherpaStreamResult(t *testing.T) {
text, segment, final, ok := parseSherpaStreamResult([]byte(`{"text":" hello world ","segment":2,"is_final":true}`))
if !ok || text != "hello world" || segment != 2 || !final {
t.Errorf("parsed = (%q, %d, %v, %v)", text, segment, final, ok)
}
if _, _, _, ok := parseSherpaStreamResult([]byte("not json")); ok {
t.Error("non-JSON should not parse")
}
}
func contains(hay, needle string) bool {
return len(needle) == 0 || (len(hay) >= len(needle) && indexOf(hay, needle) >= 0)
}
func indexOf(hay, needle string) int {
for i := 0; i+len(needle) <= len(hay); i++ {
if hay[i:i+len(needle)] == needle {
return i
}
}
return -1
}
-51
View File
@@ -1,51 +0,0 @@
package dictation
import (
"fmt"
"os"
"path/filepath"
)
// Recorded audio is staged in a hardened per-user temp directory, mirroring
// internal/tools/spill.go (PR #506): uid-suffixed name so users on a shared
// /tmp cannot collide, 0700 perms, and a pre-existing path is only accepted
// when it is a real directory (not a symlink that would redirect recordings)
// owned by the current user. Files are removed as soon as their bytes are
// read back after Stop — audio never lingers on disk.
func audioSpillRoot() string {
name := "zero-dictation"
if uid := os.Getuid(); uid >= 0 {
name = fmt.Sprintf("zero-dictation-%d", uid)
}
return filepath.Join(os.TempDir(), name)
}
func audioSpillDir() (string, error) {
dir := audioSpillRoot()
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
// MkdirAll follows symlinks and leaves an existing directory untouched, so
// verify what is actually at the path.
info, err := os.Lstat(dir)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("dictation temp path %s is not a directory", dir)
}
// MkdirAll only applies 0o700 to dirs it creates; an existing directory
// (e.g. one created under a loose umask in a prior run) can be group- or
// world-readable. Tighten it so staged audio (PII) is never visible to
// other users on a shared /tmp.
if info.Mode().Perm() != 0o700 {
if err := os.Chmod(dir, 0o700); err != nil {
return "", fmt.Errorf("tightening dictation temp dir permissions: %w", err)
}
}
if err := checkAudioDirOwner(info); err != nil {
return "", err
}
return dir, nil
}
-67
View File
@@ -1,67 +0,0 @@
package dictation
import (
"context"
"os"
"testing"
"time"
)
// TestLocalStreamingRealServer drives the full local streaming path against a
// REAL sherpa-onnx-online-websocket-server + streaming model: spawn the server,
// connect, feed a wav's PCM in 50ms chunks, and check the transcript. Opt-in:
//
// ZERO_STT_STREAM_SERVER=/path/to/sherpa-onnx-online-websocket-server \
// ZERO_STT_STREAM_MODEL=/path/to/streaming-model-dir \
// ZERO_STT_STREAM_WAV=/path/to/16k-mono.wav \
// go test ./internal/dictation/ -run RealServer -v -timeout 5m
func TestLocalStreamingRealServer(t *testing.T) {
server := os.Getenv("ZERO_STT_STREAM_SERVER")
model := os.Getenv("ZERO_STT_STREAM_MODEL")
wav := os.Getenv("ZERO_STT_STREAM_WAV")
if server == "" || model == "" || wav == "" {
t.Skip("set ZERO_STT_STREAM_SERVER, ZERO_STT_STREAM_MODEL, ZERO_STT_STREAM_WAV to run the streaming smoke test")
}
raw, err := os.ReadFile(wav)
if err != nil {
t.Fatal(err)
}
pcm := stripWavHeader(raw)
mgr := NewServerManager(ServerConfig{Binary: server, ModelPath: model, Port: 6100})
defer mgr.Shutdown(context.Background())
tr := NewLocalStreamingTranscriber(mgr)
chunks := make(chan []byte)
go func() {
defer close(chunks)
size := chunkSizeBytes(DefaultSampleRate) // 50ms
for off := 0; off < len(pcm); off += size {
end := off + size
if end > len(pcm) {
end = len(pcm)
}
chunks <- pcm[off:end]
time.Sleep(10 * time.Millisecond) // pace it a little, like a mic
}
}()
var partials int
final, err := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) { partials++ })
if err != nil {
t.Fatalf("StreamTranscribe: %v", err)
}
if final == "" {
t.Fatal("streaming returned empty transcript")
}
t.Logf("streaming transcript (%d partials): %q", partials, final)
}
// stripWavHeader drops a standard 44-byte PCM WAV header, returning the raw
// int16 samples. Good enough for the canonical 16kHz/mono/16-bit test wavs.
func stripWavHeader(b []byte) []byte {
if len(b) > 44 && string(b[:4]) == "RIFF" {
return b[44:]
}
return b
}
-45
View File
@@ -1,45 +0,0 @@
package dictation
import "context"
// Transcriber turns captured audio into text. Batch (Transcribe) and streaming
// (StreamTranscribe) are one interface but two independent code paths — a
// batch-only provider (Groq/OpenAI, sherpa-onnx-offline) leaves
// StreamTranscribe returning ErrStreamingUnsupported, and a streaming provider
// still implements Transcribe for the batch/Termux fallback.
type Transcriber interface {
// Transcribe returns the final text for one recorded clip.
Transcribe(ctx context.Context, audio []byte) (string, error)
// StreamTranscribe consumes PCM16 chunks and reports incremental results
// via onPartial(text, final) as decoding proceeds, returning the complete
// final transcript when the chunk channel closes.
//
// onPartial's text is the CURRENT best transcript of the utterance so far
// (cumulative, not a delta) so the composer can replace the live region
// wholesale; final marks a segment the provider considers settled.
//
// On a mid-stream failure (network drop for cloud, subprocess crash for
// local) StreamTranscribe returns the best-effort text accumulated from
// onPartial calls made so far PLUS a non-nil error — already-streamed text
// is never discarded, only the untranscribed tail after the failure is lost.
StreamTranscribe(ctx context.Context, chunks <-chan []byte, onPartial func(text string, final bool)) (string, error)
}
// SampleRate lets a Transcriber declare the capture rate its wire format
// expects, so the recorder can be built to match (OpenAI Realtime wants 24kHz;
// everything else uses 16kHz). A Transcriber that does not implement this uses
// DefaultSampleRate.
type SampleRate interface {
SampleRate() int
}
// RequiredSampleRate returns the capture rate a Transcriber needs.
func RequiredSampleRate(t Transcriber) int {
if sr, ok := t.(SampleRate); ok {
if rate := sr.SampleRate(); rate > 0 {
return rate
}
}
return DefaultSampleRate
}
-144
View File
@@ -1,144 +0,0 @@
package dictation
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strings"
"github.com/Gitlawb/zero/internal/providers/providerio"
)
// CloudConfig configures a batch cloud transcriber. Groq and OpenAI share the
// OpenAI-compatible /audio/transcriptions multipart endpoint — the design's
// "each owns its endpoint/model specifics while sharing the request shape"
// (§6). Key and base URL are resolved by the caller (TUI layer) from
// config/credstore, keeping this type decoupled and unit-testable.
type CloudConfig struct {
Provider string // ProviderGroq or ProviderOpenAI, for error messages
BaseURL string // e.g. https://api.groq.com/openai/v1
APIKey string
Model string // e.g. whisper-large-v3-turbo, whisper-1
// Language optionally constrains recognition (ISO-639-1). Empty = auto.
Language string
// HTTPClient is injectable for tests; nil uses providerio's shared,
// stall-hardened client.
HTTPClient *http.Client
}
type cloudTranscriber struct {
cfg CloudConfig
}
// NewCloudTranscriber builds a batch transcriber for Groq or OpenAI.
func NewCloudTranscriber(cfg CloudConfig) (Transcriber, error) {
if strings.TrimSpace(cfg.APIKey) == "" {
return nil, &SetupError{
Tool: cfg.Provider + " API key",
Hint: fmt.Sprintf("set a %s API key (run `zero auth`) to use cloud dictation", cfg.Provider),
}
}
if cfg.Model == "" {
return nil, fmt.Errorf("dictation: no model configured for provider %q", cfg.Provider)
}
base, err := providerio.NormalizeBaseURL(cfg.BaseURL, cfg.BaseURL, cfg.Provider)
if err != nil {
return nil, err
}
cfg.BaseURL = base
return &cloudTranscriber{cfg: cfg}, nil
}
func (c *cloudTranscriber) Transcribe(ctx context.Context, audio []byte) (string, error) {
if len(audio) == 0 {
return "", errors.New("no audio to transcribe")
}
body, contentType, err := c.buildMultipart(audio)
if err != nil {
return "", err
}
endpoint := c.cfg.BaseURL + "/audio/transcriptions"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
resp, err := providerio.HTTPClient(c.cfg.HTTPClient).Do(req)
if err != nil {
return "", fmt.Errorf("%s transcription request failed: %w", c.cfg.Provider, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
msg := providerio.ClassifiedError(resp.StatusCode, strings.TrimSpace(string(respBody)), c.cfg.APIKey)
// A 401/403 is a fixable credential problem: surface it as a typed AuthError
// so the TUI can offer an inline key prompt instead of a dead-end message.
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return "", &AuthError{Provider: c.cfg.Provider, Message: msg}
}
return "", errors.New(msg)
}
var parsed struct {
Text string `json:"text"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(respBody, &parsed); err != nil {
return "", fmt.Errorf("%s transcription: could not parse response: %w", c.cfg.Provider, err)
}
if parsed.Error.Message != "" {
return "", fmt.Errorf("%s transcription error: %s", c.cfg.Provider, providerio.Redact(parsed.Error.Message, c.cfg.APIKey))
}
return strings.TrimSpace(parsed.Text), nil
}
// buildMultipart assembles the transcription upload. The file field name and
// the "model"/"response_format" fields match the OpenAI audio API that Groq
// mirrors. The filename's extension tells the server the container type —
// SniffFormat keeps WAV/M4A honest so Termux's AAC uploads work.
func (c *cloudTranscriber) buildMultipart(audio []byte) ([]byte, string, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, err := w.CreateFormFile("file", SniffFormat(audio).FileName())
if err != nil {
return nil, "", err
}
if _, err := fw.Write(audio); err != nil {
return nil, "", err
}
if err := w.WriteField("model", c.cfg.Model); err != nil {
return nil, "", err
}
if err := w.WriteField("response_format", "json"); err != nil {
return nil, "", err
}
if c.cfg.Language != "" {
if err := w.WriteField("language", c.cfg.Language); err != nil {
return nil, "", err
}
}
if err := w.Close(); err != nil {
return nil, "", err
}
return buf.Bytes(), w.FormDataContentType(), nil
}
// StreamTranscribe is not supported for the batch cloud providers (Groq has no
// streaming product; OpenAI batch is one-shot). Streaming uses the dedicated
// Deepgram/OpenAI-Realtime transcribers instead.
func (c *cloudTranscriber) StreamTranscribe(context.Context, <-chan []byte, func(string, bool)) (string, error) {
return "", ErrStreamingUnsupported
}
// ErrStreamingUnsupported reports that a batch-only Transcriber cannot stream;
// the caller falls back to the batch pipeline.
var ErrStreamingUnsupported = errors.New("this transcription provider does not support streaming")
@@ -1,142 +0,0 @@
package dictation
import (
"context"
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCloudTranscribeBuildsMultipartAndParsesText(t *testing.T) {
var gotModel, gotAuth, gotFilename string
var gotFileBytes []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
if r.URL.Path != "/audio/transcriptions" {
t.Errorf("unexpected path %q", r.URL.Path)
}
mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || !strings.HasPrefix(mediaType, "multipart/") {
t.Fatalf("bad content type %q: %v", r.Header.Get("Content-Type"), err)
}
mr := multipart.NewReader(r.Body, params["boundary"])
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("multipart: %v", err)
}
switch part.FormName() {
case "model":
b, _ := io.ReadAll(part)
gotModel = string(b)
case "file":
gotFilename = part.FileName()
gotFileBytes, _ = io.ReadAll(part)
}
}
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"text":" hello world "}`)
}))
defer srv.Close()
tr, err := NewCloudTranscriber(CloudConfig{
Provider: ProviderGroq,
BaseURL: srv.URL,
APIKey: "secret-key",
Model: "whisper-large-v3-turbo",
})
if err != nil {
t.Fatalf("NewCloudTranscriber: %v", err)
}
audio := wavFixture()
text, err := tr.Transcribe(context.Background(), audio)
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if text != "hello world" {
t.Errorf("text = %q, want trimmed 'hello world'", text)
}
if gotModel != "whisper-large-v3-turbo" {
t.Errorf("model field = %q", gotModel)
}
if gotAuth != "Bearer secret-key" {
t.Errorf("auth header = %q", gotAuth)
}
if gotFilename != "audio.wav" {
t.Errorf("filename = %q, want audio.wav", gotFilename)
}
if len(gotFileBytes) != len(audio) {
t.Errorf("uploaded %d file bytes, want %d", len(gotFileBytes), len(audio))
}
}
func TestCloudTranscribeM4AFilename(t *testing.T) {
var gotFilename string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, params, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
mr := multipart.NewReader(r.Body, params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break
}
if part.FormName() == "file" {
gotFilename = part.FileName()
}
}
io.WriteString(w, `{"text":"ok"}`)
}))
defer srv.Close()
tr, _ := NewCloudTranscriber(CloudConfig{Provider: ProviderOpenAI, BaseURL: srv.URL, APIKey: "k", Model: "whisper-1"})
if _, err := tr.Transcribe(context.Background(), m4aFixture()); err != nil {
t.Fatalf("Transcribe: %v", err)
}
if gotFilename != "audio.m4a" {
t.Errorf("filename = %q, want audio.m4a", gotFilename)
}
}
func TestCloudTranscribeAuthErrorClassified(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
io.WriteString(w, `{"error":{"message":"invalid api key sk-secret-key"}}`)
}))
defer srv.Close()
tr, _ := NewCloudTranscriber(CloudConfig{Provider: ProviderGroq, BaseURL: srv.URL, APIKey: "sk-secret-key", Model: "whisper-large-v3-turbo"})
_, err := tr.Transcribe(context.Background(), wavFixture())
if err == nil {
t.Fatal("expected error on 401")
}
if strings.Contains(err.Error(), "sk-secret-key") {
t.Errorf("API key leaked into error: %v", err)
}
if !strings.Contains(err.Error(), "auth error") {
t.Errorf("expected auth-error classification, got %v", err)
}
}
func TestNewCloudTranscriberRequiresKey(t *testing.T) {
_, err := NewCloudTranscriber(CloudConfig{Provider: ProviderGroq, BaseURL: "https://api.groq.com/openai/v1", Model: "m"})
var setupErr *SetupError
if !errors.As(err, &setupErr) {
t.Fatalf("want *SetupError for missing key, got %v", err)
}
}
func TestCloudTranscribeStreamingUnsupported(t *testing.T) {
tr, _ := NewCloudTranscriber(CloudConfig{Provider: ProviderGroq, BaseURL: "https://x", APIKey: "k", Model: "m"})
_, err := tr.StreamTranscribe(context.Background(), nil, nil)
if !errors.Is(err, ErrStreamingUnsupported) {
t.Errorf("want ErrStreamingUnsupported, got %v", err)
}
}
-163
View File
@@ -1,163 +0,0 @@
package dictation
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/coder/websocket"
)
// DeepgramConfig configures the Deepgram streaming transcriber (§6b, default
// cloud streaming provider). The key is resolved by the caller.
type DeepgramConfig struct {
APIKey string
Model string // default "nova-3"
Language string
SampleRate int // capture rate; default DefaultSampleRate (16kHz)
// BaseURL overrides the wss endpoint (tests point it at a fake server).
BaseURL string
}
type deepgramTranscriber struct {
cfg DeepgramConfig
}
// NewDeepgramTranscriber builds the Deepgram streaming transcriber.
func NewDeepgramTranscriber(cfg DeepgramConfig) (Transcriber, error) {
if strings.TrimSpace(cfg.APIKey) == "" {
return nil, &SetupError{
Tool: "Deepgram API key",
Hint: "set a Deepgram API key (DEEPGRAM_API_KEY, or `zero auth`) to use Deepgram streaming dictation",
}
}
if cfg.Model == "" {
cfg.Model = "nova-3"
}
if cfg.SampleRate <= 0 {
cfg.SampleRate = DefaultSampleRate
}
if cfg.BaseURL == "" {
cfg.BaseURL = "wss://api.deepgram.com/v1/listen"
}
return &deepgramTranscriber{cfg: cfg}, nil
}
// Transcribe is unsupported — Deepgram is used only on the streaming path here.
func (d *deepgramTranscriber) Transcribe(context.Context, []byte) (string, error) {
return "", ErrStreamingUnsupported
}
func (d *deepgramTranscriber) SampleRate() int { return d.cfg.SampleRate }
func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-chan []byte, onPartial func(string, bool)) (string, error) {
endpoint := d.buildURL()
conn, _, err := websocket.Dial(ctx, endpoint, &websocket.DialOptions{
HTTPHeader: http.Header{"Authorization": {"Token " + d.cfg.APIKey}},
})
if err != nil {
return "", fmt.Errorf("connecting to Deepgram: %w", err)
}
defer conn.CloseNow()
// Deepgram consumes raw linear16 (int16) PCM — the recorder's native format,
// 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
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
// is_final segments and append the current interim to build the full text.
var finals []string
var interim string
compose := func() string {
return strings.TrimSpace(strings.Join(append(finals, interim), " "))
}
for {
typ, data, err := conn.Read(ctx)
if err != nil {
// A clean close after CloseStream is success, not failure.
if websocket.CloseStatus(err) == websocket.StatusNormalClosure {
return compose(), nil
}
select {
case werr := <-writeErrCh:
if werr != nil {
err = werr
}
default:
}
return compose(), fmt.Errorf("Deepgram stream error: %w", err)
}
if typ != websocket.MessageText {
continue
}
transcript, isFinal, ok := parseDeepgramResult(data)
if !ok {
continue
}
if isFinal {
if transcript != "" {
finals = append(finals, transcript)
}
interim = ""
} else {
interim = transcript
}
if onPartial != nil {
onPartial(compose(), isFinal)
}
}
}
func (d *deepgramTranscriber) buildURL() string {
q := url.Values{}
q.Set("encoding", "linear16")
q.Set("sample_rate", strconv.Itoa(d.cfg.SampleRate))
q.Set("channels", "1")
q.Set("interim_results", "true")
q.Set("punctuate", "true")
q.Set("model", d.cfg.Model)
if d.cfg.Language != "" {
q.Set("language", d.cfg.Language)
}
return d.cfg.BaseURL + "?" + q.Encode()
}
// parseDeepgramResult extracts the transcript and is_final flag from a Deepgram
// "Results" message: channel.alternatives[0].transcript.
func parseDeepgramResult(data []byte) (transcript string, isFinal bool, ok bool) {
var msg struct {
Type string `json:"type"`
IsFinal bool `json:"is_final"`
Channel struct {
Alternatives []struct {
Transcript string `json:"transcript"`
} `json:"alternatives"`
} `json:"channel"`
}
if err := json.Unmarshal(data, &msg); err != nil {
return "", false, false
}
// Only "Results" messages carry transcripts; ignore Metadata/UtteranceEnd/etc.
if msg.Type != "" && msg.Type != "Results" {
return "", false, false
}
if len(msg.Channel.Alternatives) == 0 {
return "", false, false
}
return strings.TrimSpace(msg.Channel.Alternatives[0].Transcript), msg.IsFinal, true
}
-391
View File
@@ -1,391 +0,0 @@
package dictation
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// LocalConfig configures the offline sherpa-onnx batch transcriber. Every tier
// (default/standard/high-end, §7) is just a different ModelPath — there are no
// tier-specific code paths; the model family is detected from the files present.
type LocalConfig struct {
// Binary is the sherpa-onnx-offline executable; looked up on PATH when a
// bare name. Empty defaults to "sherpa-onnx-offline".
Binary string
// ModelPath is the directory holding the model's .onnx + tokens.txt files
// (stt.localModelPath).
ModelPath string
NumThreads int
// Test seams.
runOutput commandOutputRunner
tempDir func() (string, error)
}
type localTranscriber struct {
cfg LocalConfig
}
// NewLocalTranscriber builds the offline transcriber. It does not touch the
// filesystem or PATH until Transcribe runs, so a missing binary/model degrades
// to a clear setup message at use time (matching internal/lsp).
func NewLocalTranscriber(cfg LocalConfig) (Transcriber, error) {
if strings.TrimSpace(cfg.ModelPath) == "" {
return nil, &SetupError{
Tool: "local STT model",
Hint: "set stt.localModelPath to a sherpa-onnx model directory (see docs/dictation.md)",
}
}
if cfg.Binary == "" {
cfg.Binary = "sherpa-onnx-offline"
}
if cfg.runOutput == nil {
cfg.runOutput = runCommandOutput
}
if cfg.tempDir == nil {
cfg.tempDir = audioSpillDir
}
return &localTranscriber{cfg: cfg}, nil
}
func (l *localTranscriber) Transcribe(ctx context.Context, audio []byte) (string, error) {
if len(audio) == 0 {
return "", errors.New("no audio to transcribe")
}
modelArgs, err := detectModelArgs(l.cfg.ModelPath)
if err != nil {
return "", err
}
dir, err := l.cfg.tempDir()
if err != nil {
return "", fmt.Errorf("preparing dictation temp dir: %w", err)
}
ext := ".wav"
if SniffFormat(audio) == FormatM4A {
ext = ".m4a"
}
tmp, err := os.CreateTemp(dir, "stt-*"+ext)
if err != nil {
return "", fmt.Errorf("staging audio for transcription: %w", err)
}
path := tmp.Name()
defer os.Remove(path) // audio never lingers on disk (§12)
if _, err := tmp.Write(audio); err != nil {
tmp.Close()
return "", err
}
if err := tmp.Close(); err != nil {
return "", err
}
args := make([]string, 0, len(modelArgs)+3)
args = append(args, modelArgs...)
if l.cfg.NumThreads > 0 {
args = append(args, "--num-threads="+strconv.Itoa(l.cfg.NumThreads))
}
args = append(args, path)
// Fail fast if already cancelled; ctx also propagates into the exec below so a
// wedged sherpa-onnx-offline is killed on cancel/timeout rather than hanging.
if err := ctx.Err(); err != nil {
return "", err
}
out, err := l.cfg.runOutput(ctx, l.cfg.Binary, args...)
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
return "", &SetupError{
Tool: l.cfg.Binary,
Hint: "install sherpa-onnx and put its offline binary on PATH (see docs/dictation.md)",
}
}
return "", fmt.Errorf("sherpa-onnx transcription failed: %w (%s)", err, lastLines(out, 3))
}
text := parseSherpaOutput(out)
if text == "" {
return "", fmt.Errorf("sherpa-onnx produced no transcript (output: %s)", lastLines(out, 3))
}
return text, nil
}
// detectModelArgs inspects a sherpa-onnx model directory and builds the
// family-specific --*=path flags. Supports the families sherpa-onnx-offline
// accepts; Moonshine (the design's default/standard/high-end tiers) is the
// primary target. tokens.txt is required by every family.
func detectModelArgs(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, &SetupError{
Tool: "local STT model",
Hint: fmt.Sprintf("stt.localModelPath %q is not readable: %v", dir, err),
}
}
files := map[string]string{} // lowercased basename -> absolute path
for _, e := range entries {
if e.IsDir() {
continue
}
files[strings.ToLower(e.Name())] = filepath.Join(dir, e.Name())
}
tokens := findTokensFile(files)
if tokens == "" {
return nil, &SetupError{
Tool: "local STT model",
Hint: fmt.Sprintf("no tokens.txt found in stt.localModelPath %q — is it a sherpa-onnx model directory?", dir),
}
}
dirName := strings.ToLower(filepath.Base(dir))
// Order matters — check the most specific signatures first so a model is
// never mis-classified as a family it only partially resembles.
// Moonshine: preprocessor + encoder + cached/uncached decoders. Its files are
// "encode"/"decode" (no trailing "r"), so they never collide with the
// "encoder"/"decoder" families below.
if pre := findContains(files, "preprocess"); pre != "" {
enc := findContains(files, "encode")
// "uncached_decode" CONTAINS "cached_decode", so match the cached decoder
// as "cached" AND not "uncached" to disambiguate (map order is random).
unc := findOnnxMatching(files, "uncached", "")
cac := findOnnxMatching(files, "cached", "uncached")
if enc != "" && unc != "" && cac != "" {
return []string{
"--moonshine-preprocessor=" + pre,
"--moonshine-encoder=" + enc,
"--moonshine-uncached-decoder=" + unc,
"--moonshine-cached-decoder=" + cac,
"--tokens=" + tokens,
}, nil
}
}
// Transducer (Zipformer/Conformer): encoder + decoder + joiner, matched as a
// consistent int8-or-fp32 set (mixing quantizations is invalid).
if set := consistentQuantizedSet(files, "encoder", "decoder", "joiner"); set != nil {
return []string{"--encoder=" + set[0], "--decoder=" + set[1], "--joiner=" + set[2], "--tokens=" + tokens}, nil
}
// Encoder+decoder families that share Whisper's file shape (encoder + decoder,
// no joiner) but need their own flags — FireRedASR-AED, Canary, Cohere. They are
// indistinguishable from Whisper by files alone, so disambiguate by name FIRST;
// anything unnamed falls through to the Whisper default below.
for _, fam := range encoderDecoderFamilies {
if nameMatchesFamily(dirName, files, fam.match) {
if set := consistentQuantizedSet(files, "encoder", "decoder"); set != nil {
return []string{fam.encoderFlag + "=" + set[0], fam.decoderFlag + "=" + set[1], "--tokens=" + tokens}, nil
}
}
}
// Whisper: encoder + decoder, NO joiner. Sherpa's Whisper files are named
// "<model>-encoder.onnx" / "<model>-decoder.onnx" (NOT "whisper-encoder"), so
// match on the encoder/decoder roles — a consistent pair — after transducer
// (which owns the encoder/decoder/joiner shape) has already been ruled out.
if set := consistentQuantizedSet(files, "encoder", "decoder"); set != nil {
return []string{"--whisper-encoder=" + set[0], "--whisper-decoder=" + set[1], "--tokens=" + tokens}, nil
}
// Single-model families: one generic model .onnx + tokens, indistinguishable by
// file shape, so they are told apart by the model directory/file name. This
// covers SenseVoice, Paraformer, and the CTC families (FireRedASR-CTC, NeMo,
// TeleSpeech, WeNet, Zipformer-CTC, Dolphin, Omnilingual). Ordered most-specific
// first so a compound name (e.g. "fire-red-asr2-ctc") matches its own family
// before a generic token in it (e.g. bare "ctc") could steer it elsewhere.
if m := singleModelOnnx(files); m != "" {
for _, fam := range singleModelFamilies {
if nameMatchesFamily(dirName, files, fam.match) {
return []string{fam.flag + "=" + m, "--tokens=" + tokens}, nil
}
}
}
return nil, &SetupError{
Tool: "local STT model",
Hint: fmt.Sprintf("could not recognize the model family in %q (supported: Moonshine, Whisper, transducer/Zipformer, Paraformer, SenseVoice, and the CTC families FireRedASR/NeMo/TeleSpeech/WeNet/Zipformer-CTC/Dolphin/Omnilingual). See docs/dictation.md", dir),
}
}
// singleModelFamilies maps a model's directory/file-name signature to the
// sherpa-onnx-offline flag for that single-file family. Each family ships exactly
// one generic model .onnx (+ tokens), so only the name tells them apart. Ordered
// so a specific compound name wins before a broader one.
var singleModelFamilies = []struct {
match []string // name substrings (any hit); most-specific spellings first
flag string
}{
{[]string{"fire-red-asr2-ctc", "fire-red-asr-ctc", "fire-red-asr", "firered"}, "--fire-red-asr-ctc"},
{[]string{"nemo-ctc", "nemo_ctc"}, "--nemo-ctc-model"},
{[]string{"zipformer2-ctc", "zipformer-ctc"}, "--zipformer-ctc-model"},
{[]string{"telespeech"}, "--telespeech-ctc"},
{[]string{"wenet"}, "--wenet-ctc-model"},
{[]string{"dolphin"}, "--dolphin-model"},
{[]string{"omnilingual"}, "--omnilingual-asr-model"},
{[]string{"medasr"}, "--medasr"},
{[]string{"tdnn"}, "--tdnn-model"},
{[]string{"sense-voice", "sensevoice"}, "--sense-voice-model"},
{[]string{"paraformer"}, "--paraformer"},
}
// encoderDecoderFamilies are offline families shaped like Whisper (an encoder +
// decoder .onnx, no joiner) but needing their own flags; matched by name before
// the Whisper fallback claims the shape.
var encoderDecoderFamilies = []struct {
match []string
encoderFlag string
decoderFlag string
}{
{[]string{"fire-red-asr"}, "--fire-red-asr-encoder", "--fire-red-asr-decoder"},
{[]string{"canary"}, "--canary-encoder", "--canary-decoder"},
{[]string{"cohere"}, "--cohere-transcribe-encoder", "--cohere-transcribe-decoder"},
}
// nameMatchesFamily reports whether the model directory name or any of its .onnx
// filenames contain one of the family's signature substrings.
func nameMatchesFamily(dirName string, files map[string]string, subs []string) bool {
for _, sub := range subs {
if strings.Contains(dirName, sub) {
return true
}
for name := range files {
if strings.HasSuffix(name, ".onnx") && strings.Contains(name, sub) {
return true
}
}
}
return false
}
// consistentQuantizedSet returns one file per role (in order), preferring an
// int8 build of each but falling back to fp32 when a role has no int8 file.
// Returns nil when any role is entirely missing.
//
// Models legitimately SHIP mixed quantization — e.g. the x-asr streaming
// zipformers ship an int8 encoder and joiner with an fp32 decoder — and
// sherpa-onnx accepts that. So this must NOT require every role to share one
// quantization; it just needs one file per role, favoring the smaller int8 when
// a choice exists. (When a model ships both int8 and fp32 for every role, this
// still picks all-int8, since int8 is available for each.)
func consistentQuantizedSet(files map[string]string, roles ...string) []string {
pick := func(role string, int8Only bool) string {
for name, path := range files {
if !strings.HasSuffix(name, ".onnx") || !strings.Contains(name, role) {
continue
}
if int8Only && !strings.Contains(name, "int8") {
continue
}
return path
}
return ""
}
out := make([]string, len(roles))
for i, role := range roles {
p := pick(role, true) // prefer int8 (smaller, faster)
if p == "" {
p = pick(role, false) // fall back to fp32 for this role
}
if p == "" {
return nil // this role is genuinely absent
}
out[i] = p
}
return out
}
// findTokensFile returns the model's tokens file. It is usually "tokens.txt",
// but some families (e.g. Whisper) prefix it as "<model>-tokens.txt".
func findTokensFile(files map[string]string) string {
if p := files["tokens.txt"]; p != "" {
return p
}
for name, path := range files {
if strings.HasSuffix(name, "tokens.txt") {
return path
}
}
return ""
}
// singleModelOnnx returns the model's single .onnx file (preferring an int8
// build), for families like SenseVoice/Paraformer that ship exactly one.
func singleModelOnnx(files map[string]string) string {
best := ""
for name, path := range files {
if !strings.HasSuffix(name, ".onnx") {
continue
}
if strings.Contains(name, "int8") {
return path
}
best = path
}
return best
}
// findOnnxMatching returns an .onnx file whose name contains `must` and, when
// `exclude` is non-empty, does NOT contain `exclude` — used to tell the cached
// from the uncached Moonshine decoder.
func findOnnxMatching(files map[string]string, must, exclude string) string {
for name, path := range files {
if !strings.HasSuffix(name, ".onnx") || !strings.Contains(name, must) {
continue
}
if exclude != "" && strings.Contains(name, exclude) {
continue
}
return path
}
return ""
}
func findContains(files map[string]string, substr string) string {
for name, path := range files {
if strings.HasSuffix(name, ".onnx") && strings.Contains(name, substr) {
return path
}
}
return ""
}
// parseSherpaOutput extracts the transcript from sherpa-onnx-offline's output.
// The binary prints a JSON object with a "text" field (amid diagnostic lines);
// scan for the first line that parses as such.
func parseSherpaOutput(out []byte) string {
for _, line := range bytes.Split(out, []byte("\n")) {
trimmed := bytes.TrimSpace(line)
if len(trimmed) == 0 || trimmed[0] != '{' {
continue
}
var parsed struct {
Text string `json:"text"`
}
if err := json.Unmarshal(trimmed, &parsed); err == nil && strings.TrimSpace(parsed.Text) != "" {
return strings.TrimSpace(parsed.Text)
}
}
return ""
}
func lastLines(out []byte, n int) string {
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, " | ")
}
// StreamTranscribe is unsupported for the offline binary; local streaming uses
// the sherpa-onnx websocket server transcriber instead.
func (l *localTranscriber) StreamTranscribe(context.Context, <-chan []byte, func(string, bool)) (string, error) {
return "", ErrStreamingUnsupported
}
@@ -1,166 +0,0 @@
package dictation
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"github.com/coder/websocket"
)
// localStreamingTranscriber streams audio to the warm sherpa-onnx websocket
// server (§6a). The server keeps encoder state across chunks (a true streaming
// transducer), so partials arrive as decoding proceeds — no redundant
// re-inference.
//
// Wire protocol (verified against sherpa-onnx's reference client, NOT the
// plan's "16-bit PCM" description, which was imprecise): the client sends
// binary frames of little-endian float32 samples normalized to [-1, 1]
// (int16/32768), then the text "Done" to end; the server replies with JSON
// result messages and finally the text "Done!" to signal completion.
type localStreamingTranscriber struct {
manager *ServerManager
}
// NewLocalStreamingTranscriber builds a streaming transcriber backed by the
// shared, session-long sherpa-onnx server manager.
func NewLocalStreamingTranscriber(manager *ServerManager) Transcriber {
return &localStreamingTranscriber{manager: manager}
}
// Transcribe is not this transcriber's job — the streaming path uses
// StreamTranscribe; the batch/Termux fallback uses the offline transcriber.
func (l *localStreamingTranscriber) Transcribe(context.Context, []byte) (string, error) {
return "", errors.New("local streaming transcriber does not support batch transcription")
}
func (l *localStreamingTranscriber) StreamTranscribe(ctx context.Context, chunks <-chan []byte, onPartial func(string, bool)) (string, error) {
url, err := l.manager.EnsureRunning(ctx)
if err != nil {
return "", err
}
conn, err := dialWithOneRestart(ctx, l.manager, url)
if err != nil {
return "", fmt.Errorf("connecting to sherpa-onnx server: %w", err)
}
defer conn.CloseNow()
// Writer goroutine: forward converted chunks, then signal end. A write error
// (server crashed) cancels the read side via writeErr.
writeErrCh := make(chan error, 1)
go func() {
for chunk := range chunks {
if err := conn.Write(ctx, websocket.MessageBinary, pcm16ToFloat32LE(chunk)); err != nil {
writeErrCh <- err
return
}
}
writeErrCh <- conn.Write(ctx, websocket.MessageText, []byte("Done"))
}()
// The server reports "text" PER SEGMENT, not cumulatively for the whole
// utterance: when it detects an endpoint (a pause between phrases, or the
// trailing silence when recording stops) it finalizes the current segment, bumps
// "segment", and resets "text" to "" for the next one. So we accumulate the text
// of every segment — otherwise a new segment's (often empty) result would
// overwrite everything already transcribed, and the transcript would vanish on
// stop. Displayed/returned text is all segments joined in order.
var segmentTexts []string
joinSegments := func() string {
parts := make([]string, 0, len(segmentTexts))
for _, s := range segmentTexts {
if t := strings.TrimSpace(s); t != "" {
parts = append(parts, t)
}
}
return strings.Join(parts, " ")
}
lastText := ""
for {
typ, data, err := conn.Read(ctx)
if err != nil {
// Mid-stream failure: return the best-effort text accumulated so far
// plus the error — already-streamed partials are never discarded (§6).
select {
case werr := <-writeErrCh:
if werr != nil {
err = werr
}
default:
}
return lastText, fmt.Errorf("sherpa-onnx stream error: %w", err)
}
if typ == websocket.MessageText && strings.TrimSpace(string(data)) == "Done!" {
break
}
text, segment, final, ok := parseSherpaStreamResult(data)
if !ok {
continue
}
if segment < 0 {
segment = 0
}
for len(segmentTexts) <= segment {
segmentTexts = append(segmentTexts, "")
}
segmentTexts[segment] = text
lastText = joinSegments()
if onPartial != nil {
onPartial(lastText, final)
}
}
_ = conn.Close(websocket.StatusNormalClosure, "")
return lastText, nil
}
// dialWithOneRestart dials the server, and on failure restarts it once and
// retries — mirroring the LSP manager's single crashed-server recovery (§6/§6a).
func dialWithOneRestart(ctx context.Context, manager *ServerManager, url string) (*websocket.Conn, error) {
conn, _, err := websocket.Dial(ctx, url, nil)
if err == nil {
return conn, nil
}
// The warm server may have died between EnsureRunning and Dial; reap and
// restart once.
_ = manager.Shutdown(ctx)
url, rerr := manager.EnsureRunning(ctx)
if rerr != nil {
return nil, rerr
}
conn, _, err = websocket.Dial(ctx, url, nil)
return conn, err
}
// parseSherpaStreamResult extracts one server JSON result. "text" is the
// hypothesis for the CURRENT segment (segment index in "segment"), which resets
// each time the recognizer endpoints — so the caller must accumulate per segment,
// not treat "text" as the whole-utterance transcript. "is_final" marks a settled
// segment.
func parseSherpaStreamResult(data []byte) (text string, segment int, final bool, ok bool) {
var parsed struct {
Text string `json:"text"`
Segment int `json:"segment"`
IsFinal bool `json:"is_final"`
}
if err := json.Unmarshal(data, &parsed); err != nil {
return "", 0, false, false
}
return strings.TrimSpace(parsed.Text), parsed.Segment, parsed.IsFinal, true
}
// pcm16ToFloat32LE converts a little-endian int16 PCM chunk to the little-endian
// float32 samples (normalized to [-1, 1]) the sherpa-onnx server expects.
func pcm16ToFloat32LE(pcm []byte) []byte {
n := len(pcm) / 2
out := make([]byte, n*4)
for i := 0; i < n; i++ {
s := int16(uint16(pcm[2*i]) | uint16(pcm[2*i+1])<<8)
bits := math.Float32bits(float32(s) / 32768.0)
binary.LittleEndian.PutUint32(out[4*i:], bits)
}
return out
}
@@ -1,269 +0,0 @@
package dictation
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func writeModelDir(t *testing.T, files ...string) string {
t.Helper()
dir := t.TempDir()
for _, f := range files {
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
return dir
}
func TestLocalTranscribeMoonshineArgs(t *testing.T) {
dir := writeModelDir(t,
"preprocess.onnx", "encode.onnx", "uncached_decode.onnx", "cached_decode.onnx", "tokens.txt")
var gotBin string
var gotArgs []string
runOutput := func(_ context.Context, name string, args ...string) ([]byte, error) {
gotBin, gotArgs = name, args
return []byte("Started\nDone!\n\n/tmp/a.wav\n{\"text\":\" transcribed speech \"}\n----"), nil
}
tr, err := NewLocalTranscriber(LocalConfig{ModelPath: dir, runOutput: runOutput, tempDir: func() (string, error) { return t.TempDir(), nil }})
if err != nil {
t.Fatalf("NewLocalTranscriber: %v", err)
}
text, err := tr.Transcribe(context.Background(), wavFixture())
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if text != "transcribed speech" {
t.Errorf("text = %q", text)
}
if gotBin != "sherpa-onnx-offline" {
t.Errorf("binary = %q", gotBin)
}
joined := strings.Join(gotArgs, " ")
for _, want := range []string{"--moonshine-preprocessor=", "--moonshine-encoder=", "--moonshine-uncached-decoder=", "--moonshine-cached-decoder=", "--tokens="} {
if !strings.Contains(joined, want) {
t.Errorf("missing flag %q in %q", want, joined)
}
}
}
func TestLocalTranscribeTransducerArgs(t *testing.T) {
dir := writeModelDir(t, "encoder-epoch-99.onnx", "decoder-epoch-99.onnx", "joiner-epoch-99.onnx", "tokens.txt")
var gotArgs []string
runOutput := func(_ context.Context, name string, args ...string) ([]byte, error) {
gotArgs = args
return []byte(`{"text":"hi"}`), nil
}
tr, _ := NewLocalTranscriber(LocalConfig{ModelPath: dir, runOutput: runOutput, tempDir: func() (string, error) { return t.TempDir(), nil }})
if _, err := tr.Transcribe(context.Background(), wavFixture()); err != nil {
t.Fatalf("Transcribe: %v", err)
}
joined := strings.Join(gotArgs, " ")
for _, want := range []string{"--encoder=", "--decoder=", "--joiner=", "--tokens="} {
if !strings.Contains(joined, want) {
t.Errorf("missing flag %q in %q", want, joined)
}
}
}
func TestDetectWhisperArgs(t *testing.T) {
// Sherpa Whisper models are named "<model>-encoder.onnx" (NOT "whisper-encoder")
// and prefix the tokens file as "<model>-tokens.txt".
dir := writeModelDir(t, "tiny.en-encoder.onnx", "tiny.en-encoder.int8.onnx",
"tiny.en-decoder.onnx", "tiny.en-decoder.int8.onnx", "tiny.en-tokens.txt")
args, err := detectModelArgs(dir)
if err != nil {
t.Fatalf("Whisper detection failed: %v", err)
}
joined := strings.Join(args, " ")
if !strings.Contains(joined, "--whisper-encoder=") || !strings.Contains(joined, "--whisper-decoder=") {
t.Errorf("expected whisper flags, got %q", joined)
}
// Must not emit transducer flags (Whisper has no joiner).
if strings.Contains(joined, "--joiner=") || strings.Contains(joined, "--encoder=") {
t.Errorf("Whisper must not be detected as a transducer: %q", joined)
}
// Consistent quantization: both int8 (preferred) or both fp32, never mixed.
if strings.Contains(joined, "encoder.int8.onnx") != strings.Contains(joined, "decoder.int8.onnx") {
t.Errorf("mixed encoder/decoder quantization: %q", joined)
}
}
func TestDetectTransducerPrefersConsistentInt8(t *testing.T) {
dir := writeModelDir(t,
"encoder-epoch-99.onnx", "encoder-epoch-99.int8.onnx",
"decoder-epoch-99.onnx", "decoder-epoch-99.int8.onnx",
"joiner-epoch-99.onnx", "joiner-epoch-99.int8.onnx", "tokens.txt")
args, err := detectModelArgs(dir)
if err != nil {
t.Fatal(err)
}
joined := strings.Join(args, " ")
// All three int8 (preferred), consistently.
for _, want := range []string{"--encoder=", "encoder-epoch-99.int8.onnx", "joiner-epoch-99.int8.onnx"} {
if !strings.Contains(joined, want) {
t.Errorf("missing %q in %q", want, joined)
}
}
}
func TestDetectTransducerMixedQuantization(t *testing.T) {
// Some models ship an int8 encoder+joiner with an fp32 decoder (e.g. the
// x-asr streaming zipformers). Detection must accept that mix, not reject it.
dir := writeModelDir(t, "encoder.int8.onnx", "decoder.onnx", "joiner.int8.onnx", "tokens.txt")
args, err := detectModelArgs(dir)
if err != nil {
t.Fatalf("mixed-quantization transducer should be detected, got %v", err)
}
joined := strings.Join(args, " ")
if !strings.Contains(joined, "encoder.int8.onnx") || !strings.Contains(joined, "decoder.onnx") || !strings.Contains(joined, "joiner.int8.onnx") {
t.Errorf("expected the actual mixed files, got %q", joined)
}
// The fp32 decoder must not be mistaken for an int8 one.
if strings.Contains(joined, "decoder.int8") {
t.Errorf("no int8 decoder exists; should use decoder.onnx: %q", joined)
}
}
func TestDetectSenseVoiceByDirName(t *testing.T) {
dir := t.TempDir() // rename won't help; use a dir whose base contains sense-voice
sv := dir + "/sherpa-onnx-sense-voice-zh-en"
if err := os.MkdirAll(sv, 0o755); err != nil {
t.Fatal(err)
}
for _, f := range []string{"model.int8.onnx", "tokens.txt"} {
os.WriteFile(sv+"/"+f, []byte("x"), 0o600)
}
args, err := detectModelArgs(sv)
if err != nil {
t.Fatalf("SenseVoice detection failed: %v", err)
}
if !strings.Contains(strings.Join(args, " "), "--sense-voice-model=") {
t.Errorf("expected sense-voice flag, got %v", args)
}
}
// writeNamedModelDir creates a model directory whose base name carries the family
// signature (how sherpa-onnx distributes single-file / CTC models), with the given
// files, and returns its path.
func writeNamedModelDir(t *testing.T, base string, files ...string) string {
t.Helper()
dir := t.TempDir() + "/" + base
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for _, f := range files {
if err := os.WriteFile(dir+"/"+f, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
return dir
}
func TestDetectSingleFileCTCFamilies(t *testing.T) {
// One generic model.onnx + tokens, family told apart by the directory name.
cases := []struct{ base, wantFlag string }{
{"sherpa-onnx-fire-red-asr2-ctc-zh_en-int8-2026-02-25", "--fire-red-asr-ctc="},
{"sherpa-onnx-nemo-ctc-en-conformer-medium", "--nemo-ctc-model="},
{"sherpa-onnx-zipformer-ctc-zh-2024", "--zipformer-ctc-model="},
{"sherpa-onnx-telespeech-ctc-int8", "--telespeech-ctc="},
{"sherpa-onnx-wenet-ctc-en", "--wenet-ctc-model="},
{"sherpa-onnx-dolphin-base-ctc-multi-lang", "--dolphin-model="},
{"sherpa-onnx-omnilingual-asr-ctc", "--omnilingual-asr-model="},
{"sherpa-onnx-tdnn-yesno", "--tdnn-model="},
}
for _, tc := range cases {
dir := writeNamedModelDir(t, tc.base, "model.int8.onnx", "tokens.txt")
args, err := detectModelArgs(dir)
if err != nil {
t.Errorf("%s: detection failed: %v", tc.base, err)
continue
}
joined := strings.Join(args, " ")
if !strings.Contains(joined, tc.wantFlag) || !strings.Contains(joined, "--tokens=") {
t.Errorf("%s: got %q, want %s", tc.base, joined, tc.wantFlag)
}
}
}
func TestDetectEncoderDecoderFamiliesBeforeWhisper(t *testing.T) {
// FireRedASR-AED / Canary share Whisper's encoder+decoder shape, so they must be
// disambiguated by name — not fall through to --whisper-encoder.
cases := []struct{ base, wantFlag string }{
{"sherpa-onnx-fire-red-asr-large-zh_en", "--fire-red-asr-encoder="},
{"sherpa-onnx-canary-180m-flash", "--canary-encoder="},
}
for _, tc := range cases {
dir := writeNamedModelDir(t, tc.base, "encoder.int8.onnx", "decoder.int8.onnx", "tokens.txt")
args, err := detectModelArgs(dir)
if err != nil {
t.Errorf("%s: detection failed: %v", tc.base, err)
continue
}
joined := strings.Join(args, " ")
if !strings.Contains(joined, tc.wantFlag) {
t.Errorf("%s: got %q, want %s", tc.base, joined, tc.wantFlag)
}
if strings.Contains(joined, "--whisper-") {
t.Errorf("%s must not be detected as Whisper: %q", tc.base, joined)
}
}
}
func TestDetectUnknownFamilyFailsCleanly(t *testing.T) {
// A model with tokens but no recognizable family → a clear SetupError, not a
// crash or wrong flags.
dir := writeModelDir(t, "some-random-model.onnx", "tokens.txt")
_, err := detectModelArgs(dir)
var setupErr *SetupError
if !errors.As(err, &setupErr) {
t.Fatalf("want a clean *SetupError, got %v", err)
}
if !strings.Contains(setupErr.Error(), "could not recognize") {
t.Errorf("expected a recognizable failure message, got %q", setupErr.Error())
}
}
func TestLocalTranscribeMissingTokens(t *testing.T) {
dir := writeModelDir(t, "encoder.onnx", "decoder.onnx", "joiner.onnx") // no tokens.txt
tr, _ := NewLocalTranscriber(LocalConfig{ModelPath: dir, runOutput: func(context.Context, string, ...string) ([]byte, error) { return nil, nil }, tempDir: func() (string, error) { return t.TempDir(), nil }})
_, err := tr.Transcribe(context.Background(), wavFixture())
var setupErr *SetupError
if !errors.As(err, &setupErr) {
t.Fatalf("want *SetupError for missing tokens, got %v", err)
}
}
func TestLocalTranscribeMissingBinary(t *testing.T) {
dir := writeModelDir(t, "preprocess.onnx", "encode.onnx", "uncached_decode.onnx", "cached_decode.onnx", "tokens.txt")
runOutput := func(context.Context, string, ...string) ([]byte, error) { return nil, exec.ErrNotFound }
tr, _ := NewLocalTranscriber(LocalConfig{ModelPath: dir, runOutput: runOutput, tempDir: func() (string, error) { return t.TempDir(), nil }})
_, err := tr.Transcribe(context.Background(), wavFixture())
var setupErr *SetupError
if !errors.As(err, &setupErr) {
t.Fatalf("want *SetupError for missing binary, got %v", err)
}
}
func TestParseSherpaOutput(t *testing.T) {
out := []byte("Started\nDone!\n\naudio.wav\n{\"lang\":\"\",\"text\":\" the quick brown fox \",\"tokens\":[]}\n----\nnum threads: 2")
if got := parseSherpaOutput(out); got != "the quick brown fox" {
t.Errorf("parsed %q", got)
}
if got := parseSherpaOutput([]byte("no json here")); got != "" {
t.Errorf("expected empty, got %q", got)
}
}
func TestNewLocalTranscriberRequiresModelPath(t *testing.T) {
_, err := NewLocalTranscriber(LocalConfig{})
var setupErr *SetupError
if !errors.As(err, &setupErr) {
t.Fatalf("want *SetupError, got %v", err)
}
}
@@ -1,210 +0,0 @@
package dictation
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/coder/websocket"
)
// OpenAI Realtime transcription-only wants 24kHz PCM16 (§6b); the recorder is
// built to match via the SampleRate hint.
const openAIRealtimeSampleRate = 24000
// OpenAIRealtimeConfig configures the OpenAI Realtime transcription transcriber
// (§6b, the credential-reuse streaming alternative). Reuses the OpenAI key.
type OpenAIRealtimeConfig struct {
APIKey string
Model string // default "gpt-4o-transcribe"
// BaseURL overrides the wss endpoint (tests point it at a fake server).
BaseURL string
}
type openAIRealtimeTranscriber struct {
cfg OpenAIRealtimeConfig
}
// NewOpenAIRealtimeTranscriber builds the OpenAI Realtime streaming transcriber.
func NewOpenAIRealtimeTranscriber(cfg OpenAIRealtimeConfig) (Transcriber, error) {
if strings.TrimSpace(cfg.APIKey) == "" {
return nil, &SetupError{
Tool: "OpenAI API key",
Hint: "set an OpenAI API key (OPENAI_API_KEY, or `zero auth`) to use OpenAI Realtime streaming dictation",
}
}
if cfg.Model == "" {
cfg.Model = "gpt-4o-transcribe"
}
if cfg.BaseURL == "" {
cfg.BaseURL = "wss://api.openai.com/v1/realtime?intent=transcription"
}
return &openAIRealtimeTranscriber{cfg: cfg}, nil
}
func (o *openAIRealtimeTranscriber) Transcribe(context.Context, []byte) (string, error) {
return "", ErrStreamingUnsupported
}
func (o *openAIRealtimeTranscriber) SampleRate() int { return openAIRealtimeSampleRate }
func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks <-chan []byte, onPartial func(string, bool)) (string, error) {
conn, _, err := websocket.Dial(ctx, o.cfg.BaseURL, &websocket.DialOptions{
HTTPHeader: http.Header{
"Authorization": {"Bearer " + o.cfg.APIKey},
"OpenAI-Beta": {"realtime=v1"},
},
})
if err != nil {
return "", fmt.Errorf("connecting to OpenAI Realtime: %w", err)
}
defer conn.CloseNow()
// Configure a transcription-only session (pcm16 in, gpt-4o-transcribe).
sessionUpdate := map[string]any{
"type": "transcription_session.update",
"session": map[string]any{
"input_audio_format": "pcm16",
"input_audio_transcription": map[string]any{
"model": o.cfg.Model,
},
},
}
if err := writeJSON(ctx, conn, sessionUpdate); err != nil {
return "", fmt.Errorf("configuring OpenAI Realtime session: %w", err)
}
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
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
// text. Accumulate completed segments plus the in-progress delta buffer.
var finalized, current strings.Builder
compose := func() string {
return strings.TrimSpace(finalized.String() + current.String())
}
committed := false
for {
typ, data, err := conn.Read(ctx)
if err != nil {
if websocket.CloseStatus(err) == websocket.StatusNormalClosure {
return compose(), nil
}
select {
case werr := <-writeErrCh:
if werr != nil {
err = werr
}
default:
}
return compose(), fmt.Errorf("OpenAI Realtime stream error: %w", err)
}
if typ != websocket.MessageText {
continue
}
evt := parseRealtimeEvent(data)
switch evt.kind {
case realtimeDelta:
current.WriteString(evt.text)
if onPartial != nil {
onPartial(compose(), false)
}
case realtimeCompleted:
// A completed item replaces the in-progress delta buffer with the
// server's authoritative transcript for that segment.
if evt.text != "" {
if finalized.Len() > 0 {
finalized.WriteString(" ")
}
finalized.WriteString(strings.TrimSpace(evt.text))
}
current.Reset()
if onPartial != nil {
onPartial(compose(), true)
}
// Once we've committed the buffer (user stopped) and the server has
// returned a completed transcription, the utterance is done.
if committed {
_ = conn.Close(websocket.StatusNormalClosure, "")
return compose(), nil
}
case realtimeCommitted:
committed = true
case realtimeError:
return compose(), fmt.Errorf("OpenAI Realtime error: %s", evt.text)
}
// The writer signals commit completion out of band; observe it so a
// stop with no further audio still flips `committed`.
select {
case werr := <-writeErrCh:
if werr == nil {
committed = true
}
default:
}
}
}
type realtimeEventKind int
const (
realtimeOther realtimeEventKind = iota
realtimeDelta
realtimeCompleted
realtimeCommitted
realtimeError
)
type realtimeEvent struct {
kind realtimeEventKind
text string
}
func parseRealtimeEvent(data []byte) realtimeEvent {
var msg struct {
Type string `json:"type"`
Delta string `json:"delta"`
Transcript string `json:"transcript"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(data, &msg); err != nil {
return realtimeEvent{kind: realtimeOther}
}
switch msg.Type {
case "conversation.item.input_audio_transcription.delta":
return realtimeEvent{kind: realtimeDelta, text: msg.Delta}
case "conversation.item.input_audio_transcription.completed":
return realtimeEvent{kind: realtimeCompleted, text: msg.Transcript}
case "input_audio_buffer.committed":
return realtimeEvent{kind: realtimeCommitted}
case "error":
return realtimeEvent{kind: realtimeError, text: msg.Error.Message}
}
return realtimeEvent{kind: realtimeOther}
}
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
return conn.Write(ctx, websocket.MessageText, data)
}
@@ -1,231 +0,0 @@
package dictation
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/coder/websocket"
)
// wsTestServer runs handler over a real localhost websocket, returning a wss->ws
// base URL the transcribers can dial. Exercising the real coder/websocket client
// against a real server is the closest unit test to the wire protocol.
func wsTestServer(t *testing.T, handler func(ctx context.Context, c *websocket.Conn)) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
handler(r.Context(), c)
}))
t.Cleanup(srv.Close)
return "ws" + strings.TrimPrefix(srv.URL, "http")
}
func TestDeepgramStreamTranscribe(t *testing.T) {
var gotAuth string
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
defer c.Close(websocket.StatusNormalClosure, "")
// Read audio frames until CloseStream, then emit interim + final results.
for {
typ, data, err := c.Read(ctx)
if err != nil {
return
}
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
break
}
}
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"Results","is_final":false,"channel":{"alternatives":[{"transcript":"hello"}]}}`))
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"Results","is_final":true,"channel":{"alternatives":[{"transcript":"hello world"}]}}`))
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "dg-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
_ = gotAuth
var partials []string
chunks := make(chan []byte, 2)
chunks <- make([]byte, 320)
close(chunks)
final, err := tr.StreamTranscribe(context.Background(), chunks, func(text string, _ bool) {
partials = append(partials, text)
})
if err != nil {
t.Fatalf("StreamTranscribe: %v", err)
}
if final != "hello world" {
t.Errorf("final = %q, want 'hello world'", final)
}
if len(partials) == 0 || partials[0] != "hello" {
t.Errorf("expected an interim 'hello' partial, got %v", partials)
}
}
func TestDeepgramSampleRateDefault(t *testing.T) {
tr, _ := NewDeepgramTranscriber(DeepgramConfig{APIKey: "k"})
if RequiredSampleRate(tr) != DefaultSampleRate {
t.Errorf("Deepgram sample rate = %d, want %d", RequiredSampleRate(tr), DefaultSampleRate)
}
}
func TestDeepgramMissingKey(t *testing.T) {
if _, err := NewDeepgramTranscriber(DeepgramConfig{}); err == nil {
t.Fatal("expected setup error for missing key")
}
}
func TestOpenAIRealtimeStreamTranscribe(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
defer c.Close(websocket.StatusNormalClosure, "")
committed := false
for {
typ, data, err := c.Read(ctx)
if err != nil {
return
}
if typ != websocket.MessageText {
continue
}
var msg struct {
Type string `json:"type"`
}
_ = json.Unmarshal(data, &msg)
switch msg.Type {
case "transcription_session.update":
// Emit incremental deltas as if audio were flowing.
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"conversation.item.input_audio_transcription.delta","delta":"the "}`))
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"conversation.item.input_audio_transcription.delta","delta":"answer"}`))
case "input_audio_buffer.commit":
committed = true
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"input_audio_buffer.committed"}`))
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"conversation.item.input_audio_transcription.completed","transcript":"the answer"}`))
}
_ = committed
}
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
if RequiredSampleRate(tr) != openAIRealtimeSampleRate {
t.Errorf("OpenAI Realtime sample rate = %d, want %d", RequiredSampleRate(tr), openAIRealtimeSampleRate)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 480)
close(chunks)
done := make(chan struct{})
var final string
var ferr error
go func() {
final, ferr = tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("StreamTranscribe did not complete")
}
if ferr != nil {
t.Fatalf("StreamTranscribe: %v", ferr)
}
if final != "the answer" {
t.Errorf("final = %q, want 'the answer'", final)
}
}
// TestLocalStreamingAccumulatesSegments is the regression for the "text vanishes
// on stop" bug: the sherpa server reports text PER SEGMENT and resets it to "" when
// it endpoints, so the transcriber must accumulate across segments rather than let
// a new (empty) segment overwrite everything already recognized.
func TestLocalStreamingAccumulatesSegments(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
defer c.Close(websocket.StatusNormalClosure, "")
go func() { // drain the client's audio + "Done"
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
}
}()
for _, m := range []string{
`{"text":" hello","segment":0,"is_final":false}`,
`{"text":" hello world","segment":0,"is_final":true}`,
`{"text":"","segment":1,"is_final":false}`, // endpoint reset — must NOT wipe seg 0
`{"text":" foo bar","segment":1,"is_final":true}`,
`{"text":"","segment":2,"is_final":true}`, // trailing empty segment at stop
} {
_ = c.Write(ctx, websocket.MessageText, []byte(m))
}
_ = c.Write(ctx, websocket.MessageText, []byte("Done!"))
})
colon := strings.LastIndex(url, ":")
port, err := strconv.Atoi(url[colon+1:])
if err != nil {
t.Fatalf("parsing test server port from %q: %v", url, err)
}
mgr := NewServerManager(ServerConfig{
ModelPath: streamingModelDir(t),
Port: port,
starter: func(commandSpec) (processHandle, io.ReadCloser, error) { return newFakeProcess(), nil, nil },
healthCheck: func(context.Context, int) error { return nil },
aliveCheck: func(int) bool { return false },
})
tr := NewLocalStreamingTranscriber(mgr)
chunks := make(chan []byte)
close(chunks) // no audio; the writer immediately sends "Done"
var partials []string
text, err := tr.StreamTranscribe(context.Background(), chunks, func(s string, _ bool) {
partials = append(partials, s)
})
if err != nil {
t.Fatalf("StreamTranscribe: %v", err)
}
if text != "hello world foo bar" {
t.Errorf("final text = %q, want %q", text, "hello world foo bar")
}
// Once text has appeared, no later partial may go empty — that would be the
// segment-reset wiping the transcript, the exact bug this guards.
seen := false
for _, p := range partials {
if p != "" {
seen = true
}
if seen && p == "" {
t.Errorf("transcript went empty after text appeared; partials=%v", partials)
}
}
}
func TestParseDeepgramResultIgnoresNonResults(t *testing.T) {
if _, _, ok := parseDeepgramResult([]byte(`{"type":"Metadata"}`)); ok {
t.Error("Metadata messages should be ignored")
}
tr, final, ok := parseDeepgramResult([]byte(`{"type":"Results","is_final":true,"channel":{"alternatives":[{"transcript":"hi"}]}}`))
if !ok || tr != "hi" || !final {
t.Errorf("parse = (%q,%v,%v)", tr, final, ok)
}
}
func TestParseRealtimeEvent(t *testing.T) {
if e := parseRealtimeEvent([]byte(`{"type":"conversation.item.input_audio_transcription.delta","delta":"x"}`)); e.kind != realtimeDelta || e.text != "x" {
t.Errorf("delta parse: %+v", e)
}
if e := parseRealtimeEvent([]byte(`{"type":"error","error":{"message":"boom"}}`)); e.kind != realtimeError || e.text != "boom" {
t.Errorf("error parse: %+v", e)
}
}
+4 -28
View File
@@ -14,7 +14,6 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/redaction"
)
@@ -144,19 +143,12 @@ func providerConfigCheck(profile config.ProviderProfile) Check {
// Report credential PRESENCE, never the value. Reported under a non-sensitive
// key ("credentialConfigured"): the prior "apiKey" key was itself sensitive, so
// check()'s redaction scrubbed the indicator to [REDACTED] — making "set"/"not
// set" invisible. HasConfiguredCredential is the shared definition of
// "key-authed" (inline key, raw auth header, or a key in the encrypted
// credential store), matching ProviderSnapshot.APIKeySet — checking only the
// inline fields made doctor and `zero providers list` disagree about the
// same stored-key profile. A stored OAuth login counts too: a keyless
// token-login provider (e.g. ChatGPT) is fully able to make requests and
// must not fail the one tool meant to verify setup.
// set" invisible. A profile can authenticate via a raw auth-header value instead
// of APIKey, and both are trimmed so a whitespace-only value reads "not set"
// (matching ProviderSnapshot.APIKeySet).
credential := "not set"
switch {
case profile.HasConfiguredCredential():
if strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "" {
credential = "set"
case providerHasStoredOAuthLogin(profile):
credential = "oauth login"
}
details := map[string]any{
"name": profile.Name,
@@ -177,22 +169,6 @@ func providerConfigCheck(profile config.ProviderProfile) Check {
return check("provider.config", "Provider config", StatusPass, fmt.Sprintf("Provider config loaded for %s.", providerName(profile)), details)
}
// providerHasStoredOAuthLogin reports whether the token store holds a login for
// any of the profile's candidates — the same candidate set and normalized-key
// matching the runtime resolver uses. Errors degrade to false.
func providerHasStoredOAuthLogin(profile config.ProviderProfile) bool {
candidates := profile.OAuthLoginCandidates()
if len(candidates) == 0 {
return false
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return false
}
_, _, ok := oauth.FirstStored(store, candidates)
return ok
}
// localProviderBaseURL reports whether the configured base_url is a loopback host
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
func localProviderBaseURL(baseURL string) bool {
+6 -6
View File
@@ -122,17 +122,17 @@ func createSecretFile(path string) ([]byte, error) {
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
}
// Remember the lock-creation error itself rather than a subsequent
// "secret file doesn't exist yet" read error -- otherwise a real
// contention/ACL problem is masked by the expected-while-waiting
// ErrNotExist once the retries are exhausted.
lastErr = err
if data, rerr := readSecretFileRetry(path); rerr == nil {
return data, nil
} else {
lastErr = rerr
}
time.Sleep(secretRetryDelay)
}
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
if lastErr != nil {
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
}
return nil, fmt.Errorf("oauth: timed out waiting for token secret lock %s", lockPath)
}
func writeNewSecretFile(path string) ([]byte, error) {
+2 -10
View File
@@ -38,16 +38,8 @@ func ValidateKey(key string) error {
return nil
}
// ProviderKey builds the store key for a provider login, normalizing the name
// to lower case. Every write (Manager.Login, the ChatGPT flow) and every
// lookup (FirstStored, GetFresh, logout, status filters) funnels through here,
// so normalizing at this one choke point keeps them symmetric: without it,
// `zero auth login xAI` stored "provider:xAI" while the profile scaffolded for
// it looked up "provider:xai" case-sensitively — a fresh, successful login
// that was invisible to the runtime.
func ProviderKey(name string) string {
return KeyPrefixProvider + strings.ToLower(strings.TrimSpace(name))
}
// ProviderKey builds the store key for a provider login.
func ProviderKey(name string) string { return KeyPrefixProvider + name }
// FirstStored returns the token and its ProviderKey for the FIRST candidate name
// that has a token in the store, with ok=false when none do. Callers pass
-17
View File
@@ -126,20 +126,3 @@ func TestResolveStorePathHonorsOverride(t *testing.T) {
t.Fatalf("path = %q, want %q", path, override)
}
}
// TestProviderKeyNormalizesCase: every provider-login write and lookup funnels
// through ProviderKey, so `zero auth login xAI` and the scaffolded profile's
// candidate "xai" must resolve to the SAME store entry — the raw-typed casing
// previously produced an invisible login.
func TestProviderKeyNormalizesCase(t *testing.T) {
if got := ProviderKey(" xAI "); got != "provider:xai" {
t.Fatalf("ProviderKey must trim and lower-case, got %q", got)
}
store, _ := newTestStore(t)
if err := store.Save(ProviderKey("xAI"), Token{AccessToken: "tok"}); err != nil {
t.Fatalf("save: %v", err)
}
if _, key, ok := FirstStored(store, []string{"xai"}); !ok || key != "provider:xai" {
t.Fatalf("candidate \"xai\" must find the mixed-case login, ok=%v key=%q", ok, key)
}
}
+2 -2
View File
@@ -89,8 +89,8 @@ func RuntimeUnsupportedReason(descriptor Descriptor) string {
var descriptors = []Descriptor{
// GitLawb OpenGateway — the recommended default. An OpenAI-compatible gateway
// that smart-routes by model id across upstream providers (xiaomi-mimo,
// minimax, qwen, google, nvidia, tencent, z-ai). Flat /v1/chat/completions
// with a Bearer ogw_live_… key; listed first and badged in every picker.
// minimax, qwen, google, nvidia, z-ai). Flat /v1/chat/completions with a
// Bearer ogw_live_… key; listed first and badged in every picker.
recommended(openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://opengateway.gitlawb.com/v1", "mimo-v2.5-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway")),
openAI("openai", "OpenAI", "https://api.openai.com/v1", "gpt-4.1", []string{"OPENAI_API_KEY"}),
anthropic("anthropic", "Anthropic", "https://api.anthropic.com", "claude-sonnet-4.5", []string{"ANTHROPIC_API_KEY"}),
+3 -4
View File
@@ -140,13 +140,12 @@ var curatedModels = map[string][]Model{
"zai": zaiCuratedModels,
"zai-cn": zaiCuratedModels,
// OpenGateway smart-routes by model id across its upstream providers
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, tencent, z-ai).
// These are the curated coding defaults; the gateway accepts any model its
// upstreams expose, so users can also type an id the picker doesn't list.
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, z-ai). These are
// the curated coding defaults; the gateway accepts any model its upstreams
// expose, so users can also type an id the picker doesn't list.
"gitlawb-opengateway": {
{ID: "mimo-v2.5-pro", Description: "catalog default (Xiaomi MiMo)"},
{ID: "mimo-v2.5-pro-ultraspeed", Description: "fast model (Xiaomi MiMo)"},
{ID: "tencent/hy3", Description: "free Tencent HY3 model"},
{ID: "MiniMax-M3", Description: "MiniMax model"},
{ID: "qwen-plus", Description: "Qwen model"},
{ID: "gemini-2.5-pro", Description: "long-context model (Google)"},
@@ -47,11 +47,6 @@ func TestModelsAreProviderScoped(t *testing.T) {
want: []string{"glm-4.5", "glm-4.6", "glm-4.5-air", "glm-z1-air"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
provider: "gitlawb-opengateway",
want: []string{"mimo-v2.5-pro", "tencent/hy3"},
notWant: []string{"openai/gpt-4.1", "claude-sonnet-4.5"},
},
}
for _, tt := range tests {
+1 -62
View File
@@ -31,7 +31,7 @@ func LooksLikeCodingModelID(id string) bool {
"mistral", "codestral", "devstral", "magistral", "ministral",
"grok", "glm", "command", "nemotron", "mixtral", "coder",
"code", "chat", "instruct", "reasoner", "reasoning", "mimo",
"hy3", "tencent", "maverick", "scout", "bankr",
"maverick", "scout", "bankr",
} {
if strings.Contains(normalized, term) {
return true
@@ -57,67 +57,6 @@ func IsKnownNonCodingModelID(id string) bool {
return false
}
// IsSTTModel reports whether a model transcribes speech to text — the models
// IsKnownNonCodingModelID filters OUT of the coding picker, filtered back IN for
// the /stt-model picker.
//
// The reliable signal is the model's declared modalities, not its name: a
// transcriber consumes audio and emits text. Anything that emits audio is
// text-to-speech or speech-to-speech, never a transcriber, so audio output is a
// hard disqualifier. Only when a catalog entry carries no usable modality
// metadata (common for the Groq/OpenAI transcription models, which models.dev
// lists sparsely) do we fall back to a name heuristic.
func IsSTTModel(model Model) bool {
audioIn := containsFold(model.InputModalities, "audio")
audioOut := containsFold(model.OutputModalities, "audio")
textOut := containsFold(model.OutputModalities, "text")
// Emits audio → TTS or speech-to-speech, regardless of a transcription-like
// name. This is what stops gpt-4o-audio-preview / *-realtime from slipping in.
if audioOut {
return false
}
// Consumes audio and emits text (or emits nothing explicit) → transcriber.
if audioIn && (textOut || len(model.OutputModalities) == 0) {
return true
}
// Declares input modalities but none is audio → cannot be speech-to-text,
// whatever it is called.
if len(model.InputModalities) > 0 && !audioIn {
return false
}
// No usable modality metadata: fall back to the name heuristic.
return IsSTTModelID(model.ID)
}
// IsSTTModelID is the name-only heuristic used when a catalog entry declares no
// modalities. It matches the known transcription model families and explicitly
// rejects text-to-speech names, so it never confuses a TTS model for a
// transcriber. Weaker than the modality signal in IsSTTModel — a fallback, not
// the primary test.
func IsSTTModelID(id string) bool {
normalized := strings.ToLower(strings.TrimSpace(id))
if normalized == "" {
return false
}
// Text-to-speech / speech-synthesis names are never transcription.
for _, tts := range []string{"tts", "text-to-speech"} {
if strings.Contains(normalized, tts) {
return false
}
}
for _, term := range []string{
"whisper", "transcribe", "transcription",
"scribe", "parakeet", "canary", "moonshine",
"sense-voice", "sensevoice", "wav2vec", "conformer",
} {
if strings.Contains(normalized, term) {
return true
}
}
return false
}
func hasCodingTag(tags []string) bool {
for _, tag := range tags {
normalized := strings.ToLower(strings.TrimSpace(tag))
+1 -1
View File
@@ -11,7 +11,7 @@ import (
)
func TestLooksLikeCodingModelID(t *testing.T) {
coding := []string{"gpt-4o", "claude-sonnet-4.5", "o1", "o3-mini", "o4-mini", "qwen2.5-coder", "deepseek-chat", "kimi-k2", "codestral-latest", "grok-code", "tencent/hy3"}
coding := []string{"gpt-4o", "claude-sonnet-4.5", "o1", "o3-mini", "o4-mini", "qwen2.5-coder", "deepseek-chat", "kimi-k2", "codestral-latest", "grok-code"}
for _, id := range coding {
if !LooksLikeCodingModelID(id) {
t.Errorf("LooksLikeCodingModelID(%q) = false, want true", id)
+2 -13
View File
@@ -80,14 +80,6 @@ func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) {
"tags": ["coding", "free"],
"cost": {"input": 0, "output": 0}
},
{
"id": "tencent/hy3",
"name": "Tencent HY3",
"description": "free Tencent chat route",
"context_window": 262144,
"tools": true,
"tags": ["free"]
},
{
"id": "image-route",
"name": "Image Route",
@@ -100,8 +92,8 @@ func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) {
if err != nil {
t.Fatalf("ParseOpenGatewayCatalog returned error: %v", err)
}
if got := strings.Join(modelIDs(models), ","); got != "minimax-m3,tencent/hy3" {
t.Fatalf("models = %#v, want gateway coding models", got)
if got := strings.Join(modelIDs(models), ","); got != "minimax-m3" {
t.Fatalf("models = %#v, want one gateway coding model", got)
}
model := models[0]
if model.ID != "minimax-m3" || model.Description != "agentic coding route" {
@@ -116,9 +108,6 @@ func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) {
if model.Source != "opengateway" {
t.Fatalf("gateway source = %q, want opengateway", model.Source)
}
if models[1].ID != "tencent/hy3" || models[1].ContextWindow != 262144 || !models[1].ToolCall {
t.Fatalf("gateway HY3 model = %#v, want Tencent HY3 with context/tools", models[1])
}
}
func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
@@ -1,46 +0,0 @@
package providermodelcatalog
import "testing"
func TestIsSTTModelID(t *testing.T) {
in := []string{"whisper-1", "whisper-large-v3-turbo", "distil-whisper-large-v3-en",
"gpt-4o-transcribe", "gpt-4o-mini-transcribe", "nova-3-scribe", "parakeet-tdt-1.1b", "canary-1b"}
for _, id := range in {
if !IsSTTModelID(id) {
t.Errorf("%q should be an STT model by name", id)
}
}
out := []string{"gpt-4o", "claude-sonnet-4", "tts-1", "gpt-4o-mini-tts",
"dall-e-3", "text-to-speech-1", ""}
for _, id := range out {
if IsSTTModelID(id) {
t.Errorf("%q should NOT be an STT model by name", id)
}
}
}
func TestIsSTTModelUsesModalities(t *testing.T) {
// Audio in, text out → transcriber, even with an unfamiliar name.
if !IsSTTModel(Model{ID: "acme-speech-v2", InputModalities: []string{"audio"}, OutputModalities: []string{"text"}}) {
t.Error("audio-in/text-out model should be classified STT regardless of name")
}
// Audio out → TTS or speech-to-speech, never STT, even if the name says "transcribe".
if IsSTTModel(Model{ID: "weird-transcribe-tts", InputModalities: []string{"text"}, OutputModalities: []string{"audio"}}) {
t.Error("an audio-OUTPUT model must never be classified STT")
}
// Realtime speech-to-speech: audio in AND audio out → not a transcriber.
if IsSTTModel(Model{ID: "gpt-4o-realtime", InputModalities: []string{"audio", "text"}, OutputModalities: []string{"audio", "text"}}) {
t.Error("audio-in AND audio-out (speech-to-speech) must not be classified STT")
}
// Text-only LLM: input declared, no audio → not STT.
if IsSTTModel(Model{ID: "gpt-4o", InputModalities: []string{"text", "image"}, OutputModalities: []string{"text"}}) {
t.Error("a text/image-in model must not be classified STT")
}
// No modality metadata: fall back to the name heuristic.
if !IsSTTModel(Model{ID: "whisper-large-v3-turbo"}) {
t.Error("with no modalities, a whisper id should fall back to STT-true")
}
if IsSTTModel(Model{ID: "gpt-4o-mini-tts"}) {
t.Error("with no modalities, a tts id should be STT-false")
}
}
-50
View File
@@ -56,56 +56,6 @@ func TestNewCreatesOpenAIProviderWithFactoryOptions(t *testing.T) {
}
}
func TestNewPassesOpenGatewayHY3ModelThrough(t *testing.T) {
transport := &captureTransport{
responseBody: "data: [DONE]\n\n",
}
client := &http.Client{Transport: transport}
provider, err := New(config.ProviderProfile{
Name: "opengateway",
CatalogID: "gitlawb-opengateway",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "ogw_live_test",
Model: "tencent/hy3",
}, Options{HTTPClient: client})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
for range stream {
}
if transport.request.URL.String() != "https://opengateway.gitlawb.com/v1/chat/completions" {
t.Fatalf("request URL = %q, want OpenGateway chat completions", transport.request.URL.String())
}
var body map[string]any
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if body["model"] != "tencent/hy3" {
t.Fatalf("model = %q, want tencent/hy3 passthrough", body["model"])
}
metadata, err := ResolveRuntimeMetadata(config.ProviderProfile{
Name: "opengateway",
CatalogID: "gitlawb-opengateway",
ProviderKind: config.ProviderKindOpenAICompatible,
Model: "tencent/hy3",
}, Options{})
if err != nil {
t.Fatalf("ResolveRuntimeMetadata() error = %v", err)
}
if metadata.ProviderKind != config.ProviderKindOpenAICompatible || metadata.APIModel != "tencent/hy3" {
t.Fatalf("runtime metadata = %#v, want OpenAI-compatible tencent/hy3", metadata)
}
}
func TestNewThreadsCustomProviderHeaders(t *testing.T) {
transport := &captureTransport{
responseBody: "data: [DONE]\n\n",
+5 -16
View File
@@ -200,23 +200,20 @@ func normalizeHostScope(raw string) string {
// directory itself or any descendant; a host grant matches its exact normalized
// host. A narrower grant never covers a tool-wide request (reqScope == ""), so
// such a request re-prompts (fail-safe).
//
// File and dir comparisons go through filepath.Rel (via scopePathEqual and the
// shared pathWithinRoot helper) rather than a plain string comparison, so they
// apply the same case-folding as the workspace-boundary check on Windows --
// otherwise a persisted grant (including a deny) could be silently bypassed by
// spelling the same path with different case.
func grantCovers(grant Grant, reqScope string) bool {
switch grant.ScopeKind {
case ScopeToolWide:
return true
case ScopeFile:
return reqScope != "" && scopePathEqual(grant.Scope, reqScope)
return reqScope != "" && reqScope == grant.Scope
case ScopeDir:
if reqScope == "" || grant.Scope == "" {
return false
}
return pathWithinRoot(grant.Scope, reqScope)
if reqScope == grant.Scope {
return true
}
return strings.HasPrefix(reqScope, grant.Scope+string(filepath.Separator))
case ScopeHost:
return reqScope != "" && normalizeHostScope(reqScope) == normalizeHostScope(grant.Scope)
default:
@@ -224,14 +221,6 @@ func grantCovers(grant Grant, reqScope string) bool {
}
}
// scopePathEqual reports whether two absolute, cleaned scope paths refer to
// the same file, applying the same case-folding filepath.Rel already uses for
// directory-boundary checks (case-insensitive path components on Windows).
func scopePathEqual(a, b string) bool {
rel, err := filepath.Rel(a, b)
return err == nil && rel == "."
}
// scopeSpecificity ranks scope kinds so the most precise covering allow wins when
// several grants match the same request.
func scopeSpecificity(kind ScopeKind) int {
-24
View File
@@ -2,8 +2,6 @@ package sandbox
import (
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -236,25 +234,3 @@ func TestGrantCovers(t *testing.T) {
})
}
}
// TestGrantCoversCaseInsensitiveOnWindows guards against a persisted grant
// (including a deny) being silently bypassed by spelling the same path with
// different case, since Windows path components are case-insensitive.
func TestGrantCoversCaseInsensitiveOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("path case-insensitivity is a Windows-specific filesystem property")
}
dir := filepath.Join(string(filepath.Separator)+"proj", "src")
file := filepath.Join(dir, "main.go")
descendant := filepath.Join(dir, "api", "z.go")
fileGrant := Grant{Scope: file, ScopeKind: ScopeFile}
dirGrant := Grant{Scope: dir, ScopeKind: ScopeDir}
if !grantCovers(fileGrant, strings.ToUpper(file)) {
t.Fatalf("file grant %q should cover differently-cased request %q on Windows", file, strings.ToUpper(file))
}
if !grantCovers(dirGrant, strings.ToUpper(descendant)) {
t.Fatalf("dir grant %q should cover differently-cased descendant %q on Windows", dir, strings.ToUpper(descendant))
}
}
+8 -15
View File
@@ -26,8 +26,6 @@ const (
secretRetryDelay = 2 * time.Millisecond
)
var openSecretLockFile = os.OpenFile
// Crypter encrypts a blob at rest with AES-256-GCM under a per-user random secret
// persisted (0600) at secretPath.
type Crypter struct {
@@ -109,7 +107,7 @@ func createSecretFile(path string) ([]byte, error) {
lockPath := path + ".lock"
var lastErr error
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
lock, err := openSecretLockFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
_ = lock.Close()
defer os.Remove(lockPath)
@@ -120,25 +118,20 @@ func createSecretFile(path string) ([]byte, error) {
}
return writeNewSecretFile(path)
}
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry too -- mirroring oauth's createSecretFile --
// otherwise concurrent secret creation spuriously fails on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
if !errors.Is(err, os.ErrExist) {
return nil, fmt.Errorf("securefile: create secret lock: %w", err)
}
// Remember the lock-creation error itself rather than a subsequent
// "secret file doesn't exist yet" read error -- otherwise a real
// contention/ACL problem is masked by the expected-while-waiting
// ErrNotExist once the retries are exhausted.
lastErr = err
if data, rerr := readSecretFileRetry(path); rerr == nil {
return data, nil
} else {
lastErr = rerr
}
time.Sleep(secretRetryDelay)
}
return nil, fmt.Errorf("securefile: timed out waiting for secret %s: %w", path, lastErr)
if lastErr != nil {
return nil, fmt.Errorf("securefile: timed out waiting for secret %s: %w", path, lastErr)
}
return nil, fmt.Errorf("securefile: timed out waiting for secret lock %s", lockPath)
}
func writeNewSecretFile(path string) ([]byte, error) {
-27
View File
@@ -7,33 +7,6 @@ import (
"testing"
)
func TestCreateSecretFileRetriesOnPermissionContention(t *testing.T) {
secretPath := filepath.Join(t.TempDir(), "k.secret")
attempts := 0
origOpen := openSecretLockFile
openSecretLockFile = func(path string, flag int, perm os.FileMode) (*os.File, error) {
attempts++
if attempts == 1 {
return nil, os.ErrPermission
}
return os.OpenFile(path, flag, perm)
}
t.Cleanup(func() {
openSecretLockFile = origOpen
})
secret, err := createSecretFile(secretPath)
if err != nil {
t.Fatalf("createSecretFile should retry on permission contention: %v", err)
}
if len(secret) != secretBytes {
t.Fatalf("secret length = %d, want %d", len(secret), secretBytes)
}
if attempts < 2 {
t.Fatalf("expected at least 2 lock attempts, got %d", attempts)
}
}
func TestSealOpenRoundTrip(t *testing.T) {
secret := filepath.Join(t.TempDir(), "k.secret")
c := NewCrypter(secret)
+1 -15
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"log"
"strings"
"unicode/utf8"
)
type ExecMode string
@@ -222,24 +221,11 @@ func summarizePayload(payload any) string {
text = string(data)
}
if len(text) > 500 {
return truncateUTF8(text, 500)
return text[:500]
}
return text
}
// truncateUTF8 returns the longest prefix of s that is at most n bytes,
// backing off to the nearest rune boundary so a multi-byte character isn't
// split — a split rune here would embed invalid UTF-8 into the exec prompt.
func truncateUTF8(s string, n int) string {
if len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n]
}
func extractText(value any) string {
switch typed := value.(type) {
case string:
-18
View File
@@ -11,7 +11,6 @@ import (
"sync"
"testing"
"time"
"unicode/utf8"
)
func TestStoreCreatesAppendsListsAndReadsEvents(t *testing.T) {
@@ -641,23 +640,6 @@ func TestFormatExecPromptTruncatesConversationMessagesAfterFilteringNoise(t *tes
}
}
// A truncation cut at a raw byte offset can land in the middle of a
// multi-byte UTF-8 rune (e.g. CJK text), embedding invalid UTF-8 into the
// exec prompt. summarizePayload must back off to a rune boundary instead.
func TestSummarizePayloadTruncatesOnRuneBoundary(t *testing.T) {
content := strings.Repeat("中文", 300) // 900 bytes of 3-byte runes
payload := json.RawMessage(fmt.Sprintf(`{"role":"user","content":%q}`, content))
got := summarizePayload(payload)
if !utf8.ValidString(got) {
t.Fatalf("summarizePayload produced invalid UTF-8: %q", got)
}
if len(got) > 500 {
t.Fatalf("expected summary to be at most 500 bytes, got %d", len(got))
}
}
func TestPrepareExecPersistsSpecialistMetadataForNewSession(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T14:30:00Z")})
-25
View File
@@ -207,31 +207,6 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
}
}
// Operator/utility-name text that only appears inside a quoted argument must
// not be mistaken for real shell syntax — e.g. `echo "foo; ls -la"` is a
// harmless echo, not a bash-style `ls` invocation, and quoted `head`/`grep`
// text isn't a piped POSIX utility.
func TestDetectShellCommandIssueIgnoresQuotedContent(t *testing.T) {
for _, command := range []string{
`echo "foo; ls -la"`,
`echo "run head over the file"`,
`echo 'ls -la is a common bash command'`,
`echo "grep for the pattern"`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected quoted content to be ignored for %q, got %#v", command, issue)
}
}
// Sanity: the same utility names still get flagged when they're NOT quoted.
// cmd.exe (unlike bash) does not treat ; as a statement separator, so
// `echo "foo" ; ls -la` is a single echo invocation with literal
// arguments, not a real ls invocation, and must not be flagged.
if issue := detectShellCommandIssue(`git log | head`, "windows"); issue == nil {
t.Fatalf("expected unquoted shell syntax to still be flagged for %q", `git log | head`)
}
}
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
issue := detectShellOutputIssue("The syntax of the command is incorrect.", "windows")
if issue == nil {
+1 -1
View File
@@ -395,7 +395,7 @@ func TestSuggestionOverlayCapsRowsWithoutMoreText(t *testing.T) {
if strings.Contains(plain, "more") {
t.Fatalf("bare slash palette should not render a more-count row, got %q", plain)
}
if !strings.Contains(plain, "│ ❯ provider") || !strings.Contains(plain, "│ ps") {
if !strings.Contains(plain, "│ ❯ provider") || !strings.Contains(plain, "│ stop") {
t.Fatalf("top of palette should render first visible command window, got %q", plain)
}
if strings.Contains(plain, "compact") {
-95
View File
@@ -289,98 +289,3 @@ func TestDispatchOptionO(t *testing.T) {
t.Errorf("model.keyMatch should NOT match ctrl+o when option+o is configured")
}
}
// A configured binding equal to a reserved hardcoded chord must be reverted
// to default rather than silently making the hardcoded action unreachable
// (e.g. configuring toggleDetailed to ctrl+f would otherwise swallow the
// /model picker's "favorite" shortcut).
func TestSanitizeKeyBindingsDropsReservedCollision(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "ctrl+f", // collides with the hardcoded favorite-model shortcut
ToggleMouse: "ctrl+e", // unaffected, should survive untouched
}
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
if !sanitized.toggleDetailed.isZero() {
t.Errorf("toggleDetailed should be reverted to default, got %q", sanitized.toggleDetailed.Label())
}
if sanitized.toggleMouse.isZero() || sanitized.toggleMouse.Label() != "Ctrl+E" {
t.Errorf("toggleMouse should be untouched, got %q", sanitized.toggleMouse.Label())
}
if len(warnings) != 1 {
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
}
}
// Two configurable actions bound to the same chord must not both fire —
// sanitizeKeyBindings should keep the first and revert the rest to default.
func TestSanitizeKeyBindingsDropsMutualCollision(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "ctrl+o",
CycleReasoning: "ctrl+o", // collides with toggleDetailed above
}
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
if sanitized.toggleDetailed.isZero() || sanitized.toggleDetailed.Label() != "Ctrl+O" {
t.Errorf("toggleDetailed (first claimant) should keep ctrl+o, got %q", sanitized.toggleDetailed.Label())
}
if !sanitized.cycleReasoning.isZero() {
t.Errorf("cycleReasoning (second claimant) should be reverted to default, got %q", sanitized.cycleReasoning.Label())
}
if len(warnings) != 1 {
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
}
}
// Non-colliding configured bindings must pass through unchanged with no
// warnings.
func TestSanitizeKeyBindingsNoCollisions(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "option+o",
ToggleSidebar: "option+b",
}
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
if len(warnings) != 0 {
t.Fatalf("want no warnings, got %v", warnings)
}
if sanitized.toggleDetailed.Label() != "Alt+O" || sanitized.toggleSidebar.Label() != "Alt+B" {
t.Fatalf("bindings should be unchanged: toggleDetailed=%q toggleSidebar=%q",
sanitized.toggleDetailed.Label(), sanitized.toggleSidebar.Label())
}
}
// A configured binding must not shadow another action that is still on its
// built-in default chord.
func TestSanitizeKeyBindingsDropsCollisionWithOtherDefault(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "ctrl+t", // collides with cycleReasoning default
}
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
if !sanitized.toggleDetailed.isZero() {
t.Errorf("toggleDetailed should be reverted to default, got %q", sanitized.toggleDetailed.Label())
}
if !sanitized.cycleReasoning.isZero() {
t.Errorf("cycleReasoning should remain default (zero), got %q", sanitized.cycleReasoning.Label())
}
if len(warnings) != 1 {
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
}
}
// Bare hardcoded keys handled directly in updateModel (e.g. Tab navigation)
// are reserved too and must not be shadowed by configurable bindings.
func TestSanitizeKeyBindingsDropsBareHardcodedCollision(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "tab",
}
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
if !sanitized.toggleDetailed.isZero() {
t.Errorf("toggleDetailed should be reverted to default, got %q", sanitized.toggleDetailed.Label())
}
if len(warnings) != 1 {
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
}
}
+20 -79
View File
@@ -3,7 +3,6 @@ package tui
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -11,10 +10,8 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/credstore"
"github.com/Gitlawb/zero/internal/doctor"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
@@ -496,37 +493,29 @@ func (m model) handleModelCommand(args string) (model, string) {
// picker calls this when a model from a non-active provider is chosen, so the
// picker can list every saved provider and switch across them (like a unified
// provider+model selector). The key is loaded from the encrypted store / env.
// The returned bool reports whether the switch actually committed — callers
// that branch on the outcome (the provider manager) must use it, never the
// display text: UI copy is not a control-flow contract (a refusal quoting a
// provider name could contain any substring, and rewording the success line
// must not change behavior).
func (m model) switchProviderModel(providerName, modelID string) (model, string, bool, tea.Cmd) {
func (m model) switchProviderModel(providerName, modelID string) (model, string, tea.Cmd) {
if m.pending {
return m, "Model\nCannot switch providers while a run is active.", false, nil
return m, "Model\nCannot switch providers while a run is active.", nil
}
if m.newProvider == nil {
return m, "Model\nProvider rebuild is not available for this TUI session.", false, nil
return m, "Model\nProvider rebuild is not available for this TUI session.", nil
}
target, ok := m.savedProviderByName(providerName)
if !ok {
return m, "Model\nunknown provider " + strconv.Quote(providerName), false, nil
return m, "Model\nunknown provider " + strconv.Quote(providerName), nil
}
target = m.profileWithCredential(target)
target.Model = strings.TrimSpace(modelID)
descriptor, hasDescriptor := m.descriptorForProfile(target)
// Gate on the resolved credential, not the APIKeyStored marker: if the stored key
// was deleted/unreadable the marker can still be set, and building a keyless
// provider would only fail later with a 401. Local/no-auth providers need no key,
// and a stored OAuth login (e.g. ChatGPT) is a credential too — the profile stays
// keyless on purpose so newProvider attaches the bearer resolver + login key.
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" &&
!(hasDescriptor && descriptor.Local) && !oauthLoginAvailable(target) {
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", false, nil
// provider would only fail later with a 401. Local/no-auth providers need no key.
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" && !(hasDescriptor && descriptor.Local) {
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", nil
}
next, err := m.newProvider(target)
if err != nil {
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), false, nil
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), nil
}
m.provider = next
m.providerProfile = target
@@ -554,80 +543,32 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
if warn := m.visionDropWarning(); warn != "" {
status += "\n" + warn
}
return m, status, true, tea.Batch(cmds...)
return m, status, tea.Batch(cmds...)
}
// profileWithCredential fills a profile's APIKey for provider construction the same
// way the runtime resolves it: a stored key (encrypted credstore), then an env var.
// The config resolver is pure (no secret I/O), so a stored-key profile carries an
// empty APIKey until this runs — every place that rebuilds the provider (model
// switch, provider switch) must call this or the request goes out with no key.
//
// A stored OAuth bearer is deliberately NOT copied into APIKey: an inline key makes
// HasConfiguredCredential true, which strips the OAuth login candidates at build
// time, so newProvider would construct the client without the bearer resolver and
// login key — for ChatGPT (Codex) that drops the `chatgpt-account-id` header and
// token refresh, 401-ing every call. Keyless is the correct shape for a token-login
// profile; newProvider resolves the login itself.
// way the runtime resolves it: a stored key (encrypted credstore), then an env var,
// then a stored OAuth bearer for token-login providers. The config resolver is pure
// (no secret I/O), so a stored-key profile carries an empty APIKey until this runs —
// every place that rebuilds the provider (model switch, provider switch) must call
// this or the request goes out with no key.
func (m model) profileWithCredential(profile config.ProviderProfile) config.ProviderProfile {
if strings.TrimSpace(profile.APIKey) == "" {
if store, err := m.providerKeyStore(); err == nil {
if store, err := config.ProviderKeyStore(); err == nil {
profile = config.ApplyStoredAPIKey(profile, store)
}
}
if strings.TrimSpace(profile.APIKey) == "" && strings.TrimSpace(profile.APIKeyEnv) != "" {
profile.APIKey = strings.TrimSpace(os.Getenv(profile.APIKeyEnv))
}
if descriptor, ok := m.descriptorForProfile(profile); ok && strings.TrimSpace(profile.APIKey) == "" && descriptor.OAuth && !descriptor.OAuthMintsKey {
if token := oauthStoredToken(m.ctx, descriptor.ID); token != "" {
profile.APIKey = token
}
}
return profile
}
// providerKeyStore opens the credential store co-located with THIS session's
// config path — the store every TUI write path (SecureProviderProfile capture,
// rename migration, delete) uses. Reading from the default-path store instead
// makes a stored key invisible whenever userConfigPath is non-default, so the
// switch gate rejects a provider whose key is right there beside its config.
// Ephemeral sessions without a config path fall back to the default store.
func (m model) providerKeyStore() (*credstore.Store, error) {
return providerKeyStoreForPath(m.userConfigPath)
}
func providerKeyStoreForPath(configPath string) (*credstore.Store, error) {
if strings.TrimSpace(configPath) != "" {
return config.ProviderKeyStoreAt(filepath.Dir(configPath))
}
return config.ProviderKeyStore()
}
// oauthLoginAvailable reports whether a stored OAuth login exists for any of the
// profile's login candidates — the SAME candidate set the runtime bearer resolver
// uses (profile name, then catalog id, only for keyless profiles), so the switch
// gate and the runtime can never disagree about whether a login counts.
func oauthLoginAvailable(profile config.ProviderProfile) bool {
_, ok := oauthLoginName(profile)
return ok
}
// oauthLoginName resolves WHICH stored login serves this profile — the same
// FirstStored selection the runtime makes. User-facing hints must name this
// entry, not the profile: after a rename ({name:"codex", catalogID:"chatgpt"})
// the token lives under the catalog id, and `zero auth logout codex` would
// delete nothing while the real login stays behind.
func oauthLoginName(profile config.ProviderProfile) (string, bool) {
candidates := profile.OAuthLoginCandidates()
if len(candidates) == 0 {
return "", false
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return "", false
}
_, key, ok := oauth.FirstStored(store, candidates)
if !ok {
return "", false
}
return strings.TrimPrefix(key, oauth.KeyPrefixProvider), true
}
func (m model) savedProviderByName(name string) (config.ProviderProfile, bool) {
name = strings.TrimSpace(name)
for _, profile := range m.savedProviders {
+2 -13
View File
@@ -419,23 +419,12 @@ func (m model) providerText() string {
}
snapshot := zerocommands.ProviderSnapshotFromProfile(m.providerProfile, true)
// A keyless profile backed by a stored OAuth login (e.g. ChatGPT) is fully
// authenticated — without this, /provider status showed a WORKING provider
// as "api key: not set" with a warning, unlike `zero providers list` which
// already fills OAuthLogin.
if !snapshot.APIKeySet {
snapshot.OAuthLogin = oauthLoginAvailable(m.providerProfile)
}
credentialState := apiKeyState(snapshot.APIKeySet)
if !snapshot.APIKeySet && snapshot.OAuthLogin {
credentialState = "oauth login"
}
profileLines = append(profileLines,
"active: "+boolText(snapshot.Active),
"kind: "+displayValue(snapshot.ProviderKind, "unknown"),
"api model: "+displayValue(snapshot.APIModel, "unknown"),
"base url: "+displayValue(snapshot.BaseURL, "default"),
"api key: "+credentialState,
"api key: "+apiKeyState(snapshot.APIKeySet),
)
if snapshot.Message != "" {
profileLines = append(profileLines, "provider status: "+snapshot.Status+" - "+snapshot.Message)
@@ -443,7 +432,7 @@ func (m model) providerText() string {
status := commandStatusOK
actionLines := providerNextActionLines(m.providerProfile, snapshot, m.providerName)
if providerCredentialRequired(m.providerProfile, snapshot.ProviderKind) && !providerProfileHasCredential(m.providerProfile) && !snapshot.OAuthLogin {
if providerCredentialRequired(m.providerProfile, snapshot.ProviderKind) && !providerProfileHasCredential(m.providerProfile) {
status = commandStatusWarning
}
return renderCommandOutput(commandOutput{
+2 -18
View File
@@ -48,8 +48,6 @@ const (
commandNew
commandSkills
commandLoop
commandVoice
commandSTTModel
commandUnknown
)
@@ -81,9 +79,9 @@ type parsedCommand struct {
var commandDefinitions = []commandDefinition{
{
name: "/provider",
usage: "/provider [add|status]",
usage: "/provider [status]",
group: commandGroupModel,
description: "Manage providers: activate, add, edit, delete.",
description: "Open provider setup.",
kind: commandProvider,
},
{
@@ -93,20 +91,6 @@ var commandDefinitions = []commandDefinition{
description: "Show or switch the active model.",
kind: commandModel,
},
{
name: "/stt-model",
usage: "/stt-model",
group: commandGroupModel,
description: "Choose the speech-to-text (dictation) model.",
kind: commandSTTModel,
},
{
name: "/voice",
usage: "/voice",
group: commandGroupRuntime,
description: "Toggle voice mode (hold Space to dictate).",
kind: commandVoice,
},
{
name: "/plan",
usage: "/plan",
+1 -1
View File
@@ -45,7 +45,7 @@ func TestFormatCommandHelpLinesGroupsCommandsByStableOrder(t *testing.T) {
for _, want := range []string{
"model:",
" /provider [add|status] - Manage providers: activate, add, edit, delete.",
" /provider [status] - Open provider setup.",
" /model [list|id] - Show or switch the active model.",
" /effort [list|low|medium|high|auto] - Show or set reasoning effort for supported models.",
"session:",
-596
View File
@@ -1,596 +0,0 @@
package tui
import (
"context"
"errors"
"fmt"
"math"
"path/filepath"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/dictation"
)
// Transcriber and Recorder are re-exported so Options and the model can refer to
// the dictation interfaces without importing the package everywhere.
type (
Transcriber = dictation.Transcriber
Recorder = dictation.Recorder
)
// dictationPhase is the recording state machine (§10): idle → recording →
// transcribing → idle. Voice mode (§13.9) changes which key enters it, not the
// machine itself.
type dictationPhase int
const (
dictIdle dictationPhase = iota
dictStarting
dictRecording
dictTranscribing
)
// dictationController holds the live dictation session. It is a value field on
// the model (threaded through Update by value); the recorder/cancel it captures
// are pointers, so they survive across frames and are shared with the async
// commands that operate on them.
type dictationController struct {
cfg config.STTConfig
build func(cfg config.STTConfig, preferStreaming bool) (Transcriber, bool, error)
shutdownServer func(context.Context) error
keyAvailable func(provider string) bool
saveKey func(provider, key string) error
phase dictationPhase
platform dictation.Platform
// downloadRoot is where the auto-download stores the engine + model ("" =
// auto-download disabled). userConfigPath is where the resolved paths persist.
downloadRoot string
userConfigPath string
// downloading: a model download is in flight (started from the /stt-model
// download chooser). downloadStatus is the live progress line shown in the
// status bar while downloading. browseLoading: the full model list is being
// fetched; browseVariants caches it once fetched.
downloading bool
downloadStatus string
browseLoading bool
browseVariants []dictation.ModelVariant
// in-flight session state
streaming bool
recorder Recorder
transcriber Transcriber
ctx context.Context
cancel context.CancelFunc
streamStop func() error
// live streaming render region: [regionStart, regionEnd) in the composer's
// rune space, replaced wholesale on each partial (§13.8). regionPrefix is a
// separator space folded into the region so a cancel restores the original
// text exactly.
regionActive bool
regionStart int
regionEnd int
regionPrefix string
// regionAnchor snapshots the text BEFORE the live region, so the next
// partial can detect external edits (typing, paste) and shift [start,end)
// to stay aligned. Updated alongside regionStart on each render.
regionAnchor string
// waveBars is the recording waveform's recent bar heights (a scrolling ring):
// audio-reactive from live mic levels on the streaming path, synthetic on the
// batch path. waveTick drives the batch synthetic animation.
waveBars []int
waveTick int
// voiceModeEnabled repurposes Space into hold-to-record (§13.9).
voiceModeEnabled bool
// eventTypesSupported records whether the terminal confirmed key-release
// reporting (Kitty protocol); eventTypesKnown gates it until the terminal has
// answered. Determines hold-to-record vs. press-to-toggle for voice mode.
eventTypesKnown bool
eventTypesSupported bool
// spaceHeld tracks a held Space in hold-to-record mode; voiceStopPending
// remembers a release that arrived before the recording finished starting.
spaceHeld bool
voiceStopPending bool
}
// dictationStartedMsg reports the outcome of Recorder.Start().
type dictationStartedMsg struct {
err error
}
// dictationTranscribedMsg carries the final transcript of a batch (or a
// completed streaming) recording.
type dictationTranscribedMsg struct {
text string
err error
submit bool
streaming bool
}
// newDictationController builds the controller from resolved options.
func newDictationController(opts Options) dictationController {
return dictationController{
cfg: opts.STT,
build: opts.BuildDictationTranscriber,
shutdownServer: opts.ShutdownDictationServer,
keyAvailable: opts.STTKeyStatus,
saveKey: opts.SaveSTTKey,
platform: dictation.DetectPlatform(),
downloadRoot: opts.STTDownloadRoot,
userConfigPath: opts.UserConfigPath,
}
}
// available reports whether dictation is wired up (a transcriber factory exists).
func (d dictationController) available() bool { return d.build != nil }
// currentModelLabel names the STT model currently configured, for status hints.
func (d dictationController) currentModelLabel() string {
cfg := d.cfg
switch cfg.STTProvider() {
case config.STTProviderLocal:
if strings.TrimSpace(cfg.LocalModelPath) == "" {
return "Local (no model set)"
}
// "Local" prefix mirrors the "Groq …"/"OpenAI …" cloud labels so the status
// line always names the backend, not just the model.
// Prefer a curated variant's friendly name when the path matches one.
for _, v := range dictation.ModelVariants() {
if v.DirName != "" && strings.Contains(cfg.LocalModelPath, v.DirName) {
return "Local · " + v.Label
}
}
return "Local · " + strings.TrimPrefix(filepath.Base(cfg.LocalModelPath), "sherpa-onnx-")
case config.STTProviderGroq:
return "Groq " + firstNonEmptyStr(cfg.Model, "whisper-large-v3-turbo")
case config.STTProviderOpenAI:
return "OpenAI " + firstNonEmptyStr(cfg.Model, "whisper-1")
}
return string(cfg.STTProvider())
}
func firstNonEmptyStr(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
// active reports whether a recording/transcription is in flight.
func (d dictationController) active() bool { return d.phase != dictIdle }
// toggleDictation starts a recording when idle and stops-and-transcribes when
// recording. Invoked by the voice-mode Space gesture; a call during
// startup/transcription is ignored (the machine is mid-transition).
func (m model) toggleDictation() (model, tea.Cmd) {
if !m.dictation.available() {
return m.appendSystemNotice("Dictation is not configured. See docs/dictation.md to set up a local engine or a Groq/OpenAI key."), nil
}
if m.dictation.downloading {
return m.appendDictationNotice("downloading", "Still setting up the dictation engine — one moment."), nil
}
switch m.dictation.phase {
case dictIdle:
return m.startDictation()
case dictRecording:
return m.stopDictation()
default:
// dictStarting / dictTranscribing: mid-transition, ignore.
return m, nil
}
}
// toggleVoiceMode flips the /voice hold-to-record gesture — the dictation
// trigger. While on, Space records; run /voice again to type spaces normally.
func (m model) toggleVoiceMode() (model, tea.Cmd) {
if !m.dictation.available() {
return m.appendSystemNotice("Dictation is not configured. See docs/dictation.md to set up a local engine or a Groq/OpenAI key."), nil
}
m.dictation.voiceModeEnabled = !m.dictation.voiceModeEnabled
if m.dictation.voiceModeEnabled {
return m.appendSystemNotice("Voice mode on (" + m.dictation.currentModelLabel() + ") — hold Space to dictate, release to transcribe. Run /voice again to turn it off (so Space types normally)."), nil
}
// Turning voice off is the "done dictating" signal, so release the warm sherpa
// streaming server — otherwise a loaded model keeps idling in RAM (and holding
// its port) until the app exits. It respawns lazily on the next streaming
// recording. Skip while a recording is still in flight so we don't kill it.
next := m.appendSystemNotice("Voice mode off.")
if next.dictation.active() {
return next, nil
}
return next, next.releaseDictationServerCmd()
}
// releaseDictationServerCmd tears down the warm sherpa-onnx streaming server off
// the UI goroutine (Shutdown waits on the process). No-op when no server backend
// is wired or none is running.
func (m model) releaseDictationServerCmd() tea.Cmd {
shutdown := m.dictation.shutdownServer
if shutdown == nil {
return nil
}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = shutdown(ctx)
return nil
}
}
// startDictation builds the recorder + transcriber and kicks off capture.
func (m model) startDictation() (model, tea.Cmd) {
transcriber, streaming, err := m.dictation.build(m.dictation.cfg, m.dictation.wantStreaming())
if err != nil {
// A missing local engine on a platform we can auto-download for → point at
// /stt-model, which offers the download-a-model chooser (a robust single
// selection rather than a press-again-to-confirm dance).
if m.dictation.canOfferDownload(err) {
return m.appendDictationNotice("download-offer",
"No local dictation model yet. Run /stt-model to choose and download one — or set stt.localModelPath yourself, or pick a cloud provider."), nil
}
return m.appendDictationNotice("setup:"+err.Error(), dictationErrorText(err)), nil
}
rec := dictation.NewRecorder(dictation.RecorderOptions{
Platform: m.dictation.platform,
SampleRate: dictation.RequiredSampleRate(transcriber),
MaxDuration: m.dictation.maxDuration(),
SilenceAutoStop: m.dictation.cfg.SilenceAutoStopEnabled(),
WindowsAudioDevice: m.dictation.cfg.WindowsAudioDevice,
})
base := m.ctx
if base == nil {
base = context.Background()
}
ctx, cancel := context.WithCancel(base)
m.dictation.recorder = rec
m.dictation.transcriber = transcriber
m.dictation.ctx = ctx
m.dictation.cancel = cancel
m.dictation.streaming = streaming
m.dictation.phase = dictStarting
if streaming {
return m.startStreamingDictation()
}
return m, startBatchRecordingCmd(rec)
}
// stopDictation ends a recording. For batch it triggers Stop()+Transcribe(); for
// streaming it stops the recorder, which closes the chunk channel and lets the
// in-flight StreamTranscribe return its final text.
func (m model) stopDictation() (model, tea.Cmd) {
m.dictation.phase = dictTranscribing
if m.dictation.streaming {
if m.dictation.streamStop != nil {
_ = m.dictation.streamStop()
}
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())
}
// cancelDictation aborts an in-flight recording without transcribing. Bound to
// Esc while a session is active.
func (m model) cancelDictation() (model, tea.Cmd) {
if m.dictation.cancel != nil {
m.dictation.cancel()
}
if m.dictation.streaming && m.dictation.streamStop != nil {
_ = m.dictation.streamStop()
} else if m.dictation.recorder != nil && m.dictation.phase != dictTranscribing {
// Best-effort: stop the capture tool so the microphone is released.
go func(r Recorder) { _, _ = r.Stop() }(m.dictation.recorder)
}
m = m.discardDictationRegion()
m.dictation.reset()
return m.appendSystemNotice("Dictation cancelled."), nil
}
// wantStreaming decides whether this recording uses the streaming pipeline:
// enabled in config and supported on this platform (desktop only — Termux's mic
// tool has no stdout mode).
func (d dictationController) wantStreaming() bool {
return d.cfg.StreamingEnabled() && d.platform.StreamingSupported()
}
func (d dictationController) maxDuration() time.Duration {
if d.cfg.MaxDurationSeconds > 0 {
return time.Duration(d.cfg.MaxDurationSeconds) * time.Second
}
return dictation.DefaultMaxDuration
}
func (d *dictationController) reset() {
d.phase = dictIdle
d.recorder = nil
d.transcriber = nil
d.ctx = nil
d.cancel = nil
d.streaming = false
d.streamStop = nil
d.regionActive = false
d.regionPrefix = ""
d.waveBars = nil
d.waveTick = 0
}
// 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 {
return func() tea.Msg {
return dictationStartedMsg{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 {
if ctx == nil {
ctx = context.Background()
}
return func() tea.Msg {
audio, err := rec.Stop()
if err != nil {
return dictationTranscribedMsg{err: err}
}
text, err := transcriber.Transcribe(ctx, audio)
return dictationTranscribedMsg{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.err != nil {
m = m.discardDictationRegion()
m.dictation.reset()
text := dictationErrorText(msg.err)
return m.appendDictationNotice("dicterr:"+text, text), nil
}
// A stop or cancel may have already advanced the phase; only arm recording if
// we are still starting.
if m.dictation.phase == dictStarting {
m.dictation.phase = dictRecording
}
// A voice-mode Space release that arrived mid-startup asked us to stop as soon
// as recording began.
if m.dictation.voiceStopPending && m.dictation.phase == dictRecording {
m.dictation.voiceStopPending = false
return m.stopDictation()
}
// Kick off the recording waveform animation.
return m, recTickCmd()
}
// 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) {
streaming := m.dictation.streaming || msg.streaming
m = m.commitDictationRegion()
// A streaming session can end via a transcriber error (not just a user stop), so
// tear the capture down here BEFORE reset() drops the handles: stop the recorder
// (kills the mic subprocess) and cancel the context (unblocks the audio tap).
// Without this, an error path leaves the mic running and the next attempt hits
// "already recording". Both are idempotent, so this is safe on the happy path.
if streaming {
if m.dictation.streamStop != nil {
_ = m.dictation.streamStop()
}
if m.dictation.cancel != nil {
m.dictation.cancel()
}
}
m.dictation.reset()
if msg.err != nil && !errors.Is(msg.err, context.Canceled) {
// 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.
if next, handled := m.maybeOfferKeyOnAuthError(msg.err); handled {
return next, nil
}
// Partial text may still be worth keeping on a mid-stream failure (§6).
if strings.TrimSpace(msg.text) != "" && !streaming {
m = m.insertDictatedText(msg.text)
return m.appendSystemNotice("Dictation interrupted; kept the partial transcript. " + dictationErrorText(msg.err)), nil
}
// Keyed so hammering the dictation gesture against the same unusable model doesn't spam the
// identical error line over and over.
text := dictationErrorText(msg.err)
return m.appendDictationNotice("dicterr:"+text, text), nil
}
if strings.TrimSpace(msg.text) == "" {
if streaming {
return m, nil // streaming already rendered live; nothing to add
}
return m.appendSystemNotice("No speech detected."), nil
}
if !streaming {
m = m.insertDictatedText(msg.text)
}
if msg.submit {
// stt.autoSubmit fires the transcript straight at the agent (off by
// default — insert-for-review is the safety net for a misheard prompt).
return m.handleSubmit()
}
return m, nil
}
// insertDictatedText inserts transcribed text at the composer cursor, adding a
// separating space when the existing text would otherwise run into it.
func (m model) insertDictatedText(text string) model {
text = strings.TrimSpace(text)
if text == "" {
return m
}
state := m.currentComposerState()
if needsLeadingSpace(state) {
text = " " + text
}
m.setComposerState(insertComposerText(state, text))
return m
}
func needsLeadingSpace(state composerState) bool {
runes := []rune(state.text)
if state.cursor <= 0 || state.cursor > len(runes) {
return false
}
prev := runes[state.cursor-1]
return prev != ' ' && prev != '\n' && prev != '\t'
}
// dictationStatusChip renders the active-recording indicator for the status
// line: a live animated waveform plus how to stop. Empty when idle.
func (m model) dictationStatusChip() string {
switch m.dictation.phase {
case dictStarting, dictRecording:
stop := "release Space to stop"
if !m.dictation.eventTypesSupported {
stop = "press Space to stop" // press-to-toggle fallback
}
wave := zeroTheme.amber.Render("● " + renderWaveBars(m.dictation.waveBars) + " REC")
return wave + zeroTheme.muted.Render(" · "+stop+", Esc to cancel")
case dictTranscribing:
return zeroTheme.accent.Render("●") + " " + zeroTheme.muted.Render("transcribing…")
}
return ""
}
const (
waveBarCount = 14
recTickInterval = 90 * time.Millisecond
)
// waveRunes maps a 0..8 bar height to a block glyph.
var waveRunes = []rune(" ▁▂▃▄▅▆▇█")
// renderWaveBars draws the scrolling bar heights as block glyphs.
func renderWaveBars(bars []int) string {
if len(bars) == 0 {
return strings.Repeat(" ", waveBarCount)
}
out := make([]rune, len(bars))
for i, h := range bars {
if h < 0 {
h = 0
}
if h > 8 {
h = 8
}
out[i] = waveRunes[h]
}
return string(out)
}
// pushLevel appends a new bar height on the right and scrolls the ring left.
func (d *dictationController) pushLevel(height int) {
nb := make([]int, waveBarCount)
if len(d.waveBars) == waveBarCount {
copy(nb, d.waveBars[1:])
}
nb[waveBarCount-1] = height
d.waveBars = nb
}
// levelHeight maps a 0..1 audio level to a 0..8 bar height.
func levelHeight(level float64) int {
h := int(level*8 + 0.5)
if h < 0 {
h = 0
}
if h > 8 {
h = 8
}
return h
}
// sttLevelMsg carries a live microphone level (0..1) from the streaming audio
// tap, driving the real audio-reactive waveform.
type sttLevelMsg struct{ level float64 }
// handleDictationLevel pushes a real mic level onto the waveform.
func (m model) handleDictationLevel(msg sttLevelMsg) model {
if m.dictation.active() {
m.dictation.pushLevel(levelHeight(msg.level))
}
return m
}
// recTickMsg drives the BATCH synthetic waveform (batch records to a file, so
// there is no live audio to react to). The streaming path animates from real
// levels instead (sttLevelMsg) and does not tick.
type recTickMsg struct{}
func recTickCmd() tea.Cmd {
return tea.Tick(recTickInterval, func(time.Time) tea.Msg { return recTickMsg{} })
}
// syntheticWaveLevel synthesizes an organic 0..8 bar height for the batch
// recording animation (batch records to a file, so there is no live signal to
// react to). Rather than loop a fixed pulse — which reads as mechanical — it
// layers a few incommensurate sinusoids so the envelope wanders and doesn't
// obviously repeat, plus a hash-based per-frame jitter for texture and an
// occasional near-silent dip so it breathes like real speech.
func syntheticWaveLevel(tick int) int {
t := float64(tick)
amp := 0.5 +
0.24*math.Sin(t*0.33) +
0.15*math.Sin(t*0.12+1.7) +
0.12*math.Sin(t*0.71+0.4) +
0.08*math.Sin(t*1.30+2.2) // faster wiggle so it never flatlines
// Deterministic jitter in ~[-0.09, 0.09] from a cheap integer hash of the tick.
h := uint32(tick)*2654435761 + 1013904223
amp += (float64(h>>16&0xffff)/0xffff - 0.5) * 0.18
// A slow, shallow gate occasionally eases the level down (a spoken pause) without
// ever flatlining, so the wave keeps breathing instead of humming a fixed shape.
if gate := math.Sin(t*0.09 + 0.6); gate < -0.5 {
amp *= 0.55
}
switch {
case amp < 0:
amp = 0
case amp > 1:
amp = 1
}
return levelHeight(amp)
}
// handleRecTick advances the batch synthetic waveform and stops when recording ends.
func (m model) handleRecTick() (model, tea.Cmd) {
if m.dictation.phase == dictRecording || m.dictation.phase == dictStarting {
m.dictation.waveTick++
m.dictation.pushLevel(syntheticWaveLevel(m.dictation.waveTick))
return m, recTickCmd()
}
return m, nil
}
// voiceModeIndicator renders the persistent "voice mode on" hint shown while
// idle so the user knows Space is repurposed to hold-to-record (§10).
func (m model) voiceModeIndicator() string {
if !m.dictation.voiceModeEnabled {
return ""
}
return zeroTheme.accent.Render("🎙 voice") + zeroTheme.muted.Render(" · "+m.dictation.currentModelLabel())
}
// dictationErrorText renders a dictation error for the transcript: a missing-
// tool setup error becomes actionable guidance, everything else is shown plainly.
func dictationErrorText(err error) string {
var setupErr *dictation.SetupError
if errors.As(err, &setupErr) {
return "Dictation unavailable: " + setupErr.Error()
}
return fmt.Sprintf("Dictation failed: %v", err)
}
-127
View File
@@ -1,127 +0,0 @@
package tui
import (
"context"
"errors"
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/dictation"
)
// sttDownloadProgressMsg carries a live download-status line (with a
// percentage) into the TUI, injected from the download goroutine via
// runtimeMessageSink.
type sttDownloadProgressMsg struct{ status string }
// dictationDownloadedMsg reports the outcome of an auto-download.
type dictationDownloadedMsg struct {
components dictation.EngineComponents
err error
// streaming is the chosen variant's pipeline (a streaming transducer vs a
// batch model), applied to the config after a successful download.
streaming bool
}
// canOfferDownload reports whether a failed build should point the user at the
// /stt-model download chooser: the failure is a missing-setup error, the user is
// on the local provider, this platform has a prebuilt engine, and a download
// root is configured.
func (d dictationController) canOfferDownload(err error) bool {
var setupErr *dictation.SetupError
return d.downloadRoot != "" && !d.downloading &&
d.cfg.STTProvider() == config.STTProviderLocal &&
dictation.AutoDownloadSupported() &&
errors.As(err, &setupErr)
}
// startVariantDownload fetches the sherpa-onnx engine + the chosen model
// variant, reporting progress live, then persists and applies the resolved paths.
func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
if m.dictation.downloading {
return m.appendSystemNotice("A dictation download is already in progress — please wait for it to finish."), nil
}
m.dictation.downloading = true
sink := m.runtimeMessageSink
root := m.dictation.downloadRoot
version := m.dictation.cfg.EngineVersion
base := m.ctx
if base == nil {
base = context.Background()
}
m.dictation.downloadStatus = "Starting download…"
m = m.appendSystemNotice(fmt.Sprintf("Setting up local dictation — downloading the offline engine and %s (~%d MB). This runs once; progress is shown in the status bar below.", v.Label, (v.Bytes>>20)+(engineDownloadBytes>>20)))
return m, func() tea.Msg {
comp, err := dictation.EnsureLocalEngine(base, dictation.DownloadOptions{
DestRoot: root,
EngineVersion: version,
ModelAssetName: v.AssetName,
ModelPinnedDigest: v.Digest,
ModelDirName: v.DirName,
ModelLabel: v.Label,
Progress: func(status string) {
if sink != nil {
sink(sttDownloadProgressMsg{status: status})
}
},
})
return dictationDownloadedMsg{components: comp, err: err, streaming: v.Streaming}
}
}
// handleDictationDownloadProgress updates the single live status line (shown in
// the status bar) — not a new transcript line per update.
func (m model) handleDictationDownloadProgress(msg sttDownloadProgressMsg) model {
if m.dictation.downloading {
m.dictation.downloadStatus = msg.status
}
return m
}
// handleDictationDownloaded persists and applies the downloaded engine, or
// reports the failure.
func (m model) handleDictationDownloaded(msg dictationDownloadedMsg) (model, tea.Cmd) {
m.dictation.downloading = false
m.dictation.downloadStatus = ""
if msg.err != nil {
return m.appendSystemNotice("Dictation setup failed: " + dictationErrorText(msg.err)), nil
}
m, err := m.applyEngineComponents(msg.components, msg.streaming)
if err != nil {
return m.appendSystemNotice("Downloaded the engine, but couldn't save the config: " + err.Error()), nil
}
return m.appendSystemNotice("Local dictation is ready. Run /voice, then hold Space to dictate."), nil
}
// applyEngineComponents persists and applies resolved engine/model paths to the
// live config (shared by a fresh download and the already-installed fast path).
func (m model) applyEngineComponents(comp dictation.EngineComponents, streaming bool) (model, error) {
if path := m.dictation.userConfigPath; path != "" {
if _, err := config.SetSTTLocalEngine(path, comp.BinaryPath, comp.ServerPath, comp.ModelPath, streaming); err != nil {
return m, err
}
}
m.dictation.cfg.Provider = config.STTProviderLocal
m.dictation.cfg.LocalBinary = comp.BinaryPath
m.dictation.cfg.LocalServerBinary = comp.ServerPath
m.dictation.cfg.LocalModelPath = comp.ModelPath
s := streaming
m.dictation.cfg.Streaming = &s
return m, nil
}
// appendDictationNotice appends a transcript notice, suppressing an immediate
// repeat of the same message — the fix for hammering the dictation gesture against an unusable
// model (or in voice mode) spamming the identical line on every keypress. It is
// stateless: it simply skips the append when the message equals the most recent
// transcript line, so an unrelated line in between lets it show again.
func (m model) appendDictationNotice(key, text string) model {
_ = key
if n := len(m.transcript); n > 0 && strings.TrimSpace(m.transcript[n-1].text) == strings.TrimSpace(text) {
return m
}
return m.appendSystemNotice(text)
}
-177
View File
@@ -1,177 +0,0 @@
package tui
import (
"errors"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/dictation"
)
// buildFails returns a controller whose build always fails with a setup error,
// as if the local engine/model were missing.
func setupErrController(downloadRoot string) dictationController {
return dictationController{
cfg: config.STTConfig{Provider: config.STTProviderLocal},
platform: dictation.DetectPlatform(),
downloadRoot: downloadRoot,
build: func(config.STTConfig, bool) (Transcriber, bool, error) {
return nil, false, &dictation.SetupError{Tool: "local STT model", Hint: "set stt.localModelPath"}
},
}
}
func TestF9PointsToSTTModelWhenLocalMissing(t *testing.T) {
if !dictation.AutoDownloadSupported() {
t.Skip("no prebuilt engine for this platform")
}
m := model{dictation: setupErrController("/tmp/zero-stt")}
next, _ := m.toggleDictation()
if !transcriptHasText(next, "/stt-model") {
t.Error("F9 with a missing local engine should point at /stt-model to download")
}
}
func TestF9PlainSetupErrorWithoutDownloadRoot(t *testing.T) {
m := model{dictation: setupErrController("")} // no download root
next, _ := m.toggleDictation()
if !transcriptHasText(next, "set stt.localModelPath") {
t.Error("without a download root, expected the plain setup error")
}
}
func TestDictationNoticeDedupe(t *testing.T) {
m := model{}
m = m.appendDictationNotice("k", "same message")
m = m.appendDictationNotice("k", "same message")
m = m.appendDictationNotice("k", "same message")
n := 0
for _, row := range m.transcript {
if row.text == "same message" {
n++
}
}
if n != 1 {
t.Errorf("consecutive identical notices should dedupe to 1, got %d", n)
}
// A different key breaks the dedupe.
m = m.appendDictationNotice("other", "another")
if !transcriptHasText(m, "another") {
t.Error("a different-keyed notice should still show")
}
}
func TestDownloadedBatchModelAppliedToConfig(t *testing.T) {
m := model{dictation: dictationController{cfg: config.STTConfig{Provider: config.STTProviderLocal}}}
m.dictation.downloading = true
comp := dictation.EngineComponents{
BinaryPath: "/x/bin/sherpa-onnx-offline",
ServerPath: "/x/bin/sherpa-onnx-online-websocket-server",
ModelPath: "/x/model",
}
got, _ := m.handleDictationDownloaded(dictationDownloadedMsg{components: comp, streaming: false})
if got.dictation.downloading {
t.Error("downloading flag should clear")
}
if got.dictation.cfg.LocalBinary != comp.BinaryPath || got.dictation.cfg.LocalModelPath != comp.ModelPath {
t.Errorf("engine paths not applied: %+v", got.dictation.cfg)
}
if got.dictation.cfg.StreamingEnabled() {
t.Error("a batch variant should set streaming off")
}
if !transcriptHasText(got, "ready") {
t.Error("expected a ready notice")
}
}
func TestDownloadedStreamingModelSetsStreamingOn(t *testing.T) {
m := model{dictation: dictationController{cfg: config.STTConfig{Provider: config.STTProviderLocal}}}
comp := dictation.EngineComponents{BinaryPath: "/x/bin/sherpa-onnx-offline", ModelPath: "/x/model"}
got, _ := m.handleDictationDownloaded(dictationDownloadedMsg{components: comp, streaming: true})
if !got.dictation.cfg.StreamingEnabled() {
t.Error("a streaming variant should set streaming on")
}
}
func TestSTTDownloadLoadingPickerThenFilled(t *testing.T) {
// The chooser opens in a single loading state (no curated-then-swap flicker),
// then handleSTTModelsFetched fills it with the fetched list.
loading := newSTTDownloadLoadingPicker()
if loading.kind != pickerSTTDownload || !loading.loading || len(loading.items) != 0 {
t.Fatalf("loading picker = %+v, want an empty, loading STT-download picker", loading)
}
m := model{dictation: dictationController{downloadRoot: "/tmp/x"}}
m.picker = loading
fetched := dictation.ModelVariants()
m = m.handleSTTModelsFetched(sttModelsFetchedMsg{variants: fetched})
if m.dictation.browseLoading {
t.Error("browseLoading should clear once the list arrives")
}
if m.picker.loading {
t.Error("the filled picker should no longer be loading")
}
if len(m.picker.items) != len(fetched) {
t.Errorf("filled picker has %d items, want %d", len(m.picker.items), len(fetched))
}
}
func TestSTTModelsFetchFailureFallsBackToCurated(t *testing.T) {
// A failed fetch with nothing cached falls back to the curated shortlist so the
// picker still works offline (never a permanently-empty loading box).
m := model{dictation: dictationController{downloadRoot: "/tmp/x"}}
m.picker = newSTTDownloadLoadingPicker()
m = m.handleSTTModelsFetched(sttModelsFetchedMsg{err: errors.New("offline")})
if m.picker.loading {
t.Error("picker should stop loading even on fetch failure")
}
if len(m.picker.items) != len(dictation.ModelVariants()) {
t.Errorf("fallback picker has %d items, want the %d curated variants",
len(m.picker.items), len(dictation.ModelVariants()))
}
}
func TestSTTDownloadPickerListsVariants(t *testing.T) {
m := model{dictation: dictationController{downloadRoot: "/tmp/x"}}
p := m.newSTTDownloadPicker()
if p == nil || p.kind != pickerSTTDownload {
t.Fatal("expected an STT download picker")
}
// Exactly the curated download variants — no dead-end "manual" row.
if len(p.items) != len(dictation.ModelVariants()) {
t.Errorf("picker has %d items, want %d variants", len(p.items), len(dictation.ModelVariants()))
}
for _, it := range p.items {
if it.Value == "manual" {
t.Error("the dead-end manual row should be gone")
}
}
}
func TestSTTDownloadSelectionStartsDownload(t *testing.T) {
m := model{dictation: dictationController{downloadRoot: "/tmp/x", cfg: config.STTConfig{Provider: config.STTProviderLocal}}}
variant := dictation.ModelVariants()[0]
next, cmd := m.handleSTTDownloadSelection(variant.ID)
if !next.dictation.downloading {
t.Error("selecting a variant should start a download")
}
if cmd == nil {
t.Error("expected a download command")
}
if !transcriptHasText(next, variant.Label) {
t.Error("expected the download-starting notice to name the variant")
}
}
func TestDownloadFailureReported(t *testing.T) {
m := model{dictation: dictationController{}}
m.dictation.downloading = true
next, _ := m.handleDictationDownloaded(dictationDownloadedMsg{err: errors.New("network down")})
got := next
if got.dictation.downloading {
t.Error("downloading flag should clear on failure")
}
if !transcriptHasText(got, "network down") {
t.Error("expected the failure reported")
}
}
-171
View File
@@ -1,171 +0,0 @@
package tui
import (
"context"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/dictation"
)
// sttPartialMsg carries an incremental streaming transcript into the TUI. It is
// injected from the streaming goroutine via runtimeMessageSink — the same
// 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
}
// startStreamingDictation begins continuous capture and launches the streaming
// transcription command. StartStreaming spawns the capture process
// synchronously, so a failure here is immediate; on success we are already
// recording (no separate "started" round-trip).
func (m model) startStreamingDictation() (model, tea.Cmd) {
chunks, stop, err := m.dictation.recorder.StartStreaming()
if err != nil {
m.dictation.reset()
return m.appendSystemNotice(dictationErrorText(err)), nil
}
m.dictation.streamStop = stop
m.dictation.phase = dictRecording
sink := m.runtimeMessageSink
ctx := m.dictation.ctx
if ctx == nil {
ctx = context.Background()
}
transcriber := m.dictation.transcriber
submit := m.dictation.cfg.AutoSubmitEnabled()
// 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
// stalling capture if the transcriber briefly lags.
tapped := make(chan []byte, 16)
go func() {
defer close(tapped)
for chunk := range chunks {
if sink != nil {
sink(sttLevelMsg{level: dictation.ChunkLevel(chunk)})
}
// Also select on ctx.Done so this goroutine can't block forever on the
// send if StreamTranscribe exits early (e.g. a sherpa startup failure) and
// stops draining `tapped` — the session's cancel unblocks and drains it.
select {
case tapped <- chunk:
case <-ctx.Done():
return
}
}
}()
streamCmd := func() tea.Msg {
onPartial := func(text string, final bool) {
if sink != nil {
sink(sttPartialMsg{text: text, final: final})
}
}
text, err := transcriber.StreamTranscribe(ctx, tapped, onPartial)
return dictationTranscribedMsg{text: text, err: err, submit: submit, streaming: true}
}
// Streaming drives the waveform from real levels (no synthetic tick needed).
return m, streamCmd
}
// handleDictationPartial renders a cumulative partial transcript into the
// 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 {
// Ignore stragglers that arrive after the session ended (cancel/final).
if m.dictation.phase != dictRecording && m.dictation.phase != dictTranscribing {
return m
}
m.applyStreamingText(msg.text)
return m
}
func (m *model) applyStreamingText(text string) {
state := m.currentComposerState()
if !m.dictation.regionActive {
m.dictation.regionActive = true
m.dictation.regionStart = state.cursor
m.dictation.regionEnd = state.cursor
m.dictation.regionPrefix = ""
// Anchor the prefix text BEFORE the live region. If the user types or
// pastes outside the region, the next partial can detect the change in
// this prefix and shift [start,end) to stay aligned.
m.dictation.regionAnchor = state.text
if needsLeadingSpace(state) {
// Fold the separator into the region so a cancel removes it too.
m.dictation.regionPrefix = " "
}
} else {
// Compare the prefix before the live region. If it changed (the user
// typed/pasted there) shift [start,end) by the length delta so the
// next partial's slice targets the right span. If the user edited
// INSIDE the region, drop the live region and re-anchor at the
// current cursor — we can't tell the partial from the user's text
// after that point.
prefix := string([]rune(state.text)[:m.dictation.regionStart])
anchor := m.dictation.regionAnchor
switch {
case prefix == anchor:
// External edit at or after the region — no shift needed.
case len(prefix) > len(anchor) && strings.HasPrefix(prefix, anchor):
// External edit inserted text just before the region (i.e. at
// position regionStart, pushing the region right).
delta := len([]rune(prefix)) - len([]rune(anchor))
m.dictation.regionStart += delta
m.dictation.regionEnd += delta
m.dictation.regionAnchor = prefix
case len(anchor) > len(prefix) && strings.HasPrefix(anchor, prefix):
// External delete just before the region.
delta := len([]rune(anchor)) - len([]rune(prefix))
m.dictation.regionStart -= delta
m.dictation.regionEnd -= delta
m.dictation.regionAnchor = prefix
default:
// User edited inside or across the region — can't safely
// overwrite. Drop the live region and re-anchor at the cursor
// so the next partial inserts as a fresh span.
m.dictation.regionStart = state.cursor
m.dictation.regionEnd = state.cursor
m.dictation.regionPrefix = ""
m.dictation.regionAnchor = string([]rune(state.text)[:state.cursor])
}
}
// Replace [regionStart, regionEnd) with prefix + the new cumulative text.
rendered := m.dictation.regionPrefix + text
cleared := deleteComposerRange(state, m.dictation.regionStart, m.dictation.regionEnd)
// Preserve the user's cursor if they moved it outside the live region
// (e.g. to edit elsewhere in the composer) — yanking it to regionStart on
// every partial would steal their focus mid-edit.
if cleared.cursor < m.dictation.regionStart || cleared.cursor > m.dictation.regionEnd {
// keep cleared.cursor
} else {
cleared.cursor = m.dictation.regionStart
}
updated := insertComposerText(cleared, rendered)
m.dictation.regionEnd = m.dictation.regionStart + len([]rune(rendered))
m.setComposerState(updated)
}
// commitDictationRegion keeps the streamed text in the composer and stops
// tracking it as a live region (used on successful completion — the final
// transcript equals the last partial already rendered).
func (m model) commitDictationRegion() model {
m.dictation.regionActive = false
return m
}
// discardDictationRegion removes the live streamed text from the composer (used
// on cancel — the user aborted, so the half-formed transcript is dropped).
func (m model) discardDictationRegion() model {
if m.dictation.regionActive {
state := m.currentComposerState()
m.setComposerState(deleteComposerRange(state, m.dictation.regionStart, m.dictation.regionEnd))
m.dictation.regionActive = false
}
return m
}
-304
View File
@@ -1,304 +0,0 @@
package tui
import (
"context"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/dictation"
)
func TestDictationTranscribedInsertsIntoComposer(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "hello", cursor: 5})
m.dictation.phase = dictTranscribing
next, _ := m.handleDictationTranscribed(dictationTranscribedMsg{text: "world"})
got := next.(model)
if got.composer.text != "hello world" {
t.Errorf("composer = %q, want 'hello world'", got.composer.text)
}
if got.dictation.active() {
t.Error("dictation should be idle after transcription")
}
}
func TestDictationTranscribedEmptyIsNoticed(t *testing.T) {
m := model{}
m.dictation.phase = dictTranscribing
next, _ := m.handleDictationTranscribed(dictationTranscribedMsg{text: " "})
got := next.(model)
if got.composer.text != "" {
t.Errorf("composer should stay empty, got %q", got.composer.text)
}
if !transcriptHasText(got, "No speech detected") {
t.Error("expected a 'no speech' notice")
}
}
func TestDictationTranscribedSetupErrorGuides(t *testing.T) {
m := model{}
m.dictation.phase = dictTranscribing
err := &dictation.SetupError{Tool: "arecord", Hint: "install alsa-utils"}
next, _ := m.handleDictationTranscribed(dictationTranscribedMsg{err: err})
got := next.(model)
if !transcriptHasText(got, "install alsa-utils") {
t.Error("expected setup guidance in the transcript")
}
}
func TestDictationAuthErrorReopensKeyPrompt(t *testing.T) {
// A cloud auth failure should offer an inline key prompt for the current
// provider (preserving its model), not dead-end on "run zero auth".
m := model{}
m.dictation.cfg = config.STTConfig{Provider: config.STTProviderGroq, Model: "whisper-large-v3-turbo"}
m.dictation.saveKey = func(string, string) error { return nil }
m.dictation.phase = dictTranscribing
err := &dictation.AuthError{Provider: "groq", Message: "auth error: your API key is missing or invalid"}
next, _ := m.handleDictationTranscribed(dictationTranscribedMsg{err: err})
got := next.(model)
if got.sttKeyPrompt == nil {
t.Fatal("an auth failure should reopen the API-key prompt")
}
if got.sttKeyPrompt.provider != "groq" || got.sttKeyPrompt.modelValue != "groq:whisper-large-v3-turbo" {
t.Errorf("key prompt = %+v, want groq / groq:whisper-large-v3-turbo", got.sttKeyPrompt)
}
}
func TestDictationNonAuthErrorDoesNotPrompt(t *testing.T) {
m := model{}
m.dictation.cfg = config.STTConfig{Provider: config.STTProviderGroq}
m.dictation.saveKey = func(string, string) error { return nil }
m.dictation.phase = dictTranscribing
next, _ := m.handleDictationTranscribed(dictationTranscribedMsg{err: errors.New("network unreachable")})
got := next.(model)
if got.sttKeyPrompt != nil {
t.Error("a non-auth error must not open the key prompt")
}
if !transcriptHasText(got, "network unreachable") {
t.Error("expected the raw error in the transcript")
}
}
func TestDictationStartedFailureResets(t *testing.T) {
m := model{}
m.dictation.phase = dictStarting
m.dictation.streaming = false
got, _ := m.handleDictationStarted(dictationStartedMsg{err: errors.New("mic busy")})
if got.dictation.active() {
t.Error("a start failure should reset to idle")
}
if !transcriptHasText(got, "mic busy") {
t.Error("expected the start error in the transcript")
}
}
func TestDictationStartedArmsRecording(t *testing.T) {
m := model{}
m.dictation.phase = dictStarting
got, _ := m.handleDictationStarted(dictationStartedMsg{})
if got.dictation.phase != dictRecording {
t.Error("a successful start should advance to recording")
}
}
func TestDictationUnavailableShowsHint(t *testing.T) {
m := model{} // no build factory
next, _ := m.toggleDictation()
if !transcriptHasText(next, "not configured") {
t.Error("expected a 'not configured' hint when dictation is unavailable")
}
}
func TestDictationStreamingPartialReplacesRegion(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "note:", cursor: 5})
m.dictation.phase = dictRecording
m.dictation.streaming = true
m = m.handleDictationPartial(sttPartialMsg{text: "the quick"})
if m.composer.text != "note: the quick" {
t.Fatalf("after first partial: %q", m.composer.text)
}
// A longer cumulative partial replaces the previous region wholesale.
m = m.handleDictationPartial(sttPartialMsg{text: "the quick brown fox"})
if m.composer.text != "note: the quick brown fox" {
t.Fatalf("after second partial: %q", m.composer.text)
}
// Cancel discards the streamed region.
m2 := m.discardDictationRegion()
if m2.composer.text != "note:" {
t.Errorf("discard should restore original text, got %q", m2.composer.text)
}
}
func TestDictationCommitKeepsStreamedText(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "", cursor: 0})
m.dictation.phase = dictRecording
m.dictation.streaming = true
m = m.handleDictationPartial(sttPartialMsg{text: "final words"})
got, _ := m.handleDictationTranscribed(dictationTranscribedMsg{text: "final words", streaming: true})
if got.(model).composer.text != "final words" {
t.Errorf("committed streamed text lost: %q", got.(model).composer.text)
}
}
func TestVoiceOffReleasesWarmServer(t *testing.T) {
shutdownCalls := 0
m := model{}
m.dictation.build = func(config.STTConfig, bool) (Transcriber, bool, error) { return nil, false, nil } // makes available()
m.dictation.shutdownServer = func(context.Context) error { shutdownCalls++; return nil }
on, _ := m.toggleVoiceMode()
if !on.dictation.voiceModeEnabled {
t.Fatal("voice should be on after first toggle")
}
off, cmd := on.toggleVoiceMode()
if off.dictation.voiceModeEnabled {
t.Fatal("voice should be off after second toggle")
}
if cmd == nil {
t.Fatal("turning voice off should return a server-release command")
}
cmd() // run it
if shutdownCalls != 1 {
t.Errorf("shutdown called %d times, want 1", shutdownCalls)
}
}
func TestVoiceOffMidRecordingKeepsServer(t *testing.T) {
m := model{}
m.dictation.build = func(config.STTConfig, bool) (Transcriber, bool, error) { return nil, false, nil }
m.dictation.shutdownServer = func(context.Context) error {
t.Error("must not tear down the server while a recording is in flight")
return nil
}
m.dictation.voiceModeEnabled = true
m.dictation.phase = dictRecording
if _, cmd := m.toggleVoiceMode(); cmd != nil {
t.Error("no server-release command should be issued mid-recording")
}
}
func TestWantStreamingGatedByConfigAndPlatform(t *testing.T) {
streamOff := false
d := dictationController{cfg: config.STTConfig{Streaming: &streamOff}, platform: dictation.PlatformLinux}
if d.wantStreaming() {
t.Error("streaming:false in config should disable streaming")
}
d = dictationController{platform: dictation.PlatformTermux}
if d.wantStreaming() {
t.Error("Termux has no streaming capture, wantStreaming must be false")
}
d = dictationController{platform: dictation.PlatformLinux}
if !d.wantStreaming() {
t.Error("desktop default should want streaming")
}
}
func TestNeedsLeadingSpace(t *testing.T) {
if needsLeadingSpace(composerState{text: "hi", cursor: 2}) != true {
t.Error("cursor after non-space should need a leading space")
}
if needsLeadingSpace(composerState{text: "hi ", cursor: 3}) != false {
t.Error("cursor after a space should not need one")
}
if needsLeadingSpace(composerState{text: "", cursor: 0}) != false {
t.Error("empty composer should not need a leading space")
}
}
func TestDictationBatchCtxNonNil(t *testing.T) {
d := dictationController{}
if got := transcribeBatchCtx(d.ctx); got == nil {
t.Error("batch ctx should never be nil")
}
}
// transcribeBatchCtx mirrors the nil-guard in transcribeBatchCmd for testing.
func transcribeBatchCtx(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return ctx
}
func transcriptHasText(m model, substr string) bool {
for _, row := range m.transcript {
if strings.Contains(row.text, substr) {
return true
}
}
return false
}
func TestWaveformRendersLevels(t *testing.T) {
// Higher levels render taller bars; the ring scrolls left as levels arrive.
var d dictationController
d.pushLevel(levelHeight(1.0)) // loud → tall
d.pushLevel(levelHeight(0.0)) // silent → short
bars := renderWaveBars(d.waveBars)
if len([]rune(bars)) != waveBarCount {
t.Errorf("waveform width = %d, want %d", len([]rune(bars)), waveBarCount)
}
// The newest (rightmost) bar reflects the last pushed level (silent → space/low).
runes := []rune(bars)
if runes[waveBarCount-1] != waveRunes[0] {
t.Errorf("last bar should be the silent level, got %q", string(runes[waveBarCount-1]))
}
}
func TestDictationLevelDrivesWave(t *testing.T) {
m := model{}
m.dictation.phase = dictRecording
m = m.handleDictationLevel(sttLevelMsg{level: 0.9})
if len(m.dictation.waveBars) != waveBarCount {
t.Fatalf("a mic level should populate the waveform ring")
}
if m.dictation.waveBars[waveBarCount-1] != levelHeight(0.9) {
t.Error("the newest bar should match the received mic level")
}
}
func TestHandleRecTickOnlyAnimatesWhileRecording(t *testing.T) {
m := model{}
m.dictation.phase = dictRecording
next, cmd := m.handleRecTick()
if next.dictation.waveTick != 1 || cmd == nil {
t.Error("recording should advance the batch synthetic wave and reschedule")
}
m.dictation.phase = dictIdle
_, cmd = m.handleRecTick()
if cmd != nil {
t.Error("idle should stop ticking")
}
}
func TestCurrentModelLabel(t *testing.T) {
// Cloud provider + model.
d := dictationController{cfg: config.STTConfig{Provider: config.STTProviderGroq, Model: "whisper-large-v3-turbo"}}
if got := d.currentModelLabel(); got != "Groq whisper-large-v3-turbo" {
t.Errorf("groq label = %q", got)
}
// Cloud provider with default model.
d = dictationController{cfg: config.STTConfig{Provider: config.STTProviderOpenAI}}
if got := d.currentModelLabel(); got != "OpenAI whisper-1" {
t.Errorf("openai default label = %q", got)
}
// Local with a downloaded curated variant → "Local · <friendly name>".
kroko := dictation.ModelVariants()[0] // Kroko streaming
d = dictationController{cfg: config.STTConfig{Provider: config.STTProviderLocal, LocalModelPath: "/home/x/.config/zero/stt/" + kroko.DirName + "/sherpa-onnx-...-kroko"}}
if got := d.currentModelLabel(); got != "Local · "+kroko.Label {
t.Errorf("local kroko label = %q, want %q", got, "Local · "+kroko.Label)
}
// Local with no model.
d = dictationController{cfg: config.STTConfig{Provider: config.STTProviderLocal}}
if got := d.currentModelLabel(); got != "Local (no model set)" {
t.Errorf("no-model label = %q", got)
}
}
-63
View File
@@ -1,63 +0,0 @@
package tui
import (
tea "charm.land/bubbletea/v2"
)
// Voice mode's Space-hold gesture — the (only) dictation trigger. Two terminal
// tiers:
//
// 1. The terminal confirms key-release reporting (Ghostty/Kitty/WezTerm, …):
// Space press starts recording, Space release stops it — true hold-to-record,
// no ambiguity.
// 2. The terminal does not confirm release events: Space falls back to
// press-to-toggle (press to start, press again to stop) — a deliberately
// simpler, robust fallback than inferring release from key-repeat timing
// (racy in a terminal). This works on ANY terminal, so voice mode is always
// usable once enabled.
//
// Only active while voice mode is on; the rest of dispatch is built on
// KeyPressMsg and is untouched.
// handleKeyboardEnhancements records the terminal's confirmed keyboard
// capabilities, so voice mode knows whether hold-to-record is available.
func (m model) handleKeyboardEnhancements(msg tea.KeyboardEnhancementsMsg) model {
m.dictation.eventTypesKnown = true
m.dictation.eventTypesSupported = msg.SupportsEventTypes()
return m
}
// handleVoiceSpacePress handles a Space press while voice mode is on.
func (m model) handleVoiceSpacePress(msg tea.KeyMsg) (model, tea.Cmd) {
if !m.dictation.eventTypesSupported {
// Tier 2: no release events — press-to-toggle.
return m.toggleDictation()
}
// Tier 1: hold-to-record. Ignore auto-repeat presses while the key is held.
if msg.Key().IsRepeat {
return m, nil
}
if m.dictation.phase == dictIdle {
m.dictation.spaceHeld = true
m.dictation.voiceStopPending = false
return m.startDictation()
}
return m, nil // already recording; the release will stop it
}
// handleVoiceSpaceRelease stops a hold-to-record session when Space is released.
func (m model) handleVoiceSpaceRelease() (model, tea.Cmd) {
if !m.dictation.spaceHeld {
return m, nil
}
m.dictation.spaceHeld = false
switch m.dictation.phase {
case dictRecording:
return m.stopDictation()
case dictStarting:
// Released before the recording finished starting; stop as soon as it does.
m.dictation.voiceStopPending = true
return m, nil
}
return m, nil
}
-144
View File
@@ -1,144 +0,0 @@
package tui
import (
"context"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
)
type fakeTUITranscriber struct{}
func (fakeTUITranscriber) Transcribe(context.Context, []byte) (string, error) { return "", nil }
func (fakeTUITranscriber) StreamTranscribe(context.Context, <-chan []byte, func(string, bool)) (string, error) {
return "", nil
}
// batchOnlyController builds a controller whose recordings take the batch path
// (streaming disabled), so startDictation returns an unexecuted command instead
// of exec'ing a real capture tool.
func batchOnlyController() dictationController {
streamOff := false
return dictationController{
cfg: config.STTConfig{Streaming: &streamOff},
platform: "linux",
build: func(config.STTConfig, bool) (Transcriber, bool, error) {
return fakeTUITranscriber{}, false, nil
},
}
}
func TestToggleVoiceModeFlips(t *testing.T) {
m := model{dictation: batchOnlyController()}
next, _ := m.toggleVoiceMode()
if !next.dictation.voiceModeEnabled {
t.Fatal("first /voice should enable voice mode")
}
if !transcriptHasText(next, "Voice mode on") {
t.Error("expected voice-mode-on notice")
}
off, _ := next.toggleVoiceMode()
if off.dictation.voiceModeEnabled {
t.Error("second /voice should disable voice mode")
}
}
func TestVoiceModeUnavailableWithoutFactory(t *testing.T) {
m := model{} // no build factory
next, _ := m.toggleVoiceMode()
if next.dictation.voiceModeEnabled {
t.Error("voice mode must not enable when dictation is unavailable")
}
if !transcriptHasText(next, "not configured") {
t.Error("expected a not-configured hint")
}
}
func TestKeyboardEnhancementsRecorded(t *testing.T) {
m := model{dictation: batchOnlyController()}
// Flags with the event-types bit unset → not supported.
m = m.handleKeyboardEnhancements(tea.KeyboardEnhancementsMsg{Flags: 0})
if !m.dictation.eventTypesKnown || m.dictation.eventTypesSupported {
t.Error("Flags=0 should be known-but-unsupported")
}
}
func TestVoiceSpaceHoldStartsRecording(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = true
press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace})
next, cmd := m.handleVoiceSpacePress(press)
if next.dictation.phase != dictStarting {
t.Fatalf("Space press should start recording (phase=%d)", next.dictation.phase)
}
if !next.dictation.spaceHeld {
t.Error("spaceHeld should be set in hold mode")
}
if cmd == nil {
t.Error("expected a start command")
}
}
func TestVoiceSpaceHoldIgnoresRepeat(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = true
m.dictation.phase = dictRecording
m.dictation.spaceHeld = true
repeat := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, IsRepeat: true})
next, _ := m.handleVoiceSpacePress(repeat)
if next.dictation.phase != dictRecording {
t.Error("auto-repeat while held must not restart or change phase")
}
}
func TestVoiceSpaceReleaseStops(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = true
m.dictation.phase = dictRecording
m.dictation.spaceHeld = true
next, _ := m.handleVoiceSpaceRelease()
if next.dictation.phase != dictTranscribing {
t.Errorf("release should stop recording → transcribing (phase=%d)", next.dictation.phase)
}
if next.dictation.spaceHeld {
t.Error("spaceHeld should clear on release")
}
}
func TestVoiceReleaseDuringStartupDefersStop(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = true
m.dictation.phase = dictStarting
m.dictation.spaceHeld = true
next, _ := m.handleVoiceSpaceRelease()
if !next.dictation.voiceStopPending {
t.Error("a release during startup should defer the stop")
}
// When the recording finishes starting, the deferred stop fires.
after, _ := next.handleDictationStarted(dictationStartedMsg{})
if after.dictation.phase != dictTranscribing {
t.Errorf("deferred stop should transition to transcribing (phase=%d)", after.dictation.phase)
}
}
func TestVoiceSpaceToggleFallback(t *testing.T) {
m := model{dictation: batchOnlyController()}
m.dictation.voiceModeEnabled = true
m.dictation.eventTypesSupported = false // no release events → toggle fallback
press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace})
next, _ := m.handleVoiceSpacePress(press)
if next.dictation.phase != dictStarting {
t.Errorf("toggle-mode Space should start recording (phase=%d)", next.dictation.phase)
}
}
-129
View File
@@ -1,8 +1,6 @@
package tui
import (
"fmt"
"strconv"
"strings"
"unicode/utf8"
@@ -79,9 +77,6 @@ func (p parsedBinding) Label() string {
b.WriteString("PgUp")
case tea.KeyPgDown:
b.WriteString("PgDn")
case tea.KeyF1, tea.KeyF2, tea.KeyF3, tea.KeyF4, tea.KeyF5, tea.KeyF6,
tea.KeyF7, tea.KeyF8, tea.KeyF9, tea.KeyF10, tea.KeyF11, tea.KeyF12:
b.WriteString(fKeyLabel(p.code))
default:
// Printable character — uppercase for display
if p.code >= 'a' && p.code <= 'z' {
@@ -211,30 +206,6 @@ func parseBinding(s string) parsedBinding {
p.code = tea.KeyPgUp
case "pgdown", "pagedown":
p.code = tea.KeyPgDown
case "f1":
p.code = tea.KeyF1
case "f2":
p.code = tea.KeyF2
case "f3":
p.code = tea.KeyF3
case "f4":
p.code = tea.KeyF4
case "f5":
p.code = tea.KeyF5
case "f6":
p.code = tea.KeyF6
case "f7":
p.code = tea.KeyF7
case "f8":
p.code = tea.KeyF8
case "f9":
p.code = tea.KeyF9
case "f10":
p.code = tea.KeyF10
case "f11":
p.code = tea.KeyF11
case "f12":
p.code = tea.KeyF12
case "?":
p.text = "?"
p.code = 0
@@ -271,13 +242,6 @@ type keyBindings struct {
toggleSidebar parsedBinding
}
// fKeyLabel renders a function-key code as "F9" etc. tea.KeyF1..KeyF12 are
// sequential, so the offset from KeyF1 gives the number.
func fKeyLabel(code rune) string {
n := int(code-tea.KeyF1) + 1
return "F" + strconv.Itoa(n)
}
// resolveKeyBindings converts a user-facing KeyBindingsConfig into the
// dispatch-ready parsed form, using empty-is-default semantics.
func resolveKeyBindings(cfg config.KeyBindingsConfig) keyBindings {
@@ -299,96 +263,3 @@ func (m model) keyMatch(b parsedBinding, msg tea.KeyMsg, defaultFn func(tea.KeyM
}
return defaultFn(msg)
}
// reservedBindings lists hardcoded (non-configurable) chords handled directly in
// model.go's key dispatch. If a configurable binding uses one of these chords,
// one of the actions becomes unreachable (depending on switch order), so
// sanitizeKeyBindings reverts the configurable binding back to its default.
var reservedBindings = []struct {
binding parsedBinding
description string
}{
{parseBinding("ctrl+c"), "cancel / exit"},
{parseBinding("esc"), "cancel / close"},
{parseBinding("enter"), "submit"},
{parseBinding("shift+tab"), "cycle permission mode"},
{parseBinding("tab"), "navigation / completion"},
{parseBinding("backspace"), "composer edit / attachment removal"},
{parseBinding("up"), "history/navigation"},
{parseBinding("down"), "history/navigation"},
{parseBinding("pgup"), "transcript scroll"},
{parseBinding("pgdown"), "transcript scroll"},
{parseBinding("ctrl+f"), "favorite model (in the /model picker)"},
{parseBinding("?"), "help overlay"},
}
// sanitizeKeyBindings drops (reverts to default) any configured binding that
// collides with a reserved hardcoded chord above, or with another
// configured binding, since either collision would silently make one of the
// two actions permanently unreachable. Returns the sanitized bindings plus a
// human-readable warning for each dropped binding, for the caller to surface
// as a startup notice.
func sanitizeKeyBindings(b keyBindings) (keyBindings, []string) {
entries := []struct {
name string
binding *parsedBinding
defaultBinding parsedBinding
}{
{"toggleDetailed", &b.toggleDetailed, parseBinding("ctrl+o")},
{"toggleMouse", &b.toggleMouse, parseBinding("ctrl+e")},
{"cycleReasoning", &b.cycleReasoning, parseBinding("ctrl+t")},
{"togglePlan", &b.togglePlan, parseBinding("ctrl+p")},
{"toggleSidebar", &b.toggleSidebar, parseBinding("ctrl+b")},
}
var warnings []string
for _, e := range entries {
if e.binding.isZero() {
continue
}
for _, other := range entries {
if other.name == e.name || !other.binding.isZero() {
continue
}
if *e.binding == other.defaultBinding {
warnings = append(warnings, fmt.Sprintf(
"keybindings.%s (%s) conflicts with keybindings.%s default (%s); using the default instead.",
e.name, e.binding.Label(), other.name, other.defaultBinding.Label()))
*e.binding = parsedBinding{}
break
}
}
}
for _, e := range entries {
if e.binding.isZero() {
continue
}
for _, reserved := range reservedBindings {
if *e.binding == reserved.binding {
warnings = append(warnings, fmt.Sprintf(
"keybindings.%s (%s) conflicts with the built-in %s shortcut; using the default instead.",
e.name, e.binding.Label(), reserved.description))
*e.binding = parsedBinding{}
break
}
}
}
claimedBy := map[parsedBinding]string{}
for _, e := range entries {
if e.binding.isZero() {
continue
}
if other, ok := claimedBy[*e.binding]; ok {
warnings = append(warnings, fmt.Sprintf(
"keybindings.%s (%s) conflicts with keybindings.%s; using the default instead.",
e.name, e.binding.Label(), other))
*e.binding = parsedBinding{}
continue
}
claimedBy[*e.binding] = e.name
}
return b, warnings
}
+13 -118
View File
@@ -62,7 +62,6 @@ const dragEdgeScrollStep = 1
type model struct {
ctx context.Context
cwd string
appVersion string
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
loadSkills func() []skills.Skill // lazy installed-skills loader for /skills + /<skill-name>
userConfigPath string
@@ -157,8 +156,6 @@ type model struct {
composerCursorVisible bool
composerPastePreviews []composerPastePreview
composerSelection composerSelectionState
dictation dictationController
sttKeyPrompt *sttKeyPromptState
// plan holds the sticky plan panel state (steps, expansion, timings)
// synced from the update_plan tool. See plan_panel.go.
plan planPanelState
@@ -747,12 +744,9 @@ func newModel(ctx context.Context, options Options) model {
notify.MaybeAddWebhookSink(notifier, os.Getenv, nil)
notifier.SetFocused(true)
resolvedKeyBindings, keyBindingWarnings := sanitizeKeyBindings(resolveKeyBindings(options.KeyBindings))
m := model{
ctx: ctx,
cwd: cwd,
appVersion: strings.TrimSpace(options.Version),
swarmDoneAt: map[string]time.Time{},
userCommands: loadedUserCommands,
loadSkills: options.LoadSkills,
@@ -787,7 +781,7 @@ func newModel(ctx context.Context, options Options) model {
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
keyBindings: resolvedKeyBindings,
keyBindings: resolveKeyBindings(options.KeyBindings),
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
hasDarkBg: true,
userAgent: options.UserAgent,
@@ -805,7 +799,6 @@ func newModel(ctx context.Context, options Options) model {
swarmSessionMap: map[string]string{},
setup: newSetupState(options.Setup),
setupSave: options.Setup.Save,
dictation: newDictationController(options),
}
// Apply an explicit theme immediately; auto stays on the dark default until
// Init's terminal background probe resolves it (see Init / BackgroundColorMsg).
@@ -824,9 +817,6 @@ func newModel(ctx context.Context, options Options) model {
m.lspManager = lsp.NewManager(cwd)
}
m.refreshMCPViewState()
for _, warning := range keyBindingWarnings {
m = m.appendSystemNotice(warning)
}
return m
}
@@ -949,8 +939,7 @@ func (m *model) stopPRWatcher() {
// by every shortcut that should defer to whichever modal is focused.
func (m model) noBlockingModal() bool {
return m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil &&
m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil &&
m.sttKeyPrompt == nil
m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil
}
func (m model) quit() (tea.Model, tea.Cmd) {
@@ -964,16 +953,12 @@ func (m model) quit() (tea.Model, tea.Cmd) {
// Best-effort with a short deadline so a slow server can't hang the quit; the
// servers are our child processes and would be reaped on exit regardless.
func (m model) shutdownLSPManager() {
if m.lspManager == nil {
return
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if m.lspManager != nil {
_ = m.lspManager.Shutdown(shutdownCtx)
}
// The warm sherpa-onnx streaming server is a session-long child process too;
// tear it down alongside the language servers (§6a).
if m.dictation.shutdownServer != nil {
_ = m.dictation.shutdownServer(shutdownCtx)
}
_ = m.lspManager.Shutdown(shutdownCtx)
}
func (m model) handleCtrlC() (tea.Model, tea.Cmd) {
@@ -1112,10 +1097,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.applyProviderWizardOAuth(msg)
case providerWizardDeviceCodeMsg:
return m.applyProviderWizardDeviceCode(msg)
case providerManagerCredsMsg:
return m.applyProviderManagerCreds(msg)
case providerManagerCleanupMsg:
return m.applyProviderManagerCleanup(msg)
case clipboardReadMsg:
// Result of a right-click paste. Insert on success; surface a brief
// status if the clipboard couldn't be read (e.g. no clipboard utility on
@@ -1142,47 +1123,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m.attachClipboardImage(msg.data, msg.mediaType), nil
case tea.PasteMsg:
// A paste into the cloud-STT key prompt fills the key (the common way to
// enter an API key), not the composer.
if m.sttKeyPrompt != nil {
m.sttKeyPrompt.input += strings.TrimSpace(msg.Content)
return m, nil
}
return m.routePaste(msg.Content)
case dictationStartedMsg:
return m.handleDictationStarted(msg)
case dictationTranscribedMsg:
return m.handleDictationTranscribed(msg)
case sttPartialMsg:
return m.handleDictationPartial(msg), nil
case sttDownloadProgressMsg:
return m.handleDictationDownloadProgress(msg), nil
case dictationDownloadedMsg:
return m.handleDictationDownloaded(msg)
case sttModelsFetchedMsg:
return m.handleSTTModelsFetched(msg), nil
case recTickMsg:
return m.handleRecTick()
case sttLevelMsg:
return m.handleDictationLevel(msg), nil
case tea.KeyboardEnhancementsMsg:
return m.handleKeyboardEnhancements(msg), nil
case tea.KeyReleaseMsg:
// Voice mode's hold-to-record ends on Space release; every other release
// event is ignored (dispatch elsewhere is press-based).
if m.dictation.voiceModeEnabled && keyIs(msg, tea.KeySpace) {
return m.handleVoiceSpaceRelease()
}
return m, nil
case tea.KeyPressMsg:
if m.setup.visible {
return m.handleSetupKey(msg)
}
// The cloud-STT API-key prompt is modal: it owns every keystroke (masked
// input) until Enter saves or Esc cancels.
if m.sttKeyPrompt != nil {
return m.handleSTTKeyPromptKey(msg)
}
m.transcriptSelection = transcriptSelectionState{}
m.composerSelection = composerSelectionState{}
m.clearMouseSelection()
@@ -1226,11 +1171,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.appendSystemNotice(fmt.Sprintf("Mouse released — drag to select and copy text. Press %s again to re-enable mouse interaction (clicks, right-click paste).", mouseKey)), nil
}
return m.appendSystemNotice("Mouse interaction re-enabled."), nil
case m.dictation.voiceModeEnabled && !m.transcriptDetailed && keyIs(msg, tea.KeySpace) && !keyHasMod(msg, tea.ModCtrl) && !keyAlt(msg) && m.noBlockingModal():
// Voice mode (/voice) repurposes Space into the record gesture — the only
// dictation trigger — so it must not also type a space. Turn voice mode
// off (/voice) to type normally.
return m.handleVoiceSpacePress(msg)
case keyIs(msg, tea.KeyEsc):
// Esc is heavily overloaded below (subchat exit, MCP cancel, ask-user,
// permission deny, wizard/picker/suggestions dismiss, ...) before ever
@@ -1241,15 +1181,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// armed for some later, unrelated Esc to silently act on.
wasConfirmingCancel := m.pending && m.cancelConfirmActive
m = m.disarmCancelConfirmation()
// An active dictation recording cancels on Esc (releases the mic, drops
// the audio) — but only if this Esc isn't a confirming run-cancel
// press. Without this guard, a user mid-recording who double-Esc's
// to kill the run finds the first Esc swallowed by dictation and
// the run still going. The pending run cancel happens further
// down at the bottom of the Esc branch.
if m.dictation.active() && !wasConfirmingCancel {
return m.cancelDictation()
}
// Subchat view exits on Esc (returns to main chat).
if m.subchat.active {
m.chatScrollOffset = m.subchat.exit()
@@ -1292,10 +1223,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.resolvePermission(permissionDecisionDeny)
}
if m.providerWizard != nil {
// Delegate so multi-level surfaces (provider manager list → edit →
// field, manage-key step) can walk BACK one level; the wizard's own
// handler closes the overlay for the single-level steps.
return m.handleProviderWizardKey(msg)
m.providerWizard = nil
return m, nil
}
if m.mcpAddWizard != nil {
m.mcpAddWizard = nil
@@ -2433,10 +2362,6 @@ func (m model) View() tea.View {
view.BackgroundColor = zeroTheme.bgPanel
}
view.ReportFocus = m.notifier != nil
// Voice mode's Space-hold gesture needs key-release events (Kitty protocol).
// Request them only while voice mode is on — the renderer re-sends the request
// only when the value changes, so gating this costs nothing (§10).
view.KeyboardEnhancements.ReportEventTypes = m.dictation.voiceModeEnabled
if m.wantsMouseCapture() {
if isRunningUnderPRoot() {
// Under PRoot the AllMotion (1003) sequence doesn't work
@@ -2510,11 +2435,8 @@ func (m model) transcriptView() string {
mcpAddOverlay := m.mcpAddWizardOverlay(width)
mcpOverlay := m.mcpManagerOverlay(width)
pickerOverlay := m.pickerOverlay(width)
sttKeyOverlay := m.sttKeyPromptOverlay(width)
viewportOverlay := ""
switch {
case sttKeyOverlay != "":
viewportOverlay = sttKeyOverlay
case helpOverlayContent != "":
viewportOverlay = helpOverlayContent
case providerOverlay != "":
@@ -3833,7 +3755,7 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
text := ""
if owner := strings.TrimSpace(item.OwnerProvider); owner != "" && !strings.EqualFold(owner, strings.TrimSpace(m.providerName)) {
// A model from another saved provider: switch provider + model together.
m, text, _, cmd = m.switchProviderModel(owner, item.Value)
m, text, cmd = m.switchProviderModel(owner, item.Value)
} else {
m, text = m.handleModelCommand(item.Value)
}
@@ -3855,19 +3777,6 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
// submitting (a bare second Enter runs it without one); names the slash
// path cannot reach run immediately instead.
m, cmd = m.chooseSkillFromPicker(item)
case pickerSTTModel:
// Selecting the local engine with no model (and auto-download available)
// chains into the variant-download picker instead of finalizing.
if next, fetchCmd, opened := m.maybeOpenSTTDownloadPicker(item.Value); opened {
return next, fetchCmd
}
text := ""
m, text = m.handleSTTModelSelection(item.Value)
if text != "" { // empty when a key prompt opened instead of finalizing
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
case pickerSTTDownload:
return m.handleSTTDownloadSelection(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.
@@ -4039,20 +3948,14 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
case commandSandboxSetup:
return m.startSandboxSetupCommand(command.text)
case commandProvider:
arg := strings.ToLower(strings.TrimSpace(command.text))
if arg == "" || arg == "add" {
if strings.TrimSpace(command.text) == "" {
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
return m, nil
}
// Bare /provider opens the list-first manager over the saved
// providers; /provider add jumps straight into the add wizard.
if arg == "add" {
m.providerWizard = m.newProviderWizard()
m.clearSuggestions()
return m, nil
}
return m.openProviderManager()
m.providerWizard = m.newProviderWizard()
m.clearSuggestions()
return m, nil
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.providerText()})
return m, nil
@@ -4071,14 +3974,6 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
m, text = m.handleModelCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandSTTModel:
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
return m, nil
}
return m.openSTTModelPicker()
case commandVoice:
return m.toggleVoiceMode()
case commandContext:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.contextText()})
return m, nil
+1 -2
View File
@@ -467,8 +467,7 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
selected := clampInt(m.picker.selected, 0, len(m.picker.items)-1)
start := selectableListStart(len(m.picker.items), maxVisible, selected)
visible := m.picker.items[start : start+maxVisible]
// y=0 titled top border, y=1 search line, y=2 separator; rows begin at y=3.
line := 3
line := 2
lastGroup := ""
for offset, item := range visible {
if item.Group != "" && item.Group != lastGroup {
+1 -2
View File
@@ -62,8 +62,7 @@ func TestMouseClickSelectsThenAppliesPickerRow(t *testing.T) {
width := m.chatColumnWidth()
top := m.overlayMouseTop(len(viewLines(m.pickerOverlay(width))), width)
// Rows: y=0 titled border, y=1 search, y=2 separator, y=3 "auto", y=4 "high".
click := testMouseClick(tea.MouseLeft, width/2, top+4)
click := testMouseClick(tea.MouseLeft, width/2, top+3)
updated, cmd := m.Update(click)
next := updated.(model)
if cmd != nil {
-7
View File
@@ -586,13 +586,6 @@ func (m model) applySetupOAuth(msg setupOAuthMsg) (tea.Model, tea.Cmd) {
if msg.apiKey != "" {
m.setup.apiKey.SetValue(msg.apiKey)
}
if msg.tokenLogin {
// Early persist on the Update goroutine (see persistOAuthLoginProvider's
// threading contract) so quitting setup after the login doesn't lose it;
// completeSetup persists the full profile with the chosen model anyway,
// so a failure here is recoverable and not fatal to setup.
_ = persistOAuthLoginProvider(m.setup.configPath, msg.providerID)
}
m.setup.oauthErr = ""
m.setup.err = ""
m.setup.oauthDevice = false
-32
View File
@@ -22,7 +22,6 @@ import (
// Options configures the reusable Zero terminal UI shell.
type Options struct {
Cwd string
Version string // CLI build version, shown on the home screen; empty hides it
UserConfigPath string
DoctorUserConfigPath string
ProjectConfigPath string
@@ -76,37 +75,6 @@ type Options struct {
// KeyBindingsConfig means "use built-in defaults" for each action.
KeyBindings config.KeyBindingsConfig
// STT configures speech-to-text dictation (§ docs/dictation.md).
STT config.STTConfig
// BuildDictationTranscriber constructs the transcriber for the current STT
// config. It lives in the CLI layer because it resolves provider API keys
// (credstore + env) and base URLs; the TUI only calls it when a recording
// starts. preferStreaming asks for the streaming backend; the returned
// `streaming` bool reports whether streaming is actually available (false
// falls the caller back to the batch pipeline). Nil disables dictation (the
// keybinding shows a "not configured" hint). cfg is passed each call (not
// captured) so a mid-session config change — e.g. the auto-download writing
// localModelPath — takes effect on the next recording.
BuildDictationTranscriber func(cfg config.STTConfig, preferStreaming bool) (t Transcriber, streaming bool, err error)
// ShutdownDictationServer tears down the warm sherpa-onnx streaming server (if
// one was started), called alongside the LSP manager's shutdown on exit. Nil
// when dictation is not wired.
ShutdownDictationServer func(context.Context) error
// STTDownloadRoot is where the auto-download stores the sherpa-onnx engine
// and model (e.g. ~/.config/zero/stt). Empty disables auto-download (the F9
// setup message then only points at manual setup / cloud providers).
STTDownloadRoot string
// STTKeyStatus reports whether an API key is already resolvable for a cloud
// STT provider ("groq"/"openai"/"deepgram"). Nil disables the inline key
// prompt (dictation then just shows the "run zero auth" setup error).
STTKeyStatus func(provider string) bool
// SaveSTTKey stores an API key for a cloud STT provider in the credential
// store, so the inline prompt can capture and persist it.
SaveSTTKey func(provider, key string) error
// AltScreen tells the model it is running inside Bubble Tea's alternate
// screen. Run sets this for the interactive app; tests can leave it false
// to exercise the native scrollback renderer.
+1 -6
View File
@@ -25,8 +25,6 @@ const (
pickerSession
pickerTheme
pickerSkill
pickerSTTModel
pickerSTTDownload
)
// pickerItem is one selectable row: Label is shown, Value is passed to the
@@ -58,9 +56,6 @@ type commandPicker struct {
allItems []pickerItem
query string
selected int
// loading marks a picker still fetching its rows (e.g. the STT model list from
// GitHub): the overlay shows a "fetching…" line instead of "no matching items".
loading bool
}
func (p *commandPicker) move(delta int) {
@@ -393,7 +388,7 @@ func (m model) modelPickerDiscoveryCmds() tea.Cmd {
// OAuth bearer for token-login providers (e.g. xAI).
func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descriptor, profile config.ProviderProfile) tea.Cmd {
authed := profile
if store, err := m.providerKeyStore(); err == nil {
if store, err := config.ProviderKeyStore(); err == nil {
authed = config.ApplyStoredAPIKey(authed, store)
}
key := strings.TrimSpace(authed.APIKey)
+3 -77
View File
@@ -14,7 +14,6 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -966,9 +965,9 @@ func TestSwitchProviderModelWarmsDiscoveryForTheNewProvider(t *testing.T) {
},
})
next, text, ok, cmd := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
if !ok || !strings.Contains(text, "Switched to ollama") {
t.Fatalf("switch notice = %q (ok=%v), want a committed switch", text, ok)
next, text, cmd := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
if !strings.Contains(text, "Switched to ollama") {
t.Fatalf("switch notice = %q, want it to confirm the switch", text)
}
if next.modelName != "kimi-k2.7-code:cloud" || next.providerName != "ollama" {
t.Fatalf("model/provider not switched: modelName=%q providerName=%q", next.modelName, next.providerName)
@@ -1023,76 +1022,3 @@ func TestNormalizeProfileForProviderCanonicalizesPlaceholderName(t *testing.T) {
}
}
}
// TestSwitchProviderModelUsesOAuthLoginWithoutInliningBearer: switching to a
// token-login provider (ChatGPT) must pass the credential gate on the stored
// OAuth login and hand newProvider a KEYLESS profile. Inlining the bearer as
// APIKey makes HasConfiguredCredential true, which strips the OAuth login
// candidates at build time — the client is then constructed without the bearer
// resolver and login key, dropping token refresh and the Codex
// `chatgpt-account-id` header, so every call 401s.
func TestSwitchProviderModelUsesOAuthLoginWithoutInliningBearer(t *testing.T) {
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123", TokenType: "Bearer"}); err != nil {
t.Fatalf("save token: %v", err)
}
var built config.ProviderProfile
m := newModel(context.Background(), Options{
ProviderName: "opengateway",
ModelName: "some-model",
Provider: &fakeProvider{},
ProviderProfile: config.ProviderProfile{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
SavedProviders: []config.ProviderProfile{
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
},
NewProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
built = profile
return &fakeProvider{}, nil
},
})
next, text, ok, _ := m.switchProviderModel("chatgpt", "gpt-5.5")
if !ok || !strings.Contains(text, "Switched to chatgpt") {
t.Fatalf("switch should succeed on the stored OAuth login, got %q (ok=%v)", text, ok)
}
if next.providerName != "chatgpt" || next.modelName != "gpt-5.5" {
t.Fatalf("switch did not commit: provider=%q model=%q", next.providerName, next.modelName)
}
if built.APIKey != "" {
t.Fatalf("OAuth bearer must not be inlined as APIKey (suppresses the runtime resolver), got %q", built.APIKey)
}
}
// TestSwitchProviderModelStillRejectsProviderWithNoCredential: with no key, no
// header, no local runtime, and no OAuth login, the gate must keep refusing.
func TestSwitchProviderModelStillRejectsProviderWithNoCredential(t *testing.T) {
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
m := newModel(context.Background(), Options{
ProviderName: "opengateway",
ModelName: "some-model",
Provider: &fakeProvider{},
ProviderProfile: config.ProviderProfile{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
SavedProviders: []config.ProviderProfile{
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
},
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
t.Fatal("newProvider must not run for a credential-less provider")
return nil, nil
},
})
_, text, ok, _ := m.switchProviderModel("chatgpt", "gpt-5.5")
if ok || !strings.Contains(text, "no usable credential") {
t.Fatalf("expected the credential gate to refuse, got %q (ok=%v)", text, ok)
}
}
-828
View File
@@ -1,828 +0,0 @@
// Provider manager: the list-first /provider surface. It shares
// providerWizardState (steps providerWizardStepManage / EditMenu / EditValue)
// so the existing overlay gating, key routing, and mouse plumbing that check
// m.providerWizard cover it without touching those call sites.
//
// UX contract: the list IS the home screen — Enter activates the selected
// provider, `a` opens the add wizard, `e` opens a field-level editor, `d`
// asks inline and deletes. Esc walks back one level and closes from the list.
package tui
import (
"os"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
const providerManagerMaxVisible = 10
// providerManagerRow is one saved provider in the list. cred is resolved
// asynchronously (keychain reads shell out to `security` on macOS and must
// never block the render loop); empty means "still checking".
type providerManagerRow struct {
profile config.ProviderProfile
local bool
cred string
}
type providerEditField int
const (
providerEditFieldName providerEditField = iota
providerEditFieldEndpoint
providerEditFieldModel
providerEditFieldAPIKey
providerEditFieldDescription
providerEditFieldSave
)
var providerEditFields = []struct {
field providerEditField
label string
hint string
}{
{providerEditFieldName, "Name", "Renames the profile; a stored key and the active pointer follow automatically."},
{providerEditFieldEndpoint, "Endpoint", "Base URL for the provider's API."},
{providerEditFieldModel, "Model", "Default model for this provider."},
{providerEditFieldAPIKey, "API key", "Enter a new key to replace the stored one; leave empty to keep it."},
{providerEditFieldDescription, "Description", "Optional label shown in the provider list."},
{providerEditFieldSave, "Save", "Persist the changes."},
}
// providerManagerCredsMsg delivers the asynchronously resolved credential
// states for the manager rows. gen guards against a stale probe finishing
// after the manager was reopened with fresh rows.
type providerManagerCredsMsg struct {
gen int
creds map[string]string
}
// openProviderManager opens /provider on the list of saved providers. With
// nothing saved yet there is nothing to manage — fall through to the add
// wizard, which is also the first-run behavior users already know.
func (m model) openProviderManager() (model, tea.Cmd) {
if len(m.savedProviders) == 0 {
m.providerWizard = m.newProviderWizard()
m.clearSuggestions()
return m, nil
}
wizard := &providerWizardState{
step: providerWizardStepManage,
manage: true,
providers: providerWizardProviders(),
}
wizard.refreshModels()
m.providerWizard = wizard
m.clearSuggestions()
return m.reloadProviderManagerRows()
}
// reloadProviderManagerRows rebuilds the list from savedProviders and kicks off
// the async credential probe for the new generation.
func (m model) reloadProviderManagerRows() (model, tea.Cmd) {
if m.providerWizard == nil {
return m, nil
}
// Carry resolved credential states forward for names still present, so a
// single-row mutation doesn't flash every other row back to "checking…"
// while the async probe re-runs.
previous := make(map[string]string, len(m.providerWizard.manageRows))
for _, row := range m.providerWizard.manageRows {
previous[row.profile.Name] = row.cred
}
rows := make([]providerManagerRow, 0, len(m.savedProviders))
for _, profile := range m.savedProviders {
row := providerManagerRow{profile: profile, cred: previous[profile.Name]}
if descriptor, ok := m.descriptorForProfile(profile); ok {
row.local = descriptor.Local
}
rows = append(rows, row)
}
m.providerWizard.manageRows = rows
m.providerWizard.manageCursor = clampInt(m.providerWizard.manageCursor, 0, maxInt(0, len(rows)-1))
// The session's live provider is the truth the user cares about (config's
// activeProvider follows it on every switch).
m.providerWizard.manageActiveName = m.providerName
m.providerWizard.manageCredGen++
return m, providerManagerCredsCmd(m.providerWizard.manageCredGen, rows, m.userConfigPath)
}
// providerManagerCredsCmd resolves each row's credential state off the UI
// goroutine: encrypted-store reads (keychain subprocess on macOS), env lookups,
// and the OAuth token store. Keys are read from the store BESIDE configPath —
// the one the manager's write paths use.
func providerManagerCredsCmd(gen int, rows []providerManagerRow, configPath string) tea.Cmd {
profiles := make([]config.ProviderProfile, len(rows))
locals := make([]bool, len(rows))
for i, row := range rows {
profiles[i] = row.profile
locals[i] = row.local
}
return func() tea.Msg {
store, storeErr := providerKeyStoreForPath(configPath)
var getter config.APIKeyGetter
if storeErr == nil {
getter = store
}
// One token-store snapshot answers every row's OAuth question — a fresh
// store open + full file read per row (×2 candidates) answered the same
// question N times over.
logins := storedOAuthLogins()
creds := make(map[string]string, len(profiles))
for i, profile := range profiles {
creds[profile.Name] = providerManagerCredState(profile, locals[i], getter, logins)
}
return providerManagerCredsMsg{gen: gen, creds: creds}
}
}
// providerManagerCredState classifies how a profile authenticates, surfacing
// broken states ("stored key missing") instead of hiding them — that exact
// state is how a lost keychain entry shows up. A stale APIKeyStored marker
// falls THROUGH to the env/OAuth checks first, matching the runtime's own
// fallback order (profileWithCredential): a provider that will switch fine via
// its env var must not render as broken.
func providerManagerCredState(profile config.ProviderProfile, local bool, store config.APIKeyGetter, logins map[string]bool) string {
if strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "" {
return "key set"
}
if profile.APIKeyStored && store != nil {
if key, ok, err := store.Get(profile.Name); err == nil && ok && strings.TrimSpace(key) != "" {
return "key stored"
}
}
if env := strings.TrimSpace(profile.APIKeyEnv); env != "" && strings.TrimSpace(os.Getenv(env)) != "" {
return "env " + env
}
// OAuthLoginCandidates is nil for key-authed profiles, so probe a marker-free
// copy: a stale marker must not hide a usable login.
keyless := profile
keyless.APIKeyStored = false
for _, candidate := range keyless.OAuthLoginCandidates() {
// Store keys are normalized to lower case (oauth.ProviderKey).
if logins[strings.ToLower(strings.TrimSpace(candidate))] {
return "oauth login"
}
}
if local {
return "local"
}
if profile.APIKeyStored {
return "stored key missing"
}
return "no credential"
}
// storedOAuthLogins snapshots which provider logins exist in the token store —
// the same one-read pattern as cli's oauthLoggedInProviders. Errors degrade to
// an empty set.
func storedOAuthLogins() map[string]bool {
logins := map[string]bool{}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return logins
}
statuses, err := store.Status(oauth.KeyPrefixProvider)
if err != nil {
return logins
}
for _, status := range statuses {
if status.HasToken {
logins[strings.ToLower(strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider))] = true
}
}
return logins
}
func (m model) applyProviderManagerCreds(msg providerManagerCredsMsg) (model, tea.Cmd) {
if m.providerWizard == nil || !m.providerWizard.manage || msg.gen != m.providerWizard.manageCredGen {
return m, nil
}
for i := range m.providerWizard.manageRows {
if cred, ok := msg.creds[m.providerWizard.manageRows[i].profile.Name]; ok {
m.providerWizard.manageRows[i].cred = cred
}
}
return m, nil
}
// providerWizardManagerStep reports whether the wizard is on one of the
// manager-owned steps (list / edit menu / edit value).
func (wizard *providerWizardState) managerStep() bool {
if wizard == nil {
return false
}
switch wizard.step {
case providerWizardStepManage, providerWizardStepEditMenu, providerWizardStepEditValue:
return true
}
return false
}
func (wizard *providerWizardState) currentManagerRow() (providerManagerRow, bool) {
if wizard == nil || len(wizard.manageRows) == 0 {
return providerManagerRow{}, false
}
wizard.manageCursor = clampInt(wizard.manageCursor, 0, len(wizard.manageRows)-1)
return wizard.manageRows[wizard.manageCursor], true
}
func (m model) handleProviderManagerKey(msg tea.KeyMsg) (model, tea.Cmd) {
wizard := m.providerWizard
switch wizard.step {
case providerWizardStepManage:
return m.handleProviderManageListKey(msg)
case providerWizardStepEditMenu:
return m.handleProviderEditMenuKey(msg)
case providerWizardStepEditValue:
return m.handleProviderEditValueKey(msg)
}
return m, nil
}
func (m model) handleProviderManageListKey(msg tea.KeyMsg) (model, tea.Cmd) {
wizard := m.providerWizard
if wizard.manageDeleting {
switch {
case keyIs(msg, tea.KeyEnter) || strings.EqualFold(keyText(msg), "y"):
return m.deleteManagerSelection()
case keyIs(msg, tea.KeyEsc) || strings.EqualFold(keyText(msg), "n"):
wizard.manageDeleting = false
}
return m, nil
}
switch {
case keyIs(msg, tea.KeyEsc):
m.providerWizard = nil
return m, nil
case keyIs(msg, tea.KeyUp):
m.moveProviderManager(-1)
return m, nil
case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
m.moveProviderManager(1)
return m, nil
case keyIs(msg, tea.KeyEnter):
return m.activateManagerSelection()
case strings.EqualFold(keyText(msg), "a"):
// Add: hand over to the normal wizard flow; Esc from its first step
// returns here (fromManage-style handling in handleProviderWizardKey).
wizard.step = providerWizardStepMethod
wizard.err = ""
wizard.manageStatus = ""
return m, nil
case strings.EqualFold(keyText(msg), "e"):
if row, ok := wizard.currentManagerRow(); ok {
wizard.beginProviderEdit(row.profile)
}
return m, nil
case strings.EqualFold(keyText(msg), "d"):
if _, ok := wizard.currentManagerRow(); ok {
wizard.manageDeleting = true
wizard.manageStatus = ""
}
return m, nil
}
return m, nil
}
func (m *model) moveProviderManager(delta int) {
wizard := m.providerWizard
count := len(wizard.manageRows)
if count == 0 {
wizard.manageCursor = 0
return
}
wizard.manageCursor = ((wizard.manageCursor+delta)%count + count) % count
wizard.manageStatus = ""
}
// activateManagerSelection makes the selected provider active via the shared
// switch path (persists activeProvider+model, rebuilds the client OAuth-aware,
// exports ZERO_PROVIDER, warms discovery). On success the manager closes and
// the switch notice lands in the transcript; a refusal (busy run, missing
// credential) stays inline so the user keeps their place in the list.
func (m model) activateManagerSelection() (model, tea.Cmd) {
wizard := m.providerWizard
row, ok := wizard.currentManagerRow()
if !ok {
return m, nil
}
if m.pending {
wizard.manageStatus = "Cannot switch providers while a run is active."
return m, nil
}
next, text, switched, cmd := m.switchProviderModel(row.profile.Name, row.profile.Model)
if switched {
next.providerWizard = nil
next.transcript = reduceTranscript(next.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return next, cmd
}
if next.providerWizard != nil {
next.providerWizard.manageStatus = strings.TrimPrefix(text, "Model\n")
}
return next, cmd
}
// deleteManagerSelection removes the confirmed provider: the config write runs
// synchronously (the list must reflect the config the instant the confirm
// resolves), while the stored-key delete and the OAuth-login lookup — a
// keychain subprocess and a token-store read — run in a follow-up tea.Cmd so
// the confirm keypress never stalls the render loop. The OAuth token is
// deliberately kept — logins outlive profiles so re-adding the provider
// doesn't force a browser round-trip; zero auth logout removes it.
func (m model) deleteManagerSelection() (model, tea.Cmd) {
wizard := m.providerWizard
wizard.manageDeleting = false
row, ok := wizard.currentManagerRow()
if !ok {
return m, nil
}
name := row.profile.Name
if strings.TrimSpace(m.userConfigPath) == "" {
wizard.manageStatus = "No user config path — cannot delete."
return m, nil
}
cfg, err := config.RemoveProvider(m.userConfigPath, name)
if err != nil {
wizard.manageStatus = "Delete failed: " + err.Error()
return m, nil
}
// Surgical removal — see saveManagerEdit for why the raw cfg.Providers list
// must not replace the resolved/filtered savedProviders wholesale.
m.savedProviders = removeSavedProvider(m.savedProviders, name)
notes := []string{"Deleted " + name + "."}
if strings.EqualFold(strings.TrimSpace(m.providerName), strings.TrimSpace(name)) {
notes = append(notes, "This session keeps running on it until you switch.")
} else if active := strings.TrimSpace(cfg.ActiveProvider); active != "" && !strings.EqualFold(active, name) {
notes = append(notes, "Active provider: "+active+".")
}
cleanup := providerManagerCleanupCmd(m.userConfigPath, row.profile)
if len(m.savedProviders) == 0 {
m.providerWizard = nil
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Provider\n" + strings.Join(notes, " ")})
return m, cleanup
}
next, cmd := m.reloadProviderManagerRows()
next.providerWizard.manageStatus = strings.Join(notes, " ")
return next, tea.Batch(cmd, cleanup)
}
// removeSavedProvider drops one profile from the in-memory saved list.
func removeSavedProvider(saved []config.ProviderProfile, name string) []config.ProviderProfile {
kept := saved[:0]
for _, profile := range saved {
if strings.EqualFold(strings.TrimSpace(profile.Name), strings.TrimSpace(name)) {
continue
}
kept = append(kept, profile)
}
return kept
}
// providerManagerCleanupMsg reports the off-thread half of a delete: the
// stored-key removal outcome and the OAuth-login hint.
type providerManagerCleanupMsg struct {
notes []string
}
// providerManagerCleanupCmd finishes a delete off the UI goroutine: the
// keychain delete shells out to `security` on macOS and the OAuth-login lookup
// reads the token store — blocking work the confirm keypress must not wait on.
// A failed key delete is surfaced rather than letting a lingering secret read
// as a clean removal.
func providerManagerCleanupCmd(configPath string, profile config.ProviderProfile) tea.Cmd {
name := profile.Name
catalogID := profile.CatalogID
return func() tea.Msg {
notes := []string{}
keyStore, storeErr := providerKeyStoreForPath(configPath)
if storeErr == nil {
_, storeErr = keyStore.Delete(name)
}
if storeErr != nil {
notes = append(notes, "Warning: its stored API key could not be deleted ("+storeErr.Error()+").")
}
if login, ok := oauthLoginName(config.ProviderProfile{Name: name, CatalogID: catalogID}); ok {
notes = append(notes, "OAuth login kept — remove with `zero auth logout "+login+"`.")
}
return providerManagerCleanupMsg{notes: notes}
}
}
func (m model) applyProviderManagerCleanup(msg providerManagerCleanupMsg) (model, tea.Cmd) {
if len(msg.notes) == 0 {
return m, nil
}
extra := strings.Join(msg.notes, " ")
if m.providerWizard != nil && m.providerWizard.manage {
if m.providerWizard.manageStatus != "" {
m.providerWizard.manageStatus += " " + extra
} else {
m.providerWizard.manageStatus = extra
}
return m, nil
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Provider\n" + extra})
return m, nil
}
// --- edit -------------------------------------------------------------------
func (wizard *providerWizardState) beginProviderEdit(profile config.ProviderProfile) {
wizard.editOriginal = profile
wizard.editDraft = profile
wizard.editDraft.APIKey = "" // key field is enter-to-replace, never prefilled
wizard.editCursor = 0
wizard.err = ""
wizard.manageStatus = ""
wizard.step = providerWizardStepEditMenu
}
func (wizard *providerWizardState) editFieldValue(field providerEditField, draft config.ProviderProfile) string {
switch field {
case providerEditFieldName:
return draft.Name
case providerEditFieldEndpoint:
return draft.BaseURL
case providerEditFieldModel:
return draft.Model
case providerEditFieldAPIKey:
return draft.APIKey
case providerEditFieldDescription:
return draft.Description
}
return ""
}
func (m model) handleProviderEditMenuKey(msg tea.KeyMsg) (model, tea.Cmd) {
wizard := m.providerWizard
switch {
case keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyLeft):
wizard.step = providerWizardStepManage
wizard.err = ""
return m, nil
case keyIs(msg, tea.KeyUp):
wizard.editCursor = ((wizard.editCursor-1)%len(providerEditFields) + len(providerEditFields)) % len(providerEditFields)
return m, nil
case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
wizard.editCursor = (wizard.editCursor + 1) % len(providerEditFields)
return m, nil
case keyIs(msg, tea.KeyEnter):
entry := providerEditFields[clampInt(wizard.editCursor, 0, len(providerEditFields)-1)]
if entry.field == providerEditFieldSave {
return m.saveManagerEdit()
}
wizard.editField = entry.field
wizard.editBuffer = wizard.editFieldValue(entry.field, wizard.editDraft)
wizard.err = ""
wizard.step = providerWizardStepEditValue
return m, nil
}
return m, nil
}
func (m model) handleProviderEditValueKey(msg tea.KeyMsg) (model, tea.Cmd) {
wizard := m.providerWizard
switch {
case keyIs(msg, tea.KeyEsc):
wizard.step = providerWizardStepEditMenu
wizard.err = ""
return m, nil
case keyIs(msg, tea.KeyEnter):
if err := wizard.commitEditBuffer(); err != "" {
wizard.err = err
return m, nil
}
wizard.err = ""
wizard.step = providerWizardStepEditMenu
return m, nil
case keyCtrl(msg, 'u'):
wizard.editBuffer = ""
wizard.err = ""
return m, nil
case keyBackspace(msg):
if wizard.editBuffer != "" {
runes := []rune(wizard.editBuffer)
wizard.editBuffer = string(runes[:len(runes)-1])
}
wizard.err = ""
return m, nil
case keyText(msg) != "":
for _, r := range keyRunes(msg) {
if r == '\n' || r == '\r' || r == '\t' {
continue
}
wizard.editBuffer += string(r)
}
wizard.err = ""
return m, nil
}
return m, nil
}
// commitEditBuffer validates and folds the edited value into the draft.
// Returns a non-empty error string to keep the user on the field.
func (wizard *providerWizardState) commitEditBuffer() string {
value := strings.TrimSpace(wizard.editBuffer)
switch wizard.editField {
case providerEditFieldName:
if value == "" {
return "name cannot be empty"
}
wizard.editDraft.Name = value
case providerEditFieldEndpoint:
if err := providerWizardEndpointError(value); err != "" {
return err
}
wizard.editDraft.BaseURL = value
case providerEditFieldModel:
if value == "" {
return "model cannot be empty"
}
wizard.editDraft.Model = value
case providerEditFieldAPIKey:
wizard.editDraft.APIKey = strings.TrimSpace(wizard.editBuffer)
case providerEditFieldDescription:
wizard.editDraft.Description = value
}
return ""
}
// saveManagerEdit persists the draft in ONE atomic config write
// (config.EditProvider handles rename — case-only included — field merge,
// verbatim description, the stored-key marker, and key migration together, so
// a failure leaves nothing half-applied). A freshly entered key is captured
// into the encrypted store under the CURRENT name first (EditProvider's rename
// migration then moves it), so config.json never holds it in cleartext. The
// live session follows a rename so ZERO_PROVIDER and the status line never
// point at a name that no longer exists.
func (m model) saveManagerEdit() (model, tea.Cmd) {
wizard := m.providerWizard
if strings.TrimSpace(m.userConfigPath) == "" {
wizard.err = "no user config path — cannot save"
return m, nil
}
oldName := strings.TrimSpace(wizard.editOriginal.Name)
newName := strings.TrimSpace(wizard.editDraft.Name)
if newName == "" {
wizard.err = "name cannot be empty"
return m, nil
}
edit := config.ProviderEdit{
Name: oldName,
NewName: newName,
BaseURL: strings.TrimSpace(wizard.editDraft.BaseURL),
Model: strings.TrimSpace(wizard.editDraft.Model),
Description: wizard.editDraft.Description,
}
if key := strings.TrimSpace(wizard.editDraft.APIKey); key != "" {
captured := config.SecureProviderProfile(config.ProviderProfile{Name: oldName, APIKey: key}, m.userConfigPath)
// On a store failure SecureProviderProfile keeps the inline key, which
// EditProvider then persists (the startup migration re-captures later) —
// the same fail-soft posture as every other capture path.
edit.APIKey = captured.APIKey
edit.APIKeyStored = captured.APIKeyStored
}
if _, err := config.EditProvider(m.userConfigPath, edit); err != nil {
wizard.err = err.Error()
return m, nil
}
// Mirror the edit into the in-memory list surgically. savedProviders was
// seeded from the RESOLVED (project-config layered) and usability-FILTERED
// provider set — substituting the raw user-file list here would drop
// project-contributed providers and resurrect filtered stubs for the session.
m.savedProviders = applySavedProviderEdit(m.savedProviders, oldName, edit)
// Keep the live session's identity in sync with a rename of the provider it
// is running on: the exported ZERO_PROVIDER must resolve for spawned children.
if strings.EqualFold(strings.TrimSpace(m.providerName), oldName) {
m.providerName = newName
m.providerProfile.Name = newName
config.SetActiveProviderEnv(newName)
}
wizard.step = providerWizardStepManage
next, cmd := m.reloadProviderManagerRows()
next.providerWizard.manageStatus = "Updated " + newName + "." + providerEditRestartNote(next.providerName, newName)
return next, cmd
}
// providerEditRestartNote reminds the user when the edited provider is the one
// this session is running on — endpoint/model/key changes only apply to the
// built client after a switch (Enter on the row re-activates and rebuilds).
// liveName is the session's provider AFTER any rename sync, so a single
// comparison against the edited profile's final name suffices.
func providerEditRestartNote(liveName string, editedName string) string {
if strings.EqualFold(strings.TrimSpace(liveName), strings.TrimSpace(editedName)) {
return " Press Enter on it to apply the changes to this session."
}
return ""
}
// applySavedProviderEdit mirrors a persisted config.EditProvider into the
// in-memory saved list without wholesale replacement (see saveManagerEdit).
func applySavedProviderEdit(saved []config.ProviderProfile, oldName string, edit config.ProviderEdit) []config.ProviderProfile {
for index := range saved {
if !strings.EqualFold(strings.TrimSpace(saved[index].Name), strings.TrimSpace(oldName)) {
continue
}
profile := &saved[index]
if newName := strings.TrimSpace(edit.NewName); newName != "" {
profile.Name = newName
}
if baseURL := strings.TrimSpace(edit.BaseURL); baseURL != "" {
profile.BaseURL = baseURL
}
if model := strings.TrimSpace(edit.Model); model != "" {
profile.Model = model
}
if apiKey := strings.TrimSpace(edit.APIKey); apiKey != "" {
profile.APIKey = apiKey
}
if edit.APIKeyStored {
profile.APIKeyStored = true
profile.APIKey = ""
}
profile.Description = strings.TrimSpace(edit.Description)
break
}
return saved
}
// --- rendering ---------------------------------------------------------------
// upsertSavedProviderProfile mirrors a freshly persisted profile into the
// in-memory saved list (replace by name, else append).
func upsertSavedProviderProfile(saved []config.ProviderProfile, profile config.ProviderProfile) []config.ProviderProfile {
for index := range saved {
if strings.EqualFold(strings.TrimSpace(saved[index].Name), strings.TrimSpace(profile.Name)) {
saved[index] = profile
return saved
}
}
return append(saved, profile)
}
func (wizard *providerWizardState) renderManageStep(width int) []string {
rows := wizard.manageRows
lines := []string{}
if wizard.manageStatus != "" {
lines = append(lines, fitStyledLine(zeroTheme.accent.Render(wizard.manageStatus), width), "")
}
if len(rows) == 0 {
lines = append(lines, zeroTheme.faint.Render(" No providers saved — press a to add one."))
return lines
}
wizard.manageCursor = clampInt(wizard.manageCursor, 0, len(rows)-1)
nameWidth := 0
for _, row := range rows {
nameWidth = maxInt(nameWidth, len([]rune(row.profile.Name)))
}
nameWidth = minInt(nameWidth, 28)
maxVisible := minInt(providerManagerMaxVisible, len(rows))
start := selectableListStart(len(rows), maxVisible, wizard.manageCursor)
for offset, row := range rows[start : start+maxVisible] {
index := start + offset
surface := transparentSurface
marker := surface(zeroTheme.faintest).Render(" ")
if index == wizard.manageCursor {
surface = zeroTheme.onSel
marker = surface(zeroTheme.accent).Render("❯ ")
}
active := ""
if strings.EqualFold(strings.TrimSpace(row.profile.Name), strings.TrimSpace(wizard.manageActiveName)) {
active = surface(zeroTheme.accent).Render(" ● active")
}
name := padProviderManagerCell(row.profile.Name, nameWidth)
meta := providerManagerRowMeta(row.profile)
left := marker + surface(zeroTheme.ink).Render(name) + " " + surface(zeroTheme.faint).Render(meta)
right := surface(zeroTheme.faint).Render(providerManagerCredDisplay(row)) + active
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
line := left + surface(zeroTheme.ink).Render(strings.Repeat(" ", maxInt(1, gap))) + right
lines = append(lines, fillPaletteLine(line, width, surface))
}
if row, ok := wizard.currentManagerRow(); ok {
lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", width)))
detail := strings.TrimSpace(row.profile.BaseURL)
if description := strings.TrimSpace(row.profile.Description); description != "" {
if detail != "" {
detail += " — "
}
detail += description
}
if detail == "" {
detail = "(no endpoint)"
}
lines = append(lines, fitStyledLine(zeroTheme.faint.Render(detail), width))
if wizard.manageDeleting {
lines = append(lines, fitStyledLine(zeroTheme.red.Render("Delete "+row.profile.Name+"? This also removes its stored API key. Enter/y confirm · Esc/n cancel"), width))
}
}
return lines
}
// manageActiveName is resolved at render time via the field set in
// reloadProviderManagerRows — kept on the struct so the pure render funcs
// don't need the model.
func providerManagerRowMeta(profile config.ProviderProfile) string {
kind := strings.TrimSpace(string(profile.ProviderKind))
if kind == "" {
kind = strings.TrimSpace(profile.Provider)
}
if catalog := strings.TrimSpace(profile.CatalogID); catalog != "" && !strings.EqualFold(catalog, profile.Name) {
kind = catalog
}
model := strings.TrimSpace(profile.Model)
switch {
case kind != "" && model != "":
return kind + " · " + model
case kind != "":
return kind
default:
return model
}
}
func providerManagerCredDisplay(row providerManagerRow) string {
if row.cred == "" {
return "checking…"
}
return row.cred
}
func padProviderManagerCell(value string, width int) string {
runes := []rune(value)
if len(runes) > width {
if width <= 1 {
return string(runes[:width])
}
return string(runes[:width-1]) + "…"
}
return value + strings.Repeat(" ", width-len(runes))
}
func (wizard *providerWizardState) renderEditMenuStep(width int) []string {
lines := []string{zeroTheme.accent.Render("Edit " + wizard.editOriginal.Name)}
wizard.editCursor = clampInt(wizard.editCursor, 0, len(providerEditFields)-1)
for index, entry := range providerEditFields {
surface := transparentSurface
marker := surface(zeroTheme.faintest).Render(" ")
if index == wizard.editCursor {
surface = zeroTheme.onSel
marker = surface(zeroTheme.accent).Render("❯ ")
}
value := wizard.editFieldValue(entry.field, wizard.editDraft)
display := ""
switch entry.field {
case providerEditFieldAPIKey:
if value != "" {
display = maskedProviderWizardKey(value) + " (new)"
} else if wizard.editOriginal.APIKeyStored {
display = "stored — enter to replace"
} else {
display = "none — enter to add"
}
case providerEditFieldSave:
display = ""
default:
display = displayValue(value, "(empty)")
}
left := marker + surface(zeroTheme.ink).Render(padProviderManagerCell(entry.label, 12))
if display != "" {
left += surface(zeroTheme.faint).Render(display)
}
lines = append(lines, fillPaletteLine(fitStyledLine(left, width), width, surface))
}
entry := providerEditFields[wizard.editCursor]
lines = append(lines, "", fitStyledLine(zeroTheme.faint.Render(entry.hint), width))
return lines
}
func (wizard *providerWizardState) renderEditValueStep(width int) []string {
entry := providerEditFields[clampInt(wizard.editCursor, 0, len(providerEditFields)-1)]
value := wizard.editBuffer
if wizard.editField == providerEditFieldAPIKey {
value = maskedProviderWizardKey(value)
}
prompt := zeroTheme.userPrompt.Render(entry.label + " > ")
if value == "" {
value = zeroTheme.faint.Render("(empty)")
} else {
value = zeroTheme.ink.Render(value)
}
return []string{
zeroTheme.accent.Render("Edit " + wizard.editOriginal.Name + " — " + entry.label),
fitStyledLine(prompt+value, width),
"",
fitStyledLine(zeroTheme.faint.Render(entry.hint), width),
}
}
-647
View File
@@ -1,647 +0,0 @@
package tui
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// managerTestModel builds a model with two saved providers, a seeded config
// file, and a fake provider factory, then opens /provider on the manager list.
func managerTestModel(t *testing.T) model {
t.Helper()
// Isolate every credential surface the manager touches: XDG redirects the
// default config dir (oauth token store, default credential store location)
// and the file backend keeps the OS keychain out of tests entirely.
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("ZERO_OAUTH_TOKENS_PATH", filepath.Join(home, "oauth-tokens.json"))
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
configPath := filepath.Join(t.TempDir(), "config.json")
seed := config.FileConfig{
ActiveProvider: "opengateway",
Providers: []config.ProviderProfile{
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", APIKey: "sk-gw", Model: "mimo-v2.5-pro", Description: "Main gateway"},
{Name: "backup", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://backup.example.com/v1", APIKey: "sk-backup", Model: "backup-model"},
},
}
data, err := json.MarshalIndent(seed, "", " ")
if err != nil {
t.Fatalf("encode seed config: %v", err)
}
if err := os.WriteFile(configPath, data, 0o600); err != nil {
t.Fatalf("write seed config: %v", err)
}
m := newModel(context.Background(), Options{
ProviderName: "opengateway",
ModelName: "mimo-v2.5-pro",
Provider: &fakeProvider{},
ProviderProfile: seed.Providers[0],
SavedProviders: seed.Providers,
UserConfigPath: configPath,
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return &fakeProvider{}, nil
},
})
m.width = 120
m.height = 40
next, _ := m.openProviderManager()
return next
}
func managerKey(t *testing.T, m model, msg tea.KeyMsg) model {
t.Helper()
next, _ := m.handleProviderWizardKey(msg)
return next
}
func TestOpenProviderManagerListsSavedProviders(t *testing.T) {
m := managerTestModel(t)
if m.providerWizard == nil || m.providerWizard.step != providerWizardStepManage {
t.Fatalf("expected the manager list to open, got %+v", m.providerWizard)
}
if len(m.providerWizard.manageRows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(m.providerWizard.manageRows))
}
rendered := m.providerWizard.render(m.width)
for _, want := range []string{"opengateway", "backup", "active", "Enter activate"} {
if !strings.Contains(rendered, want) {
t.Fatalf("manager render missing %q:\n%s", want, rendered)
}
}
}
func TestOpenProviderManagerFallsBackToWizardWhenEmpty(t *testing.T) {
m := newModel(context.Background(), Options{})
next, _ := m.openProviderManager()
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepMethod {
t.Fatalf("no saved providers should open the add wizard, got %+v", next.providerWizard)
}
}
func TestProviderManagerEnterActivatesSelection(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKey(tea.KeyDown)) // move to "backup"
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard != nil {
t.Fatalf("manager should close after a successful switch")
}
if next.providerName != "backup" || next.modelName != "backup-model" {
t.Fatalf("switch did not commit: provider=%q model=%q", next.providerName, next.modelName)
}
persisted := readManagerConfig(t, next.userConfigPath)
if persisted.ActiveProvider != "backup" {
t.Fatalf("activeProvider not persisted, got %q", persisted.ActiveProvider)
}
}
func TestProviderManagerActivateBlockedWhilePending(t *testing.T) {
m := managerTestModel(t)
m.pending = true
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil || next.providerWizard.manageStatus == "" {
t.Fatalf("a pending run must keep the manager open with a status, got %+v", next.providerWizard)
}
if next.providerName != "opengateway" {
t.Fatalf("provider must not switch while pending, got %q", next.providerName)
}
}
func TestProviderManagerDeleteConfirmsAndRemoves(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
m = managerKey(t, m, testKeyText("d"))
if !m.providerWizard.manageDeleting {
t.Fatalf("d must arm the inline delete confirm")
}
// Esc cancels the confirm without closing the manager.
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard == nil || m.providerWizard.manageDeleting {
t.Fatalf("Esc must cancel the confirm, keeping the manager open")
}
if got := readManagerConfig(t, m.userConfigPath); len(got.Providers) != 2 {
t.Fatalf("cancelled delete must not touch config, got %d providers", len(got.Providers))
}
m = managerKey(t, m, testKeyText("d"))
next, _ := m.handleProviderWizardKey(testKeyText("y"))
if next.providerWizard == nil {
t.Fatalf("manager should stay open while providers remain")
}
if len(next.savedProviders) != 1 || next.savedProviders[0].Name != "opengateway" {
t.Fatalf("savedProviders not updated: %+v", next.savedProviders)
}
persisted := readManagerConfig(t, next.userConfigPath)
if len(persisted.Providers) != 1 || persisted.Providers[0].Name != "opengateway" {
t.Fatalf("config not updated: %+v", persisted.Providers)
}
if persisted.ActiveProvider != "opengateway" {
t.Fatalf("active must be untouched when a non-active profile is deleted, got %q", persisted.ActiveProvider)
}
if !strings.Contains(next.providerWizard.manageStatus, "Deleted backup") {
t.Fatalf("expected delete status, got %q", next.providerWizard.manageStatus)
}
}
func TestProviderManagerEditModelPersists(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("e"))
if m.providerWizard.step != providerWizardStepEditMenu {
t.Fatalf("e must open the field editor, got step %v", m.providerWizard.step)
}
// Field order: Name, Endpoint, Model, API key, Description, Save.
m = managerKey(t, m, testKey(tea.KeyDown))
m = managerKey(t, m, testKey(tea.KeyDown))
m = managerKey(t, m, testKey(tea.KeyEnter)) // edit Model
if m.providerWizard.step != providerWizardStepEditValue || m.providerWizard.editField != providerEditFieldModel {
t.Fatalf("expected model value editor, got step %v field %v", m.providerWizard.step, m.providerWizard.editField)
}
m.providerWizard.editBuffer = "mimo-v3"
m = managerKey(t, m, testKey(tea.KeyEnter)) // commit field
if m.providerWizard.step != providerWizardStepEditMenu {
t.Fatalf("commit should return to the field menu")
}
// Move to Save (cursor still on Model=index 2; Save is index 5).
m = managerKey(t, m, testKey(tea.KeyDown))
m = managerKey(t, m, testKey(tea.KeyDown))
m = managerKey(t, m, testKey(tea.KeyDown))
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("save should land back on the manager list")
}
persisted := readManagerConfig(t, next.userConfigPath)
if persisted.Providers[0].Model != "mimo-v3" {
t.Fatalf("edited model not persisted: %+v", persisted.Providers[0])
}
if persisted.Providers[0].APIKey != "sk-gw" {
t.Fatalf("unrelated credential must survive the edit: %+v", persisted.Providers[0])
}
}
func TestProviderManagerRenameFollowsLiveSession(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("e"))
m = managerKey(t, m, testKey(tea.KeyEnter)) // edit Name (cursor 0)
m.providerWizard.editBuffer = "gateway-main"
m = managerKey(t, m, testKey(tea.KeyEnter))
// Save is 5 rows below Name.
for range 5 {
m = managerKey(t, m, testKey(tea.KeyDown))
}
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("save should return to the list, got %+v", next.providerWizard)
}
if next.providerName != "gateway-main" {
t.Fatalf("live session name must follow the rename, got %q", next.providerName)
}
persisted := readManagerConfig(t, next.userConfigPath)
if persisted.ActiveProvider != "gateway-main" {
t.Fatalf("activeProvider must follow the rename, got %q", persisted.ActiveProvider)
}
names := []string{}
for _, profile := range persisted.Providers {
names = append(names, profile.Name)
}
if len(persisted.Providers) != 2 || persisted.Providers[0].Name != "gateway-main" {
t.Fatalf("rename not persisted, providers: %v", names)
}
}
func TestProviderManagerEscWalksBackThenCloses(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("e"))
m = managerKey(t, m, testKey(tea.KeyEnter)) // into Name editor
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard.step != providerWizardStepEditMenu {
t.Fatalf("Esc from a field editor must return to the field menu")
}
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard.step != providerWizardStepManage {
t.Fatalf("Esc from the field menu must return to the list")
}
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard != nil {
t.Fatalf("Esc from the list must close the manager")
}
}
func TestProviderManagerAddReturnsToListOnEsc(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("a"))
if m.providerWizard.step != providerWizardStepMethod {
t.Fatalf("a must open the add wizard's method step")
}
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard == nil || m.providerWizard.step != providerWizardStepManage {
t.Fatalf("Esc from a manager-opened add flow must return to the list")
}
}
func TestProviderManagerCredsMsgFillsRowsAndIgnoresStale(t *testing.T) {
m := managerTestModel(t)
gen := m.providerWizard.manageCredGen
next, _ := m.applyProviderManagerCreds(providerManagerCredsMsg{gen: gen, creds: map[string]string{
"opengateway": "key set",
"backup": "no credential",
}})
if next.providerWizard.manageRows[0].cred != "key set" || next.providerWizard.manageRows[1].cred != "no credential" {
t.Fatalf("creds not applied: %+v", next.providerWizard.manageRows)
}
// A stale generation must not clobber fresh rows.
next, _ = next.applyProviderManagerCreds(providerManagerCredsMsg{gen: gen - 1, creds: map[string]string{
"opengateway": "stale",
}})
if next.providerWizard.manageRows[0].cred != "key set" {
t.Fatalf("stale creds applied: %+v", next.providerWizard.manageRows[0])
}
}
func readManagerConfig(t *testing.T, path string) config.FileConfig {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg config.FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
return cfg
}
// TestProviderManagerEditKeyPersistsStoredMarker: replacing the key of a
// provider that previously had NO stored-key marker (e.g. env-authed) must
// persist apiKeyStored — otherwise the secret sits in the credential store
// while every ApplyStoredAPIKey gate skips it (PR #560 review, P2).
func TestProviderManagerEditKeyPersistsStoredMarker(t *testing.T) {
m := managerTestModel(t)
// Reshape "backup" into an env-authed profile with no marker and no inline key.
seedCfg := readManagerConfig(t, m.userConfigPath)
seedCfg.Providers[1].APIKey = ""
seedCfg.Providers[1].APIKeyEnv = "BACKUP_API_KEY"
data, err := json.MarshalIndent(seedCfg, "", " ")
if err != nil {
t.Fatalf("encode config: %v", err)
}
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
m = managerKey(t, m, testKeyText("e"))
for range 3 { // Name → Endpoint → Model → API key
m = managerKey(t, m, testKey(tea.KeyDown))
}
m = managerKey(t, m, testKey(tea.KeyEnter))
if m.providerWizard.editField != providerEditFieldAPIKey {
t.Fatalf("expected API key editor, got field %v", m.providerWizard.editField)
}
m.providerWizard.editBuffer = "sk-new-secret"
m = managerKey(t, m, testKey(tea.KeyEnter))
m = managerKey(t, m, testKey(tea.KeyDown)) // Description
m = managerKey(t, m, testKey(tea.KeyDown)) // Save
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("save should return to the list, err=%q", next.providerWizard.err)
}
persisted := readManagerConfig(t, next.userConfigPath)
backup := persisted.Providers[1]
if !backup.APIKeyStored {
t.Fatalf("apiKeyStored marker must persist after a key edit: %+v", backup)
}
if backup.APIKey != "" {
t.Fatalf("cleartext key must never land in config.json: %+v", backup)
}
// The env reference is deliberately preserved as an explicit override: the
// resolver fills APIKey from a SET env var first, and the stored key applies
// only when the env var is absent — locked in here so a future merge change
// can't silently flip the precedence.
if backup.APIKeyEnv != "BACKUP_API_KEY" {
t.Fatalf("APIKeyEnv must survive a key edit as an explicit override, got %q", backup.APIKeyEnv)
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(next.userConfigPath))
if err != nil {
t.Fatalf("open store: %v", err)
}
if key, ok, err := store.Get("backup"); err != nil || !ok || key != "sk-new-secret" {
t.Fatalf("key must be captured into the store beside the config, got key=%q ok=%v err=%v", key, ok, err)
}
}
// TestProviderManagerDescriptionClearPersists: clearing a description must
// actually persist (the upsert merge treats empty as "unchanged" — PR #560, P3).
func TestProviderManagerDescriptionClearPersists(t *testing.T) {
m := managerTestModel(t) // opengateway has description "Main gateway"
m = managerKey(t, m, testKeyText("e"))
for range 4 { // Name → Endpoint → Model → API key → Description
m = managerKey(t, m, testKey(tea.KeyDown))
}
m = managerKey(t, m, testKey(tea.KeyEnter))
if m.providerWizard.editField != providerEditFieldDescription {
t.Fatalf("expected description editor, got field %v", m.providerWizard.editField)
}
m.providerWizard.editBuffer = ""
m = managerKey(t, m, testKey(tea.KeyEnter))
m = managerKey(t, m, testKey(tea.KeyDown)) // Save
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("save should return to the list, err=%q", next.providerWizard.err)
}
persisted := readManagerConfig(t, next.userConfigPath)
if persisted.Providers[0].Description != "" {
t.Fatalf("cleared description must persist, got %q", persisted.Providers[0].Description)
}
if row, ok := next.providerWizard.currentManagerRow(); !ok || row.profile.Description != "" {
t.Fatalf("manager rows must reflect the cleared description: %+v", row.profile)
}
}
// TestProviderManagerDeleteHintNamesActualOAuthLogin: after a rename the token
// lives under the catalog id, not the profile name — the cleanup hint must name
// the entry `zero auth logout` would actually delete (PR #560 review, P3).
func TestProviderManagerDeleteHintNamesActualOAuthLogin(t *testing.T) {
m := managerTestModel(t)
// Reshape "backup" into a renamed OAuth catalog profile: keyless, named
// "codex", backed by a token stored under the chatgpt catalog id.
seedCfg := readManagerConfig(t, m.userConfigPath)
seedCfg.Providers[1] = config.ProviderProfile{Name: "codex", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"}
data, err := json.MarshalIndent(seedCfg, "", " ")
if err != nil {
t.Fatalf("encode config: %v", err)
}
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
m.savedProviders = seedCfg.Providers
next, _ := m.reloadProviderManagerRows()
m = next
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
t.Fatalf("save token: %v", err)
}
m = managerKey(t, m, testKey(tea.KeyDown)) // select "codex"
m = managerKey(t, m, testKeyText("d"))
next, cmd := m.handleProviderWizardKey(testKeyText("y"))
if next.providerWizard == nil {
t.Fatalf("manager should stay open")
}
// The stored-key delete and OAuth hint run in a follow-up cmd off the UI
// goroutine; drain it into the model like the runtime would.
next = drainProviderManagerCmds(t, next, cmd)
status := next.providerWizard.manageStatus
if !strings.Contains(status, "zero auth logout chatgpt") {
t.Fatalf("hint must name the stored login (chatgpt), got %q", status)
}
if strings.Contains(status, "logout codex") {
t.Fatalf("hint must not point at a login key that does not exist, got %q", status)
}
}
// TestProviderManagerReadsStoredKeyBesideConfig: the TUI must READ stored keys
// from the same config-adjacent store its write paths use. managerTestModel
// deliberately puts userConfigPath outside XDG_CONFIG_HOME, so a key seeded
// beside the config is invisible to the default-path store — with the old
// default-store reads, the switch gate rejected the provider and the manager
// showed "stored key missing" for a perfectly healthy profile (PR #560, P2).
func TestProviderManagerReadsStoredKeyBesideConfig(t *testing.T) {
m := managerTestModel(t)
// Reshape "backup" into a stored-key profile whose key lives ONLY in the
// store beside userConfigPath.
seedCfg := readManagerConfig(t, m.userConfigPath)
seedCfg.Providers[1].APIKey = ""
seedCfg.Providers[1].APIKeyStored = true
data, err := json.MarshalIndent(seedCfg, "", " ")
if err != nil {
t.Fatalf("encode config: %v", err)
}
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(m.userConfigPath))
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("backup", "sk-beside-config"); err != nil {
t.Fatalf("seed key: %v", err)
}
m.savedProviders = seedCfg.Providers
next, _ := m.reloadProviderManagerRows()
m = next
// The async credential probe must find the key in the config-adjacent store.
cmd := providerManagerCredsCmd(m.providerWizard.manageCredGen, m.providerWizard.manageRows, m.userConfigPath)
msg, ok := cmd().(providerManagerCredsMsg)
if !ok {
t.Fatalf("expected creds msg")
}
if msg.creds["backup"] != "key stored" {
t.Fatalf("creds probe must read the store beside the config, got %q", msg.creds["backup"])
}
// And the switch path must load the key instead of rejecting the provider.
var built config.ProviderProfile
m.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
built = profile
return &fakeProvider{}, nil
}
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
next, _ = m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard != nil {
t.Fatalf("switch must succeed on a config-adjacent stored key, status=%q", next.providerWizard.manageStatus)
}
if built.APIKey != "sk-beside-config" {
t.Fatalf("switch must load the key from the store beside the config, got %q", built.APIKey)
}
}
// drainProviderManagerCmds executes a manager action's follow-up cmds (batch
// or single) and applies any providerManagerCleanupMsg, mirroring what the
// bubbletea runtime does with the returned tea.Cmd.
func drainProviderManagerCmds(t *testing.T, m model, cmd tea.Cmd) model {
t.Helper()
var apply func(c tea.Cmd)
apply = func(c tea.Cmd) {
if c == nil {
return
}
switch msg := c().(type) {
case tea.BatchMsg:
for _, sub := range msg {
apply(sub)
}
case providerManagerCleanupMsg:
m, _ = m.applyProviderManagerCleanup(msg)
}
}
apply(cmd)
return m
}
// TestProviderManagerCaseOnlyRenameUpdatesInPlace: groq → Groq through the
// editor must rename in place — the old EqualFold-skip + case-sensitive upsert
// combination appended a duplicate profile (review finding, empirically shown).
func TestProviderManagerCaseOnlyRenameUpdatesInPlace(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("e"))
m = managerKey(t, m, testKey(tea.KeyEnter)) // edit Name (cursor 0)
m.providerWizard.editBuffer = "OpenGateway"
m = managerKey(t, m, testKey(tea.KeyEnter))
for range 5 {
m = managerKey(t, m, testKey(tea.KeyDown))
}
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter)) // Save
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("save should return to the list, err=%q", next.providerWizard.err)
}
persisted := readManagerConfig(t, next.userConfigPath)
if len(persisted.Providers) != 2 {
t.Fatalf("case-only rename must not duplicate: %d providers", len(persisted.Providers))
}
if persisted.Providers[0].Name != "OpenGateway" || persisted.Providers[0].APIKey != "sk-gw" {
t.Fatalf("in-place update wrong: %+v", persisted.Providers[0])
}
if persisted.ActiveProvider != "OpenGateway" {
t.Fatalf("active must follow, got %q", persisted.ActiveProvider)
}
if len(next.savedProviders) != 2 || next.savedProviders[0].Name != "OpenGateway" {
t.Fatalf("in-memory list must mirror the rename: %+v", next.savedProviders)
}
}
// TestProviderManagerMutationsKeepResolvedProviders: savedProviders is seeded
// from the RESOLVED+FILTERED provider set — a manager delete/edit must mutate
// it surgically, not replace it with the raw user-config list (which would drop
// project-config-contributed providers for the session).
func TestProviderManagerMutationsKeepResolvedProviders(t *testing.T) {
m := managerTestModel(t)
// Simulate a project-config-contributed provider: present in the session's
// resolved list, absent from the user config.json the writers operate on.
projectProvider := config.ProviderProfile{Name: "team-gateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://team.example.com/v1", APIKey: "sk-team", Model: "team-model"}
m.savedProviders = append(m.savedProviders, projectProvider)
next, _ := m.reloadProviderManagerRows()
m = next
// Delete "backup" (a user-config profile): team-gateway must survive.
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
m = managerKey(t, m, testKeyText("d"))
next, _ = m.handleProviderWizardKey(testKeyText("y"))
names := []string{}
for _, profile := range next.savedProviders {
names = append(names, profile.Name)
}
if len(next.savedProviders) != 2 || names[0] != "opengateway" || names[1] != "team-gateway" {
t.Fatalf("project-contributed provider lost by delete: %v", names)
}
// Edit opengateway's model: team-gateway must still survive.
m = next
m.providerWizard.manageCursor = 0
m = managerKey(t, m, testKeyText("e"))
for range 2 {
m = managerKey(t, m, testKey(tea.KeyDown))
}
m = managerKey(t, m, testKey(tea.KeyEnter)) // Model field
m.providerWizard.editBuffer = "mimo-next"
m = managerKey(t, m, testKey(tea.KeyEnter))
for range 3 {
m = managerKey(t, m, testKey(tea.KeyDown))
}
next, _ = m.handleProviderWizardKey(testKey(tea.KeyEnter)) // Save
names = names[:0]
for _, profile := range next.savedProviders {
names = append(names, profile.Name)
}
if len(next.savedProviders) != 2 || names[1] != "team-gateway" {
t.Fatalf("project-contributed provider lost by edit: %v", names)
}
if next.savedProviders[0].Model != "mimo-next" {
t.Fatalf("edit not mirrored in-memory: %+v", next.savedProviders[0])
}
}
// TestProviderManagerEscWalksBackFromDeepAddFlow: Esc anywhere inside a
// manager-launched add flow returns to the provider list ("Esc walks back one
// level"), never destroying the manager context — previously only the first
// step walked back and every deeper step hard-closed the overlay.
func TestProviderManagerEscWalksBackFromDeepAddFlow(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("a")) // add flow, method step
m = managerKey(t, m, testKey(tea.KeyEnter))
if m.providerWizard == nil || m.providerWizard.step == providerWizardStepMethod {
t.Fatalf("expected to advance past the method step, got %+v", m.providerWizard)
}
deepStep := m.providerWizard.step
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard == nil {
t.Fatalf("Esc on step %v must not destroy the manager overlay", deepStep)
}
if m.providerWizard.step != providerWizardStepManage {
t.Fatalf("Esc must return to the provider list, got step %v", m.providerWizard.step)
}
}
// TestProviderManagerActivationUsesStructuredResult: a refusal whose display
// text happens to contain "Switched to" (a provider literally named that) must
// NOT close the manager as a success — the outcome bool, not UI copy, decides.
func TestProviderManagerActivationUsesStructuredResult(t *testing.T) {
m := managerTestModel(t)
seedCfg := readManagerConfig(t, m.userConfigPath)
seedCfg.Providers[1] = config.ProviderProfile{Name: "Switched to prod", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://p.example.com/v1", Model: "m"}
data, err := json.MarshalIndent(seedCfg, "", " ")
if err != nil {
t.Fatalf("encode config: %v", err)
}
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
m.savedProviders = seedCfg.Providers
next, _ := m.reloadProviderManagerRows()
m = next
m = managerKey(t, m, testKey(tea.KeyDown)) // select the credential-less provider
next, _ = m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil {
t.Fatalf("a refused switch must keep the manager open even when the refusal text contains \"Switched to\"")
}
if !strings.Contains(next.providerWizard.manageStatus, "no usable credential") {
t.Fatalf("expected the refusal inline, got %q", next.providerWizard.manageStatus)
}
if next.providerName != "opengateway" {
t.Fatalf("provider must not switch, got %q", next.providerName)
}
}
// TestProviderManagerCredStateFallsThroughStaleMarker: a stale APIKeyStored
// marker with a SET env var must render the env credential (the runtime falls
// back the same way and switches fine), not "stored key missing".
func TestProviderManagerCredStateFallsThroughStaleMarker(t *testing.T) {
t.Setenv("STALE_MARKER_KEY", "sk-env")
profile := config.ProviderProfile{Name: "gw", APIKeyStored: true, APIKeyEnv: "STALE_MARKER_KEY"}
state := providerManagerCredState(profile, false, nil, map[string]bool{})
if state != "env STALE_MARKER_KEY" {
t.Fatalf("stale marker must fall through to the env credential, got %q", state)
}
// Marker with neither store entry nor fallback: the broken state still shows.
t.Setenv("STALE_MARKER_KEY", "")
state = providerManagerCredState(profile, false, nil, map[string]bool{})
if state != "stored key missing" {
t.Fatalf("expected stored key missing with no fallback, got %q", state)
}
}
+2 -150
View File
@@ -50,18 +50,6 @@ func (m model) applyProviderWizardOAuth(msg providerWizardOAuthMsg) (model, tea.
if msg.apiKey != "" {
m.providerWizard.apiKey = msg.apiKey
}
if msg.tokenLogin {
// Persist the profile HERE, on the Update goroutine (see
// persistOAuthLoginProvider for the threading contract), and only mirror
// it into the in-memory saved list when the write succeeded — a mirror of
// a profile that never reached disk shows a phantom provider that works
// this session and silently vanishes on restart.
if err := persistOAuthLoginProvider(m.userConfigPath, msg.providerID); err != nil {
m.providerWizard.oauthErr = "Signed in, but the provider profile could not be saved: " + redaction.ErrorMessage(err, redaction.Options{})
return m, nil
}
m.savedProviders = appendOAuthLoginProfile(m.savedProviders, msg.providerID)
}
// OAuth succeeded (key minted, or a refreshable token stored for the runtime
// resolver). Skip the endpoint/credential steps and go straight to model
// selection.
@@ -153,48 +141,6 @@ func runProviderChatGPTLogin() error {
return store.Save(oauth.ProviderKey("chatgpt"), token)
}
// appendOAuthLoginProfile mirrors the profile persistOAuthLoginProvider wrote to
// config into an in-memory saved-provider list, skipping when a profile already
// serves the catalog entry (by name or catalog id).
func appendOAuthLoginProfile(saved []config.ProviderProfile, providerID string) []config.ProviderProfile {
descriptor, ok := providercatalog.Get(providerID)
if !ok {
return saved
}
for _, profile := range saved {
if strings.EqualFold(strings.TrimSpace(profile.CatalogID), descriptor.ID) ||
strings.EqualFold(strings.TrimSpace(profile.Name), descriptor.ID) {
return saved
}
}
return append(saved, config.ProviderProfile{
Name: descriptor.ID,
ProviderKind: providerWizardProviderKind(descriptor),
CatalogID: descriptor.ID,
BaseURL: descriptor.DefaultBaseURL,
Model: descriptor.DefaultModel,
})
}
// persistOAuthLoginProvider guarantees the stored login is reachable from the
// provider list even when the wizard is abandoned before the model step: the
// token alone is invisible — no profile means no entry in /provider, /model, or
// `zero providers use`, which reads as the login having vanished.
//
// MUST run on the Update goroutine (call it from a message handler, never from
// inside a tea.Cmd): config.json writes are unlocked read-modify-writes, and a
// background login — a device-code poll can complete minutes later — racing a
// UI-thread write would silently clobber it (lost update). The returned error
// lets the caller surface a failed write instead of showing a provider that
// never reached disk.
func persistOAuthLoginProvider(configPath string, catalogID string) error {
if strings.TrimSpace(configPath) == "" {
return nil
}
_, err := config.EnsureCatalogProvider(configPath, catalogID)
return err
}
// buildOAuthPresetEnv layers the process env with the preset opt-in so a
// `zero` launch from a TUI session can use the baked-in ChatGPT client_id
// without requiring the user to export ZERO_OAUTH_ALLOW_PRESETS themselves.
@@ -303,12 +249,6 @@ const (
providerWizardStepCredential
providerWizardStepModel
providerWizardStepDone
// Manager steps (provider_manager.go): the list-first /provider surface.
// They live on the same state struct so every overlay gate, key route, and
// mouse hook that already checks m.providerWizard covers them for free.
providerWizardStepManage
providerWizardStepEditMenu
providerWizardStepEditValue
)
// providerWizardMethodOption is a row in the "How do you want to connect?" step.
@@ -379,20 +319,6 @@ type providerWizardState struct {
oauthDevice bool
deviceUserCode string
deviceVerificationURI string
// Manager state (provider_manager.go): the list-first /provider surface.
manage bool
manageRows []providerManagerRow
manageCursor int
manageDeleting bool
manageStatus string
manageCredGen int
manageActiveName string
// Edit state: field-level editor for one saved profile.
editOriginal config.ProviderProfile
editDraft config.ProviderProfile
editCursor int
editField providerEditField
editBuffer string
}
func (m model) newProviderWizard() *providerWizardState {
@@ -698,26 +624,6 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
if m.providerWizard == nil {
return m, nil
}
// A manager-launched add flow honors the manager's "Esc walks back one
// level" contract on EVERY step (not just the first): Esc returns to the
// provider list instead of destroying the overlay — and with it the user's
// cursor and status — from a step deep in the flow. An in-flight OAuth login
// is abandoned the same way; bumping the attempt id makes its late result
// stale so applyProviderWizardOAuth drops it.
if m.providerWizard.manage && !m.providerWizard.managerStep() && keyIs(msg, tea.KeyEsc) {
wizard := m.providerWizard
if wizard.oauthPending {
wizard.oauthPending = false
wizard.oauthAttemptID++
}
wizard.oauthDevice = false
wizard.deviceUserCode = ""
wizard.deviceVerificationURI = ""
wizard.err = ""
wizard.oauthErr = ""
wizard.step = providerWizardStepManage
return m, nil
}
// While a browser/device OAuth login is in flight, ignore input except Esc,
// which abandons the wizard (the background flow times out and is dropped).
if m.providerWizard.oauthPending {
@@ -726,10 +632,6 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
}
return m, nil
}
// Manager steps (list / edit) own their keys entirely.
if m.providerWizard.managerStep() {
return m.handleProviderManagerKey(msg)
}
// Manage-key step: keep/replace/remove a provider's stored key.
if m.providerWizard.step == providerWizardStepManageKey {
switch {
@@ -897,13 +799,6 @@ func (m model) handleProviderWizardPaste(content string) (model, tea.Cmd) {
m.providerWizard.appendAPIKey([]rune(content))
case providerWizardStepModel:
m.providerWizard.appendModelSearch([]rune(content))
case providerWizardStepEditValue:
for _, r := range content {
if r == '\n' || r == '\r' || r == '\t' {
continue
}
m.providerWizard.editBuffer += string(r)
}
}
return m, nil
}
@@ -1117,9 +1012,6 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
m.providerProfile = profile
m.providerName = profile.Name
m.modelName = profile.Model
// Keep the in-memory saved list in sync so the provider manager and /model
// picker show the new profile without a restart.
m.savedProviders = upsertSavedProviderProfile(m.savedProviders, profile)
// Keep sub-agent child processes on the same provider we just switched to —
// same as the /model and /provider switch paths (command_center.go). Without
// this, a ZERO_PROVIDER exported by an earlier switch stays pointing at the
@@ -1239,23 +1131,13 @@ func (wizard *providerWizardState) render(width int) string {
lines = append(lines, wizard.renderModelStep(innerWidth)...)
case providerWizardStepDone:
lines = append(lines, wizard.renderDoneStep(innerWidth)...)
case providerWizardStepManage:
lines = append(lines, wizard.renderManageStep(innerWidth)...)
case providerWizardStepEditMenu:
lines = append(lines, wizard.renderEditMenuStep(innerWidth)...)
case providerWizardStepEditValue:
lines = append(lines, wizard.renderEditValueStep(innerWidth)...)
}
lines = append(lines,
zeroTheme.line.Render(strings.Repeat("─", innerWidth)),
zeroTheme.faint.Render(wizard.footer()),
)
title := "Provider setup"
if wizard.managerStep() {
title = "Providers"
}
block := styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle())
block := styledBlockFillTitle(overlayWidth, "Provider setup", lines, zeroTheme.lineStrong, lipgloss.NewStyle())
if width > overlayWidth {
return indentBlock(block, (width-overlayWidth)/2)
}
@@ -1263,30 +1145,8 @@ func (wizard *providerWizardState) render(width int) string {
}
func (wizard *providerWizardState) footer() string {
text := wizard.footerText()
// Inside a manager-launched add flow, Esc walks back to the provider list
// (see handleProviderWizardKey) — the hint must match.
if wizard.manage && !wizard.managerStep() {
text = strings.ReplaceAll(text, "Esc close", "Esc back")
}
return text
}
func (wizard *providerWizardState) footerText() string {
canRight := wizard.canAdvanceWithRight()
switch wizard.step {
case providerWizardStepManage:
if wizard.manageDeleting {
return "Enter/y delete Esc/n cancel"
}
if len(wizard.manageRows) == 0 {
return "a add Esc close"
}
return "↑/↓ move Enter activate a add e edit d delete Esc close"
case providerWizardStepEditMenu:
return "↑/↓ move Enter edit field / save Esc back"
case providerWizardStepEditValue:
return "type to edit Enter apply Ctrl+U clear Esc back"
case providerWizardStepMethod:
return "↑/↓ move Enter/→ continue Esc close"
case providerWizardStepProvider:
@@ -1329,7 +1189,7 @@ func providerWizardOverlayWidth(width int, step providerWizardStep) int {
switch step {
case providerWizardStepProvider:
target = providerWizardProviderWidth
case providerWizardStepModel, providerWizardStepManage:
case providerWizardStepModel:
target = providerWizardModelWidth
}
target = minInt(target, width)
@@ -1343,14 +1203,6 @@ func providerWizardStepLine(wizard *providerWizardState) string {
if wizard == nil {
return ""
}
if wizard.managerStep() {
switch wizard.step {
case providerWizardStepManage:
return pluralCount(len(wizard.manageRows), "saved provider")
default:
return "edit provider"
}
}
step := wizard.step
type stepLabel struct {
step providerWizardStep
@@ -1,14 +1,10 @@
package tui
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
)
@@ -388,64 +384,3 @@ func TestProviderWizardDeviceCodeIgnoresStaleAttempt(t *testing.T) {
t.Fatalf("stale device-code result applied device details: %+v", out.providerWizard)
}
}
func TestPersistOAuthLoginProviderWritesKeylessProfileWithoutStealingActive(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{"activeProvider":"opengateway","providers":[{"name":"opengateway","provider_kind":"openai-compatible","baseURL":"https://gateway.example.com/v1","apiKeyStored":true,"model":"some-model"}]}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
persistOAuthLoginProvider(configPath, "chatgpt")
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg config.FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
if cfg.ActiveProvider != "opengateway" {
t.Fatalf("active provider changed to %q", cfg.ActiveProvider)
}
if len(cfg.Providers) != 2 || cfg.Providers[1].CatalogID != "chatgpt" {
t.Fatalf("expected chatgpt profile appended, got %+v", cfg.Providers)
}
if cfg.Providers[1].APIKey != "" || cfg.Providers[1].APIKeyStored {
t.Fatalf("OAuth profile must stay keyless so the token resolver attaches, got %+v", cfg.Providers[1])
}
// Blank path (no user config, e.g. tests/ephemeral runs) must be a no-op.
persistOAuthLoginProvider("", "chatgpt")
}
func TestAppendOAuthLoginProfileAddsOnceAndRespectsRenames(t *testing.T) {
saved := []config.ProviderProfile{{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible}}
saved = appendOAuthLoginProfile(saved, "chatgpt")
if len(saved) != 2 || saved[1].Name != "chatgpt" || saved[1].CatalogID != "chatgpt" {
t.Fatalf("expected chatgpt appended, got %+v", saved)
}
if saved[1].Model == "" || saved[1].BaseURL == "" {
t.Fatalf("appended profile must carry catalog defaults, got %+v", saved[1])
}
// Idempotent: a second login must not duplicate.
saved = appendOAuthLoginProfile(saved, "chatgpt")
if len(saved) != 2 {
t.Fatalf("duplicate appended: %+v", saved)
}
// A renamed profile serving the same catalog entry blocks the append too.
renamed := []config.ProviderProfile{{Name: "codex", CatalogID: "chatgpt"}}
renamed = appendOAuthLoginProfile(renamed, "chatgpt")
if len(renamed) != 1 {
t.Fatalf("renamed profile not respected: %+v", renamed)
}
// Unknown catalog id: no-op.
if got := appendOAuthLoginProfile(nil, "no-such-provider"); got != nil {
t.Fatalf("unknown provider must not append, got %+v", got)
}
}
+3 -21
View File
@@ -92,14 +92,10 @@ func (m model) emptyStateLines(width int) []string {
// emptyStateExamples seeds the first prompt with a few representative asks.
const emptyStateExamples = `Try "explain this codebase" · "fix the failing test" · "add a --json flag"`
// emptyStateOrientation renders a faint "version · cwd · branch · model" line
// for the home screen, omitting any piece that's unknown. Empty when nothing
// is known.
// emptyStateOrientation renders a faint "cwd · branch · model" line for the home
// screen, omitting any piece that's unknown. Empty when nothing is known.
func (m model) emptyStateOrientation() string {
parts := make([]string, 0, 4)
if version := displayVersion(m.appVersion); version != "" {
parts = append(parts, version)
}
parts := make([]string, 0, 3)
if cwd := strings.TrimSpace(m.cwd); cwd != "" {
parts = append(parts, shortenPath(cwd))
}
@@ -115,20 +111,6 @@ func (m model) emptyStateOrientation() string {
return zeroTheme.faint.Render(strings.Join(parts, " · "))
}
// displayVersion formats the CLI build version for display: numeric releases
// gain a "v" prefix (0.2.0 → v0.2.0), anything else (dev, git SHAs) passes
// through unchanged. Empty stays empty so unset builds show nothing.
func displayVersion(version string) string {
version = strings.TrimSpace(version)
if version == "" {
return ""
}
if version[0] >= '0' && version[0] <= '9' {
return "v" + version
}
return version
}
func zeroWordmarkLines() []string {
lines := make([]string, 0, minInt(len(zeroWordmarkPrefixLines), len(zeroWordmarkOLines)))
for index := 0; index < len(zeroWordmarkPrefixLines) && index < len(zeroWordmarkOLines); index++ {
-23
View File
@@ -21,29 +21,6 @@ func TestEmptyStateShowsBrandAndTaglineOnly(t *testing.T) {
assertNotContains(t, view, "fix the failing test in internal/tools")
}
func TestEmptyStateShowsVersion(t *testing.T) {
m := newModel(context.Background(), Options{Version: "0.2.0"})
m.width, m.height = 100, 30
view := plainRender(t, m.View())
assertContains(t, view, "v0.2.0")
}
func TestDisplayVersion(t *testing.T) {
cases := []struct{ in, want string }{
{"0.2.0", "v0.2.0"},
{"v0.2.0", "v0.2.0"},
{"dev", "dev"},
{" ", ""},
{"", ""},
}
for _, tc := range cases {
if got := displayVersion(tc.in); got != tc.want {
t.Fatalf("displayVersion(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestEmptyStateDisappearsAfterFirstRow(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
-163
View File
@@ -1,163 +0,0 @@
package tui
import (
"errors"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/dictation"
)
// sttKeyPromptState is the inline API-key prompt shown when a cloud dictation
// provider is chosen. On submit the key is saved to the credential store and the
// model selection is finalized. When optional is set a key is already on file, so
// Esc/empty-Enter keeps it and still applies the model (this is the proactive path
// for replacing a key that turned out to be invalid — you don't have to trigger a
// failed recording first).
type sttKeyPromptState struct {
provider string // "groq" / "openai"
label string // "Groq" / "OpenAI"
modelValue string // the picker value to finalize, e.g. "groq:whisper-large-v3-turbo"
input string
optional bool // a key already resolves; entering a new one just replaces it
}
// maybeOfferKeyOnAuthError, when err is a cloud AuthError, reopens the API-key
// prompt for the currently-selected cloud provider (preserving its model) so the
// user can fix a missing/invalid key and retry. Returns handled=false for any
// other error (or when no key can be saved / the provider is local).
func (m model) maybeOfferKeyOnAuthError(err error) (model, bool) {
var authErr *dictation.AuthError
if !errors.As(err, &authErr) || m.dictation.saveKey == nil {
return m, false
}
provider := string(m.dictation.cfg.Provider)
if provider == "" || m.dictation.cfg.STTProvider() == config.STTProviderLocal {
return m, false
}
value := provider + sttValueSep + m.dictation.cfg.Model
m = m.appendSystemNotice(dictationErrorText(err) + " — paste your " + titleCase(provider) + " API key below to fix it and retry.")
// The existing key is invalid, so a new one is required here (Esc cancels).
return m.openSTTKeyPrompt(provider, value, false), true
}
// openSTTKeyPrompt begins collecting the API key for a cloud provider. optional
// marks that a key already resolves, so the prompt can be dismissed to keep it.
func (m model) openSTTKeyPrompt(provider, modelValue string, optional bool) model {
m.sttKeyPrompt = &sttKeyPromptState{
provider: provider,
label: titleCase(provider),
modelValue: modelValue,
optional: optional,
}
m.clearSuggestions()
return m
}
// handleSTTKeyPromptKey captures typing for the API-key prompt. It fully owns
// the keystroke while the prompt is open (masked input, Enter saves, Esc cancels).
func (m model) handleSTTKeyPromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
p := m.sttKeyPrompt
switch {
case keyIs(msg, tea.KeyEsc), keyCtrl(msg, 'c'):
if p.optional {
// A key is already on file — keep it and apply the model selection.
return m.finalizeSTTModelFromKey(p, "Kept your saved "+p.label+" API key. ")
}
m.sttKeyPrompt = nil
return m.appendSystemNotice("Cancelled — no " + p.label + " API key saved."), nil
case keyIs(msg, tea.KeyEnter):
return m.submitSTTKey()
case keyBackspace(msg):
if r := []rune(p.input); len(r) > 0 {
p.input = string(r[:len(r)-1])
}
return m, nil
case keyCtrl(msg, 'u'):
p.input = ""
return m, nil
default:
if t := keyText(msg); t != "" && !keyAlt(msg) && !keyHasMod(msg, tea.ModCtrl) {
p.input += t
}
return m, nil
}
}
// submitSTTKey saves the entered key and finalizes the pending model selection.
func (m model) submitSTTKey() (tea.Model, tea.Cmd) {
p := m.sttKeyPrompt
key := strings.TrimSpace(p.input)
if key == "" {
if p.optional {
// Nothing typed but a key is already on file — keep it and apply the model.
return m.finalizeSTTModelFromKey(p, "Kept your saved "+p.label+" API key. ")
}
m.sttKeyPrompt = nil
return m.appendSystemNotice("No key entered — nothing saved."), nil
}
if m.dictation.saveKey != nil {
if err := m.dictation.saveKey(p.provider, key); err != nil {
m.sttKeyPrompt = nil
return m.appendSystemNotice("Couldn't save the " + p.label + " API key: " + err.Error()), nil
}
}
return m.finalizeSTTModelFromKey(p, "Saved your "+p.label+" API key. ")
}
// finalizeSTTModelFromKey closes the prompt and commits the pending cloud model
// selection, prefixing the confirmation with a key-status note.
func (m model) finalizeSTTModelFromKey(p *sttKeyPromptState, prefix string) (tea.Model, tea.Cmd) {
m.sttKeyPrompt = nil
provider, modelID, _ := strings.Cut(p.modelValue, sttValueSep)
kind := config.STTProviderKind(provider)
m.dictation.cfg.Provider = kind
m.dictation.cfg.Model = modelID
if _, err := config.SetSTTModel(m.userConfigPath, kind, modelID); err != nil {
return m.appendSystemNotice(prefix + "But couldn't save the model: " + err.Error()), nil
}
label := p.label
if modelID != "" {
label += " " + modelID
}
return m.appendSystemNotice(prefix + "Dictation model set to " + label + ". Run /voice, then hold Space to dictate."), nil
}
// sttKeyPromptOverlay renders the API-key prompt as a centered modal, matching
// the picker's box styling. The key is masked as it is typed.
func (m model) sttKeyPromptOverlay(width int) string {
if m.sttKeyPrompt == nil {
return ""
}
p := m.sttKeyPrompt
overlayWidth := minInt(width, pickerOverlayMaxWidth)
if overlayWidth < pickerOverlayMinWidth {
overlayWidth = width
}
// Input line mirrors the provider wizard's credential step (renderCredentialStep):
// an "api key > " prompt, cursor-before-placeholder when empty, masked key with a
// trailing cursor once typing starts — so both key prompts read the same.
value := zeroTheme.accent.Render("▌") + zeroTheme.faint.Render("paste key here")
if p.input != "" {
value = zeroTheme.ink.Render(maskedProviderWizardKey(p.input)) + zeroTheme.accent.Render("▌")
}
input := zeroTheme.userPrompt.Render("api key > ") + value
intro := p.label + " isn't set up yet. Paste or type your " + p.label + " API key:"
footer := "⏎ save · Esc cancel · stored in Zero's credential store"
if p.optional {
// A key already resolves — this pass is for replacing an invalid/old one.
intro = p.label + " already has an API key. Paste a new one to replace it, or press Esc to keep it:"
footer = "⏎ replace · Esc keep saved key · stored in Zero's credential store"
}
lines := []string{
zeroTheme.ink.Render(intro),
"",
input,
"",
zeroTheme.faint.Render(footer),
}
return centerRenderedBlock(styledBlockFillTitle(overlayWidth, p.label+" API key", lines, zeroTheme.lineStrong, lipgloss.NewStyle()), width)
}
-152
View File
@@ -1,152 +0,0 @@
package tui
import (
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
)
func TestCloudKeyPromptOpensWhenNoKey(t *testing.T) {
m := model{}
m.dictation.keyAvailable = func(string) bool { return false } // not authenticated
m.dictation.saveKey = func(string, string) error { return nil }
next, text := m.handleSTTModelSelection("groq:whisper-large-v3-turbo")
if next.sttKeyPrompt == nil {
t.Fatal("selecting a keyless cloud provider should open the API-key prompt")
}
if next.sttKeyPrompt.provider != "groq" || next.sttKeyPrompt.label != "Groq" {
t.Errorf("prompt = %+v", next.sttKeyPrompt)
}
if text != "" {
t.Errorf("no notice should be appended when the prompt opens, got %q", text)
}
}
func TestCloudKeyPromptCancelKeepsPreviousProvider(t *testing.T) {
// Regression: selecting a keyless cloud provider must NOT switch the active
// provider until the key is actually saved — otherwise cancelling the prompt
// strands the session on a half-chosen provider carrying the old model.
m := model{}
m.dictation.cfg = config.STTConfig{Provider: config.STTProviderGroq, Model: "whisper-large-v3-turbo"}
m.dictation.keyAvailable = func(string) bool { return false } // OpenAI has no key
m.dictation.saveKey = func(string, string) error { return nil }
next, _ := m.handleSTTModelSelection("openai:whisper-1")
if next.sttKeyPrompt == nil {
t.Fatal("selecting a keyless cloud provider should open the key prompt")
}
if next.dictation.cfg.Provider != config.STTProviderGroq || next.dictation.cfg.Model != "whisper-large-v3-turbo" {
t.Fatalf("active provider/model changed before commit: %q/%q",
next.dictation.cfg.Provider, next.dictation.cfg.Model)
}
esc, _ := next.handleSTTKeyPromptKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyEsc}))
got := esc.(model)
if got.sttKeyPrompt != nil {
t.Error("Esc should close the prompt")
}
if got.dictation.cfg.Provider != config.STTProviderGroq || got.dictation.cfg.Model != "whisper-large-v3-turbo" {
t.Errorf("after cancel, provider/model = %q/%q; want the working Groq selection preserved",
got.dictation.cfg.Provider, got.dictation.cfg.Model)
}
}
func TestCloudKeyPromptOptionalWhenAuthenticated(t *testing.T) {
// Selecting a provider that already has a key still opens the prompt — but as an
// OPTIONAL pass, so an invalid/old key can be replaced proactively (without first
// suffering a failed recording). Esc keeps the saved key and applies the model.
dir := t.TempDir()
m := model{userConfigPath: dir + "/config.json"}
m.dictation.keyAvailable = func(string) bool { return true }
m.dictation.saveKey = func(string, string) error { return nil }
next, _ := m.handleSTTModelSelection("groq:whisper-large-v3-turbo")
if next.sttKeyPrompt == nil || !next.sttKeyPrompt.optional {
t.Fatalf("expected an optional key prompt, got %+v", next.sttKeyPrompt)
}
esc, _ := next.handleSTTKeyPromptKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyEsc}))
got := esc.(model)
if got.sttKeyPrompt != nil {
t.Error("Esc should close the optional prompt")
}
if got.dictation.cfg.Provider != config.STTProviderGroq || got.dictation.cfg.Model != "whisper-large-v3-turbo" {
t.Errorf("model not applied on keep: %q/%q", got.dictation.cfg.Provider, got.dictation.cfg.Model)
}
if !transcriptHasText(got, "Kept your saved Groq API key") {
t.Error("expected a kept-key notice")
}
}
func TestCloudKeyPromptOptionalReplaceSavesNewKey(t *testing.T) {
// Typing a new key in the optional prompt replaces the saved one and applies the model.
dir := t.TempDir()
var savedKey string
m := model{userConfigPath: dir + "/config.json"}
m.dictation.keyAvailable = func(string) bool { return true }
m.dictation.saveKey = func(_, k string) error { savedKey = k; return nil }
next, _ := m.handleSTTModelSelection("groq:whisper-large-v3-turbo")
m2 := next
for _, r := range []string{"g", "s", "k", "-", "n", "e", "w"} {
u, _ := m2.handleSTTKeyPromptKey(tea.KeyPressMsg(tea.Key{Text: r}))
m2 = u.(model)
}
done, _ := m2.handleSTTKeyPromptKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter}))
got := done.(model)
if savedKey != "gsk-new" {
t.Errorf("replacement key = %q, want gsk-new", savedKey)
}
if got.dictation.cfg.Model != "whisper-large-v3-turbo" {
t.Errorf("model not applied after replace: %q", got.dictation.cfg.Model)
}
if !transcriptHasText(got, "Saved your Groq API key") {
t.Error("expected a saved-key notice")
}
}
func TestCloudKeySavedAndModelApplied(t *testing.T) {
dir := t.TempDir()
var savedProvider, savedKey string
m := model{userConfigPath: dir + "/config.json"}
m.dictation.saveKey = func(p, k string) error { savedProvider, savedKey = p, k; return nil }
m.sttKeyPrompt = &sttKeyPromptState{provider: "groq", label: "Groq", modelValue: "groq:whisper-large-v3-turbo", input: "gsk-secret"}
next, _ := m.submitSTTKey()
got := next.(model)
if savedProvider != "groq" || savedKey != "gsk-secret" {
t.Errorf("saved key = %q/%q", savedProvider, savedKey)
}
if got.sttKeyPrompt != nil {
t.Error("prompt should close after submit")
}
if got.dictation.cfg.Model != "whisper-large-v3-turbo" {
t.Errorf("model not applied: %q", got.dictation.cfg.Model)
}
if !transcriptHasText(got, "Saved your Groq API key") {
t.Error("expected a saved-key notice")
}
}
func TestCloudKeyPromptTypingBackspaceAndCancel(t *testing.T) {
m := model{sttKeyPrompt: &sttKeyPromptState{provider: "groq", label: "Groq"}}
for _, r := range []string{"a", "b", "c"} {
next, _ := m.handleSTTKeyPromptKey(tea.KeyPressMsg(tea.Key{Text: r}))
m = next.(model)
}
if m.sttKeyPrompt.input != "abc" {
t.Fatalf("input = %q, want abc", m.sttKeyPrompt.input)
}
back, _ := m.handleSTTKeyPromptKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace}))
m = back.(model)
if m.sttKeyPrompt.input != "ab" {
t.Errorf("after backspace: %q", m.sttKeyPrompt.input)
}
esc, _ := m.handleSTTKeyPromptKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyEsc}))
if esc.(model).sttKeyPrompt != nil {
t.Error("Esc should cancel the prompt")
}
}
-402
View File
@@ -1,402 +0,0 @@
package tui
import (
"context"
"fmt"
"net/http"
"path/filepath"
"sort"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/dictation"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
)
// sttModelValue encodes a picker selection as "provider:model" (model may be
// empty for the local engine, whose model is configured via stt.localModelPath).
const sttValueSep = ":"
// baselineSTTModels are the known transcription models per cloud provider (§7/§8).
// Used as a stable floor so the picker always offers sensible options even when
// the live catalog has not surfaced them (models.dev lists Groq's Whisper models
// without cost fields; OpenAI's transcription models are absent there entirely —
// see §8). Any additional IsSTTModel entries the catalog does surface are merged
// in on top.
var baselineSTTModels = map[string][]string{
string(config.STTProviderGroq): {"whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"},
string(config.STTProviderOpenAI): {"whisper-1", "gpt-4o-transcribe", "gpt-4o-mini-transcribe"},
}
// newSTTModelPicker builds the /stt-model overlay: a local-engine row plus the
// transcription models offered by Groq and OpenAI.
func (m model) newSTTModelPicker() *commandPicker {
current := m.dictation.cfg
var items []pickerItem
// Local engine row — its model is a filesystem path set separately.
localMeta := "sherpa-onnx offline engine"
if current.STTProvider() == config.STTProviderLocal {
localMeta = "current · " + localMeta
}
items = append(items, pickerItem{
Group: "Local",
Label: "Local (sherpa-onnx)",
Value: string(config.STTProviderLocal) + sttValueSep,
Meta: localMeta,
// No locality dot: the Local/Groq/OpenAI section headers already say which is
// which, so the marker was redundant.
})
for _, prov := range []string{string(config.STTProviderGroq), string(config.STTProviderOpenAI)} {
group := titleCase(prov)
for _, id := range sttModelsForProvider(prov) {
meta := ""
if current.STTProvider() == config.STTProviderKind(prov) && current.Model == id {
meta = "current"
}
items = append(items, pickerItem{
Group: group,
Label: id,
Value: prov + sttValueSep + id,
Meta: meta,
})
}
}
if len(items) == 0 {
return nil
}
return &commandPicker{
kind: pickerSTTModel,
title: "Choose a dictation (speech-to-text) model",
items: items,
allItems: append([]pickerItem{}, items...),
}
}
// sttModelsForProvider returns the transcription model ids for a cloud provider:
// the stable baseline, plus any additional IsSTTModel entries the live catalog
// surfaces, de-duplicated and baseline-first.
func sttModelsForProvider(provider string) []string {
seen := map[string]bool{}
var out []string
for _, id := range baselineSTTModels[provider] {
if !seen[id] {
seen[id] = true
out = append(out, id)
}
}
if descriptor, ok := providercatalog.Get(provider); ok {
for _, model := range providermodelcatalog.Models(descriptor) {
id := strings.TrimSpace(model.ID)
if id == "" || seen[id] || !providermodelcatalog.IsSTTModel(model) {
continue
}
seen[id] = true
out = append(out, id)
}
}
return out
}
func (m model) openSTTModelPicker() (model, tea.Cmd) {
picker := m.newSTTModelPicker()
if picker == nil {
return m, nil
}
m.picker = picker
m.clearSuggestions()
return m, nil
}
// maybeOpenSTTDownloadPicker opens the model-download chooser when the user
// picked the local engine, no model is configured yet, and this platform can
// auto-download. It persists the local-provider choice first so a later manual
// setup is still honored. Returns (model, true) when it opened the picker.
func (m model) maybeOpenSTTDownloadPicker(value string) (model, tea.Cmd, bool) {
provider, _, _ := strings.Cut(value, sttValueSep)
if config.STTProviderKind(provider) != config.STTProviderLocal {
return m, nil, false
}
if m.dictation.downloadRoot == "" || !dictation.AutoDownloadSupported() {
return m, nil, false // manual/cloud only on this platform
}
// Always open the chooser when Local is picked — so you can select a model the
// first time AND switch models later (even with one already configured).
// Don't persist Provider=local here — applyEngineComponents (called on both
// the download-completion and the already-installed fast path) is the sole
// place that commits the provider/model. Persisting now would silently
// strand the user on an unconfigured local provider if they press Esc
// out of the picker.
m.clearSuggestions()
if len(m.dictation.browseVariants) > 0 {
// Full list already fetched this session — show it straight away.
m.picker = newSTTDownloadPickerFrom(m.dictation.browseVariants, false, m.dictation.downloadRoot, m.engineDownloaded(), m.dictation.cfg.LocalModelPath)
return m, nil, true
}
// Show a single loading state while the full list is fetched — no curated-then-
// swap flicker. handleSTTModelsFetched fills it in (or falls back to the curated
// shortlist if the fetch fails, so the picker still works offline).
m.dictation.browseLoading = true
m.picker = newSTTDownloadLoadingPicker()
return m, fetchSTTModelsCmd(), true
}
// newSTTDownloadLoadingPicker is the placeholder chooser shown while the full
// model list is being fetched — an empty, loading-marked picker so the overlay
// renders a "fetching…" line and its footer, matching the /model loading UX.
func newSTTDownloadLoadingPicker() *commandPicker {
return &commandPicker{
kind: pickerSTTDownload,
title: "Download a local dictation model — loading…",
loading: true,
}
}
// sttModelsFetchedMsg carries the full model list fetched from the release.
type sttModelsFetchedMsg struct {
variants []dictation.ModelVariant
err error
}
// fetchSTTModelsCmd lists every downloadable transcription model in the release,
// off the UI goroutine (a GitHub API round-trip).
func fetchSTTModelsCmd() tea.Cmd {
return func() tea.Msg {
// A stalled GitHub response would otherwise hang the picker loading
// state forever (http.DefaultClient has no timeout). 30s is enough
// headroom for the release + assets round-trips on a slow link.
client := &http.Client{Timeout: 30 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
variants, err := dictation.ListModels(ctx, client, "")
return sttModelsFetchedMsg{variants: variants, err: err}
}
}
// handleSTTModelsFetched fills the loading download picker with the fetched full
// model list (preserving any filter the user typed while it loaded). If the fetch
// failed and nothing is cached, it falls back to the curated shortlist so the
// picker still works offline.
func (m model) handleSTTModelsFetched(msg sttModelsFetchedMsg) model {
m.dictation.browseLoading = false
if msg.err == nil && len(msg.variants) > 0 {
m.dictation.browseVariants = msg.variants
}
if m.picker == nil || m.picker.kind != pickerSTTDownload {
return m // the chooser was already closed
}
variants := m.dictation.browseVariants
if len(variants) == 0 {
variants = dictation.ModelVariants() // fetch failed → offline curated fallback
}
// Preserve the query AND the highlighted row across the fill-in.
query := m.picker.query
selectedValue := ""
if item, ok := m.picker.current(); ok {
selectedValue = item.Value
}
m.picker = newSTTDownloadPickerFrom(variants, false, m.dictation.downloadRoot, m.engineDownloaded(), m.dictation.cfg.LocalModelPath)
m.picker.query = query
m.picker.applyQuery()
m.selectPickerValue(selectedValue)
return m
}
// handleSTTModelSelection persists the chosen dictation provider+model and
// updates the live controller so the next recording uses it (§11a — a switch
// takes effect for the next recording, never mid-utterance).
func (m model) handleSTTModelSelection(value string) (model, string) {
provider, modelID, _ := strings.Cut(value, sttValueSep)
kind := config.STTProviderKind(provider)
if kind == config.STTProviderLocal {
m.dictation.cfg.Provider = kind
// Leave localModelPath as-is; the local model is a path, not chosen here.
if _, err := config.SetSTTProvider(m.userConfigPath, kind); err != nil {
return m, "Could not save dictation provider: " + err.Error()
}
hint := "Dictation set to the local sherpa-onnx engine."
if strings.TrimSpace(m.dictation.cfg.LocalModelPath) == "" {
// Auto-download platforms never reach here (the download chooser opens
// instead); this is the manual-setup path (e.g. Termux).
hint += " Set stt.localModelPath to a model directory (see docs/dictation.md)."
} else {
hint += " Run /voice, then hold Space to dictate."
}
return m, hint
}
// Cloud provider. Route through the key prompt so a missing OR invalid key can be
// set/replaced here, proactively — not only after a failed dictation (a saved key
// can be present but invalid, and we can't tell until it's used). When a key
// already resolves the prompt is optional: Esc keeps it and just applies the model.
// Crucially, the active provider/model are only switched on commit, so cancelling
// leaves a previously-working provider (e.g. Groq) untouched.
if m.dictation.saveKey == nil {
// No credential store wired on this platform — just apply the model.
m.dictation.cfg.Provider = kind
m.dictation.cfg.Model = modelID
if _, err := config.SetSTTModel(m.userConfigPath, kind, modelID); err != nil {
return m, "Could not save dictation model: " + err.Error()
}
return m, "Dictation model set to " + titleCase(provider) + " " + modelID + "."
}
hasKey := m.dictation.keyAvailable != nil && m.dictation.keyAvailable(provider)
return m.openSTTKeyPrompt(provider, value, hasKey), ""
}
// newSTTDownloadPicker builds the model-download chooser, seeded with the
// curated shortlist. The full model list from the release is fetched
// asynchronously and merged in (see fetchSTTModelsCmd / handleSTTModelsFetched).
func (m model) newSTTDownloadPicker() *commandPicker {
return newSTTDownloadPickerFrom(dictation.ModelVariants(), true, m.dictation.downloadRoot, m.engineDownloaded(), m.dictation.cfg.LocalModelPath)
}
// engineDownloaded reports whether the shared engine is already on disk.
func (m model) engineDownloaded() bool {
return dictation.EngineDownloaded(m.dictation.downloadRoot, m.dictation.cfg.EngineVersion)
}
// newSTTDownloadPickerFrom builds the chooser from any variant list as a flat,
// un-grouped list — the Live/Batch pipeline is shown as a right-hand tag on each
// row (leading the meta) rather than as section headers, which read as cluttered
// and could even repeat a header when installed models sorted to the top. loading
// appends a hint that more models are still being fetched. downloadRoot lets it
// mark already-installed models; engineHave drops the shared engine's size from
// the per-model total once it is on disk.
func newSTTDownloadPickerFrom(variants []dictation.ModelVariant, loading bool, downloadRoot string, engineHave bool, currentPath string) *commandPicker {
isCurrent := func(v dictation.ModelVariant) bool {
// Substring match false-positives when one variant's DirName is a
// substring of another's ("tiny" inside "tiny-en"). Compare the
// last path segment only.
return currentPath != "" && v.DirName != "" && filepath.Base(currentPath) == v.DirName
}
sorted := append([]dictation.ModelVariant(nil), variants...)
installed := func(v dictation.ModelVariant) bool {
return dictation.ModelDownloaded(downloadRoot, v.DirName)
}
sort.SliceStable(sorted, func(i, j int) bool {
a, b := sorted[i], sorted[j]
// Rank by quality, pipeline-agnostic: the curated "good" picks — live AND batch
// alike — rise together to the top (the Live/Batch tag on each row tells them
// apart, so there's no separate live section). Then already-installed, then
// smallest/fastest first.
if a.Recommended != b.Recommended {
return a.Recommended
}
if ia, ib := installed(a), installed(b); ia != ib {
return ia
}
return a.Bytes < b.Bytes
})
var items []pickerItem
for _, v := range sorted {
label := v.Label
if v.Recommended {
label = "★ " + label
}
totalMB := v.Bytes >> 20
if !engineHave {
totalMB += engineDownloadBytes >> 20 // engine is downloaded once, shared by all models
}
// Meta reads: pipeline tag · [description] · size · [state].
tag := "Batch"
if v.Streaming {
tag = "Live"
}
parts := []string{tag}
if v.Description != "" {
parts = append(parts, v.Description)
}
parts = append(parts, fmt.Sprintf("%d MB", totalMB))
switch {
case isCurrent(v):
parts = append(parts, "● current")
case installed(v):
parts = append(parts, "✓ downloaded") // selecting re-applies it, no re-download
}
items = append(items, pickerItem{
Label: label,
Value: v.ID,
Meta: strings.Join(parts, " · "),
// No Local dot: every row here is a local model, so the marker only added
// noise. The Live/Batch tag in the meta is the meaningful distinction.
})
}
// The search line and footer already convey "type to filter" and "Esc", so the
// title stays just the name.
title := "Download a local dictation model"
if loading {
title = "Download a local dictation model — loading full list…"
}
return &commandPicker{
kind: pickerSTTDownload,
title: title,
items: items,
allItems: append([]pickerItem{}, items...),
}
}
// engineDownloadBytes is the approximate size of the shared sherpa-onnx engine
// bundle, added to each model's size for the total-download display.
const engineDownloadBytes = 23 << 20
// handleSTTDownloadSelection starts downloading the chosen model variant (engine
// + that model). It resolves the variant from the picker's current items so any
// browse-listed model (not just the curated shortlist) is downloadable.
func (m model) handleSTTDownloadSelection(value string) (model, tea.Cmd) {
for _, v := range m.sttDownloadVariants() {
if v.ID == value {
// Already on disk (engine + this model): apply instantly, no download UI.
if m.engineDownloaded() && dictation.ModelDownloaded(m.dictation.downloadRoot, v.DirName) {
return m.applyInstalledModel(v)
}
return m.startVariantDownload(v)
}
}
return m, nil
}
// applyInstalledModel resolves the paths of an already-downloaded model (a fast,
// network-free EnsureLocalEngine) and applies them, with no download animation.
func (m model) applyInstalledModel(v dictation.ModelVariant) (model, tea.Cmd) {
comp, err := dictation.EnsureLocalEngine(context.Background(), dictation.DownloadOptions{
DestRoot: m.dictation.downloadRoot,
EngineVersion: m.dictation.cfg.EngineVersion,
ModelAssetName: v.AssetName,
ModelPinnedDigest: v.Digest,
ModelDirName: v.DirName,
ModelLabel: v.Label,
})
if err != nil {
return m.startVariantDownload(v) // fell out of cache somehow — download it
}
m, aerr := m.applyEngineComponents(comp, v.Streaming)
if aerr != nil {
return m.appendSystemNotice("Couldn't save the config: " + aerr.Error()), nil
}
return m.appendSystemNotice(v.Label + " is already downloaded — dictation ready. Run /voice, then hold Space to dictate."), nil
}
// sttDownloadVariants returns the variants currently offered — the full fetched
// list when available, else the curated shortlist.
func (m model) sttDownloadVariants() []dictation.ModelVariant {
if len(m.dictation.browseVariants) > 0 {
return m.dictation.browseVariants
}
return dictation.ModelVariants()
}
// titleCase capitalizes the first letter of a lowercase provider id for display
// ("groq" → "Groq"), avoiding the deprecated strings.Title.
func titleCase(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
+7 -39
View File
@@ -204,9 +204,6 @@ func (m model) statusLine(width int) string {
if m.cancelConfirmActive {
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(escCancelConfirmText), width)
}
if dictation := m.dictationStatusChip(); dictation != "" {
return fitStyledLine(prefix+dictation, width)
}
return fitStyledLine(left, width)
}
@@ -218,20 +215,8 @@ func (m model) statusLine(width int) string {
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText)
} else if m.cancelConfirmActive {
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(escCancelConfirmText)
} else if m.dictation.downloading && m.dictation.downloadStatus != "" {
// A model download in progress takes over the left chip with a live percentage.
left = prefix + zeroTheme.accent.Render("⬇ ") + zeroTheme.muted.Render(m.dictation.downloadStatus)
} else if dictation := m.dictationStatusChip(); dictation != "" && m.dictation.active() {
// An active recording/transcription takes over the left chip — it is the
// most time-sensitive thing on screen (the mic is live).
left = prefix + dictation
} else {
if voice := m.voiceModeIndicator(); voice != "" {
left += zeroTheme.muted.Render(" · ") + voice
}
if summary := m.backgroundTerminalSummary(); summary != "" {
left += separator + zeroTheme.muted.Render(summary)
}
} else if summary := m.backgroundTerminalSummary(); summary != "" {
left += separator + zeroTheme.muted.Render(summary)
}
// Active loops surface a persistent "↻ N loops · next 3:05pm" segment so a
// running loop is always visible (hidden during an exit/cancel confirm above).
@@ -742,12 +727,10 @@ func (m model) pickerOverlay(width int) string {
visible = m.picker.items[start : start+maxVisible]
}
lines := make([]string, 0, len(visible)+7)
lines := make([]string, 0, len(visible)+5)
title := strings.TrimSpace(m.picker.title)
// A visible "search > …" line so typing to filter shows what you've typed,
// matching the /model picker. Followed by a separator, then the rows.
lines = append(lines, renderPickerSearchLine(m.picker.query, "type to filter…", innerWidth))
lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", innerWidth)))
hint := "↑/↓ · ⏎ · esc"
lines = append(lines, zeroTheme.faint.Render(hint))
lastGroup := ""
for index, item := range visible {
absoluteIndex := start + index
@@ -783,16 +766,8 @@ func (m model) pickerOverlay(width int) string {
lines = append(lines, fitStyledLine(line, innerWidth))
}
if len(visible) == 0 {
if m.picker.loading {
lines = append(lines, zeroTheme.faint.Render("Fetching available models…"))
} else {
lines = append(lines, zeroTheme.faint.Render(" no matching items"))
}
lines = append(lines, zeroTheme.faint.Render(" no matching items"))
}
// Hints live in the footer (a separator + faint keys), matching the /model
// picker and the other bordered boxes.
lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", innerWidth)))
lines = append(lines, zeroTheme.faint.Render("↑/↓ move Enter select Esc close"))
return centerRenderedBlock(styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle()), width)
}
@@ -909,18 +884,11 @@ func modelPickerOverlayWidth(terminalWidth int, picker *commandPicker) int {
}
func renderModelPickerSearchLine(query string, width int) string {
return renderPickerSearchLine(query, "model name...", width)
}
// renderPickerSearchLine renders the "search > <query>▌" input line shared by the
// popup pickers, so what you type while filtering is always visible. placeholder
// is the faint hint shown when the query is empty.
func renderPickerSearchLine(query, placeholder string, width int) string {
query = strings.TrimSpace(query)
prompt := zeroTheme.userPrompt.Render("search > ")
cursor := zeroTheme.accent.Render("▌")
if query == "" {
return fitStyledLine(prompt+cursor+zeroTheme.faint.Render(placeholder), width)
return fitStyledLine(prompt+cursor+zeroTheme.faint.Render("model name..."), width)
}
return fitStyledLine(prompt+zeroTheme.ink.Render(query)+cursor, width)
}
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more