From 8ddf86b3521df3d2703fd678af4f76bd8897058c Mon Sep 17 00:00:00 2001 From: Ben Godfrey Date: Sat, 11 Jul 2026 23:12:34 +0100 Subject: [PATCH 01/10] feat(metamodels): Add support for Muse Spark 1.1 via the Meta Models API --- crates/goose-providers/src/declarative.rs | 1 + .../src/declarative/definitions/meta.json | 30 ++ .../docs/getting-started/providers.md | 297 +++++++++--------- 3 files changed, 180 insertions(+), 148 deletions(-) create mode 100644 crates/goose-providers/src/declarative/definitions/meta.json diff --git a/crates/goose-providers/src/declarative.rs b/crates/goose-providers/src/declarative.rs index 4062bb69275e..bde78238972e 100644 --- a/crates/goose-providers/src/declarative.rs +++ b/crates/goose-providers/src/declarative.rs @@ -27,6 +27,7 @@ pub(crate) mod declarative_providers { inception, llama_swap, lmstudio, + meta, minimax, mistral, moonshot, diff --git a/crates/goose-providers/src/declarative/definitions/meta.json b/crates/goose-providers/src/declarative/definitions/meta.json new file mode 100644 index 000000000000..6b0c0f123756 --- /dev/null +++ b/crates/goose-providers/src/declarative/definitions/meta.json @@ -0,0 +1,30 @@ +{ + "name": "meta", + "engine": "openai", + "display_name": "Meta", + "description": "Meta's Model API, home of the Muse Spark models", + "api_key_env": "META_MODEL_API_KEY", + "base_url": "https://api.meta.ai/v1", + "catalog_provider_id": "meta", + "dynamic_models": true, + "models": [ + { + "name": "muse-spark-1.1", + "context_limit": 1000000, + "max_tokens": 32000, + "input_token_cost": 0.00000125, + "output_token_cost": 0.00000425, + "currency": "USD", + "reasoning": true + } + ], + "preserves_thinking": true, + "supports_streaming": true, + "model_doc_link": "https://dev.meta.ai/docs", + "setup_steps": [ + "Sign in to https://dev.meta.ai", + "Navigate to API Keys in your account settings", + "Create a new API key", + "Copy the key and paste it above" + ] +} diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 1b723d370162..69e05e7b5734 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -41,6 +41,7 @@ goose is compatible with a wide range of LLM providers, allowing you to choose a | [iFlytek Astron MaaS](https://maas.xfyun.cn/) | iFlytek Astron MaaS (讯飞星辰) hosting Spark X2, DeepSeek, GLM, Kimi, MiniMax, Qwen, and Astron coding models via an OpenAI-compatible API. Set `ASTRON_BASE_URL` to switch between the Token Plan and Coding Plan endpoints. | `ASTRON_API_KEY`, `ASTRON_BASE_URL` (optional) | | [LiteLLM](https://docs.litellm.ai/docs/) | LiteLLM proxy supporting multiple models with automatic prompt caching and unified API access. | `LITELLM_HOST`, `LITELLM_BASE_PATH` (optional), `LITELLM_API_KEY` (optional), `LITELLM_CUSTOM_HEADERS` (optional), `LITELLM_TIMEOUT` (optional) | | [LM Studio](https://lmstudio.ai/) | Run local models with LM Studio's OpenAI-compatible server. **Because this provider runs locally, you must first [download a model](#local-llms).** | None required. Connects to local server at `localhost:1234` by default. | +| [Meta](https://dev.meta.ai/) | Meta's Model API, home of the Muse Spark models. | `META_MODEL_API_KEY` | | [Mistral AI](https://mistral.ai/) | Provides access to Mistral models including general-purpose models, specialized coding models (Codestral), and multimodal models (Pixtral). | `MISTRAL_API_KEY` | | [NEAR AI Cloud](https://cloud.near.ai/) | TEE-backed private inference through an OpenAI-compatible API with dynamic model discovery. | `NEARAI_API_KEY` | | [Novita AI](https://novita.ai/) | 90+ open-source models with OpenAI-compatible API and competitive pricing. Supports Kimi K2.5, DeepSeek, GLM, MiniMax, Qwen, and more. | `NOVITA_API_KEY` | @@ -91,11 +92,11 @@ To configure your chosen provider, see available options, or select a model, vis **First-time users:** - + On the welcome screen the first time you open goose, you have these options: - + - + 1. Choose `Quick Setup with API Key`. @@ -111,34 +112,34 @@ To configure your chosen provider, see available options, or select a model, vis 4. When you return to goose Desktop, you're ready to begin your first session. - We recommend new users start with Agent Router by Tetrate. Tetrate provides access to multiple AI models with built-in rate limiting and automatic failover. + We recommend new users start with Agent Router by Tetrate. Tetrate provides access to multiple AI models with built-in rate limiting and automatic failover. :::info Free Credits Offer You'll receive $10 in free credits the first time you automatically authenticate with Tetrate through goose. This offer is available to both new and existing Tetrate users. ::: - 1. Choose `Agent Router by Tetrate`. + 1. Choose `Agent Router by Tetrate`. 2. goose will open a browser window for you to authenticate with Tetrate, or create a new account if you don't have one already. 3. When you return to goose Desktop, you're ready to begin your first session. - 1. Choose `Automatic setup with OpenRouter`. + 1. Choose `Automatic setup with OpenRouter`. 2. goose will open a browser window for you to authenticate with OpenRouter, or create a new account if you don't have one already. 3. When you return to the goose Desktop, you're ready to begin your first session. - 1. If you have a specific provider you want to use with goose, and an API key from that provider, choose `Other Providers`. - 2. Find the provider of your choice and click its `Configure` button. If you don't see your provider in the list, click `Add Custom Provider` at the bottom of the window to [configure a custom provider](#configure-custom-provider). + 1. If you have a specific provider you want to use with goose, and an API key from that provider, choose `Other Providers`. + 2. Find the provider of your choice and click its `Configure` button. If you don't see your provider in the list, click `Add Custom Provider` at the bottom of the window to [configure a custom provider](#configure-custom-provider). 3. Depending on your provider, you'll need to input your API Key, API Host, or other optional [parameters](#available-providers). Click the `Submit` button to authenticate and begin your first session. :::info Ollama Model Detection For Ollama users, all locally installed models display automatically in the model selection dropdown. ::: - + - **To update your LLM provider and API key:** + **To update your LLM provider and API key:** 1. Click the button in the top-left to open the sidebar 2. Click the `Settings` button on the sidebar 3. Click the `Models` tab @@ -166,7 +167,7 @@ To configure your chosen provider, see available options, or select a model, vis 4. Click `Reset Provider and Model` to clear your current settings and return to the welcome screen - 1. In your terminal, run the following command: + 1. In your terminal, run the following command: ```sh goose configure @@ -175,59 +176,59 @@ To configure your chosen provider, see available options, or select a model, vis 2. Select `Configure Providers` from the menu and press `Enter`. ``` - ┌ goose-configure + ┌ goose-configure │ ◆ What would you like to configure? // highlight-start │ ● Configure Providers (Change provider or update credentials) // highlight-end - │ ○ Custom Providers - │ ○ Add Extension - │ ○ Toggle Extensions - │ ○ Remove Extension - │ ○ goose Settings - └ + │ ○ Custom Providers + │ ○ Add Extension + │ ○ Toggle Extensions + │ ○ Remove Extension + │ ○ goose Settings + └ ``` 3. Choose a model provider and press `Enter`. Use the arrow keys (↑/↓) to move through the options, or start typing to filter the list. ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◆ Which model provider should we use? - │ ○ Amazon Bedrock - │ ○ Amazon SageMaker TGI + │ ○ Amazon Bedrock + │ ○ Amazon SageMaker TGI // highlight-start │ ● Anthropic (Claude and other models from Anthropic) // highlight-end - │ ○ Azure OpenAI + │ ○ Azure OpenAI │ ○ Claude Code CLI │ ○ ... - └ + └ ``` 4. Enter your API key (and any other configuration details) when prompted. ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◇ Which model provider should we use? - │ Anthropic + │ Anthropic │ ◆ Provider Anthropic requires ANTHROPIC_API_KEY, please enter a value // highlight-start │ ▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪ // highlight-end - └ + └ ``` - + If you're just changing models, skip any prompts to update the provider configuration. - 5. Enter your desired `ANTHROPIC_HOST` or press `Enter` to use the default. + 5. Enter your desired `ANTHROPIC_HOST` or press `Enter` to use the default. ``` ◆ Provider Anthropic requires ANTHROPIC_HOST, please enter a value @@ -239,7 +240,7 @@ To configure your chosen provider, see available options, or select a model, vis - Select the model from a list - Search for the model by name - Enter the model name directly - + ``` │ ◇ Model fetch complete @@ -252,7 +253,7 @@ To configure your chosen provider, see available options, or select a model, vis ◒ Checking your configuration... └ Configuration saved successfully ``` - + This change takes effect the next time you start a session. :::note @@ -385,7 +386,7 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T 4. Click `Configure providers` 5. Click `Add Custom Provider` at the bottom of the window 6. Fill in the provider details: - - **Provider Type**: + - **Provider Type**: - `OpenAI Compatible` (most common) - `Anthropic Compatible` - `Ollama Compatible` @@ -404,7 +405,7 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T - 1. In your terminal, run the following command: + 1. In your terminal, run the following command: ```sh goose configure @@ -413,38 +414,38 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T 2. Select `Custom Providers`. Use the arrow keys (↑/↓) to move through the options. ```sh - ┌ goose-configure + ┌ goose-configure │ ◆ What would you like to configure? │ ○ Configure Providers // highlight-start │ ● Custom Providers (Add custom provider with compatible API) // highlight-end - │ ○ Add Extension - │ ○ Toggle Extensions - │ ○ Remove Extension - │ ○ goose Settings - └ + │ ○ Add Extension + │ ○ Toggle Extensions + │ ○ Remove Extension + │ ○ goose Settings + └ ``` 3. Select `Add A Custom Provider` ```sh - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Custom Providers + │ Custom Providers │ ◆ What would you like to do? // highlight-start │ ● Add A Custom Provider (Add a new OpenAI/Anthropic/Ollama compatible Provider) // highlight-end │ ○ Remove Custom Provider - └ + └ ``` 4. Follow the prompts to enter the provider details: - - **API Type**: + - **API Type**: - `OpenAI Compatible` (most common) - `Anthropic Compatible` - `Ollama Compatible` @@ -523,8 +524,8 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T - - 1. In your terminal, run the following command: + + 1. In your terminal, run the following command: ```sh goose configure @@ -533,40 +534,40 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T 2. Select `Configure Providers` from the menu and press `Enter`. ```sh - ┌ goose-configure + ┌ goose-configure │ ◆ What would you like to configure? // highlight-start │ ● Configure Providers (Change provider or update credentials) // highlight-end - │ ○ Custom Providers - │ ○ Add Extension - │ ○ Toggle Extensions - │ ○ Remove Extension - │ ○ goose Settings - └ + │ ○ Custom Providers + │ ○ Add Extension + │ ○ Toggle Extensions + │ ○ Remove Extension + │ ○ goose Settings + └ ``` 3. Select the custom provider you want to update and press `Enter`. Use the arrow keys (↑/↓) to move through the options, or start typing to filter the list. ```sh - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◆ Which model provider should we use? - │ ○ Amazon Bedrock - │ ○ Amazon SageMaker TGI + │ ○ Amazon Bedrock + │ ○ Amazon SageMaker TGI │ ○ Anthropic - │ ○ Azure OpenAI - │ ○ Claude Code CLI + │ ○ Azure OpenAI + │ ○ Claude Code CLI // highlight-start │ ● Corporate API (Custom Corporate API provider) // highlight-end - │ ○ Cursor Agent + │ ○ Cursor Agent │ ○ ... - └ + └ ``` 4. Follow the prompts to update the fields. @@ -598,8 +599,8 @@ Your changes are available in your next goose session. - - 1. In your terminal, run the following command: + + 1. In your terminal, run the following command: ```sh goose configure @@ -608,34 +609,34 @@ Your changes are available in your next goose session. 2. Select `Custom Providers`. Use the arrow keys (↑/↓) to move through the options. ```sh - ┌ goose-configure + ┌ goose-configure │ ◆ What would you like to configure? │ ○ Configure Providers // highlight-start │ ● Custom Providers (Add custom provider with compatible API) // highlight-end - │ ○ Add Extension - │ ○ Toggle Extensions - │ ○ Remove Extension - │ ○ goose Settings - └ + │ ○ Add Extension + │ ○ Toggle Extensions + │ ○ Remove Extension + │ ○ goose Settings + └ ``` 3. Select `Remove Custom Provider`. ```sh - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Custom Providers + │ Custom Providers │ ◆ What would you like to do? - │ ○ Add A Custom Provider + │ ○ Add A Custom Provider // highlight-start │ ● Remove Custom Provider (Remove an existing custom provider) // highlight-end - └ + └ ``` 4. Select the custom provider you want to remove. @@ -658,7 +659,7 @@ Your changes are available in your next goose session. ## Using goose for Free -goose is a free and open source AI agent that you can start using right away, but not all supported [LLM Providers][providers] provide a free tier. +goose is a free and open source AI agent that you can start using right away, but not all supported [LLM Providers][providers] provide a free tier. Below, we outline a couple of free options and how to get started with them. @@ -672,7 +673,7 @@ Groq provides free access to open source (open weight) models with high-speed in Groq offers several open source models that support tool calling, including: - **moonshotai/kimi-k2-instruct-0905** - Mixture-of-Experts model with 1 trillion parameters, optimized for agentic intelligence and tool use -- **qwen/qwen3-32b** - 32.8 billion parameter model with advanced reasoning and multilingual capabilities +- **qwen/qwen3-32b** - 32.8 billion parameter model with advanced reasoning and multilingual capabilities - **llama-3.3-70b-versatile** - Meta's Llama 3.3 model for versatile applications - **llama-3.1-8b-instant** - Meta's Llama 3.1 model for fast inference @@ -682,7 +683,7 @@ To set up Groq with goose, follow these steps: - **To update your LLM provider and API key:** + **To update your LLM provider and API key:** 1. Click the button in the top-left to open the sidebar. 2. Click the `Settings` button on the sidebar. @@ -694,7 +695,7 @@ To set up Groq with goose, follow these steps: - 1. Run: + 1. Run: ```sh goose configure ``` @@ -723,7 +724,7 @@ To set up EmpirioLabs with goose, follow these steps: - **To update your LLM provider and API key:** + **To update your LLM provider and API key:** 1. Click the button in the top-left to open the sidebar. 2. Click the `Settings` button on the sidebar. @@ -735,7 +736,7 @@ To set up EmpirioLabs with goose, follow these steps: - 1. Run: + 1. Run: ```sh goose configure ``` @@ -762,7 +763,7 @@ To set up FuturMix with goose, follow these steps: - **To update your LLM provider and API key:** + **To update your LLM provider and API key:** 1. Click the button in the top-left to open the sidebar. 2. Click the `Settings` button on the sidebar. @@ -774,7 +775,7 @@ To set up FuturMix with goose, follow these steps: - 1. Run: + 1. Run: ```sh goose configure ``` @@ -801,7 +802,7 @@ To set up Novita AI with goose, follow these steps: - **To update your LLM provider and API key:** + **To update your LLM provider and API key:** 1. Click the button in the top-left to open the sidebar. 2. Click the `Settings` button on the sidebar. @@ -813,7 +814,7 @@ To set up Novita AI with goose, follow these steps: - 1. Run: + 1. Run: ```sh goose configure ``` @@ -868,7 +869,7 @@ To set up Google Gemini with goose, follow these steps: - **To update your LLM provider and API key:** + **To update your LLM provider and API key:** 1. Click the button in the top-left to open the sidebar. 2. Click the `Settings` button on the sidebar. @@ -879,7 +880,7 @@ To set up Google Gemini with goose, follow these steps: - 1. Run: + 1. Run: ```sh goose configure ``` @@ -899,7 +900,7 @@ To set up Google Gemini with goose, follow these steps: │ ◇ Provider Google Gemini requires GOOGLE_API_KEY, please enter a value │▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪ - │ + │ ◇ Enter a model from that provider: │ gemini-2.0-flash-exp │ @@ -1022,14 +1023,14 @@ Here are some local providers we support: - The native `DeepSeek-r1` model doesn't support tool calling, however, we have a [custom model](https://ollama.com/michaelneale/deepseek-r1-goose) you can use with goose. + The native `DeepSeek-r1` model doesn't support tool calling, however, we have a [custom model](https://ollama.com/michaelneale/deepseek-r1-goose) you can use with goose. :::warning Note that this is a 70B model size and requires a powerful device to run smoothly. ::: - 1. [Download Ollama](https://ollama.com/download). + 1. [Download Ollama](https://ollama.com/download). 2. In a terminal window, run the following command to install the custom DeepSeek-r1 model: ```sh @@ -1045,44 +1046,44 @@ Here are some local providers we support: 4. Choose to `Configure Providers` ``` - ┌ goose-configure + ┌ goose-configure │ ◆ What would you like to configure? │ ● Configure Providers (Change provider or update credentials) - │ ○ Toggle Extensions - │ ○ Add Extension - └ + │ ○ Toggle Extensions + │ ○ Add Extension + └ ``` 5. Choose `Ollama` as the model provider ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◆ Which model provider should we use? - │ ○ Anthropic - │ ○ Databricks - │ ○ Google Gemini - │ ○ Groq + │ ○ Anthropic + │ ○ Databricks + │ ○ Google Gemini + │ ○ Groq │ ● Ollama (Local open source models) - │ ○ OpenAI - │ ○ OpenRouter - └ + │ ○ OpenAI + │ ○ OpenRouter + └ ``` 6. Enter the host where your model is running ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◇ Which model provider should we use? - │ Ollama + │ Ollama │ ◆ Provider Ollama requires OLLAMA_HOST, please enter a value │ http://localhost:11434 @@ -1092,17 +1093,17 @@ Here are some local providers we support: 7. Enter the installed model from above ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◇ Which model provider should we use? - │ Ollama + │ Ollama │ ◇ Provider Ollama requires OLLAMA_HOST, please enter a value - │ http://localhost:11434 - │ + │ http://localhost:11434 + │ ◇ Enter a model from that provider: │ michaelneale/deepseek-r1-goose │ @@ -1112,7 +1113,7 @@ Here are some local providers we support: ``` - 1. [Download Ollama](https://ollama.com/download). + 1. [Download Ollama](https://ollama.com/download). 2. In a terminal, run any [model supporting tool-calling](https://ollama.com/search?c=tools) Example: @@ -1130,32 +1131,32 @@ Here are some local providers we support: 4. Choose to `Configure Providers` ``` - ┌ goose-configure + ┌ goose-configure │ ◆ What would you like to configure? │ ● Configure Providers (Change provider or update credentials) - │ ○ Toggle Extensions - │ ○ Add Extension - └ + │ ○ Toggle Extensions + │ ○ Add Extension + └ ``` 5. Choose `Ollama` as the model provider ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◆ Which model provider should we use? - │ ○ Anthropic - │ ○ Databricks - │ ○ Google Gemini - │ ○ Groq + │ ○ Anthropic + │ ○ Databricks + │ ○ Google Gemini + │ ○ Groq │ ● Ollama (Local open source models) - │ ○ OpenAI - │ ○ OpenRouter - └ + │ ○ OpenAI + │ ○ OpenRouter + └ ``` 6. Enter the host where your model is running @@ -1168,13 +1169,13 @@ Here are some local providers we support: ::: ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◇ Which model provider should we use? - │ Ollama + │ Ollama │ ◆ Provider Ollama requires OLLAMA_HOST, please enter a value │ http://localhost:11434 @@ -1185,13 +1186,13 @@ Here are some local providers we support: 7. Enter the model you have running ``` - ┌ goose-configure + ┌ goose-configure │ ◇ What would you like to configure? - │ Configure Providers + │ Configure Providers │ ◇ Which model provider should we use? - │ Ollama + │ Ollama │ ◇ Provider Ollama requires OLLAMA_HOST, please enter a value │ http://localhost:11434 @@ -1207,7 +1208,7 @@ Here are some local providers we support: :::tip Context Length If you notice that goose is having trouble using extensions or is ignoring [.goosehints](/docs/guides/context-engineering/using-goosehints), it is likely that the model's default context length of 4096 tokens is too low. Set the `OLLAMA_CONTEXT_LENGTH` environment variable to a [higher value](https://github.com/ollama/ollama/blob/main/docs/faq.mdx#how-can-i-specify-the-context-window-size). ::: - + @@ -1320,7 +1321,7 @@ Here are some local providers we support: docker model pull hf.co/unsloth/gemma-3n-e4b-it-gguf:q6_k ``` - 4. Configure goose to use Docker Model Runner, using the OpenAI API compatible endpoint: + 4. Configure goose to use Docker Model Runner, using the OpenAI API compatible endpoint: ```sh goose configure @@ -1329,16 +1330,16 @@ Here are some local providers we support: 5. Choose to `Configure Providers` ``` - ┌ goose-configure + ┌ goose-configure │ ◆ What would you like to configure? │ ● Configure Providers (Change provider or update credentials) - │ ○ Toggle Extensions - │ ○ Add Extension - └ + │ ○ Toggle Extensions + │ ○ Add Extension + └ ``` - 6. Choose `OpenAI` as the model provider: + 6. Choose `OpenAI` as the model provider: ``` ┌ goose-configure @@ -1354,7 +1355,7 @@ Here are some local providers we support: │ ○ OpenRouter ``` - 7. Configure Docker Model Runner endpoint as the `OPENAI_HOST`: + 7. Configure Docker Model Runner endpoint as the `OPENAI_HOST`: ``` ┌ goose-configure @@ -1370,10 +1371,10 @@ Here are some local providers we support: └ ``` - The default value for the host-side port Docker Model Runner is 12434, so the `OPENAI_HOST` value could be: - `http://localhost:12434`. + The default value for the host-side port Docker Model Runner is 12434, so the `OPENAI_HOST` value could be: + `http://localhost:12434`. - 8. Configure the base path: + 8. Configure the base path: ``` ◆ Provider OpenAI requires OPENAI_BASE_PATH, please enter a value @@ -1390,7 +1391,7 @@ Here are some local providers we support: ◇ Enter a model from that provider: │ gpt-4o │ - ◒ Checking your configuration... + ◒ Checking your configuration... └ Configuration saved successfully ``` @@ -1469,12 +1470,12 @@ When thinking is enabled, you can view the model's reasoning process. See [Viewi When selecting a Gemini 3 model, a "Thinking Level" dropdown appears automatically. Select your preference and the setting persists across sessions. - + **Interactive configuration:** - + When you run `goose configure` and select a Gemini 3 model, you'll be prompted to choose a thinking level: - + ``` ◆ Select thinking level for Gemini 3: │ ● Low - Better latency, lighter reasoning @@ -1505,16 +1506,16 @@ Some models expose their internal reasoning or "chain of thought" as part of the Reasoning output appears automatically in a collapsible **"Show reasoning"** toggle above the model's response. Click it to expand and view the model's thought process. - + Reasoning output is **hidden by default** in the CLI. To display it, set the `GOOSE_CLI_SHOW_THINKING` environment variable: - + ```bash export GOOSE_CLI_SHOW_THINKING=1 ``` - + When enabled, reasoning appears under a "Thinking:" header in dimmed text before the model's main response. - + :::note This requires stdout to be a terminal (reasoning output won't appear when piping output to a file or another command). ::: From bb2f962d80b49ba450851f4a51aae1c1eedda0aa Mon Sep 17 00:00:00 2001 From: Ben Godfrey Date: Sun, 12 Jul 2026 11:47:06 +0100 Subject: [PATCH 02/10] feat(metamodels): Add reasoning param for Meta Models API `ThinkingEffort::Off` is not supported by the Muse Spark models, so I've clamped it to `ThinkingEffort::low` which most closely matches the user's intent to have no reasoning. I'd love to get feedback on whether this approach makes sense, or if there's a better way to do it. --- crates/goose-providers/src/openai.rs | 135 ++++++++++++++++-- .../docs/getting-started/providers.md | 33 +++++ 2 files changed, 159 insertions(+), 9 deletions(-) diff --git a/crates/goose-providers/src/openai.rs b/crates/goose-providers/src/openai.rs index 3c920ca144df..bab0703e0ac8 100644 --- a/crates/goose-providers/src/openai.rs +++ b/crates/goose-providers/src/openai.rs @@ -18,9 +18,11 @@ use crate::openai_compatible::{ handle_response_openai_compat, handle_status, stream_openai_compat, stream_responses_compat, }; use crate::request_log::{start_log, LoggerHandleExt}; +use crate::thinking::ThinkingEffort; use anyhow::Result; use async_trait::async_trait; use reqwest::StatusCode; +use serde_json::json; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -346,7 +348,33 @@ impl OpenAiProvider { const PROVIDERS_NEEDING_STANDARD_CHAT_PARAMS: &[&str] = &["nearai"]; - fn sanitize_request_for_compat(&self, mut payload: serde_json::Value) -> serde_json::Value { + /// Providers whose reasoning models accept an OpenAI-style + /// `reasoning_effort` field on chat-completions requests but aren't + /// matched by [`is_openai_responses_model`] (which only recognises + /// OpenAI's own `o*`/`gpt-5*` model names). These need the unified + /// [`ThinkingEffort`] mapped onto the request explicitly. + const PROVIDERS_NEEDING_REASONING_EFFORT_MAPPING: &[&str] = &["meta"]; + + /// Maps the unified thinking effort onto Meta's Muse Spark + /// `reasoning_effort` levels: `low`, `medium`, `high`, `xhigh`. + /// + /// Muse Spark always reasons and has no supported "disable reasoning" + /// level, so `Off` is clamped to `low` (the lightest level Meta + /// supports) rather than sent as-is or omitted. + fn meta_reasoning_effort(effort: ThinkingEffort) -> &'static str { + match effort { + ThinkingEffort::Off | ThinkingEffort::Low => "low", + ThinkingEffort::Medium => "medium", + ThinkingEffort::High => "high", + ThinkingEffort::Max => "xhigh", + } + } + + fn sanitize_request_for_compat( + &self, + mut payload: serde_json::Value, + model_config: &ModelConfig, + ) -> serde_json::Value { if let Some(obj) = payload.as_object_mut() { if Self::PROVIDERS_NEEDING_MAX_TOKENS_REMAP.contains(&self.name.as_str()) { if let Some(value) = obj.remove("max_completion_tokens") { @@ -372,6 +400,20 @@ impl OpenAiProvider { } } } + + if Self::PROVIDERS_NEEDING_REASONING_EFFORT_MAPPING.contains(&self.name.as_str()) { + match model_config.thinking_effort() { + Some(effort) => { + obj.insert( + "reasoning_effort".to_string(), + json!(Self::meta_reasoning_effort(effort)), + ); + } + None => { + obj.remove("reasoning_effort"); + } + } + } } payload @@ -671,7 +713,7 @@ impl Provider for OpenAiProvider { preserve_thinking_context: self.preserve_thinking_context, }, )?; - let payload = self.sanitize_request_for_compat(payload); + let payload = self.sanitize_request_for_compat(payload, model_config); let mut log = start_log(model_config, &payload)?; let response = self @@ -895,7 +937,8 @@ mod tests { "max_completion_tokens": 16384 }); - let result = provider.sanitize_request_for_compat(payload); + let result = provider + .sanitize_request_for_compat(payload, &ModelConfig::new("mistral-medium-latest")); let obj = result.as_object().unwrap(); assert!(!obj.contains_key("max_completion_tokens")); @@ -912,7 +955,8 @@ mod tests { "max_completion_tokens": 16384 }); - let result = provider.sanitize_request_for_compat(payload); + let result = provider + .sanitize_request_for_compat(payload, &ModelConfig::new("mistral-medium-latest")); let obj = result.as_object().unwrap(); assert!(!obj.contains_key("max_completion_tokens")); @@ -928,7 +972,7 @@ mod tests { "max_completion_tokens": 16384 }); - let result = provider.sanitize_request_for_compat(payload); + let result = provider.sanitize_request_for_compat(payload, &ModelConfig::new("o3")); let obj = result.as_object().unwrap(); assert!(obj.contains_key("max_completion_tokens")); @@ -944,7 +988,8 @@ mod tests { "max_completion_tokens": 16384 }); - let result = provider.sanitize_request_for_compat(payload); + let result = + provider.sanitize_request_for_compat(payload, &ModelConfig::new("future-model")); let obj = result.as_object().unwrap(); assert!(obj.contains_key("max_completion_tokens")); @@ -959,7 +1004,10 @@ mod tests { "messages": [] }); - let result = provider.sanitize_request_for_compat(payload.clone()); + let result = provider.sanitize_request_for_compat( + payload.clone(), + &ModelConfig::new("llama-3.3-70b-versatile"), + ); assert_eq!(result, payload); } @@ -982,7 +1030,8 @@ mod tests { "max_completion_tokens": 16384 }); - let result = provider.sanitize_request_for_compat(payload); + let result = provider + .sanitize_request_for_compat(payload, &ModelConfig::new("Qwen/Qwen3.6-35B-A3B-FP8")); let obj = result.as_object().unwrap(); assert!(!obj.contains_key("reasoning_effort")); @@ -1002,7 +1051,8 @@ mod tests { "max_completion_tokens": 16384 }); - let result = provider.sanitize_request_for_compat(payload); + let result = + provider.sanitize_request_for_compat(payload, &ModelConfig::new("openai/gpt-5")); let obj = result.as_object().unwrap(); assert_eq!(obj.get("reasoning_effort"), Some(&json!("medium"))); @@ -1010,6 +1060,73 @@ mod tests { assert_eq!(obj.get("max_tokens").unwrap(), &json!(16384)); } + #[test] + fn sanitize_meta_applies_reasoning_effort_from_thinking_effort() { + let provider = make_provider("meta"); + let payload = json!({ + "model": "muse-spark-1.1", + "messages": [] + }); + let model_config = + ModelConfig::new("muse-spark-1.1").with_thinking_effort(ThinkingEffort::High); + + let result = provider.sanitize_request_for_compat(payload, &model_config); + let obj = result.as_object().unwrap(); + + assert_eq!(obj.get("reasoning_effort"), Some(&json!("high"))); + } + + #[test] + fn sanitize_meta_maps_max_thinking_effort_to_xhigh() { + let provider = make_provider("meta"); + let payload = json!({ + "model": "muse-spark-1.1", + "messages": [] + }); + let model_config = + ModelConfig::new("muse-spark-1.1").with_thinking_effort(ThinkingEffort::Max); + + let result = provider.sanitize_request_for_compat(payload, &model_config); + let obj = result.as_object().unwrap(); + + assert_eq!(obj.get("reasoning_effort"), Some(&json!("xhigh"))); + } + + #[test] + fn sanitize_meta_clamps_off_thinking_effort_to_low() { + // Muse Spark always reasons and has no "disable reasoning" level, + // so an explicit `Off` must be clamped to the lightest supported + // level rather than omitted or sent as-is. + let provider = make_provider("meta"); + let payload = json!({ + "model": "muse-spark-1.1", + "messages": [], + "reasoning_effort": "high" + }); + let model_config = + ModelConfig::new("muse-spark-1.1").with_thinking_effort(ThinkingEffort::Off); + + let result = provider.sanitize_request_for_compat(payload, &model_config); + let obj = result.as_object().unwrap(); + + assert_eq!(obj.get("reasoning_effort"), Some(&json!("low"))); + } + + #[test] + fn sanitize_meta_omits_reasoning_effort_when_unset() { + let provider = make_provider("meta"); + let payload = json!({ + "model": "muse-spark-1.1", + "messages": [] + }); + let model_config = ModelConfig::new("muse-spark-1.1"); + + let result = provider.sanitize_request_for_compat(payload, &model_config); + let obj = result.as_object().unwrap(); + + assert!(!obj.contains_key("reasoning_effort")); + } + #[test] fn nearai_uses_chat_completions_for_openai_reasoning_models() { let provider = make_provider("nearai"); diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 69e05e7b5734..187ca86306bb 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -1456,6 +1456,39 @@ Beyond single-model setups, goose supports [multi-model configurations](/docs/gu - **Planning Mode** - Use a dedicated planner model to create detailed project breakdowns before execution - **Subagents** - Delegate scoped tasks to isolated sessions to keep your primary workflow focused and efficient +## Meta Muse Spark Reasoning Effort + +Meta's Muse Spark models support a configurable reasoning effort that maps to Meta's `reasoning_effort` request parameter: +- **Low** - Faster responses, lighter reasoning +- **Medium** - Balanced reasoning depth and latency +- **High** - Deeper reasoning, higher latency +- **Max** - Sent as `xhigh`, the deepest reasoning level Meta supports + + + + When selecting a Muse Spark model, a "Thinking Effort" dropdown appears automatically. Select your preference and the setting persists across sessions. + + + + When you run `goose configure` and select a Muse Spark model, you'll be prompted to choose a thinking effort: + + ``` + ◆ Select thinking effort: + │ ● Off - No extended thinking + │ ○ Low - Better latency, lighter reasoning + │ ○ Medium - Moderate thinking + │ ○ High - Deep reasoning + │ ○ Max - No constraints on thinking depth + ``` + + You can also set this globally with the `GOOSE_THINKING_EFFORT` environment variable (`off`, `low`, `medium`, `high`, or `max`). + + + +:::note +Muse Spark always reasons and has no way to disable it, so choosing `off` is clamped to `low` (the lightest level Meta supports) rather than omitting the `reasoning_effort` parameter. +::: + ## Gemini 3 Thinking Levels Gemini 3 models support configurable thinking levels to balance response latency and reasoning depth: From 508dffb3a40bdce92427f994af997f32c228194b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 16:01:04 +0000 Subject: [PATCH 03/10] docs: add CLI formatting research report Co-authored-by: Ben Godfrey --- docs/cli-formatting-research.md | 196 ++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 docs/cli-formatting-research.md diff --git a/docs/cli-formatting-research.md b/docs/cli-formatting-research.md new file mode 100644 index 000000000000..b75a79e54ba8 --- /dev/null +++ b/docs/cli-formatting-research.md @@ -0,0 +1,196 @@ +# CLI message formatting — research notes + +This document captures how message formatting currently works in the goose +CLI (`crates/goose-cli`) and the Ink-based TUI (`ui/text`), plus a quick look +at how Claude Code and Codex format their terminal output. It is the basis +for the formatting-improvement plan that follows in this branch. + +## 1. How the CLI (`crates/goose-cli`) formats messages today + +**Renderer:** `crates/goose-cli/src/session/output.rs` +**Event loop:** `crates/goose-cli/src/session/mod.rs` (`process_agent_response`) +**Streaming markdown buffer:** `crates/goose-cli/src/session/streaming_buffer.rs` + +### Pipeline + +``` +main.rs → cli.rs → build_session → CliSession::interactive() + → input::get_input() (rustyline "> " prompt) + → handle_message_input() → push_message (no echo) → show_thinking() + → process_agent_response() + → agent.reply() stream of AgentEvent + → output::render_message_streaming() dispatches per MessageContent + Text → MarkdownBuffer.push() → print_markdown() → bat::PrettyPrinter + ToolRequest → flush buffer → render_tool_request() + ToolResponse → flush buffer → render_tool_response() + Thinking → render_thinking_streaming() (hidden unless GOOSE_CLI_SHOW_THINKING) + → flush_markdown_buffer_current_theme() + → hide_thinking(), print dim "⏱ elapsed" +``` + +### Styling stack + +- **`console`** crate for all inline styling (`style(...).dim()/.bold()/.red()/...`). +- **`bat::PrettyPrinter`** renders all assistant markdown (headers, bold, code, + lists, links) via a named syntect theme. Theme is one of `Theme::{Light, + Dark, Ansi}` (`GOOSE_CLI_THEME`, default `Ansi` → bat theme `base16`). +- **`comfy-table`** turns markdown pipe-tables into ASCII tables before + handing them to `bat`. +- **`cliclack`** for the "thinking" spinner and confirmation/elicitation + prompts. +- **`indicatif`** for MCP notification spinners/progress bars. +- **`rustyline`** for the input prompt (`> `) — no styling on user input. + +### Per-element formatting (current state) + +| Element | Indent | Color/style | Notes | +|---|---|---|---| +| User message | none | none | Not rendered at all during live chat — only pushed to history. Only shown via `bat` markdown on `/resume` replay. | +| Assistant text | 0 | theme-dependent (bat) | No leading glyph, no visual separation from surrounding tool blocks besides blank lines. | +| Tool call header | 2 spaces | `▸` dim, tool name dim, extension `.magenta().dim()` | Preceded by blank line + 40-char `─` dim rule. | +| Tool params | 4 spaces × depth (`INDENT`) | keys dim, strings green, numbers/bools yellow, null dim | `print_params`, recursive. | +| Tool output/result | 4 spaces | `.dim()` per line | Capped at 20 lines unless `/toggle` or `GOOSE_SHOW_FULL_OUTPUT`. | +| Tool error | 4 spaces | `.red().dim()` | | +| Thinking header | 0 | `"Thinking:"` dim italic | Content is raw dim text, not markdown. Hidden by default. | +| Error | 2 spaces | `"error:"` red bold + plain message | `render_error`. | +| Session header | 2 spaces | ASCII goose art, `●` green, cyan model name, dim metadata | `display_session_info`. | +| Context bar | 2 spaces | green/yellow/red bar by usage % | `display_context_usage`. | +| Elapsed time | 2 spaces | dim, `⏱` prefix | Printed after each turn. | + +### Gaps identified + +1. **No visual distinction between user and assistant text** — user input + isn't echoed/styled at all during live chat. +2. **Tool call chrome is the same 3 colors regardless of status** — there is + no visual signal for pending/running vs. success vs. failure the way the + TUI's colored status dots (`○ ◑ ● ✗`) provide. +3. **Palette is inconsistent** — tool chrome uses fixed `console` colors + (dim/magenta/green/yellow/red) independent of the light/dark/ansi theme, + while assistant markdown color comes entirely from whichever `bat` theme + is active. The two rarely match. +4. **No consistent left margin** — assistant text sits at column 0, tool + headers at column 2, params/output at column 4; nothing lines up under a + common gutter the way Claude Code/Codex align role markers. +5. **No `NO_COLOR`/non-color styling test coverage**, and no unit tests at + all for the visual shape of tool call or error output (only for + `shorten_path`, `format_elapsed_time`, streaming-buffer edge cases). + +## 2. How the Ink TUI (`ui/text`) formats messages + +**Palette:** `ui/text/src/colors.tsx` + +```ts +CRANBERRY = "#C0354A" // accent / errors / user prompt +TEAL = "#3A7D7B" // success / ready status +GOLD = "#C4883A" // in-progress / selection +TEXT_PRIMARY = "#E8E4DF" // body text +TEXT_SECONDARY = "#8FA4BD" // titles / labels +TEXT_DIM = "#5A6D84" // secondary/hint text +RULE_COLOR = "#2E3D54" // borders/rules +``` + +### Per-element formatting + +| Element | Indent/prefix | Color | Spacing | +|---|---|---|---| +| User message | `❯ ` prefix | Prefix cranberry bold; text `TEXT_PRIMARY` bold | 1 blank line before | +| Assistant markdown | none (marked-terminal `tab: 2` for nested lists) | terminal default + marked-terminal ANSI | 1 blank line before each chunk | +| Tool call (collapsed) | rounded box, full width | border `CEDAR`/dim (normal), cranberry (failed), gold (selected); title `TEXT_SECONDARY` bold | 1 blank line before, fixed 3-line box | +| Tool status dots | — | `○` dim (pending), `◑` gold (in-progress), `●` teal (completed), `✗` cranberry (failed) | — | +| Tool call (expanded) | rounded box, paddingX 1 | border gold; labels `TEXT_SECONDARY` bold; body `TEXT_PRIMARY` | 1 blank line between sections | +| Error | flush left, `⚠ Error: ` header | all cranberry | 1 blank line before | +| Loading/"thinking" | spinner + text | spinner cranberry; status text dim italic | 1 blank line before | +| Input box | rounded border, paddingX 1, marginTop 1 | border `RULE_COLOR`; prompt cranberry bold; text `TEXT_PRIMARY` | — | +| Header/rule | 2-line header + rule | title bold; status teal/cranberry/dim; separators `RULE_COLOR` | — | + +### Key takeaways to bring to the CLI + +- A **consistent, small, mostly-grey/blue-grey palette** (`TEXT_PRIMARY`, + `TEXT_SECONDARY`, `TEXT_DIM`, one accent, one success, one error) rather + than ad hoc use of `red/green/yellow/cyan/magenta`. + Terminal 256-color approximations for the CLI: + - primary text → default/white + - secondary/title → `cyan`/`blue` (dim) + - dim/muted → `.dim()` + - accent (user prompt, spinner) → single accent color + - success → green, failure → red (kept minimal, only for outcomes) +- **Status dots** (`○ ◑ ● ✗`) as a single, consistent way to show + pending/running/success/failure for tool calls, instead of always using the + same `▸` glyph regardless of outcome. +- **One blank line before each logical message block**, never more, never a + trailing blank line after. +- A **consistent left gutter**: user/assistant/tool-status prefixes should + line up under the same column so the eye can scan down a single "rail". + +## 3. Claude Code and Codex conventions (from public reports/docs) + +Neither tool's source is fully available, but public write-ups, GitHub issues +and community deep-dives converge on a consistent set of conventions: + +- **Claude Code** ("Claude Code Internals" — Kotrotsos; `how-claude-code-works`; + TUICommander docs): + - Renders with Ink (React for terminals); mostly a **grayscale/dim palette** + for structural chrome, with color reserved for meaning: green = + success/user, blue = assistant, yellow = tool/pending, red = error, cyan = + informational, gray = muted text and borders. + - Each tool call is preceded by a small **colored status dot** (`●`) that + encodes state via color (dim while running, then green/red on + completion) — deliberately rendered as its own styled span, separated by + a plain space from the tool name, to avoid ANSI dim/bold reset bugs. + - Status/mode chrome lines at the bottom of the screen are consistently + **indented by 2 spaces** (`\033[2C`). + - Permission/confirmation prompts use a stronger accent (blue) separator + and highlighted selection, contrasting with the default gray rule. +- **Codex CLI** (GitHub issues `openai/codex#17879`, `#21130`, `#12200`): + - Ships with a fairly **flat, low-contrast default** (mostly terminal + default foreground + gray/dim) and has had repeated user requests for + *more* contrast between roles — validating that a subtle, mostly-gray + palette with a couple of clear accent colors (one for user, one for + assistant/system) is the right target rather than many colors. + - Community-proposed fixes for role clarity: **bold accent-colored prefix** + for the user role, **consistent indentation width** across + user/assistant text so the transcript stays visually aligned, and a + **subtle background tint** for user input where terminal background + detection allows it. + - Tool call / exec output is shown **inline**, generally indented and + dimmed relative to the primary response text, with exit code/duration + metadata appended to the header line rather than mixed into the body. + +### Common threads to adopt + +1. A restrained, mostly gray/dim palette; color used sparingly and + *semantically* (one accent for the user, one for status/success, one for + error) rather than a different named color per tool kind. +2. **Consistent indentation** that aligns role markers/prefixes into a single + scannable column. +3. **Status indicated by a small dot/marker**, colored by outcome, rather + than a static glyph. +4. Tool output rendered **visibly indented and dimmer** than primary + assistant text, so it reads as "supporting detail" rather than the main + thread of conversation. +5. **One blank line of separation** between logical turns/blocks, not more. + +## 4. Direction for goose CLI formatting changes + +Given the above, and that this change is **CLI-formatting-only** (no new +TUI, no rebuild of the rendering pipeline, no changes to `ui/desktop`), the +plan is to: + +- Echo the **user's message** in the CLI with a consistent accent-colored + prompt prefix (mirroring the TUI's `❯`), so the transcript shows clear + turn boundaries. +- Introduce a small set of **named style helpers** in `output.rs` backed by a + restrained palette (primary/secondary/dim/accent/success/error) so tool + chrome and headers stop hard-coding `magenta`/`yellow`/`cyan` ad hoc. + Keep using `bat` for markdown body text (no change to the markdown engine). +- Add **status-aware markers** for tool calls (running vs. done vs. failed) + using a colored dot, replacing the fixed `▸` used regardless of outcome. +- Normalize **indentation** so tool header / params / output share one + consistent left gutter, and align it with the new user-message prefix + width. +- Keep spacing to **one blank line** between blocks, removing any doubled + blank lines/rules that don't add information (e.g. the 40-char `─` rule + before every tool call, which is heavier than the TUI/Claude Code/Codex + equivalents). + +See the accompanying implementation plan and test suite for specifics. From f34b484b5531e9b3cca9b925997b3f14d3b24e8c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 16:03:44 +0000 Subject: [PATCH 04/10] feat(cli): add semantic formatting module and wire into output Introduce crates/goose-cli/src/session/formatting.rs with pure, testable helpers for a consistent, mostly-grey semantic palette (Role::Primary/Secondary/Muted/Accent/Success/Error), shared indentation constants, and status glyphs modeled on the TUI and tools like Claude Code / Codex. Wire these helpers into output.rs, input.rs, and completion.rs: - User messages are echoed with an accent-colored prompt glyph and gutter-aligned continuation lines, matching the live input prompt. - Tool call headers use a single indented line with a status glyph instead of a heavy horizontal rule, and get a done/error status footer after the tool response. - Parameters, tool output, and error lines use consistent indentation and muted/secondary roles instead of ad-hoc styling. Co-authored-by: Ben Godfrey --- crates/goose-cli/src/session/completion.rs | 5 +- crates/goose-cli/src/session/formatting.rs | 323 +++++++++++++++++++++ crates/goose-cli/src/session/input.rs | 3 +- crates/goose-cli/src/session/mod.rs | 1 + crates/goose-cli/src/session/output.rs | 224 +++++++++----- 5 files changed, 485 insertions(+), 71 deletions(-) create mode 100644 crates/goose-cli/src/session/formatting.rs diff --git a/crates/goose-cli/src/session/completion.rs b/crates/goose-cli/src/session/completion.rs index 410acd93565c..ecafb21f59ce 100644 --- a/crates/goose-cli/src/session/completion.rs +++ b/crates/goose-cli/src/session/completion.rs @@ -467,7 +467,10 @@ impl Highlighter for GooseCompleter { prompt: &'p str, _default: bool, ) -> Cow<'b, str> { - Cow::Borrowed(prompt) + // Color the prompt glyph with the same accent used for user + // messages elsewhere in the transcript, so the live prompt and + // the historical (`/resume`) view of the same turn look the same. + Cow::Owned(super::formatting::apply(super::formatting::Role::Accent, prompt).to_string()) } fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { diff --git a/crates/goose-cli/src/session/formatting.rs b/crates/goose-cli/src/session/formatting.rs new file mode 100644 index 000000000000..f72c887daa31 --- /dev/null +++ b/crates/goose-cli/src/session/formatting.rs @@ -0,0 +1,323 @@ +//! Pure, testable formatting helpers for CLI message rendering. +//! +//! These functions build the *text* of what gets printed (indentation, +//! glyphs, structure) without performing any I/O, so the visual shape of +//! CLI output can be unit tested directly. `output.rs` calls into these +//! helpers and applies `console` styling (via [`role_style`]/[`apply`]) +//! before writing to stdout. +//! +//! The palette here intentionally mirrors the Ink TUI (`ui/text`): a +//! restrained, mostly dim/grey scheme with a single accent color, plus +//! green/red reserved for outcomes. + +use console::{style, Color, StyledObject}; + +/// Left margin used for top-level chrome: user prompt, tool headers, +/// status lines, and error messages. Matches the visual width of the +/// user-message prompt glyph (`USER_PROMPT_GLYPH` + a space) so everything +/// lines up in a single gutter. +pub const GUTTER: &str = " "; + +/// Indentation used for content nested under a tool header (parameters, +/// tool output). +pub const PARAM_INDENT: &str = " "; + +/// Glyph used to mark a user-submitted message. +pub const USER_PROMPT_GLYPH: &str = "❯"; + +/// Lifecycle state of a tool call, used to pick a status glyph/color. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolStatus { + Running, + Success, + Error, +} + +/// Semantic roles used to keep colors consistent across the CLI, rather +/// than picking a color ad hoc at each call site. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + /// Primary conversational text (assistant/user body copy). + Primary, + /// Secondary/structural text: labels, tool names, headers. + Secondary, + /// Muted/auxiliary text: parameter values, timestamps, hints. + Muted, + /// The single accent used for the user prompt glyph and pending state. + Accent, + /// Successful/completed outcomes. + Success, + /// Errors and failures. + Error, +} + +/// The concrete color/weight for a [`Role`]. Kept as plain data (rather +/// than a `console::StyledObject`) so it can be compared in tests without +/// depending on ANSI rendering or terminal color support. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RoleStyle { + pub color: Option, + pub dim: bool, + pub bold: bool, + pub italic: bool, +} + +/// The color/weight to use for a given semantic role. +pub fn role_style(role: Role) -> RoleStyle { + match role { + Role::Primary => RoleStyle { + color: None, + dim: false, + bold: false, + italic: false, + }, + Role::Secondary => RoleStyle { + color: Some(Color::Cyan), + dim: true, + bold: false, + italic: false, + }, + Role::Muted => RoleStyle { + color: None, + dim: true, + bold: false, + italic: false, + }, + Role::Accent => RoleStyle { + color: Some(Color::Cyan), + dim: false, + bold: true, + italic: false, + }, + Role::Success => RoleStyle { + color: Some(Color::Green), + dim: false, + bold: false, + italic: false, + }, + Role::Error => RoleStyle { + color: Some(Color::Red), + dim: false, + bold: false, + italic: false, + }, + } +} + +/// Apply a [`Role`]'s style to a piece of text, returning a `console` +/// styled object ready to print. +pub fn apply(role: Role, text: &str) -> StyledObject<&str> { + let RoleStyle { + color, + dim, + bold, + italic, + } = role_style(role); + let mut styled = style(text); + if let Some(color) = color { + styled = styled.fg(color); + } + if dim { + styled = styled.dim(); + } + if bold { + styled = styled.bold(); + } + if italic { + styled = styled.italic(); + } + styled +} + +/// The glyph shown next to a tool call to indicate its status. +pub fn status_glyph(status: ToolStatus) -> &'static str { + match status { + ToolStatus::Running => "○", + ToolStatus::Success => "●", + ToolStatus::Error => "✗", + } +} + +/// The semantic role used to color a status glyph. +pub fn status_role(status: ToolStatus) -> Role { + match status { + ToolStatus::Running => Role::Accent, + ToolStatus::Success => Role::Success, + ToolStatus::Error => Role::Error, + } +} + +/// Format a user-submitted message for echo into the transcript: the +/// first line is marked with the accent prompt glyph, continuation lines +/// align under it using [`GUTTER`]. +pub fn format_user_message_plain(text: &str) -> String { + let mut lines = text.lines(); + let mut out = String::new(); + if let Some(first) = lines.next() { + out.push_str(USER_PROMPT_GLYPH); + out.push(' '); + out.push_str(first); + } + for line in lines { + out.push('\n'); + out.push_str(GUTTER); + out.push_str(line); + } + out +} + +/// Format the plain-text (no ANSI) tool-call header line: gutter + status +/// glyph + tool name + optional extension name. +pub fn format_tool_header_plain(tool: &str, extension: &str, status: ToolStatus) -> String { + let glyph = status_glyph(status); + if extension.is_empty() { + format!("{GUTTER}{glyph} {tool}") + } else { + format!("{GUTTER}{glyph} {tool} {extension}") + } +} + +/// Format the plain-text tool-call status footer shown once a tool call +/// has finished (success or error). Returns `None` when there is nothing +/// to show (e.g. while still running). +pub fn format_tool_status_line_plain(status: ToolStatus) -> Option { + match status { + ToolStatus::Running => None, + ToolStatus::Success => Some(format!("{GUTTER}{} done", status_glyph(status))), + ToolStatus::Error => Some(format!("{GUTTER}{} error", status_glyph(status))), + } +} + +/// Format the plain-text error line shown for CLI-level errors. +pub fn format_error_line_plain(message: &str) -> String { + format!( + "{GUTTER}{} error: {message}", + status_glyph(ToolStatus::Error) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_message_is_echoed_with_accent_prompt_glyph() { + assert_eq!( + format_user_message_plain("hello"), + format!("{USER_PROMPT_GLYPH} hello") + ); + } + + #[test] + fn multiline_user_message_continuation_lines_align_under_the_gutter() { + assert_eq!( + format_user_message_plain("first\nsecond\nthird"), + format!("{USER_PROMPT_GLYPH} first\n{GUTTER}second\n{GUTTER}third") + ); + } + + #[test] + fn running_tool_header_uses_the_hollow_circle_glyph() { + let header = format_tool_header_plain("shell", "", ToolStatus::Running); + assert!( + header.contains('○'), + "running tool header should use the ○ glyph, got: {header:?}" + ); + } + + #[test] + fn tool_header_has_no_heavy_horizontal_rule() { + let header = format_tool_header_plain("shell", "", ToolStatus::Running); + assert!( + !header.contains('─'), + "tool header should not draw a full-width rule, got: {header:?}" + ); + } + + #[test] + fn tool_header_is_a_single_line_starting_with_the_gutter() { + let header = format_tool_header_plain("shell", "developer", ToolStatus::Running); + assert_eq!(header.lines().count(), 1); + assert!(header.starts_with(GUTTER)); + assert!(header.contains("shell")); + assert!(header.contains("developer")); + } + + #[test] + fn tool_status_glyph_differs_by_outcome() { + let running = status_glyph(ToolStatus::Running); + let success = status_glyph(ToolStatus::Success); + let error = status_glyph(ToolStatus::Error); + assert_ne!(running, success); + assert_ne!(success, error); + assert_ne!(running, error); + } + + #[test] + fn successful_tool_calls_get_a_status_footer() { + let footer = format_tool_status_line_plain(ToolStatus::Success); + assert!(footer.is_some()); + let footer = footer.unwrap(); + assert!(footer.starts_with(GUTTER)); + assert!(footer.contains(status_glyph(ToolStatus::Success))); + } + + #[test] + fn failed_tool_calls_get_a_status_footer_with_the_error_glyph() { + let footer = format_tool_status_line_plain(ToolStatus::Error); + assert!(footer.is_some()); + let footer = footer.unwrap(); + assert!(footer.contains(status_glyph(ToolStatus::Error))); + } + + #[test] + fn error_lines_are_indented_under_the_gutter_with_a_glyph() { + let line = format_error_line_plain("boom"); + assert!(line.starts_with(GUTTER)); + assert!(line.contains(status_glyph(ToolStatus::Error))); + assert!(line.contains("boom")); + } + + #[test] + fn pending_tool_status_uses_the_accent_role() { + assert_eq!(status_role(ToolStatus::Running), Role::Accent); + } + + #[test] + fn accent_role_has_a_distinct_color_set() { + assert!( + role_style(Role::Accent).color.is_some(), + "accent role should have an explicit color, not fall back to the default" + ); + } + + #[test] + fn success_and_error_roles_map_to_green_and_red() { + assert_eq!(role_style(Role::Success).color, Some(Color::Green)); + assert_eq!(role_style(Role::Error).color, Some(Color::Red)); + } + + #[test] + fn muted_role_is_dim_and_uses_no_extra_color() { + let muted = role_style(Role::Muted); + assert!(muted.dim); + assert_eq!(muted.color, None); + } + + #[test] + fn secondary_role_is_distinct_from_muted() { + assert_ne!(role_style(Role::Secondary), role_style(Role::Muted)); + } + + #[test] + fn apply_is_a_no_op_on_plain_text_when_colors_are_disabled() { + console::set_colors_enabled(false); + assert_eq!(apply(Role::Error, "boom").to_string(), "boom"); + assert_eq!(apply(Role::Primary, "hello").to_string(), "hello"); + } + + #[test] + fn param_indent_is_wider_than_the_gutter() { + assert!(PARAM_INDENT.len() > GUTTER.len()); + } +} diff --git a/crates/goose-cli/src/session/input.rs b/crates/goose-cli/src/session/input.rs index 6b9da991afab..ec19b36fa59e 100644 --- a/crates/goose-cli/src/session/input.rs +++ b/crates/goose-cli/src/session/input.rs @@ -150,7 +150,8 @@ pub fn get_input( rustyline::EventHandler::Conditional(Box::new(CtrlCHandler::new(completion_cache))), ); - let input = match editor.readline("> ") { + let prompt = format!("{} ", super::formatting::USER_PROMPT_GLYPH); + let input = match editor.readline(&prompt) { Ok(text) => text, Err(e) => match e { rustyline::error::ReadlineError::Interrupted => return Ok(InputResult::Exit), diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 1c02ececb962..6e0f48510b5a 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -3,6 +3,7 @@ mod completion; pub mod editor; mod elicitation; mod export; +mod formatting; mod input; mod output; pub mod streaming_buffer; diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 17648a08921b..eb50f7e49f09 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -20,6 +20,7 @@ use std::io::{Error, IsTerminal, Write}; use std::path::Path; use std::time::Duration; +use super::formatting::{self, Role, ToolStatus}; use super::streaming_buffer::MarkdownBuffer; pub const DEFAULT_MIN_PRIORITY: f32 = 0.0; @@ -221,6 +222,7 @@ pub fn set_thinking_message(s: &String) { pub fn render_message(message: &Message, debug: bool) { let theme = get_theme(); + let is_user_message = message.role == rmcp::model::Role::User; for content in &message.content { match content { @@ -235,6 +237,7 @@ pub fn render_message(message: &Message, debug: bool) { println!("action_required(elicitation_response): {}", id) } }, + MessageContent::Text(text) if is_user_message => print_user_message(&text.text), MessageContent::Text(text) => print_markdown(&text.text, theme), MessageContent::ToolRequest(req) => render_tool_request(req, theme, debug), MessageContent::ToolResponse(resp) => render_tool_response(resp, debug), @@ -280,6 +283,7 @@ pub fn render_message_streaming( debug: bool, ) { let theme = get_theme(); + let is_user_message = message.role == rmcp::model::Role::User; for content in &message.content { if !matches!(content, MessageContent::Thinking(_)) { @@ -290,6 +294,11 @@ pub fn render_message_streaming( } match content { + MessageContent::Text(text) if is_user_message => { + if let Some(safe_content) = buffer.push(&text.text) { + print_user_message(&safe_content); + } + } MessageContent::Text(text) => { if let Some(safe_content) = buffer.push(&text.text) { print_markdown(&safe_content, theme); @@ -459,7 +468,7 @@ fn should_show_thinking() -> bool { fn render_thinking(text: &str, theme: Theme) { if should_show_thinking() { - println!("\n{}", style("Thinking:").dim().italic()); + println!("\n{}", formatting::apply(Role::Muted, "Thinking:").italic()); print_markdown(text, theme); } } @@ -473,10 +482,10 @@ fn render_thinking_streaming( if should_show_thinking() { flush_markdown_buffer(buffer, theme); if !*header_shown { - println!("\n{}", style("Thinking:").dim().italic()); + println!("\n{}", formatting::apply(Role::Muted, "Thinking:").italic()); *header_shown = true; } - print!("{}", style(text).dim()); + print!("{}", formatting::apply(Role::Muted, text)); let _ = std::io::stdout().flush(); } } @@ -528,9 +537,21 @@ fn render_tool_response(resp: &ToolResponse, debug: bool) { print_tool_output(&text.text); } } + if let Some(footer) = formatting::format_tool_status_line_plain(ToolStatus::Success) { + println!( + "{}", + formatting::apply(formatting::status_role(ToolStatus::Success), &footer) + ); + } } Err(e) => { - println!(" {}", style(e.to_string()).red().dim()); + let line = format!( + "{}{} {}", + formatting::GUTTER, + formatting::status_glyph(ToolStatus::Error), + e + ); + println!("{}", formatting::apply(Role::Error, &line)); } } } @@ -551,25 +572,40 @@ fn print_tool_output(text: &str) { let lines: Vec<&str> = text.lines().collect(); if lines.len() <= max_lines { for line in &lines { - println!(" {}", style(line).dim()); + println!( + "{}{}", + formatting::PARAM_INDENT, + formatting::apply(Role::Muted, line) + ); } } else { let head = max_lines / 2; let tail = max_lines - head; for line in &lines[..head] { - println!(" {}", style(line).dim()); + println!( + "{}{}", + formatting::PARAM_INDENT, + formatting::apply(Role::Muted, line) + ); } println!( - " {}", - style(format!( - "... ({} lines hidden, /toggle to show all)", - lines.len() - head - tail - )) - .dim() + "{}{}", + formatting::PARAM_INDENT, + formatting::apply( + Role::Muted, + &format!( + "... ({} lines hidden, /toggle to show all)", + lines.len() - head - tail + ) + ) .italic() ); for line in &lines[lines.len() - tail..] { - println!(" {}", style(line).dim()); + println!( + "{}{}", + formatting::PARAM_INDENT, + formatting::apply(Role::Muted, line) + ); } } } @@ -583,7 +619,10 @@ fn is_file_tool_name(name: &str) -> bool { } pub fn render_error(message: &str) { - println!("\n {} {}\n", style("error:").red().bold(), message); + println!( + "\n{}\n", + formatting::apply(Role::Error, &formatting::format_error_line_plain(message)) + ); } pub fn render_prompts(prompts: &HashMap>) { @@ -683,9 +722,10 @@ fn render_text_editor_request(call: &CallToolRequestParams, debug: bool) { if let Some(args) = &call.arguments { if let Some(Value::String(path)) = args.get("path") { println!( - " {} {}", - style("path").dim(), - style(shorten_path(path, debug)).dim() + "{}{} {}", + formatting::PARAM_INDENT, + formatting::apply(Role::Secondary, "path"), + formatting::apply(Role::Muted, &shorten_path(path, debug)) ); } @@ -724,12 +764,17 @@ fn render_execute_code_request(call: &CallToolRequestParams, debug: bool) { let count = tool_graph.len(); let plural = if count == 1 { "" } else { "s" }; + let status = ToolStatus::Running; println!(); println!( - " {} {} {} tool call{}", - style("▸").dim(), - style("execute").dim(), - style(count).dim(), + "{}{} {} {} tool call{}", + formatting::GUTTER, + formatting::apply( + formatting::status_role(status), + formatting::status_glyph(status) + ), + formatting::apply(Role::Secondary, "execute"), + formatting::apply(Role::Muted, &count.to_string()), plural, ); @@ -782,7 +827,12 @@ fn render_delegate_request(call: &CallToolRequestParams, debug: bool) { if let Some(args) = &call.arguments { if let Some(Value::String(source)) = args.get("source") { - println!(" {} {}", style("source").dim(), style(source).dim()); + println!( + "{}{} {}", + formatting::PARAM_INDENT, + formatting::apply(Role::Secondary, "source"), + formatting::apply(Role::Muted, source) + ); } if let Some(Value::String(instructions)) = args.get("instructions") { @@ -792,14 +842,19 @@ fn render_delegate_request(call: &CallToolRequestParams, debug: bool) { instructions.clone() }; println!( - " {} {}", - style("instructions").dim(), - style(display).dim() + "{}{} {}", + formatting::PARAM_INDENT, + formatting::apply(Role::Secondary, "instructions"), + formatting::apply(Role::Muted, &display) ); } if let Some(Value::Object(params)) = args.get("parameters") { - println!(" {}:", style("parameters").dim()); + println!( + "{}{}:", + formatting::PARAM_INDENT, + formatting::apply(Role::Secondary, "parameters") + ); print_params(&Some(params.clone()), 2, debug); } @@ -823,7 +878,12 @@ fn render_todo_request(call: &CallToolRequestParams, _debug: bool) { if let Some(args) = &call.arguments { if let Some(Value::String(content)) = args.get("content") { - println!(" {} {}", style("content").dim(), style(content).dim()); + println!( + "{}{} {}", + formatting::PARAM_INDENT, + formatting::apply(Role::Secondary, "content"), + formatting::apply(Role::Muted, content) + ); } } println!(); @@ -878,10 +938,18 @@ pub fn render_subagent_tool_call( return render_subagent_tool_graph(subagent_id, tool_graph); } } + let status = ToolStatus::Running; let tool_header = format!( - " {} {}", - style("▸").dim(), - style(format_subagent_tool_call_message(subagent_id, tool_name)).dim(), + "{}{} {}", + formatting::GUTTER, + formatting::apply( + formatting::status_role(status), + formatting::status_glyph(status) + ), + formatting::apply( + Role::Secondary, + &format_subagent_tool_call_message(subagent_id, tool_name) + ), ); println!(); println!("{}", tool_header); @@ -893,13 +961,18 @@ fn render_subagent_tool_graph(subagent_id: &str, tool_graph: &[Value]) { let short_id = subagent_id.rsplit('_').next().unwrap_or(subagent_id); let count = tool_graph.len(); let plural = if count == 1 { "" } else { "s" }; + let status = ToolStatus::Running; println!(); println!( - " {} {} {} {} tool call{}", - style("▸").dim(), - style(format!("[subagent:{}]", short_id)).dim(), - style("execute_typescript").dim(), - style(count).dim(), + "{}{} {} {} {} tool call{}", + formatting::GUTTER, + formatting::apply( + formatting::status_role(status), + formatting::status_glyph(status) + ), + formatting::apply(Role::Secondary, &format!("[subagent:{}]", short_id)), + formatting::apply(Role::Secondary, "execute_typescript"), + formatting::apply(Role::Muted, &count.to_string()), plural, ); @@ -940,19 +1013,22 @@ fn render_subagent_tool_graph(subagent_id: &str, tool_graph: &[Value]) { fn print_tool_header(call: &CallToolRequestParams) { let (tool, extension) = split_tool_name(&call.name); - let tool_header = if extension.is_empty() { - format!(" {} {}", style("▸").dim(), style(&tool).dim()) - } else { - format!( - " {} {} {}", - style("▸").dim(), - style(&tool).dim(), - style(extension).magenta().dim(), - ) - }; + let status = ToolStatus::Running; + let glyph = formatting::status_glyph(status); + // Build the plain (unstyled) line once so the header's structure stays + // in lockstep with `format_tool_header_plain`'s spec, then recolor the + // status glyph separately from the tool/extension label. + let plain = formatting::format_tool_header_plain(&tool, &extension, status); + let label = plain + .strip_prefix(&format!("{}{} ", formatting::GUTTER, glyph)) + .unwrap_or(&plain); println!(); - println!(" {}", style("─".repeat(40)).dim()); - println!("{}", tool_header); + println!( + "{}{} {}", + formatting::GUTTER, + formatting::apply(formatting::status_role(status), glyph), + formatting::apply(Role::Secondary, label), + ); } // Respect NO_COLOR, as https://crates.io/crates/console already does @@ -961,6 +1037,22 @@ pub fn env_no_color() -> bool { std::env::var_os("NO_COLOR").is_none() } +/// Print a user-submitted message with the accent-colored prompt glyph, +/// aligning any continuation lines under it. Mirrors the Ink TUI's `❯` +/// prefix so user turns read as clearly distinct from assistant/tool +/// content when browsing history (e.g. `/resume`). +fn print_user_message(text: &str) { + let plain = formatting::format_user_message_plain(text); + let Some(rest) = plain.strip_prefix(formatting::USER_PROMPT_GLYPH) else { + return; + }; + print!( + "{}", + formatting::apply(Role::Accent, formatting::USER_PROMPT_GLYPH) + ); + println!("{}", formatting::apply(Role::Primary, rest)); +} + fn print_markdown(content: &str, theme: Theme) { if std::io::stdout().is_terminal() { if let Some((before, table, after)) = extract_markdown_table(content) { @@ -1156,8 +1248,6 @@ fn print_table(table_lines: &[&str], theme: Theme) { print_markdown_raw(&table_str, theme); } -const INDENT: &str = " "; - fn print_value_with_prefix(prefix: &String, value: &Value, debug: bool) { let prefix_width = measure_text_width(prefix.as_str()); print!("{}", prefix); @@ -1169,28 +1259,28 @@ fn print_value(value: &Value, debug: bool, reserve_width: usize) { .size_checked() .map(|(_h, w)| (w as usize).saturating_sub(reserve_width)); let show_full = get_show_full_tool_output(); - let formatted = match value { + let text = match value { Value::String(s) => match (max_width, debug || show_full) { - (Some(w), false) if s.len() > w => style(safe_truncate(s, w)), - _ => style(s.to_string()), - } - .green(), - Value::Number(n) => style(n.to_string()).yellow(), - Value::Bool(b) => style(b.to_string()).yellow(), - Value::Null => style("null".to_string()).dim(), + (Some(w), false) if s.len() > w => safe_truncate(s, w), + _ => s.to_string(), + }, + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "null".to_string(), _ => unreachable!(), }; - println!("{}", formatted); + println!("{}", formatting::apply(Role::Muted, &text)); } fn print_params(value: &Option, depth: usize, debug: bool) { - let indent = INDENT.repeat(depth); + let indent = formatting::PARAM_INDENT.repeat(depth); if let Some(json_object) = value { for (key, val) in json_object.iter() { + let key_label = formatting::apply(Role::Secondary, key); match val { Value::Object(obj) => { - println!("{}{}:", indent, style(key).dim()); + println!("{}{}:", indent, key_label); print_params(&Some(obj.clone()), depth + 1, debug); } Value::Array(arr) => { @@ -1216,29 +1306,25 @@ fn print_params(value: &Option, depth: usize, debug: bool) { .collect(); let joined_values = values.join(", "); print_value_with_prefix( - &format!("{}{}: ", indent, style(key).dim()), + &format!("{}{}: ", indent, key_label), &Value::String(joined_values), debug, ); } else { // Use the original multi-line format for complex arrays - println!("{}{}:", indent, style(key).dim()); + println!("{}{}:", indent, key_label); for item in arr.iter() { if let Value::Object(obj) = item { - println!("{}{}- ", indent, INDENT); + println!("{}{}- ", indent, formatting::PARAM_INDENT); print_params(&Some(obj.clone()), depth + 2, debug); } else { - println!("{}{}- {}", indent, INDENT, item); + println!("{}{}- {}", indent, formatting::PARAM_INDENT, item); } } } } _ => { - print_value_with_prefix( - &format!("{}{}: ", indent, style(key).dim()), - val, - debug, - ); + print_value_with_prefix(&format!("{}{}: ", indent, key_label), val, debug); } } } From 77914556d77890bfa790d58f4b9e9113d3643b09 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 16:03:50 +0000 Subject: [PATCH 05/10] docs: add CLI formatting implementation plan Co-authored-by: Ben Godfrey --- docs/cli-formatting-plan.md | 112 ++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/cli-formatting-plan.md diff --git a/docs/cli-formatting-plan.md b/docs/cli-formatting-plan.md new file mode 100644 index 000000000000..a19ac6fa1420 --- /dev/null +++ b/docs/cli-formatting-plan.md @@ -0,0 +1,112 @@ +# CLI message formatting — implementation plan + +Based on `docs/cli-formatting-research.md` and the spec tests in +`crates/goose-cli/src/session/formatting.rs`. Scope is strictly +`crates/goose-cli` visual/formatting code — no TUI work, no changes outside +formatting, no changes to `ui/desktop`. + +## Goals + +1. Give the user's own messages a visible, consistently styled place in the + transcript (currently invisible during live chat). +2. Replace the single static `▸` tool marker + 40-char rule with a small, + status-aware marker (pending/success/error) and a lighter-weight header, + closer to the TUI's `○ ◑ ● ✗` convention and to Claude Code/Codex's + colored status dots. +3. Centralize color choices behind a small set of semantic roles + (primary/secondary/muted/accent/success/error) instead of ad hoc + `magenta`/`yellow`/`cyan` calls, so the palette reads as one coherent, + mostly-grey/dim scheme with a couple of clear accents — matching the + TUI's palette and the "grey chrome + sparing color" pattern used by + Claude Code/Codex. +4. Normalize indentation so top-level chrome (user prompt, tool header, + status footer, error) shares one 2-space gutter, and nested content + (params, tool output) shares the existing 4-space indent — so everything + lines up under a single rail that's easy to scan. +5. Keep exactly one blank line between message blocks (no doubled + separators). + +## Non-goals + +- No new TUI, no Ink/React work. +- No change to the underlying markdown engine (`bat`) or its light/dark/ansi + theme system — only the *chrome* around tool calls/errors/user echo. +- No change to `ui/desktop`. +- No change to JSON / stream-json output modes (`emit_stream_event`) — those + are structured output for tooling, not human-facing formatting. + +## Steps + +1. **`crates/goose-cli/src/session/formatting.rs`** (new, already added with + failing tests): flesh out the pure formatting functions to their target + behavior: + - `GUTTER` (2 spaces), `USER_PROMPT_GLYPH` (`❯`). + - `ToolStatus { Running, Success, Error }` → `status_glyph` (`○ ● ✗`). + - `Role { Primary, Secondary, Muted, Accent, Success, Error }` → + `role_style` returning `RoleStyle { color, dim, bold, italic }`: + - `Primary`: no color/no decoration (inherits terminal default — + matches TUI's `TEXT_PRIMARY` and Claude Code's default body text). + - `Secondary`: cyan + dim (tool names, section labels). + - `Muted`: dim only, no color (param values, hints, secondary detail — + matches TUI's `TEXT_DIM`). + - `Accent`: cyan + bold (user prompt glyph, pending tool status). + - `Success`: green. + - `Error`: red. + - `format_user_message_plain`, `format_tool_header_plain`, + `format_tool_status_line_plain`, `format_error_line_plain`: pure string + builders matching the glyph/gutter rules above. + - Add a small `apply(role: Role, text: &str) -> console::StyledObject<&str>` + helper that turns a `RoleStyle` into an actual `console::style(...)` + call, used by `output.rs` (not itself deeply unit tested beyond + `role_style`, since it's a thin, side-effect-free wrapper). + +2. **`crates/goose-cli/src/session/output.rs`** — wire the new helpers in: + - `print_tool_header`: drop the 40-char `─` rule; use + `format_tool_header_plain` + `apply(Role::Accent, glyph)` for the + status marker (always `Running`/pending at request time, since the + CLI prints tool calls before their result is known) and + `apply(Role::Secondary, ...)` for the tool/extension name. + - `render_tool_response`: after printing tool output (or immediately, if + there is no output), print the new status footer via + `format_tool_status_line_plain` — `Success` (green `●`) on `Ok`, + `Error` (red `✗`) on `Err`, using `apply(status_role(status), ...)`. + - `render_error`: rebuild on `format_error_line_plain` + + `apply(Role::Error, ...)`, keeping the leading/trailing blank line for + separation. + - `print_params` / `print_tool_output`: keep the existing 4-space nested + indent, but recolor via `Role::Muted`/`Role::Secondary` instead of + hard-coded `.dim()/.green()/.yellow()` so values read consistently with + the rest of the palette. + - `render_thinking`/`render_thinking_streaming`: recolor the `Thinking:` + header via `Role::Muted` (unchanged behavior otherwise — still hidden + by default). + +3. **`crates/goose-cli/src/session/mod.rs`** — echo the user's message: + - In `handle_message_input` (where the user's text is currently only + pushed to history), call `output::render_user_message(&content)` + before showing the thinking spinner, so every submitted turn is + visible in the transcript with the same accent-colored `❯` prefix used + by the TUI. + - Leave `render_message_history` (used for `/resume` replay) rendering + user text through the same new helper for consistency instead of + falling back to raw markdown for user turns. + +4. **Tests**: extend `crates/goose-cli/src/session/formatting.rs`'s test + module only as needed to lock in the final `role_style`/`status_glyph` + mapping (the existing 14 tests already specify the target values). Add + a couple of `output.rs`-level tests if practical (e.g. a test that + `render_error`'s underlying string matches `format_error_line_plain`). + +5. **Docs**: no user-facing docs changes needed (internal chrome only); the + two new files in `docs/` capture the research and plan for future + maintainers. + +## Verification + +- `cargo fmt` +- `cargo test -p goose-cli` (all tests, including the 14 new ones, green) +- `cargo clippy --all-targets -- -D warnings` +- Manual smoke check: run `goose run -t "list files in /tmp"` (or similar) + in a real terminal and confirm the transcript reads top-to-bottom with a + visible user line, status-aware tool markers, and a single blank line + between blocks. From b63c24cbfca337c6795bf69e2cec0bd8dd6fa140 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 16:17:58 +0000 Subject: [PATCH 06/10] fix(cli): address formatting review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix issues found by an independent GPT-5 review of the formatting change: - Suppress the tool-call status footer for requests whose header was never shown (e.g. the internal `load` tool, or an unparseable tool call), so no orphaned '● done' line appears with nothing above it. - Route failed tool responses through the same error-line formatter used elsewhere, instead of a bespoke inline string. - format_user_message_plain now always emits the prompt glyph, even for an empty message, instead of silently rendering nothing. - Make Role::Secondary colorless (bold-only) instead of reusing Accent's cyan at a different weight, so Accent stays the only splash of color in an otherwise grey/dim palette. - Route the execute/subagent tool-graph step lines through formatting::apply(Role::Muted, ..)/PARAM_INDENT instead of ad hoc style(...).dim() calls, for one source of truth on tool-call styling. - Fix a test that mutated console's global color-enabled state without restoring it, which could leak across tests running in the same process. - Add tests: exact-string assertions for the tool status footer, an empty-message case, a Running-status no-footer case, a Secondary-is- colorless case, and coverage for the new footer-suppression bookkeeping. Co-authored-by: Ben Godfrey --- crates/goose-cli/src/session/formatting.rs | 69 +++++++++++++----- crates/goose-cli/src/session/output.rs | 82 ++++++++++++++++------ 2 files changed, 109 insertions(+), 42 deletions(-) diff --git a/crates/goose-cli/src/session/formatting.rs b/crates/goose-cli/src/session/formatting.rs index f72c887daa31..7e28a970a00f 100644 --- a/crates/goose-cli/src/session/formatting.rs +++ b/crates/goose-cli/src/session/formatting.rs @@ -39,11 +39,15 @@ pub enum ToolStatus { pub enum Role { /// Primary conversational text (assistant/user body copy). Primary, - /// Secondary/structural text: labels, tool names, headers. + /// Secondary/structural text: labels, tool names, headers. Bold but + /// colorless, so it stands out from `Muted` by weight rather than hue, + /// keeping the palette mostly grey. Secondary, /// Muted/auxiliary text: parameter values, timestamps, hints. Muted, - /// The single accent used for the user prompt glyph and pending state. + /// The single accent color used for the user prompt glyph and pending + /// state. Kept distinct from every other role's color, since it's the + /// only splash of hue in an otherwise grey/dim palette. Accent, /// Successful/completed outcomes. Success, @@ -72,9 +76,9 @@ pub fn role_style(role: Role) -> RoleStyle { italic: false, }, Role::Secondary => RoleStyle { - color: Some(Color::Cyan), - dim: true, - bold: false, + color: None, + dim: false, + bold: true, italic: false, }, Role::Muted => RoleStyle { @@ -149,12 +153,12 @@ pub fn status_role(status: ToolStatus) -> Role { /// Format a user-submitted message for echo into the transcript: the /// first line is marked with the accent prompt glyph, continuation lines -/// align under it using [`GUTTER`]. +/// align under it using [`GUTTER`]. Always includes the glyph, even for +/// an empty message, so a user turn is never rendered as nothing at all. pub fn format_user_message_plain(text: &str) -> String { let mut lines = text.lines(); - let mut out = String::new(); + let mut out = String::from(USER_PROMPT_GLYPH); if let Some(first) = lines.next() { - out.push_str(USER_PROMPT_GLYPH); out.push(' '); out.push_str(first); } @@ -208,6 +212,11 @@ mod tests { ); } + #[test] + fn empty_user_message_still_renders_the_prompt_glyph() { + assert_eq!(format_user_message_plain(""), USER_PROMPT_GLYPH); + } + #[test] fn multiline_user_message_continuation_lines_align_under_the_gutter() { assert_eq!( @@ -255,19 +264,23 @@ mod tests { #[test] fn successful_tool_calls_get_a_status_footer() { - let footer = format_tool_status_line_plain(ToolStatus::Success); - assert!(footer.is_some()); - let footer = footer.unwrap(); - assert!(footer.starts_with(GUTTER)); - assert!(footer.contains(status_glyph(ToolStatus::Success))); + assert_eq!( + format_tool_status_line_plain(ToolStatus::Success), + Some(format!("{GUTTER}● done")) + ); } #[test] fn failed_tool_calls_get_a_status_footer_with_the_error_glyph() { - let footer = format_tool_status_line_plain(ToolStatus::Error); - assert!(footer.is_some()); - let footer = footer.unwrap(); - assert!(footer.contains(status_glyph(ToolStatus::Error))); + assert_eq!( + format_tool_status_line_plain(ToolStatus::Error), + Some(format!("{GUTTER}✗ error")) + ); + } + + #[test] + fn running_tool_calls_get_no_status_footer() { + assert_eq!(format_tool_status_line_plain(ToolStatus::Running), None); } #[test] @@ -309,11 +322,29 @@ mod tests { assert_ne!(role_style(Role::Secondary), role_style(Role::Muted)); } + #[test] + fn secondary_role_is_colorless_so_accent_stays_the_only_hue() { + assert_eq!( + role_style(Role::Secondary).color, + None, + "Secondary should stand out from Muted by weight (bold), not by \ + borrowing Accent's color, to keep the palette mostly grey" + ); + } + #[test] fn apply_is_a_no_op_on_plain_text_when_colors_are_disabled() { + // `console`'s color setting is process-global, so save/restore it + // rather than leaking `false` into other tests that run in the + // same process. + let was_enabled = console::colors_enabled(); console::set_colors_enabled(false); - assert_eq!(apply(Role::Error, "boom").to_string(), "boom"); - assert_eq!(apply(Role::Primary, "hello").to_string(), "hello"); + let result = std::panic::catch_unwind(|| { + assert_eq!(apply(Role::Error, "boom").to_string(), "boom"); + assert_eq!(apply(Role::Primary, "hello").to_string(), "hello"); + }); + console::set_colors_enabled(was_enabled); + result.unwrap(); } #[test] diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index eb50f7e49f09..c9a865df8665 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -490,6 +490,21 @@ fn render_thinking_streaming( } } +thread_local! { + /// Ids of tool requests whose header was suppressed (e.g. the internal + /// `load` tool), so the matching response doesn't print an orphaned + /// status footer with no header above it. + static HEADERLESS_TOOL_IDS: RefCell> = RefCell::new(std::collections::HashSet::new()); +} + +fn mark_tool_header_suppressed(id: &str) { + HEADERLESS_TOOL_IDS.with(|ids| ids.borrow_mut().insert(id.to_string())); +} + +fn take_tool_header_was_suppressed(id: &str) -> bool { + HEADERLESS_TOOL_IDS.with(|ids| ids.borrow_mut().remove(id)) +} + fn render_tool_request(req: &ToolRequest, theme: Theme, debug: bool) { match &req.tool_call { Ok(call) => match call.name.to_string().as_str() { @@ -499,15 +514,19 @@ fn render_tool_request(req: &ToolRequest, theme: Theme, debug: bool) { "delegate" => render_delegate_request(call, debug), "subagent" => render_delegate_request(call, debug), "todo__write" => render_todo_request(call, debug), - "load" => {} + "load" => mark_tool_header_suppressed(&req.id), _ => render_default_request(call, debug), }, - Err(e) => print_markdown(&e.to_string(), theme), + Err(e) => { + mark_tool_header_suppressed(&req.id); + print_markdown(&e.to_string(), theme); + } } } fn render_tool_response(resp: &ToolResponse, debug: bool) { let config = Config::global(); + let show_footer = !take_tool_header_was_suppressed(&resp.id); match &resp.tool_result { Ok(result) => { @@ -537,22 +556,27 @@ fn render_tool_response(resp: &ToolResponse, debug: bool) { print_tool_output(&text.text); } } - if let Some(footer) = formatting::format_tool_status_line_plain(ToolStatus::Success) { + if show_footer { + if let Some(footer) = formatting::format_tool_status_line_plain(ToolStatus::Success) + { + println!( + "{}", + formatting::apply(formatting::status_role(ToolStatus::Success), &footer) + ); + } + } + } + Err(e) => { + if show_footer { println!( "{}", - formatting::apply(formatting::status_role(ToolStatus::Success), &footer) + formatting::apply( + Role::Error, + &formatting::format_error_line_plain(&e.to_string()) + ) ); } } - Err(e) => { - let line = format!( - "{}{} {}", - formatting::GUTTER, - formatting::status_glyph(ToolStatus::Error), - e - ); - println!("{}", formatting::apply(Role::Error, &line)); - } } } @@ -801,11 +825,12 @@ fn render_execute_code_request(call: &CallToolRequestParams, debug: bool) { format!(" (uses {})", deps.join(", ")) }; println!( - " {}. {} {}{}", - style(i + 1).dim(), - style(tool).dim(), - style(desc).dim(), - style(deps_str).dim() + "{}{}. {} {}{}", + formatting::PARAM_INDENT, + formatting::apply(Role::Muted, &(i + 1).to_string()), + formatting::apply(Role::Muted, tool), + formatting::apply(Role::Muted, desc), + formatting::apply(Role::Muted, &deps_str) ); } @@ -999,11 +1024,12 @@ fn render_subagent_tool_graph(subagent_id: &str, tool_graph: &[Value]) { format!(" (uses {})", deps.join(", ")) }; println!( - " {}. {} {}{}", - style(i + 1).dim(), - style(tool).dim(), - style(desc).dim(), - style(deps_str).dim() + "{}{}. {} {}{}", + formatting::PARAM_INDENT, + formatting::apply(Role::Muted, &(i + 1).to_string()), + formatting::apply(Role::Muted, tool), + formatting::apply(Role::Muted, desc), + formatting::apply(Role::Muted, &deps_str) ); } println!(); @@ -1647,6 +1673,16 @@ mod tests { } } + #[test] + fn test_suppressed_tool_header_hides_exactly_one_matching_footer() { + mark_tool_header_suppressed("tool-call-1"); + // A different id's footer is unaffected. + assert!(!take_tool_header_was_suppressed("tool-call-2")); + // The matching id's footer is suppressed exactly once. + assert!(take_tool_header_was_suppressed("tool-call-1")); + assert!(!take_tool_header_was_suppressed("tool-call-1")); + } + #[test] fn test_toggle_full_tool_output() { let initial = get_show_full_tool_output(); From 28abe10d6481056edc567791f94687386881ff13 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 16:45:31 +0000 Subject: [PATCH 07/10] feat(cli): indent normal model replies under the shared gutter Assistant text/markdown replies previously rendered flush-left via bat, while the user's own echoed messages and all the tool-call chrome sat under the shared 2-space GUTTER. Add formatting::indent_block, a pure helper that indents every non-empty line of an already-rendered block (preserving blank lines and trailing newlines exactly), and use it in print_markdown_raw by capturing bat's output via print_with_writer instead of letting it write straight to stdout. This makes normal model output line up with the user-message prompt glyph and with the rest of the transcript's left margin, instead of being the one thing still flush-left. Add unit tests locking in the indent_block spec (single/multi-line, blank-line handling, trailing newline, empty input), plus an ignored manual_visual_smoke_check test for eyeballing the combined user-message + assistant-reply rendering. Co-authored-by: Ben Godfrey --- crates/goose-cli/src/session/formatting.rs | 53 ++++++++++++++++++++++ crates/goose-cli/src/session/output.rs | 19 +++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/crates/goose-cli/src/session/formatting.rs b/crates/goose-cli/src/session/formatting.rs index 7e28a970a00f..8c6ee8b7eb0a 100644 --- a/crates/goose-cli/src/session/formatting.rs +++ b/crates/goose-cli/src/session/formatting.rs @@ -192,6 +192,25 @@ pub fn format_tool_status_line_plain(status: ToolStatus) -> Option { } } +/// Indent every non-empty line of a rendered block (e.g. an assistant +/// reply's already-highlighted markdown) under the shared [`GUTTER`], so +/// normal model output aligns with the user-message prompt glyph instead +/// of sitting flush-left. Blank lines are left empty rather than padded +/// with trailing whitespace, and the exact number of lines (including a +/// trailing blank one from a trailing newline) is preserved. +pub fn indent_block(text: &str) -> String { + text.split('\n') + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!("{GUTTER}{line}") + } + }) + .collect::>() + .join("\n") +} + /// Format the plain-text error line shown for CLI-level errors. pub fn format_error_line_plain(message: &str) -> String { format!( @@ -351,4 +370,38 @@ mod tests { fn param_indent_is_wider_than_the_gutter() { assert!(PARAM_INDENT.len() > GUTTER.len()); } + + #[test] + fn normal_model_messages_are_indented_under_the_gutter() { + assert_eq!( + indent_block("Sure, here's the answer."), + format!("{GUTTER}Sure, here's the answer.") + ); + } + + #[test] + fn multiline_model_messages_have_every_line_indented_under_the_gutter() { + assert_eq!( + indent_block("first line\nsecond line\nthird line"), + format!("{GUTTER}first line\n{GUTTER}second line\n{GUTTER}third line") + ); + } + + #[test] + fn blank_lines_in_a_model_message_are_not_padded_with_trailing_whitespace() { + assert_eq!( + indent_block("first paragraph\n\nsecond paragraph"), + format!("{GUTTER}first paragraph\n\n{GUTTER}second paragraph") + ); + } + + #[test] + fn indent_block_preserves_a_trailing_newline() { + assert_eq!(indent_block("hello\n"), format!("{GUTTER}hello\n")); + } + + #[test] + fn indent_block_is_a_no_op_on_empty_input() { + assert_eq!(indent_block(""), ""); + } } diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index c9a865df8665..7d01e0872bb7 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -1097,16 +1097,20 @@ fn print_markdown(content: &str, theme: Theme) { } } -/// Renders markdown content using bat (no table processing) +/// Renders markdown content using bat (no table processing), indented +/// under the shared gutter so normal model replies line up with the +/// user-message prompt glyph rather than sitting flush-left. fn print_markdown_raw(content: &str, theme: Theme) { + let mut rendered = String::new(); bat::PrettyPrinter::new() .input(bat::Input::from_bytes(content.as_bytes())) .theme(theme.as_str()) .colored_output(env_no_color()) .language("Markdown") .wrapping_mode(WrappingMode::NoWrapping(true)) - .print() + .print_with_writer(Some(&mut rendered)) .unwrap(); + print!("{}", formatting::indent_block(&rendered)); } fn extract_markdown_table(content: &str) -> Option<(String, Vec<&str>, &str)> { @@ -1631,6 +1635,17 @@ mod tests { use serde_json::json; use std::env; + #[test] + #[ignore] + fn manual_visual_smoke_check() { + print_user_message("How do I list files in /tmp?"); + std::println!(); + print_markdown_raw( + "Sure! Use `ls -la /tmp`.\n\n- flag `-l` for long form\n- flag `-a` for hidden files\n", + Theme::Ansi, + ); + } + #[test] fn test_short_paths_unchanged() { assert_eq!(shorten_path("/usr/bin", false), "/usr/bin"); From 6a6a1f708a18e85bd9400aeaaa9930c108f74f8b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 17:16:46 +0000 Subject: [PATCH 08/10] fix(cli): add pure helpers for tool-result status and line-aware indent - tool_result_status(is_error) maps a CallToolResult's is_error field to a ToolStatus, so a protocol-level Ok(..) that still failed (e.g. a shell command with a non-zero exit code) isn't reported as success. - indent_block now takes an at_line_start flag, and a new ends_at_line_start helper reports whether rendered text leaves the cursor at a fresh line. Streamed replies are rendered in separate chunks that don't necessarily land on line boundaries, so a chunk that only continues the previous chunk's still-open line must not get its own leading gutter. Co-authored-by: Ben Godfrey --- crates/goose-cli/src/session/formatting.rs | 131 +++++++++++++++++---- 1 file changed, 109 insertions(+), 22 deletions(-) diff --git a/crates/goose-cli/src/session/formatting.rs b/crates/goose-cli/src/session/formatting.rs index 8c6ee8b7eb0a..81071ae9a6ed 100644 --- a/crates/goose-cli/src/session/formatting.rs +++ b/crates/goose-cli/src/session/formatting.rs @@ -151,6 +151,19 @@ pub fn status_role(status: ToolStatus) -> Role { } } +/// The [`ToolStatus`] for a tool call that completed without a +/// protocol-level error, based on `CallToolResult::is_error`. A tool can +/// report `Ok(..)` at the protocol level while still failing at the +/// application level (e.g. a shell command with a non-zero exit code sets +/// `is_error: Some(true)`), and that must not be shown as a success. +pub fn tool_result_status(is_error: Option) -> ToolStatus { + if is_error == Some(true) { + ToolStatus::Error + } else { + ToolStatus::Success + } +} + /// Format a user-submitted message for echo into the transcript: the /// first line is marked with the accent prompt glyph, continuation lines /// align under it using [`GUTTER`]. Always includes the glyph, even for @@ -192,23 +205,40 @@ pub fn format_tool_status_line_plain(status: ToolStatus) -> Option { } } -/// Indent every non-empty line of a rendered block (e.g. an assistant -/// reply's already-highlighted markdown) under the shared [`GUTTER`], so -/// normal model output aligns with the user-message prompt glyph instead -/// of sitting flush-left. Blank lines are left empty rather than padded -/// with trailing whitespace, and the exact number of lines (including a -/// trailing blank one from a trailing newline) is preserved. -pub fn indent_block(text: &str) -> String { - text.split('\n') - .map(|line| { - if line.is_empty() { - String::new() - } else { - format!("{GUTTER}{line}") - } - }) - .collect::>() - .join("\n") +/// Indent a rendered block (e.g. an assistant reply's already-highlighted +/// markdown) under the shared [`GUTTER`], so normal model output aligns +/// with the user-message prompt glyph instead of sitting flush-left. +/// +/// `at_line_start` says whether the terminal cursor is already at the +/// start of a fresh line before this text is printed. This matters because +/// streamed replies are rendered in separate chunks that don't necessarily +/// break on line boundaries: a chunk that continues the previous chunk's +/// last (unterminated) line must NOT get its own leading gutter, or the +/// gutter would be inserted mid-sentence. Every line *after* an internal +/// `\n` in `text` unambiguously starts a fresh line and is always indented. +/// Blank lines are left empty rather than padded with trailing whitespace, +/// and the exact number of lines (including a trailing blank one from a +/// trailing newline) is preserved. +pub fn indent_block(text: &str, at_line_start: bool) -> String { + let mut out = String::new(); + for (i, line) in text.split('\n').enumerate() { + if i > 0 { + out.push('\n'); + } + let indent_this_line = if i == 0 { at_line_start } else { true }; + if indent_this_line && !line.is_empty() { + out.push_str(GUTTER); + } + out.push_str(line); + } + out +} + +/// Whether the text printed by [`indent_block`] leaves the cursor at the +/// start of a fresh line afterwards, i.e. whether it ends in a newline. +/// Callers use this to track `at_line_start` across separate chunks. +pub fn ends_at_line_start(text: &str) -> bool { + text.ends_with('\n') } /// Format the plain-text error line shown for CLI-level errors. @@ -310,6 +340,21 @@ mod tests { assert!(line.contains("boom")); } + #[test] + fn tool_result_with_is_error_true_is_reported_as_a_failure() { + assert_eq!(tool_result_status(Some(true)), ToolStatus::Error); + } + + #[test] + fn tool_result_with_is_error_false_is_reported_as_a_success() { + assert_eq!(tool_result_status(Some(false)), ToolStatus::Success); + } + + #[test] + fn tool_result_with_no_is_error_field_defaults_to_success() { + assert_eq!(tool_result_status(None), ToolStatus::Success); + } + #[test] fn pending_tool_status_uses_the_accent_role() { assert_eq!(status_role(ToolStatus::Running), Role::Accent); @@ -374,7 +419,7 @@ mod tests { #[test] fn normal_model_messages_are_indented_under_the_gutter() { assert_eq!( - indent_block("Sure, here's the answer."), + indent_block("Sure, here's the answer.", true), format!("{GUTTER}Sure, here's the answer.") ); } @@ -382,7 +427,7 @@ mod tests { #[test] fn multiline_model_messages_have_every_line_indented_under_the_gutter() { assert_eq!( - indent_block("first line\nsecond line\nthird line"), + indent_block("first line\nsecond line\nthird line", true), format!("{GUTTER}first line\n{GUTTER}second line\n{GUTTER}third line") ); } @@ -390,18 +435,60 @@ mod tests { #[test] fn blank_lines_in_a_model_message_are_not_padded_with_trailing_whitespace() { assert_eq!( - indent_block("first paragraph\n\nsecond paragraph"), + indent_block("first paragraph\n\nsecond paragraph", true), format!("{GUTTER}first paragraph\n\n{GUTTER}second paragraph") ); } #[test] fn indent_block_preserves_a_trailing_newline() { - assert_eq!(indent_block("hello\n"), format!("{GUTTER}hello\n")); + assert_eq!(indent_block("hello\n", true), format!("{GUTTER}hello\n")); } #[test] fn indent_block_is_a_no_op_on_empty_input() { - assert_eq!(indent_block(""), ""); + assert_eq!(indent_block("", true), ""); + assert_eq!(indent_block("", false), ""); + } + + #[test] + fn a_streamed_chunk_continuing_the_previous_line_is_not_re_indented() { + // Simulates two streaming flushes of "Hello world!" that don't + // happen to split on a line boundary: the second chunk continues + // the first chunk's still-open line, so it must not get its own + // gutter, or the sentence would visibly break in the middle. + let first = indent_block("Hello ", true); + assert_eq!(first, format!("{GUTTER}Hello ")); + let second = indent_block("world!", ends_at_line_start(&first)); + assert_eq!(second, "world!"); + } + + #[test] + fn a_streamed_chunk_starting_a_new_line_is_indented_even_mid_stream() { + // The first chunk ends with a newline, so the next chunk starts a + // genuinely fresh line and must be indented, even though this is a + // continuation of the same streamed reply rather than a new message. + let first = indent_block("first line\n", true); + assert_eq!(first, format!("{GUTTER}first line\n")); + let second = indent_block("second line", ends_at_line_start(&first)); + assert_eq!(second, format!("{GUTTER}second line")); + } + + #[test] + fn lines_after_an_internal_newline_are_always_indented_regardless_of_at_line_start() { + assert_eq!( + indent_block("continues\nbut this is fresh", false), + format!("continues\n{GUTTER}but this is fresh") + ); + } + + #[test] + fn ends_at_line_start_reflects_a_trailing_newline() { + assert!(ends_at_line_start("hello\n")); + assert!(!ends_at_line_start("hello")); + // Empty text doesn't move the cursor at all, so callers must treat + // this as "unchanged", not "now at line start" — see the empty + // check in `print_markdown_raw` before this is consulted. + assert!(!ends_at_line_start("")); } } From e794d1af245ef72f94163bd0d31c3e36fff692c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 17:16:57 +0000 Subject: [PATCH 09/10] fix(cli): fix tool-status footer and streaming gutter regressions Addresses issues found in an adversarial review of the CLI formatting changes: - render_tool_response now derives the status footer from formatting::tool_result_status(result.is_error) instead of always showing '\u{25cf} done' for Ok(..), which previously mislabeled failed tool calls (e.g. shell commands with a non-zero exit code) as successful. - render_message_streaming, print_markdown, print_markdown_raw, print_table, and flush_markdown_buffer now thread an at_line_start flag through every call, mirroring the existing thinking_header_shown pattern. Without this, each independently-rendered streaming chunk re-indented itself from the start of its own output regardless of whether the cursor was already mid-line, inserting a spurious gutter in the middle of a sentence whenever a chunk boundary didn't land on a newline (the common case for token-by-token streaming). - render_tool_response no longer glues the status footer onto un-terminated tool output in non-terminal mode (e.g. piped/redirected stdout), where tool output is printed verbatim without a guaranteed trailing newline. Added an ignored manual smoke test demonstrating the streaming fix. Co-authored-by: Ben Godfrey --- crates/goose-cli/src/session/mod.rs | 8 +- crates/goose-cli/src/session/output.rs | 153 +++++++++++++++++++------ 2 files changed, 126 insertions(+), 35 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 6e0f48510b5a..6b7376610868 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1188,6 +1188,7 @@ impl CliSession { let mut markdown_buffer = streaming_buffer::MarkdownBuffer::new(); let mut prompted_credits_urls: HashSet = HashSet::new(); let mut thinking_header_shown = false; + let mut text_at_line_start = true; let run_started = Instant::now(); let mut first_token_at: Option = None; let mut last_usage: Option = None; @@ -1315,7 +1316,7 @@ impl CliSession { if is_stream_json_mode { emit_stream_event(&StreamEvent::Message { message: message.clone() }); } else if !is_json_mode { - output::render_message_streaming(&message, &mut markdown_buffer, &mut thinking_header_shown, self.debug); + output::render_message_streaming(&message, &mut markdown_buffer, &mut thinking_header_shown, &mut text_at_line_start, self.debug); maybe_open_credits_top_up_url( &message, interactive, @@ -1372,7 +1373,10 @@ impl CliSession { } if !is_json_mode && !is_stream_json_mode { - output::flush_markdown_buffer_current_theme(&mut markdown_buffer); + output::flush_markdown_buffer_current_theme( + &mut markdown_buffer, + &mut text_at_line_start, + ); } if is_json_mode { diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 7d01e0872bb7..ec9c0da35989 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -238,7 +238,9 @@ pub fn render_message(message: &Message, debug: bool) { } }, MessageContent::Text(text) if is_user_message => print_user_message(&text.text), - MessageContent::Text(text) => print_markdown(&text.text, theme), + MessageContent::Text(text) => { + print_markdown(&text.text, theme, true); + } MessageContent::ToolRequest(req) => render_tool_request(req, theme, debug), MessageContent::ToolResponse(resp) => render_tool_response(resp, debug), MessageContent::Image(image) => { @@ -247,7 +249,7 @@ pub fn render_message(message: &Message, debug: bool) { MessageContent::Thinking(t) => render_thinking(&t.thinking, theme), MessageContent::RedactedThinking(_) => { println!("\n{}", style("Thinking:").dim().italic()); - print_markdown("Thinking was redacted", theme); + print_markdown("Thinking was redacted", theme, true); } MessageContent::SystemNotification(notification) => { match notification.notification_type { @@ -276,10 +278,20 @@ pub fn render_message(message: &Message, debug: bool) { /// Render a streaming message, using a buffer to accumulate text content /// and only render when markdown constructs are complete. +/// +/// `text_at_line_start` tracks whether the terminal cursor is at the start +/// of a fresh line, across the many separate flushes that make up one +/// streamed reply (each flush is rendered — and gutter-indented — on its +/// own, so this state must survive between calls; see +/// [`formatting::indent_block`]). Every branch here other than plain +/// assistant text ends by printing a complete, newline-terminated line (a +/// tool header, an action notice, etc.), so it resets the flag to `true` +/// once it's done. pub fn render_message_streaming( message: &Message, buffer: &mut MarkdownBuffer, thinking_header_shown: &mut bool, + text_at_line_start: &mut bool, debug: bool, ) { let theme = get_theme(); @@ -289,6 +301,7 @@ pub fn render_message_streaming( if !matches!(content, MessageContent::Thinking(_)) { if *thinking_header_shown { println!(); + *text_at_line_start = true; } *thinking_header_shown = false; } @@ -297,23 +310,26 @@ pub fn render_message_streaming( MessageContent::Text(text) if is_user_message => { if let Some(safe_content) = buffer.push(&text.text) { print_user_message(&safe_content); + *text_at_line_start = true; } } MessageContent::Text(text) => { if let Some(safe_content) = buffer.push(&text.text) { - print_markdown(&safe_content, theme); + *text_at_line_start = print_markdown(&safe_content, theme, *text_at_line_start); } } MessageContent::ToolRequest(req) => { - flush_markdown_buffer(buffer, theme); + flush_markdown_buffer(buffer, theme, text_at_line_start); render_tool_request(req, theme, debug); + *text_at_line_start = true; } MessageContent::ToolResponse(resp) => { - flush_markdown_buffer(buffer, theme); + flush_markdown_buffer(buffer, theme, text_at_line_start); render_tool_response(resp, debug); + *text_at_line_start = true; } MessageContent::ActionRequired(action) => { - flush_markdown_buffer(buffer, theme); + flush_markdown_buffer(buffer, theme, text_at_line_start); match &action.data { ActionRequiredData::ToolConfirmation { tool_name, .. } => { println!("action_required(tool_confirmation): {}", tool_name) @@ -325,18 +341,26 @@ pub fn render_message_streaming( println!("action_required(elicitation_response): {}", id) } } + *text_at_line_start = true; } MessageContent::Image(image) => { - flush_markdown_buffer(buffer, theme); + flush_markdown_buffer(buffer, theme, text_at_line_start); println!("Image: [data: {}, type: {}]", image.data, image.mime_type); + *text_at_line_start = true; } MessageContent::Thinking(t) => { - render_thinking_streaming(&t.thinking, buffer, thinking_header_shown, theme); + render_thinking_streaming( + &t.thinking, + buffer, + thinking_header_shown, + text_at_line_start, + theme, + ); } MessageContent::RedactedThinking(_) => { - flush_markdown_buffer(buffer, theme); + flush_markdown_buffer(buffer, theme, text_at_line_start); println!("\n{}", style("Thinking:").dim().italic()); - print_markdown("Thinking was redacted", theme); + *text_at_line_start = print_markdown("Thinking was redacted", theme, true); } MessageContent::SystemNotification(notification) => { match notification.notification_type { @@ -346,19 +370,22 @@ pub fn render_message_streaming( set_thinking_message(¬ification.msg); } SystemNotificationType::InlineMessage => { - flush_markdown_buffer(buffer, theme); + flush_markdown_buffer(buffer, theme, text_at_line_start); hide_thinking(); println!("\n{}", style(¬ification.msg).yellow()); + *text_at_line_start = true; } SystemNotificationType::CreditsExhausted => { - flush_markdown_buffer(buffer, theme); + flush_markdown_buffer(buffer, theme, text_at_line_start); render_credits_exhausted_notification(notification); + *text_at_line_start = true; } } } _ => { - flush_markdown_buffer(buffer, theme); + flush_markdown_buffer(buffer, theme, text_at_line_start); eprintln!("WARNING: Message content type could not be rendered"); + *text_at_line_start = true; } } } @@ -400,15 +427,15 @@ pub fn get_credits_top_up_url(message: &Message) -> Option { }) } -pub fn flush_markdown_buffer(buffer: &mut MarkdownBuffer, theme: Theme) { +pub fn flush_markdown_buffer(buffer: &mut MarkdownBuffer, theme: Theme, at_line_start: &mut bool) { let remaining = buffer.flush(); if !remaining.is_empty() { - print_markdown(&remaining, theme); + *at_line_start = print_markdown(&remaining, theme, *at_line_start); } } -pub fn flush_markdown_buffer_current_theme(buffer: &mut MarkdownBuffer) { - flush_markdown_buffer(buffer, get_theme()); +pub fn flush_markdown_buffer_current_theme(buffer: &mut MarkdownBuffer, at_line_start: &mut bool) { + flush_markdown_buffer(buffer, get_theme(), at_line_start); } pub fn render_text(text: &str, color: Option, dim: bool) { @@ -469,7 +496,7 @@ fn should_show_thinking() -> bool { fn render_thinking(text: &str, theme: Theme) { if should_show_thinking() { println!("\n{}", formatting::apply(Role::Muted, "Thinking:").italic()); - print_markdown(text, theme); + print_markdown(text, theme, true); } } @@ -477,16 +504,24 @@ fn render_thinking_streaming( text: &str, buffer: &mut MarkdownBuffer, header_shown: &mut bool, + text_at_line_start: &mut bool, theme: Theme, ) { if should_show_thinking() { - flush_markdown_buffer(buffer, theme); + flush_markdown_buffer(buffer, theme, text_at_line_start); if !*header_shown { println!("\n{}", formatting::apply(Role::Muted, "Thinking:").italic()); *header_shown = true; } print!("{}", formatting::apply(Role::Muted, text)); let _ = std::io::stdout().flush(); + // The dim thinking text is printed raw (not through the gutter + // pipeline) and rarely ends in a newline, so the next assistant + // text chunk after thinking finishes must not assume it's at a + // fresh line; the `*thinking_header_shown` transition in the + // caller's loop is what actually re-establishes a fresh line via + // its own blank-line `println!()`. + *text_at_line_start = false; } } @@ -519,7 +554,7 @@ fn render_tool_request(req: &ToolRequest, theme: Theme, debug: bool) { }, Err(e) => { mark_tool_header_suppressed(&req.id); - print_markdown(&e.to_string(), theme); + print_markdown(&e.to_string(), theme, true); } } } @@ -527,6 +562,12 @@ fn render_tool_request(req: &ToolRequest, theme: Theme, debug: bool) { fn render_tool_response(resp: &ToolResponse, debug: bool) { let config = Config::global(); let show_footer = !take_tool_header_was_suppressed(&resp.id); + // In terminal mode `print_tool_output` always ends on a fresh line (it + // prints one full, newline-terminated line at a time), but in + // non-terminal mode it dumps the raw text verbatim, which may not end + // in a newline. Track that so the footer never gets glued onto the end + // of un-terminated tool output (e.g. `printf x` piped to a file). + let mut at_line_start = true; match &resp.tool_result { Ok(result) => { @@ -552,16 +593,28 @@ fn render_tool_response(resp: &ToolResponse, debug: bool) { if debug { println!("{:#?}", content); + at_line_start = true; } else if let Some(text) = content.as_text() { - print_tool_output(&text.text); + if !text.text.is_empty() { + print_tool_output(&text.text); + at_line_start = + std::io::stdout().is_terminal() || text.text.ends_with('\n'); + } } } if show_footer { - if let Some(footer) = formatting::format_tool_status_line_plain(ToolStatus::Success) - { + if !at_line_start { + println!(); + } + // `Ok(result)` only means the tool call completed without a + // protocol-level error; the tool itself may still report + // failure (e.g. a shell command with a non-zero exit code) + // via `is_error`, which must not be shown as `● done`. + let status = formatting::tool_result_status(result.is_error); + if let Some(footer) = formatting::format_tool_status_line_plain(status) { println!( "{}", - formatting::apply(formatting::status_role(ToolStatus::Success), &footer) + formatting::apply(formatting::status_role(status), &footer) ); } } @@ -1079,28 +1132,43 @@ fn print_user_message(text: &str) { println!("{}", formatting::apply(Role::Primary, rest)); } -fn print_markdown(content: &str, theme: Theme) { +/// Renders `content` as markdown, indented under the shared gutter. +/// +/// `at_line_start` says whether the cursor is already at the start of a +/// fresh line before anything here is printed; returns whether it's at the +/// start of a fresh line afterwards, so callers that render a message in +/// several separate chunks (streaming) can thread this through the whole +/// sequence of calls without double-indenting a chunk that only continues +/// the previous one's still-open line. See [`formatting::indent_block`]. +fn print_markdown(content: &str, theme: Theme, at_line_start: bool) -> bool { if std::io::stdout().is_terminal() { if let Some((before, table, after)) = extract_markdown_table(content) { + let mut at_line_start = at_line_start; if !before.is_empty() { - print_markdown_raw(&before, theme); + at_line_start = print_markdown_raw(&before, theme, at_line_start); } - print_table(&table, theme); + at_line_start = print_table(&table, theme, at_line_start); if !after.is_empty() { - print_markdown(after, theme); + at_line_start = print_markdown(after, theme, at_line_start); } + at_line_start } else { - print_markdown_raw(content, theme); + print_markdown_raw(content, theme, at_line_start) } } else { print!("{}", content); + if content.is_empty() { + at_line_start + } else { + content.ends_with('\n') + } } } /// Renders markdown content using bat (no table processing), indented /// under the shared gutter so normal model replies line up with the /// user-message prompt glyph rather than sitting flush-left. -fn print_markdown_raw(content: &str, theme: Theme) { +fn print_markdown_raw(content: &str, theme: Theme, at_line_start: bool) -> bool { let mut rendered = String::new(); bat::PrettyPrinter::new() .input(bat::Input::from_bytes(content.as_bytes())) @@ -1110,7 +1178,12 @@ fn print_markdown_raw(content: &str, theme: Theme) { .wrapping_mode(WrappingMode::NoWrapping(true)) .print_with_writer(Some(&mut rendered)) .unwrap(); - print!("{}", formatting::indent_block(&rendered)); + print!("{}", formatting::indent_block(&rendered, at_line_start)); + if rendered.is_empty() { + at_line_start + } else { + formatting::ends_at_line_start(&rendered) + } } fn extract_markdown_table(content: &str) -> Option<(String, Vec<&str>, &str)> { @@ -1194,7 +1267,7 @@ fn extract_markdown_table(content: &str) -> Option<(String, Vec<&str>, &str)> { Some((before, table, after)) } -fn print_table(table_lines: &[&str], theme: Theme) { +fn print_table(table_lines: &[&str], theme: Theme, at_line_start: bool) -> bool { use comfy_table::{presets, Cell, CellAlignment, ContentArrangement, Table}; let mut table = Table::new(); @@ -1275,7 +1348,7 @@ fn print_table(table_lines: &[&str], theme: Theme) { } let table_str = table.to_string(); - print_markdown_raw(&table_str, theme); + print_markdown_raw(&table_str, theme, at_line_start) } fn print_value_with_prefix(prefix: &String, value: &Value, debug: bool) { @@ -1643,9 +1716,23 @@ mod tests { print_markdown_raw( "Sure! Use `ls -la /tmp`.\n\n- flag `-l` for long form\n- flag `-a` for hidden files\n", Theme::Ansi, + true, ); } + #[test] + #[ignore] + fn manual_visual_smoke_check_streaming_chunks_mid_sentence() { + // Simulates two streaming flushes that split mid-sentence (no + // newline at the chunk boundary), the way `MarkdownBuffer::push` + // routinely does. Should print one gutter-indented line reading + // "Hello world!", not two lines each with their own gutter. + std::println!("--- expect exactly one gutter before \"Hello world!\" ---"); + let at_line_start = print_markdown_raw("Hello ", Theme::Ansi, true); + print_markdown_raw("world!", Theme::Ansi, at_line_start); + std::println!(); + } + #[test] fn test_short_paths_unchanged() { assert_eq!(shorten_path("/usr/bin", false), "/usr/bin"); From e9022b122f1bf770d567d3a50f4a739f47d356f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 17:17:03 +0000 Subject: [PATCH 10/10] docs: reconcile cli-formatting-plan.md with follow-up gutter/footer fixes Documents the goal-4 follow-up (indenting normal model replies) and its implication for the 'no change to bat' non-goal, and fixes a stale note that still described Secondary as colored. Co-authored-by: Ben Godfrey --- docs/cli-formatting-plan.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/cli-formatting-plan.md b/docs/cli-formatting-plan.md index a19ac6fa1420..8b147bd3d18e 100644 --- a/docs/cli-formatting-plan.md +++ b/docs/cli-formatting-plan.md @@ -29,12 +29,29 @@ formatting, no changes to `ui/desktop`. ## Non-goals - No new TUI, no Ink/React work. -- No change to the underlying markdown engine (`bat`) or its light/dark/ansi - theme system — only the *chrome* around tool calls/errors/user echo. +- No change to `bat`'s light/dark/ansi theme system or how markdown is + highlighted — only how the already-highlighted output is captured and + laid out (see "Update" below for the one change that touches this). - No change to `ui/desktop`. - No change to JSON / stream-json output modes (`emit_stream_event`) — those are structured output for tooling, not human-facing formatting. +**Update (follow-up to goal 4):** the original pass above only indented the +*chrome* (headers/footers/errors/params), leaving ordinary model replies +flush-left and visually disconnected from the rest of the gutter-aligned +transcript. A follow-up change extends goal 4 to indent normal assistant +markdown too, by capturing `bat`'s rendered output via +`PrettyPrinter::print_with_writer` (instead of letting it write directly to +stdout) and post-processing it with `formatting::indent_block`. This still +doesn't touch `bat`'s highlighting/theme behavior, but it does mean the CLI +now owns the final `print!()` of that output instead of `bat`. Because +streamed replies are rendered in several chunks that don't necessarily +align with line boundaries, `indent_block` takes an explicit +`at_line_start` flag so a chunk that only continues the previous chunk's +still-open line isn't re-indented mid-sentence; this state is threaded +through `render_message_streaming` the same way `thinking_header_shown` +already is. + ## Steps 1. **`crates/goose-cli/src/session/formatting.rs`** (new, already added with @@ -46,7 +63,8 @@ formatting, no changes to `ui/desktop`. `role_style` returning `RoleStyle { color, dim, bold, italic }`: - `Primary`: no color/no decoration (inherits terminal default — matches TUI's `TEXT_PRIMARY` and Claude Code's default body text). - - `Secondary`: cyan + dim (tool names, section labels). + - `Secondary`: bold, no color (tool names, section labels) — kept + colorless so `Accent` remains the palette's only hue. - `Muted`: dim only, no color (param values, hints, secondary detail — matches TUI's `TEXT_DIM`). - `Accent`: cyan + bold (user prompt glyph, pending tool status).