Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
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-07 13:55:56 +02:00 committed by GitHub
parent 4972dd5694
commit b23f49e5f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 2342 additions and 240 deletions

3
.gitignore vendored
View file

@ -203,3 +203,6 @@ tests/playground/*
# Profiler HTML/TXT reports (generated by vibe.cli.profiler) # Profiler HTML/TXT reports (generated by vibe.cli.profiler)
*-profile.html *-profile.html
*-profile.txt *-profile.txt
# Snapshot test report (generated by pytest-textual-snapshot)
snapshot_report.html

View file

@ -66,7 +66,7 @@ Always go through `uv` — never invoke bare `python` or `pip`.
## Logging & errors ## Logging & errors
- Use `from vibe.core.logger import logger` — stdlib `logging` with `StructuredLogFormatter`, not `structlog`. - Use `from vibe.core.logger import logger` — stdlib `logging` with `StructuredLogFormatter`, not `structlog`.
- Configure via env: `LOG_LEVEL` (default `WARNING`), `DEBUG_MODE`, `LOG_MAX_BYTES`. Logs land in `~/.vibe/logs/vibe.log`. - Configure via env: `LOG_LEVEL` (default `WARNING`), `LOG_MAX_BYTES`. Logs land in `~/.vibe/logs/vibe.log`.
- Pass variables as keyword args, not interpolated into the message: prefer `logger.error("Failed to fetch", url=url)` over `logger.error(f"Failed to fetch {url}")`. - Pass variables as keyword args, not interpolated into the message: prefer `logger.error("Failed to fetch", url=url)` over `logger.error(f"Failed to fetch {url}")`.
- Define module-local exception hierarchies. Always chain with `raise NewError(...) from e`. Rich exceptions expose a `_fmt()` helper for human-readable output. - Define module-local exception hierarchies. Always chain with `raise NewError(...) from e`. Rich exceptions expose a `_fmt()` helper for human-readable output.

View file

@ -5,6 +5,31 @@ 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.5] - 2026-05-06
### Added
- `/loop` command to run a prompt or slash command on a recurring interval
- `default_agent` config setting
- Telemetry instrumentation for the `teleport` command
- Logging environment variables documented in `--help`
### Changed
- `enable_telemetry` now takes precedence over `enable_otel`
### Fixed
- Non-retryable errors raised by sub-activities now correctly stop the retry when the agent loop runs inside a Temporal activity (deepens the 2.9.4 fix to walk the exception cause chain)
- Compacted session IDs displayed correctly
- Reload the history file before writing so parallel instances don't clobber each other
- `read_file` flagged as truncated when the limit is reached
- CLI loader left-alignment
- Default scroll sensitivity in the TUI restored
- Loosened the "no git commit" constraint
- Ensure `enable_telemetry` takes precedence over `enable_otel`
## [2.9.4] - 2026-05-05 ## [2.9.4] - 2026-05-05
### Added ### Added

View file

@ -80,7 +80,7 @@ Logs are written to `~/.vibe/logs/vibe.log` by default. Control logging via envi
|----------|-------------|---------| |----------|-------------|---------|
| `LOG_LEVEL` | Logging level (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) | `WARNING` | | `LOG_LEVEL` | Logging level (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) | `WARNING` |
| `LOG_MAX_BYTES` | Max log file size in bytes before rotation | `10485760` (10 MB) | | `LOG_MAX_BYTES` | Max log file size in bytes before rotation | `10485760` (10 MB) |
| `DEBUG_MODE` | When `true`, forces `DEBUG` level | - | | `DEBUG_MODE` | When `true`, forces `DEBUG` logging (and attaches `debugpy` on `localhost:5678` under `vibe-acp`) | - |
Example: Example:

View file

@ -123,6 +123,22 @@ Use the `--agent` flag to select a different agent:
vibe --agent plan vibe --agent plan
``` ```
To change the default agent used when `--agent` is not passed, set
`default_agent` in your `config.toml`:
```toml
default_agent = "plan"
```
Valid values are `default`, `plan`, `accept-edits`, `auto-approve`,
`lean` (only when listed in `installed_agents`), or the name of any
custom agent file in `~/.vibe/agents/` or the project's `.vibe/agents/`
directory. Subagents such as `explore` are not accepted.
> Note: `default_agent` only applies to interactive sessions. In
> programmatic mode (`-p` / `--prompt`), Vibe falls back to `auto-approve`
> when `--agent` is not provided, so `default_agent` is ignored.
### Subagents and Task Delegation ### Subagents and Task Delegation
Vibe supports subagents for delegating tasks. Subagents run independently and can perform specialized work without user interaction, preventing the context from being overloaded. Vibe supports subagents for delegating tasks. Subagents run independently and can perform specialized work without user interaction, preventing the context from being overloaded.

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.4" version = "2.9.5"
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,21 +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.4/vibe-acp-darwin-aarch64-2.9.4.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.5/vibe-acp-darwin-aarch64-2.9.5.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.4/vibe-acp-darwin-x86_64-2.9.4.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.5/vibe-acp-darwin-x86_64-2.9.5.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.4/vibe-acp-linux-aarch64-2.9.4.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.5/vibe-acp-linux-aarch64-2.9.5.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.4/vibe-acp-linux-x86_64-2.9.4.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.5/vibe-acp-linux-x86_64-2.9.5.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[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.4/vibe-acp-windows-x86_64-2.9.4.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.5/vibe-acp-windows-x86_64-2.9.5.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.4" version = "2.9.5"
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"

View file

@ -101,6 +101,11 @@ def _build_env(vibe_home_dir: Path, *, include_api_key: bool) -> dict[str, str]:
env["PYTHONUNBUFFERED"] = "1" env["PYTHONUNBUFFERED"] = "1"
env["VIBE_HOME"] = str(vibe_home_dir) env["VIBE_HOME"] = str(vibe_home_dir)
vibe_home_dir.mkdir(parents=True, exist_ok=True)
config_file = vibe_home_dir / "config.toml"
if not config_file.exists():
config_file.write_text("enable_telemetry = false\n")
if include_api_key: if include_api_key:
env["MISTRAL_API_KEY"] = "mock" env["MISTRAL_API_KEY"] = "mock"
else: else:

View file

@ -14,7 +14,7 @@ from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
class TestCloseSession: class TestCloseSession:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_close_session_removes_session_and_closes_resources( async def test_close_session_removes_session_and_closes_resources(
self, acp_agent_loop: VibeAcpAgentLoop self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict[str, Any]]
) -> None: ) -> None:
session_response = await acp_agent_loop.new_session(cwd=".", mcp_servers=[]) session_response = await acp_agent_loop.new_session(cwd=".", mcp_servers=[])
session = acp_agent_loop.sessions[session_response.session_id] session = acp_agent_loop.sessions[session_response.session_id]
@ -30,6 +30,17 @@ class TestCloseSession:
assert session_response.session_id not in acp_agent_loop.sessions assert session_response.session_id not in acp_agent_loop.sessions
backend_close.assert_awaited_once() backend_close.assert_awaited_once()
telemetry_close.assert_awaited_once() telemetry_close.assert_awaited_once()
session_closed_events = [
event
for event in telemetry_events
if event["event_name"] == "vibe.session_closed"
]
assert len(session_closed_events) == 1
assert (
session_closed_events[0]["properties"]["session_id"]
== session_response.session_id
)
assert session_closed_events[0]["properties"]["agent_entrypoint"] == "acp"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_close_session_cancels_active_prompt( async def test_close_session_cancels_active_prompt(
@ -69,6 +80,23 @@ class TestCloseSession:
assert bg_task.cancelled() assert bg_task.cancelled()
@pytest.mark.asyncio
async def test_emit_session_closed_for_active_sessions(
self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict[str, Any]]
) -> None:
session1 = await acp_agent_loop.new_session(cwd=".", mcp_servers=[])
session2 = await acp_agent_loop.new_session(cwd=".", mcp_servers=[])
await acp_agent_loop.emit_session_closed_for_active_sessions()
session_closed_events = [
e for e in telemetry_events if e["event_name"] == "vibe.session_closed"
]
emitted_ids = {e["properties"]["session_id"] for e in session_closed_events}
assert len(session_closed_events) == 2
assert session1.session_id in emitted_ids
assert session2.session_id in emitted_ids
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_close_session_rejects_new_background_tasks( async def test_close_session_rejects_new_background_tasks(
self, acp_agent_loop: VibeAcpAgentLoop self, acp_agent_loop: VibeAcpAgentLoop

View file

@ -12,6 +12,7 @@ from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_client import FakeClient from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agent_loop import AgentLoop from vibe.core.agent_loop import AgentLoop
from vibe.core.session.session_id import shorten_session_id
@pytest.fixture @pytest.fixture
@ -79,3 +80,10 @@ class TestCompactEventHandling:
assert compact_end.status == "completed" assert compact_end.status == "completed"
assert compact_start.tool_call_id == compact_end.tool_call_id assert compact_start.tool_call_id == compact_end.tool_call_id
assert compact_end.content is not None
compact_end_text = compact_end.content[0].content
assert isinstance(compact_end_text, TextContentBlock)
assert shorten_session_id(session_response.session_id) in compact_end_text.text
assert (
shorten_session_id(session.agent_loop.session_id) in compact_end_text.text
)

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.4" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.5"
) )
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.4" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.5"
) )
assert response.auth_methods is not None assert response.auth_methods is not None

View file

@ -92,20 +92,26 @@ class TestMultiSessionCore:
) )
assert user_message1 is not None assert user_message1 is not None
assert user_message1.content == "Prompt for session 1" assert user_message1.content == "Prompt for session 1"
assistant_message1 = next(
(msg for msg in session1.agent_loop.messages if msg.role == Role.assistant),
None,
)
assert assistant_message1 is not None
assert assistant_message1.content == "Response 1"
user_message2 = next( user_message2 = next(
(msg for msg in session2.agent_loop.messages if msg.role == Role.user), None (msg for msg in session2.agent_loop.messages if msg.role == Role.user), None
) )
assert user_message2 is not None assert user_message2 is not None
assert user_message2.content == "Prompt for session 2" assert user_message2.content == "Prompt for session 2"
# Backend stream order is non-deterministic under asyncio.gather, so
# assert that both sessions received distinct responses from the
# expected set rather than pinning a specific assignment.
assistant_message1 = next(
(msg for msg in session1.agent_loop.messages if msg.role == Role.assistant),
None,
)
assistant_message2 = next( assistant_message2 = next(
(msg for msg in session2.agent_loop.messages if msg.role == Role.assistant), (msg for msg in session2.agent_loop.messages if msg.role == Role.assistant),
None, None,
) )
assert assistant_message1 is not None
assert assistant_message2 is not None assert assistant_message2 is not None
assert assistant_message2.content == "Response 2" assert {assistant_message1.content, assistant_message2.content} == {
"Response 1",
"Response 2",
}

View file

@ -199,6 +199,28 @@ def test_callable_entries_updates_completions_dynamically() -> None:
assert suggestions[0].description == "Summarize the conversation" assert suggestions[0].description == "Summarize the conversation"
def test_tab_on_slash_command_with_args_replaces_only_head() -> None:
controller, view = make_controller()
text = "/compact some args"
controller.on_text_changed(text, cursor_index=len(text))
result = controller.on_key(key_event("tab"), text=text, cursor_index=len(text))
assert result is CompletionResult.HANDLED
assert view.replacements == [Replacement(0, 8, "/compact")]
def test_enter_on_slash_command_with_args_submits_with_head_only_replacement() -> None:
controller, view = make_controller()
text = "/compact some args"
controller.on_text_changed(text, cursor_index=len(text))
result = controller.on_key(key_event("enter"), text=text, cursor_index=len(text))
assert result is CompletionResult.SUBMIT
assert view.replacements == [Replacement(0, 8, "/compact")]
def test_callable_entries_reflects_enabled_disabled_skills() -> None: def test_callable_entries_reflects_enabled_disabled_skills() -> None:
"""Test that skill enable/disable changes are reflected in completions. """Test that skill enable/disable changes are reflected in completions.

View file

@ -42,7 +42,7 @@ async def test_popup_hides_when_input_cleared(vibe_app: VibeApp) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pressing_tab_writes_selected_command_and_hides_popup( async def test_pressing_tab_writes_selected_command_and_leaves_popup_visible(
vibe_app: VibeApp, vibe_app: VibeApp,
) -> None: ) -> None:
async with vibe_app.run_test() as pilot: async with vibe_app.run_test() as pilot:
@ -53,7 +53,7 @@ async def test_pressing_tab_writes_selected_command_and_hides_popup(
await pilot.press("tab") await pilot.press("tab")
assert chat_input.value == "/config" assert chat_input.value == "/config"
assert popup.styles.display == "none" assert popup.styles.display == "block"
def ensure_selected_command(popup: CompletionPopup, expected_alias: str) -> None: def ensure_selected_command(popup: CompletionPopup, expected_alias: str) -> None:

View file

@ -133,3 +133,13 @@ class TestCommandRegistry:
assert result is not None assert result is not None
_, cmd, _ = result _, cmd, _ = result
assert cmd.handler == "_show_data_retention" assert cmd.handler == "_show_data_retention"
def test_loop_command_registration(self) -> None:
registry = CommandRegistry()
assert registry.get_command_name("/loop") == "loop"
result = registry.parse_command("/loop 30s ping")
assert result is not None
cmd_name, cmd, cmd_args = result
assert cmd_name == "loop"
assert cmd.handler == "_loop_command"
assert cmd_args == "30s ping"

View file

@ -0,0 +1,28 @@
from __future__ import annotations
from unittest.mock import MagicMock
from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.core.session.session_id import shorten_session_id
class TestCompactMessage:
def test_get_content_includes_session_ids_after_compaction(self) -> None:
message = CompactMessage()
message.post_message = MagicMock()
message.set_complete(
old_tokens=177_017,
new_tokens=23_263,
old_session_id="11111111-1111-1111-1111-111111111111",
new_session_id="22222222-2222-2222-2222-222222222222",
)
assert message.get_content() == (
"Compaction complete: 177,017 → 23,263 tokens (-87.%)\n"
"session: "
f"{shorten_session_id('11111111-1111-1111-1111-111111111111')} "
"(before compaction) → "
f"{shorten_session_id('22222222-2222-2222-2222-222222222222')} "
"(after compaction)"
)

View file

@ -0,0 +1,55 @@
from __future__ import annotations
import argparse
from vibe.cli.cli import get_initial_agent_name
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import VibeConfig
def _make_args(*, agent: str | None, prompt: str | None) -> argparse.Namespace:
return argparse.Namespace(agent=agent, prompt=prompt)
def test_uses_args_agent_when_provided() -> None:
config = VibeConfig.model_construct(default_agent=BuiltinAgentName.PLAN)
args = _make_args(agent="accept-edits", prompt=None)
assert get_initial_agent_name(args, config) == "accept-edits"
def test_falls_back_to_config_default_agent_when_args_agent_is_none() -> None:
config = VibeConfig.model_construct(default_agent=BuiltinAgentName.PLAN)
args = _make_args(agent=None, prompt=None)
assert get_initial_agent_name(args, config) == BuiltinAgentName.PLAN
def test_defaults_to_default_when_unset_in_config_and_args() -> None:
config = VibeConfig.model_construct()
args = _make_args(agent=None, prompt=None)
assert get_initial_agent_name(args, config) == BuiltinAgentName.DEFAULT
def test_programmatic_mode_promotes_default_to_auto_approve() -> None:
config = VibeConfig.model_construct(default_agent=BuiltinAgentName.DEFAULT)
args = _make_args(agent=None, prompt="hello")
assert get_initial_agent_name(args, config) == BuiltinAgentName.AUTO_APPROVE
def test_programmatic_mode_ignores_config_default_agent_when_args_agent_is_none() -> (
None
):
config = VibeConfig.model_construct(default_agent=BuiltinAgentName.PLAN)
args = _make_args(agent=None, prompt="hello")
assert get_initial_agent_name(args, config) == BuiltinAgentName.AUTO_APPROVE
def test_programmatic_mode_keeps_explicit_agent_arg() -> None:
config = VibeConfig.model_construct(default_agent=BuiltinAgentName.DEFAULT)
args = _make_args(agent="accept-edits", prompt="hello")
assert get_initial_agent_name(args, config) == "accept-edits"

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import time import time
from typing import Any
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
@ -10,7 +11,7 @@ from tests.conftest import build_test_vibe_app, build_test_vibe_config
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.types import Backend from vibe.core.types import Backend, LLMMessage, Role
def _chat_plan_gateway(*, prompt_switching_to_pro_plan: bool) -> FakeWhoAmIGateway: def _chat_plan_gateway(*, prompt_switching_to_pro_plan: bool) -> FakeWhoAmIGateway:
@ -36,6 +37,16 @@ async def _wait_until(pause, predicate, timeout: float = 2.0) -> None:
raise AssertionError("Condition was not met within the timeout") raise AssertionError("Condition was not met within the timeout")
def _teleport_failed_events(
telemetry_events: list[dict[str, Any]],
) -> list[dict[str, Any]]:
return [
event
for event in telemetry_events
if event["event_name"] == "vibe.teleport_failed"
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_teleport_command_visible_for_paid_chat_users() -> None: async def test_teleport_command_visible_for_paid_chat_users() -> None:
app = build_test_vibe_app( app = build_test_vibe_app(
@ -56,6 +67,81 @@ async def test_teleport_command_visible_for_paid_chat_users() -> None:
assert "&" in input_widget.mode_characters assert "&" in input_widget.mode_characters
@pytest.mark.asyncio
async def test_teleport_command_without_history_sends_early_failure_telemetry(
telemetry_events: list[dict[str, Any]],
) -> None:
app = build_test_vibe_app(
config=_vibe_code_enabled_config(),
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=False),
)
async with app.run_test() as pilot:
await _wait_until(
pilot.pause,
lambda: app.commands.get_command_name("/teleport") == "teleport",
)
await app.on_chat_input_container_submitted(
ChatInputContainer.Submitted("/teleport")
)
assert _teleport_failed_events(telemetry_events) == [
{
"event_name": "vibe.teleport_failed",
"properties": {
"stage": "no_history",
"error_class": "TeleportNoHistoryError",
"push_required": False,
"github_auth_required": False,
"nb_session_messages": 0,
"session_id": app.agent_loop.session_id,
},
}
]
@pytest.mark.asyncio
async def test_teleport_command_in_remote_session_sends_early_failure_telemetry(
telemetry_events: list[dict[str, Any]],
) -> None:
app = build_test_vibe_app(
config=_vibe_code_enabled_config(),
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=False),
)
app.agent_loop.messages.append(LLMMessage(role=Role.user, content="hello"))
app.agent_loop.messages.append(LLMMessage(role=Role.assistant, content="hi"))
async with app.run_test() as pilot:
await _wait_until(
pilot.pause,
lambda: app.commands.get_command_name("/teleport") == "teleport",
)
await app._remote_manager.attach(session_id="remote-session", config=app.config)
await app.on_chat_input_container_submitted(
ChatInputContainer.Submitted("/teleport")
)
await _wait_until(
pilot.pause, lambda: len(_teleport_failed_events(telemetry_events)) == 1
)
await app._remote_manager.detach()
assert _teleport_failed_events(telemetry_events) == [
{
"event_name": "vibe.teleport_failed",
"properties": {
"stage": "remote_session",
"error_class": "TeleportRemoteSessionError",
"push_required": False,
"github_auth_required": False,
"nb_session_messages": 2,
"session_id": app.agent_loop.session_id,
},
}
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_teleport_command_hidden_when_current_key_is_not_eligible() -> None: async def test_teleport_command_hidden_when_current_key_is_not_eligible() -> None:
app = build_test_vibe_app( app = build_test_vibe_app(

View file

@ -53,6 +53,7 @@ def get_base_config() -> dict[str, Any]:
} }
], ],
"enable_auto_update": False, "enable_auto_update": False,
"enable_telemetry": False,
} }
@ -174,7 +175,8 @@ def telemetry_events(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
*, *,
correlation_id: str | None = None, correlation_id: str | None = None,
) -> None: ) -> None:
event: dict[str, Any] = {"event_name": event_name, "properties": properties} merged = self.build_client_event_metadata() | properties
event: dict[str, Any] = {"event_name": event_name, "properties": merged}
if correlation_id is not None: if correlation_id is not None:
event["correlation_id"] = correlation_id event["correlation_id"] = correlation_id
events.append(event) events.append(event)

View file

@ -104,3 +104,19 @@ class TestAgentManager:
) )
manager = AgentManager(lambda: config, initial_agent="plan") manager = AgentManager(lambda: config, initial_agent="plan")
assert manager.active_profile.name == "plan" assert manager.active_profile.name == "plan"
def test_initial_agent_raises_when_agent_is_disabled(self) -> None:
config = build_test_vibe_config(
include_project_context=False,
include_prompt_detail=False,
disabled_agents=["plan"],
)
with pytest.raises(ValueError, match="not available"):
AgentManager(lambda: config, initial_agent="plan")
def test_initial_agent_raises_when_agent_does_not_exist(self) -> None:
config = build_test_vibe_config(
include_project_context=False, include_prompt_detail=False
)
with pytest.raises(ValueError, match="not found"):
AgentManager(lambda: config, initial_agent="nonexistent-agent")

304
tests/core/test_loop.py Normal file
View file

@ -0,0 +1,304 @@
from __future__ import annotations
import math
from typing import cast
import pytest
from vibe.core.loop import (
MAX_LOOPS_PER_SESSION,
MIN_INTERVAL_SECONDS,
LoopError,
LoopErrorResult,
LoopListResult,
LoopManager,
LoopOkResult,
ScheduledLoop,
format_duration,
parse_interval,
)
from vibe.core.session.session_logger import SessionLogger
class FakeMetadata:
def __init__(self) -> None:
self.loops: list[ScheduledLoop] = []
class FakeSessionLogger:
def __init__(self) -> None:
self.session_metadata = FakeMetadata()
self.persisted: list[list[ScheduledLoop]] = []
async def persist_loops(self) -> None:
self.persisted.append([*self.session_metadata.loops])
class RaisingSessionLogger:
def __init__(self) -> None:
self.session_metadata = FakeMetadata()
async def persist_loops(self) -> None:
raise RuntimeError("disk on fire")
def _build_manager() -> tuple[LoopManager, FakeSessionLogger]:
fake = FakeSessionLogger()
return LoopManager(cast(SessionLogger, fake)), fake
class TestParseInterval:
def test_parses_supported_units(self) -> None:
assert parse_interval("30s") == 30
assert parse_interval("5m") == 300
assert parse_interval("2h") == 7200
assert parse_interval("1d") == 86400
def test_parses_case_insensitively(self) -> None:
assert parse_interval("30S") == 30
assert parse_interval("2H") == 7200
def test_strips_whitespace(self) -> None:
assert parse_interval(" 30s ") == 30
@pytest.mark.parametrize("bad", ["", "5", "5x", "-5m", "5 m", "1.5m", "abc"])
def test_rejects_invalid_inputs(self, bad: str) -> None:
with pytest.raises(LoopError):
parse_interval(bad)
def test_rejects_below_minimum(self) -> None:
with pytest.raises(LoopError, match=f"at least {MIN_INTERVAL_SECONDS}"):
parse_interval("29s")
class TestFormatInterval:
@pytest.mark.parametrize("text", ["30s", "5m", "2h", "1d"])
def test_round_trip_with_parse(self, text: str) -> None:
assert format_duration(parse_interval(text)) == text
def test_formats_non_round_values(self) -> None:
assert format_duration(90) == "1m30s"
assert format_duration(180) == "3m"
assert format_duration(7200) == "2h"
assert format_duration(259200) == "3d"
def test_formats_short_version(self) -> None:
assert format_duration(90, short=True) == "1m"
assert format_duration(180, short=True) == "3m"
assert format_duration(190, short=True) == "3m"
assert format_duration(8000, short=True) == "2h"
assert format_duration(260000, short=True) == "3d"
class TestLoopManagerHandleCommand:
@pytest.mark.asyncio
async def test_empty_returns_loops_list(self) -> None:
manager, fake = _build_manager()
result = await manager.handle_command("")
assert isinstance(result, LoopListResult)
assert result.loops == []
assert fake.persisted == []
@pytest.mark.asyncio
@pytest.mark.parametrize("verb", ["list", "ls"])
async def test_list_alias_returns_loops_list(self, verb: str) -> None:
manager, fake = _build_manager()
result = await manager.handle_command(verb)
assert isinstance(result, LoopListResult)
assert result.loops == []
assert fake.persisted == []
@pytest.mark.asyncio
async def test_add_success_persists_and_returns_id(self) -> None:
manager, fake = _build_manager()
result = await manager.handle_command("1m hello world")
assert isinstance(result, LoopOkResult)
assert "every 1m" in result.message
assert "hello world" in result.message
assert len(manager.loops) == 1
loop = manager.loops[0]
assert loop.interval_seconds == 60
assert loop.prompt == "hello world"
assert result.message.count(loop.id) >= 1
assert len(fake.persisted) == 1
assert fake.persisted[0][0].id == loop.id
@pytest.mark.asyncio
async def test_add_bad_interval_returns_error_no_persist(self) -> None:
manager, fake = _build_manager()
result = await manager.handle_command("nope hello")
assert isinstance(result, LoopErrorResult)
assert "Invalid interval" in result.message
assert "Usage:" not in result.message
assert manager.loops == []
assert fake.persisted == []
@pytest.mark.asyncio
async def test_add_empty_prompt_returns_error_no_persist(self) -> None:
manager, fake = _build_manager()
result = await manager.handle_command("30s ")
assert isinstance(result, LoopErrorResult)
assert "Missing prompt" in result.message
assert manager.loops == []
assert fake.persisted == []
@pytest.mark.asyncio
async def test_add_prompt_starting_with_slash_returns_error(self) -> None:
manager, fake = _build_manager()
result = await manager.handle_command("30s /config")
assert isinstance(result, LoopErrorResult)
assert "cannot start with '/'" in result.message
assert manager.loops == []
assert fake.persisted == []
@pytest.mark.asyncio
async def test_over_limit_returns_error(self) -> None:
manager, _ = _build_manager()
for i in range(MAX_LOOPS_PER_SESSION):
result = await manager.handle_command(f"30s prompt {i}")
assert isinstance(result, LoopOkResult)
result = await manager.handle_command("30s overflow")
assert isinstance(result, LoopErrorResult)
assert "limit" in result.message.lower()
assert len(manager.loops) == MAX_LOOPS_PER_SESSION
@pytest.mark.asyncio
async def test_cancel_missing_target_is_error_no_persist(self) -> None:
manager, fake = _build_manager()
result = await manager.handle_command("cancel")
assert isinstance(result, LoopErrorResult)
assert "Missing loop id" in result.message
assert fake.persisted == []
@pytest.mark.asyncio
async def test_cancel_unknown_id_is_error_no_persist(self) -> None:
manager, fake = _build_manager()
await manager.handle_command("30s ping")
fake.persisted.clear()
result = await manager.handle_command("cancel deadbeef")
assert isinstance(result, LoopErrorResult)
assert "deadbeef" in result.message
assert fake.persisted == []
@pytest.mark.asyncio
async def test_cancel_known_id_persists(self) -> None:
manager, fake = _build_manager()
await manager.handle_command("30s ping")
loop_id = manager.loops[0].id
fake.persisted.clear()
result = await manager.handle_command(f"cancel {loop_id}")
assert isinstance(result, LoopOkResult)
assert loop_id in result.message
assert "ping" in result.message
assert manager.loops == []
assert fake.persisted == [[]]
@pytest.mark.asyncio
async def test_cancel_all_persists(self) -> None:
manager, fake = _build_manager()
await manager.handle_command("30s a")
await manager.handle_command("30s b")
fake.persisted.clear()
result = await manager.handle_command("cancel all")
assert isinstance(result, LoopOkResult)
assert "2 scheduled loop(s)" in result.message
assert manager.loops == []
assert fake.persisted == [[]]
@pytest.mark.asyncio
@pytest.mark.parametrize("verb", ["rm", "stop", "delete"])
async def test_cancel_alias_verbs_work(self, verb: str) -> None:
manager, _ = _build_manager()
await manager.handle_command("30s ping")
loop_id = manager.loops[0].id
result = await manager.handle_command(f"{verb} {loop_id}")
assert isinstance(result, LoopOkResult)
assert manager.loops == []
class TestLoopManagerPopDue:
@pytest.mark.asyncio
async def test_returns_none_when_empty(self) -> None:
manager, fake = _build_manager()
assert await manager.pop_due() is None
assert fake.persisted == []
@pytest.mark.asyncio
async def test_returns_none_when_nothing_due(self) -> None:
manager, fake = _build_manager()
await manager.handle_command("30s ping")
fake.persisted.clear()
result = await manager.pop_due(now=0.0)
assert result is None
assert fake.persisted == []
@pytest.mark.asyncio
async def test_returns_due_loop_advances_and_persists(self) -> None:
manager, fake = _build_manager()
await manager.handle_command("30s ping")
fake.persisted.clear()
loop = manager.loops[0]
original_next = loop.next_fire_at
due = await manager.pop_due(now=original_next + 100.0)
assert due is not None
assert due.id == loop.id
assert manager.loops[0].next_fire_at == original_next + 100.0 + 30.0
assert len(fake.persisted) == 1
class TestLoopManagerNextDueIn:
def test_inf_when_empty(self) -> None:
manager, _ = _build_manager()
assert manager.next_due_in() == math.inf
@pytest.mark.asyncio
async def test_correct_delta_when_populated(self) -> None:
manager, _ = _build_manager()
await manager.handle_command("30s ping")
loop = manager.loops[0]
delta = manager.next_due_in(now=loop.next_fire_at - 7.0)
assert delta == pytest.approx(7.0)
@pytest.mark.asyncio
async def test_zero_when_overdue(self) -> None:
manager, _ = _build_manager()
await manager.handle_command("30s ping")
loop = manager.loops[0]
delta = manager.next_due_in(now=loop.next_fire_at + 100.0)
assert delta == 0.0
class TestLoopManagerRestore:
def test_replaces_in_memory_list_without_persist(self) -> None:
manager, fake = _build_manager()
loops = [
ScheduledLoop(
id="aabbccdd",
interval_seconds=30,
prompt="x",
next_fire_at=1.0,
created_at=0.0,
),
ScheduledLoop(
id="11223344",
interval_seconds=60,
prompt="y",
next_fire_at=2.0,
created_at=0.0,
),
]
manager.restore(loops)
assert [loop.id for loop in manager.loops] == ["aabbccdd", "11223344"]
assert fake.persisted == []
class TestLoopManagerPersisterErrors:
@pytest.mark.asyncio
async def test_persister_exception_does_not_propagate(self) -> None:
manager = LoopManager(cast(SessionLogger, RaisingSessionLogger()))
result = await manager.handle_command("30s ping")
assert isinstance(result, LoopOkResult)
assert len(manager.loops) == 1
loop = manager.loops[0]
due = await manager.pop_due(now=loop.next_fire_at + 1.0)
assert due is not None

View file

@ -362,6 +362,48 @@ class TestTelemetryClient:
assert telemetry_events[1]["properties"]["command"] == "my_skill" assert telemetry_events[1]["properties"]["command"] == "my_skill"
assert telemetry_events[1]["properties"]["command_type"] == "skill" assert telemetry_events[1]["properties"]["command_type"] == "skill"
def test_send_teleport_completed_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_teleport_completed(
push_required=True, github_auth_required=False, nb_session_messages=4
)
assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe.teleport_completed"
assert telemetry_events[0]["properties"] == {
"push_required": True,
"github_auth_required": False,
"nb_session_messages": 4,
}
def test_send_teleport_failed_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_teleport_failed(
stage="push",
error_class="ServiceTeleportError",
push_required=True,
github_auth_required=False,
nb_session_messages=4,
)
assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe.teleport_failed"
assert telemetry_events[0]["properties"] == {
"stage": "push",
"error_class": "ServiceTeleportError",
"push_required": True,
"github_auth_required": False,
"nb_session_messages": 4,
}
def test_send_new_session_payload( def test_send_new_session_payload(
self, telemetry_events: list[dict[str, Any]] self, telemetry_events: list[dict[str, Any]]
) -> None: ) -> None:
@ -393,6 +435,55 @@ class TestTelemetryClient:
assert properties["terminal_emulator"] == "vscode" assert properties["terminal_emulator"] == "vscode"
assert "version" in properties assert "version" in properties
@pytest.mark.asyncio
async def test_send_session_closed_payload(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
TelemetryClient, "send_telemetry_event", _original_send_telemetry_event
)
config = build_test_vibe_config(enable_telemetry=True)
env_key = config.get_active_provider().api_key_env_var
monkeypatch.setenv(env_key, "sk-test")
client = TelemetryClient(
config_getter=lambda: config,
session_id_getter=lambda: "current-session",
parent_session_id_getter=lambda: "current-parent-session",
entrypoint_metadata_getter=lambda: EntrypointMetadata(
agent_entrypoint="cli",
agent_version="1.0.0",
client_name="vibe_cli",
client_version="1.0.0",
),
)
mock_post = AsyncMock(return_value=MagicMock(status_code=204))
client._client = MagicMock()
client._client.post = mock_post
client._client.aclose = AsyncMock()
client.send_session_closed()
await client.aclose()
mock_post.assert_called_once_with(
"https://api.mistral.ai/v1/datalake/events",
json={
"event": "vibe.session_closed",
"properties": {
"agent_entrypoint": "cli",
"agent_version": "1.0.0",
"client_name": "vibe_cli",
"client_version": "1.0.0",
"session_id": "current-session",
"parent_session_id": "current-parent-session",
},
},
headers={
"Content-Type": "application/json",
"Authorization": "Bearer sk-test",
"User-Agent": get_user_agent(Backend.MISTRAL),
},
)
def test_build_base_metadata_includes_entrypoint_and_session(self) -> None: def test_build_base_metadata_includes_entrypoint_and_session(self) -> None:
metadata = build_base_metadata( metadata = build_base_metadata(
entrypoint_metadata=EntrypointMetadata( entrypoint_metadata=EntrypointMetadata(

View file

@ -0,0 +1,231 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from typing import Any, cast
import pytest
from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.teleport.errors import ServiceTeleportError
from vibe.core.teleport.teleport import TeleportService
from vibe.core.teleport.types import (
TeleportAuthRequiredEvent,
TeleportCheckingGitEvent,
TeleportCompleteEvent,
TeleportFetchingUrlEvent,
TeleportPushingEvent,
TeleportPushRequiredEvent,
TeleportPushResponseEvent,
TeleportStartingWorkflowEvent,
TeleportWaitingForGitHubEvent,
)
from vibe.core.types import LLMMessage, Role
def _set_teleport_service(agent_loop: AgentLoop, service: object) -> None:
agent_loop._teleport_service = cast(TeleportService, service)
class TestTeleportAgentLoopTelemetry:
@pytest.mark.asyncio
async def test_teleport_to_vibe_code_sends_completed_success(
self, agent_loop: AgentLoop, telemetry_events: list[dict[str, Any]]
) -> None:
class FakeTeleportService:
async def __aenter__(self) -> FakeTeleportService:
return self
async def __aexit__(self, *_args: object) -> None:
return None
async def execute(
self, *_args: object, **_kwargs: object
) -> AsyncGenerator[object, object]:
yield TeleportCheckingGitEvent()
yield TeleportPushRequiredEvent()
yield TeleportPushingEvent()
yield TeleportStartingWorkflowEvent()
yield TeleportWaitingForGitHubEvent()
yield TeleportAuthRequiredEvent(oauth_url="https://github.com/auth")
yield TeleportFetchingUrlEvent()
yield TeleportCompleteEvent(url="https://chat.example.com/123")
agent_loop.messages.append(LLMMessage(role=Role.user, content="hello"))
_set_teleport_service(agent_loop, FakeTeleportService())
gen = agent_loop.teleport_to_vibe_code(None)
response = None
events = []
while True:
try:
event = await gen.asend(response)
except StopAsyncIteration:
break
events.append(event)
response = (
TeleportPushResponseEvent(approved=True)
if isinstance(event, TeleportPushRequiredEvent)
else None
)
assert isinstance(events[-1], TeleportCompleteEvent)
assert telemetry_events[-1]["event_name"] == "vibe.teleport_completed"
assert telemetry_events[-1]["properties"] == {
"push_required": True,
"github_auth_required": True,
"nb_session_messages": 1,
"session_id": agent_loop.session_id,
}
@pytest.mark.asyncio
async def test_teleport_to_vibe_code_sends_failed_stage(
self, agent_loop: AgentLoop, telemetry_events: list[dict[str, Any]]
) -> None:
class FakeTeleportService:
async def __aenter__(self) -> FakeTeleportService:
return self
async def __aexit__(self, *_args: object) -> None:
return None
async def execute(
self, *_args: object, **_kwargs: object
) -> AsyncGenerator[object, object]:
yield TeleportCheckingGitEvent()
yield TeleportStartingWorkflowEvent()
raise ServiceTeleportError("Workflow api-key-123 could not be started.")
agent_loop.messages.append(LLMMessage(role=Role.user, content="hello"))
_set_teleport_service(agent_loop, FakeTeleportService())
gen = agent_loop.teleport_to_vibe_code(None)
with pytest.raises(TeleportError, match="api-key-123"):
async for _ in gen:
pass
assert telemetry_events[-1]["event_name"] == "vibe.teleport_failed"
assert telemetry_events[-1]["properties"] == {
"stage": "workflow_start",
"error_class": "ServiceTeleportError",
"push_required": False,
"github_auth_required": False,
"nb_session_messages": 1,
"session_id": agent_loop.session_id,
}
assert "api-key-123" not in str(telemetry_events[-1]["properties"])
@pytest.mark.asyncio
async def test_teleport_to_vibe_code_sends_failed_cancelled(
self, agent_loop: AgentLoop, telemetry_events: list[dict[str, Any]]
) -> None:
class FakeTeleportService:
async def __aenter__(self) -> FakeTeleportService:
return self
async def __aexit__(self, *_args: object) -> None:
return None
async def execute(
self, *_args: object, **_kwargs: object
) -> AsyncGenerator[object, object]:
yield TeleportCheckingGitEvent()
response = yield TeleportPushRequiredEvent()
if (
not isinstance(response, TeleportPushResponseEvent)
or not response.approved
):
raise ServiceTeleportError(
"Teleport cancelled: changes not pushed."
)
agent_loop.messages.append(LLMMessage(role=Role.user, content="hello"))
_set_teleport_service(agent_loop, FakeTeleportService())
gen = agent_loop.teleport_to_vibe_code(None)
assert isinstance(await gen.asend(None), TeleportCheckingGitEvent)
assert isinstance(await gen.asend(None), TeleportPushRequiredEvent)
with pytest.raises(TeleportError, match="Teleport cancelled"):
await gen.asend(TeleportPushResponseEvent(approved=False))
assert telemetry_events[-1]["event_name"] == "vibe.teleport_failed"
assert telemetry_events[-1]["properties"] == {
"stage": "cancelled",
"error_class": "ServiceTeleportError",
"push_required": True,
"github_auth_required": False,
"nb_session_messages": 1,
"session_id": agent_loop.session_id,
}
@pytest.mark.asyncio
async def test_teleport_to_vibe_code_sends_failed_when_task_cancelled(
self, agent_loop: AgentLoop, telemetry_events: list[dict[str, Any]]
) -> None:
class FakeTeleportService:
async def __aenter__(self) -> FakeTeleportService:
return self
async def __aexit__(self, *_args: object) -> None:
return None
async def execute(
self, *_args: object, **_kwargs: object
) -> AsyncGenerator[object, object]:
yield TeleportCheckingGitEvent()
raise asyncio.CancelledError
agent_loop.messages.append(LLMMessage(role=Role.user, content="hello"))
_set_teleport_service(agent_loop, FakeTeleportService())
gen = agent_loop.teleport_to_vibe_code(None)
assert isinstance(await gen.asend(None), TeleportCheckingGitEvent)
with pytest.raises(asyncio.CancelledError):
await gen.asend(None)
assert telemetry_events[-1]["event_name"] == "vibe.teleport_failed"
assert telemetry_events[-1]["properties"] == {
"stage": "cancelled",
"error_class": "CancelledError",
"push_required": False,
"github_auth_required": False,
"nb_session_messages": 1,
"session_id": agent_loop.session_id,
}
@pytest.mark.asyncio
async def test_teleport_to_vibe_code_sends_failed_when_consumer_closes_generator(
self, agent_loop: AgentLoop, telemetry_events: list[dict[str, Any]]
) -> None:
class FakeTeleportService:
async def __aenter__(self) -> FakeTeleportService:
return self
async def __aexit__(self, *_args: object) -> None:
return None
async def execute(
self, *_args: object, **_kwargs: object
) -> AsyncGenerator[object, object]:
yield TeleportCheckingGitEvent()
yield TeleportPushRequiredEvent()
agent_loop.messages.append(LLMMessage(role=Role.user, content="hello"))
_set_teleport_service(agent_loop, FakeTeleportService())
gen = agent_loop.teleport_to_vibe_code(None)
assert isinstance(await gen.asend(None), TeleportCheckingGitEvent)
await gen.aclose()
assert telemetry_events[-1]["event_name"] == "vibe.teleport_failed"
assert telemetry_events[-1]["properties"] == {
"stage": "cancelled",
"error_class": "CancelledError",
"push_required": False,
"github_auth_required": False,
"nb_session_messages": 1,
"session_id": agent_loop.session_id,
}

View file

@ -4,7 +4,7 @@ from pathlib import Path
import pytest import pytest
from vibe.core.utils import get_server_url_from_api_base from vibe.core.utils import compact_reduction_display, get_server_url_from_api_base
import vibe.core.utils.io as io_utils import vibe.core.utils.io as io_utils
from vibe.core.utils.io import decode_safe, read_safe, read_safe_async from vibe.core.utils.io import decode_safe, read_safe, read_safe_async
@ -23,6 +23,24 @@ def test_get_server_url_from_api_base(api_base, expected):
assert get_server_url_from_api_base(api_base) == expected assert get_server_url_from_api_base(api_base) == expected
class TestCompactReductionDisplay:
def test_includes_session_ids_when_available(self) -> None:
assert compact_reduction_display(
177_017,
23_263,
old_session_id="11111111-1111-1111-1111-111111111111",
new_session_id="22222222-2222-2222-2222-222222222222",
) == (
"Compaction complete: 177,017 → 23,263 tokens (-87.%)\n"
"session: 11111111 (before compaction) → 22222222 (after compaction)"
)
def test_returns_base_message_without_session_ids(self) -> None:
assert compact_reduction_display(177_017, 23_263) == (
"Compaction complete: 177,017 → 23,263 tokens (-87.%)"
)
class TestReadSafe: class TestReadSafe:
def test_reads_utf8(self, tmp_path: Path) -> None: def test_reads_utf8(self, tmp_path: Path) -> None:
f = tmp_path / "hello.txt" f = tmp_path / "hello.txt"

View file

@ -60,7 +60,62 @@ class TestReadFileExecution:
assert result.content == "line 50\nline 51\n" assert result.content == "line 50\nline 51\n"
assert result.lines_read == 2 assert result.lines_read == 2
assert result.was_truncated
@pytest.mark.asyncio
async def test_run_marks_truncated_when_max_read_bytes_exceeded(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "big.txt"
test_file.write_text(
"".join(f"line {i}\n" for i in range(100)), encoding="utf-8"
)
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(max_read_bytes=20),
state=ReadFileState(),
)
result = await collect_result(tool.run(ReadFileArgs(path=str(test_file))))
assert result.was_truncated
assert result.lines_read > 0
@pytest.mark.asyncio
async def test_run_not_truncated_when_eof_reached(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "small.txt"
test_file.write_text("a\nb\nc\n", encoding="utf-8")
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(max_read_bytes=1024),
state=ReadFileState(),
)
result = await collect_result(tool.run(ReadFileArgs(path=str(test_file))))
assert not result.was_truncated assert not result.was_truncated
assert result.lines_read == 3
@pytest.mark.asyncio
async def test_run_not_truncated_when_limit_matches_remaining_lines(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "exact.txt"
test_file.write_text("a\nb\nc\n", encoding="utf-8")
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(max_read_bytes=1024),
state=ReadFileState(),
)
result = await collect_result(
tool.run(ReadFileArgs(path=str(test_file), limit=10))
)
assert not result.was_truncated
assert result.lines_read == 3
class TestGetResultExtra: class TestGetResultExtra:

View file

@ -5,33 +5,41 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from vibe.core.session.resume_sessions import ( from vibe.core.session.resume_sessions import (
SHORT_SESSION_ID_LEN,
list_remote_resume_sessions, list_remote_resume_sessions,
short_session_id, short_session_id,
) )
from vibe.core.session.session_id import shorten_session_id
class TestShortenSessionId:
def test_shortens_to_first_8_chars(self) -> None:
sid = "abcdef1234567890"
assert shorten_session_id(sid) == "abcdef12"
def test_from_end_shortens_to_last_8_chars(self) -> None:
sid = "abcdef1234567890"
assert shorten_session_id(sid, from_end=True) == "34567890"
def test_returns_full_id_when_shorter_than_limit(self) -> None:
sid = "abc"
assert shorten_session_id(sid) == "abc"
assert shorten_session_id(sid, from_end=True) == "abc"
class TestShortSessionId: class TestShortSessionId:
def test_local_shortens_to_first_chars(self) -> None: def test_local_delegates_to_shorten(self) -> None:
sid = "abcdef1234567890" sid = "abcdef1234567890"
result = short_session_id(sid) assert short_session_id(sid) == shorten_session_id(sid)
assert result == sid[:SHORT_SESSION_ID_LEN]
assert len(result) == SHORT_SESSION_ID_LEN
def test_local_is_default(self) -> None: def test_local_is_default(self) -> None:
sid = "abcdef1234567890" sid = "abcdef1234567890"
assert short_session_id(sid) == short_session_id(sid, source="local") assert short_session_id(sid) == short_session_id(sid, source="local")
def test_remote_shortens_to_last_chars(self) -> None: def test_remote_delegates_to_shorten_from_end(self) -> None:
sid = "abcdef1234567890" sid = "abcdef1234567890"
result = short_session_id(sid, source="remote") assert short_session_id(sid, source="remote") == shorten_session_id(
assert result == sid[-SHORT_SESSION_ID_LEN:] sid, from_end=True
assert len(result) == SHORT_SESSION_ID_LEN )
def test_returns_full_id_when_shorter_than_limit(self) -> None:
sid = "abc"
assert short_session_id(sid) == "abc"
assert short_session_id(sid, source="remote") == "abc"
def test_empty_string(self) -> None: def test_empty_string(self) -> None:
assert short_session_id("") == "" assert short_session_id("") == ""

View file

@ -11,6 +11,7 @@ import pytest
from tests.conftest import build_test_vibe_config from tests.conftest import build_test_vibe_config
from vibe.core.agents.models import AgentProfile, AgentSafety from vibe.core.agents.models import AgentProfile, AgentSafety
from vibe.core.config import SessionLoggingConfig, VibeConfig from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.loop import ScheduledLoop
from vibe.core.session.session_logger import SessionLogger from vibe.core.session.session_logger import SessionLogger
from vibe.core.tools.manager import ToolManager from vibe.core.tools.manager import ToolManager
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
@ -868,3 +869,136 @@ class TestSessionLoggerCleanupTmpFiles:
logger.maybe_cleanup_tmp_files() logger.maybe_cleanup_tmp_files()
assert cleanup_spy.call_count == 2 assert cleanup_spy.call_count == 2
class TestPersistLoops:
@pytest.mark.asyncio
async def test_writes_into_existing_metadata(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
logger = SessionLogger(session_config, "test-session-loops")
await logger.save_interaction(
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),
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
assert logger.session_metadata is not None
logger.session_metadata.loops = [
ScheduledLoop(
id="aabbccdd",
interval_seconds=30,
prompt="ping",
next_fire_at=12345.0,
created_at=12000.0,
)
]
await logger.persist_loops()
assert logger.session_dir is not None
with open(logger.session_dir / "meta.json") as f:
metadata = json.load(f)
assert metadata["session_id"] == "test-session-loops"
assert metadata["total_messages"] == 2
assert metadata["loops"] == [
{
"id": "aabbccdd",
"interval_seconds": 30,
"prompt": "ping",
"next_fire_at": 12345.0,
"created_at": 12000.0,
}
]
@pytest.mark.asyncio
async def test_noop_when_metadata_file_missing(
self, session_config: SessionLoggingConfig
) -> None:
logger = SessionLogger(session_config, "no-meta-session")
assert logger.session_dir is not None
# No save_interaction was called -> meta.json does not exist
assert not (logger.session_dir / "meta.json").exists()
assert logger.session_metadata is not None
logger.session_metadata.loops = [
ScheduledLoop(
id="aabbccdd",
interval_seconds=30,
prompt="ping",
next_fire_at=1.0,
created_at=0.0,
)
]
await logger.persist_loops()
assert not (logger.session_dir / "meta.json").exists()
@pytest.mark.asyncio
async def test_noop_when_logging_disabled(
self, disabled_session_config: SessionLoggingConfig
) -> None:
logger = SessionLogger(disabled_session_config, "ignored")
# Should not raise even though session_metadata is None
await logger.persist_loops()
@pytest.mark.asyncio
async def test_subsequent_save_interaction_preserves_loops(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
logger = SessionLogger(session_config, "loops-vs-save")
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
await logger.save_interaction(
messages=messages,
stats=AgentStats(steps=1),
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
assert logger.session_metadata is not None
logger.session_metadata.loops = [
ScheduledLoop(
id="aabbccdd",
interval_seconds=30,
prompt="ping",
next_fire_at=12345.0,
created_at=12000.0,
)
]
await logger.persist_loops()
# A subsequent save (e.g. user sends another message) must not
# overwrite the on-disk loops with the stale in-memory value.
more_messages = [*messages, LLMMessage(role=Role.user, content="Again")]
await logger.save_interaction(
messages=more_messages,
stats=AgentStats(steps=2),
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
assert logger.session_dir is not None
with open(logger.session_dir / "meta.json") as f:
metadata = json.load(f)
assert len(metadata["loops"]) == 1
assert metadata["loops"][0]["id"] == "aabbccdd"

View file

@ -191,7 +191,7 @@
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> </text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> </text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> </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="12.2" y="727.6" textLength="36.6" clip-path="url(#terminal-line-29)">▂▅▇</text><text class="terminal-r1" x="48.8" y="727.6" textLength="122" clip-path="url(#terminal-line-29)">&#160;speaking&#160;</text><text class="terminal-r6" x="170.8" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">Esc/Ctrl+C&#160;to&#160;stop</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="0" y="727.6" textLength="36.6" clip-path="url(#terminal-line-29)">▂▅▇</text><text class="terminal-r1" x="36.6" y="727.6" textLength="122" clip-path="url(#terminal-line-29)">&#160;speaking&#160;</text><text class="terminal-r6" x="158.6" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">Esc/Ctrl+C&#160;to&#160;stop</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"> </text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r7" x="0" 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)">&gt;</text><text class="terminal-r7" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"> </text><text class="terminal-r7" x="0" 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)">&gt;</text><text class="terminal-r7" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r7" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r7" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"> </text><text class="terminal-r7" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r7" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -191,7 +191,7 @@
</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> </text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> </text><text class="terminal-r1" x="0" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">Hello!&#160;I&#160;can&#160;help&#160;you.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> </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="12.2" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="24.4" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">&#160;summarizing&#160;</text><text class="terminal-r6" x="183" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">Esc/Ctrl+C&#160;to&#160;stop</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="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="12.2" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">&#160;summarizing&#160;</text><text class="terminal-r6" x="170.8" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">Esc/Ctrl+C&#160;to&#160;stop</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"> </text><text class="terminal-r7" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">┌────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;─┐</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r7" x="0" 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)">&gt;</text><text class="terminal-r7" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"> </text><text class="terminal-r7" x="0" 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)">&gt;</text><text class="terminal-r7" x="1451.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r7" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r7" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"> </text><text class="terminal-r7" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r7" x="1451.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"></text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 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="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-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="146.4" clip-path="url(#terminal-line-30)">/proxy-setup</text><text class="terminal-r1" x="219.6" y="752" textLength="561.2" clip-path="url(#terminal-line-30)">:&#160;Configure&#160;proxy&#160;and&#160;SSL&#160;certificate&#160;settings</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="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-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="109.8" clip-path="url(#terminal-line-31)">/continue</text><text class="terminal-r1" x="183" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">,&#160;</text><text class="terminal-r4" x="207.4" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">/resume</text><text class="terminal-r1" x="292.8" y="776.4" textLength="402.6" clip-path="url(#terminal-line-31)">:&#160;Browse&#160;and&#160;resume&#160;past&#160;sessions</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="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-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="85.4" clip-path="url(#terminal-line-32)">/rename</text><text class="terminal-r1" x="158.6" y="800.8" textLength="341.6" clip-path="url(#terminal-line-32)">:&#160;Rename&#160;the&#160;current&#160;session</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="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-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="134.2" clip-path="url(#terminal-line-33)">/connectors</text><text class="terminal-r1" x="207.4" y="825.2" textLength="24.4" clip-path="url(#terminal-line-33)">,&#160;</text><text class="terminal-r4" x="231.8" y="825.2" textLength="48.8" clip-path="url(#terminal-line-33)">/mcp</text><text class="terminal-r1" x="280.6" y="825.2" textLength="939.4" clip-path="url(#terminal-line-33)">:&#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="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="73.2" clip-path="url(#terminal-line-34)">/voice</text><text class="terminal-r1" x="146.4" y="849.6" textLength="317.2" clip-path="url(#terminal-line-34)">:&#160;Configure&#160;voice&#160;settings</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="122" clip-path="url(#terminal-line-35)">/leanstall</text><text class="terminal-r1" x="195.2" y="874" textLength="463.6" clip-path="url(#terminal-line-35)">:&#160;Install&#160;the&#160;Lean&#160;4&#160;agent&#160;(leanstral)</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="146.4" clip-path="url(#terminal-line-36)">/unleanstall</text><text class="terminal-r1" x="219.6" y="898.4" textLength="341.6" clip-path="url(#terminal-line-36)">:&#160;Uninstall&#160;the&#160;Lean&#160;4&#160;agent</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="85.4" clip-path="url(#terminal-line-37)">/rewind</text><text class="terminal-r1" x="158.6" y="922.8" textLength="366" clip-path="url(#terminal-line-37)">:&#160;Rewind&#160;to&#160;a&#160;previous&#160;message</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="61" clip-path="url(#terminal-line-38)">/loop</text><text class="terminal-r1" x="134.2" y="947.2" textLength="427" clip-path="url(#terminal-line-38)">:&#160;Schedule&#160;a&#160;recurring&#160;prompt.&#160;Use&#160;</text><text class="terminal-r4" x="561.2" y="947.2" textLength="305" clip-path="url(#terminal-line-38)">/loop&#160;&lt;interval&gt;&#160;&lt;prompt&gt;</text><text class="terminal-r1" x="866.2" y="947.2" textLength="24.4" clip-path="url(#terminal-line-38)">,&#160;</text><text class="terminal-r4" x="890.6" y="947.2" textLength="122" clip-path="url(#terminal-line-38)">/loop&#160;list</text><text class="terminal-r1" x="1012.6" y="947.2" textLength="61" clip-path="url(#terminal-line-38)">,&#160;or&#160;</text><text class="terminal-r4" x="1073.6" y="947.2" textLength="256.2" clip-path="url(#terminal-line-38)">/loop&#160;cancel&#160;&lt;id|all&gt;</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: 36 KiB

Before After
Before After

View file

@ -196,47 +196,47 @@
</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="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="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="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> <text class="terminal-r2" x="24.4" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"></text><text class="terminal-r3" x="48.8" y="20" textLength="219.6" clip-path="url(#terminal-line-0)">Keyboard&#160;Shortcuts</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</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-r2" x="24.4" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r1" x="48.8" y="44.4" textLength="24.4" clip-path="url(#terminal-line-1)">&#160;</text><text class="terminal-r4" x="73.2" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">Enter</text><text class="terminal-r1" x="134.2" y="44.4" textLength="183" clip-path="url(#terminal-line-1)">&#160;Submit&#160;message</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="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-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="73.2" clip-path="url(#terminal-line-2)">Ctrl+J</text><text class="terminal-r1" x="146.4" y="68.8" textLength="36.6" clip-path="url(#terminal-line-2)">&#160;/&#160;</text><text class="terminal-r4" x="183" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">Shift+Enter</text><text class="terminal-r1" x="317.2" y="68.8" textLength="183" clip-path="url(#terminal-line-2)">&#160;Insert&#160;newline</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="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-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)">Escape</text><text class="terminal-r1" x="146.4" y="93.2" textLength="402.6" clip-path="url(#terminal-line-3)">&#160;Interrupt&#160;agent&#160;or&#160;close&#160;dialogs</text><text class="terminal-r5" x="1451.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></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="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-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)">Ctrl+C</text><text class="terminal-r1" x="146.4" y="117.6" textLength="463.6" clip-path="url(#terminal-line-4)">&#160;Quit&#160;(or&#160;clear&#160;input&#160;if&#160;text&#160;present)</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="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-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+G</text><text class="terminal-r1" x="146.4" y="142" textLength="366" clip-path="url(#terminal-line-5)">&#160;Edit&#160;input&#160;in&#160;external&#160;editor</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="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-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+O</text><text class="terminal-r1" x="146.4" y="166.4" textLength="292.8" clip-path="url(#terminal-line-6)">&#160;Toggle&#160;tool&#160;output&#160;view</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="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-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="109.8" clip-path="url(#terminal-line-7)">Shift+Tab</text><text class="terminal-r1" x="183" y="190.8" textLength="512.4" clip-path="url(#terminal-line-7)">&#160;Cycle&#160;through&#160;agents&#160;(default,&#160;plan,&#160;...)</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="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-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="73.2" clip-path="url(#terminal-line-8)">Alt+↑↓</text><text class="terminal-r1" x="146.4" y="215.2" textLength="36.6" clip-path="url(#terminal-line-8)">&#160;/&#160;</text><text class="terminal-r4" x="183" y="215.2" textLength="97.6" clip-path="url(#terminal-line-8)">Ctrl+P/N</text><text class="terminal-r1" x="280.6" y="215.2" textLength="390.4" clip-path="url(#terminal-line-8)">&#160;Rewind&#160;to&#160;previous/next&#160;message</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="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-r2" x="24.4" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></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="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-r2" x="24.4" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"></text><text class="terminal-r3" x="48.8" y="264" textLength="195.2" clip-path="url(#terminal-line-10)">Special&#160;Features</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="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-r2" 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="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-r2" x="24.4" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"></text><text class="terminal-r1" x="48.8" y="312.8" textLength="24.4" clip-path="url(#terminal-line-12)">&#160;</text><text class="terminal-r4" x="73.2" y="312.8" textLength="122" clip-path="url(#terminal-line-12)">!&lt;command&gt;</text><text class="terminal-r1" x="195.2" y="312.8" textLength="366" clip-path="url(#terminal-line-12)">&#160;Execute&#160;bash&#160;command&#160;directly</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="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-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="170.8" clip-path="url(#terminal-line-13)">@path/to/file/</text><text class="terminal-r1" x="244" y="337.2" textLength="305" clip-path="url(#terminal-line-13)">&#160;Autocompletes&#160;file&#160;paths</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="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-r2" 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-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-r2" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"></text><text class="terminal-r3" x="48.8" y="386" textLength="97.6" clip-path="url(#terminal-line-15)">Commands</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="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-r2" 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="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-r2" x="24.4" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"></text><text class="terminal-r1" x="48.8" y="434.8" textLength="24.4" clip-path="url(#terminal-line-17)">&#160;</text><text class="terminal-r4" x="73.2" y="434.8" textLength="61" clip-path="url(#terminal-line-17)">/help</text><text class="terminal-r1" x="134.2" y="434.8" textLength="231.8" clip-path="url(#terminal-line-17)">:&#160;Show&#160;help&#160;message</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="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-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="85.4" clip-path="url(#terminal-line-18)">/config</text><text class="terminal-r1" x="158.6" y="459.2" textLength="268.4" clip-path="url(#terminal-line-18)">:&#160;Edit&#160;config&#160;settings</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="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-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="73.2" clip-path="url(#terminal-line-19)">/model</text><text class="terminal-r1" x="146.4" y="483.6" textLength="256.2" clip-path="url(#terminal-line-19)">:&#160;Select&#160;active&#160;model</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="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-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="109.8" clip-path="url(#terminal-line-20)">/thinking</text><text class="terminal-r1" x="183" y="508" textLength="280.6" clip-path="url(#terminal-line-20)">:&#160;Select&#160;thinking&#160;level</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="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-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="85.4" clip-path="url(#terminal-line-21)">/reload</text><text class="terminal-r1" x="158.6" y="532.4" textLength="780.8" clip-path="url(#terminal-line-21)">:&#160;Reload&#160;configuration,&#160;agent&#160;instructions,&#160;and&#160;skills&#160;from&#160;disk</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="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-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="73.2" clip-path="url(#terminal-line-22)">/clear</text><text class="terminal-r1" x="146.4" y="556.8" textLength="341.6" clip-path="url(#terminal-line-22)">:&#160;Clear&#160;conversation&#160;history</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="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-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="61" clip-path="url(#terminal-line-23)">/copy</text><text class="terminal-r1" x="134.2" y="581.2" textLength="561.2" clip-path="url(#terminal-line-23)">:&#160;Copy&#160;the&#160;last&#160;agent&#160;message&#160;to&#160;the&#160;clipboard</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="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-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="48.8" clip-path="url(#terminal-line-24)">/log</text><text class="terminal-r1" x="122" y="605.6" textLength="524.6" clip-path="url(#terminal-line-24)">:&#160;Show&#160;path&#160;to&#160;current&#160;interaction&#160;log&#160;file</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="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-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="73.2" clip-path="url(#terminal-line-25)">/debug</text><text class="terminal-r1" x="146.4" y="630" textLength="268.4" clip-path="url(#terminal-line-25)">:&#160;Toggle&#160;debug&#160;console</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="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-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="97.6" clip-path="url(#terminal-line-26)">/compact</text><text class="terminal-r1" x="170.8" y="654.4" textLength="1171.2" clip-path="url(#terminal-line-26)">:&#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="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</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-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="61" clip-path="url(#terminal-line-27)">/exit</text><text class="terminal-r1" x="134.2" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">:&#160;Exit&#160;the&#160;application</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="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-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="85.4" clip-path="url(#terminal-line-28)">/status</text><text class="terminal-r1" x="158.6" y="703.2" textLength="317.2" clip-path="url(#terminal-line-28)">:&#160;Display&#160;agent&#160;statistics</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="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-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="109.8" clip-path="url(#terminal-line-29)">/teleport</text><text class="terminal-r1" x="183" y="727.6" textLength="378.2" clip-path="url(#terminal-line-29)">:&#160;Teleport&#160;session&#160;to&#160;Vibe&#160;Code</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="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-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="146.4" clip-path="url(#terminal-line-30)">/proxy-setup</text><text class="terminal-r1" x="219.6" y="752" textLength="561.2" clip-path="url(#terminal-line-30)">:&#160;Configure&#160;proxy&#160;and&#160;SSL&#160;certificate&#160;settings</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="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-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="109.8" clip-path="url(#terminal-line-31)">/continue</text><text class="terminal-r1" x="183" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">,&#160;</text><text class="terminal-r4" x="207.4" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">/resume</text><text class="terminal-r1" x="292.8" y="776.4" textLength="402.6" clip-path="url(#terminal-line-31)">:&#160;Browse&#160;and&#160;resume&#160;past&#160;sessions</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="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-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="85.4" clip-path="url(#terminal-line-32)">/rename</text><text class="terminal-r1" x="158.6" y="800.8" textLength="341.6" clip-path="url(#terminal-line-32)">:&#160;Rename&#160;the&#160;current&#160;session</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="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-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="134.2" clip-path="url(#terminal-line-33)">/connectors</text><text class="terminal-r1" x="207.4" y="825.2" textLength="24.4" clip-path="url(#terminal-line-33)">,&#160;</text><text class="terminal-r4" x="231.8" y="825.2" textLength="48.8" clip-path="url(#terminal-line-33)">/mcp</text><text class="terminal-r1" x="280.6" y="825.2" textLength="939.4" clip-path="url(#terminal-line-33)">:&#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="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</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-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="73.2" clip-path="url(#terminal-line-34)">/voice</text><text class="terminal-r1" x="146.4" y="849.6" textLength="317.2" clip-path="url(#terminal-line-34)">:&#160;Configure&#160;voice&#160;settings</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="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-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="122" clip-path="url(#terminal-line-35)">/leanstall</text><text class="terminal-r1" x="195.2" y="874" textLength="463.6" clip-path="url(#terminal-line-35)">:&#160;Install&#160;the&#160;Lean&#160;4&#160;agent&#160;(leanstral)</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="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-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="146.4" clip-path="url(#terminal-line-36)">/unleanstall</text><text class="terminal-r1" x="219.6" y="898.4" textLength="341.6" clip-path="url(#terminal-line-36)">:&#160;Uninstall&#160;the&#160;Lean&#160;4&#160;agent</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="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-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="85.4" clip-path="url(#terminal-line-37)">/rewind</text><text class="terminal-r1" x="158.6" y="922.8" textLength="366" clip-path="url(#terminal-line-37)">:&#160;Rewind&#160;to&#160;a&#160;previous&#160;message</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="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-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="61" clip-path="url(#terminal-line-38)">/loop</text><text class="terminal-r1" x="134.2" y="947.2" textLength="427" clip-path="url(#terminal-line-38)">:&#160;Schedule&#160;a&#160;recurring&#160;prompt.&#160;Use&#160;</text><text class="terminal-r4" x="561.2" y="947.2" textLength="305" clip-path="url(#terminal-line-38)">/loop&#160;&lt;interval&gt;&#160;&lt;prompt&gt;</text><text class="terminal-r1" x="866.2" y="947.2" textLength="24.4" clip-path="url(#terminal-line-38)">,&#160;</text><text class="terminal-r4" x="890.6" y="947.2" textLength="122" clip-path="url(#terminal-line-38)">/loop&#160;list</text><text class="terminal-r1" x="1012.6" y="947.2" textLength="61" clip-path="url(#terminal-line-38)">,&#160;or&#160;</text><text class="terminal-r4" x="1073.6" y="947.2" textLength="256.2" clip-path="url(#terminal-line-38)">/loop&#160;cancel&#160;&lt;id|all&gt;</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="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-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)">

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Before After
Before After

View file

@ -11,6 +11,11 @@ def _pin_timezone(monkeypatch: pytest.MonkeyPatch) -> None:
time.tzset() time.tzset()
@pytest.fixture(autouse=True)
def _pin_snapshot_colors(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("NO_COLOR", raising=False)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _pin_banner_version(monkeypatch: pytest.MonkeyPatch) -> None: def _pin_banner_version(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr( monkeypatch.setattr(

View file

@ -9,6 +9,7 @@ from tests.mock.utils import mock_llm_chunk
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.snapshots.snap_compare import SnapCompare from tests.snapshots.snap_compare import SnapCompare
from tests.stubs.fake_backend import FakeBackend from tests.stubs.fake_backend import FakeBackend
from vibe.core.telemetry.send import TelemetryClient
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -20,6 +21,7 @@ def _enable_feedback_bar(monkeypatch: pytest.MonkeyPatch) -> None:
"vibe.cli.textual_ui.widgets.feedback_bar_manager.MIN_USER_MESSAGES_FOR_FEEDBACK", "vibe.cli.textual_ui.widgets.feedback_bar_manager.MIN_USER_MESSAGES_FOR_FEEDBACK",
1, 1,
) )
monkeypatch.setattr(TelemetryClient, "is_active", lambda self: True)
class FeedbackBarSnapshotApp(BaseSnapshotTestApp): class FeedbackBarSnapshotApp(BaseSnapshotTestApp):

View file

@ -581,6 +581,60 @@ async def test_non_retryable_passes_through_non_streaming(observer_capture) -> N
assert agent.session_logger.save_interaction.await_count == 1 assert agent.session_logger.save_interaction.await_count == 1
def _wrap_with_cause(message: str, cause: BaseException) -> RuntimeError:
# Mirrors how Temporal raises ``ActivityError`` on the workflow side with
# the original ``ApplicationError`` chained as ``__cause__`` — except we
# don't import temporalio. The wrap-site check has to traverse the chain
# to find ``non_retryable`` regardless of how deep it is.
error = RuntimeError(message)
error.__cause__ = cause
return error
@pytest.mark.asyncio
async def test_non_retryable_via_cause_chain_streaming(observer_capture) -> None:
observed, observer = observer_capture
wrapped = _wrap_with_cause(
"Activity task failed", _NonRetryableError("auth failed")
)
backend = FakeBackend(exception_to_raise=wrapped)
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(RuntimeError, match="Activity task failed"):
[_ async for _ in agent.act("Trigger non-retryable via cause chain")]
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_via_cause_chain_non_streaming(observer_capture) -> None:
observed, observer = observer_capture
wrapped = _wrap_with_cause(
"Activity task failed", _NonRetryableError("auth failed")
)
backend = FakeBackend(exception_to_raise=wrapped)
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(RuntimeError, match="Activity task failed"):
[_ async for _ in agent.act("Trigger non-retryable via cause chain")]
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

@ -80,8 +80,15 @@ 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) == 0 assert len(new_session) == 0
session_closed = [
e for e in telemetry_events if e.get("event_name") == "vibe.session_closed"
]
assert len(session_closed) == 1
assert session_closed[0]["properties"]["agent_entrypoint"] == "programmatic"
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

@ -3,6 +3,8 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
import pytest
from vibe.cli.history_manager import HistoryManager from vibe.cli.history_manager import HistoryManager
@ -74,6 +76,45 @@ def test_history_manager_stores_slash_prefixed_entries(tmp_path: Path) -> None:
assert reloaded.get_previous(current_input="") is None assert reloaded.get_previous(current_input="") is None
def test_history_manager_keeps_entries_when_reload_read_fails(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
history_file = tmp_path / "history.jsonl"
manager = HistoryManager(history_file)
manager.add("first")
def raise_os_error(*args: object, **kwargs: object) -> None:
raise OSError
monkeypatch.setattr("vibe.cli.history_manager.read_safe", raise_os_error)
manager.add("second")
assert manager.get_previous(current_input="") == "second"
assert manager.get_previous(current_input="") == "first"
def test_history_manager_resets_navigation_when_add_is_duplicate(
tmp_path: Path,
) -> None:
history_file = tmp_path / "history.jsonl"
manager = HistoryManager(history_file, max_entries=2)
manager.add("a")
manager.add("b")
other = HistoryManager(history_file, max_entries=10)
other.add("c")
other.add("d")
other.add("e")
assert manager.get_previous(current_input="") == "b"
manager.add("e")
assert manager.get_previous(current_input="") == "e"
assert manager.get_previous(current_input="") == "d"
assert manager.get_previous(current_input="") is None
def test_history_manager_allows_navigation_round_trip(tmp_path: Path) -> None: def test_history_manager_allows_navigation_round_trip(tmp_path: Path) -> None:
history_file = tmp_path / "history.jsonl" history_file = tmp_path / "history.jsonl"
manager = HistoryManager(history_file) manager = HistoryManager(history_file)

View file

@ -48,19 +48,28 @@ def _otel_provider(monkeypatch: pytest.MonkeyPatch):
class TestSetupTracing: class TestSetupTracing:
def test_noop_when_disabled(self) -> None: def test_noop_when_disabled(self) -> None:
config = MagicMock(enable_otel=False) config = MagicMock(enable_telemetry=True, enable_otel=False)
with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set:
setup_tracing(config)
mock_set.assert_not_called()
def test_noop_when_telemetry_disabled(self) -> None:
config = MagicMock(enable_telemetry=False, enable_otel=True)
with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set: with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set:
setup_tracing(config) setup_tracing(config)
mock_set.assert_not_called() mock_set.assert_not_called()
def test_noop_when_exporter_config_is_none(self) -> None: def test_noop_when_exporter_config_is_none(self) -> None:
config = MagicMock(enable_otel=True, otel_span_exporter_config=None) config = MagicMock(
enable_telemetry=True, enable_otel=True, otel_span_exporter_config=None
)
with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set: with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set:
setup_tracing(config) setup_tracing(config)
mock_set.assert_not_called() mock_set.assert_not_called()
def test_configures_provider_from_exporter_config(self) -> None: def test_configures_provider_from_exporter_config(self) -> None:
config = MagicMock( config = MagicMock(
enable_telemetry=True,
enable_otel=True, enable_otel=True,
otel_span_exporter_config=OtelSpanExporterConfig( otel_span_exporter_config=OtelSpanExporterConfig(
endpoint="https://customer.mistral.ai/telemetry/v1/traces", endpoint="https://customer.mistral.ai/telemetry/v1/traces",
@ -85,6 +94,7 @@ class TestSetupTracing:
def test_custom_endpoint_has_no_auth_headers(self) -> None: def test_custom_endpoint_has_no_auth_headers(self) -> None:
config = MagicMock( config = MagicMock(
enable_telemetry=True,
enable_otel=True, enable_otel=True,
otel_span_exporter_config=OtelSpanExporterConfig( otel_span_exporter_config=OtelSpanExporterConfig(
endpoint="https://my-collector:4318/v1/traces" endpoint="https://my-collector:4318/v1/traces"

4
uv.lock generated
View file

@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
[options] [options]
exclude-newer = "2026-04-28T08:28:58.401523Z" exclude-newer = "2026-04-29T14:28:22.645142Z"
exclude-newer-span = "P7D" exclude-newer-span = "P7D"
[options.exclude-newer-package] [options.exclude-newer-package]
@ -820,7 +820,7 @@ wheels = [
[[package]] [[package]]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.9.4" version = "2.9.5"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "agent-client-protocol" }, { name = "agent-client-protocol" },

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.4" __version__ = "2.9.5"

View file

@ -7,6 +7,7 @@ import inspect
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
import signal
import sys import sys
from typing import Any, cast, override from typing import Any, cast, override
from uuid import uuid4 from uuid import uuid4
@ -994,11 +995,22 @@ class VibeAcpAgentLoop(AcpAgent):
session = self._get_session(session_id) session = self._get_session(session_id)
self.sessions.pop(session_id, None) self.sessions.pop(session_id, None)
session.agent_loop.emit_session_closed_telemetry()
await session.close() await session.close()
await self._close_agent_loop(session.agent_loop) await self._close_agent_loop(session.agent_loop)
return CloseSessionResponse() return CloseSessionResponse()
async def emit_session_closed_for_active_sessions(self) -> None:
agent_loops = [session.agent_loop for session in self.sessions.values()]
for agent_loop in agent_loops:
agent_loop.telemetry_client._client = None
agent_loop.emit_session_closed_telemetry()
await asyncio.gather(
*(agent_loop.telemetry_client.aclose() for agent_loop in agent_loops),
return_exceptions=True,
)
async def _close_agent_loop(self, agent_loop: AgentLoop) -> None: async def _close_agent_loop(self, agent_loop: AgentLoop) -> None:
deferred_init_thread = agent_loop._deferred_init_thread deferred_init_thread = agent_loop._deferred_init_thread
if deferred_init_thread is not None and deferred_init_thread.is_alive(): if deferred_init_thread is not None and deferred_init_thread.is_alive():
@ -1248,6 +1260,7 @@ class VibeAcpAgentLoop(AcpAgent):
tool_call_id = str(uuid4()) tool_call_id = str(uuid4())
old_tokens = session.agent_loop.stats.context_tokens old_tokens = session.agent_loop.stats.context_tokens
old_session_id = session.agent_loop.session_id
parts = text_prompt.strip().split(None, 1) parts = text_prompt.strip().split(None, 1)
cmd_args = parts[1] if len(parts) > 1 else "" cmd_args = parts[1] if len(parts) > 1 else ""
@ -1268,6 +1281,8 @@ class VibeAcpAgentLoop(AcpAgent):
old_context_tokens=old_tokens or 0, old_context_tokens=old_tokens or 0,
new_context_tokens=new_tokens or 0, new_context_tokens=new_tokens or 0,
summary_length=0, summary_length=0,
old_session_id=old_session_id,
new_session_id=session.agent_loop.session_id,
tool_call_id=tool_call_id, tool_call_id=tool_call_id,
) )
await self.client.session_update( await self.client.session_update(
@ -1419,19 +1434,48 @@ class VibeAcpAgentLoop(AcpAgent):
return await self._command_reply(session, DATA_RETENTION_MESSAGE, message_id) return await self._command_reply(session, DATA_RETENTION_MESSAGE, message_id)
SESSION_CLOSED_FLUSH_TIMEOUT_SECONDS = 1.0
def run_acp_server() -> None: def run_acp_server() -> None:
agent = VibeAcpAgentLoop()
install_sigterm_flush = TelemetryClient(config_getter=VibeConfig.load).is_active()
received_sigterm = False
previous_sigterm_handler = signal.getsignal(signal.SIGTERM)
def _handle_sigterm(_signum: int, _frame: Any) -> None:
nonlocal received_sigterm
received_sigterm = True
raise KeyboardInterrupt
if install_sigterm_flush:
signal.signal(signal.SIGTERM, _handle_sigterm)
try: try:
asyncio.run( asyncio.run(
run_agent( run_agent(
agent=VibeAcpAgentLoop(), agent=agent,
use_unstable_protocol=True, use_unstable_protocol=True,
observers=[acp_message_observer], observers=[acp_message_observer],
) )
) )
except KeyboardInterrupt: except KeyboardInterrupt:
if received_sigterm:
signal.signal(signal.SIGTERM, previous_sigterm_handler)
try:
asyncio.run(
asyncio.wait_for(
agent.emit_session_closed_for_active_sessions(),
timeout=SESSION_CLOSED_FLUSH_TIMEOUT_SECONDS,
)
)
except (TimeoutError, Exception):
pass
# This is expected when the server is terminated # This is expected when the server is terminated
pass pass
except Exception as e: except Exception as e:
# Log any unexpected errors # Log any unexpected errors
print(f"ACP Agent Server error: {e}", file=sys.stderr) print(f"ACP Agent Server error: {e}", file=sys.stderr)
raise raise
finally:
if install_sigterm_flush:
signal.signal(signal.SIGTERM, previous_sigterm_handler)

View file

@ -60,6 +60,7 @@ def bootstrap_config_files() -> None:
raise raise
# When DEBUG_MODE=true, attaches debugpy on localhost:5678.
def handle_debug_mode() -> None: def handle_debug_mode() -> None:
if os.environ.get("DEBUG_MODE") != "true": if os.environ.get("DEBUG_MODE") != "true":
return return

View file

@ -231,7 +231,10 @@ def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgres
type="text", type="text",
text=( text=(
compact_reduction_display( compact_reduction_display(
event.old_context_tokens, event.new_context_tokens event.old_context_tokens,
event.new_context_tokens,
old_session_id=event.old_session_id,
new_session_id=event.new_session_id,
) )
), ),
), ),

View file

@ -32,7 +32,7 @@ class SlashCommandController:
return return
suggestions = self._completer.get_completion_items(text, cursor_index) suggestions = self._completer.get_completion_items(text, cursor_index)
if suggestions and all(alias != text for alias, _ in suggestions): if suggestions:
self._suggestions = suggestions self._suggestions = suggestions
self._selected_index = 0 self._selected_index = 0
self._view.render_completion_suggestions( self._view.render_completion_suggestions(

View file

@ -42,10 +42,10 @@ def _build_cli_entrypoint_metadata() -> EntrypointMetadata:
) )
def get_initial_agent_name(args: argparse.Namespace) -> str: def get_initial_agent_name(args: argparse.Namespace, config: VibeConfig) -> str:
if args.prompt is not None and args.agent == BuiltinAgentName.DEFAULT: if args.prompt is not None and args.agent is None:
return BuiltinAgentName.AUTO_APPROVE return BuiltinAgentName.AUTO_APPROVE
return args.agent return args.agent or config.default_agent
def get_prompt_from_stdin() -> str | None: def get_prompt_from_stdin() -> str | None:
@ -196,9 +196,9 @@ def run_cli(args: argparse.Namespace) -> None:
sys.exit(0) sys.exit(0)
try: try:
initial_agent_name = get_initial_agent_name(args)
is_interactive = args.prompt is None is_interactive = args.prompt is None
config = load_config_or_exit(interactive=is_interactive) config = load_config_or_exit(interactive=is_interactive)
initial_agent_name = get_initial_agent_name(args, config)
hook_config_result = load_hooks_from_fs(config) hook_config_result = load_hooks_from_fs(config)
setup_tracing(config) setup_tracing(config)
@ -243,10 +243,11 @@ def run_cli(args: argparse.Namespace) -> None:
except TeleportError as e: except TeleportError as e:
print(f"Teleport error: {e}", file=sys.stderr) print(f"Teleport error: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
except RuntimeError as e: except (RuntimeError, ValueError) as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
else: else:
try:
agent_loop = AgentLoop( agent_loop = AgentLoop(
config, config,
agent_name=initial_agent_name, agent_name=initial_agent_name,
@ -255,6 +256,9 @@ def run_cli(args: argparse.Namespace) -> None:
defer_heavy_init=True, defer_heavy_init=True,
hook_config_result=hook_config_result, hook_config_result=hook_config_result,
) )
except ValueError as e:
rprint(f"[red]Error:[/] {e}")
sys.exit(1)
if loaded_session: if loaded_session:
_resume_previous_session(agent_loop, *loaded_session) _resume_previous_session(agent_loop, *loaded_session)

View file

@ -161,6 +161,14 @@ class CommandRegistry:
description="Rewind to a previous message", description="Rewind to a previous message",
handler="_start_rewind_mode", handler="_start_rewind_mode",
), ),
"loop": Command(
aliases=frozenset(["/loop"]),
description=(
"Schedule a recurring prompt. "
"Use `/loop <interval> <prompt>`, `/loop list`, or `/loop cancel <id|all>`"
),
handler="_loop_command",
),
"data-retention": Command( "data-retention": Command(
aliases=frozenset(["/data-retention"]), aliases=frozenset(["/data-retention"]),
description="Show data retention information", description="Show data retention information",

View file

@ -8,7 +8,6 @@ import sys
from rich import print as rprint from rich import print as rprint
from vibe import __version__ from vibe import __version__
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config.harness_files import init_harness_files_manager from vibe.core.config.harness_files import init_harness_files_manager
from vibe.core.trusted_folders import find_trustable_files, trusted_folders_manager from vibe.core.trusted_folders import find_trustable_files, trusted_folders_manager
from vibe.setup.trusted_folders.trust_folder_dialog import ( from vibe.setup.trusted_folders.trust_folder_dialog import (
@ -18,7 +17,18 @@ from vibe.setup.trusted_folders.trust_folder_dialog import (
def parse_arguments() -> argparse.Namespace: def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run the Mistral Vibe interactive CLI") parser = argparse.ArgumentParser(
description="Run the Mistral Vibe interactive CLI",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Environment variables:\n"
" VIBE_HOME Override the Vibe home directory (default: ~/.vibe)\n"
" LOG_LEVEL Logging level: DEBUG, INFO, WARNING (default), ERROR, CRITICAL.\n"
" Logs are written to $VIBE_HOME/logs/vibe.log.\n"
" LOG_MAX_BYTES Max size of vibe.log before rotation (default: 10485760).\n"
" VIBE_* Override any config field (e.g. VIBE_ACTIVE_MODEL=local)."
),
)
parser.add_argument( parser.add_argument(
"-v", "--version", action="version", version=f"%(prog)s {__version__}" "-v", "--version", action="version", version=f"%(prog)s {__version__}"
) )
@ -72,9 +82,12 @@ def parse_arguments() -> argparse.Namespace:
parser.add_argument( parser.add_argument(
"--agent", "--agent",
metavar="NAME", metavar="NAME",
default=BuiltinAgentName.DEFAULT, default=None,
help="Agent to use (builtin: default, plan, accept-edits, auto-approve, " help="Agent to use (builtin: default, plan, accept-edits, auto-approve, "
"or custom from ~/.vibe/agents/NAME.toml)", "or custom from ~/.vibe/agents/NAME.toml). In interactive mode, "
"defaults to the 'default_agent' config setting. In programmatic "
"mode (-p/--prompt), defaults to auto-approve and 'default_agent' "
"is ignored.",
) )
parser.add_argument("--setup", action="store_true", help="Setup API key and exit") parser.add_argument("--setup", action="store_true", help="Setup API key and exit")
parser.add_argument( parser.add_argument(

View file

@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
from pathlib import Path from pathlib import Path
import tempfile
from vibe.core.utils.io import read_safe from vibe.core.utils.io import read_safe
@ -22,12 +24,10 @@ class HistoryManager:
try: try:
text = read_safe(self.history_file).text text = read_safe(self.history_file).text
except OSError: except OSError:
self._entries = []
return return
entries = [] entries = []
for raw_line in text.splitlines(): for raw_line in text.splitlines():
raw_line = raw_line.rstrip("\n\r")
if not raw_line: if not raw_line:
continue continue
try: try:
@ -36,13 +36,24 @@ class HistoryManager:
entry = raw_line entry = raw_line
entries.append(entry if isinstance(entry, str) else str(entry)) entries.append(entry if isinstance(entry, str) else str(entry))
self._entries = entries[-self.max_entries :] self._entries = entries[-self.max_entries :]
self.reset_navigation()
def _save_history(self) -> None: def _save_history(self) -> None:
try: try:
self.history_file.parent.mkdir(parents=True, exist_ok=True) self.history_file.parent.mkdir(parents=True, exist_ok=True)
with self.history_file.open("w", encoding="utf-8") as f: fd, tmp_path = tempfile.mkstemp(
prefix=f".{self.history_file.name}.",
suffix=".tmp",
dir=self.history_file.parent,
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
for entry in self._entries: for entry in self._entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n") f.write(json.dumps(entry, ensure_ascii=False) + "\n")
os.replace(tmp_path, self.history_file)
except OSError:
Path(tmp_path).unlink(missing_ok=True)
raise
except OSError: except OSError:
pass pass
@ -51,6 +62,8 @@ class HistoryManager:
if not text: if not text:
return return
self._load_history()
if self._entries and self._entries[-1] == text: if self._entries and self._entries[-1] == text:
return return
@ -60,7 +73,6 @@ class HistoryManager:
self._entries = self._entries[-self.max_entries :] self._entries = self._entries[-self.max_entries :]
self._save_history() self._save_history()
self.reset_navigation()
def get_previous(self, current_input: str) -> str | None: def get_previous(self, current_input: str) -> str | None:
if not self._entries: if not self._entries:

View file

@ -51,6 +51,7 @@ from vibe.cli.textual_ui.notifications import (
) )
from vibe.cli.textual_ui.quit_manager import QuitManager from vibe.cli.textual_ui.quit_manager import QuitManager
from vibe.cli.textual_ui.remote import RemoteSessionManager, is_progress_event from vibe.cli.textual_ui.remote import RemoteSessionManager, is_progress_event
from vibe.cli.textual_ui.scheduled_loop_runner import ScheduledLoopRunner
from vibe.cli.textual_ui.session_exit import print_session_resume_message from vibe.cli.textual_ui.session_exit import print_session_resume_message
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.cli.textual_ui.widgets.banner.banner import Banner from vibe.cli.textual_ui.widgets.banner.banner import Banner
@ -140,6 +141,7 @@ from vibe.core.session.resume_sessions import (
from vibe.core.session.saved_sessions import update_saved_session_title_at_path 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.telemetry import send_teleport_early_failure_telemetry
from vibe.core.teleport.types import ( from vibe.core.teleport.types import (
TeleportAuthCompleteEvent, TeleportAuthCompleteEvent,
TeleportAuthRequiredEvent, TeleportAuthRequiredEvent,
@ -339,7 +341,6 @@ class VibeApp(App): # noqa: PLR0904
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.scroll_sensitivity_y = 1.0
self.agent_loop = agent_loop self.agent_loop = agent_loop
self._plan_info: PlanInfo | None = None self._plan_info: PlanInfo | None = None
self._voice_manager: VoiceManagerPort = ( self._voice_manager: VoiceManagerPort = (
@ -397,7 +398,17 @@ class VibeApp(App): # noqa: PLR0904
self._rewind_mode = False self._rewind_mode = False
self._rewind_highlighted_widget: UserMessage | None = None self._rewind_highlighted_widget: UserMessage | None = None
self._fatal_init_error = False self._fatal_init_error = False
self._force_quit_task: asyncio.Task[None] | None = None
self.commands = self._build_command_registry() self.commands = self._build_command_registry()
self._loop_runner = ScheduledLoopRunner(
self.agent_loop.session_logger,
can_fire=lambda: (
not self._agent_running and self._current_bottom_app == BottomApp.Input
),
fire=self._handle_user_message,
mount=self._mount_and_scroll,
tools_collapsed=lambda: self._tools_collapsed,
)
def _configure_startup_options(self, startup: StartupOptions | None) -> None: def _configure_startup_options(self, startup: StartupOptions | None) -> None:
opts = startup or StartupOptions() opts = startup or StartupOptions()
@ -504,6 +515,8 @@ class VibeApp(App): # noqa: PLR0904
await self._resolve_plan() await self._resolve_plan()
await self._show_dangerous_directory_warning() await self._show_dangerous_directory_warning()
await self._resume_history_from_messages() await self._resume_history_from_messages()
self._loop_runner.restore_from_session()
self._loop_runner.start()
await self._check_and_show_whats_new() await self._check_and_show_whats_new()
self._schedule_update_notification() self._schedule_update_notification()
if not self._is_resuming_session: if not self._is_resuming_session:
@ -1407,6 +1420,12 @@ class VibeApp(App): # noqa: PLR0904
if show_message: if show_message:
await self._mount_and_scroll(UserMessage("/teleport")) await self._mount_and_scroll(UserMessage("/teleport"))
if not has_history: if not has_history:
send_teleport_early_failure_telemetry(
self.agent_loop.telemetry_client,
stage="no_history",
error_class="TeleportNoHistoryError",
nb_session_messages=len(self.agent_loop.messages[1:]),
)
await self._mount_and_scroll( await self._mount_and_scroll(
ErrorMessage( ErrorMessage(
"No conversation history to teleport.", "No conversation history to teleport.",
@ -1429,6 +1448,12 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(teleport_msg) await self._mount_and_scroll(teleport_msg)
if self._remote_manager.is_active: if self._remote_manager.is_active:
send_teleport_early_failure_telemetry(
self.agent_loop.telemetry_client,
stage="remote_session",
error_class="TeleportRemoteSessionError",
nb_session_messages=len(self.agent_loop.messages[1:]),
)
await loading.remove() await loading.remove()
await self._mount_and_scroll( await self._mount_and_scroll(
ErrorMessage( ErrorMessage(
@ -1818,6 +1843,8 @@ class VibeApp(App): # noqa: PLR0904
f"Session `{short_session_id(session.session_id)}` not found." f"Session `{short_session_id(session.session_id)}` not found."
) )
self._emit_session_closed_for_active_session()
loaded_messages, metadata = SessionLoader.load_session(session_path) loaded_messages, metadata = SessionLoader.load_session(session_path)
if self._chat_input_container: if self._chat_input_container:
self._chat_input_container.set_custom_border(None) self._chat_input_container.set_custom_border(None)
@ -1846,6 +1873,7 @@ class VibeApp(App): # noqa: PLR0904
if self.event_handler: if self.event_handler:
self.event_handler.is_remote = False self.event_handler.is_remote = False
await self._resume_history_from_messages() await self._resume_history_from_messages()
self._loop_runner.restore_from_session()
await self._mount_and_scroll( await self._mount_and_scroll(
UserCommandMessage( UserCommandMessage(
f"Resumed session `{short_session_id(session.session_id)}`" f"Resumed session `{short_session_id(session.session_id)}`"
@ -1856,6 +1884,8 @@ class VibeApp(App): # noqa: PLR0904
await self._remote_manager.attach( await self._remote_manager.attach(
session_id=session.session_id, config=self.config session_id=session.session_id, config=self.config
) )
self._emit_session_closed_for_active_session()
self.agent_loop.session_id = session.session_id
self._refresh_profile_widgets() self._refresh_profile_widgets()
if self._chat_input_container: if self._chat_input_container:
self._chat_input_container.set_custom_border(None) self._chat_input_container.set_custom_border(None)
@ -2018,6 +2048,10 @@ class VibeApp(App): # noqa: PLR0904
) )
) )
async def _loop_command(self, cmd_args: str = "", **kwargs: Any) -> None:
widget = await self._loop_runner.handle_command(cmd_args)
await self._mount_and_scroll(widget)
async def _compact_history(self, cmd_args: str = "", **kwargs: Any) -> None: async def _compact_history(self, cmd_args: str = "", **kwargs: Any) -> None:
if self._agent_running: if self._agent_running:
await self._mount_and_scroll( await self._mount_and_scroll(
@ -2041,22 +2075,32 @@ class VibeApp(App): # noqa: PLR0904
return return
old_tokens = self.agent_loop.stats.context_tokens old_tokens = self.agent_loop.stats.context_tokens
old_session_id = self.agent_loop.session_id
compact_msg = CompactMessage() compact_msg = CompactMessage()
self.event_handler.current_compact = compact_msg self.event_handler.current_compact = compact_msg
await self._mount_and_scroll(compact_msg) await self._mount_and_scroll(compact_msg)
self._agent_task = asyncio.create_task( self._agent_task = asyncio.create_task(
self._run_compact(compact_msg, old_tokens, cmd_args.strip()) self._run_compact(compact_msg, old_tokens, old_session_id, cmd_args.strip())
) )
async def _run_compact( async def _run_compact(
self, compact_msg: CompactMessage, old_tokens: int, extra_instructions: str = "" self,
compact_msg: CompactMessage,
old_tokens: int,
old_session_id: str,
extra_instructions: str = "",
) -> None: ) -> None:
self._agent_running = True self._agent_running = True
try: try:
await self.agent_loop.compact(extra_instructions=extra_instructions) await self.agent_loop.compact(extra_instructions=extra_instructions)
new_tokens = self.agent_loop.stats.context_tokens new_tokens = self.agent_loop.stats.context_tokens
compact_msg.set_complete(old_tokens=old_tokens, new_tokens=new_tokens) compact_msg.set_complete(
old_tokens=old_tokens,
new_tokens=new_tokens,
old_session_id=old_session_id,
new_session_id=self.agent_loop.session_id,
)
except asyncio.CancelledError: except asyncio.CancelledError:
compact_msg.set_error("Compaction interrupted") compact_msg.set_error("Compaction interrupted")
@ -2085,8 +2129,15 @@ class VibeApp(App): # noqa: PLR0904
return short_session_id(self.agent_loop.session_logger.session_id) return short_session_id(self.agent_loop.session_logger.session_id)
async def _exit_app(self, **kwargs: Any) -> None: async def _exit_app(self, **kwargs: Any) -> None:
self._emit_session_closed_for_active_session()
await self._loop_runner.stop()
self._log_reader.shutdown() self._log_reader.shutdown()
await self._narrator_manager.close() await self._narrator_manager.close()
try:
await self.agent_loop.telemetry_client.aclose()
except Exception as exc:
logger.error("Failed to close telemetry client during exit", exc_info=exc)
finally:
self.exit(result=self._get_session_resume_info()) self.exit(result=self._get_session_resume_info())
def _make_default_voice_manager(self) -> VoiceManager: def _make_default_voice_manager(self) -> VoiceManager:
@ -2746,7 +2797,16 @@ class VibeApp(App): # noqa: PLR0904
return return
self._quit_manager.request_confirmation("Ctrl+D") self._quit_manager.request_confirmation("Ctrl+D")
def _emit_session_closed_for_active_session(self) -> None:
self.agent_loop.emit_session_closed_telemetry()
def _force_quit(self) -> None: def _force_quit(self) -> None:
if self._force_quit_task is not None and not self._force_quit_task.done():
return
self._force_quit_task = asyncio.create_task(self._force_quit_async())
async def _force_quit_async(self) -> None:
self._emit_session_closed_for_active_session()
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(): if self._bash_task and not self._bash_task.done():
@ -2755,6 +2815,13 @@ class VibeApp(App): # noqa: PLR0904
self._log_reader.shutdown() self._log_reader.shutdown()
self._narrator_manager.cancel() self._narrator_manager.cancel()
try:
await self.agent_loop.telemetry_client.aclose()
except Exception as exc:
logger.error(
"Failed to close telemetry client during force quit", exc_info=exc
)
finally:
self.exit(result=self._get_session_resume_info()) self.exit(result=self._get_session_resume_info())
def action_scroll_chat_up(self) -> None: def action_scroll_chat_up(self) -> None:

View file

@ -980,7 +980,7 @@ NarratorStatus {
height: auto; height: auto;
background: transparent; background: transparent;
padding: 0; padding: 0;
margin: 0 0 0 1; margin: 0;
} }
#banner-container { #banner-container {

View file

@ -217,7 +217,10 @@ class EventHandler:
async def _handle_compact_end(self, event: CompactEndEvent) -> None: async def _handle_compact_end(self, event: CompactEndEvent) -> None:
if self.current_compact: if self.current_compact:
self.current_compact.set_complete( self.current_compact.set_complete(
old_tokens=event.old_context_tokens, new_tokens=event.new_context_tokens old_tokens=event.old_context_tokens,
new_tokens=event.new_context_tokens,
old_session_id=event.old_session_id,
new_session_id=event.new_session_id,
) )
self.current_compact = None self.current_compact = None

View file

@ -0,0 +1,99 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
import time
from textual.widget import Widget
from vibe.cli.textual_ui.widgets.messages import ErrorMessage, UserCommandMessage
from vibe.core.logger import logger
from vibe.core.loop import (
USAGE_HINT,
LoopErrorResult,
LoopListResult,
LoopManager,
LoopOkResult,
ScheduledLoop,
format_duration,
)
from vibe.core.session.session_logger import SessionLogger
def _format_loop_list(loops: list[ScheduledLoop]) -> str:
if not loops:
return "No scheduled loops."
now = time.time()
rows = ["| Prompt | Next in | Every | ID |", "|--------|------|-------|----|"]
for loop in loops:
remaining = format_duration(max(0, int(loop.next_fire_at - now)), short=True)
interval = format_duration(loop.interval_seconds)
prompt = loop.prompt.replace("|", "\\|").replace("\n", " ")
rows.append(f"| {prompt} | {remaining} | {interval} | `{loop.id}` |")
return "\n".join(rows)
class ScheduledLoopRunner:
def __init__(
self,
session_logger: SessionLogger,
*,
can_fire: Callable[[], bool],
fire: Callable[[str], Awaitable[None]],
mount: Callable[[Widget], Awaitable[None]],
tools_collapsed: Callable[[], bool],
) -> None:
self._session_logger = session_logger
self._manager = LoopManager(session_logger)
self._can_fire = can_fire
self._fire = fire
self._mount = mount
self._tools_collapsed = tools_collapsed
self._task: asyncio.Task[None] | None = None
def restore_from_session(self) -> None:
metadata = self._session_logger.session_metadata
self._manager.restore(list(metadata.loops) if metadata is not None else [])
def start(self) -> None:
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._poll())
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def handle_command(self, cmd_args: str) -> Widget:
result = await self._manager.handle_command(cmd_args)
match result:
case LoopListResult(loops=loops):
return UserCommandMessage(_format_loop_list(loops))
case LoopErrorResult(message=message):
return ErrorMessage(
f"{message}\n{USAGE_HINT}", collapsed=self._tools_collapsed()
)
case LoopOkResult(message=message):
return UserCommandMessage(message)
async def _poll(self) -> None:
while True:
try:
sleep_for = min(self._manager.next_due_in(), 1.0)
await asyncio.sleep(max(0.05, sleep_for))
if not self._can_fire():
continue
due = await self._manager.pop_due()
if due is None:
continue
await self._fire(due.prompt)
await self._mount(UserCommandMessage(f"Loop `{due.id}` fired"))
except asyncio.CancelledError:
return
except Exception as e:
logger.error("Error polling scheduled loops", exc_info=e)

View file

@ -17,6 +17,8 @@ class CompactMessage(StatusMessage):
self.add_class("compact-message") self.add_class("compact-message")
self.old_tokens: int | None = None self.old_tokens: int | None = None
self.new_tokens: int | None = None self.new_tokens: int | None = None
self.old_session_id: str | None = None
self.new_session_id: str | None = None
self.error_message: str | None = None self.error_message: str | None = None
def get_content(self) -> str: def get_content(self) -> str:
@ -26,13 +28,25 @@ class CompactMessage(StatusMessage):
if self.error_message: if self.error_message:
return f"Error: {self.error_message}" return f"Error: {self.error_message}"
return compact_reduction_display(self.old_tokens, self.new_tokens) return compact_reduction_display(
self.old_tokens,
self.new_tokens,
old_session_id=self.old_session_id,
new_session_id=self.new_session_id,
)
def set_complete( def set_complete(
self, old_tokens: int | None = None, new_tokens: int | None = None self,
old_tokens: int | None = None,
new_tokens: int | None = None,
*,
old_session_id: str | None = None,
new_session_id: str | None = None,
) -> None: ) -> None:
self.old_tokens = old_tokens self.old_tokens = old_tokens
self.new_tokens = new_tokens self.new_tokens = new_tokens
self.old_session_id = old_session_id
self.new_session_id = new_session_id
self.stop_spinning(success=True) self.stop_spinning(success=True)
self.post_message(self.Completed(self)) self.post_message(self.Completed(self))

View file

@ -66,6 +66,9 @@ from vibe.core.telemetry.types import (
TelemetryCallType, TelemetryCallType,
TelemetryRequestMetadata, TelemetryRequestMetadata,
) )
from vibe.core.teleport.errors import ServiceTeleportError
from vibe.core.teleport.telemetry import TeleportTelemetryTracker
from vibe.core.teleport.types import TeleportCompleteEvent
from vibe.core.tools.base import ( from vibe.core.tools.base import (
BaseTool, BaseTool,
InvokeContext, InvokeContext,
@ -174,9 +177,18 @@ def _is_context_too_long_error(e: Exception) -> bool:
def _is_non_retryable_error(e: BaseException) -> bool: def _is_non_retryable_error(e: BaseException) -> bool:
# Detect Temporal-style ``non_retryable`` flag without importing temporalio. # Detect Temporal-style ``non_retryable`` flag without importing temporalio.
# Wrapping such an exception in a plain RuntimeError strips the flag, so # Walks ``__cause__`` so an ``ActivityError`` whose cause is a non-retryable
# Temporal's activity retry policy will retry the call until exhaustion. # ``ApplicationError`` is detected too — that's what callers driving the
return bool(getattr(e, "non_retryable", False)) # agent loop from a Temporal activity will see when a sub-activity has
# already failed terminally.
seen: set[int] = set()
current: BaseException | None = e
while current is not None and id(current) not in seen:
if getattr(current, "non_retryable", False):
return True
seen.add(id(current))
current = current.__cause__
return False
def requires_init(fn: Callable[..., Any]) -> Callable[..., Any]: def requires_init(fn: Callable[..., Any]) -> Callable[..., Any]:
@ -504,6 +516,9 @@ class AgentLoop:
def emit_ready_telemetry(self, init_duration_ms: int) -> None: def emit_ready_telemetry(self, init_duration_ms: int) -> None:
self.telemetry_client.send_ready(init_duration_ms=init_duration_ms) self.telemetry_client.send_ready(init_duration_ms=init_duration_ms)
def emit_session_closed_telemetry(self) -> None:
self.telemetry_client.send_session_closed()
def _create_connector_registry(self) -> ConnectorRegistry | None: def _create_connector_registry(self) -> ConnectorRegistry | None:
if not connectors_enabled(): if not connectors_enabled():
return None return None
@ -592,16 +607,25 @@ class AgentLoop:
async def teleport_to_vibe_code( async def teleport_to_vibe_code(
self, prompt: str | None self, prompt: str | None
) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]: ) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]:
from vibe.core.teleport.errors import ServiceTeleportError
from vibe.core.teleport.nuage import TeleportSession from vibe.core.teleport.nuage import TeleportSession
session_messages = [
msg.model_dump(exclude_none=True) for msg in self.messages[1:]
]
telemetry_tracker = TeleportTelemetryTracker(
telemetry_client=self.telemetry_client,
nb_session_messages=len(session_messages),
stage="no_history"
if prompt is None and not session_messages
else "git_check",
)
session = TeleportSession( session = TeleportSession(
metadata={ metadata={
"agent": self.agent_profile.name, "agent": self.agent_profile.name,
"model": self.config.active_model, "model": self.config.active_model,
"stats": self.stats.model_dump(), "stats": self.stats.model_dump(),
}, },
messages=[msg.model_dump(exclude_none=True) for msg in self.messages[1:]], messages=session_messages,
) )
try: try:
async with self.teleport_service: async with self.teleport_service:
@ -610,12 +634,23 @@ class AgentLoop:
while True: while True:
try: try:
event = await gen.asend(response) event = await gen.asend(response)
telemetry_tracker.record_event(event)
if isinstance(event, TeleportCompleteEvent):
telemetry_tracker.send_success()
response = yield event response = yield event
except StopAsyncIteration: except StopAsyncIteration:
break break
except ServiceTeleportError as e: except ServiceTeleportError as e:
telemetry_tracker.record_service_error(e)
raise TeleportError(str(e)) from e raise TeleportError(str(e)) from e
except (asyncio.CancelledError, GeneratorExit):
telemetry_tracker.record_cancelled()
raise
except Exception as e:
telemetry_tracker.record_unexpected_error(e)
raise
finally: finally:
telemetry_tracker.send_failure_if_needed()
self._teleport_service = None self._teleport_service = None
def _setup_middleware(self) -> None: def _setup_middleware(self) -> None:
@ -709,6 +744,8 @@ class AgentLoop:
old_context_tokens=old_tokens, old_context_tokens=old_tokens,
new_context_tokens=new_tokens, new_context_tokens=new_tokens,
summary_length=len(summary), summary_length=len(summary),
old_session_id=old_session_id,
new_session_id=self.session_id,
) )
case MiddlewareAction.CONTINUE: case MiddlewareAction.CONTINUE:
@ -1395,8 +1432,9 @@ class AgentLoop:
responded_ids: set[str] = set() responded_ids: set[str] = set()
j = i + 1 j = i + 1
while j < len(self.messages) and self.messages[j].role == "tool": while j < len(self.messages) and self.messages[j].role == "tool":
if self.messages[j].tool_call_id: tool_call_id = self.messages[j].tool_call_id
responded_ids.add(self.messages[j].tool_call_id) if tool_call_id is not None:
responded_ids.add(tool_call_id)
j += 1 j += 1
if len(responded_ids) < expected_responses: if len(responded_ids) < expected_responses:
@ -1431,6 +1469,7 @@ class AgentLoop:
def _reset_session(self, keep_parent: bool = True) -> None: def _reset_session(self, keep_parent: bool = True) -> None:
old_session_id = self.session_id old_session_id = self.session_id
self.emit_session_closed_telemetry()
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)
parent_session_id = old_session_id if keep_parent else None parent_session_id = old_session_id if keep_parent else None

View file

@ -40,18 +40,22 @@ class AgentManager:
" ".join(str(p) for p in self._search_paths), " ".join(str(p) for p in self._search_paths),
) )
profile = self._available.get(initial_agent) available = self.available_agents
if ( profile = available.get(initial_agent)
not allow_subagent if profile is None:
and profile is not None if initial_agent in self._available:
and profile.agent_type != AgentType.AGENT raise ValueError(
): f"Agent '{initial_agent}' is not available. "
f"It may be disabled, not installed, or excluded by your config."
)
raise ValueError(f"Agent '{initial_agent}' not found.")
if not allow_subagent and profile.agent_type != AgentType.AGENT:
raise ValueError( raise ValueError(
f"Agent '{initial_agent}' is a {profile.agent_type} and cannot be used" f"Agent '{initial_agent}' is a {profile.agent_type} and cannot be used"
f" as the primary agent. Only agents of type 'agent' can be selected" f" as the primary agent. Only agents of type 'agent' can be selected"
f" with --agent." f" with --agent."
) )
self.active_profile = profile or self._available[BuiltinAgentName.DEFAULT] self.active_profile = profile
self._cached_config: VibeConfig | None = None self._cached_config: VibeConfig | None = None
@property @property

View file

@ -64,13 +64,16 @@ class CommandCompleter(Completer):
descriptions[alias] = description descriptions[alias] = description
return list(descriptions.keys()), descriptions return list(descriptions.keys()), descriptions
def _head_word(self, text: str, cursor_pos: int) -> str:
head = text.split(" ", 1)[0]
return head[1 : min(cursor_pos, len(head))].lower()
def get_completions(self, text: str, cursor_pos: int) -> list[str]: def get_completions(self, text: str, cursor_pos: int) -> list[str]:
if not text.startswith("/"): if not text.startswith("/"):
return [] return []
aliases, _ = self._build_lookup() aliases, _ = self._build_lookup()
word = text[1:cursor_pos].lower() search_str = "/" + self._head_word(text, cursor_pos)
search_str = "/" + word
return [alias for alias in aliases if alias.lower().startswith(search_str)] return [alias for alias in aliases if alias.lower().startswith(search_str)]
def get_completion_items(self, text: str, cursor_pos: int) -> list[tuple[str, str]]: def get_completion_items(self, text: str, cursor_pos: int) -> list[tuple[str, str]]:
@ -78,8 +81,7 @@ class CommandCompleter(Completer):
return [] return []
aliases, descriptions = self._build_lookup() aliases, descriptions = self._build_lookup()
word = text[1:cursor_pos].lower() search_str = "/" + self._head_word(text, cursor_pos)
search_str = "/" + word
items = [ items = [
(alias, descriptions.get(alias, "")) (alias, descriptions.get(alias, ""))
for alias in aliases for alias in aliases
@ -90,9 +92,11 @@ class CommandCompleter(Completer):
def get_replacement_range( def get_replacement_range(
self, text: str, cursor_pos: int self, text: str, cursor_pos: int
) -> tuple[int, int] | None: ) -> tuple[int, int] | None:
if text.startswith("/"): if not text.startswith("/"):
return (0, cursor_pos)
return None return None
first_space = text.find(" ")
end = cursor_pos if first_space == -1 else min(cursor_pos, first_space)
return (0, end)
class PathCompleter(Completer): class PathCompleter(Completer):

View file

@ -25,6 +25,7 @@ from pydantic_settings import (
) )
import tomli_w import tomli_w
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger from vibe.core.logger import logger
from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
@ -610,6 +611,15 @@ class VibeConfig(BaseSettings):
"A list of opt-in builtin agent names that have been explicitly installed." "A list of opt-in builtin agent names that have been explicitly installed."
), ),
) )
default_agent: str = Field(
default=BuiltinAgentName.DEFAULT,
description=(
"Agent profile to use when no --agent flag is passed in interactive "
"mode. Builtin: default, plan, accept-edits, auto-approve. "
"Ignored in programmatic mode (-p/--prompt), which falls back to "
"auto-approve when --agent is not provided."
),
)
skill_paths: list[Path] = Field( skill_paths: list[Path] = Field(
default_factory=list, default_factory=list,
description=( description=(

View file

@ -45,6 +45,8 @@ def apply_logging_config(target_logger: logging.Logger) -> None:
max_bytes = int(os.environ.get("LOG_MAX_BYTES", 10 * 1024 * 1024)) max_bytes = int(os.environ.get("LOG_MAX_BYTES", 10 * 1024 * 1024))
# DEBUG_MODE is the debugpy switch (see vibe/acp/entrypoint.py);
# it also forces DEBUG-level logging here.
if os.environ.get("DEBUG_MODE") == "true": if os.environ.get("DEBUG_MODE") == "true":
log_level_str = "DEBUG" log_level_str = "DEBUG"
else: else:

250
vibe/core/loop.py Normal file
View file

@ -0,0 +1,250 @@
from __future__ import annotations
from enum import StrEnum, auto
import math
import re
import secrets
import time
from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict
from vibe.core.logger import logger
from vibe.core.types import ScheduledLoop
if TYPE_CHECKING:
from vibe.core.session.session_logger import SessionLogger
__all__ = [
"MAX_LOOPS_PER_SESSION",
"MIN_INTERVAL_SECONDS",
"USAGE_HINT",
"IntervalUnit",
"LoopCommandResult",
"LoopError",
"LoopErrorResult",
"LoopListResult",
"LoopManager",
"LoopOkResult",
"format_duration",
"parse_interval",
]
MIN_INTERVAL_SECONDS = 30
MAX_LOOPS_PER_SESSION = 50
USAGE_HINT = """\
Usage:
/loop <interval> <prompt>
/loop list
/loop cancel <id|all>
"""
_INTERVAL_RE = re.compile(r"^(\d+)([smhd])$")
_CANCEL_VERBS = frozenset({"cancel", "rm", "stop", "delete"})
_LIST_VERBS = frozenset({"list", "ls"})
class LoopError(Exception):
pass
class IntervalUnit(StrEnum):
SECOND = auto()
MINUTE = auto()
HOUR = auto()
DAY = auto()
@property
def seconds(self) -> int:
match self:
case IntervalUnit.SECOND:
return 1
case IntervalUnit.MINUTE:
return 60
case IntervalUnit.HOUR:
return 3600
case IntervalUnit.DAY:
return 86400
@property
def suffix(self) -> str:
match self:
case IntervalUnit.SECOND:
return "s"
case IntervalUnit.MINUTE:
return "m"
case IntervalUnit.HOUR:
return "h"
case IntervalUnit.DAY:
return "d"
@classmethod
def from_suffix(cls, ch: str) -> IntervalUnit:
match ch:
case "s":
return cls.SECOND
case "m":
return cls.MINUTE
case "h":
return cls.HOUR
case "d":
return cls.DAY
case _:
raise LoopError(f"Unknown interval unit `{ch}`.")
def parse_interval(text: str) -> int:
if not text:
raise LoopError("Missing interval.")
match = _INTERVAL_RE.match(text.strip().lower())
if match is None:
raise LoopError(
f"Invalid interval `{text}`. "
"Expected: <number><unit> (e.g., 30s, 5m, 2h, 1d)."
)
value = int(match.group(1))
seconds = value * IntervalUnit.from_suffix(match.group(2)).seconds
if seconds < MIN_INTERVAL_SECONDS:
raise LoopError(f"Interval must be at least {MIN_INTERVAL_SECONDS}s.")
return seconds
def format_duration(seconds: int, short: bool = False) -> str:
parts = []
for unit in (
IntervalUnit.DAY,
IntervalUnit.HOUR,
IntervalUnit.MINUTE,
IntervalUnit.SECOND,
):
value = seconds // unit.seconds
if value > 0:
parts.append(f"{value}{unit.suffix}")
seconds %= unit.seconds
if not parts:
parts = ["0s"]
if short:
return parts[0]
return "".join(parts)
class _LoopResultBase(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
class LoopListResult(_LoopResultBase):
loops: list[ScheduledLoop]
class LoopOkResult(_LoopResultBase):
message: str
class LoopErrorResult(_LoopResultBase):
message: str
LoopCommandResult = LoopListResult | LoopOkResult | LoopErrorResult
class LoopManager:
def __init__(self, session_logger: SessionLogger) -> None:
self._session_logger = session_logger
self._loops: list[ScheduledLoop] = []
@property
def loops(self) -> list[ScheduledLoop]:
return list(self._loops)
def restore(self, loops: list[ScheduledLoop]) -> None:
self._loops = list(loops)
def next_due_in(self, now: float | None = None) -> float:
if not self._loops:
return math.inf
ts = now if now is not None else time.time()
return max(0.0, min(loop.next_fire_at for loop in self._loops) - ts)
async def pop_due(self, now: float | None = None) -> ScheduledLoop | None:
if not self._loops:
return None
ts = now if now is not None else time.time()
due_loops = [loop for loop in self._loops if loop.next_fire_at <= ts]
if not due_loops:
return None
due = min(due_loops, key=lambda loop: loop.next_fire_at)
due.next_fire_at = ts + due.interval_seconds
await self._persist()
return due
async def handle_command(self, args: str) -> LoopCommandResult:
text = args.strip()
if not text:
return self._list_result()
verb, _, rest = text.partition(" ")
verb_lower = verb.lower()
if verb_lower in _LIST_VERBS:
return self._list_result()
if verb_lower in _CANCEL_VERBS:
return await self._cancel(rest.strip())
return await self._add(verb, rest)
def _list_result(self) -> LoopListResult:
return LoopListResult(loops=list(self._loops))
async def _add(
self, interval_text: str, prompt: str
) -> LoopOkResult | LoopErrorResult:
try:
seconds = parse_interval(interval_text)
except LoopError as e:
return LoopErrorResult(message=str(e))
prompt = prompt.strip()
if not prompt:
return LoopErrorResult(message="Missing prompt.")
if prompt.startswith("/"):
return LoopErrorResult(message="Prompt cannot start with '/'.")
if len(self._loops) >= MAX_LOOPS_PER_SESSION:
return LoopErrorResult(
message=f"Loop limit reached ({MAX_LOOPS_PER_SESSION} per session)."
)
now = time.time()
loop = ScheduledLoop(
id=secrets.token_hex(4),
interval_seconds=seconds,
prompt=prompt,
next_fire_at=now + seconds,
created_at=now,
)
self._loops.append(loop)
await self._persist()
return LoopOkResult(
message=(
f"Scheduled loop `{loop.id}` every {format_duration(seconds)}: {prompt}"
)
)
async def _cancel(self, target: str) -> LoopOkResult | LoopErrorResult:
if not target:
return LoopErrorResult(message="Missing loop id.")
if target.lower() == "all":
count = len(self._loops)
self._loops.clear()
await self._persist()
return LoopOkResult(message=f"Cancelled {count} scheduled loop(s).")
match = next((loop for loop in self._loops if loop.id == target), None)
if match is None:
return LoopErrorResult(message=f"No scheduled loop with id `{target}`.")
self._loops.remove(match)
await self._persist()
return LoopOkResult(message=f"Cancelled loop `{match.id}`: {match.prompt}")
async def _persist(self) -> None:
metadata = self._session_logger.session_metadata
if metadata is not None:
metadata.loops = [*self._loops]
try:
await self._session_logger.persist_loops()
except Exception as e:
logger.error("Failed to persist scheduled loops", exc_info=e)

View file

@ -91,6 +91,7 @@ def run_programmatic( # noqa: PLR0913, PLR0917
return formatter.finalize() return formatter.finalize()
finally: finally:
agent_loop.emit_session_closed_telemetry()
await agent_loop.telemetry_client.aclose() await agent_loop.telemetry_client.aclose()
return asyncio.run(_async_run()) return asyncio.run(_async_run())

View file

@ -27,8 +27,8 @@ Never claim completion without verification — a passing test, correct read-bac
Hard Rules: Hard Rules:
Never Commit Never Commit Proactively
Do not run `git commit`, `git push`, or `git add` unless the user explicitly asks you to. Saving files is sufficient — the user will review changes and commit themselves. Do not proactively run `git add`, `git commit`, or `git push`. Saving files is the default — the user usually reviews and commits themselves. If the user explicitly asks you to stage, commit, or push, do it.
Respect User Constraints Respect User Constraints
"No writes", "just analyze", "plan only", "don't touch X" - these are hard constraints. Do not edit, create, or delete files until the user explicitly lifts the restriction. Violation of explicit user instructions is the worst failure mode. "No writes", "just analyze", "plan only", "don't touch X" - these are hard constraints. Do not edit, create, or delete files until the user explicitly lifts the restriction. Violation of explicit user instructions is the worst failure mode.

View file

@ -4,6 +4,7 @@ from pathlib import Path
import tempfile import tempfile
from vibe.core.logger import logger from vibe.core.logger import logger
from vibe.core.session.session_id import shorten_session_id
_active_scratchpads: dict[str, Path] = {} _active_scratchpads: dict[str, Path] = {}
@ -17,7 +18,11 @@ def init_scratchpad(session_id: str) -> Path | None:
return _active_scratchpads[session_id] return _active_scratchpads[session_id]
try: try:
dir_path = Path(tempfile.mkdtemp(prefix=f"vibe-scratchpad-{session_id[:8]}-")) dir_path = Path(
tempfile.mkdtemp(
prefix=f"vibe-scratchpad-{shorten_session_id(session_id)}-"
)
)
_active_scratchpads[session_id] = dir_path _active_scratchpads[session_id] = dir_path
logger.debug("Scratchpad initialized at %s", dir_path) logger.debug("Scratchpad initialized at %s", dir_path)
return dir_path return dir_path

View file

@ -8,17 +8,14 @@ from vibe.core.config import VibeConfig
from vibe.core.logger import logger from vibe.core.logger import logger
from vibe.core.nuage.client import WorkflowsClient from vibe.core.nuage.client import WorkflowsClient
from vibe.core.nuage.workflow import WorkflowExecutionStatus from vibe.core.nuage.workflow import WorkflowExecutionStatus
from vibe.core.session.session_id import shorten_session_id
from vibe.core.session.session_loader import SessionLoader from vibe.core.session.session_loader import SessionLoader
ResumeSessionSource = Literal["local", "remote"] ResumeSessionSource = Literal["local", "remote"]
SHORT_SESSION_ID_LEN = 8
def short_session_id(session_id: str, source: ResumeSessionSource = "local") -> str: def short_session_id(session_id: str, source: ResumeSessionSource = "local") -> str:
if source == "remote": return shorten_session_id(session_id, from_end=source == "remote")
return session_id[-SHORT_SESSION_ID_LEN:]
return session_id[:SHORT_SESSION_ID_LEN]
_ACTIVE_STATUSES = [ _ACTIVE_STATUSES = [

View file

@ -22,3 +22,13 @@ def generate_session_id(*, suffix: str | None = None) -> str:
def extract_suffix(session_id: str) -> str: def extract_suffix(session_id: str) -> str:
"""Extract the stable suffix (last segment after the final hyphen).""" """Extract the stable suffix (last segment after the final hyphen)."""
return session_id.rsplit("-", 1)[-1] return session_id.rsplit("-", 1)[-1]
_SHORT_LEN = 8
def shorten_session_id(session_id: str, *, from_end: bool = False) -> str:
"""Return a short human-readable slice of a session ID (8 chars)."""
if from_end:
return session_id[-_SHORT_LEN:]
return session_id[:_SHORT_LEN]

View file

@ -5,6 +5,7 @@ import json
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, TypedDict from typing import TYPE_CHECKING, Any, TypedDict
from vibe.core.session.session_id import shorten_session_id
from vibe.core.types import LLMMessage, SessionMetadata from vibe.core.types import LLMMessage, SessionMetadata
from vibe.core.utils.io import read_safe from vibe.core.utils.io import read_safe
@ -129,7 +130,7 @@ class SessionLoader:
if not save_dir.exists(): if not save_dir.exists():
return [] return []
short_id = session_id[:8] short_id = shorten_session_id(session_id)
return list(save_dir.glob(f"{config.session_prefix}_*_{short_id}")) return list(save_dir.glob(f"{config.session_prefix}_*_{short_id}"))
@staticmethod @staticmethod

View file

@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any, Literal
from anyio import NamedTemporaryFile, Path as AsyncPath from anyio import NamedTemporaryFile, Path as AsyncPath
from vibe.core.session.session_id import shorten_session_id
from vibe.core.session.session_loader import ( from vibe.core.session.session_loader import (
MESSAGES_FILENAME, MESSAGES_FILENAME,
METADATA_FILENAME, METADATA_FILENAME,
@ -63,9 +64,20 @@ class SessionLogger:
) )
timestamp = utc_now().strftime("%Y%m%d_%H%M%S") timestamp = utc_now().strftime("%Y%m%d_%H%M%S")
folder_name = f"{self.session_prefix}_{timestamp}_{self.session_id[:8]}" folder_name = (
f"{self.session_prefix}_{timestamp}_{shorten_session_id(self.session_id)}"
)
return self.save_dir / folder_name return self.save_dir / folder_name
def _get_session_info(self) -> tuple[Path, SessionMetadata] | None:
if (
not self.enabled
or self.session_dir is None
or self.session_metadata is None
):
return None
return (self.session_dir, self.session_metadata)
@property @property
def metadata_filepath(self) -> Path: def metadata_filepath(self) -> Path:
if self.session_dir is None: if self.session_dir is None:
@ -244,34 +256,34 @@ class SessionLogger:
tool_manager: ToolManager, tool_manager: ToolManager,
agent_profile: AgentProfile, agent_profile: AgentProfile,
) -> None: ) -> None:
if not self.enabled or self.session_dir is None: session_info = self._get_session_info()
return if session_info is None:
if self.session_metadata is None:
return return
session_dir, session_metadata = session_info
metadata_path = session_dir / METADATA_FILENAME
if not any(msg.role != Role.system for msg in messages): if not any(msg.role != Role.system for msg in messages):
return return
# If the session directory does not exist, create it # If the session directory does not exist, create it
try: try:
self.session_dir.mkdir(parents=True, exist_ok=True) session_dir.mkdir(parents=True, exist_ok=True)
except OSError as e: except OSError as e:
raise RuntimeError( raise RuntimeError(
f"Failed to create session directory at {self.session_dir}: {type(e).__name__}: {e}" f"Failed to create session directory at {session_dir}: {type(e).__name__}: {e}"
) from e ) from e
# Read old metadata and get total_messages # Read old metadata and get total_messages
try: try:
if self.metadata_filepath.exists(): if metadata_path.exists():
raw = (await read_safe_async(self.metadata_filepath)).text raw = (await read_safe_async(metadata_path)).text
old_metadata = json.loads(raw) old_metadata = json.loads(raw)
old_total_messages = old_metadata["total_messages"] old_total_messages = old_metadata["total_messages"]
else: else:
old_total_messages = 0 old_total_messages = 0
except Exception as e: except Exception as e:
raise RuntimeError( raise RuntimeError(
f"Failed to read session metadata at {self.metadata_filepath}: {e}" f"Failed to read session metadata at {metadata_path}: {e}"
) from e ) from e
try: try:
@ -283,7 +295,7 @@ class SessionLogger:
return return
messages_data = [m.model_dump(exclude_none=True) for m in new_messages] messages_data = [m.model_dump(exclude_none=True) for m in new_messages]
await SessionLogger.persist_messages(messages_data, self.session_dir) await SessionLogger.persist_messages(messages_data, session_dir)
# If message update succeeded, write metadata # If message update succeeded, write metadata
tools_available = [ tools_available = [
@ -307,7 +319,7 @@ class SessionLogger:
total_messages = len(non_system_messages) total_messages = len(non_system_messages)
metadata_dump = { metadata_dump = {
**self.session_metadata.model_dump(), **session_metadata.model_dump(),
"end_time": utc_now().isoformat(), "end_time": utc_now().isoformat(),
"stats": stats.model_dump(), "stats": stats.model_dump(),
"title": title, "title": title,
@ -321,14 +333,32 @@ class SessionLogger:
"system_prompt": system_prompt, "system_prompt": system_prompt,
} }
await SessionLogger.persist_metadata(metadata_dump, self.session_dir) await SessionLogger.persist_metadata(metadata_dump, session_dir)
except Exception as e: except Exception as e:
raise RuntimeError( raise RuntimeError(f"Failed to save session to {session_dir}: {e}") from e
f"Failed to save session to {self.session_dir}: {e}"
) from e
finally: finally:
self.maybe_cleanup_tmp_files() self.maybe_cleanup_tmp_files()
async def persist_loops(self) -> None:
session_info = self._get_session_info()
if session_info is None:
return
session_dir, session_metadata = session_info
metadata_path = session_dir / METADATA_FILENAME
if not metadata_path.exists():
return
try:
raw = (await read_safe_async(metadata_path)).text
metadata = json.loads(raw)
except (OSError, json.JSONDecodeError) as e:
raise RuntimeError(
f"Failed to read session metadata at {metadata_path}: {e}"
) from e
metadata["loops"] = [
loop.model_dump(mode="json") for loop in session_metadata.loops
]
await SessionLogger.persist_metadata(metadata, session_dir)
def reset_session( def reset_session(
self, session_id: str, *, parent_session_id: str | None = None self, session_id: str, *, parent_session_id: str | None = None
) -> None: ) -> None:

View file

@ -194,6 +194,15 @@ disabled_agents = ["auto-approve"]
# Opt-in builtin agents (only affects agents with install_required=True, e.g. lean) # Opt-in builtin agents (only affects agents with install_required=True, e.g. lean)
installed_agents = ["lean"] installed_agents = ["lean"]
# Agent profile to use when --agent is not passed in interactive mode
# (default: "default"). Valid values: "default", "plan", "accept-edits",
# "auto-approve", "lean" (only when listed in installed_agents), or any
# custom agent name from ~/.vibe/agents/ or .vibe/agents/. Subagents
# (e.g. "explore") are rejected. Ignored in programmatic mode
# (-p/--prompt), which falls back to "auto-approve" when --agent is not
# provided.
default_agent = "plan"
``` ```
### MCP Servers ### MCP Servers
@ -321,7 +330,7 @@ Tool, skill, and agent names support three matching modes:
``` ```
vibe [PROMPT] # Start interactive session with optional prompt vibe [PROMPT] # Start interactive session with optional prompt
vibe -p TEXT / --prompt TEXT # Programmatic mode (auto-approve, one-shot, exit) vibe -p TEXT / --prompt TEXT # Programmatic mode (auto-approve, one-shot, exit)
vibe --agent NAME # Select agent profile vibe --agent NAME # Select agent profile (falls back to `default_agent` config)
vibe --workdir DIR # Change working directory vibe --workdir DIR # Change working directory
vibe --trust # Trust cwd for this invocation only (not persisted) vibe --trust # Trust cwd for this invocation only (not persisted)
vibe -c / --continue # Continue most recent session vibe -c / --continue # Continue most recent session
@ -374,6 +383,15 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`.
- `/mcp` - Display available MCP servers (pass a server name to list its tools) - `/mcp` - Display available MCP servers (pass a server name to list its tools)
- `/resume` (or `/continue`) - Browse and resume past sessions - `/resume` (or `/continue`) - Browse and resume past sessions
- `/rewind` - Rewind to a previous message - `/rewind` - Rewind to a previous message
- `/loop <interval> <prompt>` - Schedule a recurring prompt (e.g. `/loop 30s ping`).
Intervals: `Ns/Nm/Nh/Nd`, minimum 30s, max 50 loops/session.
- `/loop` (or `/loop list` / `/loop ls`) - List current scheduled loops.
- `/loop cancel <id|all>` (aliases `rm`, `stop`, `delete`) - Cancel a loop.
- Loops fire only when the agent is idle and the input bar is focused. At
most one loop fires per poll. Overdue loops fire once on the next poll
(no catch-up); `next_fire_at` advances to `now + interval`.
- Loops are persisted in the session metadata (`loops` field of `meta.json`)
and restored on `--resume`/`--continue`.
- `/terminal-setup` - Configure Shift+Enter for newlines - `/terminal-setup` - Configure Shift+Enter for newlines
- `/proxy-setup` - Configure proxy and SSL certificate settings - `/proxy-setup` - Configure proxy and SSL certificate settings
- `/leanstall` - Install the Lean 4 agent (leanstral) - `/leanstall` - Install the Lean 4 agent (leanstral)
@ -415,6 +433,13 @@ Detailed instructions for the model...
- `MISTRAL_API_KEY` - API key for Mistral provider - `MISTRAL_API_KEY` - API key for Mistral provider
- `VIBE_ACTIVE_MODEL` - Override active model - `VIBE_ACTIVE_MODEL` - Override active model
- `VIBE_*` - Any config field can be overridden with the `VIBE_` prefix - `VIBE_*` - Any config field can be overridden with the `VIBE_` prefix
- `LOG_LEVEL` - Logging level for `$VIBE_HOME/logs/vibe.log`. One of `DEBUG`,
`INFO`, `WARNING` (default), `ERROR`, `CRITICAL`. Invalid values fall back
to `WARNING`.
- `LOG_MAX_BYTES` - Max size in bytes of `vibe.log` before rotation
(default: `10485760`, i.e. 10 MiB).
- `DEBUG_MODE` - When `true`, forces `DEBUG`-level logging. Under `vibe-acp`
it also attaches `debugpy` on `localhost:5678`.
## API Keys (.env file) ## API Keys (.env file)

View file

@ -16,6 +16,9 @@ from vibe.core.telemetry.types import (
AgentEntrypoint, AgentEntrypoint,
EntrypointMetadata, EntrypointMetadata,
TelemetryCallType, TelemetryCallType,
TeleportCompletedPayload,
TeleportFailedPayload,
TeleportFailureStage,
) )
from vibe.core.utils import get_server_url_from_api_base, get_user_agent from vibe.core.utils import get_server_url_from_api_base, get_user_agent
@ -63,7 +66,7 @@ class TelemetryClient:
def _get_mistral_provider_and_api_key(self) -> tuple[ProviderConfig, str] | None: def _get_mistral_provider_and_api_key(self) -> tuple[ProviderConfig, str] | None:
try: try:
provider = self._config_getter().get_mistral_provider() provider = self._config_getter().get_mistral_provider()
except ValueError: except Exception:
return None return None
if provider is None: if provider is None:
return None return None
@ -77,7 +80,7 @@ class TelemetryClient:
"""Check if telemetry is enabled in the current config.""" """Check if telemetry is enabled in the current config."""
try: try:
return self._config_getter().enable_telemetry return self._config_getter().enable_telemetry
except ValueError: except Exception:
return False return False
def is_active(self) -> bool: def is_active(self) -> bool:
@ -270,6 +273,9 @@ class TelemetryClient:
} }
self.send_telemetry_event("vibe.new_session", payload) self.send_telemetry_event("vibe.new_session", payload)
def send_session_closed(self) -> None:
self.send_telemetry_event("vibe.session_closed", {})
def send_onboarding_api_key_added(self) -> None: def send_onboarding_api_key_added(self) -> None:
self.send_telemetry_event( self.send_telemetry_event(
"vibe.onboarding_api_key_added", {"version": __version__} "vibe.onboarding_api_key_added", {"version": __version__}
@ -322,3 +328,35 @@ class TelemetryClient:
{"rating": rating, "version": __version__, "model": model}, {"rating": rating, "version": __version__, "model": model},
correlation_id=self.last_correlation_id, correlation_id=self.last_correlation_id,
) )
def send_teleport_completed(
self,
*,
push_required: bool,
github_auth_required: bool,
nb_session_messages: int,
) -> None:
payload: TeleportCompletedPayload = {
"push_required": push_required,
"github_auth_required": github_auth_required,
"nb_session_messages": nb_session_messages,
}
self.send_telemetry_event("vibe.teleport_completed", dict(payload))
def send_teleport_failed(
self,
*,
stage: TeleportFailureStage,
error_class: str,
push_required: bool,
github_auth_required: bool,
nb_session_messages: int,
) -> None:
payload: TeleportFailedPayload = {
"stage": stage,
"error_class": error_class,
"push_required": push_required,
"github_auth_required": github_auth_required,
"nb_session_messages": nb_session_messages,
}
self.send_telemetry_event("vibe.teleport_failed", dict(payload))

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal from typing import Literal, TypedDict
from pydantic import BaseModel from pydantic import BaseModel
@ -36,3 +36,26 @@ class TelemetryRequestMetadata(TelemetryBaseMetadata):
call_type: TelemetryCallType call_type: TelemetryCallType
call_source: str = "vibe_code" call_source: str = "vibe_code"
message_id: str | None = None message_id: str | None = None
TeleportFailureStage = Literal[
"no_history",
"remote_session",
"git_check",
"push",
"workflow_start",
"github_auth",
"fetch_url",
"cancelled",
]
class TeleportCompletedPayload(TypedDict):
push_required: bool
github_auth_required: bool
nb_session_messages: int
class TeleportFailedPayload(TeleportCompletedPayload):
stage: TeleportFailureStage
error_class: str

View file

@ -0,0 +1,94 @@
from __future__ import annotations
from dataclasses import dataclass
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.telemetry.types import TeleportFailureStage
from vibe.core.teleport.errors import ServiceTeleportError
from vibe.core.teleport.types import (
TeleportAuthRequiredEvent,
TeleportCheckingGitEvent,
TeleportCompleteEvent,
TeleportFetchingUrlEvent,
TeleportPushingEvent,
TeleportPushRequiredEvent,
TeleportStartingWorkflowEvent,
TeleportWaitingForGitHubEvent,
TeleportYieldEvent,
)
def send_teleport_early_failure_telemetry(
telemetry_client: TelemetryClient,
*,
stage: TeleportFailureStage,
error_class: str,
nb_session_messages: int,
) -> None:
telemetry_client.send_teleport_failed(
stage=stage,
error_class=error_class,
push_required=False,
github_auth_required=False,
nb_session_messages=nb_session_messages,
)
@dataclass
class TeleportTelemetryTracker:
telemetry_client: TelemetryClient
nb_session_messages: int
stage: TeleportFailureStage
push_required: bool = False
github_auth_required: bool = False
success: bool = False
error_class: str | None = None
def record_event(self, event: TeleportYieldEvent) -> None:
match event:
case TeleportCheckingGitEvent():
self.stage = "git_check"
case TeleportPushRequiredEvent():
self.push_required = True
self.stage = "cancelled"
case TeleportPushingEvent():
self.stage = "push"
case TeleportStartingWorkflowEvent():
self.stage = "workflow_start"
case TeleportWaitingForGitHubEvent():
self.stage = "github_auth"
case TeleportAuthRequiredEvent():
self.github_auth_required = True
self.stage = "github_auth"
case TeleportFetchingUrlEvent():
self.stage = "fetch_url"
case TeleportCompleteEvent():
self.success = True
def record_service_error(self, error: ServiceTeleportError) -> None:
self.error_class = type(error).__name__
def record_cancelled(self) -> None:
self.stage = "cancelled"
self.error_class = "CancelledError"
def record_unexpected_error(self, error: Exception) -> None:
self.error_class = type(error).__name__
def send_success(self) -> None:
self.telemetry_client.send_teleport_completed(
push_required=self.push_required,
github_auth_required=self.github_auth_required,
nb_session_messages=self.nb_session_messages,
)
def send_failure_if_needed(self) -> None:
if self.success or self.error_class is None:
return
self.telemetry_client.send_teleport_failed(
stage=self.stage,
error_class=self.error_class,
push_required=self.push_required,
github_auth_required=self.github_auth_required,
nb_session_messages=self.nb_session_messages,
)

View file

@ -141,7 +141,7 @@ class ReadFile(
try: try:
raw_lines: list[bytes] = [] raw_lines: list[bytes] = []
bytes_read = 0 bytes_read = 0
was_truncated = False was_truncated = True
async with await anyio.Path(file_path).open("rb") as f: async with await anyio.Path(file_path).open("rb") as f:
line_index = 0 line_index = 0
@ -155,12 +155,13 @@ class ReadFile(
line_bytes = len(raw_line) line_bytes = len(raw_line)
if bytes_read + line_bytes > self.config.max_read_bytes: if bytes_read + line_bytes > self.config.max_read_bytes:
was_truncated = True
break break
raw_lines.append(raw_line) raw_lines.append(raw_line)
bytes_read += line_bytes bytes_read += line_bytes
line_index += 1 line_index += 1
else:
was_truncated = False
except OSError as exc: except OSError as exc:
raise ToolError(f"Error reading {file_path}: {exc}") from exc raise ToolError(f"Error reading {file_path}: {exc}") from exc

View file

@ -21,7 +21,7 @@ VIBE_AGENT_NAME = "mistral-vibe"
def setup_tracing(config: VibeConfig) -> None: def setup_tracing(config: VibeConfig) -> None:
if not config.enable_otel: if not config.enable_telemetry or not config.enable_otel:
return return
exporter_cfg = config.otel_span_exporter_config exporter_cfg = config.otel_span_exporter_config

View file

@ -26,6 +26,16 @@ from pydantic import (
) )
class ScheduledLoop(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
interval_seconds: int
prompt: str
next_fire_at: float
created_at: float
class Backend(StrEnum): class Backend(StrEnum):
MISTRAL = auto() MISTRAL = auto()
GENERIC = auto() GENERIC = auto()
@ -142,6 +152,7 @@ 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
loops: list[ScheduledLoop] = Field(default_factory=list)
title: str | None = None title: str | None = None
title_source: Literal["auto", "manual"] = "auto" title_source: Literal["auto", "manual"] = "auto"
@ -418,6 +429,8 @@ class CompactEndEvent(BaseEvent):
old_context_tokens: int old_context_tokens: int
new_context_tokens: int new_context_tokens: int
summary_length: int summary_length: int
old_session_id: str | None = None
new_session_id: str | None = None
# WORKAROUND: Using tool_call to communicate compact events to the client. # WORKAROUND: Using tool_call to communicate compact events to the client.
# This should be revisited when the ACP protocol defines how compact events # This should be revisited when the ACP protocol defines how compact events
# should be represented. # should be represented.

View file

@ -1,13 +1,31 @@
from __future__ import annotations from __future__ import annotations
from vibe.core.session.session_id import shorten_session_id
def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str:
if old_tokens is None or new_tokens is None:
return "Compaction complete"
def compact_reduction_display(
old_tokens: int | None,
new_tokens: int | None,
*,
old_session_id: str | None = None,
new_session_id: str | None = None,
) -> str:
message = "Compaction complete"
if old_tokens is not None and new_tokens is not None:
reduction = old_tokens - new_tokens reduction = old_tokens - new_tokens
reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0 reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
return ( message = (
f"Compaction complete: {old_tokens:,}" f"{message}: {old_tokens:,}"
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)" f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
) )
if old_session_id is not None and new_session_id is not None:
short_old = shorten_session_id(old_session_id)
short_new = shorten_session_id(new_session_id)
message = (
f"{message}\n"
f"session: {short_old} (before compaction) → {short_new} (after compaction)"
)
return message

View file

@ -1,5 +1,5 @@
# What's new in v2.9.4 # What's new in v2.9.5
- **`/rename` command**: Rename the current session from the slash menu. - **`/loop` command**: Run a prompt or slash command on a recurring interval.
- **Persistent "always allow"**: Tool permissions granted with "always allow" now stick across sessions. - **`default_agent` config**: Set which agent profile starts each session.
- **Faster bash bang commands**: `!command` runs via async subprocess. - **Parallel-safe history**: Multiple `vibe` instances no longer clobber each other's history file.