diff --git a/README.md b/README.md index a31b916..5708323 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,19 @@ input, so scrolling, resize, and type-ahead all work while tokens stream in, and ## Slash commands -Built-ins: `/help`, `/model [name]`, `/clear`, `/tools`, `/mcp`, `/resume`, -`/cwd`, `/config`, `/reload`, `/quit`, `/exit`. In the TUI, type `/` to open a -fuzzy command palette. Add your own in config (see below). +Built-ins: `/help`, `/model [name]`, `/effort [level]`, `/clear`, `/tools`, +`/mcp`, `/resume`, `/cwd`, `/config`, `/reload`, `/quit`, `/exit`. In the TUI, +type `/` to open a fuzzy command palette; once you type `/model ` the same +palette fuzzy-completes the **model argument** from the config's `:models` +list, shown as `Anthropic: Claude Opus 4.6` (Tab inserts the selection, Enter +runs it — any model id typed by hand still works). The active model is marked +`●` and providers whose API key isn't set are annotated `· no key`. `/effort` +sets Sema's portable reasoning-effort level the same way (`none` / `minimal` / +`low` / `medium` / `high` / `xhigh`; `default` resets) — models without +reasoning support simply ignore it, and a `(model … {:effort "high"})` record +sets a per-model default. `/resume ` restores a saved session directly +(completing from your session list) and brings back the model and effort it +ran with. Add your own commands in config (see below). ## Configuration @@ -111,9 +121,27 @@ A complete `init.sema`: (configure! (coder-config {:model "" ; "" = auto-detect from API keys; or e.g. "claude-sonnet-5" + :effort "" ; reasoning effort (none…xhigh); "" = provider default :max-turns 50 ; max tool-use rounds in a single turn :tool-preview-lines 5 ; result lines shown under each tool call + ;; Models offered by the /model autocomplete, grouped by provider. + ;; Only a picker list — any model id typed by hand still works. A model + ;; may carry per-model defaults: (model id label {:effort "high"}). + :models + (list + (provider "Anthropic" + (list + (model "claude-opus-4-6" "Claude Opus 4.6") + (model "claude-sonnet-5" "Claude Sonnet 5") + (model "claude-haiku-4-5" "Claude Haiku 4.5"))) + (provider "OpenAI" + (list + (model "gpt-5.6-sol" "GPT-5.6 Sol") + (model "gpt-5.6-terra" "GPT-5.6 Terra") + (model "gpt-5.6-luna" "GPT-5.6 Luna") + (model "gpt-5.5" "GPT-5.5")))) + ;; MCP servers — each is a value; manage connections in the /mcp modal (⌃O). :mcp-servers (list @@ -138,8 +166,10 @@ A complete `init.sema`: | Key | Default | Meaning | | --- | --- | --- | | `:model` | `""` | LLM model; `""` auto-detects from `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` | +| `:effort` | `""` | Reasoning effort (`none`/`minimal`/`low`/`medium`/`high`/`xhigh`); `""` = provider default | | `:max-turns` | `50` | Max agent tool-use rounds per user turn | | `:tool-preview-lines` | `5` | Result lines shown under each tool call in the TUI | +| `:models` | Anthropic + OpenAI flagships | `(provider …)` groups of `(model …)` records driving the `/model` autocomplete | | `:mcp-servers` | `'()` | List of `(mcp-server …)` records | | `:commands` | `'()` | List of `(command …)` records | | `:keys` | `{}` | Action → key overrides | @@ -184,6 +214,18 @@ can also register commands at runtime from Sema, after loading `src/commands.sem (lambda (state args) (emit :info "hi!") state)) ``` +A command can also register **argument completions** — the palette switches to +them once you type `/name ` (this is how `/model`, `/effort`, `/resume`, and +`/config` offer theirs). The function receives the live state map (`:config`, +`:model`, `:effort`, …); an entry with `:active #t` is marked `●` in the palette: + +```sema +(register-completions! "hello" + (lambda (state) + (list {:value "world" :label "the whole world" :active #t} + {:value "mom" :label "hi mom"}))) +``` + ### Keybindings The keymap is data, merged from four layers (weakest first): the built-in diff --git a/coder.sema b/coder.sema index 42d01ed..c558794 100755 --- a/coder.sema +++ b/coder.sema @@ -21,38 +21,42 @@ (when (:help cli) (show-usage) (exit 0)) (when (:version cli) (println f"sema-coder ${VERSION}") (exit 0)) - (define provider (llm/auto-configure)) - (when (nil? provider) + ;; not `provider`/`model` — those names would shadow the config constructors + ;; init.sema calls on every load/hot-reload (same trap as `agent` below). + (define llm-provider (llm/auto-configure)) + (when (nil? llm-provider) (io/println-error "Error: No API key found. Set ANTHROPIC_API_KEY or OPENAI_API_KEY.") (exit 1)) - (define cfg (boot-config!)) ;; ensure + load init.sema, reconcile commands + servers - (define cwd (sys/cwd)) - (define model (:model cli)) + (define cfg (boot-config!)) ;; ensure + load init.sema, reconcile commands + servers + (define cwd (sys/cwd)) + (define cli-model (:model cli)) (mcp-autostart!) ;; connect :autostart servers before the agent is built ;; not `agent` — that name would shadow the (agent {…}) builtin constructor. - (define sema-coder-agent (create-agent cwd model cfg)) + (define sema-coder-agent (create-agent cwd cli-model cfg)) ;; One-shot mode — prose to stdout, nothing else. (when (:prompt cli) - (let ((result (run-turn sema-coder-agent (:prompt cli) '() on-tool-call))) + (let ((result (run-turn sema-coder-agent (:prompt cli) '() on-tool-call + (pick-effort "" (choose-model cli-model cfg) cfg)))) (println (:response result)) (exit 0))) ;; Interactive, on a TTY → the full-screen TUI. (when (sys/tty) - (tui-run cwd model cfg sema-coder-agent) + (tui-run cwd cli-model cfg sema-coder-agent) (exit 0)) ;; Not a TTY (piped/dumb terminal) → the plain line-based REPL below. (show-banner VERSION) - (show-welcome cwd model) + (show-welcome cwd cli-model) (let ((na (mcp-needs-auth))) (unless (null? na) (show-info f"${(length na)} MCP server(s) need auth: ${(string/join na ", ")}"))) (define state - {:messages '() :model model :cwd cwd :agent sema-coder-agent :config cfg}) + {:messages '() :model cli-model :effort "" + :cwd cwd :agent sema-coder-agent :config cfg}) (defun handle-input (input) (cond @@ -72,7 +76,10 @@ (set! state next)))) (else (let ((result (try - (run-turn (:agent state) (string/trim input) (:messages state) on-tool-call) + (run-turn (:agent state) (string/trim input) (:messages state) on-tool-call + (let ((c (get state :config {}))) + (pick-effort (get state :effort "") + (choose-model (get state :model "") c) c))) (catch e (show-error f"agent error: ${(get e :message (str e))}") #f)))) diff --git a/src/agent.sema b/src/agent.sema index 3ffabe4..20a7ec8 100644 --- a/src/agent.sema +++ b/src/agent.sema @@ -3,6 +3,7 @@ (load "util.sema") (load "tools.sema") (load "mcp.sema") +(load "config.sema") ;; model-effort — per-model :effort defaults (defun agent-tools () "The agent's full tool set: the app's own tools plus every connected MCP @@ -46,15 +47,15 @@ Platform: ${platform} ") "\n\n")) +(defun choose-model (model cfg) + "Model precedence: explicit MODEL > config :model > \"\" (auto-detect)." + (if (not (= model "")) model (get cfg :model ""))) + (defun create-agent (cwd model cfg) "Build the coding agent. Model precedence: explicit arg > config > auto." (set-workspace-root! cwd) (let* ((platform f"${(sys/os)} (${(sys/arch)})") - (chosen - (cond - ((not (= model "")) model) - ((not (= (get cfg :model "") "")) (get cfg :model)) - (else ""))) + (chosen (choose-model model cfg)) (prompt (build-system-prompt cwd platform))) ;; Anonymous constructor, not defagent — this is called on every model ;; switch / config reload / MCP connect, and defagent would rebind a global @@ -65,13 +66,27 @@ Platform: ${platform} :max-turns (get cfg :max-turns 50) :model chosen}))) -(defun run-turn (agent input messages on-tool) +(defun pick-effort (override model-id cfg) + "Effort precedence: session OVERRIDE (/effort) → the model's own :effort in + the config's :models entry for MODEL-ID → the config :effort. \"\" means + the provider default." + (let ((m-eff (model-effort cfg model-id))) + (cond ((and override (not (= override ""))) override) + ((not (= m-eff "")) m-eff) + (else (get cfg :effort ""))))) + +(defun turn-opts (opts effort) + "Merge a non-empty reasoning EFFORT into an agent/run OPTS map. Effort is + portable — providers/models without it treat the option as a no-op." + (if (and effort (not (= effort ""))) (assoc opts :reasoning-effort effort) opts)) + +(defun run-turn (agent input messages on-tool effort) "Blocking turn used by the non-TTY / one-shot path (no live streaming)." - (agent/run agent input {:on-tool-call on-tool :messages messages})) + (agent/run agent input (turn-opts {:on-tool-call on-tool :messages messages} effort))) -(defun run-turn-streaming (agent input messages on-tool on-text) +(defun run-turn-streaming (agent input messages on-tool on-text effort) "Turn used by the TUI: :on-text streams assistant deltas, :on-tool-call streams tool events, so the front-end can render the reply live." (agent/run agent input - {:on-tool-call on-tool :on-text on-text :messages messages})) + (turn-opts {:on-tool-call on-tool :on-text on-text :messages messages} effort))) diff --git a/src/commands.sema b/src/commands.sema index 91c865b..2191bb7 100644 --- a/src/commands.sema +++ b/src/commands.sema @@ -31,6 +31,27 @@ (unless (member name *command-order*) (set! *command-order* (append *command-order* (list name))))) +;; ── Argument completions ───────────────────────────────────────── +;; A command may register a completion source for its argument. The TUI palette +;; fuzzy-filters these once the input reads "/cmd "; commands without +;; a source keep the plain prompt. + +(define *command-completions* {}) ;; name(string) → (fn state → ({:value :label [:active]} …)) + +(defun register-completions! (name f) + (set! *command-completions* (assoc *command-completions* name f))) + +(defun command-has-completions? (name) + (if (get *command-completions* name #f) #t #f)) + +(defun command-completions (name state) + "Completion entries for /NAME's argument — ({:value … :label …} …), or '(). + :value is what gets inserted/run; :label is the human line shown; an entry + with :active #t marks the currently-in-effect value (rendered as ●)." + (if-let (f (get *command-completions* name #f)) + (f state) + '())) + (defun slash-command? (input) (string/starts-with? (string/trim input) "/")) @@ -180,17 +201,97 @@ (emit :line "") state)) +(defun provider-env-key (name) + "The conventional API-key env var for a provider display NAME, or #f." + (match (string/lower name) + ("anthropic" "ANTHROPIC_API_KEY") + ("openai" "OPENAI_API_KEY") + (_ #f))) + +(defun provider-key-missing? (name) + "True when NAME's key env var is known and unset — its models can't be called." + (if-let (k (provider-env-key name)) + (let ((v (env k))) (if (or (nil? v) (= v "")) #t #f)) + #f)) + (register-command! "model" "Show or switch the model: /model [name]" (lambda (state args) - (if (= args "") - (begin - (emit :info f"model: ${(if (= (:model state) "") "auto" (:model state))}") - state) - (begin - (emit :ok f"switched to ${args}") - (assoc state - :model args - :agent (create-agent (:cwd state) args (:config state))))))) + (let* ((cfg (get state :config {})) + (current (choose-model (get state :model "") cfg))) + (if (= args "") + (begin + (emit :info f"model: ${(if (= current "") "auto" current)}") + (for-each + (lambda (m) (emit :line (format " ~a ~a~a" + (accent (string/pad-right (:id m) 22)) + (muted f"${(:provider m)}: ${(:label m)}") + (string/append + (if (= (:id m) current) (ok " ●") "") + (if (provider-key-missing? (:provider m)) (muted " · no key") ""))))) + (model-entries cfg)) + state) + (let ((entry (model-entry cfg args))) + (emit :ok f"switched to ${args}") + (when (and entry (provider-key-missing? (:provider entry))) + (emit :info f"note: ${(provider-env-key (:provider entry))} is not set — requests to this model will fail")) + (when (and entry (not (= (get entry :effort "") "")) (= (get state :effort "") "")) + (emit :info f"effort → ${(get entry :effort "")} (model default; /effort overrides)")) + (assoc state + :model args + :agent (create-agent (:cwd state) args cfg))))))) + +;; The /model argument completes from the config's :models provider groups +;; (see config.sema) — shown in the palette as "Provider: Model Name", with the +;; active model marked and providers whose API key is missing annotated. +(register-completions! "model" + (lambda (state) + (let* ((cfg (get state :config {})) + (current (choose-model (get state :model "") cfg))) + (map (lambda (m) + {:value (:id m) + :label (string/append f"${(:provider m)}: ${(:label m)}" + (if (provider-key-missing? (:provider m)) " · no key" "")) + :active (= (:id m) current)}) + (model-entries cfg))))) + +;; Sema's portable :reasoning-effort levels (llm docs); models without +;; reasoning support ignore the option, so no per-model gating is needed. +(define effort-levels (list "none" "minimal" "low" "medium" "high" "xhigh")) + +(register-command! "effort" "Show or set reasoning effort: /effort [level|default]" + (lambda (state args) + ;; Accept ":high" as well as "high" — the docs write levels as keywords. + (let ((v (let ((a (string/trim args))) + (if (string/starts-with? a ":") (string/slice a 1 (string/length a)) a)))) + (cond + ((= v "") + (let* ((cfg (get state :config {})) + (cur (pick-effort (get state :effort "") (choose-model (get state :model "") cfg) cfg))) + (emit :info f"effort: ${(if (= cur "") "default" cur)} · levels: ${(string/join effort-levels ", ")} · /effort default resets")) + state) + ((= v "default") + (emit :ok "reasoning effort → provider default") + (assoc state :effort "")) + ((member v effort-levels) + (emit :ok f"reasoning effort → ${v}") + (assoc state :effort v)) + (else + (emit :error f"unknown effort “${v}” (try ${(string/join effort-levels ", ")}, or default)") + state))))) + +(register-completions! "effort" + (lambda (state) + (let* ((cfg (get state :config {})) + (cur (pick-effort (get state :effort "") (choose-model (get state :model "") cfg) cfg)) + (mark (if (= cur "") "default" cur))) + (map (lambda (e) (assoc e :active (= (:value e) mark))) + (list {:value "none" :label "none — thinking disabled"} + {:value "minimal" :label "minimal — barely deliberates"} + {:value "low" :label "low — quick and cheap"} + {:value "medium" :label "medium — balanced"} + {:value "high" :label "high — thorough"} + {:value "xhigh" :label "xhigh — maximum deliberation"} + {:value "default" :label "default — back to the provider default"}))))) (register-command! "clear" "Clear the conversation history" (lambda (state args) @@ -218,22 +319,53 @@ servers)))) state)) -(register-command! "resume" "Resume a past session (opens the modal; ⌃R in the TUI)" +(register-command! "resume" "Resume a past session: /resume [id] (modal: ⌃R in the TUI)" (lambda (state args) - (if *tui-active* - (open-resume-modal!) - (let ((ss (list-sessions))) - (if (= (length ss) 0) - (emit :info "no saved sessions yet") - (for-each - (lambda (m) (emit :line (format " ~a ~a msgs ~a" - (string/pad-right (:title m) 40) (:count m) (:id m)))) - ss)))) - state)) + (cond + ;; /resume — restore directly, no modal. + ((not (= args "")) + (if *tui-active* + (begin + (try (restore-session! args) + (catch e (emit :error f"couldn't restore “${args}”: ${(get e :message (str e))}"))) + ;; restore-session! updates the TUI globals; hand them back as state. + (tui-state)) + (let ((r (try (load-session args) (catch e #f)))) + (if (not r) + (begin (emit :error f"no session “${args}” (try /resume for the list)") state) + (let* ((meta (:meta r)) + (m (get meta :model ""))) + (emit :ok f"resumed “${(get meta :title args)}” · ${(length (:messages r))} messages") + (assoc state + :messages (:messages r) + :model m + :effort (get meta :effort "") + :agent (create-agent (:cwd state) m (get state :config {})))))))) + (*tui-active* (open-resume-modal!) state) + (else + (let ((ss (list-sessions))) + (if (= (length ss) 0) + (emit :info "no saved sessions yet") + (for-each + (lambda (m) (emit :line (format " ~a ~a msgs ~a" + (string/pad-right (:title m) 40) (:count m) (:id m)))) + ss))) + state)))) + +;; The /resume argument completes from the saved sessions, newest first. +(register-completions! "resume" + (lambda (state) + (map (lambda (m) {:value (:id m) + :label f"${(:title m)} · ${(:count m)} msgs"}) + (list-sessions)))) (register-command! "cwd" "Show the working directory" (lambda (state args) (emit :info (:cwd state)) state)) +(register-completions! "config" + (lambda (state) + (list {:value "edit" :label "open init.sema in your editor"}))) + (register-command! "config" "Show the config path (/config edit opens it)" (lambda (state args) (if (= args "edit") diff --git a/src/config.sema b/src/config.sema index f230cd2..f1dfd7d 100644 --- a/src/config.sema +++ b/src/config.sema @@ -1,7 +1,8 @@ ;; config.sema — configuration as DATA. ;; ;; init.sema is Lisp that calls value-returning constructors (coder-config, -;; mcp-server, command) and hands the result to (configure! cfg). The loader is +;; mcp-server, command, provider, model) and hands the result to +;; (configure! cfg). The loader is ;; pure: it captures that value and returns {:ok cfg} | {:error e} — it never ;; mutates the app's active config or prints, so the caller owns recovery policy ;; and reconciles runtime to the returned value (no accumulators, no reset). @@ -14,15 +15,62 @@ (path/join (sys/config-dir) "sema" "sema-coder"))) (defun config-path () (path/join (config-dir) "init.sema")) +;; ── Constructors: return values, never mutate ──────────────────── + +(defun model (id label . opts) + "A selectable model: ID is what the provider API accepts, LABEL is the human + name shown by the /model autocomplete. An optional OPTS map carries + per-model defaults — currently :effort, applied unless /effort overrides." + (merge (if (null? opts) {} (car opts)) {:id id :label label})) + +(defun provider (name models) + "A provider group for the /model autocomplete: display NAME plus its MODELS + (a list of (model …) records)." + {:name name :models models}) + +;; The providers/models offered by the /model autocomplete. Purely a picker +;; list — any model id typed by hand still works. A config's own :models +;; replaces this wholesale. +(define default-models + (list + (provider "Anthropic" + (list + (model "claude-opus-4-6" "Claude Opus 4.6") + (model "claude-sonnet-5" "Claude Sonnet 5") + (model "claude-haiku-4-5" "Claude Haiku 4.5"))) + (provider "OpenAI" + (list + (model "gpt-5.6-sol" "GPT-5.6 Sol") + (model "gpt-5.6-terra" "GPT-5.6 Terra") + (model "gpt-5.6-luna" "GPT-5.6 Luna") + (model "gpt-5.5" "GPT-5.5"))))) + (define default-config {:model "" ;; "" = auto-detect from API keys + :effort "" ;; reasoning effort; "" = provider default (see /effort) :max-turns 50 :tool-preview-lines 5 ;; result lines shown under a tool call in the TUI + :models default-models :mcp-servers '() :commands '() :keys {}}) ;; action → key overrides (e.g. {:mcp "ctrl-p"}) -;; ── Constructors: return values, never mutate ──────────────────── +(defun model-entries (cfg) + "Flatten the config's :models provider groups into {:id :label :provider} + entries, in declaration order." + (apply append + (map (lambda (p) + (map (lambda (m) (assoc m :provider (:name p))) (get p :models '()))) + (get cfg :models '())))) + +(defun model-entry (cfg model-id) + "The :models entry whose :id is MODEL-ID, or #f." + (let ((hits (filter #(= (:id %) model-id) (model-entries cfg)))) + (if (null? hits) #f (car hits)))) + +(defun model-effort (cfg model-id) + "The per-model :effort default declared on CFG's entry for MODEL-ID, or \"\"." + (if-let (e (model-entry cfg model-id)) (get e :effort "") "")) (defun mcp-server (name opts) "A server record. OPTS is the mcp/connect map plus the app key :autostart." @@ -69,9 +117,28 @@ (configure! (coder-config {:model \"\" ; \"\" = auto-detect from API keys; or \"claude-sonnet-5\" + :effort \"\" ; none/minimal/low/medium/high/xhigh; \"\" = provider default :max-turns 50 :tool-preview-lines 5 ; result lines shown under each tool call + ;; ── Models offered by the /model autocomplete, grouped by provider. ── + ;; Only a picker list — any model id typed by hand still works. A model + ;; may carry per-model defaults, e.g. + ;; (model \"gpt-5.6-sol\" \"GPT-5.6 Sol\" {:effort \"high\"}) + :models + (list + (provider \"Anthropic\" + (list + (model \"claude-opus-4-6\" \"Claude Opus 4.6\") + (model \"claude-sonnet-5\" \"Claude Sonnet 5\") + (model \"claude-haiku-4-5\" \"Claude Haiku 4.5\"))) + (provider \"OpenAI\" + (list + (model \"gpt-5.6-sol\" \"GPT-5.6 Sol\") + (model \"gpt-5.6-terra\" \"GPT-5.6 Terra\") + (model \"gpt-5.6-luna\" \"GPT-5.6 Luna\") + (model \"gpt-5.5\" \"GPT-5.5\")))) + ;; ── MCP servers — manage in the /mcp modal (⌃O). Each is a value. ── :mcp-servers (list diff --git a/src/session.sema b/src/session.sema index 68cbc15..a623762 100644 --- a/src/session.sema +++ b/src/session.sema @@ -17,11 +17,12 @@ (if (null? u) "(no messages)" (truncate-str (string/replace (string/trim (get (car u) :content "")) "\n" " ") 56)))) -(defun save-session! (id created model messages) - "Write the session as JSONL (meta line + one message per line). No-op if empty." +(defun save-session! (id created model effort messages) + "Write the session as JSONL (meta line + one message per line). No-op if empty. + MODEL and EFFORT are the session's effective values, so /resume restores them." (when (> (length messages) 0) (file/mkdir (sessions-dir)) - (let ((meta {:kind "meta" :id id :created created :model model + (let ((meta {:kind "meta" :id id :created created :model model :effort effort :title (session-title messages)})) (file/write (session-path id) (string/join (cons (json/encode meta) (map json/encode messages)) "\n"))))) diff --git a/src/tui.sema b/src/tui.sema index 5ccfdda..fde1ff0 100644 --- a/src/tui.sema +++ b/src/tui.sema @@ -43,6 +43,7 @@ (define *cols* 80) (define *cwd* ".") (define *model* "") +(define *effort* "") ;; /effort override; "" = config, then provider default (define *agent* #f) (define *config* {}) (define *messages* '()) @@ -68,7 +69,10 @@ ;; the accessor). (defun should-quit? () *should-quit*) -;; ── Palette (slash-command popup) ──────────────────────────────────────────── +;; ── Palette (slash popup: command names, then argument completions) ────────── +;; "/mod" → command mode: fuzzy over registered command names. +;; "/model opu" → argument mode (when the command registered completions): +;; fuzzy over its entries, shown as "Provider: Model Name". (defun palette-open? () (string/starts-with? *input* "/")) @@ -103,24 +107,100 @@ (sort-by #(- (nth % 0))) (map #(nth % 1))))) +(defun palette-arg-context () + "When the input reads \"/cmd \" and /cmd registered argument + completions, {:cmd name :query partial}; else #f." + (let ((sp (string/index-of *input* " "))) + (when (and (palette-open?) sp) + (let ((name (string/slice *input* 1 sp))) + (when (command-has-completions? name) + {:cmd name + :query (string/trim (string/slice *input* sp (string/length *input*)))}))))) + +(defun arg-score (q e) + "Best fuzzy score of Q against an entry's :label or :value; #f if neither." + (let ((a (fuzzy-score q (:label e))) + (b (fuzzy-score q (:value e)))) + (cond ((and a b) (max a b)) + (a a) + (else b)))) + +(defun arg-matches (ctx) + (->> (command-completions (:cmd ctx) (tui-state)) + (map #(list (arg-score (:query ctx) %) %)) + (filter #(nth % 0)) + (sort-by #(- (nth % 0))) + (map #(nth % 1)))) + +(defun palette-view () + "The open palette's contents — {:mode :commands|:args :title :entries [:ctx]} + — or #f when the palette is closed." + (when (palette-open?) + (if-let (ctx (palette-arg-context)) + {:mode :args :title (:cmd ctx) :ctx ctx :entries (arg-matches ctx)} + {:mode :commands :title "commands" :entries (palette-matches)}))) + +(defun palette-entries () + (if-let (pv (palette-view)) (:entries pv) '())) + +(defun palette-choice (pv sel input) + "The command string Enter should run for palette PV, selection SEL, and the + raw INPUT. Pure — on-enter dispatches the result. In argument mode an + exactly-typed value (or no match at all, e.g. a custom model id) runs as + typed; otherwise the selection runs." + (if (equal? (:mode pv) :args) + (let* ((entries (:entries pv)) + (typed (:query (:ctx pv))) + (exact? (any #(= (:value %) typed) entries))) + (if (or exact? (null? entries)) + input + f"/${(:cmd (:ctx pv))} ${(:value (nth entries (min sel (- (length entries) 1))))}")) + (let* ((matches (:entries pv)) + (parsed (parse-command input)) + ;; If the typed name is itself a command, run it; else run the selection. + (known? (get *commands* (:name parsed) #f))) + (cond (known? input) + ((> (length matches) 0) + (string/append "/" (:name (nth matches (min sel (- (length matches) 1)))) + (if (= (:args parsed) "") "" (string/append " " (:args parsed))))) + (else input))))) + +(defun palette-completion (pv sel) + "The input Tab should complete to for PV/SEL, or #f when nothing matches." + (let ((entries (:entries pv))) + (when (> (length entries) 0) + (let ((e (nth entries (min sel (- (length entries) 1))))) + (if (equal? (:mode pv) :args) + f"/${(:cmd (:ctx pv))} ${(:value e)}" + (string/append "/" (:name e) " ")))))) + (defun palette-limit (matches) (min 8 (length matches))) -(defun palette-box (matches sel bw) - "Render the slash palette as a bordered box of outer width BW: gold parens/label, +(defun palette-row (mode e inner) + "One palette row as plain text; palette-box styles selection afterwards. + An :active entry (the value currently in effect) is marked with ●." + (if (equal? mode :args) + (string/append (string/pad-right (:label e) 30) " " + (clip-width (:value e) (max 4 (- inner 35))) + (if (get e :active #f) " ●" "")) + (string/append (string/pad-right (string/append "/" (:name e)) 12) " " + (clip-width (:desc e) (max 4 (- inner 15)))))) + +(defun palette-box (pv sel bw) + "Render the palette PV as a bordered box of outer width BW: gold parens/label, faint borders, the selected row in accent. Returns a list of styled lines." - (let* ((shown (take (palette-limit matches) matches)) + (let* ((entries (:entries pv)) + (shown (take (palette-limit entries) entries)) (inner (max 8 (- bw 4))) ;; text width inside │ … │ - (label " commands ") + (label f" ${(:title pv)} ") (top (string/append (faint "┌") (accent-dim label) (faint (string/append (string/repeat "─" (max 0 (- bw 2 (string/length label)))) "┐")))) (bot (faint (string/append "└" (string/repeat "─" (max 0 (- bw 2))) "┘"))) (rows (enumerate-map (lambda (i e) (let* ((sel? (= i sel)) - (nm (string/pad-right (string/append "/" (:name e)) 12)) - (dw (max 4 (- inner 15))) - (txt (fit-plain (string/append (if sel? "▸ " " ") nm " " - (clip-width (:desc e) dw)) inner)) + (txt (fit-plain (string/append (if sel? "▸ " " ") + (palette-row (:mode pv) e (- inner 2))) inner)) (body (if sel? (accent txt) (muted txt)))) (string/append (faint "│ ") body (faint " │")))) shown))) @@ -153,6 +233,11 @@ "The session override if set, else the config's model (so a reload shows)." (if (not (= *model* "")) *model* (get *config* :model ""))) +(defun effective-effort () + "The /effort override if set, else the active model's :effort default, else + the config's :effort (so a reload shows)." + (pick-effort *effort* (effective-model) *config*)) + (defun header-line () ;; Token counts come from llm/session-usage — the cumulative sum across every ;; provider round (llm/last-usage would report only a turn's final round). @@ -160,8 +245,9 @@ (cost (get u :cost-usd 0.0)) (right (muted (string/join (append - (list (let ((m (effective-model))) (if (= m "") "auto" m)) - f"↑${(fmt-count (get u :prompt-tokens 0))} ↓${(fmt-count (get u :completion-tokens 0))}") + (list (let ((m (effective-model))) (if (= m "") "auto" m))) + (let ((e (effective-effort))) (if (= e "") '() (list f"effort ${e}"))) + (list f"↑${(fmt-count (get u :prompt-tokens 0))} ↓${(fmt-count (get u :completion-tokens 0))}") (if (> cost 0.0) (list f"$${(/ (round (* cost 10000)) 10000.0)}") '())) @@ -225,8 +311,9 @@ ;; space; wrapping to *cols*-2 leaves the right gap) so text feels less cramped. (let* ((h (transcript-height)) (tlines (viewport (transcript-lines (- *cols* 2)) h)) - (body (if (palette-open?) - (overlay-bottom tlines (palette-box (palette-matches) *psel* (- *cols* 3))) + (pv (palette-view)) + (body (if pv + (overlay-bottom tlines (palette-box pv *psel* (- *cols* 3))) tlines)) (rows (append (list (header-line)) body (list (prompt-line)) (list (status-line)))) (rows (fit-rows rows *rows*)) @@ -333,7 +420,7 @@ ;; The empty assistant block is the streaming target for :on-text. (add-block! {:kind :assistant :text ""}) (render!) - (let* ((turn (async (run-turn-streaming *agent* text *messages* on-tool-cb on-text-cb))) + (let* ((turn (async (run-turn-streaming *agent* text *messages* on-tool-cb on-text-cb (effective-effort)))) (pump (async (pump-input! turn)))) (set! *turn* turn) (let ((result (try (await turn) @@ -361,7 +448,7 @@ (defun persist-session! () (when *session-id* - (try (save-session! *session-id* *session-created* (effective-model) *messages*) + (try (save-session! *session-id* *session-created* (effective-model) (effective-effort) *messages*) (catch e nil)))) (defun restore-session! (id) @@ -369,9 +456,14 @@ (set! *messages* messages) (set! *session-id* id) (set! *session-created* (get meta :created (time-ms))) + ;; Resume the session's model + effort too, and rebuild the agent on them — + ;; a resumed conversation continues on what it was actually running. + (set! *model* (get meta :model "")) + (set! *effort* (get meta :effort "")) + (set! *agent* (create-agent *cwd* *model* *config*)) (clear-transcript!) (for-each add-block! (messages->blocks messages)) - (add-block! {:kind :info :text f"resumed “${(get meta :title id)}” · ${(length messages)} messages"}) + (add-block! {:kind :info :text f"resumed “${(get meta :title id)}” · ${(length messages)} messages · ${(let ((m (get meta :model ""))) (if (= m "") "auto" m))}"}) (set! *scroll* 0))) ;; ── Commands (dispatched through the shared registry) ──────────────────────── @@ -384,11 +476,13 @@ (add-block! {:kind kind :text text}))) (defun tui-state () - {:messages *messages* :model *model* :cwd *cwd* :agent *agent* :config *config*}) + {:messages *messages* :model *model* :effort *effort* + :cwd *cwd* :agent *agent* :config *config*}) (defun apply-tui-state! (st) (set! *messages* (:messages st)) (set! *model* (:model st)) + (set! *effort* (get st :effort "")) (set! *cwd* (:cwd st)) (set! *agent* (:agent st)) (set! *config* (:config st))) @@ -431,15 +525,7 @@ (cond ((= text "") nil) ((palette-open?) - (let* ((matches (palette-matches)) - (parsed (parse-command *input*)) - ;; If the typed name is itself a command, run it; else run the selection. - (known? (get *commands* (:name parsed) #f)) - (chosen (cond (known? *input*) - ((> (length matches) 0) - (string/append "/" (:name (nth matches (min *psel* (- (length matches) 1)))) - (if (= (:args parsed) "") "" (string/append " " (:args parsed))))) - (else *input*)))) + (let ((chosen (palette-choice (palette-view) *psel* *input*))) (clear-input!) (run-command! chosen))) (else @@ -483,17 +569,17 @@ (:end (set! *cursor* (string/length *input*))) (:tab (when (palette-open?) - (let ((matches (palette-matches))) - (when (> (length matches) 0) - (set! *input* (string/append "/" (:name (nth matches (min *psel* (- (length matches) 1)))) " ")) - (set! *cursor* (string/length *input*)))))) + (when-let (s (palette-completion (palette-view) *psel*)) + (set! *input* s) + (set! *cursor* (string/length *input*)) + (set! *psel* 0)))) (:up (if (palette-open?) (set! *psel* (max 0 (- *psel* 1))) (scroll-by! 1))) (:down (if (palette-open?) (set! *psel* (min (+ *psel* 1) - (max 0 (- (palette-limit (palette-matches)) 1)))) + (max 0 (- (palette-limit (palette-entries)) 1)))) (scroll-by! -1))) (:page-up (scroll-by! (transcript-height))) (:page-down (scroll-by! (- (transcript-height)))) diff --git a/tests/agent_test.sema b/tests/agent_test.sema index 1e02e72..9823242 100644 --- a/tests/agent_test.sema +++ b/tests/agent_test.sema @@ -10,6 +10,23 @@ (check "explicit model wins" (agent/model b) "claude-haiku-4-5-20251001") (check "max-turns from cfg" (agent/max-turns b) 5)) +;; model + effort precedence (pure helpers; unsupported models no-op the option) +(check "choose-model explicit wins" (choose-model "x" {:model "y"}) "x") +(check "choose-model config fallback" (choose-model "" {:model "y"}) "y") +(check "choose-model auto" (choose-model "" {}) "") + +(define cfg-with-model-effort + {:effort "low" + :models (list (provider "P" (list (model "m1" "M1" {:effort "xhigh"}) + (model "m2" "M2"))))}) +(check "override wins" (pick-effort "high" "m1" cfg-with-model-effort) "high") +(check "model default beats cfg" (pick-effort "" "m1" cfg-with-model-effort) "xhigh") +(check "config fallback" (pick-effort "" "m2" cfg-with-model-effort) "low") +(check "default is empty" (pick-effort "" "" {}) "") +(check "effort merged into opts" (turn-opts {:messages '()} "high") + {:messages '() :reasoning-effort "high"}) +(check "no effort, no key" (turn-opts {:messages '()} "") {:messages '()}) + ;; tool names are derived from the tools themselves, not a hand-synced list (check "tool-names derived" (tool-names) (map tool/name (all-tools))) (check "seven tool names" (length (tool-names)) 7) diff --git a/tests/config_default_test.sema b/tests/config_default_test.sema index 2c68013..83a4e25 100644 --- a/tests/config_default_test.sema +++ b/tests/config_default_test.sema @@ -19,6 +19,10 @@ (check "first server is sema" (:name srv) "sema") (check "sema autostarts" (:autostart srv) #t) (check "sema is stdio" (:transport srv) "stdio")) - (check "has the test command" (:name (car (:commands (:ok r)))) "test"))) + (check "has the test command" (:name (car (:commands (:ok r)))) "test") + (check "has the model picker list" (length (model-entries (:ok r))) 7) + (check "first picker entry" + (car (model-entries (:ok r))) + {:id "claude-opus-4-6" :label "Claude Opus 4.6" :provider "Anthropic"}))) (done) diff --git a/tests/config_test.sema b/tests/config_test.sema index c3dde3b..1c784d2 100644 --- a/tests/config_test.sema +++ b/tests/config_test.sema @@ -10,6 +10,27 @@ (command "test" {:desc "t" :run ["make" "test"]}) {:name "test" :desc "t" :run ["make" "test"]}) (check "coder-config fills defaults" (:max-turns (coder-config {:model "m"})) 50) +(check "effort defaults to provider default" (:effort (coder-config {})) "") + +;; ── models: constructors + flattening for the /model autocomplete ── +(check "model record" (model "gpt-5.5" "GPT-5.5") {:id "gpt-5.5" :label "GPT-5.5"}) +(check "model opts merged" (model "m" "M" {:effort "high"}) {:id "m" :label "M" :effort "high"}) +(check "opts can't clobber id" (:id (model "m" "M" {:id "evil"})) "m") +(check "provider record" + (provider "OpenAI" (list (model "gpt-5.5" "GPT-5.5"))) + {:name "OpenAI" :models (list {:id "gpt-5.5" :label "GPT-5.5"})}) +(check "defaults carry the model list" (length (model-entries (coder-config {}))) 7) +(check "entries carry :provider" (:provider (car (model-entries (coder-config {})))) "Anthropic") +(check "declaration order preserved" + (map :id (model-entries {:models (list (provider "P" (list (model "b" "B") (model "a" "A"))))})) + (list "b" "a")) +(check "no :models → no entries" (model-entries {}) '()) +(define cfg-me {:models (list (provider "P" (list (model "m1" "M1" {:effort "xhigh"}) (model "m2" "M2"))))}) +(check "model-entry by id" (:label (model-entry cfg-me "m2")) "M2") +(check "model-entry missing" (model-entry cfg-me "nope") #f) +(check "model-effort declared" (model-effort cfg-me "m1") "xhigh") +(check "model-effort absent" (model-effort cfg-me "m2") "") +(check "model-effort unknown" (model-effort (coder-config {}) "claude-opus-4-6") "") ;; ── load-config on a good file → {:ok cfg} ── (file/write "/tmp/sc-init-ok.sema" diff --git a/tests/palette_test.sema b/tests/palette_test.sema index 1a08553..752ccb4 100644 --- a/tests/palette_test.sema +++ b/tests/palette_test.sema @@ -21,6 +21,57 @@ (check "no matches" (palette-matches) '()) (set! *input* "") +;; ── argument completions (/model → the config's model picker) ── +(set! *config* (coder-config {})) +(set! *input* "/model ") +(let ((pv (palette-view))) + (check "space after /model → arg mode" (:mode pv) :args) + (check "box titled by the command" (:title pv) "model") + (check "empty query lists every model" (length (:entries pv)) 7)) +(set! *input* "/model opus") +(check "best match ranks first" (:value (car (palette-entries))) "claude-opus-4-6") +(set! *input* "/model gpt") +(check-true "matches by id" (any #(= (:value %) "gpt-5.5") (palette-entries))) +(set! *input* "/model terra") +(check-true "matches by label" (any #(= (:value %) "gpt-5.6-terra") (palette-entries))) +(set! *input* "/model zzzznope") +(check "no model matches" (palette-entries) '()) + +;; ── active-value marker ── +(set! *model* "claude-sonnet-5") +(set! *input* "/model ") +(check "active model marked" + (map :value (filter #(get % :active #f) (palette-entries))) + (list "claude-sonnet-5")) +(set! *input* "/effort ") +(check "default effort marked active" + (map :value (filter #(get % :active #f) (palette-entries))) + (list "default")) +(set! *model* "") + +;; ── palette-choice / palette-completion (pure Enter/Tab logic) ── +(set! *input* "/model opus") +(check "enter runs the selection" (palette-choice (palette-view) 0 *input*) "/model claude-opus-4-6") +(check "tab completes selection" (palette-completion (palette-view) 0) "/model claude-opus-4-6") +(set! *input* "/model gpt-5.5") +(check "exact value runs as typed" (palette-choice (palette-view) 0 *input*) "/model gpt-5.5") +(set! *input* "/model zzz-custom") +(check "unknown id passes through" (palette-choice (palette-view) 0 *input*) "/model zzz-custom") +(check "tab with no match is #f" (if (palette-completion (palette-view) 0) #t #f) #f) +(set! *input* "/mod") +(check "command mode enter picks match" (palette-choice (palette-view) 0 *input*) "/model") +(check "command mode tab appends space" (palette-completion (palette-view) 0) "/model ") +(set! *input* "/effort ") +(check "effort levels offered" (length (palette-entries)) 7) +(set! *input* "/effort xh") +(check "effort fuzzy match" (:value (car (palette-entries))) "xhigh") +(set! *input* "/mod") +(check "no space yet → command mode" (:mode (palette-view)) :commands) +(set! *input* "/help x") +(check "no completions registered → command mode" (:mode (palette-view)) :commands) +(set! *input* "") +(set! *config* {}) + ;; ── window-list (modal scrolling) ── (define xs (list "a" "b" "c" "d" "e")) (check "top of list" (window-list xs 0 3) {:items (list "a" "b" "c") :start 0}) diff --git a/tests/session_test.sema b/tests/session_test.sema index 2b0fdca..8c4f06c 100644 --- a/tests/session_test.sema +++ b/tests/session_test.sema @@ -12,13 +12,14 @@ {:role "assistant" :content "21 plus 21 is 42."})) (parameterize ((config-dir-override tmp)) - (save-session! "sess1" 1700000000 "claude-opus-4-8" msgs) + (save-session! "sess1" 1700000000 "claude-opus-4-8" "high" msgs) (let ((rows (list-sessions))) (check "one session listed" (length rows) 1) (let ((m (car rows))) (check "title from first user msg" (:title m) "add 21 and 21") (check "model preserved" (:model m) "claude-opus-4-8") + (check "effort preserved" (:effort m) "high") (check "created preserved" (:created m) 1700000000) (check "message count" (:count m) 4))) @@ -33,7 +34,7 @@ (check "tool-call-id linked" (:tool-call-id tm) "c1"))) ;; empty message list writes nothing - (save-session! "empty" 1700000001 "m" '()) + (save-session! "empty" 1700000001 "m" "" '()) (check "empty not persisted" (length (list-sessions)) 1) (file/delete (session-path "sess1")))