Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 41 additions & 8 deletions claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func (claudeProvider) Capabilities() ProviderCapabilities {
func (claudeProvider) ModelPicker() ProviderPicker {
return ProviderPicker{
Prompt: "Select Claude model",
Options: []string{"default", "haiku", "sonnet", "sonnet[1m]", "opus", "opus[1m]", ollamaModelOption},
Options: []string{"default", "haiku", "sonnet", "sonnet[1m]", "opus", "opus" + fastModelSuffix, "opus[1m]", "opus[1m]" + fastModelSuffix, ollamaModelOption},
AllowCustom: true,
SubConfig: map[string]string{ollamaModelOption: "ollama"},
}
Expand Down Expand Up @@ -67,7 +67,9 @@ func (claudeProvider) BaseSlashCommands() []slashCmd {
const askUserQuestionBlockCommand = `echo 'BLOCKED: the built-in AskUserQuestion tool is disabled here. Use the mcp__ask__ask_user_question MCP tool instead. It supports pick_one, pick_many, and pick_diagram question kinds and lets you bundle multiple questions in a single call; the user sees them as tabs and submits all answers together.' >&2; exit 2`

// claudeHookSettings builds the JSON passed via --settings. It wires
// hooks into claude across two purposes:
// hooks into claude across two purposes, and when fastMode is true it
// also sets the top-level "fastMode" settings key (see the note at the
// return). Two purposes for the hooks:
//
// Existing infrastructure hooks:
// - PreToolUse[AskUserQuestion]: redirects to our MCP tool (see above).
Expand All @@ -91,7 +93,7 @@ const askUserQuestionBlockCommand = `echo 'BLOCKED: the built-in AskUserQuestion
//
// The hook command invokes ask itself (ask _hook <event> --port <n>) so
// there is no external dependency on curl.
func claudeHookSettings(mcpPort int) string {
func claudeHookSettings(mcpPort int, fastMode bool) string {
exe, err := os.Executable()
if err != nil || exe == "" {
// Fall back to a PATH lookup; less reliable but better than nothing.
Expand Down Expand Up @@ -148,12 +150,23 @@ func claudeHookSettings(mcpPort int) string {
"UserPromptSubmit": []any{memoryHook("user-prompt-submit")},
},
}
// Fast mode is a top-level settings.json key, not a hook, and the
// model picker ("opus (fast)" rows) is its sole control surface. We
// emit it explicitly — true AND false — rather than omitting when
// off: --settings is the flagSettings source, which outranks the
// user/project/local settings claude merges, so an explicit false
// authoritatively overrides an ambient `fastMode: true` a user may
// have set globally (via /fast). Claude's headless (Agent SDK / -p)
// path reads fastMode from flagSettings, so this is the only conduit
// that reaches it; it still gates on model support, so false here
// (and true on a non-opus model) is a harmless no-op.
cfg["fastMode"] = fastMode
b, _ := json.Marshal(cfg)
return string(b)
}

// shellQuote wraps s in single quotes so it survives /bin/sh -c intact.
// Embedded single quotes are escaped via the classic '\'' sequence.
// Embedded single quotes are escaped via the classic '\ sequence.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
Expand Down Expand Up @@ -207,6 +220,25 @@ func claudeEnv(args ProviderSessionArgs) []string {
return env
}

// fastModelSuffix marks the fast-mode variant of a model in the picker
// option list (e.g. "opus (fast)"). Fast mode is not a distinct model —
// it's a Claude Code capability injected via --settings (see
// claudeHookSettings) — so ask strips this suffix before passing
// --model and routes the flag separately.
const fastModelSuffix = " (fast)"

// parseClaudeModel splits a stored/selected model string into the bare
// --model value and whether the fast-mode variant was chosen. The split
// is purely lexical: claude itself gates fast mode on model support
// (Opus only), so the suffix is only attached to opus rows, but a stray
// one elsewhere is a harmless no-op.
func parseClaudeModel(raw string) (model string, fast bool) {
if strings.HasSuffix(raw, fastModelSuffix) {
return strings.TrimSuffix(raw, fastModelSuffix), true
}
return raw, false
}

// claudeCLIArgs builds the argv for `claude -p`. Passing probe=true
// omits --include-partial-messages and the permission-prompt tool so
// the short-lived init probe doesn't trip permissions.
Expand All @@ -216,6 +248,7 @@ func claudeCLIArgs(args ProviderSessionArgs, probe bool) []string {
"--output-format", "stream-json",
"--verbose",
}
baseModel, fastMode := parseClaudeModel(args.Model)
if !probe {
out = append(out, "--include-partial-messages")
}
Expand All @@ -233,18 +266,18 @@ func claudeCLIArgs(args ProviderSessionArgs, probe bool) []string {
}
if args.MCPPort > 0 {
out = append(out, "--mcp-config", claudeMCPConfig(args.MCPPort, args.ProjectMCP))
out = append(out, "--settings", claudeHookSettings(args.MCPPort))
out = append(out, "--settings", claudeHookSettings(args.MCPPort, fastMode))
if !probe {
out = append(out, "--permission-prompt-tool", "mcp__ask__approval_prompt")
}
}
switch {
case strings.EqualFold(args.Model, "ollama"):
case strings.EqualFold(baseModel, "ollama"):
if args.OllamaModel != "" {
out = append(out, "--model", args.OllamaModel)
}
case args.Model != "":
out = append(out, "--model", args.Model)
case baseModel != "":
out = append(out, "--model", baseModel)
}
if args.Effort != "" {
out = append(out, "--effort", args.Effort)
Expand Down
70 changes: 69 additions & 1 deletion claude_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func TestClaudeCLIArgs_MCPConfigAndSettings(t *testing.T) {
// the port we pass in, so claude fires them into our HTTP bridge and
// the chip stays in sync with what's actually running.
func TestClaudeHookSettings_RegistersSubagentHooks(t *testing.T) {
raw := claudeHookSettings(54321)
raw := claudeHookSettings(54321, false)
var parsed struct {
Hooks map[string][]struct {
Matcher string `json:"matcher"`
Expand Down Expand Up @@ -230,6 +230,74 @@ func TestClaudeHookSettings_RegistersSubagentHooks(t *testing.T) {
}
}

// Fast mode is a Claude Code capability the headless (Agent SDK) path
// reads only from the --settings source, so ask injects it as a
// top-level "fastMode" key. It's emitted explicitly both ways: true
// when on, false when off — the explicit false outranks (flagSettings
// precedence) any ambient fastMode a user set globally, keeping the
// model picker authoritative.
func TestClaudeHookSettings_FastMode(t *testing.T) {
type settings struct {
FastMode *bool `json:"fastMode"`
}
var off settings
if err := json.Unmarshal([]byte(claudeHookSettings(1, false)), &off); err != nil {
t.Fatalf("off settings not JSON: %v", err)
}
if off.FastMode == nil || *off.FastMode {
t.Errorf("fastMode should be explicit false when off, got %v", off.FastMode)
}
var on settings
if err := json.Unmarshal([]byte(claudeHookSettings(1, true)), &on); err != nil {
t.Fatalf("on settings not JSON: %v", err)
}
if on.FastMode == nil || !*on.FastMode {
t.Errorf("fastMode should be true when on, got %v", on.FastMode)
}
}

func TestParseClaudeModel(t *testing.T) {
cases := []struct {
in string
model string
fast bool
}{
{"opus", "opus", false},
{"opus[1m]", "opus[1m]", false},
{"opus" + fastModelSuffix, "opus", true},
{"opus[1m]" + fastModelSuffix, "opus[1m]", true},
{"", "", false},
{"ollama", "ollama", false},
}
for _, c := range cases {
m, f := parseClaudeModel(c.in)
if m != c.model || f != c.fast {
t.Errorf("parseClaudeModel(%q) = (%q,%v), want (%q,%v)", c.in, m, f, c.model, c.fast)
}
}
}

// A fast-mode model row ("opus (fast)") must strip down to a bare
// --model value while routing the flag into the --settings payload (the
// only conduit claude's headless path reads). A plain opus row strips to
// the same --model but carries an explicit fastMode:false.
func TestClaudeCLIArgs_FastModeFromModel(t *testing.T) {
on := claudeCLIArgs(ProviderSessionArgs{MCPPort: 1, Model: "opus[1m]" + fastModelSuffix}, false)
if got := argAfter(on, "--model"); got != "opus[1m]" {
t.Errorf("--model = %q, want opus[1m] (suffix stripped)", got)
}
if got := argAfter(on, "--settings"); !strings.Contains(got, `"fastMode":true`) {
t.Errorf("--settings missing fastMode for a (fast) model: %s", got)
}
off := claudeCLIArgs(ProviderSessionArgs{MCPPort: 1, Model: "opus[1m]"}, false)
if got := argAfter(off, "--model"); got != "opus[1m]" {
t.Errorf("--model = %q, want opus[1m]", got)
}
if got := argAfter(off, "--settings"); !strings.Contains(got, `"fastMode":false`) {
t.Errorf("--settings should carry explicit fastMode:false for a plain model: %s", got)
}
}

func TestShellQuote_EscapesSingleQuotes(t *testing.T) {
cases := []struct {
in, want string
Expand Down
20 changes: 20 additions & 0 deletions provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,26 @@ func TestClaudeProvider_Metadata(t *testing.T) {
if !sawOllama {
t.Errorf("ModelPicker options missing ollama entry: %v", mp.Options)
}
// Fast-mode rows must each pair with a plain base row, and
// parseClaudeModel must recover that base + the fast flag — that
// pairing is what lets a user pick speed without losing the model.
opts := map[string]bool{}
for _, opt := range mp.Options {
opts[opt] = true
}
for _, fastOpt := range []string{"opus" + fastModelSuffix, "opus[1m]" + fastModelSuffix} {
if !opts[fastOpt] {
t.Errorf("ModelPicker missing fast variant %q: %v", fastOpt, mp.Options)
continue
}
base, fast := parseClaudeModel(fastOpt)
if !fast {
t.Errorf("parseClaudeModel(%q) should report fast", fastOpt)
}
if !opts[base] {
t.Errorf("fast variant %q has no plain base row %q: %v", fastOpt, base, mp.Options)
}
}

efforts := p.EffortOptions()
wantEffort := map[string]bool{
Expand Down