From f04a89b802ee630eb66750c9b98acbaacfa962a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 13:06:25 +0000 Subject: [PATCH 1/3] feat: /model argument autocomplete from a configurable :models list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slash palette now completes /model's argument: once the input reads "/model ", it fuzzy-filters a picker list shown as "Provider: Model Name" (Tab inserts the selected id, Enter runs it; any id typed by hand still works, so the list never gates model choice). The list lives in init.sema as (provider … (model id label) …) groups under a new :models key, hot-reloaded like everything else. Defaults: Anthropic (Opus 4.6, Sonnet 5, Haiku 4.5) and OpenAI (GPT-5.6 Sol/Terra/Luna, GPT-5.5). /model with no argument now prints the list. Mechanism is generic: register-completions! attaches a completion source to any command; the palette grows an argument mode on top of the existing command mode. coder.sema's top-level `provider`/`model` locals are renamed — they would shadow the new constructors on config hot-reload (the same single-namespace trap already documented for `agent`). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U4ocGFxVK7FsgQaEhFmJCC --- README.md | 32 ++++++++- coder.sema | 20 +++--- src/commands.sema | 32 +++++++++ src/config.sema | 57 +++++++++++++++- src/tui.sema | 119 +++++++++++++++++++++++++-------- tests/config_default_test.sema | 6 +- tests/config_test.sema | 12 ++++ tests/palette_test.sema | 22 ++++++ 8 files changed, 260 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index a31b916..d3bdcbb 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,10 @@ input, so scrolling, resize, and type-ahead all work while tokens stream in, and 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). +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). Add your own commands in config (see below). ## Configuration @@ -114,6 +117,22 @@ A complete `init.sema`: :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. + :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 @@ -140,6 +159,7 @@ A complete `init.sema`: | `:model` | `""` | LLM model; `""` auto-detects from `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` | | `: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 +204,16 @@ 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` offers the `:models` list): + +```sema +(register-completions! "hello" + (lambda (config) + (list {:value "world" :label "the whole world"} + {: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..4018bb9 100755 --- a/coder.sema +++ b/coder.sema @@ -21,17 +21,19 @@ (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) @@ -41,18 +43,18 @@ ;; 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 :cwd cwd :agent sema-coder-agent :config cfg}) (defun handle-input (input) (cond diff --git a/src/commands.sema b/src/commands.sema index 91c865b..3c041fc 100644 --- a/src/commands.sema +++ b/src/commands.sema @@ -31,6 +31,26 @@ (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 config → ({:value :label} …)) + +(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 cfg) + "Completion entries for /NAME's argument — ({:value … :label …} …), or '(). + :value is what gets inserted/run; :label is the human line shown." + (if-let (f (get *command-completions* name #f)) + (f cfg) + '())) + (defun slash-command? (input) (string/starts-with? (string/trim input) "/")) @@ -185,6 +205,11 @@ (if (= args "") (begin (emit :info f"model: ${(if (= (:model state) "") "auto" (:model state))}") + (for-each + (lambda (m) (emit :line (format " ~a ~a" + (accent (string/pad-right (:id m) 22)) + (muted f"${(:provider m)}: ${(:label m)}")))) + (model-entries (get state :config {}))) state) (begin (emit :ok f"switched to ${args}") @@ -192,6 +217,13 @@ :model args :agent (create-agent (:cwd state) args (:config state))))))) +;; The /model argument completes from the config's :models provider groups +;; (see config.sema) — shown in the palette as "Provider: Model Name". +(register-completions! "model" + (lambda (cfg) + (map (lambda (m) {:value (:id m) :label f"${(:provider m)}: ${(:label m)}"}) + (model-entries cfg)))) + (register-command! "clear" "Clear the conversation history" (lambda (state args) (emit :clear "") diff --git a/src/config.sema b/src/config.sema index f230cd2..e3bd8c1 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,51 @@ (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) + "A selectable model: ID is what the provider API accepts, LABEL is the human + name shown by the /model autocomplete." + {: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 :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 mcp-server (name opts) "A server record. OPTS is the mcp/connect map plus the app key :autostart." @@ -72,6 +109,22 @@ :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. + :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/tui.sema b/src/tui.sema index 5ccfdda..c841504 100644 --- a/src/tui.sema +++ b/src/tui.sema @@ -68,7 +68,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 +106,67 @@ (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) *config*) + (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-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." + (if (equal? mode :args) + (string/append (string/pad-right (:label e) 30) " " + (clip-width (:value e) (max 4 (- inner 33)))) + (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))) @@ -225,8 +271,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*)) @@ -431,17 +478,29 @@ (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*)))) - (clear-input!) - (run-command! chosen))) + (let ((pv (palette-view))) + (if (equal? (:mode pv) :args) + ;; Argument mode: an exactly-typed value (or no match at all, e.g. + ;; a custom model id) runs as typed; otherwise run the selection. + (let* ((entries (:entries pv)) + (typed (:query (:ctx pv))) + (exact? (any #(= (:value %) typed) entries)) + (chosen (if (or exact? (null? entries)) + *input* + f"/${(:cmd (:ctx pv))} ${(:value (nth entries (min *psel* (- (length entries) 1))))}"))) + (clear-input!) + (run-command! chosen)) + (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)) + (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*)))) + (clear-input!) + (run-command! chosen))))) (else (add-block! {:kind :user :text text}) (clear-input!) @@ -483,17 +542,23 @@ (: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*)))))) + (let* ((pv (palette-view)) + (entries (:entries pv))) + (when (> (length entries) 0) + (let ((e (nth entries (min *psel* (- (length entries) 1))))) + (set! *input* + (if (equal? (:mode pv) :args) + f"/${(:cmd (:ctx pv))} ${(:value e)}" + (string/append "/" (:name e) " "))) + (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/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..efdf994 100644 --- a/tests/config_test.sema +++ b/tests/config_test.sema @@ -11,6 +11,18 @@ {:name "test" :desc "t" :run ["make" "test"]}) (check "coder-config fills defaults" (:max-turns (coder-config {:model "m"})) 50) +;; ── 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 "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 {}) '()) + ;; ── load-config on a good file → {:ok cfg} ── (file/write "/tmp/sc-init-ok.sema" "(configure! (coder-config {:model \"m\" :max-turns 9 :commands (list (command \"t\" {:run [\"echo\"]}))}))") diff --git a/tests/palette_test.sema b/tests/palette_test.sema index 1a08553..7e8fc81 100644 --- a/tests/palette_test.sema +++ b/tests/palette_test.sema @@ -21,6 +21,28 @@ (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) '()) +(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}) From be156b1e7dc16c4c5eb8d277ae3cb4b46696bd6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 13:36:55 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20/effort=20=E2=80=94=20session=20rea?= =?UTF-8?q?soning-effort=20with=20palette=20autocomplete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sema's agent/run accepts a portable :reasoning-effort option (none, minimal, low, medium, high, xhigh) that maps to each provider's native control and no-ops on models without reasoning support — so a single session-level setting is safe for every model, no per-model gating. /effort sets it for the session (autocompleting via the same palette argument mode as /model), /effort default resets, and a bare /effort shows the current level. A new :effort config key supplies the hot-reloadable default, the TUI header shows a non-default level, and run-turn/run-turn-streaming thread it into agent/run's opts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U4ocGFxVK7FsgQaEhFmJCC --- README.md | 17 +++++++++++------ coder.sema | 8 +++++--- src/agent.sema | 18 ++++++++++++++---- src/commands.sema | 34 ++++++++++++++++++++++++++++++++++ src/config.sema | 2 ++ src/tui.sema | 16 ++++++++++++---- tests/agent_test.sema | 8 ++++++++ tests/config_test.sema | 1 + tests/palette_test.sema | 4 ++++ 9 files changed, 91 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d3bdcbb..2ffb377 100644 --- a/README.md +++ b/README.md @@ -85,12 +85,15 @@ 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; 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). Add your own commands 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). `/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. Add your own commands in config (see below). ## Configuration @@ -114,6 +117,7 @@ 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 @@ -157,6 +161,7 @@ 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 | diff --git a/coder.sema b/coder.sema index 4018bb9..427bb5d 100755 --- a/coder.sema +++ b/coder.sema @@ -37,7 +37,7 @@ ;; 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 (get cfg :effort "")))) (println (:response result)) (exit 0))) @@ -54,7 +54,8 @@ (show-info f"${(length na)} MCP server(s) need auth: ${(string/join na ", ")}"))) (define state - {:messages '() :model cli-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 @@ -74,7 +75,8 @@ (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 + (pick-effort (get state :effort "") (get state :config {}))) (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..078e2d3 100644 --- a/src/agent.sema +++ b/src/agent.sema @@ -65,13 +65,23 @@ Platform: ${platform} :max-turns (get cfg :max-turns 50) :model chosen}))) -(defun run-turn (agent input messages on-tool) +(defun pick-effort (override cfg) + "The session's reasoning effort: OVERRIDE (/effort) if non-empty, else the + config's :effort. \"\" means the provider default." + (if (and override (not (= override ""))) override (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 3c041fc..d2495d8 100644 --- a/src/commands.sema +++ b/src/commands.sema @@ -224,6 +224,40 @@ (map (lambda (m) {:value (:id m) :label f"${(:provider m)}: ${(:label m)}"}) (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 ((cur (pick-effort (get state :effort "") (get state :config {})))) + (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 (cfg) + (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) (emit :clear "") diff --git a/src/config.sema b/src/config.sema index e3bd8c1..1bf549a 100644 --- a/src/config.sema +++ b/src/config.sema @@ -46,6 +46,7 @@ (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 @@ -106,6 +107,7 @@ (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 diff --git a/src/tui.sema b/src/tui.sema index c841504..b4d2ad1 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* '()) @@ -199,6 +200,10 @@ "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 config's effort (so a reload shows)." + (pick-effort *effort* *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). @@ -206,8 +211,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)}") '())) @@ -380,7 +386,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) @@ -431,11 +437,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))) diff --git a/tests/agent_test.sema b/tests/agent_test.sema index 1e02e72..1c8655e 100644 --- a/tests/agent_test.sema +++ b/tests/agent_test.sema @@ -10,6 +10,14 @@ (check "explicit model wins" (agent/model b) "claude-haiku-4-5-20251001") (check "max-turns from cfg" (agent/max-turns b) 5)) +;; reasoning-effort plumbing (pure helpers; unsupported models no-op the option) +(check "override wins" (pick-effort "high" {:effort "low"}) "high") +(check "config fallback" (pick-effort "" {:effort "low"}) "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_test.sema b/tests/config_test.sema index efdf994..28a966e 100644 --- a/tests/config_test.sema +++ b/tests/config_test.sema @@ -10,6 +10,7 @@ (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"}) diff --git a/tests/palette_test.sema b/tests/palette_test.sema index 7e8fc81..50ec4d7 100644 --- a/tests/palette_test.sema +++ b/tests/palette_test.sema @@ -36,6 +36,10 @@ (check-true "matches by label" (any #(= (:value %) "gpt-5.6-terra") (palette-entries))) (set! *input* "/model zzzznope") (check "no model matches" (palette-entries) '()) +(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") From 1c43dc3bf64888e19ca0077136dafca1ad6da9dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 13:55:19 +0000 Subject: [PATCH 3/3] feat: session restore of model/effort, richer picker, /resume , per-model effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five follow-ups on the /model + /effort work: - /resume (modal or the new /resume form, which autocompletes from the saved-session list) now restores the model and effort the session ran with and rebuilds the agent on them; save-session! persists effort alongside model. Fixes resumed sessions silently continuing on the current model. - Completion sources now receive the live state map instead of just the config, so the picker can mark the value currently in effect (● on the active model / effort level) and annotate providers whose API key env var is unset (· no key). /model with no argument shows the same marks. - (model id label {:effort "high"}) sets a per-model default effort; precedence is /effort override → model default → config :effort → provider default (pick-effort, with choose-model factored out of create-agent for the model side). - /config completes its edit argument. - The palette's Enter/Tab logic is extracted into pure palette-choice / palette-completion functions, now covered headlessly in palette_test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U4ocGFxVK7FsgQaEhFmJCC --- README.md | 23 +++--- coder.sema | 7 +- src/agent.sema | 23 +++--- src/commands.sema | 150 +++++++++++++++++++++++++++++----------- src/config.sema | 20 ++++-- src/session.sema | 7 +- src/tui.sema | 93 ++++++++++++++----------- tests/agent_test.sema | 21 ++++-- tests/config_test.sema | 8 +++ tests/palette_test.sema | 25 +++++++ tests/session_test.sema | 5 +- 11 files changed, 266 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index 2ffb377..5708323 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,14 @@ Built-ins: `/help`, `/model [name]`, `/effort [level]`, `/clear`, `/tools`, 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). `/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. Add your own commands in config (see below). +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 @@ -122,7 +126,8 @@ A complete `init.sema`: :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. + ;; 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" @@ -210,12 +215,14 @@ can also register commands at runtime from Sema, after loading `src/commands.sem ``` A command can also register **argument completions** — the palette switches to -them once you type `/name ` (this is how `/model` offers the `:models` list): +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 (config) - (list {:value "world" :label "the whole world"} + (lambda (state) + (list {:value "world" :label "the whole world" :active #t} {:value "mom" :label "hi mom"}))) ``` diff --git a/coder.sema b/coder.sema index 427bb5d..c558794 100755 --- a/coder.sema +++ b/coder.sema @@ -37,7 +37,8 @@ ;; One-shot mode — prose to stdout, nothing else. (when (:prompt cli) - (let ((result (run-turn sema-coder-agent (:prompt cli) '() on-tool-call (get cfg :effort "")))) + (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))) @@ -76,7 +77,9 @@ (else (let ((result (try (run-turn (:agent state) (string/trim input) (:messages state) on-tool-call - (pick-effort (get state :effort "") (get state :config {}))) + (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 078e2d3..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,10 +66,14 @@ Platform: ${platform} :max-turns (get cfg :max-turns 50) :model chosen}))) -(defun pick-effort (override cfg) - "The session's reasoning effort: OVERRIDE (/effort) if non-empty, else the - config's :effort. \"\" means the provider default." - (if (and override (not (= override ""))) override (get cfg :effort ""))) +(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 diff --git a/src/commands.sema b/src/commands.sema index d2495d8..2191bb7 100644 --- a/src/commands.sema +++ b/src/commands.sema @@ -36,7 +36,7 @@ ;; fuzzy-filters these once the input reads "/cmd "; commands without ;; a source keep the plain prompt. -(define *command-completions* {}) ;; name(string) → (fn config → ({:value :label} …)) +(define *command-completions* {}) ;; name(string) → (fn state → ({:value :label [:active]} …)) (defun register-completions! (name f) (set! *command-completions* (assoc *command-completions* name f))) @@ -44,11 +44,12 @@ (defun command-has-completions? (name) (if (get *command-completions* name #f) #t #f)) -(defun command-completions (name cfg) +(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." + :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 cfg) + (f state) '())) (defun slash-command? (input) @@ -200,29 +201,58 @@ (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))}") - (for-each - (lambda (m) (emit :line (format " ~a ~a" - (accent (string/pad-right (:id m) 22)) - (muted f"${(:provider m)}: ${(:label m)}")))) - (model-entries (get state :config {}))) - 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". +;; (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 (cfg) - (map (lambda (m) {:value (:id m) :label f"${(:provider m)}: ${(:label m)}"}) - (model-entries cfg)))) + (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. @@ -235,7 +265,8 @@ (if (string/starts-with? a ":") (string/slice a 1 (string/length a)) a)))) (cond ((= v "") - (let ((cur (pick-effort (get state :effort "") (get state :config {})))) + (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") @@ -249,14 +280,18 @@ state))))) (register-completions! "effort" - (lambda (cfg) - (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"}))) + (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) @@ -284,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 1bf549a..f1dfd7d 100644 --- a/src/config.sema +++ b/src/config.sema @@ -17,10 +17,11 @@ ;; ── Constructors: return values, never mutate ──────────────────── -(defun model (id label) +(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." - {:id id :label label}) + 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 @@ -62,6 +63,15 @@ (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." {:name name @@ -112,7 +122,9 @@ :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. + ;; 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\" 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 b4d2ad1..fde1ff0 100644 --- a/src/tui.sema +++ b/src/tui.sema @@ -126,7 +126,7 @@ (else b)))) (defun arg-matches (ctx) - (->> (command-completions (:cmd ctx) *config*) + (->> (command-completions (:cmd ctx) (tui-state)) (map #(list (arg-score (:query ctx) %) %)) (filter #(nth % 0)) (sort-by #(- (nth % 0))) @@ -143,13 +143,46 @@ (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-row (mode e inner) - "One palette row as plain text; palette-box styles selection afterwards." + "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 33)))) + (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)))))) @@ -201,8 +234,9 @@ (if (not (= *model* "")) *model* (get *config* :model ""))) (defun effective-effort () - "The /effort override if set, else the config's effort (so a reload shows)." - (pick-effort *effort* *config*)) + "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 @@ -414,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) @@ -422,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) ──────────────────────── @@ -486,29 +525,9 @@ (cond ((= text "") nil) ((palette-open?) - (let ((pv (palette-view))) - (if (equal? (:mode pv) :args) - ;; Argument mode: an exactly-typed value (or no match at all, e.g. - ;; a custom model id) runs as typed; otherwise run the selection. - (let* ((entries (:entries pv)) - (typed (:query (:ctx pv))) - (exact? (any #(= (:value %) typed) entries)) - (chosen (if (or exact? (null? entries)) - *input* - f"/${(:cmd (:ctx pv))} ${(:value (nth entries (min *psel* (- (length entries) 1))))}"))) - (clear-input!) - (run-command! chosen)) - (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)) - (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*)))) - (clear-input!) - (run-command! chosen))))) + (let ((chosen (palette-choice (palette-view) *psel* *input*))) + (clear-input!) + (run-command! chosen))) (else (add-block! {:kind :user :text text}) (clear-input!) @@ -550,16 +569,10 @@ (:end (set! *cursor* (string/length *input*))) (:tab (when (palette-open?) - (let* ((pv (palette-view)) - (entries (:entries pv))) - (when (> (length entries) 0) - (let ((e (nth entries (min *psel* (- (length entries) 1))))) - (set! *input* - (if (equal? (:mode pv) :args) - f"/${(:cmd (:ctx pv))} ${(:value e)}" - (string/append "/" (:name e) " "))) - (set! *cursor* (string/length *input*)) - (set! *psel* 0)))))) + (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))) diff --git a/tests/agent_test.sema b/tests/agent_test.sema index 1c8655e..9823242 100644 --- a/tests/agent_test.sema +++ b/tests/agent_test.sema @@ -10,13 +10,22 @@ (check "explicit model wins" (agent/model b) "claude-haiku-4-5-20251001") (check "max-turns from cfg" (agent/max-turns b) 5)) -;; reasoning-effort plumbing (pure helpers; unsupported models no-op the option) -(check "override wins" (pick-effort "high" {:effort "low"}) "high") -(check "config fallback" (pick-effort "" {:effort "low"}) "low") -(check "default is empty" (pick-effort "" {}) "") -(check "effort merged into opts" (turn-opts {:messages '()} "high") +;; 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 '()}) +(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))) diff --git a/tests/config_test.sema b/tests/config_test.sema index 28a966e..1c784d2 100644 --- a/tests/config_test.sema +++ b/tests/config_test.sema @@ -14,6 +14,8 @@ ;; ── 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"})}) @@ -23,6 +25,12 @@ (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 50ec4d7..752ccb4 100644 --- a/tests/palette_test.sema +++ b/tests/palette_test.sema @@ -36,6 +36,31 @@ (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") 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")))