Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Jean-Baptiste Muscat <jeanbaptiste.muscatdupuis@mistral.ai>
Co-authored-by: JeroenvdV <JeroenvdV@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: allansimon-mistral <allan.simon@ext.mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Sirieix 2026-05-05 14:40:11 +02:00 committed by GitHub
parent 71f373c60c
commit 4972dd5694
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 2892 additions and 842 deletions

View file

@ -20,47 +20,25 @@ jobs:
id: set-matrix id: set-matrix
run: | run: |
# Linux: manylinux_2_28 containers → binary works on glibc >= 2.28 (RHEL 8+, Ubuntu 20.04+) # Linux: manylinux_2_28 containers → binary works on glibc >= 2.28 (RHEL 8+, Ubuntu 20.04+)
if [[ "${{ github.repository }}" == "mistralai/mistral-vibe" ]]; then matrix='{
matrix='{ "include": [
"include": [ {"runner": "ubuntu-22.04", "os": "linux", "arch": "x86_64", "container": "quay.io/pypa/manylinux_2_28_x86_64"},
{"runner": "ubuntu-22.04", "os": "linux", "arch": "x86_64", "container": "quay.io/pypa/manylinux_2_28_x86_64"}, {"runner": "ubuntu-22.04-arm", "os": "linux", "arch": "aarch64", "container": "quay.io/pypa/manylinux_2_28_aarch64"},
{"runner": "ubuntu-22.04-arm", "os": "linux", "arch": "aarch64", "container": "quay.io/pypa/manylinux_2_28_aarch64"}, {"runner": "macos-15-intel", "os": "darwin", "arch": "x86_64"},
{"runner": "macos-15-intel", "os": "darwin", "arch": "x86_64"}, {"runner": "macos-14", "os": "darwin", "arch": "aarch64"},
{"runner": "macos-14", "os": "darwin", "arch": "aarch64"}, {"runner": "windows-2022", "os": "windows", "arch": "x86_64"}
{"runner": "windows-2022", "os": "windows", "arch": "x86_64"}, ]
{"runner": "windows-11-arm", "os": "windows", "arch": "aarch64"} }'
] smoke_matrix='{
}' "include": [
smoke_matrix='{ {"runner": "ubuntu-24.04", "os": "linux", "arch": "x86_64"},
"include": [ {"runner": "ubuntu-24.04", "os": "linux", "arch": "x86_64", "tag": "old-glibc", "container": "almalinux:8"},
{"runner": "ubuntu-24.04", "os": "linux", "arch": "x86_64"}, {"runner": "ubuntu-24.04-arm", "os": "linux", "arch": "aarch64"},
{"runner": "ubuntu-24.04", "os": "linux", "arch": "x86_64", "tag": "old-glibc", "container": "almalinux:8"}, {"runner": "macos-15", "os": "darwin", "arch": "aarch64"},
{"runner": "ubuntu-24.04-arm", "os": "linux", "arch": "aarch64"}, {"runner": "macos-15-intel", "os": "darwin", "arch": "x86_64"},
{"runner": "macos-15", "os": "darwin", "arch": "aarch64"}, {"runner": "windows-latest", "os": "windows", "arch": "x86_64"}
{"runner": "macos-15-intel", "os": "darwin", "arch": "x86_64"}, ]
{"runner": "windows-latest", "os": "windows", "arch": "x86_64"}, }'
{"runner": "windows-11-arm", "os": "windows", "arch": "aarch64"}
]
}'
else # skip ARM Linux/Windows (runners not available on non public repos)
matrix='{
"include": [
{"runner": "ubuntu-22.04", "os": "linux", "arch": "x86_64", "container": "quay.io/pypa/manylinux_2_28_x86_64"},
{"runner": "macos-15-intel", "os": "darwin", "arch": "x86_64"},
{"runner": "macos-14", "os": "darwin", "arch": "aarch64"},
{"runner": "windows-2022", "os": "windows", "arch": "x86_64"}
]
}'
smoke_matrix='{
"include": [
{"runner": "ubuntu-24.04", "os": "linux", "arch": "x86_64"},
{"runner": "ubuntu-24.04", "os": "linux", "arch": "x86_64", "tag": "old-glibc", "container": "almalinux:8"},
{"runner": "macos-15", "os": "darwin", "arch": "aarch64"},
{"runner": "macos-15-intel", "os": "darwin", "arch": "x86_64"},
{"runner": "windows-latest", "os": "windows", "arch": "x86_64"}
]
}'
fi
echo "matrix=$(echo $matrix | jq -c .)" >> $GITHUB_OUTPUT echo "matrix=$(echo $matrix | jq -c .)" >> $GITHUB_OUTPUT
echo "smoke_matrix=$(echo $smoke_matrix | jq -c .)" >> $GITHUB_OUTPUT echo "smoke_matrix=$(echo $smoke_matrix | jq -c .)" >> $GITHUB_OUTPUT
@ -75,8 +53,6 @@ jobs:
container: ${{ matrix.container || '' }} container: ${{ matrix.container || '' }}
steps: steps:
- name: echo
run: echo github.repository=${{ github.repository }}
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5

View file

@ -34,6 +34,7 @@ Always go through `uv` — never invoke bare `python` or `pip`.
- Enums: `StrEnum` / `IntEnum` with `auto()` and UPPERCASE members. For type-mixing, the mix-in type comes before `Enum` in the bases. Add methods or `@property` rather than parallel lookup tables. - Enums: `StrEnum` / `IntEnum` with `auto()` and UPPERCASE members. For type-mixing, the mix-in type comes before `Enum` in the bases. Add methods or `@property` rather than parallel lookup tables.
- Write declarative, minimalist code: express intent, drop boilerplate. - Write declarative, minimalist code: express intent, drop boilerplate.
- Never call a private method from outside of it's class - Never call a private method from outside of it's class
- Avoid comments and docstrings, except for when there's a hard to spot corner case
## Typing & imports ## Typing & imports

View file

@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.9.4] - 2026-05-05
### Added
- `/rename` command to rename the current session
- `feat: vibe.at_mention_inserted` telemetry event
- "Always allow" tool permissions persist across sessions
- Eager agent-loop warmup so `vibe.ready` telemetry fires sooner
### Changed
- `bash` (`!command`) bang commands run via async subprocess for better latency
- Bumped `mistral` SDK to 2.4.4
- Bumped `cryptography` to address upstream CVEs
### Fixed
- Preserve `non_retryable` flag on exceptions raised through `_chat` / `_chat_streaming`, so callers driving the agent loop from a Temporal activity can signal "do not retry"
- `/clear` no longer chains `parent_session_id` to the previous session
- `vibe.new_session` telemetry no longer fires when resuming a session
### Removed
- Windows ARM build artifacts (no longer published; required to bump `cryptography`)
## [2.9.3] - 2026-04-30 ## [2.9.3] - 2026-04-30
### Added ### Added

View file

