From c43078f99b677504b7cb85b1970d1398556be00c Mon Sep 17 00:00:00 2001
From: Shaun Smith <1936278+evalstate@users.noreply.github.com>
Date: Wed, 9 Sep 2026 23:27:03 +0100
Subject: [PATCH] update status bar, codex device auth, kitty defaults for
herdr
---
docs/docs/_generated/tui_runtime_reference.md | 2 +-
docs/docs/guides/codex.md | 16 +-
docs/docs/guides/tui.md | 30 +++
docs/docs/models/providers/openai.md | 5 +-
docs/generate_reference_docs.py | 3 +-
examples/setup/fast-agent.yaml | 5 +
pyproject.toml | 2 +-
src/fast_agent/auth/providers.py | 5 +
src/fast_agent/cli/commands/auth.py | 23 +-
src/fast_agent/config.py | 5 +-
.../llm/provider/openai/codex_oauth.py | 118 +++++++++-
src/fast_agent/ui/prompt/input.py | 4 +-
.../prompt/status_bar/agent_capabilities.py | 7 +-
.../ui/prompt/status_bar/renderer.py | 13 +-
src/fast_agent/ui/terminal_images/renderer.py | 9 +-
.../fast_agent/commands/test_auth_command.py | 77 +++++++
.../llm/providers/test_codex_oauth.py | 217 +++++++++++++++++-
.../fast_agent/test_config_model_layering.py | 40 ++++
.../unit/fast_agent/ui/test_input_toolbar.py | 8 +-
tests/unit/fast_agent/ui/test_prompt_input.py | 34 +++
.../fast_agent/ui/test_terminal_images.py | 30 +--
uv.lock | 2 +-
22 files changed, 594 insertions(+), 61 deletions(-)
diff --git a/docs/docs/_generated/tui_runtime_reference.md b/docs/docs/_generated/tui_runtime_reference.md
index 820c45047..f10214ba7 100644
--- a/docs/docs/_generated/tui_runtime_reference.md
+++ b/docs/docs/_generated/tui_runtime_reference.md
@@ -31,7 +31,7 @@
| `logger.tool_display.stream_edit_previews` | `LOGGER__TOOL_DISPLAY__STREAM_EDIT_PREVIEWS` | `Literal['off', 'primary', 'all']` | `primary` | Stream apply_patch/edit_file previews for the primary agent or all agents. |
| `logger.tool_display.aggregate_parallel` | `LOGGER__TOOL_DISPLAY__AGGREGATE_PARALLEL` | `bool` | `True` | Aggregate safe parallel generic calls when argument bodies are disabled. |
| `logger.terminal_images.enabled` | `LOGGER__TERMINAL_IMAGES__ENABLED` | `bool` | `True` | Render image content in capable terminals. |
-| `logger.terminal_images.backend` | `LOGGER__TERMINAL_IMAGES__BACKEND` | `Literal['auto', 'textual-image', 'kitty', 'sixel', 'halfcell', 'unicode', 'none']` | `auto` | Terminal image backend; automatic Sixel rendering is fitted to the viewport. |
+| `logger.terminal_images.backend` | `LOGGER__TERMINAL_IMAGES__BACKEND` | `Literal['auto', 'textual-image', 'kitty', 'sixel', 'halfcell', 'unicode', 'none']` | `auto` | Auto uses reported terminal support, including Kitty in Herdr; use halfcell to opt out. Automatic Sixel rendering is fitted to the viewport. |
| `logger.terminal_images.width` | `LOGGER__TERMINAL_IMAGES__WIDTH` | `TerminalImageSize` | `80%` | Image render width. |
| `logger.terminal_images.height` | `LOGGER__TERMINAL_IMAGES__HEIGHT` | `TerminalImageSize` | `auto` | Image render height. |
| `shell_execution.tool_profile` | `SHELL_EXECUTION__TOOL_PROFILE` | `ShellToolProfile` | `auto` | Model-specific shell/process contract. |
diff --git a/docs/docs/guides/codex.md b/docs/docs/guides/codex.md
index 890173921..631da66c8 100644
--- a/docs/docs/guides/codex.md
+++ b/docs/docs/guides/codex.md
@@ -61,8 +61,20 @@ If you want to use the Codex OAuth models directly, authenticate once first:
fast-agent auth provider login codex
```
-This stores the token in your OS keyring. After that you can use Codex OAuth
-model aliases such as:
+Login uses device auth by default: open the displayed URL in a browser and enter
+the one-time code. No local callback server is required. Only enter a code from
+a login you started yourself.
+
+If device auth is unavailable for your account, use the existing browser callback
+flow instead:
+
+```bash
+fast-agent auth provider login codex --method browser
+```
+
+Device login failures do not automatically switch to browser login. Credentials
+are stored in your OS keyring, with a secure file fallback. After that you can use
+Codex OAuth model aliases such as:
- `codexplan` — GPT-6-Astra with medium reasoning
diff --git a/docs/docs/guides/tui.md b/docs/docs/guides/tui.md
index d77421d99..399bc081e 100644
--- a/docs/docs/guides/tui.md
+++ b/docs/docs/guides/tui.md
@@ -39,6 +39,36 @@ of the stdin interpreter. This includes direct interpreters such as
TypeScript executed with `pnpm exec tsx -` (including `pnpm -C
exec`).
Highlighting is applied while the heredoc is still streaming.
+## Terminal images
+
+Image rendering automatically uses reported terminal graphics support, including
+Kitty graphics inside Herdr. Herdr can report Kitty support even when the outer
+terminal cannot display it. For Herdr inside Foot or Windows Terminal, select
+half-cell rendering:
+
+```yaml
+logger:
+ terminal_images:
+ backend: halfcell
+```
+
+Alternatively, select it for a terminal environment:
+
+```bash
+export LOGGER__TERMINAL_IMAGES__BACKEND=halfcell
+```
+
+In PowerShell:
+
+```powershell
+$env:LOGGER__TERMINAL_IMAGES__BACKEND = "halfcell"
+```
+
+Explicit configuration takes precedence over this environment variable; leave
+`backend` unset in YAML to select it via the environment. Restart fast-agent
+after changing these settings. With Herdr inside Ghostty or Kitty, leave the
+backend at its automatic default or explicitly select `kitty`.
+
## Shell Integration
You can run a shell command with `!` - for example `! git status`. When the active agent uses a local shell environment, commands run attached to your terminal, so interactive programs such as `! nano` work as expected. If the active agent uses a remote or sandbox environment, `!` runs in that environment; use `!!` to force a local shell command instead.
diff --git a/docs/docs/models/providers/openai.md b/docs/docs/models/providers/openai.md
index 11f1071a7..2545498f9 100644
--- a/docs/docs/models/providers/openai.md
+++ b/docs/docs/models/providers/openai.md
@@ -223,9 +223,12 @@ once, then use a Codex OAuth model alias such as `astra` (GPT-6-Astra), `codexpl
**Quick Start:**
```bash
-# Start OAuth login (stores tokens in your OS keyring)
+# Start device auth (stores tokens in your OS keyring, with a secure file fallback)
fast-agent auth provider login codex
+# Alternatively, use browser callback login if device auth is unavailable
+fast-agent auth provider login codex --method browser
+
# Use GPT-6-Astra through the Codex subscription
fast-agent --model astra
diff --git a/docs/generate_reference_docs.py b/docs/generate_reference_docs.py
index 115bd33e9..e4efb495a 100644
--- a/docs/generate_reference_docs.py
+++ b/docs/generate_reference_docs.py
@@ -931,7 +931,8 @@ def generate_tui_runtime_reference() -> str:
),
"logger.terminal_images.enabled": "Render image content in capable terminals.",
"logger.terminal_images.backend": (
- "Terminal image backend; automatic Sixel rendering is fitted to the viewport."
+ "Auto uses reported terminal support, including Kitty in Herdr; "
+ "use halfcell to opt out. Automatic Sixel rendering is fitted to the viewport."
),
"logger.terminal_images.width": "Image render width.",
"logger.terminal_images.height": "Image render height.",
diff --git a/examples/setup/fast-agent.yaml b/examples/setup/fast-agent.yaml
index 71ca17f10..91698c188 100644
--- a/examples/setup/fast-agent.yaml
+++ b/examples/setup/fast-agent.yaml
@@ -159,6 +159,11 @@ logger:
show_successful_file_reads: false
stream_edit_previews: primary # off | primary | all
aggregate_parallel: true
+ # Images auto-detect reported terminal support, including Kitty in Herdr.
+ # For Herdr inside Foot/Windows Terminal, use halfcell here or set
+ # LOGGER__TERMINAL_IMAGES__BACKEND=halfcell (leave backend unset for env selection).
+ # terminal_images:
+ # backend: halfcell
# Streaming renderer for assistant responses: "markdown", "plain", or "none"
streaming: markdown
# Render markdown code fences with Rich Syntax instead of markdown fence blocks
diff --git a/pyproject.toml b/pyproject.toml
index 57595d190..2c1678874 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "fast-agent-mcp"
-version = "0.10.21"
+version = "0.10.22"
description = "Code, Build and Evaluate agents - excellent Model and Skills/MCP/ACP/A2A Support"
readme = "README.md"
license = { file = "LICENSE" }
diff --git a/src/fast_agent/auth/providers.py b/src/fast_agent/auth/providers.py
index 28557fc63..d11bd91c4 100644
--- a/src/fast_agent/auth/providers.py
+++ b/src/fast_agent/auth/providers.py
@@ -29,6 +29,7 @@ class OAuthProvider:
access_token: Callable[[], str | None]
status: Callable[[], dict[str, object]]
logout: Callable[[], bool]
+ browser_login: Callable[[], OAuthCredential] | None = None
def _xai_provider() -> OAuthProvider:
@@ -68,6 +69,9 @@ def _codex_provider() -> OAuthProvider:
def login() -> OAuthCredential:
return _codex_credential(login_codex_oauth())
+ def browser_login() -> OAuthCredential:
+ return _codex_credential(login_codex_oauth(method="browser"))
+
def credential() -> OAuthCredential | None:
tokens = load_codex_tokens()
return _codex_credential(tokens) if tokens else None
@@ -89,6 +93,7 @@ def _codex_credential(tokens: CodexOAuthTokens) -> OAuthCredential:
access_token=get_codex_access_token,
status=get_codex_token_status,
logout=clear_codex_tokens,
+ browser_login=browser_login,
)
diff --git a/src/fast_agent/cli/commands/auth.py b/src/fast_agent/cli/commands/auth.py
index 553095b6f..13d9f36d9 100644
--- a/src/fast_agent/cli/commands/auth.py
+++ b/src/fast_agent/cli/commands/auth.py
@@ -7,6 +7,7 @@
import sys
from dataclasses import asdict, dataclass
from datetime import datetime
+from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Literal, cast
from urllib.parse import urlparse
@@ -488,17 +489,35 @@ def provider_show(
_print_provider_views([view])
+class ProviderLoginMethod(str, Enum):
+ DEVICE = "device"
+ BROWSER = "browser"
+
+
@provider_app.command("login")
def provider_login(
provider: str = typer.Argument(..., help="Provider name: xai or codex"),
+ method: ProviderLoginMethod = typer.Option(
+ ProviderLoginMethod.DEVICE,
+ "--method",
+ help="Login method. Browser login is available for Codex accounts without device auth.",
+ ),
) -> None:
- """Authenticate with a model provider."""
+ """Authenticate with a model provider using device auth by default."""
from fast_agent.auth.providers import get_oauth_provider
from fast_agent.core.exceptions import ProviderKeyError, format_fast_agent_error
try:
handler = get_oauth_provider(provider)
- handler.login()
+ login = handler.login
+ if method == ProviderLoginMethod.BROWSER:
+ if handler.browser_login is None:
+ raise ProviderKeyError(
+ "Unsupported login method",
+ f"{handler.display_name} does not support browser login. Use --method device.",
+ )
+ login = handler.browser_login
+ login()
typer.echo(f"{handler.display_name} OAuth login complete.")
except ProviderKeyError as exc:
typer.echo(format_fast_agent_error(exc), err=True)
diff --git a/src/fast_agent/config.py b/src/fast_agent/config.py
index 2b6631f2f..19773d4c3 100644
--- a/src/fast_agent/config.py
+++ b/src/fast_agent/config.py
@@ -1733,7 +1733,10 @@ class TerminalImageSettings(BaseModel):
"unicode",
"none",
] = "auto"
- """Terminal image backend; automatic Sixel rendering is fitted to the viewport."""
+ """Auto uses reported terminal support, including Kitty in Herdr; use halfcell to opt out.
+
+ Automatic Sixel rendering is fitted to the viewport.
+ """
width: TerminalImageSize = "80%"
"""Image render width: cells, percentage (e.g. '80%'), 'auto', or null."""
diff --git a/src/fast_agent/llm/provider/openai/codex_oauth.py b/src/fast_agent/llm/provider/openai/codex_oauth.py
index 46afb6f37..7921d5660 100644
--- a/src/fast_agent/llm/provider/openai/codex_oauth.py
+++ b/src/fast_agent/llm/provider/openai/codex_oauth.py
@@ -1,8 +1,8 @@
"""Codex OAuth helpers for ChatGPT/Codex tokens.
-Implements the OAuth PKCE flow used by the Codex CLI, including keyring
-storage and refresh. Access tokens are used as API keys when calling the
-Codex responses endpoint.
+Implements device authorization and browser OAuth PKCE login, including
+keyring storage and refresh. Access tokens are used as API keys when calling
+the Codex responses endpoint.
"""
from __future__ import annotations
@@ -17,11 +17,11 @@
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
-from typing import Any
+from typing import Annotated, Any, Literal
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
-from pydantic import BaseModel
+from pydantic import AliasChoices, BaseModel, Field, ValidationError
from fast_agent.auth.credentials import (
OAuthCredential,
@@ -393,13 +393,15 @@ def build_authorization_url(code_challenge: str, state: str) -> str:
return f"{CODEX_AUTHORIZE_URL}?{urlencode(params)}"
-def exchange_code_for_tokens(code: str, code_verifier: str) -> CodexOAuthTokens:
+def exchange_code_for_tokens(
+ code: str, code_verifier: str, *, redirect_uri: str = CODEX_REDIRECT_URI
+) -> CodexOAuthTokens:
payload = {
"grant_type": "authorization_code",
"client_id": CODEX_CLIENT_ID,
"code": code,
"code_verifier": code_verifier,
- "redirect_uri": CODEX_REDIRECT_URI,
+ "redirect_uri": redirect_uri,
}
return _token_request(payload)
@@ -465,7 +467,7 @@ def parse_chatgpt_account_id(access_token: str) -> str | None:
return None
-def login_codex_oauth(timeout_seconds: int = 300) -> CodexOAuthTokens:
+def login_codex_browser_oauth(timeout_seconds: int = 300) -> CodexOAuthTokens:
verifier = _pkce_verifier()
challenge = _pkce_challenge(verifier)
state = secrets.token_urlsafe(16)
@@ -519,3 +521,103 @@ def login_codex_oauth(timeout_seconds: int = 300) -> CodexOAuthTokens:
tokens = exchange_code_for_tokens(code, verifier)
save_codex_tokens(tokens)
return tokens
+
+
+class _DeviceUserCode(BaseModel):
+ device_auth_id: Annotated[str, Field(min_length=1)]
+ user_code: Annotated[
+ str, Field(min_length=1, validation_alias=AliasChoices("user_code", "usercode"))
+ ]
+ interval: Annotated[str, Field(pattern=r"^\s*[0-9]+\s*$")] = "5"
+
+
+class _DeviceAuthorization(BaseModel):
+ authorization_code: Annotated[str, Field(min_length=1)]
+ code_verifier: Annotated[str, Field(min_length=1)]
+
+
+def _device_login_error(detail: str) -> ProviderKeyError:
+ return ProviderKeyError(
+ "Codex device login failed",
+ f"{detail} Retry login or use `fast-agent auth provider login codex --method browser`.",
+ )
+
+
+def login_codex_device_oauth(timeout_seconds: int = 900) -> CodexOAuthTokens:
+ """Authorize with a user-entered device code; never fall back automatically."""
+ deadline = time.monotonic() + min(timeout_seconds, 900)
+
+ def remaining() -> float:
+ seconds = deadline - time.monotonic()
+ if seconds <= 0:
+ raise _device_login_error("Device authorization timed out.")
+ return seconds
+
+ try:
+ with httpx.Client(timeout=30.0) as client:
+ response = client.post(
+ "https://auth.openai.com/api/accounts/deviceauth/usercode",
+ json={"client_id": CODEX_CLIENT_ID},
+ timeout=min(30.0, remaining()),
+ )
+ if not response.is_success:
+ raise _device_login_error(
+ f"Unable to request a device code (HTTP {response.status_code}). "
+ "Ensure device code login is enabled for your account."
+ )
+ device = _DeviceUserCode.model_validate_json(response.content)
+ interval = max(1, int(device.interval))
+ console.ensure_blocking_console()
+ console.console.print(
+ "Open https://auth.openai.com/codex/device and enter this one-time code:",
+ markup=False,
+ )
+ console.console.print(device.user_code, markup=False)
+ console.console.print(
+ "Continue only if you started this login. If a website or another person "
+ "gave you this code, cancel.",
+ markup=False,
+ )
+ while True:
+ response = client.post(
+ "https://auth.openai.com/api/accounts/deviceauth/token",
+ json={"device_auth_id": device.device_auth_id, "user_code": device.user_code},
+ timeout=min(30.0, remaining()),
+ )
+ remaining()
+ if response.is_success:
+ authorization = _DeviceAuthorization.model_validate_json(response.content)
+ break
+ if response.status_code not in (403, 404):
+ raise _device_login_error(
+ f"Device authorization failed (HTTP {response.status_code})."
+ )
+ time.sleep(min(interval, remaining()))
+ try:
+ tokens = exchange_code_for_tokens(
+ authorization.authorization_code,
+ authorization.code_verifier,
+ redirect_uri="https://auth.openai.com/deviceauth/callback",
+ )
+ except ProviderKeyError:
+ raise _device_login_error("Device authorization token exchange failed.") from None
+ except (httpx.HTTPError, ValidationError, ValueError):
+ # Do not expose response bodies, validation inputs, or token exchange errors.
+ raise _device_login_error(
+ "Device authorization could not be completed (request failed, invalid response, "
+ "or authorization timed out)."
+ ) from None
+ save_codex_tokens(tokens)
+ return tokens
+
+
+def login_codex_oauth(
+ timeout_seconds: int | None = None, *, method: Literal["device", "browser"] = "device"
+) -> CodexOAuthTokens:
+ if method == "device":
+ return login_codex_device_oauth(900 if timeout_seconds is None else timeout_seconds)
+ if method == "browser":
+ return login_codex_browser_oauth(300 if timeout_seconds is None else timeout_seconds)
+ raise ProviderKeyError(
+ "Codex OAuth login failed", "Unknown login method. Use device or browser."
+ )
diff --git a/src/fast_agent/ui/prompt/input.py b/src/fast_agent/ui/prompt/input.py
index 07aa1f50f..40198936c 100644
--- a/src/fast_agent/ui/prompt/input.py
+++ b/src/fast_agent/ui/prompt/input.py
@@ -284,8 +284,7 @@ def _initialize_prompt_input_state(
global in_multiline_mode, available_agents
in_multiline_mode = multiline
- if available_agent_names:
- available_agents = set(available_agent_names)
+ available_agents = set(available_agent_names or [agent_name])
if agent_provider is not None:
with suppress(Exception):
available_agents = set(agent_provider.visible_agent_names(force_include=agent_name))
@@ -330,6 +329,7 @@ def get_toolbar() -> HTML:
current_input_text = ""
result = render_input_toolbar(
agent_name=agent_name,
+ show_agent_name=len(available_agents) > 1,
toolbar_color=toolbar_color,
agent_provider=agent_provider,
multiline_mode=in_multiline_mode,
diff --git a/src/fast_agent/ui/prompt/status_bar/agent_capabilities.py b/src/fast_agent/ui/prompt/status_bar/agent_capabilities.py
index db188b7f1..3fb77fff7 100644
--- a/src/fast_agent/ui/prompt/status_bar/agent_capabilities.py
+++ b/src/fast_agent/ui/prompt/status_bar/agent_capabilities.py
@@ -4,7 +4,6 @@
from fast_agent.core.agent_capabilities import AgentCapabilityMode
from fast_agent.ui.binary_indicator import (
- TOOLBAR_BINARY_DISABLED_COLOR,
TOOLBAR_BINARY_ENABLED_COLOR,
render_glyph_indicator,
)
@@ -18,10 +17,12 @@ def render_agent_capability_indicator(mode: AgentCapabilityMode) -> str:
harness = mode in {AgentCapabilityMode.HARNESS_ONLY, AgentCapabilityMode.ORCHESTRATE}
subagent_indicator = render_glyph_indicator(
glyph=SUBAGENT_GLYPH,
- color=TOOLBAR_BINARY_ENABLED_COLOR if subagents else TOOLBAR_BINARY_DISABLED_COLOR,
+ color="ansiblack" if subagents else "ansiwhite",
+ foreground=TOOLBAR_BINARY_ENABLED_COLOR if subagents else "ansiblack",
)
harness_indicator = render_glyph_indicator(
glyph=f"{HARNESS_GLYPH} ",
- color=TOOLBAR_BINARY_ENABLED_COLOR if harness else TOOLBAR_BINARY_DISABLED_COLOR,
+ color="ansiblack" if harness else "ansiwhite",
+ foreground=TOOLBAR_BINARY_ENABLED_COLOR if harness else "ansiblack",
)
return f"{subagent_indicator}{harness_indicator}"
diff --git a/src/fast_agent/ui/prompt/status_bar/renderer.py b/src/fast_agent/ui/prompt/status_bar/renderer.py
index 9f62220c0..3fb1ea2d8 100644
--- a/src/fast_agent/ui/prompt/status_bar/renderer.py
+++ b/src/fast_agent/ui/prompt/status_bar/renderer.py
@@ -187,6 +187,7 @@ def resolve_active_llm(
def render_input_toolbar(
*,
agent_name: str,
+ show_agent_name: bool,
toolbar_color: str,
agent_provider: "AgentApp | None",
multiline_mode: bool,
@@ -203,10 +204,14 @@ def render_input_toolbar(
resolved_agent_state = _resolve_toolbar_agent_state_cached(
agent_name, agent_provider, cache=cache
)
- agent_identity_segment = _format_toolbar_agent_identity(
- agent_name,
- toolbar_color,
- resolved_agent_state.state.agent,
+ agent_identity_segment = (
+ _format_toolbar_agent_identity(
+ agent_name,
+ toolbar_color,
+ resolved_agent_state.state.agent,
+ )
+ if show_agent_name
+ else ""
)
attachment_summary = _resolve_attachment_summary(
current_input_text=current_input_text,
diff --git a/src/fast_agent/ui/terminal_images/renderer.py b/src/fast_agent/ui/terminal_images/renderer.py
index 1e9d0119a..1b8b82676 100644
--- a/src/fast_agent/ui/terminal_images/renderer.py
+++ b/src/fast_agent/ui/terminal_images/renderer.py
@@ -26,7 +26,6 @@
logger = get_logger(__name__)
MAX_TERMINAL_IMAGE_SOURCE_BYTES = 25 * 1024 * 1024
TERMINAL_IMAGE_FETCH_TIMEOUT_SECONDS = 10.0
-HERDR_HALFCELL_NOTICE = "Warning: Herdr active; using half-cell image rendering."
_TEXTUAL_IMAGE_CLASS_BY_BACKEND: dict[str, str] = {
"auto": "Image",
@@ -153,8 +152,6 @@ def render_image_items(
continue
renderables.append(Text(item.artifact.label, style="dim"))
renderables.append(renderable)
- if _uses_herdr_auto_halfcell(settings.backend):
- renderables.append(Text(HERDR_HALFCELL_NOTICE, style="dim yellow"))
renderables.extend(Text(metadata, style="dim") for metadata in item.metadata)
if not renderables:
@@ -350,7 +347,7 @@ def _resolve_textual_image_class(backend: str) -> Any | None:
if class_name is None:
return None
- if backend in {"auto", "halfcell"} and _herdr_active():
+ if backend == "halfcell" and _herdr_active():
try:
module = import_module("fast_agent.ui.terminal_images.halfcell")
except ImportError:
@@ -384,7 +381,3 @@ def _resolve_textual_image_class(backend: str) -> Any | None:
def _herdr_active() -> bool:
return os.environ.get("HERDR_ENV") == "1"
-
-
-def _uses_herdr_auto_halfcell(backend: str) -> bool:
- return backend == "auto" and _herdr_active()
diff --git a/tests/unit/fast_agent/commands/test_auth_command.py b/tests/unit/fast_agent/commands/test_auth_command.py
index 085890efe..ca1e83fe7 100644
--- a/tests/unit/fast_agent/commands/test_auth_command.py
+++ b/tests/unit/fast_agent/commands/test_auth_command.py
@@ -770,3 +770,80 @@ def test_auth_mcp_list_reports_invalid_settings_yaml_without_traceback(tmp_path:
assert result.exit_code == 1, result.output
assert "Error loading fast-agent settings:" in result.output
assert "Traceback" not in result.output
+
+
+@pytest.mark.parametrize("provider", ["codex", "codexplan", "codexresponses"])
+@pytest.mark.parametrize("method", [None, "device", "browser"])
+def test_codex_login_method_selection(
+ monkeypatch: pytest.MonkeyPatch, provider: str, method: str | None
+) -> None:
+ from fast_agent.llm.provider.openai import codex_oauth
+
+ calls: list[str] = []
+
+ def device_login(timeout_seconds: int = 900) -> codex_oauth.CodexOAuthTokens:
+ calls.append("device")
+ return codex_oauth.CodexOAuthTokens(access_token="test-token")
+
+ def browser_login(timeout_seconds: int = 300) -> codex_oauth.CodexOAuthTokens:
+ calls.append("browser")
+ return codex_oauth.CodexOAuthTokens(access_token="test-token")
+
+ monkeypatch.setattr(codex_oauth, "login_codex_device_oauth", device_login)
+ monkeypatch.setattr(codex_oauth, "login_codex_browser_oauth", browser_login)
+ args = ["provider", "login", provider]
+ if method:
+ args.extend(["--method", method])
+
+ result = CliRunner().invoke(auth_command.app, args)
+
+ assert result.exit_code == 0, result.output
+ assert calls == [method or "device"]
+ assert "Codex OAuth login complete." in result.output
+ assert "test-token" not in result.output
+
+
+def test_xai_browser_login_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
+ from fast_agent.llm.provider.openai import xai_oauth
+
+ def unexpected_login() -> OAuthCredential:
+ pytest.fail("unsupported browser login must not start device login")
+
+ monkeypatch.setattr(xai_oauth, "login_xai_oauth", unexpected_login)
+
+ result = CliRunner().invoke(
+ auth_command.app, ["provider", "login", "xai", "--method", "browser"]
+ )
+
+ assert result.exit_code == 1
+ assert "does not support browser login" in result.output
+
+
+def test_provider_login_rejects_unknown_method() -> None:
+ result = CliRunner().invoke(
+ auth_command.app, ["provider", "login", "codex", "--method", "unknown"]
+ )
+
+ assert result.exit_code == 2
+
+
+def test_codex_device_login_failure_does_not_switch_to_browser(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from fast_agent.core.exceptions import ProviderKeyError
+ from fast_agent.llm.provider.openai import codex_oauth
+
+ def device_login(timeout_seconds: int = 900) -> codex_oauth.CodexOAuthTokens:
+ raise ProviderKeyError("Device login unavailable", "Use --method browser.")
+
+ def unexpected_login(timeout_seconds: int = 300) -> codex_oauth.CodexOAuthTokens:
+ pytest.fail("failed device login must not switch to browser automatically")
+
+ monkeypatch.setattr(codex_oauth, "login_codex_device_oauth", device_login)
+ monkeypatch.setattr(codex_oauth, "login_codex_browser_oauth", unexpected_login)
+
+ result = CliRunner().invoke(auth_command.app, ["provider", "login", "codex"])
+
+ assert result.exit_code == 1
+ assert "--method browser" in result.output
+ assert "login complete" not in result.output
diff --git a/tests/unit/fast_agent/llm/providers/test_codex_oauth.py b/tests/unit/fast_agent/llm/providers/test_codex_oauth.py
index a0a450272..6ad449992 100644
--- a/tests/unit/fast_agent/llm/providers/test_codex_oauth.py
+++ b/tests/unit/fast_agent/llm/providers/test_codex_oauth.py
@@ -1,6 +1,9 @@
import json
from pathlib import Path
+from unittest.mock import Mock
+from urllib.parse import parse_qs
+import httpx
import pytest
from fast_agent.auth.credentials import OAuthCredential, StoredCredential, save_oauth_credential
@@ -290,4 +293,216 @@ def close(self) -> None:
)
with pytest.raises(codex_oauth.ProviderKeyError, match="State parameter mismatch"):
- codex_oauth.login_codex_oauth()
+ codex_oauth.login_codex_browser_oauth()
+
+
+@pytest.fixture
+def device_login(monkeypatch):
+
+ clock = [0.0]
+ requests: list[httpx.Request] = []
+ responses: list[httpx.Response | Exception] = []
+ sleeps: list[float] = []
+
+ def sleep(seconds: float) -> None:
+ assert seconds > 0
+ sleeps.append(seconds)
+ clock[0] += seconds
+
+ def handle(request: httpx.Request) -> httpx.Response:
+ requests.append(request)
+ response = responses.pop(0)
+ if isinstance(response, Exception):
+ raise response
+ return response
+
+ client_type = httpx.Client
+ monkeypatch.setattr(
+ codex_oauth.httpx,
+ "Client",
+ lambda **kwargs: client_type(transport=httpx.MockTransport(handle)),
+ )
+ monkeypatch.setattr(codex_oauth.time, "monotonic", lambda: clock[0])
+ monkeypatch.setattr(codex_oauth.time, "sleep", sleep)
+ monkeypatch.setattr(codex_oauth.console, "ensure_blocking_console", lambda: None)
+ display = Mock()
+ save = Mock()
+ monkeypatch.setattr(codex_oauth.console.console, "print", display)
+ monkeypatch.setattr(codex_oauth, "save_codex_tokens", save)
+ monkeypatch.setattr(
+ codex_oauth, "login_codex_browser_oauth", Mock(side_effect=AssertionError("no fallback"))
+ )
+ return responses, requests, sleeps, save, display
+
+
+@pytest.mark.parametrize("alias", ["user_code", "usercode"])
+def test_device_login_protocol(device_login, alias: str) -> None:
+
+ responses, requests, sleeps, save, display = device_login
+ responses.extend(
+ [
+ httpx.Response(
+ 200, json={"device_auth_id": "id", alias: "[bold]code", "interval": "2"}
+ ),
+ httpx.Response(403),
+ httpx.Response(404),
+ httpx.Response(
+ 200, json={"authorization_code": "auth-code", "code_verifier": "verifier"}
+ ),
+ httpx.Response(200, json={"access_token": "access", "refresh_token": "refresh"}),
+ ]
+ )
+ tokens = codex_oauth.login_codex_oauth()
+ assert tokens.access_token == "access"
+ save.assert_called_once_with(tokens)
+ assert sleeps == [2, 2]
+ assert str(requests[0].url) == "https://auth.openai.com/api/accounts/deviceauth/usercode"
+ assert json.loads(requests[0].content) == {"client_id": codex_oauth.CODEX_CLIENT_ID}
+ for request in requests[1:4]:
+ assert str(request.url) == "https://auth.openai.com/api/accounts/deviceauth/token"
+ assert json.loads(request.content) == {"device_auth_id": "id", "user_code": "[bold]code"}
+
+ exchange = parse_qs(requests[-1].content.decode())
+ assert exchange["redirect_uri"] == ["https://auth.openai.com/deviceauth/callback"]
+ assert exchange["code"] == ["auth-code"]
+ assert exchange["code_verifier"] == ["verifier"]
+ display.assert_any_call("[bold]code", markup=False)
+ assert any("only if you started" in call.args[0] for call in display.call_args_list)
+
+
+@pytest.mark.parametrize("interval,expected", [(None, [5, 1]), ("0", [1] * 6), ("999", [6])])
+def test_device_timeout_has_bounded_nonzero_sleeps(device_login, interval, expected) -> None:
+
+ responses, requests, sleeps, save, _ = device_login
+ payload = {"device_auth_id": "id", "user_code": "code"}
+ if interval is not None:
+ payload["interval"] = interval
+ responses.append(httpx.Response(200, json=payload))
+ responses.extend(httpx.Response(403) for _ in expected)
+ with pytest.raises(codex_oauth.ProviderKeyError, match="timed out"):
+ codex_oauth.login_codex_device_oauth(6)
+ assert sleeps == expected
+ assert len(requests) == len(expected) + 1
+ save.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ "stage,status,payload",
+ [
+ ("request", 404, {}),
+ ("request", 500, {}),
+ ("request", 200, {"device_auth_id": "id"}),
+ ("request", 200, {"device_auth_id": 123, "user_code": "code"}),
+ ("request", 200, {"device_auth_id": "id", "user_code": "code", "interval": "bad"}),
+ ("poll", 401, {"secret": "sensitive"}),
+ ("poll", 429, {}),
+ ("poll", 200, {"authorization_code": "sensitive"}),
+ ("poll", 200, {"authorization_code": "sensitive", "code_verifier": ""}),
+ ],
+)
+def test_device_failures_do_not_store(device_login, stage, status, payload) -> None:
+
+ responses, _, _, save, _ = device_login
+ if stage == "poll":
+ responses.append(httpx.Response(200, json={"device_auth_id": "id", "user_code": "code"}))
+ responses.append(httpx.Response(status, json=payload))
+ with pytest.raises(codex_oauth.ProviderKeyError) as error:
+ codex_oauth.login_codex_device_oauth()
+ assert "fast-agent auth provider login codex --method browser" in str(error.value)
+ assert "sensitive" not in str(error.value)
+ save.assert_not_called()
+
+
+@pytest.mark.parametrize("failure", ["network", "json", "exchange"])
+def test_device_transport_and_exchange_failures(device_login, monkeypatch, failure) -> None:
+
+ responses, _, _, save, _ = device_login
+ if failure == "network":
+ responses.append(httpx.ConnectError("sensitive"))
+ elif failure == "json":
+ responses.append(httpx.Response(200, content=b"not json sensitive"))
+ else:
+ responses.extend(
+ [
+ httpx.Response(200, json={"device_auth_id": "id", "user_code": "code"}),
+ httpx.Response(
+ 200, json={"authorization_code": "code", "code_verifier": "verifier"}
+ ),
+ ]
+ )
+ monkeypatch.setattr(
+ codex_oauth,
+ "exchange_code_for_tokens",
+ Mock(side_effect=codex_oauth.ProviderKeyError("sensitive", "sensitive")),
+ )
+ with pytest.raises(codex_oauth.ProviderKeyError) as error:
+ codex_oauth.login_codex_device_oauth()
+ assert "--method browser" in str(error.value)
+ assert "sensitive" not in str(error.value)
+ save.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ "method,timeout,expected",
+ [("device", None, 900), ("browser", None, 300), ("device", 7, 7), ("browser", 8, 8)],
+)
+def test_login_dispatch(monkeypatch, method, timeout, expected) -> None:
+
+ device = Mock()
+ browser = Mock()
+ monkeypatch.setattr(codex_oauth, "login_codex_device_oauth", device)
+ monkeypatch.setattr(codex_oauth, "login_codex_browser_oauth", browser)
+ result = codex_oauth.login_codex_oauth(timeout, method=method)
+ selected, unused = (device, browser) if method == "device" else (browser, device)
+ selected.assert_called_once_with(expected)
+ assert result is selected.return_value
+ unused.assert_not_called()
+
+
+def test_browser_login_preserves_callback_and_redirect(monkeypatch) -> None:
+
+ server = Mock()
+ server.serve_once.return_value = ("code", "state")
+ monkeypatch.setattr(codex_oauth, "_CallbackServer", Mock(return_value=server))
+ monkeypatch.setattr(codex_oauth.secrets, "token_urlsafe", lambda size: "state")
+ monkeypatch.setattr(codex_oauth.console, "ensure_blocking_console", lambda: None)
+ monkeypatch.setattr(codex_oauth.console.console, "print", Mock())
+ request = Mock(return_value=CodexOAuthTokens(access_token="token"))
+ save = Mock()
+ monkeypatch.setattr(codex_oauth, "_token_request", request)
+ monkeypatch.setattr(codex_oauth, "save_codex_tokens", save)
+
+ tokens = codex_oauth.login_codex_oauth(method="browser")
+
+ server.start.assert_called_once()
+ server.serve_once.assert_called_once_with(timeout_seconds=300)
+ server.close.assert_called_once()
+ assert request.call_args.args[0]["redirect_uri"] == codex_oauth.CODEX_REDIRECT_URI
+ save.assert_called_once_with(tokens)
+
+
+def test_device_deadline_is_capped_at_fifteen_minutes(device_login) -> None:
+
+ responses, requests, sleeps, save, _ = device_login
+ responses.extend(
+ [
+ httpx.Response(
+ 200, json={"device_auth_id": "id", "user_code": "code", "interval": "1000"}
+ ),
+ httpx.Response(404),
+ ]
+ )
+ with pytest.raises(codex_oauth.ProviderKeyError, match="timed out"):
+ codex_oauth.login_codex_device_oauth(1800)
+ assert sleeps == [900]
+ assert len(requests) == 2
+ save.assert_not_called()
+
+
+def test_device_expired_deadline_makes_no_requests(device_login) -> None:
+ _, requests, sleeps, save, _ = device_login
+ with pytest.raises(codex_oauth.ProviderKeyError, match="timed out"):
+ codex_oauth.login_codex_device_oauth(0)
+ assert requests == []
+ assert sleeps == []
+ save.assert_not_called()
diff --git a/tests/unit/fast_agent/test_config_model_layering.py b/tests/unit/fast_agent/test_config_model_layering.py
index f72b46720..9be21f0f5 100644
--- a/tests/unit/fast_agent/test_config_model_layering.py
+++ b/tests/unit/fast_agent/test_config_model_layering.py
@@ -447,3 +447,43 @@ def test_get_settings_pairs_secrets_with_selected_config_directory(tmp_path: Pat
os.environ.pop("FAST_AGENT_HOME", None)
else:
os.environ["FAST_AGENT_HOME"] = previous_home
+
+
+@pytest.mark.parametrize("source", ["config", "environment"])
+def test_terminal_image_halfcell_selection(tmp_path: Path, monkeypatch, source: str) -> None:
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(config_module, "_settings", None)
+ monkeypatch.delenv("LOGGER__TERMINAL_IMAGES__BACKEND", raising=False)
+ config_path = tmp_path / "fast-agent.yaml"
+ if source == "config":
+ config_path.write_text("logger:\n terminal_images:\n backend: halfcell\n")
+ else:
+ config_path.write_text("logger:\n terminal_images:\n width: 40\n")
+ monkeypatch.setenv("LOGGER__TERMINAL_IMAGES__BACKEND", "halfcell")
+
+ settings = get_settings(config_path, no_home=True)
+
+ assert settings.logger.terminal_images.backend == "halfcell"
+ assert settings.logger.terminal_images.enabled
+ if source == "environment":
+ assert settings.logger.terminal_images.width == 40
+
+
+def test_terminal_image_config_takes_precedence_over_environment(
+ tmp_path: Path, monkeypatch
+) -> None:
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(config_module, "_settings", None)
+ monkeypatch.setenv("LOGGER__TERMINAL_IMAGES__BACKEND", "halfcell")
+ config_path = tmp_path / "fast-agent.yaml"
+ config_path.write_text("logger:\n terminal_images:\n backend: kitty\n")
+
+ assert get_settings(config_path, no_home=True).logger.terminal_images.backend == "kitty"
+
+
+def test_terminal_image_environment_rejects_invalid_backend(tmp_path: Path, monkeypatch) -> None:
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("LOGGER__TERMINAL_IMAGES__BACKEND", "invalid")
+
+ with pytest.raises(ValueError, match="logger.terminal_images.backend"):
+ Settings()
diff --git a/tests/unit/fast_agent/ui/test_input_toolbar.py b/tests/unit/fast_agent/ui/test_input_toolbar.py
index aae7eeecc..8c475e67a 100644
--- a/tests/unit/fast_agent/ui/test_input_toolbar.py
+++ b/tests/unit/fast_agent/ui/test_input_toolbar.py
@@ -274,16 +274,16 @@ def test_build_middle_segment_renders_muted_process_indicator_when_idle() -> Non
def test_agent_capability_indicator_styles_each_capability_independently() -> None:
assert render_agent_capability_indicator(AgentCapabilityMode.STANDARD) == (
- ""
+ ""
)
assert render_agent_capability_indicator(AgentCapabilityMode.DELEGATE) == (
- ""
+ ""
)
assert render_agent_capability_indicator(AgentCapabilityMode.HARNESS_ONLY) == (
- ""
+ ""
)
assert render_agent_capability_indicator(AgentCapabilityMode.ORCHESTRATE) == (
- ""
+ ""
)
diff --git a/tests/unit/fast_agent/ui/test_prompt_input.py b/tests/unit/fast_agent/ui/test_prompt_input.py
index d62182115..c88f0821e 100644
--- a/tests/unit/fast_agent/ui/test_prompt_input.py
+++ b/tests/unit/fast_agent/ui/test_prompt_input.py
@@ -3,6 +3,7 @@
from typing import TYPE_CHECKING, Any, cast
import pytest
+from prompt_toolkit.formatted_text import to_formatted_text
if TYPE_CHECKING:
from prompt_toolkit import PromptSession
@@ -112,3 +113,36 @@ async def prompt_async(self, prompt_text: object, **_kwargs: object) -> str:
assert getattr(prompts[0], "value", "") == (
"Enter value for name <draft> [local] (required): "
)
+
+
+@pytest.mark.parametrize(
+ ("agent_names", "show_name"),
+ [(None, False), ([], False), (["dev"], False), (["dev", "review"], True)],
+)
+def test_toolbar_shows_agent_name_only_when_agents_can_be_switched(
+ monkeypatch: pytest.MonkeyPatch, agent_names: list[str] | None, show_name: bool
+) -> None:
+ # A previous multi-agent prompt must not leave a stale identity in a new one.
+ monkeypatch.setattr(prompt_input, "available_agents", {"dev", "previous"})
+ monkeypatch.setattr(prompt_input, "agent_histories", {})
+ monkeypatch.setattr(prompt_input, "in_multiline_mode", False)
+ prompt_input._initialize_prompt_input_state(
+ agent_name="dev",
+ multiline=False,
+ available_agent_names=agent_names,
+ agent_provider=None,
+ )
+ session = _FakeSession()
+ toolbar = prompt_input._build_toolbar(
+ agent_name="dev",
+ toolbar_color="ansiblue",
+ agent_provider=None,
+ shell_context=prompt_input.ShellInputContext(),
+ session_factory=lambda: cast("PromptSession[Any]", session),
+ )
+
+ text = "".join(fragment[1] for fragment in to_formatted_text(toolbar()))
+
+ assert ("dev" in text) is show_name
+ assert "NRM" in text
+ assert "fast-agent" in text
diff --git a/tests/unit/fast_agent/ui/test_terminal_images.py b/tests/unit/fast_agent/ui/test_terminal_images.py
index 69f97b282..09d9ff7c3 100644
--- a/tests/unit/fast_agent/ui/test_terminal_images.py
+++ b/tests/unit/fast_agent/ui/test_terminal_images.py
@@ -223,36 +223,25 @@ def import_backend(name: str):
assert terminal_image_renderer._resolve_textual_image_class("sixel") is ViewportAwareSixelImage
-def test_herdr_auto_backend_uses_sanitized_halfcell_renderer(monkeypatch) -> None:
- monkeypatch.setenv("HERDR_ENV", "1")
-
- assert (
- terminal_image_renderer._resolve_textual_image_class("auto")
- is halfcell_renderer.HerdrAwareHalfcellImage
- )
- assert (
- terminal_image_renderer._resolve_textual_image_class("halfcell")
- is halfcell_renderer.HerdrAwareHalfcellImage
- )
-
-
-def test_explicit_kitty_backend_is_unchanged_in_herdr(monkeypatch) -> None:
+@pytest.mark.parametrize("herdr", [False, True])
+def test_auto_backend_honors_reported_kitty_support(monkeypatch, herdr: bool) -> None:
class TGPImage:
pass
- monkeypatch.setenv("HERDR_ENV", "1")
+ monkeypatch.setenv("HERDR_ENV", "1" if herdr else "0")
monkeypatch.setattr(
terminal_image_renderer,
"import_module",
- lambda name: SimpleNamespace(TGPImage=TGPImage),
+ lambda name: SimpleNamespace(Image=TGPImage, TGPImage=TGPImage),
)
+ assert terminal_image_renderer._resolve_textual_image_class("auto") is TGPImage
assert terminal_image_renderer._resolve_textual_image_class("kitty") is TGPImage
-def test_herdr_auto_halfcell_warning_follows_image(monkeypatch) -> None:
+def test_explicit_halfcell_backend_in_herdr_has_no_fallback_warning(monkeypatch) -> None:
monkeypatch.setenv("HERDR_ENV", "1")
- settings = TerminalImageSettings(backend="auto", width=1, height=1)
+ settings = TerminalImageSettings(backend="halfcell", width=1, height=1)
renderable = terminal_image_renderer.render_image_items(
settings,
@@ -260,9 +249,8 @@ def test_herdr_auto_halfcell_warning_follows_image(monkeypatch) -> None:
)
assert isinstance(renderable, Group)
- assert len(renderable.renderables) == 3
- assert isinstance(renderable.renderables[2], terminal_image_renderer.Text)
- assert renderable.renderables[2].plain == terminal_image_renderer.HERDR_HALFCELL_NOTICE
+ assert len(renderable.renderables) == 2
+ assert isinstance(renderable.renderables[1], halfcell_renderer.HerdrAwareHalfcellImage)
def test_herdr_halfcell_replaces_implausible_cell_geometry(monkeypatch) -> None:
diff --git a/uv.lock b/uv.lock
index 28ee15982..8b9b30f3e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -903,7 +903,7 @@ requires-dist = [{ name = "fast-agent-mcp", editable = "." }]
[[package]]
name = "fast-agent-mcp"
-version = "0.10.21"
+version = "0.10.22"
source = { editable = "." }
dependencies = [
{ name = "a2a-sdk" },