diff --git a/lib/ourocode/command/registry/builtin.ex b/lib/ourocode/command/registry/builtin.ex index 882cb53..b4aa2ba 100644 --- a/lib/ourocode/command/registry/builtin.ex +++ b/lib/ourocode/command/registry/builtin.ex @@ -215,13 +215,21 @@ defmodule Ourocode.Command.Registry.Builtin do summary: "Show plugin readiness and reload guidance.", run_spec: %{kind: :builtin_action, action: :show_config} }, + %{ + name: "provider", + slash: "/provider", + aliases: ["/providers"], + category: :runtime, + summary: "Pick the active main-session provider/backend.", + run_spec: %{kind: :builtin_action, action: :select_provider} + }, %{ name: "model", slash: "/model", aliases: ["/models"], category: :runtime, - summary: "Pick the active main-session backend (detected models).", - run_spec: %{kind: :builtin_action, action: :select_model} + summary: "Show provider-specific model commands and slug selection status.", + run_spec: %{kind: :builtin_action, action: :show_model_commands} }, %{ name: "theme", diff --git a/lib/ourocode/model/catalog.ex b/lib/ourocode/model/catalog.ex index 6e5f16c..cac3c02 100644 --- a/lib/ourocode/model/catalog.ex +++ b/lib/ourocode/model/catalog.ex @@ -27,10 +27,15 @@ defmodule Ourocode.Model.Catalog do @spec list(keyword()) :: [Model.t()] def list(opts \\ []) do which = Keyword.get(opts, :which, &System.find_executable/1) + cli_stream = Keyword.get(opts, :cli_stream, &Ourocode.Model.Cli.stream/4) codex_signed_in? = Keyword.get_lazy(opts, :codex_signed_in, &Codex.signed_in?/0) anthropic_signed_in? = Keyword.get_lazy(opts, :anthropic_signed_in, &Anthropic.signed_in?/0) - [codex_model(codex_signed_in?), claude_api_model(anthropic_signed_in?) | cli_models(which)] + [ + codex_model(codex_signed_in?), + claude_api_model(anthropic_signed_in?) + | cli_models(which, cli_stream) + ] end @doc """ @@ -60,6 +65,31 @@ defmodule Ourocode.Model.Catalog do @spec selectable([Model.t()]) :: [Model.t()] def selectable(models), do: Enum.reject(models, &(&1.status == :unavailable)) + @doc "Provider-specific model slugs available for a backend provider." + @spec provider_model_slugs(atom()) :: [map()] + defdelegate provider_model_slugs(provider_id), to: Ourocode.Model.ProviderModels + + @doc "Built-in default model slug for a backend provider." + @spec default_provider_model_slug(atom()) :: String.t() | nil + defdelegate default_provider_model_slug(provider_id), to: Ourocode.Model.ProviderModels + + @doc "Looks up a provider-specific model slug after normalizing user input." + @spec fetch_provider_model_slug(atom(), term()) :: map() | nil + defdelegate fetch_provider_model_slug(provider_id, slug), to: Ourocode.Model.ProviderModels + + @doc """ + Validates a provider-specific model slug. + + Built-in slugs must be present in `provider_model_slugs/1`. Tests and future + provider migration paths may pass `allow_custom?: true` to store a nonblank + slug without making it the global default or adding it to the catalog. + """ + @spec validate_provider_model_slug(atom(), term(), keyword()) :: + {:ok, String.t()} + | {:error, :unknown_provider | :invalid_slug | :blank_slug | :unknown_slug} + defdelegate validate_provider_model_slug(provider_id, slug, opts \\ []), + to: Ourocode.Model.ProviderModels + defp codex_model(signed_in?) do %Model{ id: :codex, @@ -107,7 +137,7 @@ defmodule Ourocode.Model.Catalog do } end - defp cli_models(which) do + defp cli_models(which, cli_stream) do Ourocode.Model.Cli.specs() |> Map.keys() |> Enum.sort() @@ -128,7 +158,7 @@ defmodule Ourocode.Model.Catalog do _none -> prompt end - Ourocode.Model.Cli.stream(id, prompt, Keyword.put(opts, :which, which), on_chunk) + cli_stream.(id, prompt, Keyword.put(opts, :which, which), on_chunk) end } end) diff --git a/lib/ourocode/model/cli.ex b/lib/ourocode/model/cli.ex index 58b7bf1..80a6547 100644 --- a/lib/ourocode/model/cli.ex +++ b/lib/ourocode/model/cli.ex @@ -37,6 +37,21 @@ defmodule Ourocode.Model.Cli do end end + @doc false + @spec runner_command( + String.t(), + [String.t()], + {:unix | :win32, atom()}, + (String.t() -> String.t() | nil) + ) :: {String.t(), [String.t()]} + def runner_command(path, args, os_type \\ :os.type(), which \\ &System.find_executable/1) + + def runner_command(path, args, {:win32, _name}, _which), do: {path, args} + + def runner_command(path, args, _os_type, which) when is_function(which, 1) do + {which.("sh") || "/bin/sh", ["-c", ~s(exec "$0" "$@" delay = Keyword.get(opts, :retry_base_delay_ms, @retry_base_delay_ms) - run_with_retry(id, path, args(id, prompt, nil), on_chunk, delay, 1) + run_with_retry(id, path, args(id, prompt, nil), on_chunk, delay, 1, run) end end - defp run_with_retry(id, path, args, on_chunk, delay, attempt) do + defp run_with_retry(id, path, args, on_chunk, delay, attempt, run) do emitted = :counters.new(1, []) counted_chunk = fn chunk -> @@ -67,11 +83,11 @@ defmodule Ourocode.Model.Cli do on_chunk.(chunk) end - case run(id, path, args, counted_chunk) do + case run.(id, path, args, counted_chunk) do {:error, {:exit, _status}} = error -> if :counters.get(emitted, 1) == 0 and attempt < @max_attempts do Process.sleep(delay * Integer.pow(2, attempt - 1)) - run_with_retry(id, path, args, on_chunk, delay, attempt + 1) + run_with_retry(id, path, args, on_chunk, delay, attempt + 1, run) else error end @@ -82,18 +98,15 @@ defmodule Ourocode.Model.Cli do end defp run(id, path, args, on_chunk) do - # Spawn through `sh -c 'exec "$0" "$@" - "user: #{user}\nassistant: #{assistant}" + "user: #{normalize_newlines(user)}\nassistant: #{normalize_newlines(assistant)}" end) elision = if elided > 0, do: "[#{elided} earlier turn(s) elided]\n\n", else: "" - """ - ## Conversation so far (replayed by the ourocode harness) - #{elision}#{transcript} - - ## Current message - #{prompt} - """ + IO.iodata_to_binary([ + "## Conversation so far (replayed by the ourocode harness)\n", + elision, + transcript, + "\n\n## Current message\n", + normalize_newlines(prompt), + "\n" + ]) end @doc """ @@ -148,6 +149,12 @@ defmodule Ourocode.Model.Conversation do utf8_prefix(text, @turn_cap_bytes) <> "\n...[truncated]" end + defp normalize_newlines(text) do + text + |> String.replace("\r\n", "\n") + |> String.replace("\r", "\n") + end + # A byte cut lands at most 3 bytes inside a UTF-8 sequence; after that the # content was not valid UTF-8 to begin with and is kept as cut. defp utf8_prefix(bin, limit), do: trim_invalid(binary_part(bin, 0, limit), 3) diff --git a/lib/ourocode/model/profile.ex b/lib/ourocode/model/profile.ex index 5d754a9..7bf404d 100644 --- a/lib/ourocode/model/profile.ex +++ b/lib/ourocode/model/profile.ex @@ -136,13 +136,20 @@ defmodule Ourocode.Model.Profile do defp runtime_name(_label, model_label), do: short_model_label(model_label) defp pick_model(models, candidates, active_model, opts) do - ready_candidate(models, candidates) || + ready_active_candidate(active_model, candidates) || + ready_candidate(models, candidates) || ready_active(active_model) || selectable_candidate(models, candidates) || active_model(active_model) || Keyword.get_lazy(opts, :default_model, &Catalog.default/0) end + defp ready_active_candidate(%Model{id: id} = model, candidates) do + if id in candidates and Model.ready?(model), do: model + end + + defp ready_active_candidate(_model, _candidates), do: nil + defp ready_candidate(models, candidates) do Enum.find_value(candidates, fn id -> case Catalog.fetch(models, id) do diff --git a/lib/ourocode/model/provider_models.ex b/lib/ourocode/model/provider_models.ex new file mode 100644 index 0000000..ac62421 --- /dev/null +++ b/lib/ourocode/model/provider_models.ex @@ -0,0 +1,90 @@ +defmodule Ourocode.Model.ProviderModels do + @moduledoc """ + Provider-specific model slug catalog. + + Backend providers and provider model slugs are separate UI choices. This + module keeps slug validation out of the backend discovery catalog. + """ + + @codex_model_slugs [ + %{provider_id: :codex, slug: "gpt-5.5", label: "gpt-5.5", default?: true}, + %{provider_id: :codex, slug: "gpt-5.3-codex", label: "gpt-5.3-codex", default?: false} + ] + + @provider_model_slugs %{codex: @codex_model_slugs} + + @doc "Provider-specific model slugs available for a backend provider." + @spec provider_model_slugs(atom()) :: [map()] + def provider_model_slugs(provider_id) when is_atom(provider_id), + do: Map.get(@provider_model_slugs, provider_id, []) + + def provider_model_slugs(_provider_id), do: [] + + @doc "Built-in default model slug for a backend provider." + @spec default_provider_model_slug(atom()) :: String.t() | nil + def default_provider_model_slug(provider_id) when is_atom(provider_id) do + provider_id + |> provider_model_slugs() + |> Enum.find(&Map.get(&1, :default?, false)) + |> case do + %{slug: slug} -> slug + nil -> nil + end + end + + def default_provider_model_slug(_provider_id), do: nil + + @doc "Looks up a provider-specific model slug after normalizing user input." + @spec fetch_provider_model_slug(atom(), term()) :: map() | nil + def fetch_provider_model_slug(provider_id, slug) when is_atom(provider_id) do + with {:ok, normalized} <- normalize_provider_model_slug(slug) do + Enum.find(provider_model_slugs(provider_id), &(&1.slug == normalized)) + else + _error -> nil + end + end + + def fetch_provider_model_slug(_provider_id, _slug), do: nil + + @doc """ + Validates a provider-specific model slug. + + Built-in slugs must be present in `provider_model_slugs/1`. Tests and future + provider migration paths may pass `allow_custom?: true` to store a nonblank + slug without making it the global default or adding it to the catalog. + """ + @spec validate_provider_model_slug(atom(), term(), keyword()) :: + {:ok, String.t()} + | {:error, :unknown_provider | :invalid_slug | :blank_slug | :unknown_slug} + def validate_provider_model_slug(provider_id, slug, opts \\ []) + + def validate_provider_model_slug(provider_id, slug, opts) when is_atom(provider_id) do + with {:provider, [_ | _]} <- {:provider, provider_model_slugs(provider_id)}, + {:ok, normalized} <- normalize_provider_model_slug(slug) do + cond do + fetch_provider_model_slug(provider_id, normalized) -> + {:ok, normalized} + + Keyword.get(opts, :allow_custom?, false) -> + {:ok, normalized} + + true -> + {:error, :unknown_slug} + end + else + {:provider, []} -> {:error, :unknown_provider} + {:error, reason} -> {:error, reason} + end + end + + def validate_provider_model_slug(_provider_id, _slug, _opts), do: {:error, :unknown_provider} + + defp normalize_provider_model_slug(slug) when is_binary(slug) do + case String.trim(slug) do + "" -> {:error, :blank_slug} + normalized -> {:ok, normalized} + end + end + + defp normalize_provider_model_slug(_slug), do: {:error, :invalid_slug} +end diff --git a/lib/ourocode/provider/codex/client.ex b/lib/ourocode/provider/codex/client.ex index 325c27b..8af120b 100644 --- a/lib/ourocode/provider/codex/client.ex +++ b/lib/ourocode/provider/codex/client.ex @@ -13,7 +13,32 @@ defmodule Ourocode.Provider.Codex.Client do alias Ourocode.Provider.Codex.Responses @endpoint "https://chatgpt.com/backend-api/codex/responses" - @default_model "gpt-5.3-codex" + @default_model "gpt-5.5" + + @doc "Returns the ChatGPT Codex model used when no explicit model is passed." + @spec default_model() :: String.t() + def default_model, do: env_model() || @default_model + + @doc """ + Builds the OpenAI Responses API request body using stream model precedence. + + Precedence is explicit unmarked `opts[:model]`, `OUROCODE_CODEX_MODEL`, + session/config model (`opts[:model]` with `model_source: :session` or + `opts[:configured_model]`), then the built-in Codex fallback. + """ + @spec stream_request_body(String.t(), keyword(), String.t()) :: map() + def stream_request_body(prompt, opts, instructions) + when is_binary(prompt) and is_list(opts) and is_binary(instructions) do + model = stream_model(opts) + + case Keyword.get(opts, :input) do + input when is_list(input) and input != [] -> + Responses.request_body_for_input(input, model, instructions) + + _none -> + request_body(prompt, model, instructions) + end + end @doc """ Streams `prompt`, invoking `on_chunk.(text)` for each output delta. @@ -27,19 +52,11 @@ defmodule Ourocode.Provider.Codex.Client do case Codex.authorization() do {:ok, %{access: access, account_id: account_id}} -> session_id = Keyword.get(opts, :session_id, "ourocode-main") - model = Keyword.get(opts, :model, @default_model) instructions = Keyword.get(opts, :instructions, Ourocode.Prompt.system()) # `opts[:input]` carries prepared multi-turn input items (history + # current message); without it the prompt is a single user turn. - body = - case Keyword.get(opts, :input) do - input when is_list(input) and input != [] -> - Responses.request_body_for_input(input, model, instructions) - - _none -> - request_body(prompt, model, instructions) - end + body = stream_request_body(prompt, opts, instructions) headers = httpc_headers(Codex.api_headers(access, account_id, session_id)) @@ -144,6 +161,41 @@ defmodule Ourocode.Provider.Codex.Client do Enum.map(headers, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end) end + defp stream_model(opts) do + opts + |> model_candidates() + |> Enum.find_value(&present_model/1) + end + + defp model_candidates(opts) do + if Keyword.get(opts, :model_source) == :session do + [ + env_model(), + Keyword.get(opts, :model), + Keyword.get(opts, :configured_model), + @default_model + ] + else + [ + Keyword.get(opts, :model), + env_model(), + Keyword.get(opts, :configured_model), + @default_model + ] + end + end + + defp env_model, do: present_model(System.get_env("OUROCODE_CODEX_MODEL")) + + defp present_model(model) when is_binary(model) do + case String.trim(model) do + "" -> nil + trimmed -> trimmed + end + end + + defp present_model(_model), do: nil + defp truncate(body) when is_binary(body), do: String.slice(body, 0, 300) defp truncate(_body), do: "" end diff --git a/lib/ourocode/terminal/command_action_dispatcher.ex b/lib/ourocode/terminal/command_action_dispatcher.ex index 6fd8793..2e05f6f 100644 --- a/lib/ourocode/terminal/command_action_dispatcher.ex +++ b/lib/ourocode/terminal/command_action_dispatcher.ex @@ -28,7 +28,7 @@ defmodule Ourocode.Terminal.CommandActionDispatcher do CommandPreflightCommands.render(action, command_event, state, registry) CommandModelCommands.handles?(action) -> - CommandModelCommands.render(action, state) + CommandModelCommands.render(action, command_event, state) CommandStatusCommands.handles?(action) -> CommandStatusCommands.render(action, state) diff --git a/lib/ourocode/terminal/command_discovery_commands.ex b/lib/ourocode/terminal/command_discovery_commands.ex index cabc825..80ae587 100644 --- a/lib/ourocode/terminal/command_discovery_commands.ex +++ b/lib/ourocode/terminal/command_discovery_commands.ex @@ -14,7 +14,7 @@ defmodule Ourocode.Terminal.CommandDiscoveryCommands do ] @command_slashes ~w( /help /commands /verify /agents /mcp /config /theme /sandbox /sessions /resume - /model /login /plugins /approve /cancel /exit + /provider /model /login /plugins /approve /cancel /exit ) @type action :: :show_help | :show_commands | :show_skills | :show_capabilities @@ -108,7 +108,7 @@ defmodule Ourocode.Terminal.CommandDiscoveryCommands do defp help_entries(entries) do wanted = - ~w(/help /commands /agents /mcp /config /theme /verify /sandbox /sessions /model /login /cancel) + ~w(/help /commands /agents /mcp /config /theme /verify /sandbox /sessions /provider /model /login /cancel) by_slash = Map.new(entries, &{&1.slash, &1}) diff --git a/lib/ourocode/terminal/command_model_commands.ex b/lib/ourocode/terminal/command_model_commands.ex index 7d51eed..da64f42 100644 --- a/lib/ourocode/terminal/command_model_commands.ex +++ b/lib/ourocode/terminal/command_model_commands.ex @@ -1,54 +1,154 @@ defmodule Ourocode.Terminal.CommandModelCommands do @moduledoc """ - Product-facing model picker/status renderer for `/model`. + Product-facing provider and model command renderers. """ alias Ourocode.Model alias Ourocode.Model.Catalog - alias Ourocode.Terminal.WorkspaceText + alias Ourocode.Terminal.{TuiState, WorkspaceText} - @actions [:select_model] + @actions [:select_provider, :show_model_commands] @spec handles?(term()) :: boolean() def handles?(action), do: action in @actions - @spec render(:select_model, map()) :: {:ok, map()} - def render(:select_model, state) do + @spec render(:select_provider | :show_model_commands, map()) :: {:ok, map()} + def render(action, state), do: render(action, %{args: []}, state) + + @spec render(:select_provider | :show_model_commands, map(), map()) :: + {:ok, map()} | {:error, term()} + def render(:select_provider, _command_event, state) do models = Catalog.list() default = Catalog.default() - workspace = workspace(models, default) + workspace = provider_workspace(models, default) IO.puts(state.output, WorkspaceText.render(workspace)) {:ok, %{status: :rendered, workspace: workspace, count: length(models)}} end - defp workspace(models, default) do + def render(:show_model_commands, command_event, state) do + case Map.get(command_event, :args, []) do + [] -> + render_model_list(state) + + [slug] -> + select_model_slug(slug, state) + + args -> + IO.puts(state.output, "model: use /model with exactly one slug") + {:error, {:invalid_model_slug_args, args}} + end + end + + defp render_model_list(state) do + provider_id = active_provider_id(state) + active_slug = active_provider_model_slug(state, provider_id) + workspace = model_command_workspace(provider_id, active_slug) + + IO.puts(state.output, WorkspaceText.render(workspace)) + status = if workspace.records == [], do: :empty, else: :rendered + {:ok, %{status: status, workspace: workspace, count: length(workspace.records)}} + end + + defp select_model_slug(slug, state) do + provider_id = active_provider_id(state) + + case fetch_tui_state(state) do + nil -> + IO.puts(state.output, "model: cannot persist #{slug}; no active TUI state is available") + {:error, :model_state_unavailable} + + tui_state -> + case TuiState.put_provider_model_slug(tui_state, provider_id, slug) do + :ok -> + normalized = TuiState.provider_model_slug(tui_state, provider_id) + IO.puts(state.output, "model: #{normalized} selected for #{provider_id}") + {:ok, %{status: :selected, provider_id: provider_id, slug: normalized}} + + {:error, reason} -> + IO.puts(state.output, model_slug_error(provider_id, slug, reason)) + {:error, {:invalid_model_slug, reason}} + end + end + end + + defp provider_workspace(models, default) do records = models - |> Enum.map(&model_record(&1, default)) + |> Enum.map(&provider_record(&1, default)) %{ - kind: "model", - title: "Models", - status: model_status(records), + kind: "provider", + title: "Providers", + status: provider_status(records), selected: default_record_id(default), records: records, detail: selected_detail(records, default_record_id(default)), actions: [ action("login", "Sign in", "/login", "l"), - action("detect", "Detect models", "ourocode --detect", "d"), + action("detect", "Detect providers", "ourocode --detect", "d"), action("verify", "Run health checks", "/verify", "v") ], shortcuts: ["Up/Dn rows", "Enter select", "type to compose"], - next: "Use /login for ChatGPT, or choose a ready CLI backend." + next: "Use /login for ChatGPT, or choose a ready CLI provider." } end - defp model_record(%Model{} = model, default) do + defp model_command_workspace(provider_id, active_slug) do + slugs = Catalog.provider_model_slugs(provider_id) + records = Enum.map(slugs, &model_slug_record(&1, active_slug)) + selected = selected_model_record_id(active_slug, records) + + %{ + kind: "model", + title: "#{provider_label(provider_id)} models", + status: model_status(provider_id, active_slug, records), + selected: selected, + records: records, + detail: model_detail(provider_id, records, selected), + actions: [ + action("provider", "Choose provider", "/provider", "p"), + action("verify", "Run health checks", "/verify", "v") + ], + shortcuts: ["type /model ", "use /provider for backends"], + next: model_next(provider_id, records) + } + end + + defp model_slug_record(%{slug: slug, label: label} = model, active_slug) do + active? = slug == active_slug + + %{ + id: "model:" <> slug, + title: label, + state: if(active?, do: "active", else: "available"), + health: if(Map.get(model, :default?, false), do: "default", else: "ready"), + fields: %{ + slug: slug, + command: "/model " <> slug, + provider: Atom.to_string(Map.fetch!(model, :provider_id)) + }, + actions: [action("select", "Select model", "/model " <> slug, "Enter")] + } + end + + defp no_model_detail(provider_id) do + %{ + id: "model:none", + title: "No model list is available for #{provider_id}", + state: "empty", + fields: %{provider: Atom.to_string(provider_id), command: "/provider"} + } + end + + defp model_detail(provider_id, [], _selected), do: no_model_detail(provider_id) + defp model_detail(_provider_id, records, selected), do: selected_detail(records, selected) + + defp provider_record(%Model{} = model, default) do active? = model.id == default.id %{ - id: model_id(model), + id: provider_id(model), title: model.label, state: if(active?, do: "active", else: model_state(model.status)), health: model_health(model.status), @@ -66,13 +166,79 @@ defmodule Ourocode.Terminal.CommandModelCommands do %{title: "No model detected.", state: "empty", fields: %{}} end - defp model_status(records) do + defp selected_model_record_id(active_slug, records) do + active_id = if is_binary(active_slug), do: "model:" <> active_slug + + cond do + Enum.any?(records, &(Map.get(&1, :id) == active_id)) -> active_id + record = List.first(records) -> record.id + true -> nil + end + end + + defp model_status(_provider_id, active_slug, [_ | _]) when is_binary(active_slug), + do: "active #{active_slug}" + + defp model_status(_provider_id, _active_slug, [_ | _]), do: "available" + defp model_status(provider_id, _active_slug, []), do: "no models for #{provider_id}" + + defp model_next(_provider_id, [_ | _]), + do: "Use /model to set the active model for this provider." + + defp model_next(provider_id, []), + do: + "No model list is available for #{provider_id}. Use /provider to choose a provider with models." + + defp active_provider_id(state) do + cond do + is_pid(fetch_tui_state(state)) -> + TuiState.model_id(fetch_tui_state(state)) || Catalog.default().id + + is_atom(get_in(state, [:active_model, :id])) -> + get_in(state, [:active_model, :id]) + + true -> + Catalog.default().id + end + end + + defp active_provider_model_slug(state, provider_id) do + case fetch_tui_state(state) do + pid when is_pid(pid) -> TuiState.provider_model_slug(pid, provider_id) + _other -> Catalog.default_provider_model_slug(provider_id) + end + end + + defp fetch_tui_state(state), do: Map.get(state, :tui_state) + + defp provider_label(provider_id) do + provider_id + |> Atom.to_string() + |> String.replace("_api", "") + |> String.replace("_", " ") + |> String.capitalize() + end + + defp model_slug_error(provider_id, slug, :unknown_slug), + do: "model: unknown slug #{slug} for #{provider_id}. Run /model to list available slugs." + + defp model_slug_error(provider_id, _slug, :unknown_provider), + do: + "model: no model list is available for #{provider_id}. Use /provider to choose another provider." + + defp model_slug_error(_provider_id, _slug, :blank_slug), + do: "model: blank slug. Use /model ." + + defp model_slug_error(_provider_id, _slug, :invalid_slug), + do: "model: invalid slug. Use /model ." + + defp provider_status(records) do ready = Enum.count(records, &(Map.get(&1, :health) == "ready")) "#{ready} ready" end - defp model_id(%Model{id: id}), do: "model:" <> Atom.to_string(id) - defp default_record_id(%Model{} = model), do: model_id(model) + defp provider_id(%Model{id: id}), do: "provider:" <> Atom.to_string(id) + defp default_record_id(%Model{} = model), do: provider_id(model) defp model_kind(:oauth), do: "ChatGPT sign-in" defp model_kind(:cli), do: "local CLI" defp model_kind(kind), do: Atom.to_string(kind) @@ -98,10 +264,13 @@ defmodule Ourocode.Terminal.CommandModelCommands do end defp model_actions(%Model{status: :ready}) do - [action("select", "Select model", "/model", "Enter"), action("verify", "Verify", "/verify", "v")] + [ + action("select", "Select provider", "/provider", "Enter"), + action("verify", "Verify", "/verify", "v") + ] end - defp model_actions(_model), do: [action("detect", "Detect models", "ourocode --detect", "d")] + defp model_actions(_model), do: [action("detect", "Detect providers", "ourocode --detect", "d")] defp action(id, label, command, shortcut) do %{id: id, label: label, command: command, shortcut: shortcut, enabled: true} diff --git a/lib/ourocode/terminal/event_loop_state.ex b/lib/ourocode/terminal/event_loop_state.ex index 4856c6f..471461e 100644 --- a/lib/ourocode/terminal/event_loop_state.ex +++ b/lib/ourocode/terminal/event_loop_state.ex @@ -57,6 +57,7 @@ defmodule Ourocode.Terminal.EventLoopState do focus_events: [], focus_state: Map.get(options, :focus_state, FocusState.new()), pane_model: Map.get(options, :pane_model, FocusNavigation.default_pane_model()), + tui_state: Map.get(options, :tui_state), keyboard_focus_bindings: FocusNavigation.keyboard_focus_bindings(options), recoverable_errors: [] } diff --git a/lib/ourocode/terminal/model_status.ex b/lib/ourocode/terminal/model_status.ex index 0c78394..b59f6bf 100644 --- a/lib/ourocode/terminal/model_status.ex +++ b/lib/ourocode/terminal/model_status.ex @@ -37,4 +37,24 @@ defmodule Ourocode.Terminal.ModelStatus do do: {"model: #{label} #{hint}", :dim} def auth_label(_model), do: {"no model - /model", :dim} + + @doc "Provider/model footer label with optional last-turn latency." + @spec hud_status(Model.t(), String.t() | nil, non_neg_integer() | nil) :: String.t() + def hud_status(%Model{} = model, provider_model_slug, last_turn_ms) do + label = hud_label(model, provider_model_slug) + + case last_turn_ms do + ms when is_integer(ms) -> "#{label} · #{format_latency(ms)}" + _none -> label + end + end + + @spec hud_label(Model.t(), String.t() | nil) :: String.t() + def hud_label(%Model{label: label}, slug) when is_binary(slug) and slug != "", + do: "provider: #{label} model: #{slug}" + + def hud_label(%Model{label: label}, _slug), do: "provider: #{label}" + + defp format_latency(ms) when ms < 1_000, do: "#{ms}ms" + defp format_latency(ms), do: "#{Float.round(ms / 1_000, 1)}s" end diff --git a/lib/ourocode/terminal/tui.ex b/lib/ourocode/terminal/tui.ex index e6bebdb..c92d9c0 100644 --- a/lib/ourocode/terminal/tui.ex +++ b/lib/ourocode/terminal/tui.ex @@ -65,6 +65,7 @@ defmodule Ourocode.Terminal.Tui do |> Map.put(:output, output) |> Map.put(:read_line, read_line) |> Map.put(:prompt, @prompt) + |> Map.put(:tui_state, state) |> attach_live_turn_feedback(state) |> attach_active_model_provider(state) diff --git a/lib/ourocode/terminal/tui_chat.ex b/lib/ourocode/terminal/tui_chat.ex index c6b44de..2405e52 100644 --- a/lib/ourocode/terminal/tui_chat.ex +++ b/lib/ourocode/terminal/tui_chat.ex @@ -54,9 +54,21 @@ defmodule Ourocode.Terminal.TuiChat do model_prompt = maybe_paused_interview_prompt(result, prompt) conversation = conversation(result, state) stream_opts = [session_id: session_id(result), history: conversation] + stream_opts = put_configured_model(stream_opts, model, state) started = System.monotonic_time(:millisecond) - case run_turn(model, model_prompt, stream_opts, started, result, output, state, cols, rows, redraw) do + case run_turn( + model, + model_prompt, + stream_opts, + started, + result, + output, + state, + cols, + rows, + redraw + ) do {:ok, full} -> # Remember the exchange as the user typed it (not the paused-interview # wrapper) so follow-up turns read as a clean dialogue. @@ -85,7 +97,18 @@ defmodule Ourocode.Terminal.TuiChat do # when no chunk has arrived yet, chunks render as they stream in, a bare # Esc or Ctrl+C cancels the turn, and keystrokes typed during the turn are # re-buffered for the input loop instead of being dropped. - defp run_turn(model, model_prompt, stream_opts, started, result, output, state, cols, rows, redraw) do + defp run_turn( + model, + model_prompt, + stream_opts, + started, + result, + output, + state, + cols, + rows, + redraw + ) do caller = self() {pid, ref} = @@ -153,6 +176,20 @@ defmodule Ourocode.Terminal.TuiChat do end end + defp put_configured_model(opts, %Model{id: provider_id}, state) when is_atom(provider_id) do + case TuiState.provider_model_slug(state, provider_id) do + slug when is_binary(slug) and slug != "" -> + opts + |> Keyword.put(:model, slug) + |> Keyword.put(:model_source, :session) + + _none -> + opts + end + end + + defp put_configured_model(opts, _model, _state), do: opts + # A bare Esc or a Ctrl+C cancels the turn; longer escape sequences (arrow # keys and friends) are ordinary input and must not abort the stream. defp cancel_request?(data), do: data == <<27>> or String.contains?(data, <<3>>) @@ -220,5 +257,5 @@ defmodule Ourocode.Terminal.TuiChat do "ourocode-main" end - defp log(output, text), do: IO.puts(output, text) + defp log(output, text), do: IO.puts(output, String.replace_invalid(text, "")) end diff --git a/lib/ourocode/terminal/tui_frame.ex b/lib/ourocode/terminal/tui_frame.ex index 72cbb03..5f6064a 100644 --- a/lib/ourocode/terminal/tui_frame.ex +++ b/lib/ourocode/terminal/tui_frame.ex @@ -10,6 +10,7 @@ defmodule Ourocode.Terminal.TuiFrame do InterviewPanel.QuestionLedger, LiveResult, LiveTurnActivity, + ModelStatus, RendererInterview, RuntimeSplit, Screen, @@ -177,17 +178,11 @@ defmodule Ourocode.Terminal.TuiFrame do # first token arrived (a CLI subprocess is multi-second; a direct API call # is ~1s). Latency shows only after a real turn has been timed. defp model_status(state) do - label = TuiModelSelection.active_model(state, 2_000).label - - case TuiState.last_turn_ms(state) do - ms when is_integer(ms) -> "#{label} · #{format_latency(ms)}" - _none -> label - end + model = TuiModelSelection.active_model(state, 2_000) + slug = TuiState.provider_model_slug(state, model.id) + ModelStatus.hud_status(model, slug, TuiState.last_turn_ms(state)) end - defp format_latency(ms) when ms < 1_000, do: "#{ms}ms" - defp format_latency(ms), do: "#{Float.round(ms / 1_000, 1)}s" - defp interview_block_lines(result, nav, tick) do Ourocode.Terminal.InterviewPanel.interview_block_lines(result, nav, tick) end diff --git a/lib/ourocode/terminal/tui_model_selection.ex b/lib/ourocode/terminal/tui_model_selection.ex index 145d3a1..ef0992e 100644 --- a/lib/ourocode/terminal/tui_model_selection.ex +++ b/lib/ourocode/terminal/tui_model_selection.ex @@ -1,6 +1,6 @@ defmodule Ourocode.Terminal.TuiModelSelection do @moduledoc """ - Model selection, active-model cache, and model-picker actions for the raw TUI. + Provider selection, active-model cache, and provider-picker actions for the raw TUI. """ alias Ourocode.Model @@ -59,7 +59,7 @@ defmodule Ourocode.Terminal.TuiModelSelection do Model.ready?(selected_model) -> TuiState.put_model_id(state, selected_model.id) - log.(output, "model: #{selected_model.label}") + log.(output, "provider: #{selected_model.label}") redraw.(result, output, state, "", cols, rows) Model.needs_auth?(selected_model) -> diff --git a/lib/ourocode/terminal/tui_state.ex b/lib/ourocode/terminal/tui_state.ex index 9ecd133..7b9fd23 100644 --- a/lib/ourocode/terminal/tui_state.ex +++ b/lib/ourocode/terminal/tui_state.ex @@ -12,6 +12,8 @@ defmodule Ourocode.Terminal.TuiState do WorkspaceNavigation } + alias Ourocode.Model.Catalog + @double_press_ms 800 @history_limit 50 @@ -143,6 +145,43 @@ defmodule Ourocode.Terminal.TuiState do @spec put_model_id(pid(), atom()) :: :ok def put_model_id(state, id), do: Agent.update(state, &%{&1 | model_id: id, model_cache: nil}) + @spec model_id(pid()) :: atom() | nil + def model_id(state), do: Agent.get(state, &Map.get(&1, :model_id)) + + @spec provider_model_slug(pid(), atom()) :: String.t() | nil + def provider_model_slug(state, provider_id) when is_atom(provider_id) do + Agent.get(state, fn current -> + current + |> Map.get(:model_slug_by_provider, %{}) + |> Map.get(provider_id, Catalog.default_provider_model_slug(provider_id)) + end) + end + + def provider_model_slug(_state, _provider_id), do: nil + + @spec put_provider_model_slug(pid(), atom(), term(), keyword()) :: + :ok | {:error, :unknown_provider | :invalid_slug | :blank_slug | :unknown_slug} + def put_provider_model_slug(state, provider_id, slug, opts \\ []) + + def put_provider_model_slug(state, provider_id, slug, opts) when is_atom(provider_id) do + case Catalog.validate_provider_model_slug(provider_id, slug, opts) do + {:ok, normalized} -> + Agent.update(state, fn current -> + slugs = + current + |> Map.get(:model_slug_by_provider, %{}) + |> Map.put(provider_id, normalized) + + %{current | model_slug_by_provider: slugs, model_cache: nil} + end) + + {:error, reason} -> + {:error, reason} + end + end + + def put_provider_model_slug(_state, _provider_id, _slug, _opts), do: {:error, :unknown_provider} + @doc "Time to first token of the last completed chat turn, in ms." @spec last_turn_ms(pid()) :: non_neg_integer() | nil def last_turn_ms(state), do: Agent.get(state, &Map.get(&1, :last_turn_ms)) diff --git a/lib/ourocode/terminal/tui_state_initial.ex b/lib/ourocode/terminal/tui_state_initial.ex index e1a9b86..7c6bd19 100644 --- a/lib/ourocode/terminal/tui_state_initial.ex +++ b/lib/ourocode/terminal/tui_state_initial.ex @@ -28,6 +28,7 @@ defmodule Ourocode.Terminal.TuiStateInitial do tick: 0, size: {120, 40}, model_id: nil, + model_slug_by_provider: %{}, model_cache: nil, scroll: 0, workspace: nil, diff --git a/lib/ourocode/terminal/tui_submit.ex b/lib/ourocode/terminal/tui_submit.ex index 51d1d1d..375a17f 100644 --- a/lib/ourocode/terminal/tui_submit.ex +++ b/lib/ourocode/terminal/tui_submit.ex @@ -19,8 +19,8 @@ defmodule Ourocode.Terminal.TuiSubmit do def handle("", _result, _output, _state, _cols, _rows, _callbacks), do: :continue def handle("/login", result, output, state, cols, rows, callbacks) do - # Open the backend picker so the user chooses what to sign into; picking - # a not-ready OAuth backend (Codex or Claude) starts its login. + # Open the provider picker so the user chooses what to sign into; picking + # a not-ready OAuth provider (Codex or Claude) starts its login. TuiState.put_mode(state, :model) TuiState.put_pidx(state, 0) redraw(callbacks).(result, output, state, "", cols, rows) @@ -66,7 +66,7 @@ defmodule Ourocode.Terminal.TuiSubmit do def handle("/quit", _result, _output, _state, _cols, _rows, _callbacks), do: :exit def handle(line, result, output, state, cols, rows, callbacks) - when line in ["/model", "/models"] do + when line in ["/provider", "/providers"] do TuiState.put_mode(state, :model) TuiState.put_pidx(state, 0) redraw(callbacks).(result, output, state, "", cols, rows) diff --git a/test/ourocode/command/registry/builtin_test.exs b/test/ourocode/command/registry/builtin_test.exs index 40be4b2..b0b2fe2 100644 --- a/test/ourocode/command/registry/builtin_test.exs +++ b/test/ourocode/command/registry/builtin_test.exs @@ -31,6 +31,7 @@ defmodule Ourocode.Command.Registry.BuiltinTest do "/sandbox", "/sessions", "/config", + "/provider", "/model", "/theme", "/login", @@ -101,6 +102,19 @@ defmodule Ourocode.Command.Registry.BuiltinTest do assert preflight.aliases == [] assert preflight.summary == "Preview what a command would do before executing it." + provider = Enum.find(entries, &(&1.slash == "/provider")) + model = Enum.find(entries, &(&1.slash == "/model")) + + assert provider.aliases == ["/providers"] + assert provider.run_spec.action == :select_provider + assert provider.summary =~ "provider" + assert provider.summary =~ "backend" + + assert model.aliases == ["/models"] + assert model.run_spec.action == :show_model_commands + assert model.summary =~ "model" + refute model.summary =~ "backend" + assert pane.args == [ %{name: "pane_id", required?: true, description: "Pane or work item id"} ] diff --git a/test/ourocode/model/catalog_test.exs b/test/ourocode/model/catalog_test.exs index 8c0e482..0b648a7 100644 --- a/test/ourocode/model/catalog_test.exs +++ b/test/ourocode/model/catalog_test.exs @@ -74,6 +74,32 @@ defmodule Ourocode.Model.CatalogTest do assert Model.ready?(inn) end + test "codex model slug catalog is separate from provider backend ids" do + slugs = Catalog.provider_model_slugs(:codex) + + assert Enum.map(slugs, & &1.provider_id) == [:codex, :codex] + assert Enum.map(slugs, & &1.slug) == ["gpt-5.5", "gpt-5.3-codex"] + assert Catalog.default_provider_model_slug(:codex) == "gpt-5.5" + assert Catalog.fetch_provider_model_slug(:codex, " gpt-5.5 ").slug == "gpt-5.5" + refute Catalog.fetch_provider_model_slug(:codex, "codex") + end + + test "provider model slug validation accepts explicit custom test slugs only when requested" do + assert {:ok, "gpt-5.5"} = Catalog.validate_provider_model_slug(:codex, " gpt-5.5 ") + + assert {:ok, "test-codex-model"} = + Catalog.validate_provider_model_slug(:codex, " test-codex-model ", + allow_custom?: true + ) + + assert {:error, :blank_slug} = Catalog.validate_provider_model_slug(:codex, " ") + assert {:error, :invalid_slug} = Catalog.validate_provider_model_slug(:codex, 123) + assert {:error, :unknown_provider} = Catalog.validate_provider_model_slug(:unknown, "gpt-5.5") + + assert {:error, :unknown_slug} = + Catalog.validate_provider_model_slug(:codex, "test-codex-model") + end + test "default can use remaining non-agent CLIs, else falls back to codex" do with_cli = Catalog.default( @@ -169,31 +195,48 @@ defmodule Ourocode.Model.CatalogTest do test "cli runner replays conversation history into the one-shot prompt" do alias Ourocode.Model.Conversation - dir = - Path.join(System.tmp_dir!(), "ourocode-catalog-cli-#{System.unique_integer([:positive])}") - - on_exit(fn -> File.rm_rf!(dir) end) - File.mkdir_p!(dir) + parent = self() + which = fn bin -> if bin == "gemini", do: "gemini-test-bin", else: nil end - # Echoes its prompt argument back, standing in for `gemini -p `. - script = Path.join(dir, "gemini") - File.write!(script, "#!/bin/sh\nprintf '%s' \"$2\"\n") - File.chmod!(script, 0o755) + cli_stream = fn id, prompt, opts, on_chunk -> + send(parent, {:cli_stream, id, prompt, opts}) + on_chunk.(prompt) + {:ok, prompt} + end - which = fn bin -> if bin == "gemini", do: script, else: nil end - gemini = Catalog.fetch(Catalog.list(codex_signed_in: false, which: which), :gemini) + gemini = + Catalog.fetch( + Catalog.list(codex_signed_in: false, which: which, cli_stream: cli_stream), + :gemini + ) conversation = Conversation.add_turn(Conversation.new(), "first question", "first answer") assert {:ok, echoed} = - Model.stream(gemini, "follow-up", [history: conversation], fn _chunk -> :ok end) + Model.stream(gemini, "follow-up", [history: conversation], fn chunk -> + send(parent, {:chunk, :follow_up, chunk}) + end) + + assert_received {:cli_stream, :gemini, ^echoed, opts} + refute Keyword.has_key?(opts, :history) + assert Keyword.fetch!(opts, :which).("gemini") == "gemini-test-bin" + assert_received {:chunk, :follow_up, ^echoed} + + echoed = String.replace(echoed, "\r\n", "\n") assert echoed =~ "user: first question\nassistant: first answer" assert echoed =~ "## Current message\nfollow-up" # An empty conversation leaves the first turn byte-identical. assert {:ok, "plain"} = - Model.stream(gemini, "plain", [history: Conversation.new()], fn _chunk -> :ok end) + Model.stream(gemini, "plain", [history: Conversation.new()], fn chunk -> + send(parent, {:chunk, :plain, chunk}) + end) + + assert_received {:cli_stream, :gemini, "plain", plain_opts} + refute Keyword.has_key?(plain_opts, :history) + assert Keyword.fetch!(plain_opts, :which).("gemini") == "gemini-test-bin" + assert_received {:chunk, :plain, "plain"} end test "stream dispatches through the model's runner" do diff --git a/test/ourocode/model/cli_test.exs b/test/ourocode/model/cli_test.exs index 1415424..7e71945 100644 --- a/test/ourocode/model/cli_test.exs +++ b/test/ourocode/model/cli_test.exs @@ -11,85 +11,96 @@ defmodule Ourocode.Model.CliTest do refute "--append-system-prompt" in Cli.args(:gemini, "hi", "You are ourocode.") end + test "Windows runner invokes the CLI executable directly without POSIX shell dependency" do + path = "C:/Program Files/Gemini/gemini.exe" + args = ["-p", "hello from a path with spaces"] + + assert Cli.runner_command(path, args, {:win32, :nt}, fn _bin -> + flunk("unexpected shell lookup") + end) == + {path, args} + end + + test "Unix runner keeps the stdin-closing shell wrapper" do + path = "/opt/gemini cli/bin/gemini" + args = ["-p", "hello from a path with spaces"] + + assert Cli.runner_command(path, args, {:unix, :linux}, fn "sh" -> "/usr/bin/sh" end) == + {"/usr/bin/sh", ["-c", ~s(exec "$0" "$@" + :counters.add(calls, 1, 1) - File.chmod!(gemini_path, 0o755) + case :counters.get(calls, 1) do + 1 -> + {:error, {:exit, 1}} + + 2 -> + on_chunk.("hello") + {:ok, "hello"} + end + end assert {:ok, "hello"} = Cli.stream( :gemini, "hello", - [which: fn "gemini" -> gemini_path end, retry_base_delay_ms: 1], + [which: fn "gemini" -> gemini_path end, retry_base_delay_ms: 1, run: run], fn _chunk -> :ok end ) - assert File.exists?(marker) + assert :counters.get(calls, 1) == 2 end test "does not retry once output has reached the renderer" do - tmp_dir = tmp_dir!() - count = Path.join(tmp_dir, "count") - gemini_path = Path.join(tmp_dir, "gemini") - - File.write!(gemini_path, """ - #!/bin/sh - echo run >> "#{count}" - printf 'partial ' - exit 1 - """) + gemini_path = fake_executable_path() + calls = :counters.new(1, []) + parent = self() - File.chmod!(gemini_path, 0o755) + run = fn :gemini, ^gemini_path, ["-p", "hello"], on_chunk -> + :counters.add(calls, 1, 1) + on_chunk.("partial ") + {:error, {:exit, 1}} + end assert {:error, {:exit, 1}} = Cli.stream( :gemini, "hello", - [which: fn "gemini" -> gemini_path end, retry_base_delay_ms: 1], - fn _chunk -> :ok end + [which: fn "gemini" -> gemini_path end, retry_base_delay_ms: 1, run: run], + fn chunk -> send(parent, {:chunk, chunk}) end ) - assert File.read!(count) == "run\n" + assert :counters.get(calls, 1) == 1 + assert_received {:chunk, "partial "} end test "a persistent silent failure surfaces after the retry budget" do - tmp_dir = tmp_dir!() - count = Path.join(tmp_dir, "count") - gemini_path = Path.join(tmp_dir, "gemini") + gemini_path = fake_executable_path() + calls = :counters.new(1, []) - File.write!(gemini_path, """ - #!/bin/sh - echo run >> "#{count}" - exit 7 - """) - - File.chmod!(gemini_path, 0o755) + run = fn :gemini, ^gemini_path, ["-p", "hello"], _on_chunk -> + :counters.add(calls, 1, 1) + {:error, {:exit, 7}} + end assert {:error, {:exit, 7}} = Cli.stream( :gemini, "hello", - [which: fn "gemini" -> gemini_path end, retry_base_delay_ms: 1], + [which: fn "gemini" -> gemini_path end, retry_base_delay_ms: 1, run: run], fn _chunk -> :ok end ) - assert File.read!(count) == "run\nrun\nrun\n" + assert :counters.get(calls, 1) == 3 end - defp tmp_dir! do - tmp_dir = - Path.join(System.tmp_dir!(), "ourocode-cli-test-#{System.unique_integer([:positive])}") - - File.mkdir_p!(tmp_dir) - on_exit(fn -> File.rm_rf(tmp_dir) end) - tmp_dir + defp fake_executable_path do + Path.join(System.tmp_dir!(), "gemini-test-bin-#{System.unique_integer([:positive])}") end end diff --git a/test/ourocode/model/conversation_test.exs b/test/ourocode/model/conversation_test.exs index d805988..9fb07b5 100644 --- a/test/ourocode/model/conversation_test.exs +++ b/test/ourocode/model/conversation_test.exs @@ -27,6 +27,18 @@ defmodule Ourocode.Model.ConversationTest do assert second == "and the tty layer?" end + test "render_prompt normalizes CRLF transcript boundaries without losing content" do + conversation = + Conversation.new() + |> Conversation.add_turn("first line\r\nsecond line", "answer line\r\nnext answer") + + prompt = Conversation.render_prompt(conversation, "current line\r\nnext current") + + refute prompt =~ "\r" + assert prompt =~ "user: first line\nsecond line\nassistant: answer line\nnext answer" + assert prompt =~ "## Current message\ncurrent line\nnext current" + end + test "render_prompt keeps a contiguous recent window under the byte budget" do big = String.duplicate("a", 3_000) @@ -61,6 +73,7 @@ defmodule Ourocode.Model.ConversationTest do |> Conversation.add_turn("다시", "응답") listed = Conversation.to_list(conversation) + assert listed == [ %{"user" => "ping", "assistant" => "pong"}, %{"user" => "다시", "assistant" => "응답"} diff --git a/test/ourocode/model/profile_test.exs b/test/ourocode/model/profile_test.exs index a30ee06..3304437 100644 --- a/test/ourocode/model/profile_test.exs +++ b/test/ourocode/model/profile_test.exs @@ -50,6 +50,22 @@ defmodule Ourocode.Model.ProfileTest do assert profile.llm_backend == "gemini" end + test "prefers the active model instance when it is a ready profile candidate" do + active = model(:codex, "codex fake runner", :ready) + + profile = + Profile.for_route(:pm, + active_model: active, + models: [ + model(:claude_api, "claude api", :unavailable), + model(:codex, "catalog codex", :ready) + ] + ) + + assert profile.model == active + assert profile.model_label == "codex fake runner" + end + test "display label uses product-facing role language" do assert Profile.display_label(%{label: "execute/codex", model_label: "codex (ChatGPT)"}) == "Execute · Codex Runtime" diff --git a/test/ourocode/provider/codex_client_test.exs b/test/ourocode/provider/codex_client_test.exs index f53da14..969c056 100644 --- a/test/ourocode/provider/codex_client_test.exs +++ b/test/ourocode/provider/codex_client_test.exs @@ -1,8 +1,74 @@ defmodule Ourocode.Provider.Codex.ClientTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false alias Ourocode.Provider.Codex.Client + test "default_model uses the ChatGPT Codex supported model name" do + previous = System.get_env("OUROCODE_CODEX_MODEL") + System.delete_env("OUROCODE_CODEX_MODEL") + + on_exit(fn -> + if previous, + do: System.put_env("OUROCODE_CODEX_MODEL", previous), + else: System.delete_env("OUROCODE_CODEX_MODEL") + end) + + assert Client.default_model() == "gpt-5.5" + end + + test "default_model can be overridden for provider migrations" do + previous = System.get_env("OUROCODE_CODEX_MODEL") + System.put_env("OUROCODE_CODEX_MODEL", " custom-codex-model ") + + on_exit(fn -> + if previous, + do: System.put_env("OUROCODE_CODEX_MODEL", previous), + else: System.delete_env("OUROCODE_CODEX_MODEL") + end) + + assert Client.default_model() == "custom-codex-model" + end + + test "stream_request_body resolves explicit env configured and fallback model precedence" do + previous = System.get_env("OUROCODE_CODEX_MODEL") + System.put_env("OUROCODE_CODEX_MODEL", " env-codex-model ") + + on_exit(fn -> + if previous, + do: System.put_env("OUROCODE_CODEX_MODEL", previous), + else: System.delete_env("OUROCODE_CODEX_MODEL") + end) + + assert Client.stream_request_body("hello", [model: " explicit-codex-model "], "sys")[ + "model" + ] == "explicit-codex-model" + + assert Client.stream_request_body("hello", [configured_model: "gpt-5.3-codex"], "sys")[ + "model" + ] == "env-codex-model" + + assert Client.stream_request_body( + "hello", + [model: "gpt-5.3-codex", model_source: :session], + "sys" + )["model"] == "env-codex-model" + + System.put_env("OUROCODE_CODEX_MODEL", " ") + + assert Client.stream_request_body( + "hello", + [model: " gpt-5.3-codex ", model_source: :session], + "sys" + )["model"] == "gpt-5.3-codex" + + assert Client.stream_request_body("hello", [configured_model: " gpt-5.3-codex "], "sys")[ + "model" + ] == "gpt-5.3-codex" + + assert Client.stream_request_body("hello", [configured_model: " "], "sys")["model"] == + "gpt-5.5" + end + test "request_body builds the Responses API user turn" do body = Client.request_body("hello", "gpt-5.3-codex", "sys") diff --git a/test/ourocode/terminal/command_discovery_commands_test.exs b/test/ourocode/terminal/command_discovery_commands_test.exs index a34e7a4..b3b18d4 100644 --- a/test/ourocode/terminal/command_discovery_commands_test.exs +++ b/test/ourocode/terminal/command_discovery_commands_test.exs @@ -51,10 +51,34 @@ defmodule Ourocode.Terminal.CommandDiscoveryCommandsTest do assert text =~ "/theme" refute text =~ "/mcps" assert text =~ "/sandbox" + assert text =~ "/provider" + assert text =~ "Pick the active main-session provider/backend." + assert text =~ "/model" + assert text =~ "Show provider-specific model commands and slug selection status." + refute text =~ "/model Pick the active main-session provider/backend." refute text =~ "[builtin/discovery]" refute text =~ "/wonder-tool" end + test "rendered help exposes provider picker separately from model selection", %{ + registry: registry, + state: state, + output: output + } do + assert {:ok, %{count: count}} = + CommandDiscoveryCommands.render(:show_help, state, registry) + + {_input, text} = StringIO.contents(output) + + assert count > 0 + assert text =~ "common:" + assert text =~ "/provider" + assert text =~ "Pick the active main-session provider/backend." + assert text =~ "/model" + assert text =~ "Show provider-specific model commands and slug selection status." + refute text =~ "/model Pick the active main-session provider/backend." + end + test "render_registry renders entries and returns count", %{ registry: registry, output: output @@ -69,6 +93,8 @@ defmodule Ourocode.Terminal.CommandDiscoveryCommandsTest do assert text =~ "/help" assert text =~ "commands:" refute text =~ "[builtin/discovery]" + assert text =~ "/provider" + assert text =~ "Pick the active main-session provider/backend." end test "render skills filters skill-capable registry sources", %{ diff --git a/test/ourocode/terminal/command_model_commands_test.exs b/test/ourocode/terminal/command_model_commands_test.exs new file mode 100644 index 0000000..8c77da5 --- /dev/null +++ b/test/ourocode/terminal/command_model_commands_test.exs @@ -0,0 +1,168 @@ +defmodule Ourocode.Terminal.CommandModelCommandsTest do + use ExUnit.Case, async: true + + alias Ourocode.Terminal.{CommandHandler, CommandInput, CommandModelCommands, TuiState} + + setup do + {:ok, output} = StringIO.open("") + tui_state = TuiState.start_link() + + on_exit(fn -> + safe_close(output, &StringIO.close/1) + safe_close(tui_state, &Agent.stop/1) + end) + + %{output: output, tui_state: tui_state, state: %{output: output, tui_state: tui_state}} + end + + test "render select_provider shows provider/backend picker surface", %{ + state: state, + output: output + } do + assert {:ok, %{status: :rendered, workspace: workspace, count: count}} = + CommandModelCommands.render(:select_provider, state) + + {_input, text} = StringIO.contents(output) + + assert count > 0 + assert workspace.kind == "provider" + assert workspace.title == "Providers" + assert workspace.next =~ "choose a ready CLI provider" + assert Enum.any?(workspace.records, &String.starts_with?(&1.id, "provider:")) + assert Enum.any?(workspace.actions, &(&1.command == "/login")) + assert Enum.any?(workspace.actions, &(&1.command == "/verify")) + assert text =~ "Providers" + assert text =~ "provider" + assert text =~ "/login" + assert text =~ "/verify" + end + + test "render show_model_commands lists active provider model slugs", %{ + state: state, + tui_state: tui_state, + output: output + } do + TuiState.put_model_id(tui_state, :codex) + + assert {:ok, %{status: :rendered, workspace: workspace, count: 2}} = + CommandModelCommands.render( + :show_model_commands, + %{command: "/model", args: []}, + state + ) + + {_input, text} = StringIO.contents(output) + + assert workspace.kind == "model" + assert workspace.title == "Codex models" + assert workspace.status == "active gpt-5.5" + assert workspace.selected == "model:gpt-5.5" + assert Enum.map(workspace.records, & &1.title) == ["gpt-5.5", "gpt-5.3-codex"] + assert workspace.detail.fields.command == "/model gpt-5.5" + assert Enum.any?(workspace.actions, &(&1.command == "/provider")) + assert workspace.shortcuts == ["type /model ", "use /provider for backends"] + assert workspace.next =~ "Use /model " + + assert text =~ "Codex models" + assert text =~ "gpt-5.5" + assert text =~ "gpt-5.3-codex" + assert text =~ "/provider" + assert text =~ "Use /model " + refute text =~ "Pick the active main-session provider/backend." + end + + test "models alias renders the same active provider model list", %{ + state: state, + tui_state: tui_state, + output: output + } do + TuiState.put_model_id(tui_state, :codex) + + assert {:ok, %{status: :rendered, workspace: workspace}} = + CommandHandler.handle(CommandInput.command_event("/models"), command_state(state)) + + {_input, text} = StringIO.contents(output) + + assert workspace.kind == "model" + assert workspace.selected == "model:gpt-5.5" + assert text =~ "gpt-5.5" + assert text =~ "gpt-5.3-codex" + end + + test "direct codex slug selection persists the provider-specific model slug", %{ + state: state, + tui_state: tui_state, + output: output + } do + TuiState.put_model_id(tui_state, :codex) + + assert {:ok, %{status: :selected, provider_id: :codex, slug: "gpt-5.5"}} = + CommandHandler.handle( + CommandInput.command_event("/model gpt-5.5"), + command_state(state) + ) + + assert TuiState.provider_model_slug(tui_state, :codex) == "gpt-5.5" + + {_input, text} = StringIO.contents(output) + assert text =~ "model: gpt-5.5 selected for codex" + end + + test "invalid slug and extra words report visible errors without mutating active slug", %{ + state: state, + tui_state: tui_state, + output: output + } do + TuiState.put_model_id(tui_state, :codex) + TuiState.put_provider_model_slug(tui_state, :codex, "gpt-5.5") + + assert {:error, {:invalid_model_slug, :unknown_slug}} = + CommandHandler.handle( + CommandInput.command_event("/model invalid-slug"), + command_state(state) + ) + + assert TuiState.provider_model_slug(tui_state, :codex) == "gpt-5.5" + + assert {:error, {:invalid_model_slug_args, ["gpt-5.3-codex", "extra"]}} = + CommandHandler.handle( + CommandInput.command_event("/model gpt-5.3-codex extra"), + command_state(state) + ) + + assert TuiState.provider_model_slug(tui_state, :codex) == "gpt-5.5" + + {_input, text} = StringIO.contents(output) + assert text =~ "model: unknown slug invalid-slug for codex" + assert text =~ "model: use /model with exactly one slug" + end + + test "providers without a model catalog render a clear visible empty state", %{ + state: state, + tui_state: tui_state, + output: output + } do + TuiState.put_model_id(tui_state, :gemini) + + assert {:ok, %{status: :empty, workspace: workspace, count: 0}} = + CommandHandler.handle(CommandInput.command_event("/model"), command_state(state)) + + {_input, text} = StringIO.contents(output) + + assert workspace.kind == "model" + assert workspace.title == "Gemini models" + assert workspace.records == [] + assert text =~ "No model list is available for gemini" + end + + defp command_state(state) do + Map.put_new(state, :startup_result, %{commands: %{entries: [], aliases: %{}}}) + end + + defp safe_close(pid, close) when is_pid(pid) and is_function(close, 1) do + if Process.alive?(pid), do: close.(pid) + :ok + catch + :exit, _reason -> :ok + end +end diff --git a/test/ourocode/terminal/event_loop_command_dispatch_test.exs b/test/ourocode/terminal/event_loop_command_dispatch_test.exs index 86b84ab..1aaf6a1 100644 --- a/test/ourocode/terminal/event_loop_command_dispatch_test.exs +++ b/test/ourocode/terminal/event_loop_command_dispatch_test.exs @@ -4,6 +4,7 @@ defmodule Ourocode.Terminal.EventLoopCommandDispatchTest do alias Ourocode.Terminal.CommandInput alias Ourocode.Terminal.EventLoopCommandDispatch alias Ourocode.Terminal.EventLoopState + alias Ourocode.Terminal.TuiState test "submit persists a command event and records successful dispatch" do command_event = CommandInput.command_event("/status") @@ -53,6 +54,41 @@ defmodule Ourocode.Terminal.EventLoopCommandDispatchTest do assert [%{command: "/approve"}] = state.command_events end + test "default command handler sees live TUI model state for model commands" do + tui_state = TuiState.start_link() + TuiState.put_model_id(tui_state, :codex) + output = string_io() + + on_exit(fn -> safe_stop(tui_state) end) + + state = + state( + output: output, + on_command: :default_command_handler, + tui_state: tui_state + ) + + assert {:ok, state} = + EventLoopCommandDispatch.submit(CommandInput.command_event("/model"), state) + + assert {:ok, state} = + EventLoopCommandDispatch.submit( + CommandInput.command_event("/model gpt-5.5"), + state + ) + + assert state.iterations == 2 + assert TuiState.provider_model_slug(tui_state, :codex) == "gpt-5.5" + + {_input, text} = StringIO.contents(output) + assert text =~ "Codex models" + assert text =~ "gpt-5.5" + assert text =~ "gpt-5.3-codex" + assert text =~ "model: gpt-5.5 selected for codex" + refute text =~ "no models for ; 0 choices" + refute text =~ "no active TUI state is available" + end + test "dispatch normalizes raised command handler exceptions" do command_event = CommandInput.command_event("/explode") @@ -84,4 +120,10 @@ defmodule Ourocode.Terminal.EventLoopCommandDispatchTest do {:ok, output} = StringIO.open("") output end + + defp safe_stop(pid) do + if Process.alive?(pid), do: Agent.stop(pid) + catch + :exit, _reason -> :ok + end end diff --git a/test/ourocode/terminal/model_status_test.exs b/test/ourocode/terminal/model_status_test.exs index 8e3d039..46efaf9 100644 --- a/test/ourocode/terminal/model_status_test.exs +++ b/test/ourocode/terminal/model_status_test.exs @@ -54,6 +54,16 @@ defmodule Ourocode.Terminal.ModelStatusTest do assert ModelStatus.auth_label(nil) == {"no model - /model", :dim} end + test "hud_status separates provider and model slug with optional latency" do + codex = model(:codex, "codex (ChatGPT)") + + assert ModelStatus.hud_status(codex, "gpt-5.3-codex", 1_234) == + "provider: codex (ChatGPT) model: gpt-5.3-codex · 1.2s" + + assert ModelStatus.hud_status(codex, "", 42) == "provider: codex (ChatGPT) · 42ms" + assert ModelStatus.hud_status(codex, nil, nil) == "provider: codex (ChatGPT)" + end + defp model(id, label, status \\ :ready) do %Model{ id: id, diff --git a/test/ourocode/terminal/tui_chat_test.exs b/test/ourocode/terminal/tui_chat_test.exs index 7cb3088..4e201f4 100644 --- a/test/ourocode/terminal/tui_chat_test.exs +++ b/test/ourocode/terminal/tui_chat_test.exs @@ -40,6 +40,33 @@ defmodule Ourocode.Terminal.TuiChatTest do assert Conversation.empty?(TuiState.conversation(state)) end + test "chat passes the active Codex model slug as a session-sourced model opt" do + state = TuiState.start_link() + TuiState.put_model_id(state, :codex) + TuiState.put_provider_model_slug(state, :codex, "gpt-5.3-codex") + + {:ok, output} = StringIO.open("") + parent = self() + + model = %Model{ + id: :codex, + label: "codex (ChatGPT)", + kind: :oauth, + status: :ready, + run: fn prompt, opts, on_chunk -> + send(parent, {:ran, prompt, Keyword.get(opts, :model), Keyword.get(opts, :model_source)}) + on_chunk.("pong") + {:ok, "pong"} + end + } + + redraw = fn _result, _output, _state, _buffer, _cols, _rows -> :ok end + + TuiChat.chat("ping", %{}, output, state, 80, 24, fn _state -> model end, redraw) + + assert_received {:ran, "ping", "gpt-5.3-codex", :session} + end + test "the conversation survives a TUI restart for the same project" do state_dir = Path.join(System.tmp_dir!(), "ourocode-chat-persist-#{System.unique_integer([:positive])}") @@ -76,7 +103,18 @@ defmodule Ourocode.Terminal.TuiChatTest do # First run: one exchange, persisted on completion. first_state = TuiState.start_link() {:ok, first_output} = StringIO.open("") - TuiChat.chat("ping", result, first_output, first_state, 80, 24, fn _state -> model end, redraw) + + TuiChat.chat( + "ping", + result, + first_output, + first_state, + 80, + 24, + fn _state -> model end, + redraw + ) + assert_received {:history, %Conversation{turns: []}} Agent.stop(first_state) diff --git a/test/ourocode/terminal/tui_model_selection_test.exs b/test/ourocode/terminal/tui_model_selection_test.exs index 2f28f73..c74e113 100644 --- a/test/ourocode/terminal/tui_model_selection_test.exs +++ b/test/ourocode/terminal/tui_model_selection_test.exs @@ -13,12 +13,23 @@ defmodule Ourocode.Terminal.TuiModelSelectionTest do assert TuiModelSelection.selected([], 10) == nil end - test "choose stores ready model selection, logs it, and closes model mode" do + test "choose stores ready provider selection, invalidates cache, logs it, and closes picker mode" do state = TuiState.start_link() TuiState.put_mode(state, :model) TuiState.put_pidx(state, 1) {:ok, output} = StringIO.open("") + Agent.update(state, fn current -> + %{ + current + | model_cache: %{ + id: :alpha, + expires_at: System.monotonic_time(:millisecond) + 60_000, + model: model(:alpha, "alpha") + } + } + end) + TuiModelSelection.choose(%{}, output, state, 80, 24, models: [model(:alpha, "alpha"), model(:bravo, "bravo")], redraw: fn _result, _output, _state, _buffer, _cols, _rows -> :ok end, @@ -28,8 +39,9 @@ defmodule Ourocode.Terminal.TuiModelSelectionTest do {_input, captured} = StringIO.contents(output) current = Agent.get(state, & &1) - assert captured =~ "model: bravo" + assert captured =~ "provider: bravo" assert current.model_id == :bravo + assert current.model_cache == nil assert current.mode == :normal assert current.pidx == 0 end diff --git a/test/ourocode/terminal/tui_state_test.exs b/test/ourocode/terminal/tui_state_test.exs index a03e8c9..a1e43ef 100644 --- a/test/ourocode/terminal/tui_state_test.exs +++ b/test/ourocode/terminal/tui_state_test.exs @@ -63,6 +63,42 @@ defmodule Ourocode.Terminal.TuiStateTest do assert Agent.get(state, & &1.model_cache) == nil end + test "provider id and provider-specific model slug are stored separately" do + state = TuiState.start_link() + + assert :ok = TuiState.put_model_id(state, :codex) + assert :ok = TuiState.put_provider_model_slug(state, :codex, " gpt-5.5 ") + + assert Agent.get(state, & &1.model_id) == :codex + assert TuiState.provider_model_slug(state, :codex) == "gpt-5.5" + + assert :ok = + TuiState.put_provider_model_slug(state, :codex, " test-codex-model ", + allow_custom?: true + ) + + assert Agent.get(state, & &1.model_id) == :codex + assert TuiState.provider_model_slug(state, :codex) == "test-codex-model" + end + + test "changing provider model slug invalidates model cache and rejects malformed slugs" do + state = TuiState.start_link() + + Agent.update(state, fn current -> + %{current | model_id: :codex, model_cache: %{id: :codex, model: :cached, expires_at: 123}} + end) + + assert :ok = TuiState.put_provider_model_slug(state, :codex, "gpt-5.5") + assert Agent.get(state, & &1.model_cache) == nil + + assert {:error, :blank_slug} = TuiState.put_provider_model_slug(state, :codex, " ") + + assert {:error, :unknown_provider} = + TuiState.put_provider_model_slug(state, :unknown, "gpt-5.5") + + assert {:error, :invalid_slug} = TuiState.put_provider_model_slug(state, :codex, 123) + end + test "edit, take_buffer, leftover, and notification helpers mutate state" do state = TuiState.start_link() diff --git a/test/ourocode/terminal/tui_submit_test.exs b/test/ourocode/terminal/tui_submit_test.exs index 5287237..f2779a6 100644 --- a/test/ourocode/terminal/tui_submit_test.exs +++ b/test/ourocode/terminal/tui_submit_test.exs @@ -16,10 +16,44 @@ defmodule Ourocode.Terminal.TuiSubmitTest do %{output: output, state: state} end - test "handle opens model picker for model commands", %{output: output, state: state} do + test "handle opens provider picker for provider commands", %{output: output, state: state} do callbacks = callbacks(self()) - assert :continue = TuiSubmit.handle("/model", %{}, output, state, 80, 24, callbacks) + assert :continue = TuiSubmit.handle("/provider", %{}, output, state, 80, 24, callbacks) + assert TuiState.mode(state) == :model + assert TuiState.pidx(state) == 0 + assert_receive {:redraw, "", 80, 24} + + TuiState.put_mode(state, :normal) + TuiState.put_pidx(state, 3) + + assert :continue = TuiSubmit.handle("/providers", %{}, output, state, 100, 30, callbacks) + assert TuiState.mode(state) == :model + assert TuiState.pidx(state) == 0 + assert_receive {:redraw, "", 100, 30} + end + + test "handle leaves model commands on slash command dispatch", %{output: output, state: state} do + callbacks = callbacks(self()) + TuiState.put_mode(state, :normal) + TuiState.put_pidx(state, 3) + + for line <- ["/model", "/models", "/model bad"] do + assert {:submit, ^line} = TuiSubmit.handle(line, %{}, output, state, 80, 24, callbacks) + assert TuiState.mode(state) == :normal + assert TuiState.pidx(state) == 3 + refute_received {:redraw, "", 80, 24} + end + end + + test "handle login still opens provider picker for auth selection", %{ + output: output, + state: state + } do + callbacks = callbacks(self()) + TuiState.put_pidx(state, 5) + + assert :continue = TuiSubmit.handle("/login", %{}, output, state, 80, 24, callbacks) assert TuiState.mode(state) == :model assert TuiState.pidx(state) == 0 assert_receive {:redraw, "", 80, 24}