@ -219,7 +219,7 @@ Simply run `vibe` to enter the interactive chat loop.
- **Tool Output Toggle**: Press `Ctrl+O` to toggle the tool output view. - **Tool Output Toggle**: Press `Ctrl+O` to toggle the tool output view.
- **Todo View Toggle**: Press `Ctrl+T` to toggle the todo list view. - **Todo View Toggle**: Press `Ctrl+T` to toggle the todo list view.
- **Debug Console**: Press `Ctrl+\` to toggle the debug console. - **Debug Console**: Press `Ctrl+\` to toggle the debug console.
- **Auto-Approve Toggle**: Press `Shift+Tab` to toggle auto-approve mode on/off. - **Agent Selection**: Press `Shift+Tab` to cycle through agents (default, plan, ...).
You can start Vibe with a prompt using the following command: You can start Vibe with a prompt using the following command:
@ -227,8 +227,6 @@ You can start Vibe with a prompt using the following command:
vibe "Refactor the main function in cli/main.py to be more modular." vibe "Refactor the main function in cli/main.py to be more modular."
``` ```
**Note**: The `--auto-approve` flag automatically approves all tool executions without prompting. In interactive mode, you can also toggle auto-approve on/off using `Shift+Tab`.
### Trust Folder System ### Trust Folder System
Vibe includes a trust folder system to ensure you only run the agent in directories you trust. When you first run Vibe in a new directory which contains a `.vibe` subfolder, it may ask you to confirm whether you trust the folder. Vibe includes a trust folder system to ensure you only run the agent in directories you trust. When you first run Vibe in a new directory which contains a `.vibe` subfolder, it may ask you to confirm whether you trust the folder.
@ -412,7 +410,15 @@ Vibe supports multiple ways to configure your API keys:
### Custom System Prompts ### Custom System Prompts
You can create custom system prompts to replace the default one (`prompts/cli.md`). Create a markdown file in the `~/.vibe/prompts/` directory with your custom prompt content. You can create `AGENTS.md` files to add custom instructions. You can also replace the entire system prompt.
Place `AGENTS.md` files in:
- `~/.vibe/AGENTS.md` — user-level instructions for all projects
- Project directories — project-specific instructions, loaded from cwd up to the trust root
Priority: closer directories override more distant ones. Instructions in `AGENTS.md` override the default system prompt. Files are only loaded for trusted folders.
Custom system prompts entirely replace the default one (`prompts/cli.md`). Create a markdown file in the `~/.vibe/prompts/` directory with your custom prompt content.
To use a custom system prompt, set the `system_prompt_id` in your configuration to match the filename (without the `.md` extension): To use a custom system prompt, set the `system_prompt_id` in your configuration to match the filename (without the `.md` extension):

View file

@ -56,5 +56,4 @@ runs:
run: | run: |
cd "${{ github.workspace }}" cd "${{ github.workspace }}"
uv run --directory "${{ github.action_path }}" vibe \ uv run --directory "${{ github.action_path }}" vibe \
--auto-approve \
-p "$PROMPT_INPUT" -p "$PROMPT_INPUT"

View file

@ -1,7 +1,7 @@
id = "mistral-vibe" id = "mistral-vibe"
name = "Mistral Vibe" name = "Mistral Vibe"
description = "Mistral's open-source coding assistant" description = "Mistral's open-source coding assistant"
version = "2.9.3" version = "2.9.4"
schema_version = 1 schema_version = 1
authors = ["Mistral AI"] authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe" repository = "https://github.com/mistralai/mistral-vibe"
@ -11,25 +11,21 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg" icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64] [agent_servers.mistral-vibe.targets.darwin-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.3/vibe-acp-darwin-aarch64-2.9.3.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.4/vibe-acp-darwin-aarch64-2.9.4.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64] [agent_servers.mistral-vibe.targets.darwin-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.3/vibe-acp-darwin-x86_64-2.9.3.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.4/vibe-acp-darwin-x86_64-2.9.4.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64] [agent_servers.mistral-vibe.targets.linux-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.3/vibe-acp-linux-aarch64-2.9.3.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.4/vibe-acp-linux-aarch64-2.9.4.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64] [agent_servers.mistral-vibe.targets.linux-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.3/vibe-acp-linux-x86_64-2.9.3.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.4/vibe-acp-linux-x86_64-2.9.4.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.3/vibe-acp-windows-aarch64-2.9.3.zip"
cmd = "./vibe-acp.exe"
[agent_servers.mistral-vibe.targets.windows-x86_64] [agent_servers.mistral-vibe.targets.windows-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.3/vibe-acp-windows-x86_64-2.9.3.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.4/vibe-acp-windows-x86_64-2.9.4.zip"
cmd = "./vibe-acp.exe" cmd = "./vibe-acp.exe"

View file

@ -1,6 +1,6 @@
[project] [project]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.9.3" version = "2.9.4"
description = "Minimal CLI coding agent by Mistral" description = "Minimal CLI coding agent by Mistral"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@ -31,7 +31,7 @@ dependencies = [
"anyio>=4.12.0", "anyio>=4.12.0",
"cachetools>=5.5.0", "cachetools>=5.5.0",
"charset-normalizer>=3.4.4", "charset-normalizer>=3.4.4",
"cryptography>=44.0.0,<=46.0.3", "cryptography>=44.0.0",
"gitpython>=3.1.46", "gitpython>=3.1.46",
"giturlparse>=0.14.0", "giturlparse>=0.14.0",
"google-auth>=2.0.0", "google-auth>=2.0.0",
@ -40,7 +40,7 @@ dependencies = [
"keyring>=25.6.0", "keyring>=25.6.0",
"markdownify>=1.2.2", "markdownify>=1.2.2",
"mcp>=1.14.0", "mcp>=1.14.0",
"mistralai==2.3.2", "mistralai==2.4.4",
"opentelemetry-api>=1.39.1", "opentelemetry-api>=1.39.1",
"opentelemetry-exporter-otlp-proto-http>=1.39.1", "opentelemetry-exporter-otlp-proto-http>=1.39.1",
"opentelemetry-sdk>=1.39.1", "opentelemetry-sdk>=1.39.1",

View file

@ -565,7 +565,7 @@ async def start_session_with_request_permission(
assert isinstance(last_response, RequestPermissionJsonRpcRequest) assert isinstance(last_response, RequestPermissionJsonRpcRequest)
assert last_response.params is not None assert last_response.params is not None
assert len(last_response.params.options) == 3 assert len(last_response.params.options) == 4
return last_response return last_response
@ -778,13 +778,15 @@ class TestToolCallStructure:
) )
assert permission_request.params is not None assert permission_request.params is not None
# Verify "Allow always" option includes the pattern label # Verify granular permissions are passed in field_meta
allow_always = next( allow_always = next(
o o
for o in permission_request.params.options for o in permission_request.params.options
if o.option_id == ToolOption.ALLOW_ALWAYS if o.option_id == ToolOption.ALLOW_ALWAYS
) )
assert "npm install *" in allow_always.name assert allow_always.name == "Allow for remainder of this session"
assert allow_always.field_meta is not None
assert "required_permissions" in allow_always.field_meta
@pytest.mark.skip(reason="Long running tool call updates are not implemented yet") @pytest.mark.skip(reason="Long running tool call updates are not implemented yet")
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -34,7 +34,7 @@ class TestACPInitialize:
), ),
) )
assert response.agent_info == Implementation( assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.3" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.4"
) )
assert response.auth_methods == [] assert response.auth_methods == []
@ -62,7 +62,7 @@ class TestACPInitialize:
), ),
) )
assert response.agent_info == Implementation( assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.3" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.4"
) )
assert response.auth_methods is not None assert response.auth_methods is not None

View file

@ -0,0 +1,363 @@
from __future__ import annotations
import json
from pathlib import Path
from acp.schema import SessionInfoUpdate
import pytest
import tomli_w
from tests.conftest import build_test_vibe_config, get_base_config
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.acp.exceptions import InternalError, InvalidRequestError, SessionNotFoundError
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import ModelConfig, SessionLoggingConfig
@pytest.fixture
def acp_agent_with_session_config(
backend: FakeBackend, temp_session_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> tuple[VibeAcpAgentLoop, FakeClient]:
session_config = SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
)
config = build_test_vibe_config(
active_model="devstral-latest",
models=[
ModelConfig(
name="devstral-latest", provider="mistral", alias="devstral-latest"
)
],
session_logging=session_config,
)
class PatchedAgentLoop(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
self._base_config = config
self.agent_manager.invalidate_config()
monkeypatch.setattr("vibe.acp.acp_agent_loop.AgentLoop", PatchedAgentLoop)
monkeypatch.setattr(VibeAcpAgentLoop, "_load_config", lambda self: config)
monkeypatch.setattr(
"vibe.acp.acp_agent_loop.VibeConfig.load", lambda *args, **kwargs: config
)
vibe_acp_agent = VibeAcpAgentLoop()
client = FakeClient()
vibe_acp_agent.on_connect(client)
client.on_connect(vibe_acp_agent)
return vibe_acp_agent, client
class TestSessionSetTitle:
@pytest.mark.asyncio
async def test_updates_live_unsaved_session_title(
self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient]
) -> None:
acp_agent, client = acp_agent_with_session_config
response = await acp_agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
assert response is not None
result = await acp_agent.ext_method(
"session/set_title",
{"sessionId": response.session_id, "title": "Manual title"},
)
assert result == {}
session = acp_agent.sessions[response.session_id]
metadata = session.agent_loop.session_logger.session_metadata
assert metadata is not None
assert metadata.title == "Manual title"
assert metadata.title_source == "manual"
assert metadata.end_time is None
assert not session.agent_loop.session_logger.metadata_filepath.exists()
info_updates = [
notification.update
for notification in client._session_updates
if isinstance(notification.update, SessionInfoUpdate)
]
assert len(info_updates) == 1
assert info_updates[0].title == "Manual title"
assert info_updates[0].updated_at is None
@pytest.mark.asyncio
async def test_updates_live_saved_session_title(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
) -> None:
acp_agent, client = acp_agent_with_session_config
saved_session_id = "saved-session-12345678"
acp_session_id = saved_session_id[:8]
cwd = str(Path.cwd())
session_dir = create_test_session(
temp_session_dir,
saved_session_id,
cwd,
title="Old title",
end_time="2024-01-01T12:05:00Z",
)
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=acp_session_id)
client._session_updates.clear()
result = await acp_agent.ext_method(
"session/set_title",
{"sessionId": saved_session_id, "title": "Renamed session"},
)
assert result == {}
session = acp_agent.sessions[acp_session_id]
metadata = session.agent_loop.session_logger.session_metadata
assert metadata is not None
assert metadata.title == "Renamed session"
assert metadata.title_source == "manual"
assert metadata.end_time == "2024-01-01T12:05:00Z"
assert session.agent_loop.session_id == saved_session_id
saved_metadata = json.loads((session_dir / "meta.json").read_text())
assert saved_metadata["title"] == "Renamed session"
assert saved_metadata["title_source"] == "manual"
assert saved_metadata["end_time"] == "2024-01-01T12:05:00Z"
info_updates = [
notification
for notification in client._session_updates
if isinstance(notification.update, SessionInfoUpdate)
]
assert len(info_updates) == 1
assert info_updates[0].session_id == acp_session_id
assert info_updates[0].update.title == "Renamed session"
assert info_updates[0].update.updated_at == metadata.end_time
assert saved_metadata["end_time"] == info_updates[0].update.updated_at
@pytest.mark.asyncio
async def test_loaded_session_title_is_unchanged_when_persist_fails(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
acp_agent, client = acp_agent_with_session_config
saved_session_id = "saved-session-12345678"
acp_session_id = saved_session_id[:8]
cwd = str(Path.cwd())
create_test_session(
temp_session_dir,
saved_session_id,
cwd,
title="Old title",
end_time="2024-01-01T12:05:00Z",
)
await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=acp_session_id)
client._session_updates.clear()
async def fail_persist(*args, **kwargs):
raise ValueError("Cannot rewrite metadata")
monkeypatch.setattr(
"vibe.acp.acp_agent_loop.update_saved_session_title_at_path", fail_persist
)
with pytest.raises(InternalError):
await acp_agent.ext_method(
"session/set_title",
{"sessionId": saved_session_id, "title": "Renamed session"},
)
session = acp_agent.sessions[acp_session_id]
metadata = session.agent_loop.session_logger.session_metadata
assert metadata is not None
assert metadata.title == "Old title"
assert metadata.title_source == "auto"
assert not [
notification
for notification in client._session_updates
if isinstance(notification.update, SessionInfoUpdate)
]
@pytest.mark.asyncio
async def test_updates_saved_but_not_loaded_session_title(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
) -> None:
acp_agent, client = acp_agent_with_session_config
session_id = "offline-session-12345678"
cwd = str(Path.cwd())
session_dir = create_test_session(
temp_session_dir,
session_id,
cwd,
title="Old title",
end_time="2024-01-01T12:05:00Z",
)
result = await acp_agent.ext_method(
"session/set_title", {"sessionId": session_id, "title": "Renamed session"}
)
assert result == {}
saved_metadata = json.loads((session_dir / "meta.json").read_text())
assert saved_metadata["title"] == "Renamed session"
assert saved_metadata["title_source"] == "manual"
info_updates = [
notification
for notification in client._session_updates
if isinstance(notification.update, SessionInfoUpdate)
]
assert len(info_updates) == 1
assert info_updates[0].session_id == session_id
assert info_updates[0].update.title == "Renamed session"
assert info_updates[0].update.updated_at == saved_metadata["end_time"]
@pytest.mark.asyncio
async def test_updates_saved_session_with_configured_log_dir_without_api_key(
self,
config_dir: Path,
temp_session_dir: Path,
create_test_session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config = {
**get_base_config(),
"session_logging": {
"enabled": True,
"save_dir": str(temp_session_dir),
"session_prefix": "session",
},
}
monkeypatch.setenv("VIBE_HOME", str(config_dir))
(config_dir / "config.toml").write_text(tomli_w.dumps(config), encoding="utf-8")
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
session_id = "offline-session-12345678"
session_dir = create_test_session(
temp_session_dir,
session_id,
str(Path.cwd()),
title="Old title",
end_time="2024-01-01T12:05:00Z",
)
acp_agent = VibeAcpAgentLoop()
client = FakeClient()
acp_agent.on_connect(client)
client.on_connect(acp_agent)
result = await acp_agent.ext_method(
"session/set_title",
{"sessionId": session_id, "title": "Renamed without key"},
)
assert result == {}
saved_metadata = json.loads((session_dir / "meta.json").read_text())
assert saved_metadata["title"] == "Renamed without key"
assert saved_metadata["title_source"] == "manual"
info_updates = [
notification
for notification in client._session_updates
if isinstance(notification.update, SessionInfoUpdate)
]
assert len(info_updates) == 1
assert info_updates[0].session_id == session_id
assert info_updates[0].update.title == "Renamed without key"
@pytest.mark.asyncio
async def test_raises_on_invalid_params(
self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient]
) -> None:
acp_agent, _client = acp_agent_with_session_config
with pytest.raises(InvalidRequestError):
await acp_agent.ext_method(
"session/set_title", {"title": "Missing session id"}
)
with pytest.raises(InvalidRequestError):
await acp_agent.ext_method(
"session/set_title", {"sessionId": "missing-title-session"}
)
with pytest.raises(InvalidRequestError):
await acp_agent.ext_method(
"session/set_title",
{"sessionId": "blank-title-session", "title": " "},
)
with pytest.raises(InvalidRequestError):
await acp_agent.ext_method(
"session/set_title", {"sessionId": " ", "title": "Blank session id"}
)
with pytest.raises(InvalidRequestError):
await acp_agent.ext_method(
"session/set_title",
{"savedSessionId": "saved-session", "title": "Unsupported target"},
)
@pytest.mark.asyncio
async def test_session_id_falls_back_to_saved_session_lookup(
self,
acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient],
temp_session_dir: Path,
create_test_session,
) -> None:
acp_agent, client = acp_agent_with_session_config
session_id = "saved-session-12345678"
cwd = str(Path.cwd())
session_dir = create_test_session(
temp_session_dir,
session_id,
cwd,
title="Old title",
end_time="2024-01-01T12:05:00Z",
)
result = await acp_agent.ext_method(
"session/set_title", {"sessionId": session_id, "title": "Renamed session"}
)
assert result == {}
saved_metadata = json.loads((session_dir / "meta.json").read_text())
assert saved_metadata["title"] == "Renamed session"
assert saved_metadata["title_source"] == "manual"
info_updates = [
notification
for notification in client._session_updates
if isinstance(notification.update, SessionInfoUpdate)
]
assert len(info_updates) == 1
assert info_updates[0].session_id == session_id
assert info_updates[0].update.title == "Renamed session"
@pytest.mark.asyncio
async def test_raises_when_session_cannot_be_found(
self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient]
) -> None:
acp_agent, _client = acp_agent_with_session_config
with pytest.raises(SessionNotFoundError):
await acp_agent.ext_method(
"session/set_title",
{"sessionId": "missing-session", "title": "Renamed session"},
)

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@ -42,6 +43,37 @@ class TestTelemetryNotification:
assert telemetry_events == [] assert telemetry_events == []
@pytest.mark.asyncio
async def test_at_mention_inserted_dispatches_telemetry(
self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict[str, Any]]
) -> None:
session = await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
telemetry_events.clear()
await acp_agent_loop.ext_notification(
"telemetry/send",
{
"event": "vibe.at_mention_inserted",
"session_id": session.session_id,
"properties": {
"nb_mentions": 2,
"context_types": {"file": 1, "folder": 1},
"file_extensions": {".py": 1},
"message_id": "msg-abc",
},
},
)
at_mention_events = [
e for e in telemetry_events if e["event_name"] == "vibe.at_mention_inserted"
]
assert len(at_mention_events) == 1
props = at_mention_events[0]["properties"]
assert props["nb_mentions"] == 2
assert props["context_types"] == {"file": 1, "folder": 1}
assert props["file_extensions"] == {".py": 1}
assert props["message_id"] == "msg-abc"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_raises_on_invalid_params( async def test_raises_on_invalid_params(
self, acp_agent_loop: VibeAcpAgentLoop self, acp_agent_loop: VibeAcpAgentLoop

View file

@ -81,10 +81,13 @@ class TestBuildPermissionOptions:
] ]
result = build_permission_options(permissions) result = build_permission_options(permissions)
assert len(result) == 3 assert len(result) == 4
allow_always = next(o for o in result if o.option_id == ToolOption.ALLOW_ALWAYS) allow_always = next(o for o in result if o.option_id == ToolOption.ALLOW_ALWAYS)
assert "npm install *" in allow_always.name
assert "session" in allow_always.name.lower() assert "session" in allow_always.name.lower()
allow_permanent = next(
o for o in result if o.option_id == ToolOption.ALLOW_ALWAYS_PERMANENT
)
assert "Always allow" == allow_permanent.name
def test_allow_always_has_field_meta(self) -> None: def test_allow_always_has_field_meta(self) -> None:
permissions = [ permissions = [
@ -118,6 +121,6 @@ class TestBuildPermissionOptions:
allow_once = next(o for o in result if o.option_id == ToolOption.ALLOW_ONCE) allow_once = next(o for o in result if o.option_id == ToolOption.ALLOW_ONCE)
reject_once = next(o for o in result if o.option_id == ToolOption.REJECT_ONCE) reject_once = next(o for o in result if o.option_id == ToolOption.REJECT_ONCE)
assert allow_once.name == "Allow once" assert allow_once.name == "Allow once"
assert reject_once.name == "Reject once" assert reject_once.name == "Deny"
assert allow_once.field_meta is None assert allow_once.field_meta is None
assert reject_once.field_meta is None assert reject_once.field_meta is None

View file

@ -0,0 +1,18 @@
from __future__ import annotations
from pathlib import Path
from vibe.core.autocompletion.path_prompt import build_path_prompt_payload
def test_deduplicates_same_file_mentioned_twice(tmp_path: Path) -> None:
readme = tmp_path / "README.md"
readme.write_text("hello", encoding="utf-8")
payload = build_path_prompt_payload(
"See @README.md and again @README.md", base_dir=tmp_path
)
assert len(payload.resources) == 1
assert payload.resources[0].path == readme
assert len(payload.all_resources) == 2

View file

@ -102,6 +102,16 @@ class TestCommandRegistry:
_, cmd, _ = result _, cmd, _ = result
assert cmd.handler == "_show_session_picker" assert cmd.handler == "_show_session_picker"
def test_rename_command_registration(self) -> None:
registry = CommandRegistry()
assert registry.get_command_name("/rename") == "rename"
assert registry.get_command_name("/title") is None
result = registry.parse_command("/rename Better title")
assert result is not None
_, cmd, cmd_args = result
assert cmd.handler == "_rename_session"
assert cmd_args == "Better title"
def test_parse_command_keeps_args_for_no_arg_commands(self) -> None: def test_parse_command_keeps_args_for_no_arg_commands(self) -> None:
registry = CommandRegistry() registry = CommandRegistry()
result = registry.parse_command("/help extra") result = registry.parse_command("/help extra")

View file

@ -0,0 +1,162 @@
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from tests.conftest import build_test_vibe_app, build_test_vibe_config
from vibe.cli.textual_ui.widgets.messages import ErrorMessage, UserCommandMessage
from vibe.core.config import SessionLoggingConfig
def _enabled_session_config(save_dir: Path) -> SessionLoggingConfig:
return SessionLoggingConfig(enabled=True, save_dir=str(save_dir))
@pytest.mark.asyncio
async def test_rename_command_updates_live_unsaved_session_title(
tmp_path: Path,
) -> None:
config = build_test_vibe_config(session_logging=_enabled_session_config(tmp_path))
app = build_test_vibe_app(config=config)
async with app.run_test() as pilot:
handled = await app._handle_command("/rename Manual title")
await pilot.pause()
messages = app.query(UserCommandMessage)
assert any(
message._content == 'Session renamed to "Manual title".'
for message in messages
)
assert handled is True
metadata = app.agent_loop.session_logger.session_metadata
assert metadata is not None
assert metadata.title == "Manual title"
assert metadata.title_source == "manual"
assert not app.agent_loop.session_logger.metadata_filepath.exists()
@pytest.mark.asyncio
async def test_rename_command_persists_existing_session_metadata(
tmp_path: Path,
) -> None:
config = build_test_vibe_config(session_logging=_enabled_session_config(tmp_path))
app = build_test_vibe_app(config=config)
logger = app.agent_loop.session_logger
assert logger.session_dir is not None
assert logger.session_metadata is not None
logger.session_dir.mkdir(parents=True)
existing_metadata = {
**logger.session_metadata.model_dump(),
"end_time": "2024-01-01T12:05:00Z",
"extra_field": "preserved",
}
logger.metadata_filepath.write_text(json.dumps(existing_metadata), encoding="utf-8")
async with app.run_test() as pilot:
handled = await app._handle_command("/rename Persisted title")
await pilot.pause()
assert handled is True
metadata = logger.session_metadata
assert metadata is not None
assert metadata.title == "Persisted title"
assert metadata.title_source == "manual"
saved_metadata = json.loads(logger.metadata_filepath.read_text())
assert saved_metadata["title"] == "Persisted title"
assert saved_metadata["title_source"] == "manual"
assert saved_metadata["end_time"] == "2024-01-01T12:05:00Z"
assert saved_metadata["extra_field"] == "preserved"
@pytest.mark.asyncio
async def test_resume_picker_shows_renamed_session_title(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = build_test_vibe_config(
session_logging=_enabled_session_config(tmp_path), vibe_code_enabled=False
)
app = build_test_vibe_app(config=config)
logger = app.agent_loop.session_logger
assert logger.session_dir is not None
assert logger.session_metadata is not None
logger.session_dir.mkdir(parents=True)
existing_metadata = {
**logger.session_metadata.model_dump(),
"end_time": "2024-01-01T12:05:00Z",
"total_messages": 1,
}
logger.metadata_filepath.write_text(json.dumps(existing_metadata), encoding="utf-8")
logger.messages_filepath.write_text(
'{"role": "user", "content": "Original prompt"}\n', encoding="utf-8"
)
captured_picker = None
async def capture_picker(picker):
nonlocal captured_picker
captured_picker = picker
monkeypatch.setattr(app, "_switch_from_input", capture_picker)
async with app.run_test() as pilot:
handled = await app._handle_command("/rename New title")
await app._show_session_picker()
await pilot.pause()
assert handled is True
assert captured_picker is not None
assert captured_picker._latest_messages[f"local:{logger.session_id}"] == "New title"
@pytest.mark.asyncio
async def test_rename_command_requires_title(tmp_path: Path) -> None:
config = build_test_vibe_config(session_logging=_enabled_session_config(tmp_path))
app = build_test_vibe_app(config=config)
async with app.run_test() as pilot:
handled = await app._handle_command("/rename")
await pilot.pause()
errors = app.query(ErrorMessage)
assert any(error._error == "Usage: /rename <title>" for error in errors)
assert handled is True
@pytest.mark.asyncio
async def test_rename_command_is_intercepted_for_remote_sessions(
tmp_path: Path,
) -> None:
config = build_test_vibe_config(session_logging=_enabled_session_config(tmp_path))
app = build_test_vibe_app(config=config)
async with app.run_test() as pilot:
with patch(
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
) as MockSource:
remote_source = MagicMock()
remote_source.session_id = "remote-session-id"
remote_source.is_terminated = False
remote_source.is_waiting_for_input = False
MockSource.return_value = remote_source
await app._remote_manager.attach(
session_id="remote-session-id", config=config
)
handled = await app._handle_command("/rename Remote title")
await pilot.pause()
errors = app.query(ErrorMessage)
assert any(
error._error == "Renaming is only supported for local sessions."
for error in errors
)
assert handled is True

View file

@ -249,6 +249,81 @@ class TestTelemetryClient:
assert telemetry_events[0]["event_name"] == "vibe.user_cancelled_action" assert telemetry_events[0]["event_name"] == "vibe.user_cancelled_action"
assert telemetry_events[0]["properties"]["action"] == "interrupt_agent" assert telemetry_events[0]["properties"]["action"] == "interrupt_agent"
def test_send_at_mention_inserted_payload(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
client.send_at_mention_inserted(
nb_mentions=2,
context_types={"file": 1, "folder": 1},
file_extensions={".py": 1},
message_id="msg-123",
)
assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe.at_mention_inserted"
assert telemetry_events[0]["properties"] == {
"nb_mentions": 2,
"context_types": {"file": 1, "folder": 1},
"file_extensions": {".py": 1},
"message_id": "msg-123",
}
def test_send_at_mention_inserted_null_file_extensions(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
client.send_at_mention_inserted(
nb_mentions=1,
context_types={"folder": 1},
file_extensions=None,
message_id=None,
)
assert len(telemetry_events) == 1
assert telemetry_events[0]["properties"]["file_extensions"] is None
assert telemetry_events[0]["properties"]["message_id"] is None
def test_send_at_mention_inserted_multiple_files_counts_extensions(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
client.send_at_mention_inserted(
nb_mentions=4,
context_types={"file": 3, "folder": 1},
file_extensions={".py": 2, ".ts": 1},
message_id="msg-multi",
)
props = telemetry_events[0]["properties"]
assert props["nb_mentions"] == 4
assert props["context_types"] == {"file": 3, "folder": 1}
assert props["file_extensions"] == {".py": 2, ".ts": 1}
def test_send_at_mention_inserted_duplicate_references_reflected_in_counts(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
client.send_at_mention_inserted(
nb_mentions=4,
context_types={"file": 2, "folder": 2},
file_extensions={".py": 2},
message_id="msg-dupes",
)
props = telemetry_events[0]["properties"]
assert props["nb_mentions"] == 4
assert props["context_types"] == {"file": 2, "folder": 2}
assert props["file_extensions"] == {".py": 2}
def test_send_auto_compact_triggered_payload( def test_send_auto_compact_triggered_payload(
self, telemetry_events: list[dict[str, Any]] self, telemetry_events: list[dict[str, Any]]
) -> None: ) -> None:

View file

@ -0,0 +1,110 @@
from __future__ import annotations
import io
import os
from pathlib import Path
import subprocess
import sys
import pexpect
import pytest
from tests import TESTS_ROOT
from tests.e2e.common import (
ansi_tolerant_pattern,
send_ctrl_c_until_quit_confirmation,
wait_for_main_screen,
wait_for_request_count,
)
from tests.e2e.mock_server import StreamingMockServer
def _venv_executable(venv_path: Path, name: str) -> Path:
if os.name == "nt":
return venv_path / "Scripts" / f"{name}.exe"
return venv_path / "bin" / name
def _build_wheel(dist_dir: Path) -> Path:
subprocess.run(
["uv", "build", "--wheel", "--out-dir", str(dist_dir)],
cwd=TESTS_ROOT.parent,
check=True,
)
wheels = sorted(dist_dir.glob("mistral_vibe-*.whl"))
assert len(wheels) == 1
return wheels[0]
def _install_fresh_wheel(tmp_path: Path, wheel_path: Path) -> Path:
venv_path = tmp_path / "fresh-install-venv"
subprocess.run(
["uv", "venv", "--no-config", "--python", sys.executable, str(venv_path)],
cwd=tmp_path,
check=True,
)
python_path = _venv_executable(venv_path, "python")
subprocess.run(
[
"uv",
"pip",
"install",
"--no-config",
"--refresh",
"--python",
str(python_path),
str(wheel_path),
],
cwd=tmp_path,
check=True,
)
return _venv_executable(venv_path, "vibe")
@pytest.mark.timeout(90)
def test_fresh_wheel_install_can_spawn_cli_and_complete_happy_path(
streaming_mock_server: StreamingMockServer,
setup_e2e_env: None,
e2e_workdir: Path,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
wheel_path = _build_wheel(tmp_path / "dist")
vibe_executable = _install_fresh_wheel(tmp_path, wheel_path)
monkeypatch.delenv("PYTHONPATH", raising=False)
captured = io.StringIO()
child = pexpect.spawn(
str(vibe_executable),
["--workdir", str(e2e_workdir)],
cwd=str(tmp_path),
env=os.environ,
encoding="utf-8",
timeout=30,
dimensions=(36, 120),
)
child.logfile_read = captured
try:
wait_for_main_screen(child, timeout=20)
child.send("Greet")
child.send("\r")
wait_for_request_count(
lambda: len(streaming_mock_server.requests), expected_count=1, timeout=10
)
child.expect(ansi_tolerant_pattern("Hello from mock server"), timeout=10)
send_ctrl_c_until_quit_confirmation(child, captured, timeout=5)
child.expect(pexpect.EOF, timeout=10)
finally:
if child.isalive():
child.terminate(force=True)
if not child.closed:
child.close()
output = captured.getvalue()
assert "Welcome to Mistral Vibe" not in output
assert streaming_mock_server.requests[-1].get("model") == "mock-model"

View file

@ -79,7 +79,9 @@ def test_spawn_cli_asks_bash_permission_and_shows_tool_output_after_approval(
wait_for_request_count( wait_for_request_count(
lambda: len(streaming_mock_server.requests), expected_count=1, timeout=10 lambda: len(streaming_mock_server.requests), expected_count=1, timeout=10
) )
wait_for_rendered_text(child, captured, needle="bash command", timeout=10) wait_for_rendered_text(
child, captured, needle="Permission for the bash tool", timeout=10
)
child.send("y") child.send("y")
child.send("\r") child.send("\r")
wait_for_rendered_text(child, captured, needle=PREDICTABLE_OUTPUT, timeout=10) wait_for_rendered_text(child, captured, needle=PREDICTABLE_OUTPUT, timeout=10)

View file

@ -0,0 +1,178 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from vibe.core.config import SessionLoggingConfig
from vibe.core.session.saved_sessions import update_saved_session_title
@pytest.fixture
def temp_session_dir(tmp_path: Path) -> Path:
session_dir = tmp_path / "sessions"
session_dir.mkdir()
return session_dir
@pytest.fixture
def session_config(temp_session_dir: Path) -> SessionLoggingConfig:
return SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="test", enabled=True
)
class TestUpdateSavedSessionTitle:
@pytest.mark.asyncio
async def test_updates_saved_session_title_without_losing_existing_metadata(
self, session_config: SessionLoggingConfig
) -> None:
session_dir = Path(session_config.save_dir)
saved_session_dir = session_dir / "test_20240101_120000_aaaaaaaa"
saved_session_dir.mkdir()
(saved_session_dir / "messages.jsonl").write_text(
'{"role": "user", "content": "Hello"}\n', encoding="utf-8"
)
original_metadata = {
"session_id": "aaaaaaaa-1111",
"start_time": "2024-01-01T12:00:00Z",
"end_time": "2024-01-01T12:05:00Z",
"git_commit": None,
"git_branch": None,
"username": "test-user",
"environment": {"working_directory": "/home/user/project"},
"title": "Old title",
"stats": {"steps": 2},
"total_messages": 1,
"tools_available": [
{
"type": "function",
"function": {"name": "bash", "description": "Run shell commands"},
}
],
"config": {"active_model": "test-model"},
"system_prompt": {"role": "system", "content": "You are helpful"},
}
metadata_file = saved_session_dir / "meta.json"
metadata_file.write_text(json.dumps(original_metadata), encoding="utf-8")
updated_metadata = await update_saved_session_title(
"aaaaaaaa-1111", "Renamed session", session_config
)
assert updated_metadata == {
**original_metadata,
"title": "Renamed session",
"title_source": "manual",
}
assert json.loads(metadata_file.read_text(encoding="utf-8")) == updated_metadata
@pytest.mark.asyncio
async def test_rejects_empty_title(
self, session_config: SessionLoggingConfig
) -> None:
session_dir = Path(session_config.save_dir)
saved_session_dir = session_dir / "test_20240101_120000_bbbbbbbb"
saved_session_dir.mkdir()
(saved_session_dir / "messages.jsonl").write_text(
'{"role": "user", "content": "Hello"}\n', encoding="utf-8"
)
original_metadata = {
"session_id": "bbbbbbbb-2222",
"start_time": "2024-01-01T12:00:00Z",
"end_time": "2024-01-01T12:05:00Z",
"git_commit": None,
"git_branch": None,
"username": "test-user",
"environment": {"working_directory": "/home/user/project"},
"title": "Manual title",
"title_source": "manual",
"stats": {"steps": 2},
}
metadata_file = saved_session_dir / "meta.json"
metadata_file.write_text(json.dumps(original_metadata), encoding="utf-8")
with pytest.raises(ValueError, match="Session title cannot be empty."):
await update_saved_session_title("bbbbbbbb-2222", " ", session_config)
@pytest.mark.asyncio
async def test_preserves_saved_session_end_time_when_updating_title(
self, session_config: SessionLoggingConfig
) -> None:
session_dir = Path(session_config.save_dir)
saved_session_dir = session_dir / "test_20240101_120000_cccccccc"
saved_session_dir.mkdir()
(saved_session_dir / "messages.jsonl").write_text(
'{"role": "user", "content": "Hello"}\n', encoding="utf-8"
)
original_metadata = {
"session_id": "cccccccc-3333",
"start_time": "2024-01-01T12:00:00Z",
"end_time": "2024-01-01T12:05:00Z",
"git_commit": None,
"git_branch": None,
"username": "test-user",
"environment": {"working_directory": "/home/user/project"},
"title": "Old title",
}
metadata_file = saved_session_dir / "meta.json"
metadata_file.write_text(json.dumps(original_metadata), encoding="utf-8")
updated_metadata = await update_saved_session_title(
"cccccccc-3333", "Renamed session", session_config
)
assert updated_metadata == {
**original_metadata,
"title": "Renamed session",
"title_source": "manual",
}
assert json.loads(metadata_file.read_text(encoding="utf-8")) == updated_metadata
@pytest.mark.asyncio
async def test_raises_for_missing_saved_session(
self, session_config: SessionLoggingConfig
) -> None:
with pytest.raises(ValueError, match="Session not found: missing-session"):
await update_saved_session_title(
"missing-session", "Renamed", session_config
)
@pytest.mark.asyncio
async def test_requires_exact_saved_session_id(
self, session_config: SessionLoggingConfig
) -> None:
session_dir = Path(session_config.save_dir)
saved_session_dir = session_dir / "test_20240101_120000_dddddddd"
saved_session_dir.mkdir()
(saved_session_dir / "messages.jsonl").write_text(
'{"role": "user", "content": "Hello"}\n', encoding="utf-8"
)
original_metadata = {
"session_id": "dddddddd-4444",
"start_time": "2024-01-01T12:00:00Z",
"end_time": "2024-01-01T12:05:00Z",
"git_commit": None,
"git_branch": None,
"username": "test-user",
"environment": {"working_directory": "/home/user/project"},
"title": "Old title",
}
metadata_file = saved_session_dir / "meta.json"
metadata_file.write_text(json.dumps(original_metadata), encoding="utf-8")
with pytest.raises(ValueError, match="Session not found: dddddddd"):
await update_saved_session_title("dddddddd", "Renamed", session_config)
assert (
json.loads(metadata_file.read_text(encoding="utf-8")) == original_metadata
)

View file

@ -1049,6 +1049,36 @@ class TestSessionLoaderGetFirstUserMessage:
class TestSessionLoaderUTF8Encoding: class TestSessionLoaderUTF8Encoding:
def test_load_metadata_defaults_title_source_for_existing_sessions(
self, session_config: SessionLoggingConfig
) -> None:
session_dir = Path(session_config.save_dir)
session_folder = session_dir / "test_20230101_120000_legacyttl"
session_folder.mkdir()
metadata_content = {
"session_id": "legacy-title-test",
"start_time": "2023-01-01T12:00:00Z",
"end_time": "2023-01-01T12:05:00Z",
"environment": {"working_directory": "/home/user/project"},
"username": "testuser",
"git_commit": None,
"git_branch": None,
"title": "Existing title",
}
metadata_file = session_folder / "meta.json"
with metadata_file.open("w", encoding="utf-8") as f:
json.dump(metadata_content, f, indent=2, ensure_ascii=False)
messages_file = session_folder / "messages.jsonl"
messages_file.write_text('{"role": "user", "content": "Hello"}\n')
metadata = SessionLoader.load_metadata(session_folder)
assert metadata.title == "Existing title"
assert metadata.title_source == "auto"
def test_load_metadata_with_utf8_encoding( def test_load_metadata_with_utf8_encoding(
self, session_config: SessionLoggingConfig, create_test_session self, session_config: SessionLoggingConfig, create_test_session
) -> None: ) -> None:

View file

@ -139,6 +139,8 @@ class TestSessionLoggerMetadata:
assert metadata.username == "testuser" assert metadata.username == "testuser"
assert "working_directory" in metadata.environment assert "working_directory" in metadata.environment
assert metadata.environment["working_directory"] == str(Path.cwd()) assert metadata.environment["working_directory"] == str(Path.cwd())
assert metadata.title is None
assert metadata.title_source == "auto"
@patch("vibe.core.session.session_logger.subprocess.run") @patch("vibe.core.session.session_logger.subprocess.run")
@patch("vibe.core.session.session_logger.getpass.getuser") @patch("vibe.core.session.session_logger.getpass.getuser")
@ -161,6 +163,50 @@ class TestSessionLoggerMetadata:
assert metadata.username == "testuser" assert metadata.username == "testuser"
class TestSessionLoggerTitleManagement:
def test_set_title_marks_live_session_title_as_manual(
self, session_config: SessionLoggingConfig
) -> None:
logger = SessionLogger(session_config, "test-session-123")
logger.set_title("Manual title")
assert logger.session_metadata is not None
assert logger.session_metadata.title == "Manual title"
assert logger.session_metadata.title_source == "manual"
def test_set_title_none_returns_live_session_to_auto_mode(
self, session_config: SessionLoggingConfig
) -> None:
logger = SessionLogger(session_config, "test-session-123")
logger.set_title("Manual title")
logger.set_title(None)
assert logger.session_metadata is not None
assert logger.session_metadata.title is None
assert logger.session_metadata.title_source == "auto"
def test_set_title_rejects_empty_title(
self, session_config: SessionLoggingConfig
) -> None:
logger = SessionLogger(session_config, "test-session-123")
with pytest.raises(ValueError, match="Session title cannot be empty."):
logger.set_title(" ")
def test_set_title_preserves_live_session_end_time(
self, session_config: SessionLoggingConfig
) -> None:
logger = SessionLogger(session_config, "test-session-123")
assert logger.session_metadata is not None
logger.session_metadata.end_time = "2026-01-01T10:00:00+00:00"
logger.set_title("Manual title")
assert logger.session_metadata.end_time == "2026-01-01T10:00:00+00:00"
class TestSessionLoggerSaveInteraction: class TestSessionLoggerSaveInteraction:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_interaction_disabled( async def test_save_interaction_disabled(
@ -234,6 +280,7 @@ class TestSessionLoggerSaveInteraction:
assert metadata["stats"]["steps"] == stats.steps assert metadata["stats"]["steps"] == stats.steps
assert "title" in metadata assert "title" in metadata
assert metadata["title"] == "Hello" assert metadata["title"] == "Hello"
assert metadata["title_source"] == "auto"
assert "system_prompt" in metadata assert "system_prompt" in metadata
@pytest.mark.asyncio @pytest.mark.asyncio
@ -447,6 +494,7 @@ class TestSessionLoggerSaveInteraction:
assert metadata["total_messages"] == 1 assert metadata["total_messages"] == 1
assert metadata["stats"]["steps"] == stats.steps assert metadata["stats"]["steps"] == stats.steps
assert metadata["title"] == "Untitled session" assert metadata["title"] == "Untitled session"
assert metadata["title_source"] == "auto"
messages_file = logger.session_dir / "messages.jsonl" messages_file = logger.session_dir / "messages.jsonl"
assert messages_file.exists() assert messages_file.exists()
@ -496,6 +544,46 @@ class TestSessionLoggerSaveInteraction:
assert metadata["stats"]["steps"] == stats.steps assert metadata["stats"]["steps"] == stats.steps
expected_title = long_message[:50] + "" expected_title = long_message[:50] + ""
assert metadata["title"] == expected_title assert metadata["title"] == expected_title
assert metadata["title_source"] == "auto"
@pytest.mark.asyncio
async def test_save_interaction_preserves_manual_title(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
assert logger.session_metadata is not None
logger.set_title("Manual title")
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
stats = AgentStats(
steps=1, session_prompt_tokens=10, session_completion_tokens=20
)
await logger.save_interaction(
messages=messages,
stats=stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
assert logger.session_dir is not None
metadata_file = logger.session_dir / "meta.json"
with open(metadata_file) as f:
metadata = json.load(f)
assert metadata["title"] == "Manual title"
assert metadata["title_source"] == "manual"
messages_file = logger.session_dir / "messages.jsonl" messages_file = logger.session_dir / "messages.jsonl"
assert messages_file.exists() assert messages_file.exists()

View file

@ -33,11 +33,11 @@
} }
.terminal-r1 { fill: #c5c8c6 } .terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #4b4e55 } .terminal-r2 { fill: #68a0b3 }
.terminal-r3 { fill: #68a0b3 } .terminal-r3 { fill: #4b4e55 }
.terminal-r4 { fill: #292929 } .terminal-r4 { fill: #9a9b99 }
.terminal-r5 { fill: #9a9b99 } .terminal-r5 { fill: #608ab1;font-weight: bold }
.terminal-r6 { fill: #608ab1;font-weight: bold } .terminal-r6 { fill: #292929 }
.terminal-r7 { fill: #98a84b;font-weight: bold } .terminal-r7 { fill: #98a84b;font-weight: bold }
.terminal-r8 { fill: #ff8205;font-weight: bold } .terminal-r8 { fill: #ff8205;font-weight: bold }
</style> </style>
@ -197,56 +197,56 @@
</g> </g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)"> <g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#4b4e55" x="1451.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="489.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="513.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="538.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="733.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="757.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="782.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="806.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="831.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="855.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="879.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="904.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="928.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="953.1" width="12.2" height="24.65" shape-rendering="crispEdges"/> <rect fill="#4b4e55" x="1451.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="489.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="513.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="538.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="733.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="757.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="782.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="806.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="831.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="855.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="879.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="904.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="928.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="953.1" width="12.2" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix"> <g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="20" textLength="414.8" clip-path="url(#terminal-line-0)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r2" x="1451.8" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"></text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> <text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="20" textLength="61" clip-path="url(#terminal-line-0)">Type&#160;</text><text class="terminal-r2" x="231.8" y="20" textLength="61" clip-path="url(#terminal-line-0)">/help</text><text class="terminal-r1" x="292.8" y="20" textLength="256.2" clip-path="url(#terminal-line-0)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">Type&#160;</text><text class="terminal-r3" x="231.8" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">/help</text><text class="terminal-r1" x="292.8" y="44.4" textLength="256.2" clip-path="url(#terminal-line-1)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"> </text><text class="terminal-r3" x="1451.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"> </text><text class="terminal-r4" x="24.4" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r5" x="48.8" y="68.8" textLength="219.6" clip-path="url(#terminal-line-2)">Keyboard&#160;Shortcuts</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r5" x="24.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r6" x="48.8" y="93.2" textLength="219.6" clip-path="url(#terminal-line-3)">Keyboard&#160;Shortcuts</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"> </text><text class="terminal-r4" x="24.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r1" x="48.8" y="93.2" textLength="24.4" clip-path="url(#terminal-line-3)">&#160;</text><text class="terminal-r7" x="73.2" y="93.2" textLength="61" clip-path="url(#terminal-line-3)">Enter</text><text class="terminal-r1" x="134.2" y="93.2" textLength="183" clip-path="url(#terminal-line-3)">&#160;Submit&#160;message</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r5" x="24.4" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r1" x="48.8" y="117.6" textLength="24.4" clip-path="url(#terminal-line-4)">&#160;</text><text class="terminal-r7" x="73.2" y="117.6" textLength="61" clip-path="url(#terminal-line-4)">Enter</text><text class="terminal-r1" x="134.2" y="117.6" textLength="183" clip-path="url(#terminal-line-4)">&#160;Submit&#160;message</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"> </text><text class="terminal-r4" x="24.4" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r1" x="48.8" y="117.6" textLength="24.4" clip-path="url(#terminal-line-4)">&#160;</text><text class="terminal-r7" x="73.2" y="117.6" textLength="73.2" clip-path="url(#terminal-line-4)">Ctrl+J</text><text class="terminal-r1" x="146.4" y="117.6" textLength="36.6" clip-path="url(#terminal-line-4)">&#160;/&#160;</text><text class="terminal-r7" x="183" y="117.6" textLength="134.2" clip-path="url(#terminal-line-4)">Shift+Enter</text><text class="terminal-r1" x="317.2" y="117.6" textLength="183" clip-path="url(#terminal-line-4)">&#160;Insert&#160;newline</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r5" x="24.4" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r1" x="48.8" y="142" textLength="24.4" clip-path="url(#terminal-line-5)">&#160;</text><text class="terminal-r7" x="73.2" y="142" textLength="73.2" clip-path="url(#terminal-line-5)">Ctrl+J</text><text class="terminal-r1" x="146.4" y="142" textLength="36.6" clip-path="url(#terminal-line-5)">&#160;/&#160;</text><text class="terminal-r7" x="183" y="142" textLength="134.2" clip-path="url(#terminal-line-5)">Shift+Enter</text><text class="terminal-r1" x="317.2" y="142" textLength="183" clip-path="url(#terminal-line-5)">&#160;Insert&#160;newline</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"> </text><text class="terminal-r4" x="24.4" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r1" x="48.8" y="142" textLength="24.4" clip-path="url(#terminal-line-5)">&#160;</text><text class="terminal-r7" x="73.2" y="142" textLength="73.2" clip-path="url(#terminal-line-5)">Escape</text><text class="terminal-r1" x="146.4" y="142" textLength="402.6" clip-path="url(#terminal-line-5)">&#160;Interrupt&#160;agent&#160;or&#160;close&#160;dialogs</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r5" x="24.4" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r1" x="48.8" y="166.4" textLength="24.4" clip-path="url(#terminal-line-6)">&#160;</text><text class="terminal-r7" x="73.2" y="166.4" textLength="73.2" clip-path="url(#terminal-line-6)">Escape</text><text class="terminal-r1" x="146.4" y="166.4" textLength="402.6" clip-path="url(#terminal-line-6)">&#160;Interrupt&#160;agent&#160;or&#160;close&#160;dialogs</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"> </text><text class="terminal-r4" x="24.4" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r1" x="48.8" y="166.4" textLength="24.4" clip-path="url(#terminal-line-6)">&#160;</text><text class="terminal-r7" x="73.2" y="166.4" textLength="73.2" clip-path="url(#terminal-line-6)">Ctrl+C</text><text class="terminal-r1" x="146.4" y="166.4" textLength="463.6" clip-path="url(#terminal-line-6)">&#160;Quit&#160;(or&#160;clear&#160;input&#160;if&#160;text&#160;present)</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r5" x="24.4" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="48.8" y="190.8" textLength="24.4" clip-path="url(#terminal-line-7)">&#160;</text><text class="terminal-r7" x="73.2" y="190.8" textLength="73.2" clip-path="url(#terminal-line-7)">Ctrl+C</text><text class="terminal-r1" x="146.4" y="190.8" textLength="463.6" clip-path="url(#terminal-line-7)">&#160;Quit&#160;(or&#160;clear&#160;input&#160;if&#160;text&#160;present)</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"> </text><text class="terminal-r4" x="24.4" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="48.8" y="190.8" textLength="24.4" clip-path="url(#terminal-line-7)">&#160;</text><text class="terminal-r7" x="73.2" y="190.8" textLength="73.2" clip-path="url(#terminal-line-7)">Ctrl+G</text><text class="terminal-r1" x="146.4" y="190.8" textLength="366" clip-path="url(#terminal-line-7)">&#160;Edit&#160;input&#160;in&#160;external&#160;editor</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r5" x="24.4" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="48.8" y="215.2" textLength="24.4" clip-path="url(#terminal-line-8)">&#160;</text><text class="terminal-r7" x="73.2" y="215.2" textLength="73.2" clip-path="url(#terminal-line-8)">Ctrl+G</text><text class="terminal-r1" x="146.4" y="215.2" textLength="366" clip-path="url(#terminal-line-8)">&#160;Edit&#160;input&#160;in&#160;external&#160;editor</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"> </text><text class="terminal-r4" x="24.4" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="48.8" y="215.2" textLength="24.4" clip-path="url(#terminal-line-8)">&#160;</text><text class="terminal-r7" x="73.2" y="215.2" textLength="73.2" clip-path="url(#terminal-line-8)">Ctrl+O</text><text class="terminal-r1" x="146.4" y="215.2" textLength="292.8" clip-path="url(#terminal-line-8)">&#160;Toggle&#160;tool&#160;output&#160;view</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r5" x="24.4" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="48.8" y="239.6" textLength="24.4" clip-path="url(#terminal-line-9)">&#160;</text><text class="terminal-r7" x="73.2" y="239.6" textLength="73.2" clip-path="url(#terminal-line-9)">Ctrl+O</text><text class="terminal-r1" x="146.4" y="239.6" textLength="292.8" clip-path="url(#terminal-line-9)">&#160;Toggle&#160;tool&#160;output&#160;view</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"> </text><text class="terminal-r4" x="24.4" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="48.8" y="239.6" textLength="24.4" clip-path="url(#terminal-line-9)">&#160;</text><text class="terminal-r7" x="73.2" y="239.6" textLength="109.8" clip-path="url(#terminal-line-9)">Shift+Tab</text><text class="terminal-r1" x="183" y="239.6" textLength="512.4" clip-path="url(#terminal-line-9)">&#160;Cycle&#160;through&#160;agents&#160;(default,&#160;plan,&#160;...)</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r5" x="24.4" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"></text><text class="terminal-r1" x="48.8" y="264" textLength="24.4" clip-path="url(#terminal-line-10)">&#160;</text><text class="terminal-r7" x="73.2" y="264" textLength="109.8" clip-path="url(#terminal-line-10)">Shift+Tab</text><text class="terminal-r1" x="183" y="264" textLength="305" clip-path="url(#terminal-line-10)">&#160;Toggle&#160;auto-approve&#160;mode</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"> </text><text class="terminal-r4" x="24.4" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"></text><text class="terminal-r1" x="48.8" y="264" textLength="24.4" clip-path="url(#terminal-line-10)">&#160;</text><text class="terminal-r7" x="73.2" y="264" textLength="73.2" clip-path="url(#terminal-line-10)">Alt+↑↓</text><text class="terminal-r1" x="146.4" y="264" textLength="36.6" clip-path="url(#terminal-line-10)">&#160;/&#160;</text><text class="terminal-r7" x="183" y="264" textLength="97.6" clip-path="url(#terminal-line-10)">Ctrl+P/N</text><text class="terminal-r1" x="280.6" y="264" textLength="390.4" clip-path="url(#terminal-line-10)">&#160;Rewind&#160;to&#160;previous/next&#160;message</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r5" x="24.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r1" x="48.8" y="288.4" textLength="24.4" clip-path="url(#terminal-line-11)">&#160;</text><text class="terminal-r7" x="73.2" y="288.4" textLength="73.2" clip-path="url(#terminal-line-11)">Alt+↑↓</text><text class="terminal-r1" x="146.4" y="288.4" textLength="36.6" clip-path="url(#terminal-line-11)">&#160;/&#160;</text><text class="terminal-r7" x="183" y="288.4" textLength="97.6" clip-path="url(#terminal-line-11)">Ctrl+P/N</text><text class="terminal-r1" x="280.6" y="288.4" textLength="390.4" clip-path="url(#terminal-line-11)">&#160;Rewind&#160;to&#160;previous/next&#160;message</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"> </text><text class="terminal-r4" x="24.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r5" x="24.4" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"> </text><text class="terminal-r4" x="24.4" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></text><text class="terminal-r5" x="48.8" y="312.8" textLength="195.2" clip-path="url(#terminal-line-12)">Special&#160;Features</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r5" x="24.4" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r6" x="48.8" y="337.2" textLength="195.2" clip-path="url(#terminal-line-13)">Special&#160;Features</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"> </text><text class="terminal-r4" x="24.4" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r5" x="24.4" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"> </text><text class="terminal-r4" x="24.4" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r1" x="48.8" y="361.6" textLength="24.4" clip-path="url(#terminal-line-14)">&#160;</text><text class="terminal-r7" x="73.2" y="361.6" textLength="122" clip-path="url(#terminal-line-14)">!&lt;command&gt;</text><text class="terminal-r1" x="195.2" y="361.6" textLength="366" clip-path="url(#terminal-line-14)">&#160;Execute&#160;bash&#160;command&#160;directly</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r5" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="48.8" y="386" textLength="24.4" clip-path="url(#terminal-line-15)">&#160;</text><text class="terminal-r7" x="73.2" y="386" textLength="122" clip-path="url(#terminal-line-15)">!&lt;command&gt;</text><text class="terminal-r1" x="195.2" y="386" textLength="366" clip-path="url(#terminal-line-15)">&#160;Execute&#160;bash&#160;command&#160;directly</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"> </text><text class="terminal-r4" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="48.8" y="386" textLength="24.4" clip-path="url(#terminal-line-15)">&#160;</text><text class="terminal-r7" x="73.2" y="386" textLength="170.8" clip-path="url(#terminal-line-15)">@path/to/file/</text><text class="terminal-r1" x="244" y="386" textLength="305" clip-path="url(#terminal-line-15)">&#160;Autocompletes&#160;file&#160;paths</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r5" x="24.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r1" x="48.8" y="410.4" textLength="24.4" clip-path="url(#terminal-line-16)">&#160;</text><text class="terminal-r7" x="73.2" y="410.4" textLength="170.8" clip-path="url(#terminal-line-16)">@path/to/file/</text><text class="terminal-r1" x="244" y="410.4" textLength="305" clip-path="url(#terminal-line-16)">&#160;Autocompletes&#160;file&#160;paths</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"> </text><text class="terminal-r4" x="24.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r5" x="24.4" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"> </text><text class="terminal-r4" x="24.4" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r5" x="48.8" y="434.8" textLength="97.6" clip-path="url(#terminal-line-17)">Commands</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r5" x="24.4" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r6" x="48.8" y="459.2" textLength="97.6" clip-path="url(#terminal-line-18)">Commands</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"> </text><text class="terminal-r4" x="24.4" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r5" x="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"> </text><text class="terminal-r4" x="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="48.8" y="483.6" textLength="24.4" clip-path="url(#terminal-line-19)">&#160;</text><text class="terminal-r7" x="73.2" y="483.6" textLength="61" clip-path="url(#terminal-line-19)">/help</text><text class="terminal-r1" x="134.2" y="483.6" textLength="231.8" clip-path="url(#terminal-line-19)">:&#160;Show&#160;help&#160;message</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r5" x="24.4" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="48.8" y="508" textLength="24.4" clip-path="url(#terminal-line-20)">&#160;</text><text class="terminal-r7" x="73.2" y="508" textLength="61" clip-path="url(#terminal-line-20)">/help</text><text class="terminal-r1" x="134.2" y="508" textLength="231.8" clip-path="url(#terminal-line-20)">:&#160;Show&#160;help&#160;message</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"> </text><text class="terminal-r4" x="24.4" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="48.8" y="508" textLength="24.4" clip-path="url(#terminal-line-20)">&#160;</text><text class="terminal-r7" x="73.2" y="508" textLength="85.4" clip-path="url(#terminal-line-20)">/config</text><text class="terminal-r1" x="158.6" y="508" textLength="268.4" clip-path="url(#terminal-line-20)">:&#160;Edit&#160;config&#160;settings</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r5" x="24.4" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="48.8" y="532.4" textLength="24.4" clip-path="url(#terminal-line-21)">&#160;</text><text class="terminal-r7" x="73.2" y="532.4" textLength="85.4" clip-path="url(#terminal-line-21)">/config</text><text class="terminal-r1" x="158.6" y="532.4" textLength="268.4" clip-path="url(#terminal-line-21)">:&#160;Edit&#160;config&#160;settings</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"> </text><text class="terminal-r4" x="24.4" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="48.8" y="532.4" textLength="24.4" clip-path="url(#terminal-line-21)">&#160;</text><text class="terminal-r7" x="73.2" y="532.4" textLength="73.2" clip-path="url(#terminal-line-21)">/model</text><text class="terminal-r1" x="146.4" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)">:&#160;Select&#160;active&#160;model</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r5" x="24.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="48.8" y="556.8" textLength="24.4" clip-path="url(#terminal-line-22)">&#160;</text><text class="terminal-r7" x="73.2" y="556.8" textLength="73.2" clip-path="url(#terminal-line-22)">/model</text><text class="terminal-r1" x="146.4" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)">:&#160;Select&#160;active&#160;model</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"> </text><text class="terminal-r4" x="24.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="48.8" y="556.8" textLength="24.4" clip-path="url(#terminal-line-22)">&#160;</text><text class="terminal-r7" x="73.2" y="556.8" textLength="109.8" clip-path="url(#terminal-line-22)">/thinking</text><text class="terminal-r1" x="183" y="556.8" textLength="280.6" clip-path="url(#terminal-line-22)">:&#160;Select&#160;thinking&#160;level</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r5" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="48.8" y="581.2" textLength="24.4" clip-path="url(#terminal-line-23)">&#160;</text><text class="terminal-r7" x="73.2" y="581.2" textLength="109.8" clip-path="url(#terminal-line-23)">/thinking</text><text class="terminal-r1" x="183" y="581.2" textLength="280.6" clip-path="url(#terminal-line-23)">:&#160;Select&#160;thinking&#160;level</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> </text><text class="terminal-r4" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="48.8" y="581.2" textLength="24.4" clip-path="url(#terminal-line-23)">&#160;</text><text class="terminal-r7" x="73.2" y="581.2" textLength="85.4" clip-path="url(#terminal-line-23)">/reload</text><text class="terminal-r1" x="158.6" y="581.2" textLength="780.8" clip-path="url(#terminal-line-23)">:&#160;Reload&#160;configuration,&#160;agent&#160;instructions,&#160;and&#160;skills&#160;from&#160;disk</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r5" x="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="48.8" y="605.6" textLength="24.4" clip-path="url(#terminal-line-24)">&#160;</text><text class="terminal-r7" x="73.2" y="605.6" textLength="85.4" clip-path="url(#terminal-line-24)">/reload</text><text class="terminal-r1" x="158.6" y="605.6" textLength="780.8" clip-path="url(#terminal-line-24)">:&#160;Reload&#160;configuration,&#160;agent&#160;instructions,&#160;and&#160;skills&#160;from&#160;disk</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> </text><text class="terminal-r4" x="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="48.8" y="605.6" textLength="24.4" clip-path="url(#terminal-line-24)">&#160;</text><text class="terminal-r7" x="73.2" y="605.6" textLength="73.2" clip-path="url(#terminal-line-24)">/clear</text><text class="terminal-r1" x="146.4" y="605.6" textLength="341.6" clip-path="url(#terminal-line-24)">:&#160;Clear&#160;conversation&#160;history</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r5" x="24.4" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="48.8" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">&#160;</text><text class="terminal-r7" x="73.2" y="630" textLength="73.2" clip-path="url(#terminal-line-25)">/clear</text><text class="terminal-r1" x="146.4" y="630" textLength="341.6" clip-path="url(#terminal-line-25)">:&#160;Clear&#160;conversation&#160;history</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> </text><text class="terminal-r4" x="24.4" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="48.8" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">&#160;</text><text class="terminal-r7" x="73.2" y="630" textLength="61" clip-path="url(#terminal-line-25)">/copy</text><text class="terminal-r1" x="134.2" y="630" textLength="561.2" clip-path="url(#terminal-line-25)">:&#160;Copy&#160;the&#160;last&#160;agent&#160;message&#160;to&#160;the&#160;clipboard</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r5" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="48.8" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">&#160;</text><text class="terminal-r7" x="73.2" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">/copy</text><text class="terminal-r1" x="134.2" y="654.4" textLength="561.2" clip-path="url(#terminal-line-26)">:&#160;Copy&#160;the&#160;last&#160;agent&#160;message&#160;to&#160;the&#160;clipboard</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> </text><text class="terminal-r4" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="48.8" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">&#160;</text><text class="terminal-r7" x="73.2" y="654.4" textLength="48.8" clip-path="url(#terminal-line-26)">/log</text><text class="terminal-r1" x="122" y="654.4" textLength="524.6" clip-path="url(#terminal-line-26)">:&#160;Show&#160;path&#160;to&#160;current&#160;interaction&#160;log&#160;file</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r5" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="48.8" y="678.8" textLength="24.4" clip-path="url(#terminal-line-27)">&#160;</text><text class="terminal-r7" x="73.2" y="678.8" textLength="48.8" clip-path="url(#terminal-line-27)">/log</text><text class="terminal-r1" x="122" y="678.8" textLength="524.6" clip-path="url(#terminal-line-27)">:&#160;Show&#160;path&#160;to&#160;current&#160;interaction&#160;log&#160;file</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> </text><text class="terminal-r4" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="48.8" y="678.8" textLength="24.4" clip-path="url(#terminal-line-27)">&#160;</text><text class="terminal-r7" x="73.2" y="678.8" textLength="73.2" clip-path="url(#terminal-line-27)">/debug</text><text class="terminal-r1" x="146.4" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">:&#160;Toggle&#160;debug&#160;console</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r5" x="24.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="48.8" y="703.2" textLength="24.4" clip-path="url(#terminal-line-28)">&#160;</text><text class="terminal-r7" x="73.2" y="703.2" textLength="73.2" clip-path="url(#terminal-line-28)">/debug</text><text class="terminal-r1" x="146.4" y="703.2" textLength="268.4" clip-path="url(#terminal-line-28)">:&#160;Toggle&#160;debug&#160;console</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> </text><text class="terminal-r4" x="24.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="48.8" y="703.2" textLength="24.4" clip-path="url(#terminal-line-28)">&#160;</text><text class="terminal-r7" x="73.2" y="703.2" textLength="97.6" clip-path="url(#terminal-line-28)">/compact</text><text class="terminal-r1" x="170.8" y="703.2" textLength="1171.2" clip-path="url(#terminal-line-28)">:&#160;Compact&#160;conversation&#160;history&#160;by&#160;summarizing.&#160;Optionally&#160;pass&#160;instructions&#160;to&#160;guide&#160;the&#160;summary</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r5" x="24.4" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="48.8" y="727.6" textLength="24.4" clip-path="url(#terminal-line-29)">&#160;</text><text class="terminal-r7" x="73.2" y="727.6" textLength="97.6" clip-path="url(#terminal-line-29)">/compact</text><text class="terminal-r1" x="170.8" y="727.6" textLength="1171.2" clip-path="url(#terminal-line-29)">:&#160;Compact&#160;conversation&#160;history&#160;by&#160;summarizing.&#160;Optionally&#160;pass&#160;instructions&#160;to&#160;guide&#160;the&#160;summary</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"> </text><text class="terminal-r4" x="24.4" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="48.8" y="727.6" textLength="24.4" clip-path="url(#terminal-line-29)">&#160;</text><text class="terminal-r7" x="73.2" y="727.6" textLength="61" clip-path="url(#terminal-line-29)">/exit</text><text class="terminal-r1" x="134.2" y="727.6" textLength="268.4" clip-path="url(#terminal-line-29)">:&#160;Exit&#160;the&#160;application</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r5" x="24.4" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="48.8" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">&#160;</text><text class="terminal-r7" x="73.2" y="752" textLength="61" clip-path="url(#terminal-line-30)">/exit</text><text class="terminal-r1" x="134.2" y="752" textLength="268.4" clip-path="url(#terminal-line-30)">:&#160;Exit&#160;the&#160;application</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"> </text><text class="terminal-r4" x="24.4" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="48.8" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">&#160;</text><text class="terminal-r7" x="73.2" y="752" textLength="85.4" clip-path="url(#terminal-line-30)">/status</text><text class="terminal-r1" x="158.6" y="752" textLength="317.2" clip-path="url(#terminal-line-30)">:&#160;Display&#160;agent&#160;statistics</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r5" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="48.8" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">&#160;</text><text class="terminal-r7" x="73.2" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">/status</text><text class="terminal-r1" x="158.6" y="776.4" textLength="317.2" clip-path="url(#terminal-line-31)">:&#160;Display&#160;agent&#160;statistics</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"> </text><text class="terminal-r4" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="48.8" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">&#160;</text><text class="terminal-r7" x="73.2" y="776.4" textLength="146.4" clip-path="url(#terminal-line-31)">/proxy-setup</text><text class="terminal-r1" x="219.6" y="776.4" textLength="561.2" clip-path="url(#terminal-line-31)">:&#160;Configure&#160;proxy&#160;and&#160;SSL&#160;certificate&#160;settings</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r5" x="24.4" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="48.8" y="800.8" textLength="24.4" clip-path="url(#terminal-line-32)">&#160;</text><text class="terminal-r7" x="73.2" y="800.8" textLength="146.4" clip-path="url(#terminal-line-32)">/proxy-setup</text><text class="terminal-r1" x="219.6" y="800.8" textLength="561.2" clip-path="url(#terminal-line-32)">:&#160;Configure&#160;proxy&#160;and&#160;SSL&#160;certificate&#160;settings</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"> </text><text class="terminal-r4" x="24.4" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="48.8" y="800.8" textLength="24.4" clip-path="url(#terminal-line-32)">&#160;</text><text class="terminal-r7" x="73.2" y="800.8" textLength="109.8" clip-path="url(#terminal-line-32)">/continue</text><text class="terminal-r1" x="183" y="800.8" textLength="24.4" clip-path="url(#terminal-line-32)">,&#160;</text><text class="terminal-r7" x="207.4" y="800.8" textLength="85.4" clip-path="url(#terminal-line-32)">/resume</text><text class="terminal-r1" x="292.8" y="800.8" textLength="402.6" clip-path="url(#terminal-line-32)">:&#160;Browse&#160;and&#160;resume&#160;past&#160;sessions</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r5" x="24.4" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="48.8" y="825.2" textLength="24.4" clip-path="url(#terminal-line-33)">&#160;</text><text class="terminal-r7" x="73.2" y="825.2" textLength="109.8" clip-path="url(#terminal-line-33)">/continue</text><text class="terminal-r1" x="183" y="825.2" textLength="24.4" clip-path="url(#terminal-line-33)">,&#160;</text><text class="terminal-r7" x="207.4" y="825.2" textLength="85.4" clip-path="url(#terminal-line-33)">/resume</text><text class="terminal-r1" x="292.8" y="825.2" textLength="402.6" clip-path="url(#terminal-line-33)">:&#160;Browse&#160;and&#160;resume&#160;past&#160;sessions</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"> </text><text class="terminal-r4" x="24.4" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="48.8" y="825.2" textLength="24.4" clip-path="url(#terminal-line-33)">&#160;</text><text class="terminal-r7" x="73.2" y="825.2" textLength="85.4" clip-path="url(#terminal-line-33)">/rename</text><text class="terminal-r1" x="158.6" y="825.2" textLength="341.6" clip-path="url(#terminal-line-33)">:&#160;Rename&#160;the&#160;current&#160;session</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r5" x="24.4" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"></text><text class="terminal-r1" x="48.8" y="849.6" textLength="24.4" clip-path="url(#terminal-line-34)">&#160;</text><text class="terminal-r7" x="73.2" y="849.6" textLength="134.2" clip-path="url(#terminal-line-34)">/connectors</text><text class="terminal-r1" x="207.4" y="849.6" textLength="24.4" clip-path="url(#terminal-line-34)">,&#160;</text><text class="terminal-r7" x="231.8" y="849.6" textLength="48.8" clip-path="url(#terminal-line-34)">/mcp</text><text class="terminal-r1" x="280.6" y="849.6" textLength="939.4" clip-path="url(#terminal-line-34)">:&#160;Display&#160;available&#160;MCP&#160;servers&#160;and&#160;connectors.&#160;Pass&#160;a&#160;name&#160;to&#160;list&#160;its&#160;tools</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"> </text><text class="terminal-r4" x="24.4" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"></text><text class="terminal-r1" x="48.8" y="849.6" textLength="24.4" clip-path="url(#terminal-line-34)">&#160;</text><text class="terminal-r7" x="73.2" y="849.6" textLength="134.2" clip-path="url(#terminal-line-34)">/connectors</text><text class="terminal-r1" x="207.4" y="849.6" textLength="24.4" clip-path="url(#terminal-line-34)">,&#160;</text><text class="terminal-r7" x="231.8" y="849.6" textLength="48.8" clip-path="url(#terminal-line-34)">/mcp</text><text class="terminal-r1" x="280.6" y="849.6" textLength="939.4" clip-path="url(#terminal-line-34)">:&#160;Display&#160;available&#160;MCP&#160;servers&#160;and&#160;connectors.&#160;Pass&#160;a&#160;name&#160;to&#160;list&#160;its&#160;tools</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r5" x="24.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"></text><text class="terminal-r1" x="48.8" y="874" textLength="24.4" clip-path="url(#terminal-line-35)">&#160;</text><text class="terminal-r7" x="73.2" y="874" textLength="73.2" clip-path="url(#terminal-line-35)">/voice</text><text class="terminal-r1" x="146.4" y="874" textLength="317.2" clip-path="url(#terminal-line-35)">:&#160;Configure&#160;voice&#160;settings</text><text class="terminal-r1" x="1464" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"> </text><text class="terminal-r4" x="24.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"></text><text class="terminal-r1" x="48.8" y="874" textLength="24.4" clip-path="url(#terminal-line-35)">&#160;</text><text class="terminal-r7" x="73.2" y="874" textLength="73.2" clip-path="url(#terminal-line-35)">/voice</text><text class="terminal-r1" x="146.4" y="874" textLength="317.2" clip-path="url(#terminal-line-35)">:&#160;Configure&#160;voice&#160;settings</text><text class="terminal-r1" x="1464" y="874" textLength="12.2" clip-path="url(#terminal-line-35)">
</text><text class="terminal-r5" x="24.4" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)"></text><text class="terminal-r1" x="48.8" y="898.4" textLength="24.4" clip-path="url(#terminal-line-36)">&#160;</text><text class="terminal-r7" x="73.2" y="898.4" textLength="122" clip-path="url(#terminal-line-36)">/leanstall</text><text class="terminal-r1" x="195.2" y="898.4" textLength="463.6" clip-path="url(#terminal-line-36)">:&#160;Install&#160;the&#160;Lean&#160;4&#160;agent&#160;(leanstral)</text><text class="terminal-r1" x="1464" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)"> </text><text class="terminal-r4" x="24.4" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)"></text><text class="terminal-r1" x="48.8" y="898.4" textLength="24.4" clip-path="url(#terminal-line-36)">&#160;</text><text class="terminal-r7" x="73.2" y="898.4" textLength="122" clip-path="url(#terminal-line-36)">/leanstall</text><text class="terminal-r1" x="195.2" y="898.4" textLength="463.6" clip-path="url(#terminal-line-36)">:&#160;Install&#160;the&#160;Lean&#160;4&#160;agent&#160;(leanstral)</text><text class="terminal-r1" x="1464" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)">
</text><text class="terminal-r5" x="24.4" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)"></text><text class="terminal-r1" x="48.8" y="922.8" textLength="24.4" clip-path="url(#terminal-line-37)">&#160;</text><text class="terminal-r7" x="73.2" y="922.8" textLength="146.4" clip-path="url(#terminal-line-37)">/unleanstall</text><text class="terminal-r1" x="219.6" y="922.8" textLength="341.6" clip-path="url(#terminal-line-37)">:&#160;Uninstall&#160;the&#160;Lean&#160;4&#160;agent</text><text class="terminal-r1" x="1464" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)"> </text><text class="terminal-r4" x="24.4" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)"></text><text class="terminal-r1" x="48.8" y="922.8" textLength="24.4" clip-path="url(#terminal-line-37)">&#160;</text><text class="terminal-r7" x="73.2" y="922.8" textLength="146.4" clip-path="url(#terminal-line-37)">/unleanstall</text><text class="terminal-r1" x="219.6" y="922.8" textLength="341.6" clip-path="url(#terminal-line-37)">:&#160;Uninstall&#160;the&#160;Lean&#160;4&#160;agent</text><text class="terminal-r1" x="1464" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)">
</text><text class="terminal-r5" x="24.4" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)"></text><text class="terminal-r1" x="48.8" y="947.2" textLength="24.4" clip-path="url(#terminal-line-38)">&#160;</text><text class="terminal-r7" x="73.2" y="947.2" textLength="85.4" clip-path="url(#terminal-line-38)">/rewind</text><text class="terminal-r1" x="158.6" y="947.2" textLength="366" clip-path="url(#terminal-line-38)">:&#160;Rewind&#160;to&#160;a&#160;previous&#160;message</text><text class="terminal-r1" x="1464" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)"> </text><text class="terminal-r4" x="24.4" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)"></text><text class="terminal-r1" x="48.8" y="947.2" textLength="24.4" clip-path="url(#terminal-line-38)">&#160;</text><text class="terminal-r7" x="73.2" y="947.2" textLength="85.4" clip-path="url(#terminal-line-38)">/rewind</text><text class="terminal-r1" x="158.6" y="947.2" textLength="366" clip-path="url(#terminal-line-38)">:&#160;Rewind&#160;to&#160;a&#160;previous&#160;message</text><text class="terminal-r1" x="1464" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)">
</text><text class="terminal-r5" x="24.4" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)"></text><text class="terminal-r1" x="48.8" y="971.6" textLength="24.4" clip-path="url(#terminal-line-39)">&#160;</text><text class="terminal-r7" x="73.2" y="971.6" textLength="183" clip-path="url(#terminal-line-39)">/data-retention</text><text class="terminal-r1" x="256.2" y="971.6" textLength="402.6" clip-path="url(#terminal-line-39)">:&#160;Show&#160;data&#160;retention&#160;information</text><text class="terminal-r1" x="1464" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)"> </text><text class="terminal-r4" x="24.4" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)"></text><text class="terminal-r1" x="48.8" y="971.6" textLength="24.4" clip-path="url(#terminal-line-39)">&#160;</text><text class="terminal-r7" x="73.2" y="971.6" textLength="183" clip-path="url(#terminal-line-39)">/data-retention</text><text class="terminal-r1" x="256.2" y="971.6" textLength="402.6" clip-path="url(#terminal-line-39)">:&#160;Show&#160;data&#160;retention&#160;information</text><text class="terminal-r1" x="1464" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)">
</text><text class="terminal-r1" x="1464" y="996" textLength="12.2" clip-path="url(#terminal-line-40)"> </text><text class="terminal-r1" x="1464" y="996" textLength="12.2" clip-path="url(#terminal-line-40)">
</text><text class="terminal-r1" x="1464" y="1020.4" textLength="12.2" clip-path="url(#terminal-line-41)"> </text><text class="terminal-r1" x="1464" y="1020.4" textLength="12.2" clip-path="url(#terminal-line-41)">
</text><text class="terminal-r5" x="0" y="1044.8" textLength="1464" clip-path="url(#terminal-line-42)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="1044.8" textLength="12.2" clip-path="url(#terminal-line-42)"> </text><text class="terminal-r4" x="0" y="1044.8" textLength="1464" clip-path="url(#terminal-line-42)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="1044.8" textLength="12.2" clip-path="url(#terminal-line-42)">
</text><text class="terminal-r5" x="0" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)"></text><text class="terminal-r8" x="24.4" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)">&gt;</text><text class="terminal-r5" x="1451.8" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)"></text><text class="terminal-r1" x="1464" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)"> </text><text class="terminal-r4" x="0" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)"></text><text class="terminal-r8" x="24.4" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)">&gt;</text><text class="terminal-r4" x="1451.8" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)"></text><text class="terminal-r1" x="1464" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)">
</text><text class="terminal-r5" x="0" y="1093.6" textLength="12.2" clip-path="url(#terminal-line-44)"></text><text class="terminal-r5" x="1451.8" y="1093.6" textLength="12.2" clip-path="url(#terminal-line-44)"></text><text class="terminal-r1" x="1464" y="1093.6" textLength="12.2" clip-path="url(#terminal-line-44)"> </text><text class="terminal-r4" x="0" y="1093.6" textLength="12.2" clip-path="url(#terminal-line-44)"></text><text class="terminal-r4" x="1451.8" y="1093.6" textLength="12.2" clip-path="url(#terminal-line-44)"></text><text class="terminal-r1" x="1464" y="1093.6" textLength="12.2" clip-path="url(#terminal-line-44)">
</text><text class="terminal-r5" x="0" y="1118" textLength="12.2" clip-path="url(#terminal-line-45)"></text><text class="terminal-r5" x="1451.8" y="1118" textLength="12.2" clip-path="url(#terminal-line-45)"></text><text class="terminal-r1" x="1464" y="1118" textLength="12.2" clip-path="url(#terminal-line-45)"> </text><text class="terminal-r4" x="0" y="1118" textLength="12.2" clip-path="url(#terminal-line-45)"></text><text class="terminal-r4" x="1451.8" y="1118" textLength="12.2" clip-path="url(#terminal-line-45)"></text><text class="terminal-r1" x="1464" y="1118" textLength="12.2" clip-path="url(#terminal-line-45)">
</text><text class="terminal-r5" x="0" y="1142.4" textLength="1464" clip-path="url(#terminal-line-46)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="1142.4" textLength="12.2" clip-path="url(#terminal-line-46)"> </text><text class="terminal-r4" x="0" y="1142.4" textLength="1464" clip-path="url(#terminal-line-46)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="1142.4" textLength="12.2" clip-path="url(#terminal-line-46)">
</text><text class="terminal-r5" x="0" y="1166.8" textLength="158.6" clip-path="url(#terminal-line-47)">/test/workdir</text><text class="terminal-r5" x="1256.6" y="1166.8" textLength="207.4" clip-path="url(#terminal-line-47)">0%&#160;of&#160;200k&#160;tokens</text> </text><text class="terminal-r4" x="0" y="1166.8" textLength="158.6" clip-path="url(#terminal-line-47)">/test/workdir</text><text class="terminal-r4" x="1256.6" y="1166.8" textLength="207.4" clip-path="url(#terminal-line-47)">0%&#160;of&#160;200k&#160;tokens</text>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Before After
Before After

View file

@ -33,13 +33,12 @@
} }
.terminal-r1 { fill: #c5c8c6 } .terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #68a0b3 } .terminal-r2 { fill: #9a9b99 }
.terminal-r3 { fill: #4b4e55 } .terminal-r3 { fill: #608ab1;font-weight: bold }
.terminal-r4 { fill: #9a9b99 } .terminal-r4 { fill: #98a84b;font-weight: bold }
.terminal-r5 { fill: #608ab1;font-weight: bold } .terminal-r5 { fill: #4b4e55 }
.terminal-r6 { fill: #292929 } .terminal-r6 { fill: #292929 }
.terminal-r7 { fill: #98a84b;font-weight: bold } .terminal-r7 { fill: #ff8205;font-weight: bold }
.terminal-r8 { fill: #ff8205;font-weight: bold }
</style> </style>
<defs> <defs>
@ -197,56 +196,56 @@
</g> </g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)"> <g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#4b4e55" x="1451.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="489.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="513.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="538.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="733.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="757.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="782.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="806.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="831.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="855.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="879.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="904.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="928.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="953.1" width="12.2" height="24.65" shape-rendering="crispEdges"/> <rect fill="#4b4e55" x="1451.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="489.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="513.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="538.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="733.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="757.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="782.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="806.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="831.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="855.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="879.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="904.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="928.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="953.1" width="12.2" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix"> <g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="20" textLength="61" clip-path="url(#terminal-line-0)">Type&#160;</text><text class="terminal-r2" x="231.8" y="20" textLength="61" clip-path="url(#terminal-line-0)">/help</text><text class="terminal-r1" x="292.8" y="20" textLength="256.2" clip-path="url(#terminal-line-0)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> <text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r3" x="1451.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"> </text><text class="terminal-r2" x="24.4" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r3" x="48.8" y="44.4" textLength="219.6" clip-path="url(#terminal-line-1)">Keyboard&#160;Shortcuts</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r4" x="24.4" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r5" x="48.8" y="68.8" textLength="219.6" clip-path="url(#terminal-line-2)">Keyboard&#160;Shortcuts</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"> </text><text class="terminal-r2" x="24.4" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r1" x="48.8" y="68.8" textLength="24.4" clip-path="url(#terminal-line-2)">&#160;</text><text class="terminal-r4" x="73.2" y="68.8" textLength="61" clip-path="url(#terminal-line-2)">Enter</text><text class="terminal-r1" x="134.2" y="68.8" textLength="183" clip-path="url(#terminal-line-2)">&#160;Submit&#160;message</text><text class="terminal-r5" x="1451.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r4" x="24.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r1" x="48.8" y="93.2" textLength="24.4" clip-path="url(#terminal-line-3)">&#160;</text><text class="terminal-r7" x="73.2" y="93.2" textLength="61" clip-path="url(#terminal-line-3)">Enter</text><text class="terminal-r1" x="134.2" y="93.2" textLength="183" clip-path="url(#terminal-line-3)">&#160;Submit&#160;message</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"> </text><text class="terminal-r2" x="24.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r1" x="48.8" y="93.2" textLength="24.4" clip-path="url(#terminal-line-3)">&#160;</text><text class="terminal-r4" x="73.2" y="93.2" textLength="73.2" clip-path="url(#terminal-line-3)">Ctrl+J</text><text class="terminal-r1" x="146.4" y="93.2" textLength="36.6" clip-path="url(#terminal-line-3)">&#160;/&#160;</text><text class="terminal-r4" x="183" y="93.2" textLength="134.2" clip-path="url(#terminal-line-3)">Shift+Enter</text><text class="terminal-r1" x="317.2" y="93.2" textLength="183" clip-path="url(#terminal-line-3)">&#160;Insert&#160;newline</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r4" x="24.4" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r1" x="48.8" y="117.6" textLength="24.4" clip-path="url(#terminal-line-4)">&#160;</text><text class="terminal-r7" x="73.2" y="117.6" textLength="73.2" clip-path="url(#terminal-line-4)">Ctrl+J</text><text class="terminal-r1" x="146.4" y="117.6" textLength="36.6" clip-path="url(#terminal-line-4)">&#160;/&#160;</text><text class="terminal-r7" x="183" y="117.6" textLength="134.2" clip-path="url(#terminal-line-4)">Shift+Enter</text><text class="terminal-r1" x="317.2" y="117.6" textLength="183" clip-path="url(#terminal-line-4)">&#160;Insert&#160;newline</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"> </text><text class="terminal-r2" x="24.4" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r1" x="48.8" y="117.6" textLength="24.4" clip-path="url(#terminal-line-4)">&#160;</text><text class="terminal-r4" x="73.2" y="117.6" textLength="73.2" clip-path="url(#terminal-line-4)">Escape</text><text class="terminal-r1" x="146.4" y="117.6" textLength="402.6" clip-path="url(#terminal-line-4)">&#160;Interrupt&#160;agent&#160;or&#160;close&#160;dialogs</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r4" x="24.4" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r1" x="48.8" y="142" textLength="24.4" clip-path="url(#terminal-line-5)">&#160;</text><text class="terminal-r7" x="73.2" y="142" textLength="73.2" clip-path="url(#terminal-line-5)">Escape</text><text class="terminal-r1" x="146.4" y="142" textLength="402.6" clip-path="url(#terminal-line-5)">&#160;Interrupt&#160;agent&#160;or&#160;close&#160;dialogs</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"> </text><text class="terminal-r2" x="24.4" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r1" x="48.8" y="142" textLength="24.4" clip-path="url(#terminal-line-5)">&#160;</text><text class="terminal-r4" x="73.2" y="142" textLength="73.2" clip-path="url(#terminal-line-5)">Ctrl+C</text><text class="terminal-r1" x="146.4" y="142" textLength="463.6" clip-path="url(#terminal-line-5)">&#160;Quit&#160;(or&#160;clear&#160;input&#160;if&#160;text&#160;present)</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r4" x="24.4" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r1" x="48.8" y="166.4" textLength="24.4" clip-path="url(#terminal-line-6)">&#160;</text><text class="terminal-r7" x="73.2" y="166.4" textLength="73.2" clip-path="url(#terminal-line-6)">Ctrl+C</text><text class="terminal-r1" x="146.4" y="166.4" textLength="463.6" clip-path="url(#terminal-line-6)">&#160;Quit&#160;(or&#160;clear&#160;input&#160;if&#160;text&#160;present)</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"> </text><text class="terminal-r2" x="24.4" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r1" x="48.8" y="166.4" textLength="24.4" clip-path="url(#terminal-line-6)">&#160;</text><text class="terminal-r4" x="73.2" y="166.4" textLength="73.2" clip-path="url(#terminal-line-6)">Ctrl+G</text><text class="terminal-r1" x="146.4" y="166.4" textLength="366" clip-path="url(#terminal-line-6)">&#160;Edit&#160;input&#160;in&#160;external&#160;editor</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r4" x="24.4" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="48.8" y="190.8" textLength="24.4" clip-path="url(#terminal-line-7)">&#160;</text><text class="terminal-r7" x="73.2" y="190.8" textLength="73.2" clip-path="url(#terminal-line-7)">Ctrl+G</text><text class="terminal-r1" x="146.4" y="190.8" textLength="366" clip-path="url(#terminal-line-7)">&#160;Edit&#160;input&#160;in&#160;external&#160;editor</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"> </text><text class="terminal-r2" x="24.4" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="48.8" y="190.8" textLength="24.4" clip-path="url(#terminal-line-7)">&#160;</text><text class="terminal-r4" x="73.2" y="190.8" textLength="73.2" clip-path="url(#terminal-line-7)">Ctrl+O</text><text class="terminal-r1" x="146.4" y="190.8" textLength="292.8" clip-path="url(#terminal-line-7)">&#160;Toggle&#160;tool&#160;output&#160;view</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r4" x="24.4" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="48.8" y="215.2" textLength="24.4" clip-path="url(#terminal-line-8)">&#160;</text><text class="terminal-r7" x="73.2" y="215.2" textLength="73.2" clip-path="url(#terminal-line-8)">Ctrl+O</text><text class="terminal-r1" x="146.4" y="215.2" textLength="292.8" clip-path="url(#terminal-line-8)">&#160;Toggle&#160;tool&#160;output&#160;view</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"> </text><text class="terminal-r2" x="24.4" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="48.8" y="215.2" textLength="24.4" clip-path="url(#terminal-line-8)">&#160;</text><text class="terminal-r4" x="73.2" y="215.2" textLength="109.8" clip-path="url(#terminal-line-8)">Shift+Tab</text><text class="terminal-r1" x="183" y="215.2" textLength="512.4" clip-path="url(#terminal-line-8)">&#160;Cycle&#160;through&#160;agents&#160;(default,&#160;plan,&#160;...)</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r4" x="24.4" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="48.8" y="239.6" textLength="24.4" clip-path="url(#terminal-line-9)">&#160;</text><text class="terminal-r7" x="73.2" y="239.6" textLength="109.8" clip-path="url(#terminal-line-9)">Shift+Tab</text><text class="terminal-r1" x="183" y="239.6" textLength="305" clip-path="url(#terminal-line-9)">&#160;Toggle&#160;auto-approve&#160;mode</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"> </text><text class="terminal-r2" x="24.4" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="48.8" y="239.6" textLength="24.4" clip-path="url(#terminal-line-9)">&#160;</text><text class="terminal-r4" x="73.2" y="239.6" textLength="73.2" clip-path="url(#terminal-line-9)">Alt+↑↓</text><text class="terminal-r1" x="146.4" y="239.6" textLength="36.6" clip-path="url(#terminal-line-9)">&#160;/&#160;</text><text class="terminal-r4" x="183" y="239.6" textLength="97.6" clip-path="url(#terminal-line-9)">Ctrl+P/N</text><text class="terminal-r1" x="280.6" y="239.6" textLength="390.4" clip-path="url(#terminal-line-9)">&#160;Rewind&#160;to&#160;previous/next&#160;message</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r4" x="24.4" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"></text><text class="terminal-r1" x="48.8" y="264" textLength="24.4" clip-path="url(#terminal-line-10)">&#160;</text><text class="terminal-r7" x="73.2" y="264" textLength="73.2" clip-path="url(#terminal-line-10)">Alt+↑↓</text><text class="terminal-r1" x="146.4" y="264" textLength="36.6" clip-path="url(#terminal-line-10)">&#160;/&#160;</text><text class="terminal-r7" x="183" y="264" textLength="97.6" clip-path="url(#terminal-line-10)">Ctrl+P/N</text><text class="terminal-r1" x="280.6" y="264" textLength="390.4" clip-path="url(#terminal-line-10)">&#160;Rewind&#160;to&#160;previous/next&#160;message</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"> </text><text class="terminal-r2" x="24.4" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"></text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r4" x="24.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"> </text><text class="terminal-r2" x="24.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"></text><text class="terminal-r3" x="48.8" y="288.4" textLength="195.2" clip-path="url(#terminal-line-11)">Special&#160;Features</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r4" x="24.4" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></text><text class="terminal-r5" x="48.8" y="312.8" textLength="195.2" clip-path="url(#terminal-line-12)">Special&#160;Features</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"> </text><text class="terminal-r2" x="24.4" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r4" x="24.4" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"> </text><text class="terminal-r2" x="24.4" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"></text><text class="terminal-r1" x="48.8" y="337.2" textLength="24.4" clip-path="url(#terminal-line-13)">&#160;</text><text class="terminal-r4" x="73.2" y="337.2" textLength="122" clip-path="url(#terminal-line-13)">!&lt;command&gt;</text><text class="terminal-r1" x="195.2" y="337.2" textLength="366" clip-path="url(#terminal-line-13)">&#160;Execute&#160;bash&#160;command&#160;directly</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r4" x="24.4" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r1" x="48.8" y="361.6" textLength="24.4" clip-path="url(#terminal-line-14)">&#160;</text><text class="terminal-r7" x="73.2" y="361.6" textLength="122" clip-path="url(#terminal-line-14)">!&lt;command&gt;</text><text class="terminal-r1" x="195.2" y="361.6" textLength="366" clip-path="url(#terminal-line-14)">&#160;Execute&#160;bash&#160;command&#160;directly</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"> </text><text class="terminal-r2" x="24.4" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"></text><text class="terminal-r1" x="48.8" y="361.6" textLength="24.4" clip-path="url(#terminal-line-14)">&#160;</text><text class="terminal-r4" x="73.2" y="361.6" textLength="170.8" clip-path="url(#terminal-line-14)">@path/to/file/</text><text class="terminal-r1" x="244" y="361.6" textLength="305" clip-path="url(#terminal-line-14)">&#160;Autocompletes&#160;file&#160;paths</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r4" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="48.8" y="386" textLength="24.4" clip-path="url(#terminal-line-15)">&#160;</text><text class="terminal-r7" x="73.2" y="386" textLength="170.8" clip-path="url(#terminal-line-15)">@path/to/file/</text><text class="terminal-r1" x="244" y="386" textLength="305" clip-path="url(#terminal-line-15)">&#160;Autocompletes&#160;file&#160;paths</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"> </text><text class="terminal-r2" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r4" x="24.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"> </text><text class="terminal-r2" x="24.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"></text><text class="terminal-r3" x="48.8" y="410.4" textLength="97.6" clip-path="url(#terminal-line-16)">Commands</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r4" x="24.4" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r5" x="48.8" y="434.8" textLength="97.6" clip-path="url(#terminal-line-17)">Commands</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"> </text><text class="terminal-r2" x="24.4" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r4" x="24.4" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"> </text><text class="terminal-r2" x="24.4" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"></text><text class="terminal-r1" x="48.8" y="459.2" textLength="24.4" clip-path="url(#terminal-line-18)">&#160;</text><text class="terminal-r4" x="73.2" y="459.2" textLength="61" clip-path="url(#terminal-line-18)">/help</text><text class="terminal-r1" x="134.2" y="459.2" textLength="231.8" clip-path="url(#terminal-line-18)">:&#160;Show&#160;help&#160;message</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r4" x="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="48.8" y="483.6" textLength="24.4" clip-path="url(#terminal-line-19)">&#160;</text><text class="terminal-r7" x="73.2" y="483.6" textLength="61" clip-path="url(#terminal-line-19)">/help</text><text class="terminal-r1" x="134.2" y="483.6" textLength="231.8" clip-path="url(#terminal-line-19)">:&#160;Show&#160;help&#160;message</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"> </text><text class="terminal-r2" x="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"></text><text class="terminal-r1" x="48.8" y="483.6" textLength="24.4" clip-path="url(#terminal-line-19)">&#160;</text><text class="terminal-r4" x="73.2" y="483.6" textLength="85.4" clip-path="url(#terminal-line-19)">/config</text><text class="terminal-r1" x="158.6" y="483.6" textLength="268.4" clip-path="url(#terminal-line-19)">:&#160;Edit&#160;config&#160;settings</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r4" x="24.4" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="48.8" y="508" textLength="24.4" clip-path="url(#terminal-line-20)">&#160;</text><text class="terminal-r7" x="73.2" y="508" textLength="85.4" clip-path="url(#terminal-line-20)">/config</text><text class="terminal-r1" x="158.6" y="508" textLength="268.4" clip-path="url(#terminal-line-20)">:&#160;Edit&#160;config&#160;settings</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"> </text><text class="terminal-r2" x="24.4" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"></text><text class="terminal-r1" x="48.8" y="508" textLength="24.4" clip-path="url(#terminal-line-20)">&#160;</text><text class="terminal-r4" x="73.2" y="508" textLength="73.2" clip-path="url(#terminal-line-20)">/model</text><text class="terminal-r1" x="146.4" y="508" textLength="256.2" clip-path="url(#terminal-line-20)">:&#160;Select&#160;active&#160;model</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r4" x="24.4" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="48.8" y="532.4" textLength="24.4" clip-path="url(#terminal-line-21)">&#160;</text><text class="terminal-r7" x="73.2" y="532.4" textLength="73.2" clip-path="url(#terminal-line-21)">/model</text><text class="terminal-r1" x="146.4" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)">:&#160;Select&#160;active&#160;model</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"> </text><text class="terminal-r2" x="24.4" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"></text><text class="terminal-r1" x="48.8" y="532.4" textLength="24.4" clip-path="url(#terminal-line-21)">&#160;</text><text class="terminal-r4" x="73.2" y="532.4" textLength="109.8" clip-path="url(#terminal-line-21)">/thinking</text><text class="terminal-r1" x="183" y="532.4" textLength="280.6" clip-path="url(#terminal-line-21)">:&#160;Select&#160;thinking&#160;level</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r4" x="24.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="48.8" y="556.8" textLength="24.4" clip-path="url(#terminal-line-22)">&#160;</text><text class="terminal-r7" x="73.2" y="556.8" textLength="109.8" clip-path="url(#terminal-line-22)">/thinking</text><text class="terminal-r1" x="183" y="556.8" textLength="280.6" clip-path="url(#terminal-line-22)">:&#160;Select&#160;thinking&#160;level</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"> </text><text class="terminal-r2" x="24.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="48.8" y="556.8" textLength="24.4" clip-path="url(#terminal-line-22)">&#160;</text><text class="terminal-r4" x="73.2" y="556.8" textLength="85.4" clip-path="url(#terminal-line-22)">/reload</text><text class="terminal-r1" x="158.6" y="556.8" textLength="780.8" clip-path="url(#terminal-line-22)">:&#160;Reload&#160;configuration,&#160;agent&#160;instructions,&#160;and&#160;skills&#160;from&#160;disk</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r4" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="48.8" y="581.2" textLength="24.4" clip-path="url(#terminal-line-23)">&#160;</text><text class="terminal-r7" x="73.2" y="581.2" textLength="85.4" clip-path="url(#terminal-line-23)">/reload</text><text class="terminal-r1" x="158.6" y="581.2" textLength="780.8" clip-path="url(#terminal-line-23)">:&#160;Reload&#160;configuration,&#160;agent&#160;instructions,&#160;and&#160;skills&#160;from&#160;disk</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> </text><text class="terminal-r2" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="48.8" y="581.2" textLength="24.4" clip-path="url(#terminal-line-23)">&#160;</text><text class="terminal-r4" x="73.2" y="581.2" textLength="73.2" clip-path="url(#terminal-line-23)">/clear</text><text class="terminal-r1" x="146.4" y="581.2" textLength="341.6" clip-path="url(#terminal-line-23)">:&#160;Clear&#160;conversation&#160;history</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r4" x="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="48.8" y="605.6" textLength="24.4" clip-path="url(#terminal-line-24)">&#160;</text><text class="terminal-r7" x="73.2" y="605.6" textLength="73.2" clip-path="url(#terminal-line-24)">/clear</text><text class="terminal-r1" x="146.4" y="605.6" textLength="341.6" clip-path="url(#terminal-line-24)">:&#160;Clear&#160;conversation&#160;history</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> </text><text class="terminal-r2" x="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="48.8" y="605.6" textLength="24.4" clip-path="url(#terminal-line-24)">&#160;</text><text class="terminal-r4" x="73.2" y="605.6" textLength="61" clip-path="url(#terminal-line-24)">/copy</text><text class="terminal-r1" x="134.2" y="605.6" textLength="561.2" clip-path="url(#terminal-line-24)">:&#160;Copy&#160;the&#160;last&#160;agent&#160;message&#160;to&#160;the&#160;clipboard</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="24.4" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="48.8" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">&#160;</text><text class="terminal-r7" x="73.2" y="630" textLength="61" clip-path="url(#terminal-line-25)">/copy</text><text class="terminal-r1" x="134.2" y="630" textLength="561.2" clip-path="url(#terminal-line-25)">:&#160;Copy&#160;the&#160;last&#160;agent&#160;message&#160;to&#160;the&#160;clipboard</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> </text><text class="terminal-r2" x="24.4" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="48.8" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">&#160;</text><text class="terminal-r4" x="73.2" y="630" textLength="48.8" clip-path="url(#terminal-line-25)">/log</text><text class="terminal-r1" x="122" y="630" textLength="524.6" clip-path="url(#terminal-line-25)">:&#160;Show&#160;path&#160;to&#160;current&#160;interaction&#160;log&#160;file</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="48.8" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">&#160;</text><text class="terminal-r7" x="73.2" y="654.4" textLength="48.8" clip-path="url(#terminal-line-26)">/log</text><text class="terminal-r1" x="122" y="654.4" textLength="524.6" clip-path="url(#terminal-line-26)">:&#160;Show&#160;path&#160;to&#160;current&#160;interaction&#160;log&#160;file</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> </text><text class="terminal-r2" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="48.8" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">&#160;</text><text class="terminal-r4" x="73.2" y="654.4" textLength="73.2" clip-path="url(#terminal-line-26)">/debug</text><text class="terminal-r1" x="146.4" y="654.4" textLength="268.4" clip-path="url(#terminal-line-26)">:&#160;Toggle&#160;debug&#160;console</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="48.8" y="678.8" textLength="24.4" clip-path="url(#terminal-line-27)">&#160;</text><text class="terminal-r7" x="73.2" y="678.8" textLength="73.2" clip-path="url(#terminal-line-27)">/debug</text><text class="terminal-r1" x="146.4" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">:&#160;Toggle&#160;debug&#160;console</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> </text><text class="terminal-r2" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="48.8" y="678.8" textLength="24.4" clip-path="url(#terminal-line-27)">&#160;</text><text class="terminal-r4" x="73.2" y="678.8" textLength="97.6" clip-path="url(#terminal-line-27)">/compact</text><text class="terminal-r1" x="170.8" y="678.8" textLength="1171.2" clip-path="url(#terminal-line-27)">:&#160;Compact&#160;conversation&#160;history&#160;by&#160;summarizing.&#160;Optionally&#160;pass&#160;instructions&#160;to&#160;guide&#160;the&#160;summary</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r4" x="24.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="48.8" y="703.2" textLength="24.4" clip-path="url(#terminal-line-28)">&#160;</text><text class="terminal-r7" x="73.2" y="703.2" textLength="97.6" clip-path="url(#terminal-line-28)">/compact</text><text class="terminal-r1" x="170.8" y="703.2" textLength="1171.2" clip-path="url(#terminal-line-28)">:&#160;Compact&#160;conversation&#160;history&#160;by&#160;summarizing.&#160;Optionally&#160;pass&#160;instructions&#160;to&#160;guide&#160;the&#160;summary</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> </text><text class="terminal-r2" x="24.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="48.8" y="703.2" textLength="24.4" clip-path="url(#terminal-line-28)">&#160;</text><text class="terminal-r4" x="73.2" y="703.2" textLength="61" clip-path="url(#terminal-line-28)">/exit</text><text class="terminal-r1" x="134.2" y="703.2" textLength="268.4" clip-path="url(#terminal-line-28)">:&#160;Exit&#160;the&#160;application</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r4" x="24.4" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="48.8" y="727.6" textLength="24.4" clip-path="url(#terminal-line-29)">&#160;</text><text class="terminal-r7" x="73.2" y="727.6" textLength="61" clip-path="url(#terminal-line-29)">/exit</text><text class="terminal-r1" x="134.2" y="727.6" textLength="268.4" clip-path="url(#terminal-line-29)">:&#160;Exit&#160;the&#160;application</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"> </text><text class="terminal-r2" x="24.4" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="48.8" y="727.6" textLength="24.4" clip-path="url(#terminal-line-29)">&#160;</text><text class="terminal-r4" x="73.2" y="727.6" textLength="85.4" clip-path="url(#terminal-line-29)">/status</text><text class="terminal-r1" x="158.6" y="727.6" textLength="317.2" clip-path="url(#terminal-line-29)">:&#160;Display&#160;agent&#160;statistics</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r4" x="24.4" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="48.8" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">&#160;</text><text class="terminal-r7" x="73.2" y="752" textLength="85.4" clip-path="url(#terminal-line-30)">/status</text><text class="terminal-r1" x="158.6" y="752" textLength="317.2" clip-path="url(#terminal-line-30)">:&#160;Display&#160;agent&#160;statistics</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"> </text><text class="terminal-r2" x="24.4" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"></text><text class="terminal-r1" x="48.8" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">&#160;</text><text class="terminal-r4" x="73.2" y="752" textLength="109.8" clip-path="url(#terminal-line-30)">/teleport</text><text class="terminal-r1" x="183" y="752" textLength="378.2" clip-path="url(#terminal-line-30)">:&#160;Teleport&#160;session&#160;to&#160;Vibe&#160;Code</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r4" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="48.8" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">&#160;</text><text class="terminal-r7" x="73.2" y="776.4" textLength="109.8" clip-path="url(#terminal-line-31)">/teleport</text><text class="terminal-r1" x="183" y="776.4" textLength="378.2" clip-path="url(#terminal-line-31)">:&#160;Teleport&#160;session&#160;to&#160;Vibe&#160;Code</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"> </text><text class="terminal-r2" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="48.8" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">&#160;</text><text class="terminal-r4" x="73.2" y="776.4" textLength="146.4" clip-path="url(#terminal-line-31)">/proxy-setup</text><text class="terminal-r1" x="219.6" y="776.4" textLength="561.2" clip-path="url(#terminal-line-31)">:&#160;Configure&#160;proxy&#160;and&#160;SSL&#160;certificate&#160;settings</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r4" x="24.4" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="48.8" y="800.8" textLength="24.4" clip-path="url(#terminal-line-32)">&#160;</text><text class="terminal-r7" x="73.2" y="800.8" textLength="146.4" clip-path="url(#terminal-line-32)">/proxy-setup</text><text class="terminal-r1" x="219.6" y="800.8" textLength="561.2" clip-path="url(#terminal-line-32)">:&#160;Configure&#160;proxy&#160;and&#160;SSL&#160;certificate&#160;settings</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"> </text><text class="terminal-r2" x="24.4" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="48.8" y="800.8" textLength="24.4" clip-path="url(#terminal-line-32)">&#160;</text><text class="terminal-r4" x="73.2" y="800.8" textLength="109.8" clip-path="url(#terminal-line-32)">/continue</text><text class="terminal-r1" x="183" y="800.8" textLength="24.4" clip-path="url(#terminal-line-32)">,&#160;</text><text class="terminal-r4" x="207.4" y="800.8" textLength="85.4" clip-path="url(#terminal-line-32)">/resume</text><text class="terminal-r1" x="292.8" y="800.8" textLength="402.6" clip-path="url(#terminal-line-32)">:&#160;Browse&#160;and&#160;resume&#160;past&#160;sessions</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r4" x="24.4" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="48.8" y="825.2" textLength="24.4" clip-path="url(#terminal-line-33)">&#160;</text><text class="terminal-r7" x="73.2" y="825.2" textLength="109.8" clip-path="url(#terminal-line-33)">/continue</text><text class="terminal-r1" x="183" y="825.2" textLength="24.4" clip-path="url(#terminal-line-33)">,&#160;</text><text class="terminal-r7" x="207.4" y="825.2" textLength="85.4" clip-path="url(#terminal-line-33)">/resume</text><text class="terminal-r1" x="292.8" y="825.2" textLength="402.6" clip-path="url(#terminal-line-33)">:&#160;Browse&#160;and&#160;resume&#160;past&#160;sessions</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"> </text><text class="terminal-r2" x="24.4" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"></text><text class="terminal-r1" x="48.8" y="825.2" textLength="24.4" clip-path="url(#terminal-line-33)">&#160;</text><text class="terminal-r4" x="73.2" y="825.2" textLength="85.4" clip-path="url(#terminal-line-33)">/rename</text><text class="terminal-r1" x="158.6" y="825.2" textLength="341.6" clip-path="url(#terminal-line-33)">:&#160;Rename&#160;the&#160;current&#160;session</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r4" x="24.4" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"></text><text class="terminal-r1" x="48.8" y="849.6" textLength="24.4" clip-path="url(#terminal-line-34)">&#160;</text><text class="terminal-r7" x="73.2" y="849.6" textLength="134.2" clip-path="url(#terminal-line-34)">/connectors</text><text class="terminal-r1" x="207.4" y="849.6" textLength="24.4" clip-path="url(#terminal-line-34)">,&#160;</text><text class="terminal-r7" x="231.8" y="849.6" textLength="48.8" clip-path="url(#terminal-line-34)">/mcp</text><text class="terminal-r1" x="280.6" y="849.6" textLength="939.4" clip-path="url(#terminal-line-34)">:&#160;Display&#160;available&#160;MCP&#160;servers&#160;and&#160;connectors.&#160;Pass&#160;a&#160;name&#160;to&#160;list&#160;its&#160;tools</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"> </text><text class="terminal-r2" x="24.4" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"></text><text class="terminal-r1" x="48.8" y="849.6" textLength="24.4" clip-path="url(#terminal-line-34)">&#160;</text><text class="terminal-r4" x="73.2" y="849.6" textLength="134.2" clip-path="url(#terminal-line-34)">/connectors</text><text class="terminal-r1" x="207.4" y="849.6" textLength="24.4" clip-path="url(#terminal-line-34)">,&#160;</text><text class="terminal-r4" x="231.8" y="849.6" textLength="48.8" clip-path="url(#terminal-line-34)">/mcp</text><text class="terminal-r1" x="280.6" y="849.6" textLength="939.4" clip-path="url(#terminal-line-34)">:&#160;Display&#160;available&#160;MCP&#160;servers&#160;and&#160;connectors.&#160;Pass&#160;a&#160;name&#160;to&#160;list&#160;its&#160;tools</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r4" x="24.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"></text><text class="terminal-r1" x="48.8" y="874" textLength="24.4" clip-path="url(#terminal-line-35)">&#160;</text><text class="terminal-r7" x="73.2" y="874" textLength="73.2" clip-path="url(#terminal-line-35)">/voice</text><text class="terminal-r1" x="146.4" y="874" textLength="317.2" clip-path="url(#terminal-line-35)">:&#160;Configure&#160;voice&#160;settings</text><text class="terminal-r1" x="1464" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"> </text><text class="terminal-r2" x="24.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"></text><text class="terminal-r1" x="48.8" y="874" textLength="24.4" clip-path="url(#terminal-line-35)">&#160;</text><text class="terminal-r4" x="73.2" y="874" textLength="73.2" clip-path="url(#terminal-line-35)">/voice</text><text class="terminal-r1" x="146.4" y="874" textLength="317.2" clip-path="url(#terminal-line-35)">:&#160;Configure&#160;voice&#160;settings</text><text class="terminal-r1" x="1464" y="874" textLength="12.2" clip-path="url(#terminal-line-35)">
</text><text class="terminal-r4" x="24.4" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)"></text><text class="terminal-r1" x="48.8" y="898.4" textLength="24.4" clip-path="url(#terminal-line-36)">&#160;</text><text class="terminal-r7" x="73.2" y="898.4" textLength="122" clip-path="url(#terminal-line-36)">/leanstall</text><text class="terminal-r1" x="195.2" y="898.4" textLength="463.6" clip-path="url(#terminal-line-36)">:&#160;Install&#160;the&#160;Lean&#160;4&#160;agent&#160;(leanstral)</text><text class="terminal-r1" x="1464" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)"> </text><text class="terminal-r2" x="24.4" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)"></text><text class="terminal-r1" x="48.8" y="898.4" textLength="24.4" clip-path="url(#terminal-line-36)">&#160;</text><text class="terminal-r4" x="73.2" y="898.4" textLength="122" clip-path="url(#terminal-line-36)">/leanstall</text><text class="terminal-r1" x="195.2" y="898.4" textLength="463.6" clip-path="url(#terminal-line-36)">:&#160;Install&#160;the&#160;Lean&#160;4&#160;agent&#160;(leanstral)</text><text class="terminal-r1" x="1464" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)">
</text><text class="terminal-r4" x="24.4" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)"></text><text class="terminal-r1" x="48.8" y="922.8" textLength="24.4" clip-path="url(#terminal-line-37)">&#160;</text><text class="terminal-r7" x="73.2" y="922.8" textLength="146.4" clip-path="url(#terminal-line-37)">/unleanstall</text><text class="terminal-r1" x="219.6" y="922.8" textLength="341.6" clip-path="url(#terminal-line-37)">:&#160;Uninstall&#160;the&#160;Lean&#160;4&#160;agent</text><text class="terminal-r1" x="1464" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)"> </text><text class="terminal-r2" x="24.4" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)"></text><text class="terminal-r1" x="48.8" y="922.8" textLength="24.4" clip-path="url(#terminal-line-37)">&#160;</text><text class="terminal-r4" x="73.2" y="922.8" textLength="146.4" clip-path="url(#terminal-line-37)">/unleanstall</text><text class="terminal-r1" x="219.6" y="922.8" textLength="341.6" clip-path="url(#terminal-line-37)">:&#160;Uninstall&#160;the&#160;Lean&#160;4&#160;agent</text><text class="terminal-r1" x="1464" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)">
</text><text class="terminal-r4" x="24.4" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)"></text><text class="terminal-r1" x="48.8" y="947.2" textLength="24.4" clip-path="url(#terminal-line-38)">&#160;</text><text class="terminal-r7" x="73.2" y="947.2" textLength="85.4" clip-path="url(#terminal-line-38)">/rewind</text><text class="terminal-r1" x="158.6" y="947.2" textLength="366" clip-path="url(#terminal-line-38)">:&#160;Rewind&#160;to&#160;a&#160;previous&#160;message</text><text class="terminal-r1" x="1464" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)"> </text><text class="terminal-r2" x="24.4" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)"></text><text class="terminal-r1" x="48.8" y="947.2" textLength="24.4" clip-path="url(#terminal-line-38)">&#160;</text><text class="terminal-r4" x="73.2" y="947.2" textLength="85.4" clip-path="url(#terminal-line-38)">/rewind</text><text class="terminal-r1" x="158.6" y="947.2" textLength="366" clip-path="url(#terminal-line-38)">:&#160;Rewind&#160;to&#160;a&#160;previous&#160;message</text><text class="terminal-r1" x="1464" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)">
</text><text class="terminal-r4" x="24.4" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)"></text><text class="terminal-r1" x="48.8" y="971.6" textLength="24.4" clip-path="url(#terminal-line-39)">&#160;</text><text class="terminal-r7" x="73.2" y="971.6" textLength="183" clip-path="url(#terminal-line-39)">/data-retention</text><text class="terminal-r1" x="256.2" y="971.6" textLength="402.6" clip-path="url(#terminal-line-39)">:&#160;Show&#160;data&#160;retention&#160;information</text><text class="terminal-r1" x="1464" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)"> </text><text class="terminal-r2" x="24.4" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)"></text><text class="terminal-r1" x="48.8" y="971.6" textLength="24.4" clip-path="url(#terminal-line-39)">&#160;</text><text class="terminal-r4" x="73.2" y="971.6" textLength="183" clip-path="url(#terminal-line-39)">/data-retention</text><text class="terminal-r1" x="256.2" y="971.6" textLength="402.6" clip-path="url(#terminal-line-39)">:&#160;Show&#160;data&#160;retention&#160;information</text><text class="terminal-r1" x="1464" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)">
</text><text class="terminal-r1" x="1464" y="996" textLength="12.2" clip-path="url(#terminal-line-40)"> </text><text class="terminal-r1" x="1464" y="996" textLength="12.2" clip-path="url(#terminal-line-40)">
</text><text class="terminal-r1" x="1464" y="1020.4" textLength="12.2" clip-path="url(#terminal-line-41)"> </text><text class="terminal-r1" x="1464" y="1020.4" textLength="12.2" clip-path="url(#terminal-line-41)">
</text><text class="terminal-r4" x="0" y="1044.8" textLength="1464" clip-path="url(#terminal-line-42)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="1044.8" textLength="12.2" clip-path="url(#terminal-line-42)"> </text><text class="terminal-r2" x="0" y="1044.8" textLength="1464" clip-path="url(#terminal-line-42)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="1044.8" textLength="12.2" clip-path="url(#terminal-line-42)">
</text><text class="terminal-r4" x="0" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)"></text><text class="terminal-r8" x="24.4" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)">&gt;</text><text class="terminal-r4" x="1451.8" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)"></text><text class="terminal-r1" x="1464" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)"> </text><text class="terminal-r2" x="0" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)"></text><text class="terminal-r7" x="24.4" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)">&gt;</text><text class="terminal-r2" x="1451.8" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)"></text><text class="terminal-r1" x="1464" y="1069.2" textLength="12.2" clip-path="url(#terminal-line-43)">
</text><text class="terminal-r4" x="0" y="1093.6" textLength="12.2" clip-path="url(#terminal-line-44)"></text><text class="terminal-r4" x="1451.8" y="1093.6" textLength="12.2" clip-path="url(#terminal-line-44)"></text><text class="terminal-r1" x="1464" y="1093.6" textLength="12.2" clip-path="url(#terminal-line-44)"> </text><text class="terminal-r2" x="0" y="1093.6" textLength="12.2" clip-path="url(#terminal-line-44)"></text><text class="terminal-r2" x="1451.8" y="1093.6" textLength="12.2" clip-path="url(#terminal-line-44)"></text><text class="terminal-r1" x="1464" y="1093.6" textLength="12.2" clip-path="url(#terminal-line-44)">
</text><text class="terminal-r4" x="0" y="1118" textLength="12.2" clip-path="url(#terminal-line-45)"></text><text class="terminal-r4" x="1451.8" y="1118" textLength="12.2" clip-path="url(#terminal-line-45)"></text><text class="terminal-r1" x="1464" y="1118" textLength="12.2" clip-path="url(#terminal-line-45)"> </text><text class="terminal-r2" x="0" y="1118" textLength="12.2" clip-path="url(#terminal-line-45)"></text><text class="terminal-r2" x="1451.8" y="1118" textLength="12.2" clip-path="url(#terminal-line-45)"></text><text class="terminal-r1" x="1464" y="1118" textLength="12.2" clip-path="url(#terminal-line-45)">
</text><text class="terminal-r4" x="0" y="1142.4" textLength="1464" clip-path="url(#terminal-line-46)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="1142.4" textLength="12.2" clip-path="url(#terminal-line-46)"> </text><text class="terminal-r2" x="0" y="1142.4" textLength="1464" clip-path="url(#terminal-line-46)">└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1464" y="1142.4" textLength="12.2" clip-path="url(#terminal-line-46)">
</text><text class="terminal-r4" x="0" y="1166.8" textLength="158.6" clip-path="url(#terminal-line-47)">/test/workdir</text><text class="terminal-r4" x="1256.6" y="1166.8" textLength="207.4" clip-path="url(#terminal-line-47)">0%&#160;of&#160;200k&#160;tokens</text> </text><text class="terminal-r2" x="0" y="1166.8" textLength="158.6" clip-path="url(#terminal-line-47)">/test/workdir</text><text class="terminal-r2" x="1256.6" y="1166.8" textLength="207.4" clip-path="url(#terminal-line-47)">0%&#160;of&#160;200k&#160;tokens</text>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Before After
Before After

View file

@ -536,6 +536,51 @@ async def test_context_too_long_non_streaming(observer_capture) -> None:
assert agent.session_logger.save_interaction.await_count == 1 assert agent.session_logger.save_interaction.await_count == 1
class _NonRetryableError(Exception):
# Mimics Temporal's ``ApplicationError(non_retryable=True)`` without
# pulling temporalio into vibe's test deps. The wrap-site check relies
# on the truthy ``non_retryable`` attribute, not the concrete type.
non_retryable = True
@pytest.mark.asyncio
async def test_non_retryable_passes_through_streaming(observer_capture) -> None:
observed, observer = observer_capture
backend = FakeBackend(exception_to_raise=_NonRetryableError("auth failed"))
agent = build_test_agent_loop(
config=make_config(),
backend=backend,
message_observer=observer,
enable_streaming=True,
)
agent.session_logger.save_interaction = AsyncMock(return_value=None)
with pytest.raises(_NonRetryableError, match="auth failed"):
[_ async for _ in agent.act("Trigger non-retryable failure while streaming")]
assert [role for role, _ in observed] == [Role.system, Role.user]
assert agent.session_logger.save_interaction.await_count == 1
@pytest.mark.asyncio
async def test_non_retryable_passes_through_non_streaming(observer_capture) -> None:
observed, observer = observer_capture
backend = FakeBackend(exception_to_raise=_NonRetryableError("auth failed"))
agent = build_test_agent_loop(
config=make_config(),
backend=backend,
message_observer=observer,
enable_streaming=False,
)
agent.session_logger.save_interaction = AsyncMock(return_value=None)
with pytest.raises(_NonRetryableError, match="auth failed"):
[_ async for _ in agent.act("Trigger non-retryable failure without streaming")]
assert [role for role, _ in observed] == [Role.system, Role.user]
assert agent.session_logger.save_interaction.await_count == 1
def _snapshot_events(events: list) -> list[tuple[str, str]]: def _snapshot_events(events: list) -> list[tuple[str, str]]:
return [ return [
(type(e).__name__, e.content) (type(e).__name__, e.content)

View file

@ -627,6 +627,7 @@ class TestClearHistoryFullReset:
assert agent.session_id != original_session_id assert agent.session_id != original_session_id
assert agent.session_id == agent.session_logger.session_id assert agent.session_id == agent.session_logger.session_id
assert agent.parent_session_id is None
class TestClearHistoryObserverBugfix: class TestClearHistoryObserverBugfix:

View file

@ -0,0 +1,120 @@
from __future__ import annotations
from pathlib import Path
import tomllib
from tests.conftest import build_test_agent_loop, build_test_vibe_config
from vibe.core.tools.permissions import PermissionScope, RequiredPermission
def _read_persisted_config(config_dir: Path) -> dict:
config_file = config_dir / "config.toml"
with config_file.open("rb") as f:
return tomllib.load(f)
class TestApproveAlwaysPermanentNoGranularPermissions:
def test_sets_tool_permission_always_in_config(self, config_dir: Path):
agent = build_test_agent_loop()
agent.approve_always("bash", None, save_permanently=True)
persisted = _read_persisted_config(config_dir)
assert persisted["tools"]["bash"]["permission"] == "always"
def test_session_only_does_not_persist(self, config_dir: Path):
agent = build_test_agent_loop()
agent.approve_always("bash", None, save_permanently=False)
assert agent.config.tools["bash"]["permission"] == "always"
persisted = _read_persisted_config(config_dir)
assert "bash" not in persisted.get("tools", {})
class TestApproveAlwaysPermanentWithGranularPermissions:
def _make_permissions(self) -> list[RequiredPermission]:
return [
RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern="npm install foo",
session_pattern="npm install *",
label="npm install *",
)
]
def test_persists_allowlist_to_config(self, config_dir: Path):
agent = build_test_agent_loop()
perms = self._make_permissions()
agent.approve_always("bash", perms, save_permanently=True)
persisted = _read_persisted_config(config_dir)
assert persisted["tools"]["bash"]["allowlist"] == ["npm install *"]
def test_also_adds_session_rules(self, config_dir: Path):
agent = build_test_agent_loop()
perms = self._make_permissions()
agent.approve_always("bash", perms, save_permanently=True)
assert len(agent._session_rules) == 1
rule = agent._session_rules[0]
assert rule.tool_name == "bash"
assert rule.scope == PermissionScope.COMMAND_PATTERN
assert rule.session_pattern == "npm install *"
def test_session_only_does_not_persist_allowlist(self, config_dir: Path):
agent = build_test_agent_loop()
perms = self._make_permissions()
agent.approve_always("bash", perms, save_permanently=False)
assert len(agent._session_rules) == 1
persisted = _read_persisted_config(config_dir)
assert "bash" not in persisted.get("tools", {})
def test_does_not_duplicate_existing_allowlist_entries(self, config_dir: Path):
config = build_test_vibe_config(
tools={"bash": {"allowlist": ["npm install *"]}}
)
agent = build_test_agent_loop(config=config)
perms = self._make_permissions()
agent.approve_always("bash", perms, save_permanently=True)
persisted = _read_persisted_config(config_dir)
# Pattern already existed -- nothing new should be written
assert persisted.get("tools", {}).get("bash", {}).get("allowlist") is None
def test_appends_new_patterns_to_existing_allowlist(self, config_dir: Path):
config = build_test_vibe_config(tools={"bash": {"allowlist": ["git *"]}})
agent = build_test_agent_loop(config=config)
perms = self._make_permissions()
agent.approve_always("bash", perms, save_permanently=True)
persisted = _read_persisted_config(config_dir)
assert persisted["tools"]["bash"]["allowlist"] == ["git *", "npm install *"]
def test_multiple_permissions_persisted(self, config_dir: Path):
agent = build_test_agent_loop()
perms = [
RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern="npm install foo",
session_pattern="npm install *",
label="npm install *",
),
RequiredPermission(
scope=PermissionScope.OUTSIDE_DIRECTORY,
invocation_pattern="/tmp/newdir",
session_pattern="/tmp/*",
label="/tmp/*",
),
]
agent.approve_always("bash", perms, save_permanently=True)
persisted = _read_persisted_config(config_dir)
assert persisted["tools"]["bash"]["allowlist"] == ["/tmp/*", "npm install *"]

View file

@ -80,9 +80,7 @@ def test_run_programmatic_preload_streaming_is_batched(
new_session = [ new_session = [
e for e in telemetry_events if e.get("event_name") == "vibe.new_session" e for e in telemetry_events if e.get("event_name") == "vibe.new_session"
] ]
assert len(new_session) == 1 assert len(new_session) == 0
assert new_session[0]["properties"]["entrypoint"] == "programmatic"
assert "version" in new_session[0]["properties"]
assert ( assert (
spy.emitted[0][1] == "You are Vibe, a super useful programming assistant." spy.emitted[0][1] == "You are Vibe, a super useful programming assistant."

View file

@ -1,11 +1,12 @@
from __future__ import annotations from __future__ import annotations
import os import os
from typing import cast from typing import Any, cast
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import httpx import httpx
import pytest import pytest
import respx
from tests.stubs.fake_connector_registry import FakeConnectorRegistry from tests.stubs.fake_connector_registry import FakeConnectorRegistry
from tests.stubs.fake_mcp_registry import FakeMCPRegistry from tests.stubs.fake_mcp_registry import FakeMCPRegistry
@ -13,6 +14,7 @@ from vibe.core.config import ConnectorConfig, VibeConfig
from vibe.core.tools.base import BaseToolConfig, ToolError from vibe.core.tools.base import BaseToolConfig, ToolError
from vibe.core.tools.connectors import CONNECTORS_ENV_VAR from vibe.core.tools.connectors import CONNECTORS_ENV_VAR
from vibe.core.tools.connectors.connector_registry import ( from vibe.core.tools.connectors.connector_registry import (
ConnectorRegistry,
RemoteTool, RemoteTool,
_connector_error_message, _connector_error_message,
_normalize_name, _normalize_name,
@ -497,3 +499,206 @@ class TestConnectorDisableFiltering:
) )
assert "connector_wiki_search" in tm.available_tools assert "connector_wiki_search" in tm.available_tools
assert "connector_mail_send" not in tm.available_tools assert "connector_mail_send" not in tm.available_tools
# ---------------------------------------------------------------------------
# Bootstrap-based discovery (ConnectorRegistry._discover_all via httpx)
# ---------------------------------------------------------------------------
_BOOTSTRAP_URL = "https://api.mistral.ai/v1/connectors/bootstrap"
def _make_bootstrap_response(
connectors: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
return {"connectors": connectors or [], "errors": None}
def _make_connector_payload(
*,
connector_id: str = "conn-1",
name: str = "wiki",
is_ready: bool = True,
tools: list[dict[str, Any]] | None = None,
bootstrap_errors: list[str] | None = None,
) -> dict[str, Any]:
return {
"id": connector_id,
"name": name,
"display_name": name,
"description": name,
"status": {"is_ready": is_ready},
"tools": tools or [],
"bootstrap_errors": bootstrap_errors,
}
def _make_tool_payload(
name: str = "search", description: str = "Search docs"
) -> dict[str, Any]:
return {
"name": name,
"description": description,
"inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}},
}
class TestBootstrapDiscovery:
@respx.mock
@pytest.mark.asyncio
async def test_discovers_tools_from_bootstrap(self) -> None:
payload = _make_bootstrap_response([
_make_connector_payload(
name="wiki",
tools=[_make_tool_payload("search"), _make_tool_payload("read")],
)
])
respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))
registry = ConnectorRegistry(api_key="test-key")
tools = await registry.get_tools_async()
assert "connector_wiki_search" in tools
assert "connector_wiki_read" in tools
assert registry.connector_count == 1
assert registry.is_connected("wiki")
@respx.mock
@pytest.mark.asyncio
async def test_skips_not_ready_connectors(self) -> None:
payload = _make_bootstrap_response([
_make_connector_payload(
name="broken", is_ready=False, bootstrap_errors=["auth failed"]
),
_make_connector_payload(name="healthy", tools=[_make_tool_payload("ping")]),
])
respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))
registry = ConnectorRegistry(api_key="test-key")
tools = await registry.get_tools_async()
assert "connector_healthy_ping" in tools
assert not any("broken" in name for name in tools)
assert not registry.is_connected("broken")
assert registry.is_connected("healthy")
@respx.mock
@pytest.mark.asyncio
async def test_handles_bootstrap_http_error(self) -> None:
respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(500, text="Internal Server Error")
)
registry = ConnectorRegistry(api_key="test-key")
tools = await registry.get_tools_async()
assert tools == {}
assert registry.connector_count == 0
@respx.mock
@pytest.mark.asyncio
async def test_deduplicates_connector_aliases(self) -> None:
payload = _make_bootstrap_response([
_make_connector_payload(
connector_id="c-1", name="mcp", tools=[_make_tool_payload("a")]
),
_make_connector_payload(
connector_id="c-2", name="mcp", tools=[_make_tool_payload("b")]
),
])
respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))
registry = ConnectorRegistry(api_key="test-key")
tools = await registry.get_tools_async()
assert "connector_mcp_a" in tools
assert "connector_mcp_2_b" in tools
assert registry.connector_count == 2
@respx.mock
@pytest.mark.asyncio
async def test_skips_connector_without_id(self) -> None:
payload = _make_bootstrap_response([
{"name": "broken", "status": {"is_ready": True}, "tools": []},
_make_connector_payload(name="valid", tools=[_make_tool_payload("ping")]),
])
respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))
registry = ConnectorRegistry(api_key="test-key")
tools = await registry.get_tools_async()
assert "connector_valid_ping" in tools
assert registry.connector_count == 1
@respx.mock
@pytest.mark.asyncio
async def test_empty_connectors_list(self) -> None:
payload = _make_bootstrap_response([])
respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))
registry = ConnectorRegistry(api_key="test-key")
tools = await registry.get_tools_async()
assert tools == {}
assert registry.connector_count == 0
@respx.mock
@pytest.mark.asyncio
async def test_uses_custom_server_url(self) -> None:
custom_url = "https://custom.api.example.com/v1/connectors/bootstrap"
payload = _make_bootstrap_response([
_make_connector_payload(tools=[_make_tool_payload("ping")])
])
respx.get(custom_url).mock(return_value=httpx.Response(200, json=payload))
registry = ConnectorRegistry(
api_key="test-key", server_url="https://custom.api.example.com"
)
tools = await registry.get_tools_async()
assert "connector_wiki_ping" in tools
@respx.mock
@pytest.mark.asyncio
async def test_caches_after_first_call(self) -> None:
payload = _make_bootstrap_response([
_make_connector_payload(tools=[_make_tool_payload("search")])
])
route = respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(200, json=payload)
)
registry = ConnectorRegistry(api_key="test-key")
await registry.get_tools_async()
await registry.get_tools_async()
assert route.call_count == 1
@respx.mock
@pytest.mark.asyncio
async def test_refresh_connector_updates_cache(self) -> None:
payload = _make_bootstrap_response([
_make_connector_payload(
connector_id="c-1", name="wiki", tools=[_make_tool_payload("search")]
)
])
respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))
registry = ConnectorRegistry(api_key="test-key")
await registry.get_tools_async()
# Refresh returns updated tools from a second bootstrap call
refresh_payload = _make_bootstrap_response([
_make_connector_payload(
connector_id="c-1",
name="wiki",
tools=[_make_tool_payload("search"), _make_tool_payload("write")],
)
])
respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(200, json=refresh_payload)
)
refreshed = await registry.refresh_connector_async("wiki")
assert "connector_wiki_search" in refreshed
assert "connector_wiki_write" in refreshed

View file

@ -16,6 +16,18 @@ from vibe.core.types import Role
async def _wait_for_bash_output_message( async def _wait_for_bash_output_message(
vibe_app: VibeApp, pilot, timeout: float = 1.0 vibe_app: VibeApp, pilot, timeout: float = 1.0
) -> BashOutputMessage:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if message := next(iter(vibe_app.query(BashOutputMessage)), None):
if not message._pending:
return message
await pilot.pause(0.05)
raise TimeoutError(f"BashOutputMessage did not appear within {timeout}s")
async def _wait_for_pending_bash_message(
vibe_app: VibeApp, pilot, timeout: float = 1.0
) -> BashOutputMessage: ) -> BashOutputMessage:
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
@ -157,3 +169,71 @@ async def test_ui_sends_manual_command_output_to_next_agent_turn() -> None:
assert user_messages[-2].content == injected_message.content assert user_messages[-2].content == injected_message.content
assert user_messages[-2].injected is True assert user_messages[-2].injected is True
assert user_messages[-1].content == "what did the command print?" assert user_messages[-1].content == "what did the command print?"
@pytest.mark.asyncio
async def test_ui_shows_command_immediately_in_pending_state(vibe_app: VibeApp) -> None:
"""The command line should appear before the process finishes."""
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!sleep 10"
await pilot.press("enter")
message = await _wait_for_pending_bash_message(vibe_app, pilot)
assert message._pending is True
# command line is rendered
cmd_widget = message.query_one(".bash-command", Static)
assert str(cmd_widget.render()) == "sleep 10"
# no output container yet
assert not list(message.query(".bash-output"))
# clean up: cancel the background task
if vibe_app._bash_task and not vibe_app._bash_task.done():
vibe_app._bash_task.cancel()
@pytest.mark.asyncio
async def test_ui_streams_output_incrementally(vibe_app: VibeApp) -> None:
"""Output should appear as the command produces it, not all at once."""
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
# print lines with a small delay so streaming has a chance to show partial output
chat_input.value = "!bash -lc 'echo first; echo second'"
await pilot.press("enter")
message = await _wait_for_bash_output_message(vibe_app, pilot)
output_widget = message.query_one(".bash-output", Static)
rendered = str(output_widget.render())
assert "first" in rendered
assert "second" in rendered
@pytest.mark.asyncio
async def test_ui_cancels_running_command_on_new_submit(vibe_app: VibeApp) -> None:
"""Submitting new input while a bang command is running should cancel it."""
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!sleep 30"
await pilot.press("enter")
await _wait_for_pending_bash_message(vibe_app, pilot)
assert vibe_app._bash_task is not None
assert not vibe_app._bash_task.done()
# submit a new command which should cancel the first one
chat_input.value = "!echo done"
await pilot.press("enter")
# wait until we have two messages and the second is finished
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
all_msgs = list(vibe_app.query(BashOutputMessage))
if len(all_msgs) == 2 and not all_msgs[1]._pending:
break
await pilot.pause(0.05)
all_msgs = list(vibe_app.query(BashOutputMessage))
assert len(all_msgs) == 2
second = all_msgs[1]
output_widget = second.query_one(".bash-output", Static)
assert str(output_widget.render()) == "done"

746
uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
VIBE_ROOT = Path(__file__).parent VIBE_ROOT = Path(__file__).parent
__version__ = "2.9.3" __version__ = "2.9.4"

View file

@ -52,6 +52,7 @@ from acp.schema import (
SessionConfigOptionSelect, SessionConfigOptionSelect,
SessionForkCapabilities, SessionForkCapabilities,
SessionInfo, SessionInfo,
SessionInfoUpdate,
SessionListCapabilities, SessionListCapabilities,
SetSessionConfigOptionResponse, SetSessionConfigOptionResponse,
SseMcpServer, SseMcpServer,
@ -122,6 +123,10 @@ from vibe.core.proxy_setup import (
set_proxy_var, set_proxy_var,
unset_proxy_var, unset_proxy_var,
) )
from vibe.core.session.saved_sessions import (
update_saved_session_title,
update_saved_session_title_at_path,
)
from vibe.core.session.session_loader import SessionLoader from vibe.core.session.session_loader import SessionLoader
from vibe.core.skills.manager import SkillManager from vibe.core.skills.manager import SkillManager
from vibe.core.telemetry.build_metadata import build_entrypoint_metadata from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
@ -159,6 +164,15 @@ class ForkSessionParams(BaseModel):
message_id: str | None = Field(default=None, alias="messageId") message_id: str | None = Field(default=None, alias="messageId")
class SessionSetTitleRequest(BaseModel):
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
session_id: str = Field(
validation_alias=AliasChoices("session_id", "sessionId"), min_length=1
)
title: str = Field(min_length=1)
class TelemetrySendNotification(BaseModel): class TelemetrySendNotification(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
@ -167,7 +181,20 @@ class TelemetrySendNotification(BaseModel):
session_id: str = Field(validation_alias=AliasChoices("session_id", "sessionId")) session_id: str = Field(validation_alias=AliasChoices("session_id", "sessionId"))
_EVENT_DISPATCHERS: dict[str, Callable[[TelemetryClient, dict[str, Any]], None]] = {} def _dispatch_at_mention_inserted(
client: TelemetryClient, properties: dict[str, Any]
) -> None:
client.send_at_mention_inserted(
nb_mentions=properties.get("nb_mentions", 0),
context_types=properties.get("context_types", {}),
file_extensions=properties.get("file_extensions"),
message_id=properties.get("message_id"),
)
_EVENT_DISPATCHERS: dict[str, Callable[[TelemetryClient, dict[str, Any]], None]] = {
"vibe.at_mention_inserted": _dispatch_at_mention_inserted
}
def _resolved_user_message_id(client_message_id: str | None) -> str: def _resolved_user_message_id(client_message_id: str | None) -> str:
@ -301,9 +328,20 @@ class VibeAcpAgentLoop(AcpAgent):
agent_loop.set_approval_callback(self._create_approval_callback(session.id)) agent_loop.set_approval_callback(self._create_approval_callback(session.id))
session.spawn(self._send_available_commands(session)) session.spawn(self._send_available_commands(session))
session.spawn(self._warm_up_agent_loop(agent_loop))
return session return session
async def _warm_up_agent_loop(self, agent_loop: AgentLoop) -> None:
"""Proactively await deferred init so `vibe.ready` telemetry is emitted
without waiting for the user's first prompt. Errors are swallowed here
and will resurface on the first `act()` call via `requires_init`.
"""
try:
await agent_loop.wait_until_ready()
except Exception:
pass
def _create_agent_loop( def _create_agent_loop(
self, config: VibeConfig, agent_name: str, hook_config_result: Any = None self, config: VibeConfig, agent_name: str, hook_config_result: Any = None
) -> AgentLoop: ) -> AgentLoop:
@ -398,6 +436,11 @@ class VibeAcpAgentLoop(AcpAgent):
case ToolOption.ALLOW_ALWAYS: case ToolOption.ALLOW_ALWAYS:
session.agent_loop.approve_always(tool_name, required_permissions) session.agent_loop.approve_always(tool_name, required_permissions)
return (ApprovalResponse.YES, None) return (ApprovalResponse.YES, None)
case ToolOption.ALLOW_ALWAYS_PERMANENT:
session.agent_loop.approve_always(
tool_name, required_permissions, save_permanently=True
)
return (ApprovalResponse.YES, None)
case ToolOption.REJECT_ONCE: case ToolOption.REJECT_ONCE:
session.agent_loop.telemetry_client.send_user_cancelled_action( session.agent_loop.telemetry_client.send_user_cancelled_action(
"reject_approval" "reject_approval"
@ -454,6 +497,29 @@ class VibeAcpAgentLoop(AcpAgent):
raise SessionNotFoundError(session_id) raise SessionNotFoundError(session_id)
return self.sessions[session_id] return self.sessions[session_id]
def _find_acp_session_by_vibe_session_id(
self, session_id: str
) -> AcpSessionLoop | None:
for candidate in self.sessions.values():
if candidate.agent_loop.session_id == session_id:
return candidate
return None
def _load_session_logging_config(self) -> SessionLoggingConfig:
try:
return VibeConfig.load().session_logging
except MissingAPIKeyError:
try:
persisted_config = VibeConfig.get_persisted_config()
return SessionLoggingConfig.model_validate(
persisted_config.get("session_logging", {})
)
except Exception as e:
raise ConfigurationError(str(e)) from e
except Exception as e:
raise ConfigurationError(str(e)) from e
def _build_usage(self, session: AcpSessionLoop) -> Usage: def _build_usage(self, session: AcpSessionLoop) -> Usage:
stats = session.agent_loop.stats stats = session.agent_loop.stats
return Usage( return Usage(
@ -1008,9 +1074,97 @@ class VibeAcpAgentLoop(AcpAgent):
) -> ResumeSessionResponse: ) -> ResumeSessionResponse:
raise NotImplementedMethodError("resume_session") raise NotImplementedMethodError("resume_session")
async def _emit_session_info_update(
self, session_id: str, *, title: str, updated_at: str | None
) -> None:
update_kwargs: dict[str, Any] = {
"session_update": "session_info_update",
"title": title,
}
if updated_at is not None:
update_kwargs["updated_at"] = updated_at
await self.client.session_update(
session_id=session_id, update=SessionInfoUpdate(**update_kwargs)
)
async def _persist_live_session_title(
self, session: AcpSessionLoop, title: str
) -> dict[str, Any] | None:
logger = session.agent_loop.session_logger
if not logger.enabled or logger.session_dir is None:
return None
if not logger.metadata_filepath.exists():
return None
try:
return await update_saved_session_title_at_path(logger.session_dir, title)
except ValueError as exc:
raise InternalError(
f"Failed to persist title update for session {logger.session_id}: {exc}"
) from exc
def _set_live_session_title(self, session: AcpSessionLoop, title: str) -> None:
try:
session.agent_loop.session_logger.set_title(title)
except ValueError as exc:
raise InvalidRequestError(
f"Invalid ACP session title request: {exc}"
) from exc
async def _handle_session_set_title(self, params: dict[str, Any]) -> dict[str, Any]:
try:
request = SessionSetTitleRequest.model_validate(params)
except ValidationError as exc:
raise InvalidRequestError(
f"Invalid ACP session title request: {exc}"
) from exc
live_session = self.sessions.get(
request.session_id
) or self._find_acp_session_by_vibe_session_id(request.session_id)
if live_session is None:
try:
metadata = await update_saved_session_title(
request.session_id,
request.title,
self._load_session_logging_config(),
)
except ValueError as exc:
raise SessionNotFoundError(request.session_id) from exc
await self._emit_session_info_update(
request.session_id,
title=request.title,
updated_at=metadata.get("end_time"),
)
return {}
persisted_metadata = await self._persist_live_session_title(
live_session, request.title
)
self._set_live_session_title(live_session, request.title)
updated_at = (
persisted_metadata.get("end_time")
if persisted_metadata is not None
else (
live_session.agent_loop.session_logger.session_metadata.end_time
if live_session.agent_loop.session_logger.session_metadata is not None
else None
)
)
await self._emit_session_info_update(
live_session.id, title=request.title, updated_at=updated_at
)
return {}
@override @override
async def ext_method(self, method: str, params: dict) -> dict: async def ext_method(self, method: str, params: dict) -> dict:
raise NotImplementedMethodError("ext_method") if method == "session/set_title":
return await self._handle_session_set_title(params)
raise NotImplementedMethodError(method)
@override @override
async def ext_notification(self, method: str, params: dict) -> None: async def ext_notification(self, method: str, params: dict) -> None:

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from enum import StrEnum from enum import StrEnum
from typing import TYPE_CHECKING, Literal, cast from typing import TYPE_CHECKING
from acp.schema import ( from acp.schema import (
AgentMessageChunk, AgentMessageChunk,
@ -9,6 +9,7 @@ from acp.schema import (
ContentToolCallContent, ContentToolCallContent,
ModelInfo, ModelInfo,
PermissionOption, PermissionOption,
PermissionOptionKind,
SessionConfigOptionSelect, SessionConfigOptionSelect,
SessionConfigSelectOption, SessionConfigSelectOption,
SessionMode, SessionMode,
@ -34,25 +35,31 @@ if TYPE_CHECKING:
class ToolOption(StrEnum): class ToolOption(StrEnum):
ALLOW_ONCE = "allow_once" ALLOW_ONCE = "allow_once"
ALLOW_ALWAYS = "allow_always" ALLOW_ALWAYS = "allow_always"
ALLOW_ALWAYS_PERMANENT = "allow_always_permanent"
REJECT_ONCE = "reject_once" REJECT_ONCE = "reject_once"
REJECT_ALWAYS = "reject_always" REJECT_ALWAYS = "reject_always"
_KIND_ALLOW_ONCE: PermissionOptionKind = "allow_once"
_KIND_ALLOW_ALWAYS: PermissionOptionKind = "allow_always"
_KIND_REJECT_ONCE: PermissionOptionKind = "reject_once"
TOOL_OPTIONS = [ TOOL_OPTIONS = [
PermissionOption( PermissionOption(
option_id=ToolOption.ALLOW_ONCE, option_id=ToolOption.ALLOW_ONCE, name="Allow once", kind=_KIND_ALLOW_ONCE
name="Allow once",
kind=cast(Literal["allow_once"], ToolOption.ALLOW_ONCE),
), ),
PermissionOption( PermissionOption(
option_id=ToolOption.ALLOW_ALWAYS, option_id=ToolOption.ALLOW_ALWAYS,
name="Allow for this session", name="Allow for remainder of this session",
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS), kind=_KIND_ALLOW_ALWAYS,
), ),
PermissionOption( PermissionOption(
option_id=ToolOption.REJECT_ONCE, option_id=ToolOption.ALLOW_ALWAYS_PERMANENT,
name="Reject once", name="Always allow",
kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE), kind=_KIND_ALLOW_ALWAYS,
),
PermissionOption(
option_id=ToolOption.REJECT_ONCE, name="Deny", kind=_KIND_REJECT_ONCE
), ),
] ]
@ -64,7 +71,6 @@ def build_permission_options(
if not required_permissions: if not required_permissions:
return TOOL_OPTIONS return TOOL_OPTIONS
labels = ", ".join(rp.label for rp in required_permissions)
permissions_meta = [ permissions_meta = [
{ {
"scope": rp.scope, "scope": rp.scope,
@ -77,20 +83,22 @@ def build_permission_options(
return [ return [
PermissionOption( PermissionOption(
option_id=ToolOption.ALLOW_ONCE, option_id=ToolOption.ALLOW_ONCE, name="Allow once", kind=_KIND_ALLOW_ONCE
name="Allow once",
kind=cast(Literal["allow_once"], ToolOption.ALLOW_ONCE),
), ),
PermissionOption( PermissionOption(
option_id=ToolOption.ALLOW_ALWAYS, option_id=ToolOption.ALLOW_ALWAYS,
name=f"Allow for this session: {labels}", name="Allow for remainder of this session",
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS), kind=_KIND_ALLOW_ALWAYS,
field_meta={"required_permissions": permissions_meta}, field_meta={"required_permissions": permissions_meta},
), ),
PermissionOption( PermissionOption(
option_id=ToolOption.REJECT_ONCE, option_id=ToolOption.ALLOW_ALWAYS_PERMANENT,
name="Reject once", name="Always allow",
kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE), kind=_KIND_ALLOW_ALWAYS,
field_meta={"required_permissions": permissions_meta},
),
PermissionOption(
option_id=ToolOption.REJECT_ONCE, name="Deny", kind=_KIND_REJECT_ONCE
), ),
] ]

View file

@ -265,6 +265,7 @@ def run_cli(args: argparse.Namespace) -> None:
initial_prompt=args.initial_prompt or stdin_prompt, initial_prompt=args.initial_prompt or stdin_prompt,
teleport_on_start=args.teleport, teleport_on_start=args.teleport,
show_resume_picker=args.resume is True, show_resume_picker=args.resume is True,
is_resuming_session=loaded_session is not None,
), ),
) )

View file

@ -128,6 +128,11 @@ class CommandRegistry:
description="Browse and resume past sessions", description="Browse and resume past sessions",
handler="_show_session_picker", handler="_show_session_picker",
), ),
"rename": Command(
aliases=frozenset(["/rename"]),
description="Rename the current session",
handler="_rename_session",
),
"mcp": Command( "mcp": Command(
aliases=frozenset(["/mcp", "/connectors"]), aliases=frozenset(["/mcp", "/connectors"]),
description=( description=(
@ -225,7 +230,7 @@ class CommandRegistry:
"- `Ctrl+C` Quit (or clear input if text present)", "- `Ctrl+C` Quit (or clear input if text present)",
"- `Ctrl+G` Edit input in external editor", "- `Ctrl+G` Edit input in external editor",
"- `Ctrl+O` Toggle tool output view", "- `Ctrl+O` Toggle tool output view",
"- `Shift+Tab` Toggle auto-approve mode", "- `Shift+Tab` Cycle through agents (default, plan, ...)",
f"- `{ALT_KEY}+↑↓` / `Ctrl+P/N` Rewind to previous/next message", f"- `{ALT_KEY}+↑↓` / `Ctrl+P/N` Rewind to previous/next message",
"", "",
"### Special Features", "### Special Features",

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import codecs
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import aclosing from contextlib import aclosing
from dataclasses import dataclass from dataclasses import dataclass
@ -9,9 +10,9 @@ import gc
import os import os
from pathlib import Path from pathlib import Path
import signal import signal
import subprocess
import time import time
from typing import Any, ClassVar, assert_never, cast from typing import Any, ClassVar, assert_never, cast
from uuid import uuid4
from weakref import WeakKeyDictionary from weakref import WeakKeyDictionary
import webbrowser import webbrowser
@ -121,6 +122,7 @@ from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile from vibe.core.agents import AgentProfile
from vibe.core.audio_player.audio_player import AudioPlayer from vibe.core.audio_player.audio_player import AudioPlayer
from vibe.core.audio_recorder import AudioRecorder from vibe.core.audio_recorder import AudioRecorder
from vibe.core.autocompletion.path_prompt import build_path_prompt_payload
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig from vibe.core.config import VibeConfig
from vibe.core.data_retention import DATA_RETENTION_MESSAGE from vibe.core.data_retention import DATA_RETENTION_MESSAGE
@ -135,6 +137,7 @@ from vibe.core.session.resume_sessions import (
list_remote_resume_sessions, list_remote_resume_sessions,
short_session_id, short_session_id,
) )
from vibe.core.session.saved_sessions import update_saved_session_title_at_path
from vibe.core.session.session_loader import SessionLoader from vibe.core.session.session_loader import SessionLoader
from vibe.core.skills.manager import SkillManager from vibe.core.skills.manager import SkillManager
from vibe.core.teleport.types import ( from vibe.core.teleport.types import (
@ -294,6 +297,7 @@ class StartupOptions:
initial_prompt: str | None = None initial_prompt: str | None = None
teleport_on_start: bool = False teleport_on_start: bool = False
show_resume_picker: bool = False show_resume_picker: bool = False
is_resuming_session: bool = False
class VibeApp(App): # noqa: PLR0904 class VibeApp(App): # noqa: PLR0904
@ -349,6 +353,7 @@ class VibeApp(App): # noqa: PLR0904
self._agent_running = False self._agent_running = False
self._interrupt_requested = False self._interrupt_requested = False
self._agent_task: asyncio.Task | None = None self._agent_task: asyncio.Task | None = None
self._bash_task: asyncio.Task | None = None
self._remote_manager = RemoteSessionManager() self._remote_manager = RemoteSessionManager()
self._loading_widget: LoadingWidget | None = None self._loading_widget: LoadingWidget | None = None
@ -374,12 +379,7 @@ class VibeApp(App): # noqa: PLR0904
self._update_cache_repository = update_cache_repository self._update_cache_repository = update_cache_repository
self._current_version = current_version self._current_version = current_version
self._plan_offer_gateway = plan_offer_gateway self._plan_offer_gateway = plan_offer_gateway
opts = startup or StartupOptions() self._configure_startup_options(startup)
self._initial_prompt = opts.initial_prompt
self._teleport_on_start = (
opts.teleport_on_start and self.agent_loop.base_config.vibe_code_enabled
)
self._show_resume_picker = opts.show_resume_picker
self._last_escape_time: float | None = None self._last_escape_time: float | None = None
self._quit_manager = QuitManager(self) self._quit_manager = QuitManager(self)
self._banner: Banner | None = None self._banner: Banner | None = None
@ -399,6 +399,15 @@ class VibeApp(App): # noqa: PLR0904
self._fatal_init_error = False self._fatal_init_error = False
self.commands = self._build_command_registry() self.commands = self._build_command_registry()
def _configure_startup_options(self, startup: StartupOptions | None) -> None:
opts = startup or StartupOptions()
self._initial_prompt = opts.initial_prompt
self._teleport_on_start = (
opts.teleport_on_start and self.agent_loop.base_config.vibe_code_enabled
)
self._show_resume_picker = opts.show_resume_picker
self._is_resuming_session = opts.is_resuming_session
@property @property
def config(self) -> VibeConfig: def config(self) -> VibeConfig:
return self.agent_loop.config return self.agent_loop.config
@ -497,7 +506,8 @@ class VibeApp(App): # noqa: PLR0904
await self._resume_history_from_messages() await self._resume_history_from_messages()
await self._check_and_show_whats_new() await self._check_and_show_whats_new()
self._schedule_update_notification() self._schedule_update_notification()
self.agent_loop.emit_new_session_telemetry() if not self._is_resuming_session:
self.agent_loop.emit_new_session_telemetry()
self.call_after_refresh(self._refresh_banner) self.call_after_refresh(self._refresh_banner)
self._show_hook_config_issues_once() self._show_hook_config_issues_once()
@ -586,11 +596,15 @@ class VibeApp(App): # noqa: PLR0904
input_widget = self.query_one(ChatInputContainer) input_widget = self.query_one(ChatInputContainer)
input_widget.value = "" input_widget.value = ""
if self._bash_task and not self._bash_task.done():
self._bash_task.cancel()
self._bash_task = None
if self._agent_running: if self._agent_running:
await self._interrupt_agent_loop() await self._interrupt_agent_loop()
if value.startswith("!"): if value.startswith("!"):
await self._handle_bash_command(value[1:]) self._bash_task = asyncio.create_task(self._handle_bash_command(value[1:]))
return return
if value.startswith("&") and self.commands.has_command("teleport"): if value.startswith("&") and self.commands.has_command("teleport"):
@ -619,6 +633,16 @@ class VibeApp(App): # noqa: PLR0904
if self._pending_approval and not self._pending_approval.done(): if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result((ApprovalResponse.YES, None)) self._pending_approval.set_result((ApprovalResponse.YES, None))
async def on_approval_app_approval_granted_always_permanent(
self, message: ApprovalApp.ApprovalGrantedAlwaysPermanent
) -> None:
self.agent_loop.approve_always(
message.tool_name, message.required_permissions, save_permanently=True
)
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result((ApprovalResponse.YES, None))
async def on_approval_app_approval_rejected( async def on_approval_app_approval_rejected(
self, message: ApprovalApp.ApprovalRejected self, message: ApprovalApp.ApprovalRejected
) -> None: ) -> None:
@ -905,6 +929,40 @@ class VibeApp(App): # noqa: PLR0904
await self._handle_user_message(prompt) await self._handle_user_message(prompt)
return True return True
@staticmethod
async def _bash_read_stream(
stream: asyncio.StreamReader | None,
parts: list[str],
bash_msg: BashOutputMessage,
) -> None:
if not stream:
return
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
while True:
chunk = await stream.read(4096)
if not chunk:
break
text = decoder.decode(chunk)
if not text:
continue
parts.append(text)
await bash_msg.append_output(text)
final_text = decoder.decode(b"", final=True)
if not final_text:
return
parts.append(final_text)
await bash_msg.append_output(final_text)
@staticmethod
async def _kill_running_process(proc: asyncio.subprocess.Process | None) -> None:
if proc is None or proc.returncode is not None:
return
try:
proc.kill()
except ProcessLookupError:
pass
await proc.wait()
async def _handle_bash_command(self, command: str) -> None: async def _handle_bash_command(self, command: str) -> None:
if not command: if not command:
await self._mount_and_scroll( await self._mount_and_scroll(
@ -914,21 +972,52 @@ class VibeApp(App): # noqa: PLR0904
) )
return return
bash_msg = BashOutputMessage(command, str(Path.cwd()), pending=True)
await self._mount_and_scroll(bash_msg)
proc: asyncio.subprocess.Process | None = None
stdout_parts: list[str] = []
stderr_parts: list[str] = []
try: try:
result = subprocess.run( proc = await asyncio.create_subprocess_shell(
command, shell=True, capture_output=True, text=False, timeout=30 command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout = (
result.stdout.decode("utf-8", errors="replace") if result.stdout else ""
)
stderr = (
result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
)
output = stdout or stderr or "(no output)"
exit_code = result.returncode
await self._mount_and_scroll(
BashOutputMessage(command, str(Path.cwd()), output, exit_code)
) )
try:
await asyncio.wait_for(
asyncio.gather(
self._bash_read_stream(proc.stdout, stdout_parts, bash_msg),
self._bash_read_stream(proc.stderr, stderr_parts, bash_msg),
proc.wait(),
),
timeout=30,
)
except TimeoutError:
await self._kill_running_process(proc)
stdout = "".join(stdout_parts)
stderr = "".join(stderr_parts)
await bash_msg.finish(1)
await self._mount_and_scroll(
ErrorMessage(
"Command timed out after 30 seconds",
collapsed=self._tools_collapsed,
)
)
await self.agent_loop.inject_user_context(
self._format_manual_command_context(
command=command,
cwd=str(Path.cwd()),
stdout=stdout,
stderr=stderr,
status="timed out after 30 seconds",
)
)
return
stdout = "".join(stdout_parts)
stderr = "".join(stderr_parts)
exit_code = proc.returncode or 0
await bash_msg.finish(exit_code)
await self.agent_loop.inject_user_context( await self.agent_loop.inject_user_context(
self._format_manual_command_context( self._format_manual_command_context(
command=command, command=command,
@ -938,33 +1027,25 @@ class VibeApp(App): # noqa: PLR0904
stderr=stderr, stderr=stderr,
) )
) )
except subprocess.TimeoutExpired as error: except asyncio.CancelledError:
stdout = ( await self._kill_running_process(proc)
error.stdout.decode("utf-8", errors="replace") await bash_msg.finish(1, interrupted=True)
if isinstance(error.stdout, bytes) stdout = "".join(stdout_parts)
else (error.stdout or "") stderr = "".join(stderr_parts)
)
stderr = (
error.stderr.decode("utf-8", errors="replace")
if isinstance(error.stderr, bytes)
else (error.stderr or "")
)
await self._mount_and_scroll(
ErrorMessage(
"Command timed out after 30 seconds",
collapsed=self._tools_collapsed,
)
)
await self.agent_loop.inject_user_context( await self.agent_loop.inject_user_context(
self._format_manual_command_context( self._format_manual_command_context(
command=command, command=command,
cwd=str(Path.cwd()), cwd=str(Path.cwd()),
stdout=stdout, stdout=stdout,
stderr=stderr, stderr=stderr,
status="timed out after 30 seconds", status="interrupted by user",
) )
) )
except Exception as e: except Exception as e:
await self._kill_running_process(proc)
await bash_msg.finish(1)
stdout = "".join(stdout_parts)
stderr = "".join(stderr_parts)
await self._mount_and_scroll( await self._mount_and_scroll(
ErrorMessage(f"Command failed: {e}", collapsed=self._tools_collapsed) ErrorMessage(f"Command failed: {e}", collapsed=self._tools_collapsed)
) )
@ -972,6 +1053,8 @@ class VibeApp(App): # noqa: PLR0904
self._format_manual_command_context( self._format_manual_command_context(
command=command, command=command,
cwd=str(Path.cwd()), cwd=str(Path.cwd()),
stdout=stdout,
stderr=stderr,
status=f"failed before completion: {e}", status=f"failed before completion: {e}",
) )
) )
@ -1230,10 +1313,30 @@ class VibeApp(App): # noqa: PLR0904
try: try:
await self._handle_agent_loop_init() await self._handle_agent_loop_init()
await self._ensure_loading_widget() await self._ensure_loading_widget()
message_id = str(uuid4())
prompt_payload = build_path_prompt_payload(prompt, base_dir=Path.cwd())
if prompt_payload.all_resources:
context_types: dict[str, int] = {}
for r in prompt_payload.all_resources:
context_types[r.kind] = context_types.get(r.kind, 0) + 1
file_ext_counts: dict[str, int] = {}
for r in prompt_payload.all_resources:
if r.kind == "file" and r.path.suffix:
file_ext_counts[r.path.suffix] = (
file_ext_counts.get(r.path.suffix, 0) + 1
)
self.agent_loop.telemetry_client.send_at_mention_inserted(
nb_mentions=len(prompt_payload.all_resources),
context_types=context_types,
file_extensions=file_ext_counts or None,
message_id=message_id,
)
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd()) rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
self._narrator_manager.cancel() self._narrator_manager.cancel()
self._narrator_manager.on_turn_start(rendered_prompt) self._narrator_manager.on_turn_start(rendered_prompt)
async with aclosing(self.agent_loop.act(rendered_prompt)) as events: async with aclosing(
self.agent_loop.act(rendered_prompt, client_message_id=message_id)
) as events:
await self._handle_agent_loop_events(events) await self._handle_agent_loop_events(events)
except asyncio.CancelledError: except asyncio.CancelledError:
await self._handle_turn_error() await self._handle_turn_error()
@ -1566,6 +1669,53 @@ class VibeApp(App): # noqa: PLR0904
async def _show_data_retention(self, **kwargs: Any) -> None: async def _show_data_retention(self, **kwargs: Any) -> None:
await self._mount_and_scroll(UserCommandMessage(DATA_RETENTION_MESSAGE)) await self._mount_and_scroll(UserCommandMessage(DATA_RETENTION_MESSAGE))
async def _rename_local_session(self, title: str) -> str:
session_logger = self.agent_loop.session_logger
if not session_logger.enabled or session_logger.session_metadata is None:
raise ValueError("Session logging is disabled in configuration.")
if (
session_logger.session_dir is not None
and session_logger.metadata_filepath.exists()
):
await update_saved_session_title_at_path(session_logger.session_dir, title)
session_logger.set_title(title)
renamed_title = session_logger.session_metadata.title
assert renamed_title is not None
return renamed_title
async def _rename_session(self, cmd_args: str = "", **kwargs: Any) -> None:
if self._remote_manager.is_active:
await self._mount_and_scroll(
ErrorMessage(
"Renaming is only supported for local sessions.",
collapsed=self._tools_collapsed,
)
)
return
title = cmd_args.strip()
if not title:
await self._mount_and_scroll(
ErrorMessage("Usage: /rename <title>", collapsed=self._tools_collapsed)
)
return
try:
renamed_title = await self._rename_local_session(title)
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
f"Failed to rename session: {e}", collapsed=self._tools_collapsed
)
)
return
await self._mount_and_scroll(
UserCommandMessage(f'Session renamed to "{renamed_title}".')
)
async def _show_session_picker(self, **kwargs: Any) -> None: async def _show_session_picker(self, **kwargs: Any) -> None:
cwd = str(Path.cwd()) cwd = str(Path.cwd())
local_sessions = ( local_sessions = (
@ -1608,7 +1758,8 @@ class VibeApp(App): # noqa: PLR0904
sessions = sorted(raw_sessions, key=lambda s: s.end_time or "", reverse=True) sessions = sorted(raw_sessions, key=lambda s: s.end_time or "", reverse=True)
latest_messages = { latest_messages = {
s.option_id: SessionLoader.get_first_user_message( s.option_id: s.title
or SessionLoader.get_first_user_message(
s.session_id, self.config.session_logging s.session_id, self.config.session_logging
) )
for s in sessions for s in sessions
@ -2598,6 +2749,8 @@ class VibeApp(App): # noqa: PLR0904
def _force_quit(self) -> None: def _force_quit(self) -> None:
if self._agent_task and not self._agent_task.done(): if self._agent_task and not self._agent_task.done():
self._agent_task.cancel() self._agent_task.cancel()
if self._bash_task and not self._bash_task.done():
self._bash_task.cancel()
self._remote_manager.cancel_stream_task() self._remote_manager.cancel_stream_task()
self._log_reader.shutdown() self._log_reader.shutdown()

View file

@ -413,6 +413,10 @@ Markdown {
&.bash-error { &.bash-error {
color: ansi_red; color: ansi_red;
} }
&.bash-interrupted {
color: ansi_yellow;
}
} }
.bash-command { .bash-command {
@ -786,7 +790,7 @@ StatusMessage {
.approval-title { .approval-title {
height: auto; height: auto;
text-style: bold; text-style: bold;
color: ansi_yellow; color: ansi_bright_yellow;
} }
.approval-tool-info-container { .approval-tool-info-container {
@ -808,6 +812,11 @@ StatusMessage {
.approval-option { .approval-option {
height: auto; height: auto;
color: ansi_default; color: ansi_default;
margin-bottom: 1;
&:last-of-type {
margin-bottom: 0;
}
} }
.approval-cursor-selected { .approval-cursor-selected {
@ -817,14 +826,14 @@ StatusMessage {
} }
&.approval-option-no { &.approval-option-no {
color: ansi_red; color: ansi_bright_red;
text-style: bold; text-style: bold;
} }
} }
.approval-option-selected { .approval-option-selected {
&.approval-option-yes { &.approval-option-yes {
color: ansi_green; color: ansi_white;
} }
&.approval-option-no { &.approval-option-no {
@ -839,7 +848,7 @@ StatusMessage {
.approval-description { .approval-description {
height: auto; height: auto;
color: ansi_default; color: ansi_bright_black;
} }
.code-block { .code-block {

View file

@ -20,6 +20,8 @@ class ApprovalApp(Container):
can_focus = True can_focus = True
can_focus_children = False can_focus_children = False
NUM_OPTIONS = 4
BINDINGS: ClassVar[list[BindingType]] = [ BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "move_up", "Up", show=False), Binding("up", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False), Binding("down", "move_down", "Down", show=False),
@ -27,8 +29,9 @@ class ApprovalApp(Container):
Binding("1", "select_1", "Yes", show=False), Binding("1", "select_1", "Yes", show=False),
Binding("y", "select_1", "Yes", show=False), Binding("y", "select_1", "Yes", show=False),
Binding("2", "select_2", "Always Tool Session", show=False), Binding("2", "select_2", "Always Tool Session", show=False),
Binding("3", "select_3", "No", show=False), Binding("3", "select_3", "Always Permanent", show=False),
Binding("n", "select_3", "No", show=False), Binding("4", "select_4", "No", show=False),
Binding("n", "select_4", "No", show=False),
] ]
class ApprovalGranted(Message): class ApprovalGranted(Message):
@ -49,6 +52,18 @@ class ApprovalApp(Container):
self.tool_args = tool_args self.tool_args = tool_args
self.required_permissions = required_permissions self.required_permissions = required_permissions
class ApprovalGrantedAlwaysPermanent(Message):
def __init__(
self,
tool_name: str,
tool_args: BaseModel,
required_permissions: list[RequiredPermission],
) -> None:
super().__init__()
self.tool_name = tool_name
self.tool_args = tool_args
self.required_permissions = required_permissions
class ApprovalRejected(Message): class ApprovalRejected(Message):
def __init__(self, tool_name: str, tool_args: BaseModel) -> None: def __init__(self, tool_name: str, tool_args: BaseModel) -> None:
super().__init__() super().__init__()
@ -77,20 +92,18 @@ class ApprovalApp(Container):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Vertical(id="approval-options"): with Vertical(id="approval-options"):
yield NoMarkupStatic("") yield NoMarkupStatic("")
for _ in range(3): for _ in range(self.NUM_OPTIONS):
widget = NoMarkupStatic("", classes="approval-option") widget = NoMarkupStatic("", classes="approval-option")
self.option_widgets.append(widget) self.option_widgets.append(widget)
yield widget yield widget
yield NoMarkupStatic("")
self.help_widget = NoMarkupStatic( self.help_widget = NoMarkupStatic(
"↑↓ navigate Enter select ESC reject", classes="approval-help" "↑↓ navigate Enter select ESC reject", classes="approval-help"
) )
yield self.help_widget yield self.help_widget
with Vertical(id="approval-content"): with Vertical(id="approval-content"):
self.title_widget = NoMarkupStatic( title = self._build_title()
f"{self.tool_name} command", classes="approval-title" self.title_widget = NoMarkupStatic(title, classes="approval-title")
)
yield self.title_widget yield self.title_widget
with VerticalScroll(classes="approval-tool-info-scroll"): with VerticalScroll(classes="approval-tool-info-scroll"):
@ -99,6 +112,12 @@ class ApprovalApp(Container):
) )
yield self.tool_info_container yield self.tool_info_container
def _build_title(self) -> str:
if self.required_permissions:
labels = ", ".join(rp.label for rp in self.required_permissions)
return f"Permission for the {self.tool_name} tool ({labels})"
return f"Permission for the {self.tool_name} tool"
async def on_mount(self) -> None: async def on_mount(self) -> None:
await self._update_tool_info() await self._update_tool_info()
self._update_options() self._update_options()
@ -113,16 +132,11 @@ class ApprovalApp(Container):
await self.tool_info_container.mount(approval_widget) await self.tool_info_container.mount(approval_widget)
def _update_options(self) -> None: def _update_options(self) -> None:
if self.required_permissions:
labels = ", ".join(rp.label for rp in self.required_permissions)
always_text = f"Yes and always allow for this session: {labels}"
else:
always_text = f"Yes and always allow {self.tool_name} for this session"
options = [ options = [
("Yes", "yes"), ("Allow once", "yes"),
(always_text, "yes"), ("Allow for remainder of this session", "yes"),
("No and tell the agent what to do instead", "no"), ("Always allow", "yes"),
("Deny", "no"),
] ]
for idx, ((text, color_type), widget) in enumerate( for idx, ((text, color_type), widget) in enumerate(
@ -154,11 +168,11 @@ class ApprovalApp(Container):
widget.add_class("approval-option-no") widget.add_class("approval-option-no")
def action_move_up(self) -> None: def action_move_up(self) -> None:
self.selected_option = (self.selected_option - 1) % 3 self.selected_option = (self.selected_option - 1) % self.NUM_OPTIONS
self._update_options() self._update_options()
def action_move_down(self) -> None: def action_move_down(self) -> None:
self.selected_option = (self.selected_option + 1) % 3 self.selected_option = (self.selected_option + 1) % self.NUM_OPTIONS
self._update_options() self._update_options()
def action_select(self) -> None: def action_select(self) -> None:
@ -176,9 +190,13 @@ class ApprovalApp(Container):
self.selected_option = 2 self.selected_option = 2
self._handle_selection(2) self._handle_selection(2)
def action_select_4(self) -> None:
self.selected_option = 3
self._handle_selection(3)
def action_reject(self) -> None: def action_reject(self) -> None:
self.selected_option = 2 self.selected_option = 3
self._handle_selection(2) self._handle_selection(3)
def _handle_selection(self, option: int) -> None: def _handle_selection(self, option: int) -> None:
match option: match option:
@ -197,6 +215,14 @@ class ApprovalApp(Container):
) )
) )
case 2: case 2:
self.post_message(
self.ApprovalGrantedAlwaysPermanent(
tool_name=self.tool_name,
tool_args=self.tool_args,
required_permissions=self.required_permissions,
)
)
case 3:
self.post_message( self.post_message(
self.ApprovalRejected( self.ApprovalRejected(
tool_name=self.tool_name, tool_args=self.tool_args tool_name=self.tool_name, tool_args=self.tool_args

View file

@ -264,23 +264,92 @@ class InterruptMessage(Static):
class BashOutputMessage(Static): class BashOutputMessage(Static):
def __init__(self, command: str, cwd: str, output: str, exit_code: int) -> None: def __init__(
self,
command: str,
cwd: str,
output: str = "",
exit_code: int = 0,
*,
pending: bool = False,
) -> None:
super().__init__() super().__init__()
self.add_class("bash-output-message") self.add_class("bash-output-message")
self._command = command self._command = command
self._cwd = cwd self._cwd = cwd
self._output = output.rstrip("\n") self._output = output.rstrip("\n")
self._exit_code = exit_code self._exit_code = exit_code
self._pending = pending
self._output_widget: NoMarkupStatic | None = None
self._output_container: Horizontal | None = None
self._prompt_widget: NonSelectableStatic | None = None
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
status_class = "bash-success" if self._exit_code == 0 else "bash-error" status_class = (
"bash-error"
if not self._pending and self._exit_code != 0
else "bash-success"
)
self.add_class(status_class) self.add_class(status_class)
with Horizontal(classes="bash-command-line"): with Horizontal(classes="bash-command-line"):
yield NonSelectableStatic("$ ", classes=f"bash-prompt {status_class}") self._prompt_widget = NonSelectableStatic(
"$ ", classes=f"bash-prompt {status_class}"
)
yield self._prompt_widget
yield NoMarkupStatic(self._command, classes="bash-command") yield NoMarkupStatic(self._command, classes="bash-command")
with Horizontal(classes="bash-output-container"): if not self._pending:
yield ExpandingBorder(classes="bash-output-border") self._output_container = Horizontal(classes="bash-output-container")
yield NoMarkupStatic(self._output, classes="bash-output") with self._output_container:
yield ExpandingBorder(classes="bash-output-border")
self._output_widget = NoMarkupStatic(
self._output, classes="bash-output"
)
yield self._output_widget
async def _ensure_output_container(self) -> None:
if self._output_container is not None:
return
self._output_widget = NoMarkupStatic("", classes="bash-output")
self._output_container = Horizontal(
ExpandingBorder(classes="bash-output-border"),
self._output_widget,
classes="bash-output-container",
)
await self.mount(self._output_container)
async def append_output(self, text: str) -> None:
await self._ensure_output_container()
self._output += text
if self._output_widget:
self._output_widget.update(self._output.rstrip("\n"))
async def finish(self, exit_code: int, *, interrupted: bool = False) -> None:
self._exit_code = exit_code
self._pending = False
if interrupted:
self.remove_class("bash-success")
self.add_class("bash-interrupted")
if self._prompt_widget:
self._prompt_widget.remove_class("bash-success")
self._prompt_widget.add_class("bash-interrupted")
elif exit_code != 0:
self.remove_class("bash-success")
self.add_class("bash-error")
if self._prompt_widget:
self._prompt_widget.remove_class("bash-success")
self._prompt_widget.add_class("bash-error")
if interrupted:
suffix = (
"\n(interrupted)"
if self._output and not self._output.endswith("\n")
else "(interrupted)"
)
self._output += suffix
if not self._output:
self._output = "(no output)"
await self._ensure_output_container()
if self._output_widget:
self._output_widget.update(self._output.rstrip("\n"))
class ErrorMessage(Static): class ErrorMessage(Static):

View file

@ -172,6 +172,13 @@ def _is_context_too_long_error(e: Exception) -> bool:
return False return False
def _is_non_retryable_error(e: BaseException) -> bool:
# Detect Temporal-style ``non_retryable`` flag without importing temporalio.
# Wrapping such an exception in a plain RuntimeError strips the flag, so
# Temporal's activity retry policy will retry the call until exhaustion.
return bool(getattr(e, "non_retryable", False))
def requires_init(fn: Callable[..., Any]) -> Callable[..., Any]: def requires_init(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator that awaits deferred initialization before executing the method.""" """Decorator that awaits deferred initialization before executing the method."""
if inspect.isasyncgenfunction(fn): if inspect.isasyncgenfunction(fn):
@ -451,6 +458,10 @@ class AgentLoop:
session_pattern=rp.session_pattern, session_pattern=rp.session_pattern,
) )
) )
if save_permanently:
self.config.add_tool_allowlist_patterns(
tool_name, [rp.session_pattern for rp in required_permissions]
)
else: else:
self.set_tool_permission( self.set_tool_permission(
tool_name, ToolPermission.ALWAYS, save_permanently=save_permanently tool_name, ToolPermission.ALWAYS, save_permanently=save_permanently
@ -1199,6 +1210,8 @@ class AgentLoop:
raise RateLimitError(provider.name, active_model.name) from e raise RateLimitError(provider.name, active_model.name) from e
if _is_context_too_long_error(e): if _is_context_too_long_error(e):
raise ContextTooLongError(provider.name, active_model.name) from e raise ContextTooLongError(provider.name, active_model.name) from e
if _is_non_retryable_error(e):
raise
raise RuntimeError( raise RuntimeError(
f"API error from {provider.name} (model: {active_model.name}): {e}" f"API error from {provider.name} (model: {active_model.name}): {e}"
@ -1275,6 +1288,8 @@ class AgentLoop:
raise RateLimitError(provider.name, active_model.name) from e raise RateLimitError(provider.name, active_model.name) from e
if _is_context_too_long_error(e): if _is_context_too_long_error(e):
raise ContextTooLongError(provider.name, active_model.name) from e raise ContextTooLongError(provider.name, active_model.name) from e
if _is_non_retryable_error(e):
raise
raise RuntimeError( raise RuntimeError(
f"API error from {provider.name} (model: {active_model.name}): {e}" f"API error from {provider.name} (model: {active_model.name}): {e}"
@ -1414,13 +1429,14 @@ class AgentLoop:
i += 1 i += 1
def _reset_session(self) -> None: def _reset_session(self, keep_parent: bool = True) -> None:
old_session_id = self.session_id old_session_id = self.session_id
suffix = extract_suffix(self.session_id) suffix = extract_suffix(self.session_id)
self.session_id = generate_session_id(suffix=suffix) self.session_id = generate_session_id(suffix=suffix)
self.parent_session_id = old_session_id parent_session_id = old_session_id if keep_parent else None
self.parent_session_id = parent_session_id
self.session_logger.reset_session( self.session_logger.reset_session(
self.session_id, parent_session_id=old_session_id self.session_id, parent_session_id=parent_session_id
) )
self.emit_new_session_telemetry() self.emit_new_session_telemetry()
@ -1500,7 +1516,7 @@ class AgentLoop:
self.middleware_pipeline.reset() self.middleware_pipeline.reset()
self.tool_manager.reset_all() self.tool_manager.reset_all()
self._reset_session() self._reset_session(keep_parent=False)
@requires_init @requires_init
async def compact(self, extra_instructions: str = "") -> str: async def compact(self, extra_instructions: str = "") -> str:

View file

@ -9,7 +9,7 @@ from typing import Literal
class PathResource: class PathResource:
path: Path path: Path
alias: str alias: str
kind: Literal["file", "directory"] kind: Literal["file", "folder"]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -17,13 +17,14 @@ class PathPromptPayload:
display_text: str display_text: str
prompt_text: str prompt_text: str
resources: list[PathResource] resources: list[PathResource]
all_resources: list[PathResource]
def build_path_prompt_payload( def build_path_prompt_payload(
message: str, *, base_dir: Path | None = None message: str, *, base_dir: Path | None = None
) -> PathPromptPayload: ) -> PathPromptPayload:
if not message: if not message:
return PathPromptPayload(message, message, []) return PathPromptPayload(message, message, [], [])
resolved_base = (base_dir or Path.cwd()).resolve() resolved_base = (base_dir or Path.cwd()).resolve()
prompt_parts: list[str] = [] prompt_parts: list[str] = []
@ -44,7 +45,7 @@ def build_path_prompt_payload(
prompt_text = "".join(prompt_parts) prompt_text = "".join(prompt_parts)
unique_resources = _dedupe_resources(resources) unique_resources = _dedupe_resources(resources)
return PathPromptPayload(message, prompt_text, unique_resources) return PathPromptPayload(message, prompt_text, unique_resources, resources)
def _is_path_anchor(message: str, pos: int) -> bool: def _is_path_anchor(message: str, pos: int) -> bool:
@ -93,7 +94,7 @@ def _to_resource(candidate: str, base_dir: Path) -> PathResource | None:
if not resolved.exists(): if not resolved.exists():
return None return None
kind = "directory" if resolved.is_dir() else "file" kind = "folder" if resolved.is_dir() else "file"
return PathResource(path=resolved, alias=candidate, kind=kind) return PathResource(path=resolved, alias=candidate, kind=kind)

View file

@ -44,7 +44,7 @@ def _path_prompt_to_content_blocks(
"uri": resource.path.as_uri(), "uri": resource.path.as_uri(),
"name": resource.alias, "name": resource.alias,
}) })
case "directory": case "folder":
blocks.append({ blocks.append({
"type": "resource_link", "type": "resource_link",
"uri": resource.path.as_uri(), "uri": resource.path.as_uri(),

View file

@ -332,8 +332,6 @@ MCPServer = Annotated[
class ConnectorConfig(BaseModel): class ConnectorConfig(BaseModel):
"""Per-connector settings persisted in config.toml under ``[[connectors]]``."""
name: str = Field(description="Normalized connector alias to match against.") name: str = Field(description="Normalized connector alias to match against.")
disabled: bool = Field( disabled: bool = Field(
default=False, default=False,
@ -937,9 +935,21 @@ class VibeConfig(BaseSettings):
] ]
type(self).save_updates({"models": models}) type(self).save_updates({"models": models})
def add_tool_allowlist_patterns(self, tool_name: str, patterns: list[str]) -> None:
current_allowlist: list[str] = list(
self.tools.get(tool_name, {}).get("allowlist", [])
)
new_patterns = [p for p in patterns if p not in current_allowlist]
if not new_patterns:
return
merged = sorted(current_allowlist + new_patterns)
self.save_updates({"tools": {tool_name: {"allowlist": merged}}})
if tool_name not in self.tools:
self.tools[tool_name] = {}
self.tools[tool_name]["allowlist"] = merged
@classmethod @classmethod
def get_persisted_config(cls) -> dict[str, Any]: def get_persisted_config(cls) -> dict[str, Any]:
"""Return the raw config as persisted in the TOML file (no profile merging)."""
return TomlFileSettingsSource(cls).toml_data return TomlFileSettingsSource(cls).toml_data
@classmethod @classmethod

View file

@ -67,8 +67,8 @@ def run_programmatic( # noqa: PLR0913, PLR0917
logger.info( logger.info(
"Loaded %d messages from previous session", len(non_system_messages) "Loaded %d messages from previous session", len(non_system_messages)
) )
else:
agent_loop.emit_new_session_telemetry() agent_loop.emit_new_session_telemetry()
if teleport and config.vibe_code_enabled: if teleport and config.vibe_code_enabled:
gen = agent_loop.teleport_to_vibe_code(prompt or None) gen = agent_loop.teleport_to_vibe_code(prompt or None)

View file

@ -0,0 +1,58 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from vibe.core.config import SessionLoggingConfig
from vibe.core.session.session_loader import METADATA_FILENAME, SessionLoader
from vibe.core.session.session_logger import SessionLogger
from vibe.core.utils.io import read_safe
def _normalize_session_title(title: str) -> str:
normalized_title = title.strip()
if not normalized_title:
raise ValueError("Session title cannot be empty.")
return normalized_title
def _resolve_saved_session_dir(
session_id: str, session_config: SessionLoggingConfig
) -> Path:
for session_dir in SessionLoader._find_session_dirs_by_short_id(
session_id, session_config
):
try:
metadata = _load_raw_metadata(session_dir)
except (OSError, json.JSONDecodeError):
continue
if metadata.get("session_id") == session_id:
return session_dir
raise ValueError(f"Session not found: {session_id}")
def _load_raw_metadata(session_dir: Path) -> dict[str, Any]:
metadata_path = session_dir / METADATA_FILENAME
return json.loads(read_safe(metadata_path).text)
async def update_saved_session_title_at_path(
session_dir: Path, title: str
) -> dict[str, Any]:
normalized_title = _normalize_session_title(title)
metadata = _load_raw_metadata(session_dir)
updated_metadata = {**metadata, "title": normalized_title, "title_source": "manual"}
await SessionLogger.persist_metadata(updated_metadata, session_dir)
return updated_metadata
async def update_saved_session_title(
session_id: str, title: str, session_config: SessionLoggingConfig
) -> dict[str, Any]:
session_dir = _resolve_saved_session_dir(session_id, session_config)
return await update_saved_session_title_at_path(session_dir, title)

View file

@ -8,7 +8,7 @@ import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
from threading import Lock from threading import Lock
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, Literal
from anyio import NamedTemporaryFile, Path as AsyncPath from anyio import NamedTemporaryFile, Path as AsyncPath
@ -134,6 +134,8 @@ class SessionLogger:
git_branch=git_branch, git_branch=git_branch,
username=user_name, username=user_name,
environment={"working_directory": str(Path.cwd())}, environment={"working_directory": str(Path.cwd())},
title=None,
title_source="auto",
) )
def _get_title(self, messages: Sequence[LLMMessage]) -> str: def _get_title(self, messages: Sequence[LLMMessage]) -> str:
@ -154,6 +156,37 @@ class SessionLogger:
return title return title
def _set_title_state(
self, title: str | None, *, source: Literal["auto", "manual"]
) -> None:
if self.session_metadata is None:
return
self.session_metadata.title = title
self.session_metadata.title_source = source
def set_title(self, title: str | None) -> None:
if title is None:
self._set_title_state(None, source="auto")
return
normalized_title = title.strip()
if not normalized_title:
raise ValueError("Session title cannot be empty.")
self._set_title_state(normalized_title, source="manual")
def _resolve_title(self, messages: Sequence[LLMMessage]) -> str | None:
if self.session_metadata is None:
return self._get_title(messages)
if self.session_metadata.title_source == "manual":
return self.session_metadata.title
title = self._get_title(messages)
self._set_title_state(title, source="auto")
return title
@staticmethod @staticmethod
async def persist_metadata(metadata: Any, session_dir: Path) -> None: async def persist_metadata(metadata: Any, session_dir: Path) -> None:
temp_metadata_filepath = None temp_metadata_filepath = None
@ -265,7 +298,7 @@ class SessionLogger:
for tool_class in tool_manager.available_tools.values() for tool_class in tool_manager.available_tools.values()
] ]
title = self._get_title(messages) title = self._resolve_title(messages)
system_prompt = ( system_prompt = (
messages[0].model_dump() messages[0].model_dump()
if len(messages) > 0 and messages[0].role == Role.system if len(messages) > 0 and messages[0].role == Role.system

View file

@ -300,6 +300,22 @@ class TelemetryClient:
payload = {"init_duration_ms": init_duration_ms} payload = {"init_duration_ms": init_duration_ms}
self.send_telemetry_event("vibe.ready", payload) self.send_telemetry_event("vibe.ready", payload)
def send_at_mention_inserted(
self,
*,
nb_mentions: int,
context_types: dict[str, int],
file_extensions: dict[str, int] | None,
message_id: str | None,
) -> None:
payload: dict[str, Any] = {
"nb_mentions": nb_mentions,
"context_types": context_types,
"file_extensions": file_extensions,
"message_id": message_id,
}
self.send_telemetry_event("vibe.at_mention_inserted", payload)
def send_user_rating_feedback(self, rating: int, model: str) -> None: def send_user_rating_feedback(self, rating: int, model: str) -> None:
self.send_telemetry_event( self.send_telemetry_event(
"vibe.user_rating_feedback", "vibe.user_rating_feedback",

View file

@ -7,9 +7,6 @@ from typing import TYPE_CHECKING, Any, ClassVar
import httpx import httpx
from mistralai.client import Mistral from mistralai.client import Mistral
from mistralai.client.models.connectorsqueryfilters import (
ConnectorsQueryFiltersTypedDict,
)
from vibe.core.logger import logger from vibe.core.logger import logger
from vibe.core.tools.base import ( from vibe.core.tools.base import (
@ -33,9 +30,7 @@ from vibe.core.utils import run_sync
if TYPE_CHECKING: if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent from vibe.core.types import ToolResultEvent
_LIST_PAGE_SIZE = 100 _BOOTSTRAP_TIMEOUT = 30.0
_LIST_QUERY_FILTERS: ConnectorsQueryFiltersTypedDict = {"active": True}
_TOOL_FETCH_TIMEOUT = 8.0
def _normalize_name(name: str) -> str: def _normalize_name(name: str) -> str:
@ -44,25 +39,15 @@ def _normalize_name(name: str) -> str:
return result or "unnamed" return result or "unnamed"
def _connector_tool_to_remote(tool: dict[str, Any] | Any) -> RemoteTool | None: def _connector_tool_to_remote(tool: dict[str, Any]) -> RemoteTool | None:
"""Convert a ConnectorTool (SDK object or raw dict) to a RemoteTool.""" """Convert a bootstrap tool dict to a RemoteTool."""
if isinstance(tool, dict): name = tool.get("name")
name = tool.get("name")
description = tool.get("description")
schema = tool.get("jsonschema") or tool.get("json_schema")
else:
name = getattr(tool, "name", None)
description = getattr(tool, "description", None)
schema = getattr(tool, "jsonschema", None) or getattr(tool, "json_schema", None)
if not name: if not name:
return None return None
if schema is not None and not isinstance(schema, dict):
dump = getattr(schema, "model_dump", None)
schema = dump() if callable(dump) else None
return RemoteTool.model_validate({ return RemoteTool.model_validate({
"name": name, "name": name,
"description": description, "description": tool.get("description"),
"inputSchema": schema or {"type": "object", "properties": {}}, "inputSchema": tool.get("inputSchema") or {"type": "object", "properties": {}},
}) })
@ -208,6 +193,36 @@ def create_connector_proxy_tool_class(
return ConnectorProxyTool return ConnectorProxyTool
def _deduplicate_connectors(
connectors: list[dict[str, Any]],
) -> list[tuple[str, str, dict[str, Any]]]:
"""Deduplicate connectors by normalized alias, preserving order.
When two connectors share the same alias, disambiguate with a
numeric suffix rather than silently dropping.
"""
seen_names: set[str] = set()
result: list[tuple[str, str, dict[str, Any]]] = []
for connector in connectors:
connector_id = str(connector.get("id", ""))
if not connector_id:
continue
raw_name = connector.get("name") or connector_id
alias = _normalize_name(raw_name)
if alias in seen_names:
original = alias
suffix = 2
while f"{original}_{suffix}" in seen_names:
suffix += 1
alias = f"{original}_{suffix}"
logger.warning(
"Connector %r alias %r collides, using %r", raw_name, original, alias
)
seen_names.add(alias)
result.append((connector_id, alias, connector))
return result
class ConnectorRegistry: class ConnectorRegistry:
"""Discovers connector tools from the Mistral API. """Discovers connector tools from the Mistral API.
@ -240,6 +255,47 @@ class ConnectorRegistry:
return await self._discover_all() return await self._discover_all()
async def _fetch_bootstrap(self) -> dict[str, Any]:
base_url = self._server_url or _DEFAULT_BASE_URL
url = f"{base_url}/v1/connectors/bootstrap"
headers = {"Authorization": f"Bearer {self._api_key}"}
async with httpx.AsyncClient(timeout=_BOOTSTRAP_TIMEOUT) as http:
response = await http.get(url, headers=headers)
response.raise_for_status()
return response.json()
def _build_tools_for_connector(
self,
*,
connector_id: str,
alias: str,
name: str,
raw_tools: list[dict[str, Any]],
) -> dict[str, type[BaseTool]]:
tools_map: dict[str, type[BaseTool]] = {}
for tool in raw_tools:
remote = _connector_tool_to_remote(tool)
if remote is None:
continue
try:
proxy_cls = create_connector_proxy_tool_class(
connector_name=name,
connector_alias=alias,
connector_id=connector_id,
remote=remote,
api_key=self._api_key,
server_url=self._server_url,
)
tools_map[proxy_cls.get_name()] = proxy_cls
except Exception:
logger.warning(
"Failed to register connector tool %s for %s",
tool.get("name", "<unknown>"),
name,
exc_info=True,
)
return tools_map
async def _discover_all(self) -> dict[str, type[BaseTool]]: async def _discover_all(self) -> dict[str, type[BaseTool]]:
async with self._discover_lock: async with self._discover_lock:
# Re-check under lock — another coroutine may have finished # Re-check under lock — another coroutine may have finished
@ -250,157 +306,55 @@ class ConnectorRegistry:
result.update(tools) result.update(tools)
return result return result
async with self._get_client() as client: try:
connectors: list[Any] = [] data = await self._fetch_bootstrap()
except Exception:
logger.warning("Failed to bootstrap connectors", exc_info=True)
self._cache = {}
self._connector_names = []
self._connector_connected = {}
self._alias_to_id = {}
return {}
try: unique_connectors = _deduplicate_connectors(data.get("connectors") or [])
page = await client.beta.connectors.list_async(
page_size=_LIST_PAGE_SIZE, query_filters=_LIST_QUERY_FILTERS cache: dict[str, dict[str, type[BaseTool]]] = {}
all_tools: dict[str, type[BaseTool]] = {}
connector_names: list[str] = []
connector_connected: dict[str, bool] = {}
for connector_id, alias, connector in unique_connectors:
connector_names.append(alias)
name = connector.get("name") or connector_id
if bootstrap_errors := connector.get("bootstrap_errors"):
logger.warning(
"Connector %r bootstrap errors: %s", name, bootstrap_errors
) )
connectors.extend(page.items or [])
while page.pagination and page.pagination.next_cursor:
page = await client.beta.connectors.list_async(
page_size=_LIST_PAGE_SIZE,
cursor=page.pagination.next_cursor,
query_filters=_LIST_QUERY_FILTERS,
)
connectors.extend(page.items or [])
except Exception as exc:
logger.warning(f"Failed to list connectors: {exc}")
self._cache = {}
self._connector_names = []
self._connector_connected = {}
self._alias_to_id = {}
return {}
# Build results locally to avoid publishing incomplete state. status = connector.get("status") or {}
cache: dict[str, dict[str, type[BaseTool]]] = {} if not status.get("is_ready", False):
all_tools: dict[str, type[BaseTool]] = {} connector_connected[alias] = False
connector_names: list[str] = [] continue
connector_connected: dict[str, bool] = {}
# Deduplicate by normalized name, preserving order. tools_map = self._build_tools_for_connector(
# When two connectors share the same alias, disambiguate
# with a numeric suffix rather than silently dropping.
seen_names: set[str] = set()
unique_connectors: list[tuple[str, str, Any]] = []
for connector in connectors:
connector_id = str(connector.id)
raw_name = connector.name or connector_id
alias = _normalize_name(raw_name)
if alias in seen_names:
original = alias
suffix = 2
while f"{alias}_{suffix}" in seen_names:
suffix += 1
alias = f"{alias}_{suffix}"
logger.warning(
f"Connector {raw_name!r} alias {original!r} collides, "
f"using {alias!r}"
)
seen_names.add(alias)
unique_connectors.append((connector_id, alias, connector))
# Fetch tools for all connectors in parallel.
tasks = [
self._fetch_connector_tools(client, c, alias)
for _, alias, c in unique_connectors
]
results = await asyncio.gather(*tasks)
for (connector_id, alias, _), tools_map in zip(
unique_connectors, results, strict=True
):
connector_names.append(alias)
if tools_map is None:
# Timeout or error — show in UI but don't cache so
# a refresh will retry discovery for this connector.
connector_connected[alias] = False
continue
cache[connector_id] = tools_map
all_tools.update(tools_map)
# TODO: replace with actual API field when available
connector_connected[alias] = bool(tools_map)
# Publish atomically — concurrent callers waiting on the
# lock will see the completed cache.
self._connector_names = connector_names
self._connector_connected = connector_connected
self._alias_to_id = {alias: cid for cid, alias, _ in unique_connectors}
self._cache = cache
return all_tools
async def _fetch_connector_tools(
self, client: Mistral, connector: Any, connector_alias: str
) -> dict[str, type[BaseTool]] | None:
"""Fetch tools for a single connector via ``list_tools_async``.
The list endpoint does not always include tools inline, so we
call ``list_tools_async`` per connector to get the full set.
"""
connector_id = str(connector.id)
name = connector.name or connector_id
try:
response = await asyncio.wait_for(
client.beta.connectors.list_tools_async(
connector_id_or_name=connector_id
),
timeout=_TOOL_FETCH_TIMEOUT,
)
except TimeoutError:
logger.warning(
f"Timeout fetching tools for connector {name} (>{_TOOL_FETCH_TIMEOUT}s)"
)
return None
except Exception as exc:
logger.warning(f"Failed to list tools for connector {name}: {exc}")
return None
# The SDK may return a plain list, an iterable wrapper, or an
# object with a `data`/`items` attribute. Handle all cases.
if isinstance(response, list):
raw_tools: list[Any] = response
elif hasattr(response, "data"):
raw_tools = list(response.data or [])
elif hasattr(response, "items"):
raw_tools = list(response.items or [])
else:
try:
raw_tools = list(response)
except TypeError:
logger.warning(
f"Unexpected response type from list_tools_async for {name}: "
f"{type(response).__name__}"
)
raw_tools = []
result: dict[str, type[BaseTool]] = {}
for tool in raw_tools:
remote = _connector_tool_to_remote(tool)
if remote is None:
continue
try:
proxy_cls = create_connector_proxy_tool_class(
connector_name=name,
connector_alias=connector_alias,
connector_id=connector_id, connector_id=connector_id,
remote=remote, alias=alias,
api_key=self._api_key, name=name,
server_url=self._server_url, raw_tools=connector.get("tools") or [],
) )
result[proxy_cls.get_name()] = proxy_cls cache[connector_id] = tools_map
except Exception as exc: all_tools.update(tools_map)
tool_name = ( connector_connected[alias] = bool(tools_map)
tool.get("name")
if isinstance(tool, dict) # Publish atomically — concurrent callers waiting on the
else getattr(tool, "name", "<unknown>") # lock will see the completed cache.
) self._connector_names = connector_names
logger.warning( self._connector_connected = connector_connected
f"Failed to register connector tool {tool_name} for {name}: {exc}" self._alias_to_id = {alias: cid for cid, alias, _ in unique_connectors}
) self._cache = cache
return result
return all_tools
@property @property
def connector_count(self) -> int: def connector_count(self) -> int:
@ -421,6 +375,7 @@ class ConnectorRegistry:
async def refresh_connector_async(self, alias: str) -> dict[str, type[BaseTool]]: async def refresh_connector_async(self, alias: str) -> dict[str, type[BaseTool]]:
"""Re-fetch tools for a single connector by alias. """Re-fetch tools for a single connector by alias.
Calls the bootstrap endpoint and extracts the matching connector.
Updates the internal cache for that connector only. Returns Updates the internal cache for that connector only. Returns
the new tool map (empty dict on failure). the new tool map (empty dict on failure).
""" """
@ -428,15 +383,27 @@ class ConnectorRegistry:
if connector_id is None: if connector_id is None:
return {} return {}
tools_map: dict[str, type[BaseTool]] | None = None
try: try:
async with self._get_client() as client: data = await self._fetch_bootstrap()
connector = await client.beta.connectors.get_async( for connector in data.get("connectors") or []:
connector_id_or_name=connector_id if str(connector.get("id")) != connector_id:
continue
name = connector.get("name") or connector_id
status = connector.get("status") or {}
if not status.get("is_ready", False):
break
tools_map = self._build_tools_for_connector(
connector_id=connector_id,
alias=alias,
name=name,
raw_tools=connector.get("tools") or [],
) )
tools_map = await self._fetch_connector_tools(client, connector, alias) break
except Exception: except Exception:
logger.debug("Failed to refresh connector %s", alias) logger.debug("Failed to refresh connector %s", alias)
tools_map = None
if self._cache is None: if self._cache is None:
self._cache = {} self._cache = {}

View file

@ -142,6 +142,8 @@ class SessionMetadata(BaseModel):
git_branch: str | None git_branch: str | None
environment: dict[str, str | None] environment: dict[str, str | None]
username: str username: str
title: str | None = None
title_source: Literal["auto", "manual"] = "auto"
StrToolChoice = Literal["auto", "none", "any", "required"] StrToolChoice = Literal["auto", "none", "any", "required"]

View file

@ -1,12 +1,5 @@
# What's new in v2.9.1 # What's new in v2.9.4
- **Default model**: Migrated to `mistral-medium-3.5`. - **`/rename` command**: Rename the current session from the slash menu.
- **Connector OAuth**: Authenticate connectors via OAuth directly from the `/mcp` menu. - **Persistent "always allow"**: Tool permissions granted with "always allow" now stick across sessions.
- **Faster bash bang commands**: `!command` runs via async subprocess.
# What's new in v2.9.0
- **Scratchpad directory**: Model's autonomous workspace for temporary files, intermediate results, and drafts shared with subagents
- **`/copy` command**: New slash command to copy content directly from the conversation
- **`--trust` flag**: Grant session-only tool permissions
- **MCP toggle**: Enable/disable MCP servers and individual tools from the `/mcp` menu
- **Custom compaction**: Provide custom instructions when compacting context via `/compact`
- **Thinking level picker**: Choose thinking level with `/thinking`