Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai>
Co-Authored-By: Clément Drouin <clement.drouin@mistral.ai>
Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-Authored-By: Clément Siriex <clement.sirieix@mistral.ai>
Co-Authored-By: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-Authored-By: Thaddee Tyl <thaddee.tyl@gmail.com>
Co-Authored-By: David Brochart <david.brochart@gmail.com>
Co-Authored-By: Joseph Guhlin <joseph.guhlin@gmail.com>
Co-Authored-By: Thomas Kenbeek <thomaskenbeek@gmail.com>
Co-Authored-By: Remenby31 <baptiste.cruvellier31@gmail.com>
This commit is contained in:
Mathias Gesbert
2026-01-27 16:39:30 +01:00
committed by Mathias Gesbert
parent 79f215d91c
commit d33db9fff8
217 changed files with 16911 additions and 4305 deletions

View File

@@ -15,6 +15,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
@@ -54,6 +55,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
@@ -89,6 +91,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4

25
.vscode/launch.json vendored
View File

@@ -1,5 +1,5 @@
{
"version": "1.3.5",
"version": "2.0.0",
"configurations": [
{
"name": "ACP Server",
@@ -15,7 +15,10 @@
"type": "debugpy",
"request": "launch",
"module": "pytest",
"args": ["-v", "-s"],
"args": [
"-v",
"-s"
],
"console": "integratedTerminal",
"justMyCode": false,
"cwd": "${workspaceFolder}",
@@ -28,7 +31,13 @@
"type": "debugpy",
"request": "launch",
"module": "pytest",
"args": ["-k", "${input:test_identifier}", "-vvv", "-s", "--no-header"],
"args": [
"-k",
"${input:test_identifier}",
"-vvv",
"-s",
"--no-header"
],
"console": "integratedTerminal",
"justMyCode": false,
"cwd": "${workspaceFolder}",
@@ -53,6 +62,16 @@
"request": "attach",
"processId": "${command:pickProcess}",
"justMyCode": true
},
{
"name": "Attach to Zed Process (Port)",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "localhost",
"port": 5678
},
"justMyCode": true
}
],
"inputs": [

View File

@@ -5,6 +5,52 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.0.0] - 2026-01-27
### Added
- Subagent support
- AskUserQuestion tool for interactive user input
- User-defined slash commands through skills
- What's new message display on version update
- Auto-update feature
- Environment variables and timeout support for MCP servers
- Editor shortcut support
- Shift+enter support for VS Code Insiders
- Message ID property for messages
- Client notification of compaction events
- debugpy support for macOS debugging
### Changed
- Mode system refactored to Agents
- Standardized managers
- Improved system prompt
- Updated session storage to separate metadata from messages
- Use shell environment to determine shell in bash tool
- Expanded user input handling
- Bumped agent-client-protocol to 0.7.1
- Refactored UI to require AgentLoop at VibeApp construction
- Updated README with new MCP server config
- Improved readability of the AskUserQuerstion tool output
### Fixed
- Use ensure_ascii=False for all JSON dumps
- Delete long-living temporary session files
- Ignore system prompt when saving/loading session messages
- Bash tool timeout handling
- Clipboard: no markup parsing of selected texts
- Canonical imports
- Remove last user message from compaction
- Pause tool timer while awaiting user action
### Removed
- instructions.md support
- workdir setting in config file
## [1.3.5] - 2026-01-12
### Fixed

328
README.md
View File

@@ -53,6 +53,39 @@ uv tool install mistral-vibe
pip install mistral-vibe
```
## Table of Contents
- [Features](#features)
- [Built-in Agents](#built-in-agents)
- [Subagents and Task Delegation](#subagents-and-task-delegation)
- [Interactive User Questions](#interactive-user-questions)
- [Terminal Requirements](#terminal-requirements)
- [Quick Start](#quick-start)
- [Usage](#usage)
- [Interactive Mode](#interactive-mode)
- [Trust Folder System](#trust-folder-system)
- [Programmatic Mode](#programmatic-mode)
- [Slash Commands](#slash-commands)
- [Built-in Slash Commands](#built-in-slash-commands)
- [Custom Slash Commands via Skills](#custom-slash-commands-via-skills)
- [Skills System](#skills-system)
- [Creating Skills](#creating-skills)
- [Skill Discovery](#skill-discovery)
- [Managing Skills](#managing-skills)
- [Configuration](#configuration)
- [Configuration File Location](#configuration-file-location)
- [API Key Configuration](#api-key-configuration)
- [Custom System Prompts](#custom-system-prompts)
- [Custom Agent Configurations](#custom-agent-configurations)
- [Tool Management](#tool-management)
- [MCP Server Configuration](#mcp-server-configuration)
- [Session Management](#session-management)
- [Update Settings](#update-settings)
- [Custom Vibe Home Directory](#custom-vibe-home-directory)
- [Editors/IDEs](#editorsides)
- [Resources](#resources)
- [License](#license)
## Features
- **Interactive Chat**: A conversational AI agent that understands your requests and breaks down complex tasks.
@@ -61,6 +94,8 @@ pip install mistral-vibe
- Execute shell commands in a stateful terminal (`bash`).
- Recursively search code with `grep` (with `ripgrep` support).
- Manage a `todo` list to track the agent's work.
- Ask interactive questions to gather user input (`ask_user_question`).
- Delegate tasks to subagents for parallel work (`task`).
- **Project-Aware Context**: Vibe automatically scans your project's file structure and Git status to provide relevant context to the agent, improving its understanding of your codebase.
- **Advanced CLI Experience**: Built with modern libraries for a smooth and efficient workflow.
- Autocompletion for slash commands (`/`) and file paths (`@`).
@@ -68,6 +103,70 @@ pip install mistral-vibe
- Beautiful Themes.
- **Highly Configurable**: Customize models, providers, tool permissions, and UI preferences through a simple `config.toml` file.
- **Safety First**: Features tool execution approval.
- **Multiple Built-in Agents**: Choose from different agent profiles tailored for specific workflows.
### Built-in Agents
Vibe comes with several built-in agent profiles, each designed for different use cases:
- **`default`**: Standard agent that requires approval for tool executions. Best for general use.
- **`plan`**: Read-only agent for exploration and planning. Auto-approves safe tools like `grep` and `read_file`.
- **`accept-edits`**: Auto-approves file edits only (`write_file`, `search_replace`). Useful for code refactoring.
- **`auto-approve`**: Auto-approves all tool executions. Use with caution.
Use the `--agent` flag to select a different agent:
```bash
vibe --agent plan
```
### 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.
The `task` tool allows the agent to delegate work to subagents:
```
> Can you explore the codebase structure while I work on something else?
🤖 I'll use the task tool to delegate this to the explore subagent.
> task(task="Analyze the project structure and architecture", agent="explore")
```
Create custom subagents by adding `agent_type = "subagent"` to your agent configuration. Vibe comes with a built-in subagent called `explore`, a read-only subagent for codebase exploration used internally for delegation.
### Interactive User Questions
The `ask_user_question` tool allows the agent to ask you clarifying questions during its work. This enables more interactive and collaborative workflows.
```
> Can you help me refactor this function?
🤖 I need to understand your requirements better before proceeding.
> ask_user_question(questions=[{
"question": "What's the main goal of this refactoring?",
"options": [
{"label": "Performance", "description": "Make it run faster"},
{"label": "Readability", "description": "Make it easier to understand"},
{"label": "Maintainability", "description": "Make it easier to modify"}
]
}])
```
The agent can ask multiple questions at once, displayed as tabs. Each question supports 2-4 options plus an automatic "Other" option for free text responses.
## Terminal Requirements
Vibe's interactive interface requires a modern terminal emulator. Recommended terminal emulators include:
- **WezTerm** (cross-platform)
- **Alacritty** (cross-platform)
- **Ghostty** (Linux and macOS)
- **Kitty** (Linux and macOS)
Most modern terminals should work, but older or minimal terminal emulators may have display issues.
## Quick Start
@@ -89,6 +188,8 @@ pip install mistral-vibe
- Prompt you to enter your API key if it's not already configured
- Save your API key to `~/.vibe/.env` for future use
Alternatively, you can configure your API key separately using `vibe --setup`.
4. Start interacting with the agent!
```
@@ -112,8 +213,12 @@ Simply run `vibe` to enter the interactive chat loop.
- **Multi-line Input**: Press `Ctrl+J` or `Shift+Enter` for select terminals to insert a newline.
- **File Paths**: Reference files in your prompt using the `@` symbol for smart autocompletion (e.g., `> Read the file @src/agent.py`).
- **Shell Commands**: Prefix any command with `!` to execute it directly in your shell, bypassing the agent (e.g., `> !ls -l`).
- **External Editor**: Press `Ctrl+G` to edit your current input in an external editor.
- **Tool Output Toggle**: Press `Ctrl+O` to toggle the tool output view.
- **Todo View Toggle**: Press `Ctrl+T` to toggle the todo list view.
- **Auto-Approve Toggle**: Press `Shift+Tab` to toggle auto-approve mode on/off.
You can start Vibe with a prompt with the following command:
You can start Vibe with a prompt using the following command:
```bash
vibe "Refactor the main function in cli/main.py to be more modular."
@@ -121,6 +226,14 @@ vibe "Refactor the main function in cli/main.py to be more modular."
**Note**: The `--auto-approve` flag automatically approves all tool executions without prompting. In interactive mode, you can also toggle auto-approve on/off using `Shift+Tab`.
### Trust Folder System
Vibe includes a trust folder system to ensure you only run the agent in directories you trust. When you first run Vibe in a new directory which contains a `.vibe` subfolder, it may ask you to confirm whether you trust the folder.
Trusted folders are remembered for future sessions. You can manage trusted folders through its configuration file `~/.vibe/trusted_folders.toml`.
This safety feature helps prevent accidental execution in sensitive directories.
### Programmatic Mode
You can run Vibe non-interactively by piping input or using the `--prompt` flag. This is useful for scripting.
@@ -129,18 +242,126 @@ You can run Vibe non-interactively by piping input or using the `--prompt` flag.
vibe --prompt "Refactor the main function in cli/main.py to be more modular."
```
by default it will use `auto-approve` mode.
By default, it uses `auto-approve` mode.
### Slash Commands
#### Programmatic Mode Options
When using `--prompt`, you can specify additional options:
- **`--max-turns N`**: Limit the maximum number of assistant turns. The session will stop after N turns.
- **`--max-price DOLLARS`**: Set a maximum cost limit in dollars. The session will be interrupted if the cost exceeds this limit.
- **`--enabled-tools TOOL`**: Enable specific tools. In programmatic mode, this disables all other tools. Can be specified multiple times. Supports exact names, glob patterns (e.g., `bash*`), or regex with `re:` prefix (e.g., `re:^serena_.*$`).
- **`--output FORMAT`**: Set the output format. Options:
- `text` (default): Human-readable text output
- `json`: All messages as JSON at the end
- `streaming`: Newline-delimited JSON per message
Example:
```bash
vibe --prompt "Analyze the codebase" --max-turns 5 --max-price 1.0 --output json
```
## Slash Commands
Use slash commands for meta-actions and configuration changes during a session.
### Built-in Slash Commands
Vibe provides several built-in slash commands. Use slash commands by typing them in the input box:
```
> /help
```
### Custom Slash Commands via Skills
You can define your own slash commands through the skills system. Skills are reusable components that extend Vibe's functionality.
To create a custom slash command:
1. Create a skill directory with a `SKILL.md` file
2. Set `user-invocable = true` in the skill metadata
3. Define the command logic in your skill
Example skill metadata:
```markdown
---
name: my-skill
description: My custom skill with slash commands
user-invocable: true
---
```
Custom slash commands appear in the autocompletion menu alongside built-in commands.
## Skills System
Vibe's skills system allows you to extend functionality through reusable components. Skills can add new tools, slash commands, and specialized behaviors.
Vibe follows the [Agent Skills specification](https://agentskills.io/specification) for skill format and structure.
### Creating Skills
Skills are defined in directories with a `SKILL.md` file containing metadata in YAML frontmatter. For example, `~/.vibe/skills/code-review/SKILL.md`:
```markdown
---
name: code-review
description: Perform automated code reviews
license: MIT
compatibility: Python 3.12+
user-invocable: true
allowed-tools:
- read_file
- grep
- ask_user_question
---
# Code Review Skill
This skill helps analyze code quality and suggest improvements.
```
### Skill Discovery
Vibe discovers skills from multiple locations:
1. **Global skills directory**: `~/.vibe/skills/`
2. **Local project skills**: `.vibe/skills/` in your project
3. **Custom paths**: Configured in `config.toml`
```toml
skill_paths = ["/path/to/custom/skills"]
```
### Managing Skills
Enable or disable skills using patterns in your configuration:
```toml
# Enable specific skills
enabled_skills = ["code-review", "test-*"]
# Disable specific skills
disabled_skills = ["experimental-*"]
```
Skills support the same pattern matching as tools (exact names, glob patterns, and regex).
## Configuration
### Configuration File Location
Vibe is configured via a `config.toml` file. It looks for this file first in `./.vibe/config.toml` and then falls back to `~/.vibe/config.toml`.
### API Key Configuration
To use Vibe, you'll need a Mistral API key. You can obtain one by signing up at [https://console.mistral.ai](https://console.mistral.ai).
You can configure your API key using `vibe --setup`, or through one of the methods below.
Vibe supports multiple ways to configure your API keys:
1. **Interactive Setup (Recommended for first-time users)**: When you run Vibe for the first time or if your API key is missing, Vibe will prompt you to enter it. The key will be securely saved to `~/.vibe/.env` for future sessions.
@@ -204,7 +425,32 @@ permission = "always"
permission = "always"
```
Note: this implies that you have setup a redteam prompt names `~/.vibe/prompts/redteam.md`
Note: This implies that you have set up a redteam prompt named `~/.vibe/prompts/redteam.md`.
### Tool Management
#### Enable/Disable Tools with Patterns
You can control which tools are active using `enabled_tools` and `disabled_tools`.
These fields support exact names, glob patterns, and regular expressions.
Examples:
```toml
# Only enable tools that start with "serena_" (glob)
enabled_tools = ["serena_*"]
# Regex (prefix with re:) — matches full tool name (case-insensitive)
enabled_tools = ["re:^serena_.*$"]
# Disable a group with glob; everything else stays enabled
disabled_tools = ["mcp_*", "grep"]
```
Notes:
- MCP tool names use underscores, e.g., `serena_list` not `serena.list`.
- Regex patterns are matched against the full tool name using fullmatch.
### MCP Server Configuration
@@ -232,6 +478,7 @@ name = "fetch_server"
transport = "stdio"
command = "uvx"
args = ["mcp-server-fetch"]
env = { "DEBUG" = "1", "LOG_LEVEL" = "info" }
```
Supported transports:
@@ -249,6 +496,9 @@ Key fields:
- `api_key_env`: Environment variable containing the API key
- `command`: Command to run for stdio transport
- `args`: Additional arguments for stdio transport
- `startup_timeout_sec`: Timeout in seconds for the server to start and initialize (default 10s)
- `tool_timeout_sec`: Timeout in seconds for tool execution (default 60s)
- `env`: Environment variables to set for the MCP server of transport type stdio
MCP tools are named using the pattern `{server_name}_{tool_name}` and can be configured with permissions like built-in tools:
@@ -261,31 +511,63 @@ permission = "always"
permission = "ask"
```
### Enable/disable tools with patterns
MCP server configurations support additional features:
You can control which tools are active using `enabled_tools` and `disabled_tools`.
These fields support exact names, glob patterns, and regular expressions.
- **Environment variables**: Set environment variables for MCP servers
- **Custom timeouts**: Configure startup and tool execution timeouts
Examples:
Example with environment variables and timeouts:
```toml
# Only enable tools that start with "serena_" (glob)
enabled_tools = ["serena_*"]
# Regex (prefix with re:) — matches full tool name (case-insensitive)
enabled_tools = ["re:^serena_.*$"]
# Heuristic regex support (patterns like `serena.*` are treated as regex)
enabled_tools = ["serena.*"]
# Disable a group with glob; everything else stays enabled
disabled_tools = ["mcp_*", "grep"]
[[mcp_servers]]
name = "my_server"
transport = "http"
url = "http://localhost:8000"
env = { "DEBUG" = "1", "LOG_LEVEL" = "info" }
startup_timeout_sec = 15
tool_timeout_sec = 120
```
Notes:
### Session Management
- MCP tool names use underscores, e.g., `serena_list` not `serena.list`.
- Regex patterns are matched against the full tool name using fullmatch.
#### Session Continuation and Resumption
Vibe supports continuing from previous sessions:
- **`--continue`** or **`-c`**: Continue from the most recent saved session
- **`--resume SESSION_ID`**: Resume a specific session by ID (supports partial matching)
```bash
# Continue from last session
vibe --continue
# Resume specific session
vibe --resume abc123
```
Session logging must be enabled in your configuration for these features to work.
#### Working Directory Control
Use the `--workdir` option to specify a working directory:
```bash
vibe --workdir /path/to/project
```
This is useful when you want to run Vibe from a different location than your current directory.
### Update Settings
#### Auto-Update
Vibe includes an automatic update feature that keeps your installation current. This is enabled by default.
To disable auto-updates, add this to your `config.toml`:
```toml
enable_auto_update = false
```
### Custom Vibe Home Directory
@@ -302,7 +584,7 @@ This affects where Vibe looks for:
- `agents/` - Custom agent configurations
- `prompts/` - Custom system prompts
- `tools/` - Custom tools
- `logs/` - Session logsRetryTo run code, enable code execution and file creation in Settings > Capabilities.
- `logs/` - Session logs
## Editors/IDEs

View File

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

View File

@@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
version = "1.3.5"
version = "2.0.0"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@@ -27,8 +27,8 @@ classifiers = [
"Topic :: Utilities",
]
dependencies = [
"agent-client-protocol==0.6.3",
"aiofiles>=24.1.0",
"agent-client-protocol==0.7.1",
"anyio>=4.12.0",
"httpx>=0.28.1",
"mcp>=1.14.0",
"mistralai==1.9.11",
@@ -90,6 +90,7 @@ dev = [
"typos>=1.34.0",
"vulture>=2.14",
"pytest-xdist>=3.8.0",
"debugpy>=1.8.19",
]
build = ["pyinstaller>=6.17.0"]

View File

@@ -10,6 +10,8 @@ This script increments the version in pyproject.toml based on the specified bump
from __future__ import annotations
import argparse
from datetime import date
import os
from pathlib import Path
import re
import subprocess
@@ -50,7 +52,6 @@ def update_hard_values_files(filepath: str, patterns: list[tuple[str, str]]) ->
if not path.exists():
raise FileNotFoundError(f"{filepath} not found in current directory")
# Replace patterns
for pattern, replacement in patterns:
content = path.read_text()
updated_content = re.sub(pattern, replacement, content, flags=re.MULTILINE)
@@ -71,7 +72,6 @@ def get_current_version() -> str:
content = pyproject_path.read_text()
# Find version line
version_match = re.search(r'^version = "([^"]+)"$', content, re.MULTILINE)
if not version_match:
raise ValueError("Version not found in pyproject.toml")
@@ -79,7 +79,60 @@ def get_current_version() -> str:
return version_match.group(1)
def update_changelog(new_version: str) -> None:
changelog_path = Path("CHANGELOG.md")
if not changelog_path.exists():
raise FileNotFoundError("CHANGELOG.md not found in current directory")
content = changelog_path.read_text()
today = date.today().isoformat()
first_entry_match = re.search(r"^## \[[\d.]+\]", content, re.MULTILINE)
if not first_entry_match:
raise ValueError("Could not find version entry in CHANGELOG.md")
insert_position = first_entry_match.start()
new_entry = f"## [{new_version}] - {today}\n\n"
new_entry += "### Added\n\n"
new_entry += "### Changed\n\n"
new_entry += "### Fixed\n\n"
new_entry += "### Removed\n\n"
new_entry += "\n"
updated_content = content[:insert_position] + new_entry + content[insert_position:]
changelog_path.write_text(updated_content)
print(f"Added changelog entry for version {new_version}")
def print_warning(new_version: str) -> None:
warning = f"""
{"=" * 80}
⚠️ WARNING: CHANGELOG UPDATE REQUIRED ⚠️
{"=" * 80}
Don't forget to fill in the changelog entry for version {new_version} in CHANGELOG.md! 📝
Also, remember to fill in vibe/whats_new.md if needed (you can leave it blank). 📝
{"=" * 80}
"""
print(warning, file=sys.stderr)
def clean_up_whats_new_message() -> None:
whats_new_path = Path("vibe/whats_new.md")
if not whats_new_path.exists():
raise FileNotFoundError("whats_new.md not found in current directory")
whats_new_path.write_text("")
def main() -> None:
os.chdir(Path(__file__).parent.parent)
parser = argparse.ArgumentParser(
description="Bump semver version in pyproject.toml",
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -140,9 +193,15 @@ Examples:
[(f'version="{current_version}"', f'version="{new_version}"')],
)
# Update CHANGELOG.md
update_changelog(new_version)
clean_up_whats_new_message()
subprocess.run(["uv", "lock"], check=True)
print(f"\nSuccessfully bumped version from {current_version} to {new_version}")
print_warning(new_version)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)

284
scripts/prepare_release.py Executable file
View File

@@ -0,0 +1,284 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
import re
import subprocess
import sys
def run_git_command(
*args: str, check: bool = True, capture_output: bool = False
) -> subprocess.CompletedProcess[str]:
"""Run a git command and return the result."""
result = subprocess.run(
["git"] + list(args), check=check, capture_output=capture_output, text=True
)
return result
def ensure_public_remote() -> None:
result = run_git_command("remote", "-v", capture_output=True, check=False)
remotes = result.stdout
public_remote_url = "git@github.com:mistralai/mistral-vibe.git"
if public_remote_url in remotes:
print("Public remote already exists with correct URL")
return
print(f"Creating public remote: {public_remote_url}")
run_git_command("remote", "add", "public", public_remote_url)
print("Public remote created successfully")
def switch_to_tag(version: str) -> None:
tag = f"v{version}"
print(f"Switching to tag {tag}...")
result = run_git_command(
"rev-parse", "--verify", tag, capture_output=True, check=False
)
if result.returncode != 0:
raise ValueError(f"Tag {tag} does not exist")
run_git_command("switch", "--detach", tag)
print(f"Successfully switched to tag {tag}")
def get_version_from_pyproject() -> str:
pyproject_path = Path("pyproject.toml")
if not pyproject_path.exists():
raise FileNotFoundError("pyproject.toml not found in current directory")
content = pyproject_path.read_text()
version_match = re.search(r'^version = "([^"]+)"$', content, re.MULTILINE)
if not version_match:
raise ValueError("Version not found in pyproject.toml")
return version_match.group(1)
def get_latest_version() -> str:
result = run_git_command("ls-remote", "--tags", "public", capture_output=True)
remote_tags_output = (
result.stdout.strip().split("\n") if result.stdout.strip() else []
)
if not remote_tags_output:
raise ValueError("No version tags found on public remote")
versions: list[tuple[int, int, int, str]] = []
MIN_PARTS_IN_LS_REMOTE_LINE = 2 # hash and ref
for line in remote_tags_output:
parts = line.split()
if len(parts) < MIN_PARTS_IN_LS_REMOTE_LINE:
continue
_hash, tag_ref = parts[0], parts[1]
if not tag_ref.startswith("refs/tags/"):
continue
tag = tag_ref.replace("refs/tags/", "")
match = re.match(r"^v(\d+\.\d+\.\d+)$", tag)
if not match:
continue
tag_version = match.group(1)
try:
major, minor, patch = parse_version(tag_version)
versions.append((major, minor, patch, tag_version))
except ValueError:
continue
if not versions:
raise ValueError(
"No valid version tags found on public remote (format: vX.Y.Z)"
)
versions.sort()
return max(versions)[3]
def parse_version(version_str: str) -> tuple[int, int, int]:
match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_str.strip())
if not match:
raise ValueError(f"Invalid version format: {version_str}")
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def create_release_branch(version: str) -> None:
branch_name = f"release/v{version}"
print(f"Creating release branch: {branch_name}")
result = run_git_command(
"branch", "--list", branch_name, capture_output=True, check=False
)
if result.stdout.strip():
print(f"Warning: Branch {branch_name} already exists", file=sys.stderr)
response = input(f"Delete and recreate {branch_name}? (y/N): ")
if response.lower() == "y":
run_git_command("branch", "-D", branch_name)
else:
print("Aborting", file=sys.stderr)
sys.exit(1)
run_git_command("switch", "-c", branch_name)
print(f"Created and switched to branch {branch_name}")
def cherry_pick_commits(previous_version: str, current_version: str) -> None:
previous_tag = f"v{previous_version}-private"
current_tag = f"v{current_version}-private"
result = run_git_command(
"rev-parse", "--verify", previous_tag, capture_output=True, check=False
)
if result.returncode != 0:
raise ValueError(f"Tag {previous_tag} does not exist")
result = run_git_command(
"rev-parse", "--verify", current_tag, capture_output=True, check=False
)
if result.returncode != 0:
raise ValueError(f"Tag {current_tag} does not exist")
print(f"Cherry-picking commits from {previous_tag}..{current_tag}...")
run_git_command("cherry-pick", f"{previous_tag}..{current_tag}")
print("Successfully cherry-picked all commits")
def get_commits_summary(previous_version: str, current_version: str) -> str:
previous_tag = f"v{previous_version}-private"
current_tag = f"v{current_version}-private"
result = run_git_command(
"log", f"{previous_tag}..{current_tag}", "--oneline", capture_output=True
)
return result.stdout.strip()
def get_changelog_entry(version: str) -> str:
changelog_path = Path("CHANGELOG.md")
if not changelog_path.exists():
return "CHANGELOG.md not found"
content = changelog_path.read_text()
pattern = rf"^## \[{re.escape(version)}\] - .+?(?=^## \[|\Z)"
match = re.search(pattern, content, re.MULTILINE | re.DOTALL)
if not match:
return f"No changelog entry found for version {version}"
return match.group(0).strip()
def print_summary(
current_version: str,
previous_version: str,
commits_summary: str,
changelog_entry: str,
) -> None:
print("\n" + "=" * 80)
print("RELEASE PREPARATION SUMMARY")
print("=" * 80)
print(f"\nVersion: {current_version}")
print(f"Previous version: {previous_version}")
print(f"Release branch: release/v{current_version}")
print("\n" + "-" * 80)
print("COMMITS IN THIS RELEASE")
print("-" * 80)
if commits_summary:
print(commits_summary)
else:
print("No commits found")
print("\n" + "-" * 80)
print("CHANGELOG ENTRY")
print("-" * 80)
print(changelog_entry)
print("\n" + "-" * 80)
print("NEXT STEPS")
print("-" * 80)
print(
f"To review/edit commits before publishing, use interactive rebase:\n"
f" git rebase -i v{previous_version}"
)
print("\n" + "-" * 80)
print("REMINDERS")
print("-" * 80)
print("Before publishing the release:")
print(" ✓ Credit any public contributors in the release notes")
print(" ✓ Close related issues once the release is published")
print(
" ✓ Review and update the changelog if needed "
"(should be made in the private main branch)"
)
print("\n" + "=" * 80)
def main() -> None:
parser = argparse.ArgumentParser(
description="Prepare a release branch by cherry-picking from private tags",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("version", help="Version to prepare release for (e.g., 1.1.3)")
args = parser.parse_args()
current_version = args.version
try:
# Step 1: Ensure public remote exists
ensure_public_remote()
# Step 2: Fetch all remotes
print("Fetching all remotes...")
run_git_command("fetch", "--all")
print("Successfully fetched all remotes")
# Step 3: Find latest version
previous_version = get_latest_version()
print(f"Previous version: {previous_version}")
# Step 4: Verify version matches pyproject.toml
pyproject_version = get_version_from_pyproject()
if current_version != pyproject_version:
raise ValueError(
f"Version mismatch: provided version '{current_version}' does not match "
f"pyproject.toml version '{pyproject_version}'"
)
print(f"Version verified: {current_version}")
# Step 5: Switch to previous version tag
switch_to_tag(previous_version)
# Step 6: Create release branch
create_release_branch(current_version)
# Step 7: Cherry-pick commits
cherry_pick_commits(previous_version, current_version)
# Step 8: Get summary information
commits_summary = get_commits_summary(previous_version, current_version)
changelog_entry = get_changelog_entry(current_version)
# Step 9: Print summary
print_summary(
current_version, previous_version, commits_summary, changelog_entry
)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

42
tests/acp/conftest.py Normal file
View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from unittest.mock import patch
import pytest
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agent_loop import AgentLoop
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@pytest.fixture
def backend() -> FakeBackend:
backend = FakeBackend(
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
)
)
return backend
def _create_acp_agent() -> VibeAcpAgentLoop:
vibe_acp_agent = VibeAcpAgentLoop()
client = FakeClient()
vibe_acp_agent.on_connect(client)
client.on_connect(vibe_acp_agent)
return vibe_acp_agent # pyright: ignore[reportReturnType]
@pytest.fixture
def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
class PatchedAgent(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs, backend=backend)
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgent).start()
return _create_acp_agent()

View File

@@ -189,7 +189,7 @@ class WriteTextFileJsonRpcResponse(JsonRpcResponse):
result: None = None
async def get_acp_agent_process(
async def get_acp_agent_loop_process(
mock_env: dict[str, str], vibe_home: Path
) -> AsyncGenerator[asyncio.subprocess.Process]:
current_env = os.environ.copy()
@@ -358,43 +358,43 @@ def parse_conversation(message_texts: list[str]) -> list[JsonRpcMessage]:
return parsed_messages
async def initialize_session(acp_agent_process: asyncio.subprocess.Process) -> str:
async def initialize_session(acp_agent_loop_process: asyncio.subprocess.Process) -> str:
await send_json_rpc(
acp_agent_process,
InitializeJsonRpcRequest(id=1, params=InitializeRequest(protocolVersion=1)),
acp_agent_loop_process,
InitializeJsonRpcRequest(id=1, params=InitializeRequest(protocol_version=1)),
)
initialize_response = await read_response_for_id(
acp_agent_process, expected_id=1, timeout=5.0
acp_agent_loop_process, expected_id=1, timeout=5.0
)
assert initialize_response is not None
await send_json_rpc(
acp_agent_process,
acp_agent_loop_process,
NewSessionJsonRpcRequest(
id=2, params=NewSessionRequest(cwd=str(PLAYGROUND_DIR), mcpServers=[])
id=2, params=NewSessionRequest(cwd=str(PLAYGROUND_DIR), mcp_servers=[])
),
)
session_response = await read_response_for_id(acp_agent_process, expected_id=2)
session_response = await read_response_for_id(acp_agent_loop_process, expected_id=2)
assert session_response is not None
session_response_json = json.loads(session_response)
session_response_obj = NewSessionJsonRpcResponse.model_validate(
session_response_json
)
assert session_response_obj.result is not None, "No result in response"
return session_response_obj.result.sessionId
return session_response_obj.result.session_id
class TestSessionManagement:
@pytest.mark.asyncio
async def test_multiple_sessions_unique_ids(self, vibe_home_dir: Path) -> None:
mock_env = get_mocking_env(mock_chunks=[mock_llm_chunk() for _ in range(3)])
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_dir
):
await send_json_rpc(
process,
InitializeJsonRpcRequest(
id=1, params=InitializeRequest(protocolVersion=1)
id=1, params=InitializeRequest(protocol_version=1)
),
)
await read_response_for_id(process, expected_id=1, timeout=5.0)
@@ -406,7 +406,7 @@ class TestSessionManagement:
NewSessionJsonRpcRequest(
id=i + 2,
params=NewSessionRequest(
cwd=str(PLAYGROUND_DIR), mcpServers=[]
cwd=str(PLAYGROUND_DIR), mcp_servers=[]
),
),
)
@@ -418,16 +418,18 @@ class TestSessionManagement:
response = NewSessionJsonRpcResponse.model_validate(response_json)
assert response.error is None, f"JSON-RPC error: {response.error}"
assert response.result is not None, "No result in response"
session_ids.append(response.result.sessionId)
session_ids.append(response.result.session_id)
assert len(set(session_ids)) == 3
class TestSessionUpdates:
@pytest.mark.asyncio
async def test_agent_message_chunk_structure(self, vibe_home_dir: Path) -> None:
async def test_agent_loop_message_chunk_structure(
self, vibe_home_dir: Path
) -> None:
mock_env = get_mocking_env([mock_llm_chunk(content="Hi")])
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_dir
):
# Check stderr for error details if process failed
@@ -444,11 +446,19 @@ class TestSessionUpdates:
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
sessionId=session_id,
session_id=session_id,
prompt=[TextContentBlock(type="text", text="Just say hi")],
),
),
)
user_response_text = await read_response(process)
assert user_response_text is not None
user_response = UpdateJsonRpcNotification.model_validate(
json.loads(user_response_text)
)
assert user_response.params is not None
assert user_response.params.update.session_update == "user_message_chunk"
text_response = await read_response(process)
assert text_response is not None
response = UpdateJsonRpcNotification.model_validate(
@@ -456,8 +466,9 @@ class TestSessionUpdates:
)
assert response.params is not None
assert response.params.update.sessionUpdate == "agent_message_chunk"
assert response.params.update.session_update == "agent_message_chunk"
assert response.params.update.content is not None
assert isinstance(response.params.update.content, TextContentBlock)
assert response.params.update.content.type == "text"
assert response.params.update.content.text is not None
assert response.params.update.content.text == "Hi"
@@ -478,7 +489,7 @@ class TestSessionUpdates:
),
mock_llm_chunk(content="The files containing the pattern 'auth' are ..."),
])
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_dir
):
session_id = await initialize_session(process)
@@ -488,7 +499,7 @@ class TestSessionUpdates:
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
sessionId=session_id,
session_id=session_id,
prompt=[
TextContentBlock(
type="text",
@@ -511,7 +522,7 @@ class TestSessionUpdates:
for r in responses
if isinstance(r, UpdateJsonRpcNotification)
and r.params is not None
and r.params.update.sessionUpdate == "tool_call"
and r.params.update.session_update == "tool_call"
),
None,
)
@@ -519,11 +530,11 @@ class TestSessionUpdates:
assert tool_call.params is not None
assert tool_call.params.update is not None
assert tool_call.params.update.sessionUpdate == "tool_call"
assert tool_call.params.update.session_update == "tool_call"
assert tool_call.params.update.kind == "search"
assert tool_call.params.update.title == "grep: 'auth'"
assert tool_call.params.update.title == "Grepping 'auth'"
assert (
tool_call.params.update.rawInput
tool_call.params.update.raw_input
== '{"pattern":"auth","path":".","max_matches":null,"use_default_ignore":true}'
)
@@ -537,7 +548,7 @@ async def start_session_with_request_permission(
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
sessionId=session_id,
session_id=session_id,
prompt=[TextContentBlock(type="text", text=prompt)],
),
),
@@ -575,7 +586,7 @@ class TestToolCallStructure:
)
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
session_id = await initialize_session(process)
@@ -584,7 +595,7 @@ class TestToolCallStructure:
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
sessionId=session_id,
session_id=session_id,
prompt=[
TextContentBlock(
type="text",
@@ -611,8 +622,8 @@ class TestToolCallStructure:
first_request = permission_requests[0]
assert first_request.params is not None
assert first_request.params.toolCall is not None
assert first_request.params.toolCall.toolCallId is not None
assert first_request.params.tool_call is not None
assert first_request.params.tool_call.tool_call_id is not None
@pytest.mark.asyncio
async def test_tool_call_update_approved_structure(
@@ -635,7 +646,7 @@ class TestToolCallStructure:
mock_llm_chunk(content="The file test.txt has been created"),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
permission_request = await start_session_with_request_permission(
@@ -649,7 +660,7 @@ class TestToolCallStructure:
id=permission_request.id,
result=RequestPermissionResponse(
outcome=AllowedOutcome(
outcome="selected", optionId=selected_option_id
outcome="selected", option_id=selected_option_id
)
),
),
@@ -665,9 +676,9 @@ class TestToolCallStructure:
and r.method == "session/update"
and r.params is not None
and r.params.update is not None
and r.params.update.sessionUpdate == "tool_call_update"
and r.params.update.toolCallId
== (permission_request.params.toolCall.toolCallId)
and r.params.update.session_update == "tool_call_update"
and r.params.update.tool_call_id
== (permission_request.params.tool_call.tool_call_id)
and r.params.update.status == "completed"
),
None,
@@ -697,7 +708,7 @@ class TestToolCallStructure:
),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
permission_request = await start_session_with_request_permission(
@@ -712,7 +723,7 @@ class TestToolCallStructure:
id=permission_request.id,
result=RequestPermissionResponse(
outcome=AllowedOutcome(
outcome="selected", optionId=selected_option_id
outcome="selected", option_id=selected_option_id
)
),
),
@@ -727,9 +738,9 @@ class TestToolCallStructure:
if isinstance(r, UpdateJsonRpcNotification)
and r.method == "session/update"
and r.params is not None
and r.params.update.sessionUpdate == "tool_call_update"
and r.params.update.toolCallId
== (permission_request.params.toolCall.toolCallId)
and r.params.update.session_update == "tool_call_update"
and r.params.update.tool_call_id
== (permission_request.params.tool_call.tool_call_id)
and r.params.update.status == "failed"
),
None,
@@ -758,7 +769,7 @@ class TestToolCallStructure:
mock_llm_chunk(content="The command sleep 3 has been run"),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
session_id = await initialize_session(process)
@@ -767,7 +778,7 @@ class TestToolCallStructure:
PromptJsonRpcRequest(
id=3,
params=PromptRequest(
sessionId=session_id,
session_id=session_id,
prompt=[
TextContentBlock(
type="text",
@@ -786,7 +797,7 @@ class TestToolCallStructure:
for r in responses
if isinstance(r, UpdateJsonRpcNotification)
and r.params is not None
and r.params.update.sessionUpdate == "tool_call_update"
and r.params.update.session_update == "tool_call_update"
and r.params.update.status == "in_progress"
]
@@ -817,7 +828,7 @@ class TestToolCallStructure:
),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_grep_ask
):
permission_request = await start_session_with_request_permission(
@@ -832,7 +843,7 @@ class TestToolCallStructure:
id=permission_request.id,
result=RequestPermissionResponse(
outcome=AllowedOutcome(
outcome="selected", optionId=selected_option_id
outcome="selected", option_id=selected_option_id
)
),
),
@@ -847,10 +858,10 @@ class TestToolCallStructure:
for r in responses
if isinstance(r, UpdateJsonRpcNotification)
and r.params is not None
and r.params.update.sessionUpdate == "tool_call_update"
and r.params.update.session_update == "tool_call_update"
and r.params.update.status == "failed"
and r.params.update.rawOutput is not None
and r.params.update.toolCallId is not None
and r.params.update.raw_output is not None
and r.params.update.tool_call_id is not None
),
None,
)
@@ -888,7 +899,7 @@ class TestCancellationStructure:
),
]
mock_env = get_mocking_env(custom_results)
async for process in get_acp_agent_process(
async for process in get_acp_agent_loop_process(
mock_env=mock_env, vibe_home=vibe_home_dir
):
permission_request = await start_session_with_request_permission(
@@ -920,9 +931,9 @@ class TestCancellationStructure:
if isinstance(r, UpdateJsonRpcNotification)
and r.method == "session/update"
and r.params is not None
and r.params.update.sessionUpdate == "tool_call_update"
and r.params.update.toolCallId
== (permission_request.params.toolCall.toolCallId)
and r.params.update.session_update == "tool_call_update"
and r.params.update.tool_call_id
== (permission_request.params.tool_call.tool_call_id)
and r.params.update.status == "failed"
),
None,
@@ -935,7 +946,7 @@ class TestCancellationStructure:
for r in responses
if isinstance(r, PromptJsonRpcResponse)
and r.result is not None
and r.result.stopReason == "cancelled"
and r.result.stop_reason == "cancelled"
),
None,
)

View File

@@ -2,9 +2,10 @@ from __future__ import annotations
import asyncio
from acp.schema import TerminalOutputResponse, WaitForTerminalExitResponse
from acp.schema import EnvVariable, TerminalOutputResponse, WaitForTerminalExitResponse
import pytest
from tests.mock.utils import collect_result
from vibe.acp.tools.builtins.bash import AcpBashState, Bash
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.bash import BashArgs, BashResult, BashToolConfig
@@ -26,7 +27,7 @@ class MockTerminalHandle:
async def wait_for_exit(self) -> WaitForTerminalExitResponse:
await asyncio.sleep(self._wait_delay)
return WaitForTerminalExitResponse(exitCode=self._exit_code)
return WaitForTerminalExitResponse(exit_code=self._exit_code)
async def current_output(self) -> TerminalOutputResponse:
return TerminalOutputResponse(output=self._output, truncated=False)
@@ -38,36 +39,72 @@ class MockTerminalHandle:
pass
class MockConnection:
class MockClient:
def __init__(self, terminal_handle: MockTerminalHandle | None = None) -> None:
self._terminal_handle = terminal_handle or MockTerminalHandle()
self._create_terminal_called = False
self._session_update_called = False
self._create_terminal_error: Exception | None = None
self._last_create_request = None
self._last_create_params: dict[
str, str | list[str] | list[EnvVariable] | int | None
] = {}
async def createTerminal(self, request) -> MockTerminalHandle:
async def create_terminal(
self,
command: str,
session_id: str,
args: list[str] | None = None,
cwd: str | None = None,
env: list | None = None,
output_byte_limit: int | None = None,
**kwargs,
) -> MockTerminalHandle:
self._create_terminal_called = True
self._last_create_request = request
self._last_create_params = {
"command": command,
"session_id": session_id,
"args": args,
"cwd": cwd,
"env": env,
"output_byte_limit": output_byte_limit,
}
if self._create_terminal_error:
raise self._create_terminal_error
return self._terminal_handle
async def sessionUpdate(self, notification) -> None:
async def terminal_output(
self, session_id: str, terminal_id: str, **kwargs
) -> TerminalOutputResponse:
return await self._terminal_handle.current_output()
async def wait_for_terminal_exit(
self, session_id: str, terminal_id: str, **kwargs
) -> WaitForTerminalExitResponse:
return await self._terminal_handle.wait_for_exit()
async def release_terminal(
self, session_id: str, terminal_id: str, **kwargs
) -> None:
await self._terminal_handle.release()
async def kill_terminal(self, session_id: str, terminal_id: str, **kwargs) -> None:
await self._terminal_handle.kill()
async def session_update(self, session_id: str, update, **kwargs) -> None:
self._session_update_called = True
@pytest.fixture
def mock_connection() -> MockConnection:
return MockConnection()
def mock_client() -> MockClient:
return MockClient()
@pytest.fixture
def acp_bash_tool(mock_connection: MockConnection) -> Bash:
def acp_bash_tool(mock_client: MockClient) -> Bash:
config = BashToolConfig()
# Use model_construct to bypass Pydantic validation for testing
state = AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
client=mock_client,
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
@@ -128,72 +165,69 @@ class TestAcpBashBasic:
class TestAcpBashExecution:
@pytest.mark.asyncio
async def test_run_success(
self, acp_bash_tool: Bash, mock_connection: MockConnection
self, acp_bash_tool: Bash, mock_client: MockClient
) -> None:
from pathlib import Path
args = BashArgs(command="echo hello")
result = await acp_bash_tool.run(args)
result = await collect_result(acp_bash_tool.run(args))
assert isinstance(result, BashResult)
assert result.stdout == "test output"
assert result.stderr == ""
assert result.returncode == 0
assert mock_connection._create_terminal_called
assert mock_client._create_terminal_called
# Verify CreateTerminalRequest was created correctly
request = mock_connection._last_create_request
assert request is not None
assert request.sessionId == "test_session_123"
assert request.command == "echo"
assert request.args == ["hello"]
assert request.cwd == str(Path.cwd()) # effective_workdir defaults to cwd
# Verify create_terminal was called correctly
params = mock_client._last_create_params
assert params["session_id"] == "test_session_123"
assert params["command"] == "echo"
assert params["args"] == ["hello"]
assert params["cwd"] == str(Path.cwd()) # effective_workdir defaults to cwd
@pytest.mark.asyncio
async def test_run_creates_terminal_with_env_vars(
self, mock_connection: MockConnection
self, mock_client: MockClient
) -> None:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="NODE_ENV=test npm run build")
await tool.run(args)
await collect_result(tool.run(args))
request = mock_connection._last_create_request
assert request is not None
assert len(request.env) == 1
assert request.env[0].name == "NODE_ENV"
assert request.env[0].value == "test"
assert request.command == "npm"
assert request.args == ["run", "build"]
params = mock_client._last_create_params
env = params["env"]
assert env is not None
assert (
isinstance(env, list) and len(env) > 0 and isinstance(env[0], EnvVariable)
)
assert len(env) == 1
assert env[0].name == "NODE_ENV"
assert env[0].value == "test"
assert params["command"] == "npm"
assert params["args"] == ["run", "build"]
@pytest.mark.asyncio
async def test_run_with_nonzero_exit_code(
self, mock_connection: MockConnection
) -> None:
async def test_run_with_nonzero_exit_code(self, mock_client: MockClient) -> None:
custom_handle = MockTerminalHandle(
terminal_id="custom_terminal", exit_code=1, output="error: command failed"
)
mock_connection._terminal_handle = custom_handle
mock_client._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test_command")
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
@@ -201,23 +235,19 @@ class TestAcpBashExecution:
)
@pytest.mark.asyncio
async def test_run_create_terminal_failure(
self, mock_connection: MockConnection
) -> None:
mock_connection._create_terminal_error = RuntimeError("Connection failed")
async def test_run_create_terminal_failure(self, mock_client: MockClient) -> None:
mock_client._create_terminal_error = RuntimeError("Connection failed")
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
@@ -225,38 +255,36 @@ class TestAcpBashExecution:
)
@pytest.mark.asyncio
async def test_run_without_connection(self) -> None:
async def test_run_without_client(self) -> None:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=None, session_id="test_session", tool_call_id="test_call"
client=None, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
== "Connection not available in tool state. This tool can only be used within an ACP session."
== "Client not available in tool state. This tool can only be used within an ACP session."
)
@pytest.mark.asyncio
async def test_run_without_session_id(self) -> None:
mock_connection = MockConnection()
mock_client = MockClient()
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id=None,
tool_call_id="test_call",
client=mock_client, session_id=None, tool_call_id="test_call"
),
)
args = BashArgs(command="test")
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
@@ -264,25 +292,21 @@ class TestAcpBashExecution:
)
@pytest.mark.asyncio
async def test_run_with_none_exit_code(
self, mock_connection: MockConnection
) -> None:
async def test_run_with_none_exit_code(self, mock_client: MockClient) -> None:
custom_handle = MockTerminalHandle(
terminal_id="none_exit_terminal", exit_code=None, output="output"
)
mock_connection._terminal_handle = custom_handle
mock_client._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test_command")
result = await tool.run(args)
result = await collect_result(tool.run(args))
assert result.returncode == 0
assert result.stdout == "output"
@@ -291,41 +315,39 @@ class TestAcpBashExecution:
class TestAcpBashTimeout:
@pytest.mark.asyncio
async def test_run_with_timeout_raises_error_and_kills(
self, mock_connection: MockConnection
self, mock_client: MockClient
) -> None:
custom_handle = MockTerminalHandle(
terminal_id="timeout_terminal",
output="partial output",
wait_delay=20, # Longer than the 1 second timeout
)
mock_connection._terminal_handle = custom_handle
mock_client._terminal_handle = custom_handle
# Use a config with different default timeout to verify args timeout overrides it
tool = Bash(
config=BashToolConfig(default_timeout=30),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="slow_command", timeout=1)
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
assert custom_handle._killed
@pytest.mark.asyncio
async def test_run_timeout_handles_kill_failure(
self, mock_connection: MockConnection
self, mock_client: MockClient
) -> None:
custom_handle = MockTerminalHandle(
terminal_id="kill_failure_terminal",
wait_delay=20, # Longer than the 1 second timeout
)
mock_connection._terminal_handle = custom_handle
mock_client._terminal_handle = custom_handle
async def failing_kill() -> None:
raise RuntimeError("Kill failed")
@@ -335,78 +357,70 @@ class TestAcpBashTimeout:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="slow_command", timeout=1)
# Should still raise timeout error even if kill fails
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
class TestAcpBashEmbedding:
@pytest.mark.asyncio
async def test_run_with_embedding(self, mock_connection: MockConnection) -> None:
async def test_run_with_embedding(self, mock_client: MockClient) -> None:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
await tool.run(args)
await collect_result(tool.run(args))
assert mock_connection._session_update_called
assert mock_client._session_update_called
@pytest.mark.asyncio
async def test_run_embedding_without_tool_call_id(
self, mock_connection: MockConnection
self, mock_client: MockClient
) -> None:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id=None,
client=mock_client, session_id="test_session", tool_call_id=None
),
)
args = BashArgs(command="test")
await tool.run(args)
await collect_result(tool.run(args))
# Embedding should be skipped when tool_call_id is None
assert not mock_connection._session_update_called
assert not mock_client._session_update_called
@pytest.mark.asyncio
async def test_run_embedding_handles_exception(
self, mock_connection: MockConnection
self, mock_client: MockClient
) -> None:
# Make sessionUpdate raise an exception
async def failing_session_update(notification) -> None:
# Make session_update raise an exception
async def failing_session_update(session_id: str, update, **kwargs) -> None:
raise RuntimeError("Session update failed")
mock_connection.sessionUpdate = failing_session_update
mock_client.session_update = failing_session_update
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
# Should not raise, embedding failure is silently ignored
result = await tool.run(args)
result = await collect_result(tool.run(args))
assert result is not None
assert result.stdout == "test output"
@@ -415,25 +429,23 @@ class TestAcpBashEmbedding:
class TestAcpBashConfig:
@pytest.mark.asyncio
async def test_run_uses_config_default_timeout(
self, mock_connection: MockConnection
self, mock_client: MockClient
) -> None:
custom_handle = MockTerminalHandle(
terminal_id="config_timeout_terminal",
wait_delay=0.01, # Shorter than config timeout
)
mock_connection._terminal_handle = custom_handle
mock_client._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(default_timeout=30),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="fast", timeout=None)
result = await tool.run(args)
result = await collect_result(tool.run(args))
# Should succeed with config timeout
assert result.returncode == 0
@@ -442,10 +454,10 @@ class TestAcpBashConfig:
class TestAcpBashCleanup:
@pytest.mark.asyncio
async def test_run_releases_terminal_on_success(
self, mock_connection: MockConnection
self, mock_client: MockClient
) -> None:
custom_handle = MockTerminalHandle(terminal_id="cleanup_terminal")
mock_connection._terminal_handle = custom_handle
mock_client._terminal_handle = custom_handle
release_called = False
@@ -458,20 +470,18 @@ class TestAcpBashCleanup:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
await tool.run(args)
await collect_result(tool.run(args))
assert release_called
@pytest.mark.asyncio
async def test_run_releases_terminal_on_timeout(
self, mock_connection: MockConnection
self, mock_client: MockClient
) -> None:
# The handle will wait 2 seconds, but timeout is 1 second,
# so asyncio.wait_for() will raise TimeoutError
@@ -479,7 +489,7 @@ class TestAcpBashCleanup:
terminal_id="timeout_cleanup_terminal",
wait_delay=2.0, # Longer than the 1 second timeout
)
mock_connection._terminal_handle = custom_handle
mock_client._terminal_handle = custom_handle
release_called = False
@@ -492,45 +502,39 @@ class TestAcpBashCleanup:
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="slow", timeout=1)
# Timeout raises an error, but terminal should still be released
try:
await tool.run(args)
await collect_result(tool.run(args))
except ToolError:
pass
assert release_called
@pytest.mark.asyncio
async def test_run_handles_release_failure(
self, mock_connection: MockConnection
) -> None:
async def test_run_handles_release_failure(self, mock_client: MockClient) -> None:
custom_handle = MockTerminalHandle(terminal_id="release_failure_terminal")
async def failing_release() -> None:
raise RuntimeError("Release failed")
custom_handle.release = failing_release
mock_connection._terminal_handle = custom_handle
mock_client._terminal_handle = custom_handle
tool = Bash(
config=BashToolConfig(),
state=AcpBashState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = BashArgs(command="test")
# Should not raise, release failure is silently ignored
result = await tool.run(args)
result = await collect_result(tool.run(args))
assert result is not None
assert result.stdout == "test output"

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
from pathlib import Path
from typing import cast
from unittest.mock import patch
from acp.schema import TextContentBlock, ToolCallProgress, ToolCallStart
import pytest
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
@pytest.fixture
def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
class PatchedAgent(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
# Force our config with auto_compact_threshold=1
kwargs["config"] = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=1,
)
super().__init__(*args, **kwargs, backend=backend)
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgent).start()
vibe_acp_agent = VibeAcpAgentLoop()
client = FakeClient()
vibe_acp_agent.on_connect(client)
client.on_connect(vibe_acp_agent)
return vibe_acp_agent
class TestCompactEventHandling:
@pytest.mark.asyncio
async def test_prompt_handles_compact_events(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
"""Verify prompt() sends tool_call session updates for compact events."""
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session = acp_agent_loop.sessions[session_response.session_id]
session.agent_loop.stats.context_tokens = 2
await acp_agent_loop.prompt(
prompt=[TextContentBlock(type="text", text="Hello")],
session_id=session_response.session_id,
)
mock_client = cast(FakeClient, acp_agent_loop.client)
updates = [n.update for n in mock_client._session_updates]
compact_start = next(
(
u
for u in updates
if isinstance(u, ToolCallStart)
and u.title.startswith("Compacting conversation history")
),
None,
)
assert compact_start is not None
assert compact_start.session_update == "tool_call"
assert compact_start.kind == "other"
assert compact_start.status == "in_progress"
compact_end = next(
(
u
for u in updates
if isinstance(u, ToolCallProgress)
and u.tool_call_id == compact_start.tool_call_id
),
None,
)
assert compact_end is not None
assert compact_end.session_update == "tool_call_update"
assert compact_end.status == "completed"
assert compact_start.tool_call_id == compact_end.tool_call_id

View File

@@ -1,9 +1,8 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from acp import AgentSideConnection, NewSessionRequest, PromptRequest
from acp import PromptRequest
from acp.schema import (
EmbeddedResourceContentBlock,
ResourceContentBlock,
@@ -13,58 +12,28 @@ from acp.schema import (
import pytest
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_connection import FakeAgentSideConnection
from vibe.acp.acp_agent import VibeAcpAgent
from vibe.core.agent import Agent
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@pytest.fixture
def backend() -> FakeBackend:
backend = FakeBackend(
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
)
)
return backend
@pytest.fixture
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
class PatchedAgent(Agent):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs, backend=backend)
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
vibe_acp_agent: VibeAcpAgent | None = None
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
nonlocal vibe_acp_agent
vibe_acp_agent = VibeAcpAgent(connection)
return vibe_acp_agent
FakeAgentSideConnection(_create_agent)
return vibe_acp_agent # pyright: ignore[reportReturnType]
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.types import Role
class TestACPContent:
@pytest.mark.asyncio
async def test_text_content(
self, acp_agent: VibeAcpAgent, backend: FakeBackend
self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
prompt_request = PromptRequest(
prompt=[TextContentBlock(type="text", text="Say hi")],
sessionId=session_response.sessionId,
session_id=session_response.session_id,
)
response = await acp_agent.prompt(params=prompt_request)
response = await acp_agent_loop.prompt(
prompt=prompt_request.prompt, session_id=session_response.session_id
)
assert response.stopReason == "end_turn"
assert response.stop_reason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,
@@ -74,12 +43,13 @@ class TestACPContent:
@pytest.mark.asyncio
async def test_resource_content(
self, acp_agent: VibeAcpAgent, backend: FakeBackend
self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
prompt_request = PromptRequest(
response = await acp_agent_loop.prompt(
prompt=[
TextContentBlock(type="text", text="What does this file do?"),
EmbeddedResourceContentBlock(
@@ -87,16 +57,14 @@ class TestACPContent:
resource=TextResourceContents(
uri="file:///home/my_file.py",
text="def hello():\n print('Hello, world!')",
mimeType="text/x-python",
mime_type="text/x-python",
),
),
],
sessionId=session_response.sessionId,
session_id=session_response.session_id,
)
response = await acp_agent.prompt(params=prompt_request)
assert response.stopReason == "end_turn"
assert response.stop_reason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,
@@ -111,12 +79,13 @@ class TestACPContent:
@pytest.mark.asyncio
async def test_resource_link_content(
self, acp_agent: VibeAcpAgent, backend: FakeBackend
self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
prompt_request = PromptRequest(
response = await acp_agent_loop.prompt(
prompt=[
TextContentBlock(type="text", text="Analyze this resource"),
ResourceContentBlock(
@@ -125,16 +94,14 @@ class TestACPContent:
name="document.pdf",
title="Important Document",
description="A PDF document containing project specifications",
mimeType="application/pdf",
mime_type="application/pdf",
size=1024,
),
],
sessionId=session_response.sessionId,
session_id=session_response.session_id,
)
response = await acp_agent.prompt(params=prompt_request)
assert response.stopReason == "end_turn"
assert response.stop_reason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,
@@ -146,19 +113,20 @@ class TestACPContent:
+ "\nname: document.pdf"
+ "\ntitle: Important Document"
+ "\ndescription: A PDF document containing project specifications"
+ "\nmimeType: application/pdf"
+ "\nmime_type: application/pdf"
+ "\nsize: 1024"
)
assert user_message.content == expected_content
@pytest.mark.asyncio
async def test_resource_link_minimal(
self, acp_agent: VibeAcpAgent, backend: FakeBackend
self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
prompt_request = PromptRequest(
response = await acp_agent_loop.prompt(
prompt=[
ResourceContentBlock(
type="resource_link",
@@ -166,12 +134,10 @@ class TestACPContent:
name="minimal.txt",
)
],
sessionId=session_response.sessionId,
session_id=session_response.session_id,
)
response = await acp_agent.prompt(params=prompt_request)
assert response.stopReason == "end_turn"
assert response.stop_reason == "end_turn"
user_message = next(
(msg for msg in backend._requests_messages[0] if msg.role == Role.user),
None,

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from acp import PROTOCOL_VERSION, AgentSideConnection, InitializeRequest
from acp import PROTOCOL_VERSION
from acp.schema import (
AgentCapabilities,
ClientCapabilities,
@@ -9,66 +9,51 @@ from acp.schema import (
)
import pytest
from tests.stubs.fake_connection import FakeAgentSideConnection
from vibe.acp.acp_agent import VibeAcpAgent
@pytest.fixture
def acp_agent() -> VibeAcpAgent:
vibe_acp_agent: VibeAcpAgent | None = None
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
nonlocal vibe_acp_agent
vibe_acp_agent = VibeAcpAgent(connection)
return vibe_acp_agent
FakeAgentSideConnection(_create_agent)
return vibe_acp_agent # pyright: ignore[reportReturnType]
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
class TestACPInitialize:
@pytest.mark.asyncio
async def test_initialize(self, acp_agent: VibeAcpAgent) -> None:
"""Test regular initialize without terminal-auth capabilities."""
request = InitializeRequest(protocolVersion=PROTOCOL_VERSION)
response = await acp_agent.initialize(request)
async def test_initialize(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
assert response.protocolVersion == PROTOCOL_VERSION
assert response.agentCapabilities == AgentCapabilities(
loadSession=False,
promptCapabilities=PromptCapabilities(
audio=False, embeddedContext=True, image=False
assert response.protocol_version == PROTOCOL_VERSION
assert response.agent_capabilities == AgentCapabilities(
load_session=False,
prompt_capabilities=PromptCapabilities(
audio=False, embedded_context=True, image=False
),
)
assert response.agentInfo == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.5"
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.0"
)
assert response.authMethods == []
assert response.auth_methods == []
@pytest.mark.asyncio
async def test_initialize_with_terminal_auth(self, acp_agent: VibeAcpAgent) -> None:
async def test_initialize_with_terminal_auth(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
"""Test initialize with terminal-auth capabilities to check it was included."""
client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True})
request = InitializeRequest(
protocolVersion=PROTOCOL_VERSION, clientCapabilities=client_capabilities
response = await acp_agent_loop.initialize(
protocol_version=PROTOCOL_VERSION, client_capabilities=client_capabilities
)
response = await acp_agent.initialize(request)
assert response.protocolVersion == PROTOCOL_VERSION
assert response.agentCapabilities == AgentCapabilities(
loadSession=False,
promptCapabilities=PromptCapabilities(
audio=False, embeddedContext=True, image=False
assert response.protocol_version == PROTOCOL_VERSION
assert response.agent_capabilities == AgentCapabilities(
load_session=False,
prompt_capabilities=PromptCapabilities(
audio=False, embedded_context=True, image=False
),
)
assert response.agentInfo == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.5"
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.0.0"
)
assert response.authMethods is not None
assert len(response.authMethods) == 1
auth_method = response.authMethods[0]
assert response.auth_methods is not None
assert len(response.auth_methods) == 1
auth_method = response.auth_methods[0]
assert auth_method.id == "vibe-setup"
assert auth_method.name == "Register your API Key"
assert auth_method.description == "Register your API Key inside Mistral Vibe"

View File

@@ -2,101 +2,52 @@ from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from unittest.mock import patch
from uuid import uuid4
from acp import (
PROTOCOL_VERSION,
InitializeRequest,
NewSessionRequest,
PromptRequest,
RequestError,
)
from acp import PROTOCOL_VERSION, RequestError
from acp.schema import TextContentBlock
import pytest
from pytest import raises
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_connection import FakeAgentSideConnection
from vibe.acp.acp_agent import VibeAcpAgent
from vibe.core.agent import Agent
from vibe.core.config import ModelConfig, VibeConfig
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.types import Role
@pytest.fixture
def backend() -> FakeBackend:
backend = FakeBackend()
return backend
@pytest.fixture
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
config = VibeConfig(
active_model="devstral-latest",
models=[
ModelConfig(
name="devstral-latest", provider="mistral", alias="devstral-latest"
)
],
)
class PatchedAgent(Agent):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.backend = backend
self.config = config
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
vibe_acp_agent: VibeAcpAgent | None = None
def _create_agent(connection: Any) -> VibeAcpAgent:
nonlocal vibe_acp_agent
vibe_acp_agent = VibeAcpAgent(connection)
return vibe_acp_agent
FakeAgentSideConnection(_create_agent)
return vibe_acp_agent # pyright: ignore[reportReturnType]
class TestMultiSessionCore:
@pytest.mark.asyncio
async def test_different_sessions_use_different_agents(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
session1_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
session1_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session1 = acp_agent.sessions[session1_response.sessionId]
session2_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session1 = acp_agent_loop.sessions[session1_response.session_id]
session2_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session2 = acp_agent.sessions[session2_response.sessionId]
session2 = acp_agent_loop.sessions[session2_response.session_id]
assert session1.id != session2.id
# Each agent should be independent
assert session1.agent is not session2.agent
assert id(session1.agent) != id(session2.agent)
# Each agent loop should be independent
assert session1.agent_loop is not session2.agent_loop
assert id(session1.agent_loop) != id(session2.agent_loop)
@pytest.mark.asyncio
async def test_error_on_nonexistent_session(self, acp_agent: VibeAcpAgent) -> None:
await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
async def test_error_on_nonexistent_session(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
fake_session_id = "fake-session-id-" + str(uuid4())
with raises(RequestError) as exc_info:
await acp_agent.prompt(
PromptRequest(
sessionId=fake_session_id,
prompt=[TextContentBlock(type="text", text="Hello, world!")],
)
await acp_agent_loop.prompt(
session_id=fake_session_id,
prompt=[TextContentBlock(type="text", text="Hello, world!")],
)
assert isinstance(exc_info.value, RequestError)
@@ -104,17 +55,17 @@ class TestMultiSessionCore:
@pytest.mark.asyncio
async def test_simultaneous_message_processing(
self, acp_agent: VibeAcpAgent, backend: FakeBackend
self, acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend
) -> None:
await acp_agent.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION))
session1_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
session1_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session1 = acp_agent.sessions[session1_response.sessionId]
session2_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session1 = acp_agent_loop.sessions[session1_response.session_id]
session2_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session2 = acp_agent.sessions[session2_response.sessionId]
session2 = acp_agent_loop.sessions[session2_response.session_id]
backend._streams = [
[mock_llm_chunk(content="Response 1")],
@@ -122,40 +73,38 @@ class TestMultiSessionCore:
]
async def run_session1():
await acp_agent.prompt(
PromptRequest(
sessionId=session1.id,
prompt=[TextContentBlock(type="text", text="Prompt for session 1")],
)
await acp_agent_loop.prompt(
session_id=session1.id,
prompt=[TextContentBlock(type="text", text="Prompt for session 1")],
)
async def run_session2():
await acp_agent.prompt(
PromptRequest(
sessionId=session2.id,
prompt=[TextContentBlock(type="text", text="Prompt for session 2")],
)
await acp_agent_loop.prompt(
session_id=session2.id,
prompt=[TextContentBlock(type="text", text="Prompt for session 2")],
)
await asyncio.gather(run_session1(), run_session2())
user_message1 = next(
(msg for msg in session1.agent.messages if msg.role == Role.user), None
(msg for msg in session1.agent_loop.messages if msg.role == Role.user), None
)
assert user_message1 is not None
assert user_message1.content == "Prompt for session 1"
assistant_message1 = next(
(msg for msg in session1.agent.messages if msg.role == Role.assistant), None
(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(
(msg for msg in session2.agent.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.content == "Prompt for session 2"
assistant_message2 = next(
(msg for msg in session2.agent.messages if msg.role == Role.assistant), None
(msg for msg in session2.agent_loop.messages if msg.role == Role.assistant),
None,
)
assert assistant_message2 is not None
assert assistant_message2.content == "Response 2"

View File

@@ -3,31 +3,17 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from acp import AgentSideConnection, NewSessionRequest, SetSessionModelRequest
import pytest
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_connection import FakeAgentSideConnection
from vibe.acp.acp_agent import VibeAcpAgent
from vibe.core.agent import Agent
from tests.acp.conftest import _create_acp_agent
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import ModelConfig, VibeConfig
from vibe.core.modes import AgentMode
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@pytest.fixture
def backend() -> FakeBackend:
backend = FakeBackend(
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
)
)
return backend
@pytest.fixture
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
def acp_agent_loop(backend) -> VibeAcpAgentLoop:
config = VibeConfig(
active_model="devstral-latest",
models=[
@@ -40,101 +26,90 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
],
)
class PatchedAgent(Agent):
class PatchedAgentLoop(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
self.config = config
self._base_config = config
self.agent_manager.invalidate_config()
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start()
vibe_acp_agent: VibeAcpAgent | None = None
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
nonlocal vibe_acp_agent
vibe_acp_agent = VibeAcpAgent(connection)
return vibe_acp_agent
FakeAgentSideConnection(_create_agent)
return vibe_acp_agent # pyright: ignore[reportReturnType]
return _create_acp_agent()
class TestACPNewSession:
@pytest.mark.asyncio
async def test_new_session_response_structure(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
assert session_response.sessionId is not None
assert session_response.session_id is not None
acp_session = next(
(
s
for s in acp_agent.sessions.values()
if s.id == session_response.sessionId
for s in acp_agent_loop.sessions.values()
if s.id == session_response.session_id
),
None,
)
assert acp_session is not None
assert (
acp_session.agent.interaction_logger.session_id
== session_response.sessionId
acp_session.agent_loop.session_logger.session_id
== session_response.session_id
)
assert session_response.sessionId == acp_session.agent.session_id
assert session_response.session_id == acp_session.agent_loop.session_id
assert session_response.models is not None
assert session_response.models.currentModelId is not None
assert session_response.models.availableModels is not None
assert len(session_response.models.availableModels) == 2
assert session_response.models.current_model_id is not None
assert session_response.models.available_models is not None
assert len(session_response.models.available_models) == 2
assert session_response.models.currentModelId == "devstral-latest"
assert session_response.models.availableModels[0].modelId == "devstral-latest"
assert session_response.models.availableModels[0].name == "devstral-latest"
assert session_response.models.availableModels[1].modelId == "devstral-small"
assert session_response.models.availableModels[1].name == "devstral-small"
assert session_response.models.current_model_id == "devstral-latest"
assert session_response.models.available_models[0].model_id == "devstral-latest"
assert session_response.models.available_models[0].name == "devstral-latest"
assert session_response.models.available_models[1].model_id == "devstral-small"
assert session_response.models.available_models[1].name == "devstral-small"
assert session_response.modes is not None
assert session_response.modes.currentModeId is not None
assert session_response.modes.availableModes is not None
assert len(session_response.modes.availableModes) == 4
assert session_response.modes.current_mode_id is not None
assert session_response.modes.available_modes is not None
assert len(session_response.modes.available_modes) == 4
assert session_response.modes.currentModeId == AgentMode.DEFAULT.value
assert session_response.modes.availableModes[0].id == AgentMode.DEFAULT.value
assert session_response.modes.availableModes[0].name == "Default"
assert (
session_response.modes.availableModes[1].id == AgentMode.AUTO_APPROVE.value
)
assert session_response.modes.availableModes[1].name == "Auto Approve"
assert session_response.modes.availableModes[2].id == AgentMode.PLAN.value
assert session_response.modes.availableModes[2].name == "Plan"
assert (
session_response.modes.availableModes[3].id == AgentMode.ACCEPT_EDITS.value
)
assert session_response.modes.availableModes[3].name == "Accept Edits"
assert session_response.modes.current_mode_id == BuiltinAgentName.DEFAULT
# Check that all primary agents are available (order may vary)
mode_ids = {m.id for m in session_response.modes.available_modes}
assert mode_ids == {
BuiltinAgentName.DEFAULT,
BuiltinAgentName.AUTO_APPROVE,
BuiltinAgentName.PLAN,
BuiltinAgentName.ACCEPT_EDITS,
}
@pytest.mark.skip(reason="TODO: Fix this test")
@pytest.mark.asyncio
async def test_new_session_preserves_model_after_set_model(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
assert session_response.models is not None
assert session_response.models.currentModelId == "devstral-latest"
assert session_response.models.current_model_id == "devstral-latest"
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="devstral-small"
)
assert response is not None
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
assert session_response.models is not None
assert session_response.models.currentModelId == "devstral-small"
assert session_response.models.current_model_id == "devstral-small"

View File

@@ -2,9 +2,10 @@ from __future__ import annotations
from pathlib import Path
from acp import ReadTextFileRequest, ReadTextFileResponse
from acp import ReadTextFileResponse
import pytest
from tests.mock.utils import collect_result
from vibe.acp.tools.builtins.read_file import AcpReadFileState, ReadFile
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.read_file import (
@@ -14,7 +15,7 @@ from vibe.core.tools.builtins.read_file import (
)
class MockConnection:
class MockClient:
def __init__(
self,
file_content: str = "line 1\nline 2\nline 3",
@@ -24,41 +25,54 @@ class MockConnection:
self._read_error = read_error
self._read_text_file_called = False
self._session_update_called = False
self._last_read_request: ReadTextFileRequest | None = None
self._last_read_params: dict[str, str | int | None] = {}
async def readTextFile(self, request: ReadTextFileRequest) -> ReadTextFileResponse:
async def read_text_file(
self,
path: str,
session_id: str,
limit: int | None = None,
line: int | None = None,
**kwargs,
) -> ReadTextFileResponse:
self._read_text_file_called = True
self._last_read_request = request
self._last_read_params = {
"path": path,
"session_id": session_id,
"limit": limit,
"line": line,
}
if self._read_error:
raise self._read_error
content = self._file_content
if request.line is not None or request.limit is not None:
if line is not None or limit is not None:
lines = content.splitlines(keepends=True)
start_line = (request.line or 1) - 1 # Convert to 0-indexed
end_line = (
start_line + request.limit if request.limit is not None else len(lines)
)
start_line = (line or 1) - 1 # Convert to 0-indexed
end_line = start_line + limit if limit is not None else len(lines)
lines = lines[start_line:end_line]
content = "".join(lines)
return ReadTextFileResponse(content=content)
async def sessionUpdate(self, notification) -> None:
async def session_update(self, session_id: str, update, **kwargs) -> None:
self._session_update_called = True
@pytest.fixture
def mock_connection() -> MockConnection:
return MockConnection()
def mock_client() -> MockClient:
return MockClient()
@pytest.fixture
def acp_read_file_tool(mock_connection: MockConnection, tmp_path: Path) -> ReadFile:
config = ReadFileToolConfig(workdir=tmp_path)
def acp_read_file_tool(
mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> ReadFile:
monkeypatch.chdir(tmp_path)
config = ReadFileToolConfig()
state = AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
client=mock_client, # type: ignore[arg-type]
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
@@ -73,166 +87,159 @@ class TestAcpReadFileBasic:
class TestAcpReadFileExecution:
@pytest.mark.asyncio
async def test_run_success(
self,
acp_read_file_tool: ReadFile,
mock_connection: MockConnection,
tmp_path: Path,
self, acp_read_file_tool: ReadFile, mock_client: MockClient, tmp_path: Path
) -> None:
test_file = tmp_path / "test_file.txt"
test_file.touch()
args = ReadFileArgs(path=str(test_file))
result = await acp_read_file_tool.run(args)
result = await collect_result(acp_read_file_tool.run(args))
assert isinstance(result, ReadFileResult)
assert result.path == str(test_file)
assert result.content == "line 1\nline 2\nline 3"
assert result.lines_read == 3
assert mock_connection._read_text_file_called
assert mock_connection._session_update_called
assert mock_client._read_text_file_called
assert mock_client._session_update_called
# Verify ReadTextFileRequest was created correctly
request = mock_connection._last_read_request
assert request is not None
assert request.sessionId == "test_session_123"
assert request.path == str(test_file)
assert request.line is None # offset=0 means no line specified
assert request.limit is None
# Verify read_text_file was called correctly
params = mock_client._last_read_params
assert params["session_id"] == "test_session_123"
assert params["path"] == str(test_file)
assert params["line"] is None # offset=0 means no line specified
assert params["limit"] is None
@pytest.mark.asyncio
async def test_run_with_offset(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file), offset=1)
result = await tool.run(args)
result = await collect_result(tool.run(args))
assert result.lines_read == 2
assert result.content == "line 2\nline 3"
request = mock_connection._last_read_request
assert request is not None
assert request.line == 2 # offset=1 means line 2 (1-indexed)
params = mock_client._last_read_params
assert params["line"] == 2 # offset=1 means line 2 (1-indexed)
@pytest.mark.asyncio
async def test_run_with_limit(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file), limit=2)
result = await tool.run(args)
result = await collect_result(tool.run(args))
assert result.lines_read == 2
assert result.content == "line 1\nline 2\n"
request = mock_connection._last_read_request
assert request is not None
assert request.limit == 2
params = mock_client._last_read_params
assert params["limit"] == 2
@pytest.mark.asyncio
async def test_run_with_offset_and_limit(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test_file.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file), offset=1, limit=1)
result = await tool.run(args)
result = await collect_result(tool.run(args))
assert result.lines_read == 1
assert result.content == "line 2\n"
request = mock_connection._last_read_request
assert request is not None
assert request.line == 2
assert request.limit == 1
params = mock_client._last_read_params
assert params["line"] == 2
assert params["limit"] == 1
@pytest.mark.asyncio
async def test_run_read_error(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_connection._read_error = RuntimeError("File not found")
monkeypatch.chdir(tmp_path)
mock_client._read_error = RuntimeError("File not found")
test_file = tmp_path / "test.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file))
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert str(exc_info.value) == f"Error reading {test_file}: File not found"
@pytest.mark.asyncio
async def test_run_without_connection(self, tmp_path: Path) -> None:
async def test_run_without_connection(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test.txt"
test_file.touch()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
connection=None, session_id="test_session", tool_call_id="test_call"
client=None, session_id="test_session", tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file))
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
== "Connection not available in tool state. This tool can only be used within an ACP session."
== "Client not available in tool state. This tool can only be used within an ACP session."
)
@pytest.mark.asyncio
async def test_run_without_session_id(self, tmp_path: Path) -> None:
async def test_run_without_session_id(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test.txt"
test_file.touch()
mock_connection = MockConnection()
mock_client = MockClient()
tool = ReadFile(
config=ReadFileToolConfig(workdir=tmp_path),
config=ReadFileToolConfig(),
state=AcpReadFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id=None,
tool_call_id="test_call",
client=mock_client, session_id=None, tool_call_id="test_call"
),
)
args = ReadFileArgs(path=str(test_file))
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert (
str(exc_info.value)

View File

@@ -2,9 +2,10 @@ from __future__ import annotations
from pathlib import Path
from acp import ReadTextFileRequest, ReadTextFileResponse, WriteTextFileRequest
from acp import ReadTextFileResponse
import pytest
from tests.mock.utils import collect_result
from vibe.acp.tools.builtins.search_replace import AcpSearchReplaceState, SearchReplace
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.search_replace import (
@@ -15,7 +16,7 @@ from vibe.core.tools.builtins.search_replace import (
from vibe.core.types import ToolCallEvent, ToolResultEvent
class MockConnection:
class MockClient:
def __init__(
self,
file_content: str = "original line 1\noriginal line 2\noriginal line 3",
@@ -28,43 +29,59 @@ class MockConnection:
self._read_text_file_called = False
self._write_text_file_called = False
self._session_update_called = False
self._last_read_request: ReadTextFileRequest | None = None
self._last_write_request: WriteTextFileRequest | None = None
self._write_calls: list[WriteTextFileRequest] = []
self._last_read_params: dict[str, str | int | None] = {}
self._last_write_params: dict[str, str] = {}
self._write_calls: list[dict[str, str]] = []
async def readTextFile(self, request: ReadTextFileRequest) -> ReadTextFileResponse:
async def read_text_file(
self,
path: str,
session_id: str,
limit: int | None = None,
line: int | None = None,
**kwargs,
) -> ReadTextFileResponse:
self._read_text_file_called = True
self._last_read_request = request
self._last_read_params = {
"path": path,
"session_id": session_id,
"limit": limit,
"line": line,
}
if self._read_error:
raise self._read_error
return ReadTextFileResponse(content=self._file_content)
async def writeTextFile(self, request: WriteTextFileRequest) -> None:
async def write_text_file(
self, content: str, path: str, session_id: str, **kwargs
) -> None:
self._write_text_file_called = True
self._last_write_request = request
self._write_calls.append(request)
params = {"content": content, "path": path, "session_id": session_id}
self._last_write_params = params
self._write_calls.append(params)
if self._write_error:
raise self._write_error
async def sessionUpdate(self, notification) -> None:
async def session_update(self, session_id: str, update, **kwargs) -> None:
self._session_update_called = True
@pytest.fixture
def mock_connection() -> MockConnection:
return MockConnection()
def mock_client() -> MockClient:
return MockClient()
@pytest.fixture
def acp_search_replace_tool(
mock_connection: MockConnection, tmp_path: Path
mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> SearchReplace:
config = SearchReplaceConfig(workdir=tmp_path)
monkeypatch.chdir(tmp_path)
config = SearchReplaceConfig()
state = AcpSearchReplaceState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
client=mock_client,
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
@@ -81,7 +98,7 @@ class TestAcpSearchReplaceExecution:
async def test_run_success(
self,
acp_search_replace_tool: SearchReplace,
mock_connection: MockConnection,
mock_client: MockClient,
tmp_path: Path,
) -> None:
test_file = tmp_path / "test_file.txt"
@@ -92,41 +109,39 @@ class TestAcpSearchReplaceExecution:
args = SearchReplaceArgs(
file_path=str(test_file), content=search_replace_content
)
result = await acp_search_replace_tool.run(args)
result = await collect_result(acp_search_replace_tool.run(args))
assert isinstance(result, SearchReplaceResult)
assert result.file == str(test_file)
assert result.blocks_applied == 1
assert mock_connection._read_text_file_called
assert mock_connection._write_text_file_called
assert mock_connection._session_update_called
assert mock_client._read_text_file_called
assert mock_client._write_text_file_called
assert mock_client._session_update_called
# Verify ReadTextFileRequest was created correctly
read_request = mock_connection._last_read_request
assert read_request is not None
assert read_request.sessionId == "test_session_123"
assert read_request.path == str(test_file)
# Verify read_text_file was called correctly
read_params = mock_client._last_read_params
assert read_params["session_id"] == "test_session_123"
assert read_params["path"] == str(test_file)
# Verify WriteTextFileRequest was created correctly
write_request = mock_connection._last_write_request
assert write_request is not None
assert write_request.sessionId == "test_session_123"
assert write_request.path == str(test_file)
# Verify write_text_file was called correctly
write_params = mock_client._last_write_params
assert write_params["session_id"] == "test_session_123"
assert write_params["path"] == str(test_file)
assert (
write_request.content == "original line 1\nmodified line 2\noriginal line 3"
write_params["content"]
== "original line 1\nmodified line 2\noriginal line 3"
)
@pytest.mark.asyncio
async def test_run_with_backup(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = SearchReplaceConfig(create_backup=True, workdir=tmp_path)
monkeypatch.chdir(tmp_path)
config = SearchReplaceConfig(create_backup=True)
tool = SearchReplace(
config=config,
state=AcpSearchReplaceState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
@@ -138,26 +153,25 @@ class TestAcpSearchReplaceExecution:
args = SearchReplaceArgs(
file_path=str(test_file), content=search_replace_content
)
result = await tool.run(args)
result = await collect_result(tool.run(args))
assert result.blocks_applied == 1
# Should have written the main file and the backup
assert len(mock_connection._write_calls) >= 1
assert len(mock_client._write_calls) >= 1
# Check if backup was written (it should be written to .bak file)
assert sum(w.path.endswith(".bak") for w in mock_connection._write_calls) == 1
assert sum(w["path"].endswith(".bak") for w in mock_client._write_calls) == 1
@pytest.mark.asyncio
async def test_run_read_error(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_connection._read_error = RuntimeError("File not found")
monkeypatch.chdir(tmp_path)
mock_client._read_error = RuntimeError("File not found")
tool = SearchReplace(
config=SearchReplaceConfig(workdir=tmp_path),
config=SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
@@ -168,7 +182,7 @@ class TestAcpSearchReplaceExecution:
file_path=str(test_file), content=search_replace_content
)
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
@@ -177,19 +191,18 @@ class TestAcpSearchReplaceExecution:
@pytest.mark.asyncio
async def test_run_write_error(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_connection._write_error = RuntimeError("Permission denied")
monkeypatch.chdir(tmp_path)
mock_client._write_error = RuntimeError("Permission denied")
test_file = tmp_path / "test.txt"
test_file.touch()
mock_connection._file_content = "old" # Update mock to return correct content
mock_client._file_content = "old" # Update mock to return correct content
tool = SearchReplace(
config=SearchReplaceConfig(workdir=tmp_path),
config=SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
@@ -198,21 +211,21 @@ class TestAcpSearchReplaceExecution:
file_path=str(test_file), content=search_replace_content
)
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert str(exc_info.value) == f"Error writing {test_file}: Permission denied"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"connection,session_id,expected_error",
"client,session_id,expected_error",
[
(
None,
"test_session",
"Connection not available in tool state. This tool can only be used within an ACP session.",
"Client not available in tool state. This tool can only be used within an ACP session.",
),
(
MockConnection(),
MockClient(),
None,
"Session ID not available in tool state. This tool can only be used within an ACP session.",
),
@@ -221,18 +234,18 @@ class TestAcpSearchReplaceExecution:
async def test_run_without_required_state(
self,
tmp_path: Path,
connection: MockConnection | None,
client: MockClient | None,
session_id: str | None,
expected_error: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "test.txt"
test_file.touch()
tool = SearchReplace(
config=SearchReplaceConfig(workdir=tmp_path),
config=SearchReplaceConfig(),
state=AcpSearchReplaceState.model_construct(
connection=connection, # type: ignore[arg-type]
session_id=session_id,
tool_call_id="test_call",
client=client, session_id=session_id, tool_call_id="test_call"
),
)
@@ -241,7 +254,7 @@ class TestAcpSearchReplaceExecution:
file_path=str(test_file), content=search_replace_content
)
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert str(exc_info.value) == expected_error
@@ -262,16 +275,17 @@ class TestAcpSearchReplaceSessionUpdates:
update = SearchReplace.tool_call_session_update(event)
assert update is not None
assert update.sessionUpdate == "tool_call"
assert update.toolCallId == "test_call_123"
assert update.session_update == "tool_call"
assert update.tool_call_id == "test_call_123"
assert update.kind == "edit"
assert update.title is not None
assert update.content is not None
assert isinstance(update.content, list)
assert len(update.content) == 1
assert update.content[0].type == "diff"
assert update.content[0].path == "/tmp/test.txt"
assert update.content[0].oldText == "old text"
assert update.content[0].newText == "new text"
assert update.content[0].old_text == "old text"
assert update.content[0].new_text == "new text"
assert update.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"
@@ -311,15 +325,16 @@ class TestAcpSearchReplaceSessionUpdates:
update = SearchReplace.tool_result_session_update(event)
assert update is not None
assert update.sessionUpdate == "tool_call_update"
assert update.toolCallId == "test_call_123"
assert update.session_update == "tool_call_update"
assert update.tool_call_id == "test_call_123"
assert update.status == "completed"
assert update.content is not None
assert isinstance(update.content, list)
assert len(update.content) == 1
assert update.content[0].type == "diff"
assert update.content[0].path == "/tmp/test.txt"
assert update.content[0].oldText == "old text"
assert update.content[0].newText == "new text"
assert update.content[0].old_text == "old text"
assert update.content[0].new_text == "new text"
assert update.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"

View File

@@ -1,207 +1,178 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from acp import AgentSideConnection, NewSessionRequest, SetSessionModeRequest
import pytest
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_connection import FakeAgentSideConnection
from vibe.acp.acp_agent import VibeAcpAgent
from vibe.core.agent import Agent
from vibe.core.modes import AgentMode
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
@pytest.fixture
def backend() -> FakeBackend:
backend = FakeBackend(
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
)
)
return backend
@pytest.fixture
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
class PatchedAgent(Agent):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs, backend=backend)
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
vibe_acp_agent: VibeAcpAgent | None = None
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
nonlocal vibe_acp_agent
vibe_acp_agent = VibeAcpAgent(connection)
return vibe_acp_agent
FakeAgentSideConnection(_create_agent)
return vibe_acp_agent # pyright: ignore[reportReturnType]
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agents.models import BuiltinAgentName
class TestACPSetMode:
@pytest.mark.asyncio
async def test_set_mode_to_default(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
async def test_set_mode_to_default(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
await acp_session.agent.switch_mode(AgentMode.AUTO_APPROVE)
await acp_session.agent_loop.switch_agent(BuiltinAgentName.AUTO_APPROVE)
response = await acp_agent.setSessionMode(
SetSessionModeRequest(sessionId=session_id, modeId=AgentMode.DEFAULT.value)
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id=BuiltinAgentName.DEFAULT
)
assert response is not None
assert acp_session.agent.mode == AgentMode.DEFAULT
assert acp_session.agent.auto_approve is False
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
assert acp_session.agent_loop.auto_approve is False
@pytest.mark.asyncio
async def test_set_mode_to_auto_approve(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
async def test_set_mode_to_auto_approve(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
assert acp_session.agent.mode == AgentMode.DEFAULT
assert acp_session.agent.auto_approve is False
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
assert acp_session.agent_loop.auto_approve is False
response = await acp_agent.setSessionMode(
SetSessionModeRequest(
sessionId=session_id, modeId=AgentMode.AUTO_APPROVE.value
)
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id=BuiltinAgentName.AUTO_APPROVE
)
assert response is not None
assert acp_session.agent.mode == AgentMode.AUTO_APPROVE
assert acp_session.agent.auto_approve is True
@pytest.mark.asyncio
async def test_set_mode_to_plan(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
)
session_id = session_response.sessionId
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
assert acp_session.agent.mode == AgentMode.DEFAULT
response = await acp_agent.setSessionMode(
SetSessionModeRequest(sessionId=session_id, modeId=AgentMode.PLAN.value)
)
assert response is not None
assert acp_session.agent.mode == AgentMode.PLAN
assert (
acp_session.agent.auto_approve is True
acp_session.agent_loop.agent_profile.name == BuiltinAgentName.AUTO_APPROVE
)
assert acp_session.agent_loop.auto_approve is True
@pytest.mark.asyncio
async def test_set_mode_to_plan(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id=BuiltinAgentName.PLAN
)
assert response is not None
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.PLAN
assert (
acp_session.agent_loop.auto_approve is True
) # Plan mode auto-approves read-only tools
@pytest.mark.asyncio
async def test_set_mode_to_accept_edits(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
async def test_set_mode_to_accept_edits(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
assert acp_session.agent.mode == AgentMode.DEFAULT
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
response = await acp_agent.setSessionMode(
SetSessionModeRequest(
sessionId=session_id, modeId=AgentMode.ACCEPT_EDITS.value
)
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id=BuiltinAgentName.ACCEPT_EDITS
)
assert response is not None
assert acp_session.agent.mode == AgentMode.ACCEPT_EDITS
assert (
acp_session.agent.auto_approve is False
acp_session.agent_loop.agent_profile.name == BuiltinAgentName.ACCEPT_EDITS
)
assert (
acp_session.agent_loop.auto_approve is False
) # Accept Edits mode doesn't auto-approve all
@pytest.mark.asyncio
async def test_set_mode_invalid_mode_returns_none(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_mode = acp_session.agent.mode
initial_auto_approve = acp_session.agent.auto_approve
initial_agent = acp_session.agent_loop.agent_profile.name
initial_auto_approve = acp_session.agent_loop.auto_approve
response = await acp_agent.setSessionMode(
SetSessionModeRequest(sessionId=session_id, modeId="invalid-mode")
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id="invalid-mode"
)
assert response is None
assert acp_session.agent.mode == initial_mode
assert acp_session.agent.auto_approve == initial_auto_approve
assert acp_session.agent_loop.agent_profile.name == initial_agent
assert acp_session.agent_loop.auto_approve == initial_auto_approve
@pytest.mark.asyncio
async def test_set_mode_to_same_mode(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
async def test_set_mode_to_same_mode(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_mode = AgentMode.DEFAULT
assert acp_session.agent.mode == initial_mode
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
response = await acp_agent.setSessionMode(
SetSessionModeRequest(sessionId=session_id, modeId=initial_mode.value)
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id=BuiltinAgentName.DEFAULT
)
assert response is not None
assert acp_session.agent.mode == initial_mode
assert acp_session.agent.auto_approve is False
assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT
assert acp_session.agent_loop.auto_approve is False
@pytest.mark.asyncio
async def test_set_mode_with_empty_string(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
async def test_set_mode_with_empty_string(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_mode = acp_session.agent.mode
initial_auto_approve = acp_session.agent.auto_approve
initial_agent = acp_session.agent_loop.agent_profile.name
initial_auto_approve = acp_session.agent_loop.auto_approve
response = await acp_agent.setSessionMode(
SetSessionModeRequest(sessionId=session_id, modeId="")
response = await acp_agent_loop.set_session_mode(
session_id=session_id, mode_id=""
)
assert response is None
assert acp_session.agent.mode == initial_mode
assert acp_session.agent.auto_approve == initial_auto_approve
assert acp_session.agent_loop.agent_profile.name == initial_agent
assert acp_session.agent_loop.auto_approve == initial_auto_approve

View File

@@ -3,30 +3,17 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from acp import AgentSideConnection, NewSessionRequest, SetSessionModelRequest
import pytest
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_connection import FakeAgentSideConnection
from vibe.acp.acp_agent import VibeAcpAgent
from vibe.core.agent import Agent
from tests.acp.conftest import _create_acp_agent
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import ModelConfig, VibeConfig
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role
from vibe.core.types import LLMMessage, Role
@pytest.fixture
def backend() -> FakeBackend:
backend = FakeBackend(
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Hi"),
usage=LLMUsage(prompt_tokens=1, completion_tokens=1),
)
)
return backend
@pytest.fixture
def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
def acp_agent_loop(backend) -> VibeAcpAgentLoop:
config = VibeConfig(
active_model="devstral-latest",
models=[
@@ -49,10 +36,11 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
VibeConfig.dump_config(config.model_dump())
class PatchedAgent(Agent):
class PatchedAgentLoop(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
self.config = config
self._base_config = config
self.agent_manager.invalidate_config()
try:
active_model = config.get_active_model()
self.stats.input_price_per_million = active_model.input_price
@@ -60,90 +48,86 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
except ValueError:
pass
patch("vibe.acp.acp_agent.VibeAgent", side_effect=PatchedAgent).start()
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start()
vibe_acp_agent: VibeAcpAgent | None = None
def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent:
nonlocal vibe_acp_agent
vibe_acp_agent = VibeAcpAgent(connection)
return vibe_acp_agent
FakeAgentSideConnection(_create_agent)
return vibe_acp_agent # pyright: ignore[reportReturnType]
return _create_acp_agent()
class TestACPSetModel:
@pytest.mark.asyncio
async def test_set_model_success(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
async def test_set_model_success(self, acp_agent_loop: VibeAcpAgentLoop) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
assert acp_session.agent.config.active_model == "devstral-latest"
assert acp_session.agent_loop.config.active_model == "devstral-latest"
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="devstral-small"
)
assert response is not None
assert acp_session.agent.config.active_model == "devstral-small"
assert acp_session.agent_loop.config.active_model == "devstral-small"
@pytest.mark.asyncio
async def test_set_model_invalid_model_returns_none(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_model = acp_session.agent.config.active_model
initial_model = acp_session.agent_loop.config.active_model
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="non-existent-model")
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="non-existent-model"
)
assert response is None
assert acp_session.agent.config.active_model == initial_model
assert acp_session.agent_loop.config.active_model == initial_model
@pytest.mark.asyncio
async def test_set_model_to_same_model(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
async def test_set_model_to_same_model(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
initial_model = "devstral-latest"
assert acp_session is not None
assert acp_session.agent.config.active_model == initial_model
assert acp_session.agent_loop.config.active_model == initial_model
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId=initial_model)
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id=initial_model
)
assert response is not None
assert acp_session.agent.config.active_model == initial_model
assert acp_session.agent_loop.config.active_model == initial_model
@pytest.mark.asyncio
async def test_set_model_saves_to_config(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
async def test_set_model_saves_to_config(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
with patch("vibe.acp.acp_agent.VibeConfig.save_updates") as mock_save:
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
with patch("vibe.acp.acp_agent_loop.VibeConfig.save_updates") as mock_save:
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="devstral-small"
)
assert response is not None
@@ -151,157 +135,171 @@ class TestACPSetModel:
@pytest.mark.asyncio
async def test_set_model_does_not_save_on_invalid_model(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
with patch("vibe.acp.acp_agent.VibeConfig.save_updates") as mock_save:
response = await acp_agent.setSessionModel(
SetSessionModelRequest(
sessionId=session_id, modelId="non-existent-model"
)
with patch("vibe.acp.acp_agent_loop.VibeConfig.save_updates") as mock_save:
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="non-existent-model"
)
assert response is None
mock_save.assert_not_called()
@pytest.mark.asyncio
async def test_set_model_with_empty_string(self, acp_agent: VibeAcpAgent) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
async def test_set_model_with_empty_string(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_model = acp_session.agent.config.active_model
initial_model = acp_session.agent_loop.config.active_model
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="")
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id=""
)
assert response is None
assert acp_session.agent.config.active_model == initial_model
assert acp_session.agent_loop.config.active_model == initial_model
@pytest.mark.asyncio
async def test_set_model_updates_active_model(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
assert acp_session.agent.config.get_active_model().alias == "devstral-latest"
await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
assert (
acp_session.agent_loop.config.get_active_model().alias == "devstral-latest"
)
assert acp_session.agent.config.get_active_model().alias == "devstral-small"
await acp_agent_loop.set_session_model(
session_id=session_id, model_id="devstral-small"
)
assert (
acp_session.agent_loop.config.get_active_model().alias == "devstral-small"
)
@pytest.mark.asyncio
async def test_set_model_calls_reload_with_initial_messages(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
with patch.object(
acp_session.agent, "reload_with_initial_messages"
acp_session.agent_loop, "reload_with_initial_messages"
) as mock_reload:
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="devstral-small"
)
assert response is not None
mock_reload.assert_called_once()
call_args = mock_reload.call_args
assert call_args.kwargs["config"] is not None
assert call_args.kwargs["config"].active_model == "devstral-small"
assert call_args.kwargs["base_config"] is not None
assert call_args.kwargs["base_config"].active_model == "devstral-small"
@pytest.mark.asyncio
async def test_set_model_preserves_conversation_history(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
user_msg = LLMMessage(role=Role.user, content="Hello")
assistant_msg = LLMMessage(role=Role.assistant, content="Hi there!")
acp_session.agent.messages.append(user_msg)
acp_session.agent.messages.append(assistant_msg)
acp_session.agent_loop.messages.append(user_msg)
acp_session.agent_loop.messages.append(assistant_msg)
assert len(acp_session.agent.messages) == 3
assert len(acp_session.agent_loop.messages) == 3
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="devstral-small"
)
assert response is not None
assert len(acp_session.agent.messages) == 3
assert acp_session.agent.messages[0].role == Role.system
assert acp_session.agent.messages[1].content == "Hello"
assert acp_session.agent.messages[2].content == "Hi there!"
assert len(acp_session.agent_loop.messages) == 3
assert acp_session.agent_loop.messages[0].role == Role.system
assert acp_session.agent_loop.messages[1].content == "Hello"
assert acp_session.agent_loop.messages[2].content == "Hi there!"
@pytest.mark.asyncio
async def test_set_model_resets_stats_with_new_model_pricing(
self, acp_agent: VibeAcpAgent
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
session_response = await acp_agent.newSession(
NewSessionRequest(cwd=str(Path.cwd()), mcpServers=[])
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
session_id = session_response.sessionId
session_id = session_response.session_id
acp_session = next(
(s for s in acp_agent.sessions.values() if s.id == session_id), None
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
)
assert acp_session is not None
initial_model = acp_session.agent.config.get_active_model()
initial_model = acp_session.agent_loop.config.get_active_model()
initial_input_price = initial_model.input_price
initial_output_price = initial_model.output_price
initial_stats_input = acp_session.agent.stats.input_price_per_million
initial_stats_output = acp_session.agent.stats.output_price_per_million
initial_stats_input = acp_session.agent_loop.stats.input_price_per_million
initial_stats_output = acp_session.agent_loop.stats.output_price_per_million
assert acp_session.agent.stats.input_price_per_million == initial_input_price
assert acp_session.agent.stats.output_price_per_million == initial_output_price
assert (
acp_session.agent_loop.stats.input_price_per_million == initial_input_price
)
assert (
acp_session.agent_loop.stats.output_price_per_million
== initial_output_price
)
response = await acp_agent.setSessionModel(
SetSessionModelRequest(sessionId=session_id, modelId="devstral-small")
response = await acp_agent_loop.set_session_model(
session_id=session_id, model_id="devstral-small"
)
assert response is not None
new_model = acp_session.agent.config.get_active_model()
new_model = acp_session.agent_loop.config.get_active_model()
new_input_price = new_model.input_price
new_output_price = new_model.output_price
assert new_input_price != initial_input_price
assert new_output_price != initial_output_price
assert acp_session.agent.stats.input_price_per_million == new_input_price
assert acp_session.agent.stats.output_price_per_million == new_output_price
assert acp_session.agent_loop.stats.input_price_per_million == new_input_price
assert acp_session.agent_loop.stats.output_price_per_million == new_output_price
assert acp_session.agent.stats.input_price_per_million != initial_stats_input
assert acp_session.agent.stats.output_price_per_million != initial_stats_output
assert (
acp_session.agent_loop.stats.input_price_per_million != initial_stats_input
)
assert (
acp_session.agent_loop.stats.output_price_per_million
!= initial_stats_output
)

View File

@@ -2,9 +2,9 @@ from __future__ import annotations
from pathlib import Path
from acp import WriteTextFileRequest
import pytest
from tests.mock.utils import collect_result
from vibe.acp.tools.builtins.write_file import AcpWriteFileState, WriteFile
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.write_file import (
@@ -15,7 +15,7 @@ from vibe.core.tools.builtins.write_file import (
from vibe.core.types import ToolCallEvent, ToolResultEvent
class MockConnection:
class MockClient:
def __init__(
self, write_error: Exception | None = None, file_exists: bool = False
) -> None:
@@ -23,29 +23,38 @@ class MockConnection:
self._file_exists = file_exists
self._write_text_file_called = False
self._session_update_called = False
self._last_write_request: WriteTextFileRequest | None = None
self._last_write_params: dict[str, str] = {}
async def writeTextFile(self, request: WriteTextFileRequest) -> None:
async def write_text_file(
self, content: str, path: str, session_id: str, **kwargs
) -> None:
self._write_text_file_called = True
self._last_write_request = request
self._last_write_params = {
"content": content,
"path": path,
"session_id": session_id,
}
if self._write_error:
raise self._write_error
async def sessionUpdate(self, notification) -> None:
async def session_update(self, session_id: str, update, **kwargs) -> None:
self._session_update_called = True
@pytest.fixture
def mock_connection() -> MockConnection:
return MockConnection()
def mock_client() -> MockClient:
return MockClient()
@pytest.fixture
def acp_write_file_tool(mock_connection: MockConnection, tmp_path: Path) -> WriteFile:
config = WriteFileConfig(workdir=tmp_path)
def acp_write_file_tool(
mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> WriteFile:
monkeypatch.chdir(tmp_path)
config = WriteFileConfig()
state = AcpWriteFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
client=mock_client,
session_id="test_session_123",
tool_call_id="test_tool_call_456",
)
@@ -60,40 +69,35 @@ class TestAcpWriteFileBasic:
class TestAcpWriteFileExecution:
@pytest.mark.asyncio
async def test_run_success_new_file(
self,
acp_write_file_tool: WriteFile,
mock_connection: MockConnection,
tmp_path: Path,
self, acp_write_file_tool: WriteFile, mock_client: MockClient, tmp_path: Path
) -> None:
test_file = tmp_path / "test_file.txt"
args = WriteFileArgs(path=str(test_file), content="Hello, world!")
result = await acp_write_file_tool.run(args)
result = await collect_result(acp_write_file_tool.run(args))
assert isinstance(result, WriteFileResult)
assert result.path == str(test_file)
assert result.content == "Hello, world!"
assert result.bytes_written == len(b"Hello, world!")
assert result.file_existed is False
assert mock_connection._write_text_file_called
assert mock_connection._session_update_called
assert mock_client._write_text_file_called
assert mock_client._session_update_called
# Verify WriteTextFileRequest was created correctly
request = mock_connection._last_write_request
assert request is not None
assert request.sessionId == "test_session_123"
assert request.path == str(test_file)
assert request.content == "Hello, world!"
# Verify write_text_file was called correctly
params = mock_client._last_write_params
assert params["session_id"] == "test_session_123"
assert params["path"] == str(test_file)
assert params["content"] == "Hello, world!"
@pytest.mark.asyncio
async def test_run_success_overwrite(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
tool = WriteFile(
config=WriteFileConfig(workdir=tmp_path),
config=WriteFileConfig(),
state=AcpWriteFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
@@ -102,78 +106,80 @@ class TestAcpWriteFileExecution:
# Simulate existing file by checking in the core tool logic
# The ACP tool doesn't check existence, it's handled by the core tool
args = WriteFileArgs(path=str(test_file), content="New content", overwrite=True)
result = await tool.run(args)
result = await collect_result(tool.run(args))
assert isinstance(result, WriteFileResult)
assert result.path == str(test_file)
assert result.content == "New content"
assert result.bytes_written == len(b"New content")
assert result.file_existed is True
assert mock_connection._write_text_file_called
assert mock_connection._session_update_called
assert mock_client._write_text_file_called
assert mock_client._session_update_called
# Verify WriteTextFileRequest was created correctly
request = mock_connection._last_write_request
assert request is not None
assert request.sessionId == "test_session"
assert request.path == str(test_file)
assert request.content == "New content"
# Verify write_text_file was called correctly
params = mock_client._last_write_params
assert params["session_id"] == "test_session"
assert params["path"] == str(test_file)
assert params["content"] == "New content"
@pytest.mark.asyncio
async def test_run_write_error(
self, mock_connection: MockConnection, tmp_path: Path
self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_connection._write_error = RuntimeError("Permission denied")
monkeypatch.chdir(tmp_path)
mock_client._write_error = RuntimeError("Permission denied")
tool = WriteFile(
config=WriteFileConfig(workdir=tmp_path),
config=WriteFileConfig(),
state=AcpWriteFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id="test_session",
tool_call_id="test_call",
client=mock_client, session_id="test_session", tool_call_id="test_call"
),
)
test_file = tmp_path / "test.txt"
args = WriteFileArgs(path=str(test_file), content="test")
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert str(exc_info.value) == f"Error writing {test_file}: Permission denied"
@pytest.mark.asyncio
async def test_run_without_connection(self, tmp_path: Path) -> None:
async def test_run_without_connection(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
tool = WriteFile(
config=WriteFileConfig(workdir=tmp_path),
config=WriteFileConfig(),
state=AcpWriteFileState.model_construct(
connection=None, session_id="test_session", tool_call_id="test_call"
client=None, session_id="test_session", tool_call_id="test_call"
),
)
args = WriteFileArgs(path=str(tmp_path / "test.txt"), content="test")
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
== "Connection not available in tool state. This tool can only be used within an ACP session."
== "Client not available in tool state. This tool can only be used within an ACP session."
)
@pytest.mark.asyncio
async def test_run_without_session_id(self, tmp_path: Path) -> None:
mock_connection = MockConnection()
async def test_run_without_session_id(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
mock_client = MockClient()
tool = WriteFile(
config=WriteFileConfig(workdir=tmp_path),
config=WriteFileConfig(),
state=AcpWriteFileState.model_construct(
connection=mock_connection, # type: ignore[arg-type]
session_id=None,
tool_call_id="test_call",
client=mock_client, session_id=None, tool_call_id="test_call"
),
)
args = WriteFileArgs(path=str(tmp_path / "test.txt"), content="test")
with pytest.raises(ToolError) as exc_info:
await tool.run(args)
await collect_result(tool.run(args))
assert (
str(exc_info.value)
@@ -192,16 +198,17 @@ class TestAcpWriteFileSessionUpdates:
update = WriteFile.tool_call_session_update(event)
assert update is not None
assert update.sessionUpdate == "tool_call"
assert update.toolCallId == "test_call_123"
assert update.session_update == "tool_call"
assert update.tool_call_id == "test_call_123"
assert update.kind == "edit"
assert update.title is not None
assert update.content is not None
assert isinstance(update.content, list)
assert len(update.content) == 1
assert update.content[0].type == "diff"
assert update.content[0].path == "/tmp/test.txt"
assert update.content[0].oldText is None
assert update.content[0].newText == "Hello"
assert update.content[0].old_text is None
assert update.content[0].new_text == "Hello"
assert update.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"
@@ -241,15 +248,16 @@ class TestAcpWriteFileSessionUpdates:
update = WriteFile.tool_result_session_update(event)
assert update is not None
assert update.sessionUpdate == "tool_call_update"
assert update.toolCallId == "test_call_123"
assert update.session_update == "tool_call_update"
assert update.tool_call_id == "test_call_123"
assert update.status == "completed"
assert update.content is not None
assert isinstance(update.content, list)
assert len(update.content) == 1
assert update.content[0].type == "diff"
assert update.content[0].path == "/tmp/test.txt"
assert update.content[0].oldText is None
assert update.content[0].newText == "Hello"
assert update.content[0].old_text is None
assert update.content[0].new_text == "Hello"
assert update.locations is not None
assert len(update.locations) == 1
assert update.locations[0].path == "/tmp/test.txt"

View File

@@ -61,7 +61,7 @@ def make_controller(
("/exit", "Exit application"),
("/vim", "Toggle vim keybindings"),
]
completer = CommandCompleter(commands)
completer = CommandCompleter(lambda: commands)
view = StubView()
controller = SlashCommandController(completer, view)
@@ -162,3 +162,74 @@ def test_on_key_enter_submits_selected_completion() -> None:
assert result is CompletionResult.SUBMIT
assert view.replacements == [Replacement(0, 2, "/compact")]
assert view.reset_count == 1
def test_callable_entries_updates_completions_dynamically() -> None:
"""Test that CommandCompleter with a callable updates entries when the callable returns different values.
This simulates config reload where available skills change.
"""
available_skills: list[tuple[str, str]] = []
def get_entries() -> list[tuple[str, str]]:
base_commands = [("/help", "Display help"), ("/config", "Show configuration")]
return base_commands + available_skills
completer = CommandCompleter(get_entries)
view = StubView()
controller = SlashCommandController(completer, view)
# Initially, only base commands are available
controller.on_text_changed("/", cursor_index=1)
suggestions, _ = view.suggestion_events[-1]
assert [s.alias for s in suggestions] == ["/help", "/config"]
# Simulate config reload: add a skill
available_skills.append(("/summarize", "Summarize the conversation"))
# Now completions should include the new skill
controller.on_text_changed("/", cursor_index=1)
suggestions, _ = view.suggestion_events[-1]
assert [s.alias for s in suggestions] == ["/help", "/config", "/summarize"]
# And searching for "/s" should find the new skill
controller.on_text_changed("/s", cursor_index=2)
suggestions, _ = view.suggestion_events[-1]
assert [s.alias for s in suggestions] == ["/summarize"]
assert suggestions[0].description == "Summarize the conversation"
def test_callable_entries_reflects_enabled_disabled_skills() -> None:
"""Test that skill enable/disable changes are reflected in completions.
This simulates the scenario where a user changes enabled_skills in config
and runs /reload.
"""
enabled_skills: set[str] = {"commit", "review"}
all_skills = [
("/commit", "Create a git commit"),
("/review", "Review code changes"),
("/deploy", "Deploy to production"),
]
def get_entries() -> list[tuple[str, str]]:
return [(name, desc) for name, desc in all_skills if name[1:] in enabled_skills]
completer = CommandCompleter(get_entries)
view = StubView()
controller = SlashCommandController(completer, view)
# Initially only commit and review are enabled
controller.on_text_changed("/", cursor_index=1)
suggestions, _ = view.suggestion_events[-1]
assert [s.alias for s in suggestions] == ["/commit", "/review"]
# Simulate config reload: enable deploy, disable commit
enabled_skills.discard("commit")
enabled_skills.add("deploy")
# Now completions should reflect the change
controller.on_text_changed("/", cursor_index=1)
suggestions, _ = view.suggestion_events[-1]
assert [s.alias for s in suggestions] == ["/review", "/deploy"]

View File

@@ -10,6 +10,7 @@ from textual.widgets import Markdown
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
@@ -20,7 +21,8 @@ def vibe_config() -> VibeConfig:
@pytest.fixture
def vibe_app(vibe_config: VibeConfig) -> VibeApp:
return VibeApp(config=vibe_config)
agent_loop = AgentLoop(vibe_config)
return VibeApp(agent_loop=agent_loop)
@pytest.mark.asyncio
@@ -60,7 +62,7 @@ async def test_pressing_tab_writes_selected_command_and_keeps_popup_visible(
await pilot.press(*"/co")
await pilot.press("tab")
assert chat_input.value == "/config"
assert chat_input.value == "/compact"
assert popup.styles.display == "block"
@@ -88,11 +90,11 @@ async def test_arrow_navigation_updates_selected_suggestion(vibe_app: VibeApp) -
await pilot.press(*"/c")
ensure_selected_command(popup, "/config")
await pilot.press("down")
ensure_selected_command(popup, "/clear")
await pilot.press("down")
ensure_selected_command(popup, "/compact")
await pilot.press("up")
ensure_selected_command(popup, "/config")
ensure_selected_command(popup, "/clear")
@pytest.mark.asyncio
@@ -102,11 +104,11 @@ async def test_arrow_navigation_cycles_through_suggestions(vibe_app: VibeApp) ->
await pilot.press(*"/co")
ensure_selected_command(popup, "/config")
await pilot.press("down")
ensure_selected_command(popup, "/compact")
await pilot.press("up")
await pilot.press("down")
ensure_selected_command(popup, "/config")
await pilot.press("up")
ensure_selected_command(popup, "/compact")
@pytest.mark.asyncio

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGatewayError,
WhoAmIGatewayUnauthorized,
WhoAmIResponse,
)
class FakeWhoAmIGateway:
def __init__(
self,
response: WhoAmIResponse | None = None,
*,
unauthorized: bool = False,
error: bool = False,
) -> None:
self._response = response
self._unauthorized = unauthorized
self._error = error
self.calls: list[str] = []
async def whoami(self, api_key: str) -> WhoAmIResponse:
self.calls.append(api_key)
if self._unauthorized:
raise WhoAmIGatewayUnauthorized()
if self._error:
raise WhoAmIGatewayError()
if self._response is None:
msg = "FakeWhoAmIGateway requires a response when no error is set."
raise RuntimeError(msg)
return self._response

View File

@@ -0,0 +1,103 @@
from __future__ import annotations
import logging
import pytest
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from vibe.cli.plan_offer.decide_plan_offer import PlanOfferAction, decide_plan_offer
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
@pytest.mark.asyncio
async def test_proposes_upgrade_without_call_when_api_key_is_empty() -> None:
gateway = FakeWhoAmIGateway(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=False,
prompt_switching_to_pro_plan=False,
)
)
action = await decide_plan_offer("", gateway)
assert action is PlanOfferAction.UPGRADE
assert gateway.calls == []
@pytest.mark.parametrize(
("response", "expected"),
[
(
WhoAmIResponse(
is_pro_plan=True,
advertise_pro_plan=False,
prompt_switching_to_pro_plan=False,
),
PlanOfferAction.NONE,
),
(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=True,
prompt_switching_to_pro_plan=False,
),
PlanOfferAction.UPGRADE,
),
(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=False,
prompt_switching_to_pro_plan=True,
),
PlanOfferAction.SWITCH_TO_PRO_KEY,
),
],
ids=["with-a-pro-plan", "without-a-pro-plan", "with-a-non-pro-key"],
)
@pytest.mark.asyncio
async def test_proposes_an_action_based_on_current_plan_status(
response: WhoAmIResponse, expected: PlanOfferAction
) -> None:
gateway = FakeWhoAmIGateway(response)
action = await decide_plan_offer("api-key", gateway)
assert action is expected
assert gateway.calls == ["api-key"]
@pytest.mark.asyncio
async def test_proposes_nothing_when_nothing_is_suggested() -> None:
gateway = FakeWhoAmIGateway(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=False,
prompt_switching_to_pro_plan=False,
)
)
action = await decide_plan_offer("api-key", gateway)
assert action is PlanOfferAction.NONE
assert gateway.calls == ["api-key"]
@pytest.mark.asyncio
async def test_proposes_upgrade_when_api_key_is_unauthorized() -> None:
gateway = FakeWhoAmIGateway(unauthorized=True)
action = await decide_plan_offer("bad-key", gateway)
assert action is PlanOfferAction.UPGRADE
assert gateway.calls == ["bad-key"]
@pytest.mark.asyncio
async def test_proposes_none_and_logs_warning_when_gateway_error_occurs(
caplog: pytest.LogCaptureFixture,
) -> None:
gateway = FakeWhoAmIGateway(error=True)
with caplog.at_level(logging.WARNING):
action = await decide_plan_offer("api-key", gateway)
assert action is PlanOfferAction.NONE
assert gateway.calls == ["api-key"]
assert "Failed to fetch plan status." in caplog.text

View File

@@ -0,0 +1,122 @@
from __future__ import annotations
import httpx
import pytest
import respx
from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGatewayError,
WhoAmIGatewayUnauthorized,
WhoAmIResponse,
)
@pytest.mark.asyncio
async def test_returns_plan_flags(respx_mock: respx.MockRouter) -> None:
route = respx_mock.get("http://test/api/vibe/whoami").mock(
return_value=httpx.Response(
200,
json={
"is_pro_plan": True,
"advertise_pro_plan": False,
"prompt_switching_to_pro_plan": False,
},
)
)
gateway = HttpWhoAmIGateway(base_url="http://test")
response = await gateway.whoami("api-key")
assert route.called
request = route.calls.last.request
assert request.headers["Authorization"] == "Bearer api-key"
assert response.is_pro_plan is True
assert response.advertise_pro_plan is False
assert response.prompt_switching_to_pro_plan is False
@pytest.mark.asyncio
@pytest.mark.parametrize("status_code", [401, 403])
async def test_raises_on_unauthorized(
respx_mock: respx.MockRouter, status_code: int
) -> None:
respx_mock.get("http://test/api/vibe/whoami").mock(
return_value=httpx.Response(status_code, json={"error": "unauthorized"})
)
gateway = HttpWhoAmIGateway(base_url="http://test")
with pytest.raises(WhoAmIGatewayUnauthorized):
await gateway.whoami("bad-key")
@pytest.mark.asyncio
async def test_raises_on_non_success(respx_mock: respx.MockRouter) -> None:
respx_mock.get("http://test/api/vibe/whoami").mock(
return_value=httpx.Response(500, json={"error": "boom"})
)
gateway = HttpWhoAmIGateway(base_url="http://test")
with pytest.raises(WhoAmIGatewayError):
await gateway.whoami("api-key")
@pytest.mark.asyncio
async def test_incomplete_payload_defaults_missing_flags_to_false(
respx_mock: respx.MockRouter,
) -> None:
respx_mock.get("http://test/api/vibe/whoami").mock(
return_value=httpx.Response(200, json={"is_pro_plan": True})
)
gateway = HttpWhoAmIGateway(base_url="http://test")
response = await gateway.whoami("api-key")
assert response == WhoAmIResponse(
is_pro_plan=True, advertise_pro_plan=False, prompt_switching_to_pro_plan=False
)
@pytest.mark.asyncio
async def test_wraps_request_error(respx_mock: respx.MockRouter) -> None:
respx_mock.get("http://test/api/vibe/whoami").mock(
side_effect=httpx.ConnectError("boom")
)
gateway = HttpWhoAmIGateway(base_url="http://test")
with pytest.raises(WhoAmIGatewayError):
await gateway.whoami("api-key")
@pytest.mark.asyncio
async def test_parses_boolean_strings(respx_mock: respx.MockRouter) -> None:
respx_mock.get("http://test/api/vibe/whoami").mock(
return_value=httpx.Response(
200,
json={
"is_pro_plan": "true",
"advertise_pro_plan": "false",
"prompt_switching_to_pro_plan": "true",
},
)
)
gateway = HttpWhoAmIGateway(base_url="http://test")
response = await gateway.whoami("api-key")
assert response == WhoAmIResponse(
is_pro_plan=True, advertise_pro_plan=False, prompt_switching_to_pro_plan=True
)
@pytest.mark.asyncio
async def test_raises_on_invalid_boolean_string(respx_mock: respx.MockRouter) -> None:
respx_mock.get("http://test/api/vibe/whoami").mock(
return_value=httpx.Response(200, json={"is_pro_plan": "yes"})
)
gateway = HttpWhoAmIGateway(base_url="http://test")
with pytest.raises(WhoAmIGatewayError):
await gateway.whoami("api-key")

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import pytest
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from tests.stubs.fake_backend import FakeBackend
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.messages import PlanOfferMessage
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
def _make_app(gateway: FakeWhoAmIGateway, config: VibeConfig | None = None) -> VibeApp:
config = config or VibeConfig(
session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False
)
agent_loop = AgentLoop(config=config, backend=FakeBackend())
return VibeApp(agent_loop=agent_loop, plan_offer_gateway=gateway)
@pytest.mark.asyncio
async def test_app_shows_upgrade_offer_in_plan_offer_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "api-key")
gateway = FakeWhoAmIGateway(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=True,
prompt_switching_to_pro_plan=False,
)
)
app = _make_app(gateway)
async with app.run_test() as pilot:
await pilot.pause(0.1)
offer = app.query_one(PlanOfferMessage)
assert "Upgrade to" in offer.get_text()
assert "Pro" in offer.get_text()
assert gateway.calls == ["api-key"]
@pytest.mark.asyncio
async def test_app_shows_switch_to_pro_key_offer_in_plan_offer_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "api-key")
gateway = FakeWhoAmIGateway(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=False,
prompt_switching_to_pro_plan=True,
)
)
app = _make_app(gateway)
async with app.run_test() as pilot:
await pilot.pause(0.1)
offer = app.query_one(PlanOfferMessage)
assert "Switch to your" in offer.get_text()
assert "Pro API key" in offer.get_text()
assert gateway.calls == ["api-key"]

View File

@@ -102,7 +102,10 @@ def test_copy_selection_to_clipboard_success(
mock_copy_fn.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
'"selected text" copied to clipboard', severity="information", timeout=2
'"selected text" copied to clipboard',
severity="information",
timeout=2,
markup=False,
)
@@ -126,7 +129,10 @@ def test_copy_selection_to_clipboard_tries_all(
fn_2.assert_called_once_with("selected text")
fn_3.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
'"selected text" copied to clipboard', severity="information", timeout=2
'"selected text" copied to clipboard',
severity="information",
timeout=2,
markup=False,
)
@@ -175,6 +181,7 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No
'"first selection⏎second selection" copied to clipboard',
severity="information",
timeout=2,
markup=False,
)
@@ -266,8 +273,13 @@ def test_get_copy_fns_no_system_tools(mock_which: MagicMock, mock_app: App) -> N
assert copy_fns[2] == mock_app.copy_to_clipboard
@patch("vibe.cli.clipboard.platform.system")
@patch("vibe.cli.clipboard.shutil.which")
def test_get_copy_fns_with_xclip(mock_which: MagicMock, mock_app: App) -> None:
def test_get_copy_fns_with_xclip(
mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
) -> None:
mock_platform_system.return_value = "Linux"
def which_side_effect(cmd: str) -> str | None:
return "/usr/bin/xclip" if cmd == "xclip" else None
@@ -282,8 +294,13 @@ def test_get_copy_fns_with_xclip(mock_which: MagicMock, mock_app: App) -> None:
assert copy_fns[3] == mock_app.copy_to_clipboard
@patch("vibe.cli.clipboard.platform.system")
@patch("vibe.cli.clipboard.shutil.which")
def test_get_copy_fns_with_wl_copy(mock_which: MagicMock, mock_app: App) -> None:
def test_get_copy_fns_with_wl_copy(
mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
) -> None:
mock_platform_system.return_value = "Linux"
def which_side_effect(cmd: str) -> str | None:
return "/usr/bin/wl-copy" if cmd == "wl-copy" else None
@@ -298,10 +315,13 @@ def test_get_copy_fns_with_wl_copy(mock_which: MagicMock, mock_app: App) -> None
assert copy_fns[3] == mock_app.copy_to_clipboard
@patch("vibe.cli.clipboard.platform.system")
@patch("vibe.cli.clipboard.shutil.which")
def test_get_copy_fns_with_both_system_tools(
mock_which: MagicMock, mock_app: App
mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
) -> None:
mock_platform_system.return_value = "Linux"
def which_side_effect(cmd: str) -> str | None:
match cmd:
case "wl-copy":

View File

@@ -0,0 +1,72 @@
"""Tests for the external editor module."""
from __future__ import annotations
from unittest.mock import patch
from vibe.cli.textual_ui.external_editor import ExternalEditor
class TestGetEditor:
def test_returns_visual_first(self) -> None:
with patch.dict("os.environ", {"VISUAL": "vim", "EDITOR": "nvim"}, clear=True):
assert ExternalEditor.get_editor() == "vim"
def test_falls_back_to_editor(self) -> None:
with patch.dict("os.environ", {"EDITOR": "nvim"}, clear=True):
assert ExternalEditor.get_editor() == "nvim"
def test_falls_back_when_no_editor(self) -> None:
with patch.dict("os.environ", {}, clear=True):
assert ExternalEditor.get_editor() == "nano"
class TestEdit:
def test_returns_modified_content(self) -> None:
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
with patch("subprocess.run") as mock_run:
with patch("pathlib.Path.read_text", return_value="modified"):
with patch("pathlib.Path.unlink"):
editor = ExternalEditor()
result = editor.edit("original")
assert result == "modified"
mock_run.assert_called_once()
def test_returns_none_when_content_unchanged(self) -> None:
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
with patch("subprocess.run"):
with patch("pathlib.Path.read_text", return_value="same"):
with patch("pathlib.Path.unlink"):
editor = ExternalEditor()
result = editor.edit("same")
assert result is None
def test_strips_trailing_whitespace(self) -> None:
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
with patch("subprocess.run"):
with patch("pathlib.Path.read_text", return_value="content\n\n"):
with patch("pathlib.Path.unlink"):
editor = ExternalEditor()
result = editor.edit("original")
assert result == "content"
def test_handles_editor_with_args(self) -> None:
with patch.dict("os.environ", {"VISUAL": "code --wait"}, clear=True):
with patch("subprocess.run") as mock_run:
with patch("pathlib.Path.read_text", return_value="edited"):
with patch("pathlib.Path.unlink"):
editor = ExternalEditor()
editor.edit("original")
call_args = mock_run.call_args[0][0]
assert call_args[0] == "code"
assert call_args[1] == "--wait"
def test_returns_none_on_subprocess_error(self) -> None:
import subprocess as sp
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
with patch("subprocess.run", side_effect=sp.CalledProcessError(1, "vim")):
with patch("pathlib.Path.unlink"):
editor = ExternalEditor()
result = editor.edit("test")
assert result is None

View File

@@ -0,0 +1,513 @@
from __future__ import annotations
import pytest
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
Choice,
Question,
)
@pytest.fixture
def single_question_args():
return AskUserQuestionArgs(
questions=[
Question(
question="Which database?",
header="DB",
options=[
Choice(label="PostgreSQL", description="Relational DB"),
Choice(label="MongoDB", description="Document DB"),
],
)
]
)
@pytest.fixture
def multi_question_args():
return AskUserQuestionArgs(
questions=[
Question(
question="Which database?",
header="DB",
options=[Choice(label="PostgreSQL"), Choice(label="MongoDB")],
),
Question(
question="Which framework?",
header="Framework",
options=[Choice(label="FastAPI"), Choice(label="Django")],
),
]
)
@pytest.fixture
def multi_select_args():
return AskUserQuestionArgs(
questions=[
Question(
question="Which features?",
header="Features",
options=[
Choice(label="Auth"),
Choice(label="Caching"),
Choice(label="Logging"),
],
multi_select=True,
)
]
)
class TestQuestionAppState:
def test_init_state(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
assert app.current_question_idx == 0
assert app.selected_option == 0
assert len(app.answers) == 0
assert len(app.other_texts) == 0
def test_total_options_single_select(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
# 2 options + Other = 3 (no Submit for single-select)
assert app._total_options == 3
def test_total_options_multi_select_includes_submit(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
# 3 options + Other + Submit = 5
assert app._total_options == 5
assert app._other_option_idx == 3
assert app._submit_option_idx == 4
def test_is_other_selected(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
assert app._is_other_selected is False
app.selected_option = 2 # Other option
assert app._is_other_selected is True
def test_is_submit_selected(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
assert app._is_submit_selected is False
app.selected_option = 4 # Submit option
assert app._is_submit_selected is True
def test_is_submit_selected_false_for_single_select(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
# Even if selected_option is 3, is_submit_selected is False for single-select
app.selected_option = 3
assert app._is_submit_selected is False
def test_store_other_text_per_question(self, multi_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_question_args)
# Store text for question 0
app.other_texts[0] = "Custom DB"
# Switch to question 1
app.current_question_idx = 1
app.other_texts[1] = "Custom Framework"
# Verify both stored separately
assert app._get_other_text(0) == "Custom DB"
assert app._get_other_text(1) == "Custom Framework"
def test_save_regular_option_answer(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 0 # PostgreSQL
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert answer_text == "PostgreSQL"
assert is_other is False
def test_save_other_option_answer(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 2 # Other
app.other_texts[0] = "SQLite"
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert answer_text == "SQLite"
assert is_other is True
def test_save_other_option_empty_does_not_save(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 2 # Other
app.other_texts[0] = "" # Empty
app._save_current_answer()
assert 0 not in app.answers
def test_all_answered_false_initially(self, multi_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_question_args)
assert app._all_answered() is False
def test_all_answered_true_when_complete(self, multi_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_question_args)
app.answers[0] = ("PostgreSQL", False)
app.answers[1] = ("FastAPI", False)
assert app._all_answered() is True
def test_multi_select_toggle(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
# Initially no selections
assert len(app.multi_selections.get(0, set())) == 0
# Add selection
app.multi_selections.setdefault(0, set()).add(0)
assert 0 in app.multi_selections[0]
# Add another
app.multi_selections[0].add(2)
assert 2 in app.multi_selections[0]
# Remove first
app.multi_selections[0].discard(0)
assert 0 not in app.multi_selections[0]
assert 2 in app.multi_selections[0]
def test_multi_select_save_answer(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.multi_selections[0] = {0, 2} # Auth and Logging
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert "Auth" in answer_text
assert "Logging" in answer_text
assert is_other is False
def test_multi_select_with_other(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.multi_selections[0] = {0, 3} # Auth and Other
app.other_texts[0] = "Custom Feature"
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert "Auth" in answer_text
assert "Custom Feature" in answer_text
assert is_other is True
class TestQuestionAppActions:
def test_action_move_down(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
assert app.selected_option == 0
app.action_move_down()
assert app.selected_option == 1
app.action_move_down()
assert app.selected_option == 2 # Other
app.action_move_down()
assert app.selected_option == 0 # Wraps around
def test_action_move_up(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
assert app.selected_option == 0
app.action_move_up()
assert app.selected_option == 2 # Wraps to Other
app.action_move_up()
assert app.selected_option == 1
def test_switch_question_preserves_other_text(self, multi_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_question_args)
app.other_texts[0] = "Text for Q1"
app._switch_question(1)
assert app.current_question_idx == 1
assert app._get_other_text(0) == "Text for Q1"
assert app._get_other_text(1) == ""
class TestMultiSelectOtherBehavior:
def test_multi_select_other_does_not_advance_on_save(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.selected_option = 3 # Other option (3 options + Other)
app.other_texts[0] = "Custom feature"
# Save should not advance for multi-select
app._save_current_answer()
# Should stay on same question
assert app.current_question_idx == 0
def test_multi_select_other_toggle_adds_to_selections(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
other_idx = len(app._current_question.options) # 3
# Initially no selections
assert len(app.multi_selections.get(0, set())) == 0
# Add Other to selections
app.multi_selections.setdefault(0, set()).add(other_idx)
app.other_texts[0] = "Custom"
# Can still add regular options
app.multi_selections[0].add(0) # Auth
app.multi_selections[0].add(1) # Caching
assert other_idx in app.multi_selections[0]
assert 0 in app.multi_selections[0]
assert 1 in app.multi_selections[0]
def test_multi_select_save_with_other_and_regular_options(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
other_idx = len(app._current_question.options)
# Select Auth (0), Logging (2), and Other (3)
app.multi_selections[0] = {0, 2, other_idx}
app.other_texts[0] = "Custom Feature"
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert "Auth" in answer_text
assert "Logging" in answer_text
assert "Custom Feature" in answer_text
assert is_other is True
def test_multi_select_other_without_text_not_in_answer(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
other_idx = len(app._current_question.options)
# Select Auth (0) and Other (3) but no text for Other
app.multi_selections[0] = {0, other_idx}
app.other_texts[0] = ""
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert "Auth" in answer_text
# Empty Other should not appear
assert is_other is False # No valid Other text
def test_multi_select_can_toggle_after_selecting_other(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
other_idx = len(app._current_question.options)
# Select Other first
app.multi_selections[0] = {other_idx}
app.other_texts[0] = "Custom"
# Now navigate to and toggle Auth
app.selected_option = 0
selections = app.multi_selections.setdefault(0, set())
selections.add(0) # Toggle Auth on
assert 0 in app.multi_selections[0]
assert other_idx in app.multi_selections[0]
# Toggle Auth off
selections.discard(0)
assert 0 not in app.multi_selections[0]
assert other_idx in app.multi_selections[0]
def test_multi_select_empty_selections_does_not_save(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
# No selections
app._save_current_answer()
assert 0 not in app.answers
class TestSingleSelectOtherBehavior:
def test_single_select_other_with_text_saves(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 2 # Other
app.other_texts[0] = "Custom DB"
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert answer_text == "Custom DB"
assert is_other is True
def test_single_select_other_without_text_does_not_save(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 2 # Other
app.other_texts[0] = ""
app._save_current_answer()
assert 0 not in app.answers
def test_single_select_regular_option_saves_immediately(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 1 # MongoDB
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert answer_text == "MongoDB"
assert is_other is False
class TestMultiSelectAutoSelect:
def test_typing_auto_selects_other(self, multi_select_args):
from unittest.mock import MagicMock
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.other_input = MagicMock()
app.other_input.value = "Custom text"
# Initially no selections
assert app._other_option_idx not in app.multi_selections.get(0, set())
# Simulate input change
from textual.widgets import Input
app.on_input_changed(Input.Changed(app.other_input, "Custom text"))
# Other should now be selected
assert app._other_option_idx in app.multi_selections[0]
def test_clearing_auto_deselects_other(self, multi_select_args):
from unittest.mock import MagicMock
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.other_input = MagicMock()
# Start with Other selected and text
app.multi_selections[0] = {app._other_option_idx}
app.other_input.value = "" # Cleared
# Simulate input change with empty value
from textual.widgets import Input
app.on_input_changed(Input.Changed(app.other_input, ""))
# Other should now be deselected
assert app._other_option_idx not in app.multi_selections[0]
def test_auto_select_preserves_other_selections(self, multi_select_args):
from unittest.mock import MagicMock
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.other_input = MagicMock()
app.other_input.value = "Custom"
# Pre-select Auth and Logging
app.multi_selections[0] = {0, 2}
# Simulate typing
from textual.widgets import Input
app.on_input_changed(Input.Changed(app.other_input, "Custom"))
# All selections should be preserved plus Other
assert 0 in app.multi_selections[0]
assert 2 in app.multi_selections[0]
assert app._other_option_idx in app.multi_selections[0]
class TestMultiSelectSubmit:
def test_navigate_to_submit(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
# Navigate down through all options to Submit
for _ in range(4): # 0->1->2->3(Other)->4(Submit)
app.action_move_down()
assert app.selected_option == 4
assert app._is_submit_selected is True
def test_submit_wraps_around(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.selected_option = 4 # Submit
app.action_move_down()
assert app.selected_option == 0

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from textual.selection import Selection
from textual.widget import Widget
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.textual_ui.app import VibeApp
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
class ClipboardSelectionWidget(Widget):
def __init__(self, selected_text: str) -> None:
super().__init__()
self._selected_text = selected_text
@property
def text_selection(self) -> Selection | None:
return Selection(None, None)
def get_selection(self, selection: Selection) -> tuple[str, str] | None:
return (self._selected_text, "\n")
@pytest.mark.asyncio
async def test_ui_clipboard_notification_does_not_crash_on_markup_text(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_loop = AgentLoop(
config=VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
enable_update_checks=False,
)
)
app = VibeApp(agent_loop=agent_loop)
async with app.run_test(notifications=True) as pilot:
await app.mount(ClipboardSelectionWidget("[/]"))
with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns:
mock_get_copy_fns.return_value = [MagicMock()]
copy_selection_to_clipboard(app)
await pilot.pause(0.1)
notifications = list(app._notifications)
assert notifications
notification = notifications[-1]
assert notification.markup is False
assert "copied to clipboard" in notification.message

View File

@@ -0,0 +1,135 @@
from __future__ import annotations
import pytest
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.messages import AssistantMessage, UserMessage
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.types import FunctionCall, LLMMessage, Role, ToolCall
@pytest.fixture
def vibe_config() -> VibeConfig:
return VibeConfig(
session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False
)
@pytest.mark.asyncio
async def test_ui_displays_messages_when_resuming_session(
vibe_config: VibeConfig,
) -> None:
"""Test that messages are properly displayed when resuming a session."""
agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
# Simulate a previous session with messages
user_msg = LLMMessage(role=Role.user, content="Hello, how are you?")
assistant_msg = LLMMessage(
role=Role.assistant,
content="I'm doing well, thank you!",
tool_calls=[
ToolCall(
id="tool_call_1",
index=0,
function=FunctionCall(
name="read_file", arguments='{"path": "test.txt"}'
),
)
],
)
tool_result_msg = LLMMessage(
role=Role.tool,
content="File content here",
name="read_file",
tool_call_id="tool_call_1",
)
agent_loop.messages.extend([user_msg, assistant_msg, tool_result_msg])
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
async with app.run_test() as pilot:
# Wait for the app to initialize and rebuild history
await pilot.pause(0.5)
# Verify user message is displayed
user_messages = app.query(UserMessage)
assert len(user_messages) == 1
assert user_messages[0]._content == "Hello, how are you?"
# Verify assistant message is displayed
assistant_messages = app.query(AssistantMessage)
assert len(assistant_messages) == 1
assert assistant_messages[0]._content == "I'm doing well, thank you!"
# Verify tool call message is displayed
tool_call_messages = app.query(ToolCallMessage)
assert len(tool_call_messages) == 1
assert tool_call_messages[0]._tool_name == "read_file"
# Verify tool result message is displayed
tool_result_messages = app.query(ToolResultMessage)
assert len(tool_result_messages) == 1
assert tool_result_messages[0].tool_name == "read_file"
assert tool_result_messages[0]._content == "File content here"
@pytest.mark.asyncio
async def test_ui_does_not_display_messages_when_only_system_messages_exist(
vibe_config: VibeConfig,
) -> None:
"""Test that no messages are displayed when only system messages exist."""
agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
# Only system messages
system_msg = LLMMessage(role=Role.system, content="System prompt")
agent_loop.messages.append(system_msg)
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
async with app.run_test() as pilot:
await pilot.pause(0.5)
# Verify no user or assistant messages are displayed
user_messages = app.query(UserMessage)
assert len(user_messages) == 0
assistant_messages = app.query(AssistantMessage)
assert len(assistant_messages) == 0
@pytest.mark.asyncio
async def test_ui_displays_multiple_user_assistant_turns(
vibe_config: VibeConfig,
) -> None:
"""Test that multiple conversation turns are properly displayed."""
agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
# Multiple conversation turns
messages = [
LLMMessage(role=Role.user, content="First question"),
LLMMessage(role=Role.assistant, content="First answer"),
LLMMessage(role=Role.user, content="Second question"),
LLMMessage(role=Role.assistant, content="Second answer"),
]
agent_loop.messages.extend(messages)
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
async with app.run_test() as pilot:
await pilot.pause(0.5)
# Verify all messages are displayed
user_messages = app.query(UserMessage)
assert len(user_messages) == 2
assert user_messages[0]._content == "First question"
assert user_messages[1]._content == "Second question"
assistant_messages = app.query(AssistantMessage)
assert len(assistant_messages) == 2
assert assistant_messages[0]._content == "First answer"
assert assistant_messages[1]._content == "Second answer"

View File

@@ -29,6 +29,7 @@ def get_base_config() -> dict[str, Any]:
"alias": "devstral-latest",
}
],
"enable_auto_update": False,
}
@@ -74,3 +75,8 @@ def _mock_platform(monkeypatch: pytest.MonkeyPatch) -> None:
"""
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setenv("SHELL", "/bin/sh")
@pytest.fixture(autouse=True)
def _mock_update_commands(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["true"])

78
tests/core/test_agents.py Normal file
View File

@@ -0,0 +1,78 @@
from __future__ import annotations
import pytest
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import BUILTIN_AGENTS, EXPLORE, AgentSafety, AgentType
from vibe.core.config import VibeConfig
class TestAgentProfile:
def test_explore_agent_is_subagent(self) -> None:
"""Test that EXPLORE agent has SUBAGENT type."""
assert EXPLORE.agent_type == AgentType.SUBAGENT
def test_explore_agent_has_safe_safety(self) -> None:
"""Test that EXPLORE agent has SAFE safety level."""
assert EXPLORE.safety == AgentSafety.SAFE
def test_explore_agent_has_enabled_tools(self) -> None:
"""Test that EXPLORE agent has expected enabled tools."""
enabled_tools = EXPLORE.overrides.get("enabled_tools", [])
assert "grep" in enabled_tools
assert "read_file" in enabled_tools
def test_builtin_agents_contains_explore(self) -> None:
"""Test that BUILTIN_AGENTS includes explore."""
assert "explore" in BUILTIN_AGENTS
assert BUILTIN_AGENTS["explore"] is EXPLORE
class TestAgentManager:
@pytest.fixture
def manager(self) -> AgentManager:
config = VibeConfig(include_project_context=False, include_prompt_detail=False)
return AgentManager(lambda: config)
def test_get_subagents_returns_only_subagents(self, manager: AgentManager) -> None:
"""Test that only SUBAGENT type agents are returned."""
subagents = manager.get_subagents()
for agent in subagents:
assert agent.agent_type == AgentType.SUBAGENT
def test_get_subagents_includes_explore(self, manager: AgentManager) -> None:
"""Test that EXPLORE is included in subagents."""
subagents = manager.get_subagents()
names = [a.name for a in subagents]
assert "explore" in names
def test_get_subagents_excludes_agents(self, manager: AgentManager) -> None:
"""Test that AGENT type agents are not returned."""
subagents = manager.get_subagents()
names = [a.name for a in subagents]
# These are AGENT type
assert "default" not in names
assert "plan" not in names
assert "auto-approve" not in names
def test_get_builtin_agent(self, manager: AgentManager) -> None:
"""Test getting a builtin agent by name."""
agent = manager.get_agent("explore")
assert agent is EXPLORE
assert agent.agent_type == AgentType.SUBAGENT
def test_get_nonexistent_agent_raises(self, manager: AgentManager) -> None:
"""Test that getting a nonexistent agent raises ValueError."""
with pytest.raises(ValueError, match="not found"):
manager.get_agent("nonexistent-agent")
def test_get_default_agent(self, manager: AgentManager) -> None:
"""Test getting the default agent."""
agent = manager.get_agent("default")
assert agent.name == "default"
assert agent.agent_type == AgentType.AGENT

View File

@@ -1,8 +1,16 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
import json
from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role, ToolCall
from vibe.core.types import (
LLMChunk,
LLMMessage,
LLMUsage,
Role,
ToolCall,
ToolStreamEvent,
)
MOCK_DATA_ENV_VAR = "VIBE_MOCK_LLM_DATA"
@@ -39,4 +47,15 @@ def get_mocking_env(mock_chunks: list[LLMChunk] | None = None) -> dict[str, str]
mock_data = [LLMChunk.model_dump(mock_chunk) for mock_chunk in mock_chunks]
return {MOCK_DATA_ENV_VAR: json.dumps(mock_data)}
return {MOCK_DATA_ENV_VAR: json.dumps(mock_data, ensure_ascii=False)}
async def collect_result[T](gen: AsyncGenerator[ToolStreamEvent | T, None]) -> T:
"""Collect the final result from an AsyncGenerator, filtering out stream events."""
result = None
async for item in gen:
if not isinstance(item, ToolStreamEvent):
result = item
if result is None:
raise RuntimeError("Generator did not yield a result")
return result

View File

@@ -57,4 +57,4 @@ def test_successfully_completes(
onboarding.run_onboarding(StubApp("completed"))
out = capsys.readouterr().out
assert out == ""
assert 'Setup complete 🎉. Run "vibe" to start using the Mistral Vibe CLI.' in out

View File

@@ -0,0 +1,539 @@
from __future__ import annotations
from datetime import datetime
import json
from pathlib import Path
import time
import pytest
from vibe.core.config import SessionLoggingConfig
from vibe.core.session.session_loader import SessionLoader
from vibe.core.types import LLMMessage, Role, ToolCall
@pytest.fixture
def temp_session_dir(tmp_path: Path) -> Path:
"""Create a temporary directory for session loader tests."""
session_dir = tmp_path / "sessions"
session_dir.mkdir()
return session_dir
@pytest.fixture
def session_config(temp_session_dir: Path) -> SessionLoggingConfig:
"""Create a session logging config for testing."""
return SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="test", enabled=True
)
@pytest.fixture
def create_test_session():
"""Helper fixture to create a test session with messages and metadata."""
def _create_test_session(
session_dir: Path,
session_id: str,
messages: list[LLMMessage] | None = None,
metadata: dict | None = None,
) -> Path:
"""Create a test session directory with messages and metadata files."""
# Create session directory
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
session_folder = session_dir / f"test_{timestamp}_{session_id[:8]}"
session_folder.mkdir(exist_ok=True)
# Create messages file
messages_file = session_folder / "messages.jsonl"
if messages is None:
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
with messages_file.open("w", encoding="utf-8") as f:
for message in messages:
f.write(
json.dumps(
message.model_dump(exclude_none=True), ensure_ascii=False
)
+ "\n"
)
# Create metadata file
metadata_file = session_folder / "meta.json"
if metadata is None:
metadata = {
"session_id": session_id,
"start_time": "2023-01-01T12:00:00",
"end_time": "2023-01-01T12:05:00",
"total_messages": 2,
"stats": {
"steps": 1,
"session_prompt_tokens": 10,
"session_completion_tokens": 20,
},
"system_prompt": {"content": "System prompt", "role": "system"},
}
with metadata_file.open("w", encoding="utf-8") as f:
json.dump(metadata, f, indent=2)
return session_folder
return _create_test_session
class TestSessionLoaderFindLatestSession:
def test_find_latest_session_no_sessions(
self, session_config: SessionLoggingConfig
) -> None:
"""Test finding latest session when no sessions exist."""
result = SessionLoader.find_latest_session(session_config)
assert result is None
def test_find_latest_session_single_session(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding latest session with a single session."""
session_dir = Path(session_config.save_dir)
session = create_test_session(session_dir, "session-123")
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result.exists()
assert result == session
def test_find_latest_session_multiple_sessions(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding latest session with multiple sessions."""
session_dir = Path(session_config.save_dir)
create_test_session(session_dir, "session-123")
time.sleep(0.01)
create_test_session(session_dir, "session-456")
time.sleep(0.01)
latest = create_test_session(session_dir, "session-789")
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result.exists()
assert result == latest
def test_find_latest_session_nonexistent_save_dir(self) -> None:
"""Test finding latest session when save directory doesn't exist."""
# Modify config to point to non-existent directory
bad_config = SessionLoggingConfig(
save_dir="/nonexistent/path", session_prefix="test", enabled=True
)
result = SessionLoader.find_latest_session(bad_config)
assert result is None
def test_find_latest_session_with_invalid_sessions(
self, session_config: SessionLoggingConfig
) -> None:
"""Test finding latest session when only invalid sessions exist."""
session_dir = Path(session_config.save_dir)
invalid_session1 = session_dir / "test_20230101_120000_invalid1"
invalid_session1.mkdir()
(invalid_session1 / "messages.jsonl").write_text("[]") # Missing meta.json
invalid_session2 = session_dir / "test_20230101_120001_invalid2"
invalid_session2.mkdir()
(invalid_session2 / "meta.json").write_text('{"session_id": "invalid"}')
result = SessionLoader.find_latest_session(session_config)
assert result is None # Should return None when no valid sessions exist
def test_find_latest_session_with_mixed_valid_invalid(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding latest session when both valid and invalid sessions exist."""
session_dir = Path(session_config.save_dir)
invalid_session = session_dir / "test_20230101_120000_invalid"
invalid_session.mkdir()
(invalid_session / "messages.jsonl").write_text(
'{"role": "user", "content": "test"}\n'
)
time.sleep(0.01)
valid_session = create_test_session(session_dir, "test_20230101_120001_valid")
time.sleep(0.01)
newest_invalid = session_dir / "test_20230101_120002_newest"
newest_invalid.mkdir()
(newest_invalid / "messages.jsonl").write_text(
'{"role": "user", "content": "test"}\n'
)
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result == valid_session
def test_find_latest_session_with_invalid_json(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding latest session when sessions have invalid JSON."""
session_dir = Path(session_config.save_dir)
invalid_meta_session = session_dir / "test_20230101_120000_invalid_meta"
invalid_meta_session.mkdir()
(invalid_meta_session / "messages.jsonl").write_text(
'{"role": "user", "content": "test"}\n'
)
(invalid_meta_session / "meta.json").write_text("{invalid json}")
time.sleep(0.01)
invalid_msg_session = session_dir / "test_20230101_120001_invalid_msg"
invalid_msg_session.mkdir()
(invalid_msg_session / "messages.jsonl").write_text("{invalid json}")
(invalid_msg_session / "meta.json").write_text('{"session_id": "invalid"}')
time.sleep(0.01)
valid_session = create_test_session(session_dir, "test_20230101_120002_valid")
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result == valid_session
class TestSessionLoaderFindSessionById:
def test_find_session_by_id_exact_match(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding session by exact ID match."""
session_dir = Path(session_config.save_dir)
session_folder = create_test_session(session_dir, "test-session-123")
# Test with full UUID format
result = SessionLoader.find_session_by_id("test-session-123", session_config)
assert result is not None
assert result == session_folder
def test_find_session_by_id_short_uuid(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding session by short UUID."""
session_dir = Path(session_config.save_dir)
session_folder = create_test_session(
session_dir, "abc12345-6789-0123-4567-89abcdef0123"
)
# Test with short UUID
result = SessionLoader.find_session_by_id("abc12345", session_config)
assert result is not None
assert result == session_folder
def test_find_session_by_id_partial_match(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding session by partial ID match"""
session_dir = Path(session_config.save_dir)
session_folder = create_test_session(session_dir, "abc12345678")
# Test with partial match
result = SessionLoader.find_session_by_id("abc12345", session_config)
assert result is not None
assert result == session_folder
def test_find_session_by_id_multiple_matches(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding session when multiple sessions match (should return most recent)."""
session_dir = Path(session_config.save_dir)
# Create first session
create_test_session(session_dir, "abcd1234")
# Sleep to ensure different modification times
time.sleep(0.01)
# Create second session with similar ID prefix
session_2 = create_test_session(session_dir, "abcd1234")
result = SessionLoader.find_session_by_id("abcd1234", session_config)
assert result is not None
assert result == session_2
def test_find_session_by_id_no_match(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding session by ID when no match exists."""
session_dir = Path(session_config.save_dir)
create_test_session(session_dir, "test-session-123")
result = SessionLoader.find_session_by_id("nonexistent", session_config)
assert result is None
def test_find_session_by_id_nonexistent_save_dir(self) -> None:
"""Test finding session by ID when save directory doesn't exist."""
bad_config = SessionLoggingConfig(
save_dir="/nonexistent/path", session_prefix="test", enabled=True
)
result = SessionLoader.find_session_by_id("test-session", bad_config)
assert result is None
class TestSessionLoaderLoadSession:
def test_load_session_success(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test successfully loading a session."""
session_dir = Path(session_config.save_dir)
session_folder = create_test_session(session_dir, "test-session-123")
messages, metadata = SessionLoader.load_session(session_folder)
# Verify messages
assert len(messages) == 2
assert messages[0].role == "user"
assert messages[0].content == "Hello"
assert messages[1].role == "assistant"
assert messages[1].content == "Hi there!"
# Verify metadata
assert metadata["session_id"] == "test-session-123"
assert metadata["total_messages"] == 2
assert "stats" in metadata
assert "system_prompt" in metadata
def test_load_session_empty_messages(
self, session_config: SessionLoggingConfig
) -> None:
"""Test loading session with empty messages file."""
session_dir = Path(session_config.save_dir)
session_folder = session_dir / "test_20230101_120000_test123"
session_folder.mkdir()
# Create empty messages file
messages_file = session_folder / "messages.jsonl"
messages_file.write_text("")
# Create metadata file
metadata_file = session_folder / "meta.json"
metadata_file.write_text('{"session_id": "test-session"}')
with pytest.raises(ValueError, match="Session messages file is empty"):
SessionLoader.load_session(session_folder)
def test_load_session_invalid_json_messages(
self, session_config: SessionLoggingConfig
) -> None:
"""Test loading session with invalid JSON in messages file."""
session_dir = Path(session_config.save_dir)
session_folder = session_dir / "test_20230101_120000_test123"
session_folder.mkdir()
# Create messages file with invalid JSON
messages_file = session_folder / "messages.jsonl"
messages_file.write_text("{invalid json}")
# Create metadata file
metadata_file = session_folder / "meta.json"
metadata_file.write_text('{"session_id": "test-session"}')
with pytest.raises(ValueError, match="Session messages contain invalid JSON"):
SessionLoader.load_session(session_folder)
def test_load_session_invalid_json_metadata(
self, session_config: SessionLoggingConfig
) -> None:
"""Test loading session with invalid JSON in metadata file."""
session_dir = Path(session_config.save_dir)
session_folder = session_dir / "test_20230101_120000_test123"
session_folder.mkdir()
# Create valid messages file
messages_file = session_folder / "messages.jsonl"
messages_file.write_text('{"role": "user", "content": "Hello"}\n')
# Create metadata file with invalid JSON
metadata_file = session_folder / "meta.json"
metadata_file.write_text("{invalid json}")
with pytest.raises(ValueError, match="Session metadata contains invalid JSON"):
SessionLoader.load_session(session_folder)
def test_load_session_no_metadata_file(
self, session_config: SessionLoggingConfig
) -> None:
"""Test loading session when metadata file doesn't exist."""
session_dir = Path(session_config.save_dir)
session_folder = session_dir / "test_20230101_120000_test123"
session_folder.mkdir()
# Create valid messages file using the same format as create_test_session
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
messages_file = session_folder / "messages.jsonl"
with messages_file.open("w", encoding="utf-8") as f:
for message in messages:
f.write(
json.dumps(
message.model_dump(exclude_none=True), ensure_ascii=False
)
+ "\n"
)
loaded_messages, metadata = SessionLoader.load_session(session_folder)
assert len(loaded_messages) == 2
assert loaded_messages[0].content == "Hello"
assert loaded_messages[0].role == Role.user
assert loaded_messages[1].content == "Hi there!"
assert loaded_messages[1].role == Role.assistant
assert metadata == {}
def test_load_session_nonexistent_directory(
self, session_config: SessionLoggingConfig
) -> None:
"""Test loading session from non-existent directory."""
nonexistent_dir = Path(session_config.save_dir) / "nonexistent"
with pytest.raises(ValueError, match="Error reading session messages"):
SessionLoader.load_session(nonexistent_dir)
class TestSessionLoaderEdgeCases:
def test_find_latest_session_with_different_prefixes(
self, session_config: SessionLoggingConfig
) -> None:
"""Test finding latest session when sessions have different prefixes."""
session_dir = Path(session_config.save_dir)
# Create sessions with different prefixes
other_session = session_dir / "other_20230101_120000_test123"
other_session.mkdir()
(other_session / "messages.jsonl").write_text(
'{"role": "user", "content": "test"}\n'
)
test_session = session_dir / "test_20230101_120000_test456"
test_session.mkdir()
(test_session / "messages.jsonl").write_text(
'{"role": "user", "content": "test"}\n'
)
(test_session / "meta.json").write_text('{"session_id": "test456"}')
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result.name == "test_20230101_120000_test456"
def test_find_session_by_id_with_special_characters(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding session by ID containing special characters."""
session_dir = Path(session_config.save_dir)
session_folder = create_test_session(
session_dir, "test-session_with-special.chars"
)
result = SessionLoader.find_session_by_id(
"test-session_with-special.chars", session_config
)
assert result is not None
assert result == session_folder
def test_load_session_with_complex_messages(
self, session_config: SessionLoggingConfig
) -> None:
"""Test loading session with complex message structures."""
session_dir = Path(session_config.save_dir)
session_folder = session_dir / "test_20230101_120000_test123"
session_folder.mkdir()
# Create messages with complex structure
complex_messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(
role=Role.user,
content="Complex message",
reasoning_content="Some reasoning",
tool_calls=[ToolCall(id="call1", index=1, type="function")],
),
LLMMessage(
role=Role.assistant,
content="Response",
tool_calls=[ToolCall(id="call2", index=2, type="function")],
),
]
messages_file = session_folder / "messages.jsonl"
with messages_file.open("w", encoding="utf-8") as f:
for message in complex_messages:
f.write(
json.dumps(
message.model_dump(exclude_none=True), ensure_ascii=False
)
+ "\n"
)
# Create metadata file
metadata_file = session_folder / "meta.json"
metadata_file.write_text('{"session_id": "complex-session"}')
messages, _ = SessionLoader.load_session(session_folder)
# Verify complex messages are loaded correctly
assert len(messages) == 2
assert messages[0].role == Role.user
assert messages[0].content == "Complex message"
assert messages[0].reasoning_content == "Some reasoning"
assert len(messages[0].tool_calls or []) == 1
assert messages[1].role == Role.assistant
assert len(messages[1].tool_calls or []) == 1
assert messages[1].content == "Response"
def test_load_session_system_prompt_ignored_in_messages(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that system prompt is ignored when written in messages.jsonl."""
session_dir = Path(session_config.save_dir)
session_folder = session_dir / "test_20230101_120000_test123"
session_folder.mkdir()
messages_with_system = [
LLMMessage(role=Role.system, content="System prompt from messages"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
messages_file = session_folder / "messages.jsonl"
with messages_file.open("w", encoding="utf-8") as f:
for message in messages_with_system:
f.write(
json.dumps(
message.model_dump(exclude_none=True), ensure_ascii=False
)
+ "\n"
)
metadata_file = session_folder / "meta.json"
metadata_file.write_text(
json.dumps({"session_id": "test-session", "total_messages": 3})
)
messages, metadata = SessionLoader.load_session(session_folder)
# Verify that system prompt from messages.jsonl is ignored
assert len(messages) == 2
assert messages[0].role == Role.user
assert messages[0].content == "Hello"
assert messages[1].role == Role.assistant
assert messages[1].content == "Hi there!"

View File

@@ -0,0 +1,641 @@
from __future__ import annotations
from datetime import datetime, timedelta
import json
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from vibe.core.agents.models import AgentProfile, AgentSafety
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.session.session_logger import SessionLogger
from vibe.core.tools.manager import ToolManager
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
@pytest.fixture
def temp_session_dir(tmp_path: Path) -> Path:
"""Create a temporary directory for session logging tests."""
session_dir = tmp_path / "sessions"
session_dir.mkdir()
return session_dir
@pytest.fixture
def session_config(temp_session_dir: Path) -> SessionLoggingConfig:
"""Create a session logging config for testing."""
return SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="test", enabled=True
)
@pytest.fixture
def disabled_session_config() -> SessionLoggingConfig:
"""Create a disabled session logging config for testing."""
return SessionLoggingConfig(
save_dir="/tmp/test", session_prefix="test", enabled=False
)
@pytest.fixture
def mock_agent_profile() -> AgentProfile:
"""Create a mock agent profile for testing."""
return AgentProfile(
name="test-agent",
display_name="Test Agent",
description="A test agent",
safety=AgentSafety.NEUTRAL,
overrides={},
)
@pytest.fixture
def mock_tool_manager() -> ToolManager:
"""Create a mock tool manager for testing."""
manager = MagicMock(spec=ToolManager)
manager.available_tools = {}
return manager
@pytest.fixture
def mock_vibe_config() -> VibeConfig:
"""Create a mock vibe config for testing."""
return VibeConfig(active_model="test-model", models=[], providers=[])
class TestSessionLoggerInitialization:
def test_enabled_session_logger_initialization(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that SessionLogger initializes correctly when enabled."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
assert logger.enabled is True
assert logger.session_id == session_id
assert logger.save_dir == Path(session_config.save_dir)
assert logger.session_prefix == session_config.session_prefix
assert logger.session_dir is not None
assert logger.session_metadata is not None
assert isinstance(logger.session_metadata, SessionMetadata)
# Check that session directory was created
assert logger.session_dir is not None
assert str(logger.session_dir).startswith(str(session_config.save_dir))
# Check session directory name format
dir_name = logger.session_dir.name
assert dir_name.startswith(f"{session_config.session_prefix}_")
assert session_id[:8] in dir_name
def test_disabled_session_logger_initialization(
self, disabled_session_config: SessionLoggingConfig
) -> None:
"""Test that SessionLogger initializes correctly when disabled."""
session_id = "test-session-123"
logger = SessionLogger(disabled_session_config, session_id)
assert logger.enabled is False
assert logger.session_id == "disabled"
assert logger.save_dir is None
assert logger.session_prefix is None
assert logger.session_dir is None
assert logger.session_metadata is None
class TestSessionLoggerMetadata:
@patch("vibe.core.session.session_logger.subprocess.run")
@patch("vibe.core.session.session_logger.getpass.getuser")
def test_session_metadata_initialization(
self, mock_getuser, mock_subprocess, session_config: SessionLoggingConfig
) -> None:
"""Test that session metadata is correctly initialized."""
# Mock git commands
git_commit_mock = MagicMock()
git_commit_mock.returncode = 0
git_commit_mock.stdout = "abc123\n"
git_branch_mock = MagicMock()
git_branch_mock.returncode = 0
git_branch_mock.stdout = "main\n"
mock_subprocess.side_effect = [git_commit_mock, git_branch_mock]
mock_getuser.return_value = "testuser"
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
assert logger.session_metadata is not None
metadata = logger.session_metadata
assert metadata.session_id == session_id
assert metadata.start_time == logger.session_start_time
assert metadata.end_time is None
assert metadata.git_commit == "abc123"
assert metadata.git_branch == "main"
assert metadata.username == "testuser"
assert "working_directory" in metadata.environment
assert metadata.environment["working_directory"] == str(Path.cwd())
@patch("vibe.core.session.session_logger.subprocess.run")
@patch("vibe.core.session.session_logger.getpass.getuser")
def test_session_metadata_with_git_errors(
self, mock_getuser, mock_subprocess, session_config: SessionLoggingConfig
) -> None:
"""Test that session metadata handles git command errors gracefully."""
# Mock git commands to fail
mock_subprocess.side_effect = FileNotFoundError("git not found")
mock_getuser.return_value = "testuser"
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
assert logger.session_metadata is not None
metadata = logger.session_metadata
assert metadata.git_commit is None
assert metadata.git_branch is None
assert metadata.username == "testuser"
class TestSessionLoggerSaveInteraction:
@pytest.mark.asyncio
async def test_save_interaction_disabled(
self, disabled_session_config: SessionLoggingConfig
) -> None:
"""Test that save_interaction returns None when logging is disabled."""
logger = SessionLogger(disabled_session_config, "test-session")
result = await logger.save_interaction(
messages=[],
stats=AgentStats(),
base_config=VibeConfig(active_model="test", models=[], providers=[]),
tool_manager=MagicMock(),
agent_profile=AgentProfile(
name="test",
display_name="Test",
description="Test agent",
safety=AgentSafety.NEUTRAL,
overrides={},
),
)
assert result is None
@pytest.mark.asyncio
async def test_save_interaction_success(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
"""Test that save_interaction successfully saves session data."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
# Create test messages
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
# Create test stats
stats = AgentStats(
steps=1, session_prompt_tokens=10, session_completion_tokens=20
)
# Test that save_interaction returns a path when enabled
result = await logger.save_interaction(
messages=messages,
stats=stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
# Verify the result
assert result is not None
assert str(logger.session_dir) in result
# Verify that files were created
assert logger.session_dir is not None
messages_file = logger.session_dir / "messages.jsonl"
metadata_file = logger.session_dir / "meta.json"
assert messages_file.exists()
assert metadata_file.exists()
# Verify that metadata contains expected data
with open(metadata_file) as f:
metadata = json.load(f)
assert metadata["session_id"] == session_id
assert metadata["total_messages"] == 2
assert metadata["stats"]["steps"] == stats.steps
assert "title" in metadata
assert metadata["title"] == "Hello"
assert "system_prompt" in metadata
@pytest.mark.asyncio
async def test_save_interaction_system_prompt_in_metadata(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
"""Test that system prompt is saved in metadata and not in messages."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
stats = AgentStats(
steps=1, session_prompt_tokens=10, session_completion_tokens=20
)
result = await logger.save_interaction(
messages=messages,
stats=stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
assert result is not None
assert logger.session_dir is not None
metadata_file = logger.session_dir / "meta.json"
with open(metadata_file) as f:
metadata = json.load(f)
assert "system_prompt" in metadata
assert metadata["system_prompt"]["content"] == "System prompt"
assert metadata["system_prompt"]["role"] == "system"
messages_file = logger.session_dir / "messages.jsonl"
with open(messages_file) as f:
lines = f.readlines()
messages_data = [json.loads(line) for line in lines]
assert len(messages_data) == 2
assert messages_data[0]["role"] == "user"
assert messages_data[1]["role"] == "assistant"
@pytest.mark.asyncio
async def test_save_interaction_with_existing_messages(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
"""Test that save_interaction correctly handles existing messages."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
# First save - create initial session
initial_messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
stats = AgentStats(
steps=1, session_prompt_tokens=10, session_completion_tokens=20
)
await logger.save_interaction(
messages=initial_messages,
stats=stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
# Second save - add more messages
new_messages = [
LLMMessage(role=Role.user, content="How are you?"),
LLMMessage(role=Role.assistant, content="I'm fine, thanks!"),
]
all_messages = initial_messages + new_messages
updated_stats = AgentStats(
steps=2, session_prompt_tokens=20, session_completion_tokens=40
)
result = await logger.save_interaction(
messages=all_messages,
stats=updated_stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
# Verify that the result is not None
assert result is not None
# Verify that metadata was updated
assert logger.session_dir is not None
metadata_file = logger.session_dir / "meta.json"
with open(metadata_file) as f:
metadata = json.load(f)
assert metadata["total_messages"] == 4
assert metadata["stats"]["steps"] == updated_stats.steps
@pytest.mark.asyncio
async def test_save_interaction_no_user_messages(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
"""Test that save_interaction handles sessions with no user messages."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
# Create messages with no user messages (only system and assistant)
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
stats = AgentStats(
steps=1, session_prompt_tokens=10, session_completion_tokens=20
)
result = await logger.save_interaction(
messages=messages,
stats=stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
# Verify the result
assert result is not None
assert str(logger.session_dir) in result
# Verify that metadata contains expected data
assert logger.session_dir is not None
metadata_file = logger.session_dir / "meta.json"
with open(metadata_file) as f:
metadata = json.load(f)
assert metadata["session_id"] == session_id
assert metadata["total_messages"] == 1
assert metadata["stats"]["steps"] == stats.steps
assert metadata["title"] == "Untitled session"
@pytest.mark.asyncio
async def test_save_interaction_long_user_message(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
"""Test that save_interaction truncates long user messages for title."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
# Create a long user message (more than 50 characters)
long_message = "This is a very long user message that exceeds fifty characters and should be truncated"
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content=long_message),
LLMMessage(role=Role.assistant, content="Response"),
]
stats = AgentStats(
steps=1, session_prompt_tokens=10, session_completion_tokens=20
)
result = await logger.save_interaction(
messages=messages,
stats=stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
# Verify the result
assert result is not None
assert str(logger.session_dir) in result
# Verify that metadata contains expected data
assert logger.session_dir is not None
metadata_file = logger.session_dir / "meta.json"
with open(metadata_file) as f:
metadata = json.load(f)
assert metadata["session_id"] == session_id
assert metadata["total_messages"] == 2
assert metadata["stats"]["steps"] == stats.steps
expected_title = long_message[:50] + ""
assert metadata["title"] == expected_title
class TestSessionLoggerResetSession:
def test_reset_session(self, session_config: SessionLoggingConfig) -> None:
"""Test that reset_session correctly resets session information."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
# Store original session info
original_session_id = logger.session_id
original_metadata = logger.session_metadata
# Reset session
new_session_id = "test-session-456"
logger.reset_session(new_session_id)
# Verify session was reset
assert logger.session_id == new_session_id
assert logger.session_start_time != "N/A" # Should be a valid timestamp
assert logger.session_metadata is not None
assert logger.session_metadata.session_id == new_session_id
# Verify that metadata was recreated (different object)
assert logger.session_metadata is not original_metadata
assert logger.session_id != original_session_id
def test_reset_session_disabled(
self, disabled_session_config: SessionLoggingConfig
) -> None:
"""Test that reset_session does nothing when logging is disabled."""
logger = SessionLogger(disabled_session_config, "test-session")
# Reset session should not raise any errors
logger.reset_session("new-session")
# Verify state is unchanged
assert logger.enabled is False
assert logger.session_id == "disabled"
class TestSessionLoggerFileOperations:
def test_save_folder(self, session_config: SessionLoggingConfig) -> None:
"""Test that save_folder creates correct folder name."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
folder = logger.save_folder
assert folder.parent == Path(session_config.save_dir)
assert folder.name.startswith(f"{session_config.session_prefix}_")
assert session_id[:8] in folder.name
def test_metadata_filepath(self, session_config: SessionLoggingConfig) -> None:
"""Test that metadata_filepath returns correct path."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
metadata_file = logger.metadata_filepath
assert logger.session_dir is not None
assert metadata_file == logger.session_dir / "meta.json"
def test_messages_filepath(self, session_config: SessionLoggingConfig) -> None:
"""Test that messages_filepath returns correct path."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
messages_file = logger.messages_filepath
assert logger.session_dir is not None
assert messages_file == logger.session_dir / "messages.jsonl"
def test_disabled_file_operations_raise_errors(
self, disabled_session_config: SessionLoggingConfig
) -> None:
"""Test that file operations raise errors when logging is disabled."""
logger = SessionLogger(disabled_session_config, "test-session")
with pytest.raises(
RuntimeError,
match="Cannot get session save folder when logging is disabled",
):
assert logger.save_folder is None
with pytest.raises(
RuntimeError,
match="Cannot get session metadata filepath when logging is disabled",
):
assert logger.metadata_filepath is None
with pytest.raises(
RuntimeError,
match="Cannot get session messages filepath when logging is disabled",
):
assert logger.messages_filepath is None
def create_temp_file_ago(tmp_path: Path, filename: str, minutes_ago: int = 0) -> Path:
"""Create a file with a modification time of `minutes_ago` minutes ago."""
file = tmp_path / filename
file.touch()
old_time = datetime.now() - timedelta(minutes=minutes_ago)
os.utime(file, (old_time.timestamp(), old_time.timestamp()))
return file
class TestSessionLoggerCleanupTmpFiles:
def test_cleanup_tmp_files_disabled(
self, disabled_session_config: SessionLoggingConfig
) -> None:
"""Test that cleanup_tmp_files returns early when logging is disabled."""
logger = SessionLogger(disabled_session_config, "test-session")
logger.cleanup_tmp_files()
def test_cleanup_tmp_files_no_tmp_files(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that cleanup_tmp_files handles no tmp files gracefully."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
logger.cleanup_tmp_files()
def test_cleanup_tmp_files_deletes_old_files(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that cleanup_tmp_files deletes tmp files older than 5 minutes."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
assert logger.session_dir is not None
logger.session_dir.mkdir(parents=True, exist_ok=True)
old_tmp_file = create_temp_file_ago(
logger.session_dir, "session-123.json.tmp", 10
)
new_tmp_file = create_temp_file_ago(logger.session_dir, "session-123.json")
logger.cleanup_tmp_files()
assert not old_tmp_file.exists()
assert new_tmp_file.exists()
def test_cleanup_tmp_files_recursive(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that cleanup_tmp_files works recursively in subdirectories."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
assert logger.session_dir is not None
logger.session_dir.mkdir(parents=True, exist_ok=True)
subdir_1 = logger.session_dir / "session-123"
subdir_1.mkdir()
old_tmp_file = create_temp_file_ago(subdir_1, "meta.json.tmp", 10)
new_tmp_file = create_temp_file_ago(subdir_1, "meta.json")
subdir_2 = logger.session_dir / "session-456"
subdir_2.mkdir()
old_tmp_file_2 = create_temp_file_ago(subdir_2, "meta.json.tmp", 10)
logger.cleanup_tmp_files()
assert not old_tmp_file.exists()
assert not old_tmp_file_2.exists()
assert new_tmp_file.exists()
def test_cleanup_tmp_files_handles_exceptions(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that cleanup_tmp_files handles exceptions gracefully."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
assert logger.session_dir is not None
logger.session_dir.mkdir(parents=True, exist_ok=True)
old_tmp_file = create_temp_file_ago(logger.session_dir, "meta.json.tmp", 10)
another_old_tmp_file = create_temp_file_ago(
logger.session_dir, "meta-002.json.tmp", 10
)
# Mock the unlink method to raise an exception for the first file
original_unlink = Path.unlink
def mock_unlink(self):
if str(self) == str(old_tmp_file):
raise OSError("Mocked error")
return original_unlink(self)
with patch.object(Path, "unlink", mock_unlink):
logger.cleanup_tmp_files()
assert old_tmp_file.exists()
assert not another_old_tmp_file.exists()

View File

@@ -0,0 +1,177 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from vibe.core.config import SessionLoggingConfig
from vibe.core.session.session_migration import migrate_sessions
@pytest.fixture
def temp_session_dir(tmp_path: Path) -> Path:
session_dir = tmp_path / "sessions"
session_dir.mkdir()
return session_dir
@pytest.fixture
def session_config(temp_session_dir: Path) -> SessionLoggingConfig:
return SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="test", enabled=True
)
@pytest.fixture
def disabled_session_config() -> SessionLoggingConfig:
return SessionLoggingConfig(
save_dir="/tmp/test", session_prefix="test", enabled=False
)
@pytest.fixture
def old_session_data() -> dict:
return {
"metadata": {
"session_id": "test-session-123",
"start_time": "2023-01-01T00:00:00",
"end_time": "2023-01-01T01:00:00",
"git_commit": "abc123",
"git_branch": "main",
"username": "testuser",
"environment": {"working_directory": "/test"},
},
"messages": [
{"role": "system", "content": "System prompt"},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
],
}
class TestSessionMigration:
@pytest.mark.asyncio
async def test_migrate_sessions_disabled_config(
self, disabled_session_config: SessionLoggingConfig
) -> None:
"""Test that migration does nothing when config is disabled."""
result = await migrate_sessions(disabled_session_config)
assert result == 0
@pytest.mark.asyncio
async def test_migrate_sessions_no_save_dir(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that migration handles missing save_dir gracefully."""
config = SessionLoggingConfig(save_dir="", session_prefix="test", enabled=True)
result = await migrate_sessions(config)
assert result == 0
@pytest.mark.asyncio
async def test_migrate_sessions_no_old_files(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that migration handles no old session files gracefully."""
session_dir = Path(session_config.save_dir)
session_dir.mkdir(exist_ok=True)
result = await migrate_sessions(session_config)
assert result == 0
@pytest.mark.asyncio
async def test_migrate_sessions_successful_migration(
self, session_config: SessionLoggingConfig, old_session_data: dict
) -> None:
"""Test successful migration of old session files."""
session_dir = Path(session_config.save_dir)
old_session_file = session_dir / "test_session-123.json"
with open(old_session_file, "w") as f:
json.dump(old_session_data, f)
result = await migrate_sessions(session_config)
assert result == 1
assert not old_session_file.exists()
session_subdir = session_dir / "test_session-123"
assert session_subdir.is_dir()
metadata_file = session_subdir / "meta.json"
assert metadata_file.is_file()
with open(metadata_file) as f:
metadata = json.load(f)
assert metadata == old_session_data["metadata"]
messages_file = session_subdir / "messages.jsonl"
assert messages_file.exists()
with open(messages_file) as f:
lines = f.readlines()
assert len(lines) == len(old_session_data["messages"])
for i, line in enumerate(lines):
message = json.loads(line.strip())
assert message == old_session_data["messages"][i]
@pytest.mark.asyncio
async def test_migrate_sessions_multiple_files(
self, session_config: SessionLoggingConfig, old_session_data: dict
) -> None:
"""Test migration of multiple old session files."""
session_dir = Path(session_config.save_dir)
session_files = []
for i in range(3):
session_file = session_dir / f"test_session-{i:03d}.json"
session_files.append(session_file)
modified_data = old_session_data.copy()
modified_data["metadata"]["session_id"] = f"test-session-{i}"
with open(session_file, "w") as f:
json.dump(modified_data, f)
result = await migrate_sessions(session_config)
assert result == 3
for session_file in session_files:
assert not session_file.exists()
for i in range(3):
session_subdir = session_dir / f"test_session-{i:03d}"
assert session_subdir.exists()
assert session_subdir.is_dir()
metadata_file = session_subdir / "meta.json"
messages_file = session_subdir / "messages.jsonl"
assert metadata_file.exists()
assert messages_file.exists()
@pytest.mark.asyncio
async def test_migrate_sessions_error_handling(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that migration handles errors gracefully and continues."""
session_dir = Path(session_config.save_dir)
valid_session_file = session_dir / "test_session-valid.json"
valid_data = {
"metadata": {"session_id": "valid-session"},
"messages": [{"role": "user", "content": "Hello"}],
}
with open(valid_session_file, "w") as f:
json.dump(valid_data, f)
invalid_session_file = session_dir / "test_session-invalid.json"
with open(invalid_session_file, "w") as f:
f.write("{invalid json}")
result = await migrate_sessions(session_config)
assert result == 1
valid_session_subdir = session_dir / "test_session-valid"
assert valid_session_subdir.exists()
assert not valid_session_file.exists()
assert invalid_session_file.exists()

View File

@@ -35,6 +35,7 @@ def create_skill(
compatibility: str | None = None,
metadata: dict[str, str] | None = None,
allowed_tools: str | None = None,
user_invocable: bool | None = None,
body: str = "## Instructions\n\nTest instructions here.",
) -> Path:
skill_dir = skills_dir / name
@@ -49,6 +50,8 @@ def create_skill(
frontmatter["metadata"] = metadata
if allowed_tools:
frontmatter["allowed-tools"] = allowed_tools
if user_invocable is not None:
frontmatter["user-invocable"] = user_invocable
yaml_str = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True)
content = f"---\n{yaml_str}---\n\n{body}"

View File

@@ -273,3 +273,191 @@ class TestSkillManagerGetSkill:
def test_returns_none_for_unknown_skill(self, skill_manager: SkillManager) -> None:
assert skill_manager.get_skill("nonexistent-skill") is None
class TestSkillManagerFiltering:
def test_enabled_skills_filters_to_only_enabled(self, skills_dir: Path) -> None:
create_skill(skills_dir, "skill-a", "Skill A")
create_skill(skills_dir, "skill-b", "Skill B")
create_skill(skills_dir, "skill-c", "Skill C")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
enabled_skills=["skill-a", "skill-c"],
)
manager = SkillManager(lambda: config)
skills = manager.available_skills
assert len(skills) == 2
assert "skill-a" in skills
assert "skill-b" not in skills
assert "skill-c" in skills
def test_disabled_skills_excludes_disabled(self, skills_dir: Path) -> None:
create_skill(skills_dir, "skill-a", "Skill A")
create_skill(skills_dir, "skill-b", "Skill B")
create_skill(skills_dir, "skill-c", "Skill C")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
disabled_skills=["skill-b"],
)
manager = SkillManager(lambda: config)
skills = manager.available_skills
assert len(skills) == 2
assert "skill-a" in skills
assert "skill-b" not in skills
assert "skill-c" in skills
def test_enabled_skills_takes_precedence_over_disabled(
self, skills_dir: Path
) -> None:
create_skill(skills_dir, "skill-a", "Skill A")
create_skill(skills_dir, "skill-b", "Skill B")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
enabled_skills=["skill-a"],
disabled_skills=["skill-a"], # Should be ignored
)
manager = SkillManager(lambda: config)
skills = manager.available_skills
assert len(skills) == 1
assert "skill-a" in skills
def test_glob_pattern_matching(self, skills_dir: Path) -> None:
create_skill(skills_dir, "search-code", "Search code")
create_skill(skills_dir, "search-docs", "Search docs")
create_skill(skills_dir, "other-skill", "Other skill")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
enabled_skills=["search-*"],
)
manager = SkillManager(lambda: config)
skills = manager.available_skills
assert len(skills) == 2
assert "search-code" in skills
assert "search-docs" in skills
assert "other-skill" not in skills
def test_regex_pattern_matching(self, skills_dir: Path) -> None:
create_skill(skills_dir, "skill-v1", "Skill v1")
create_skill(skills_dir, "skill-v2", "Skill v2")
create_skill(skills_dir, "other-skill", "Other skill")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
enabled_skills=["re:skill-v\\d+"],
)
manager = SkillManager(lambda: config)
skills = manager.available_skills
assert len(skills) == 2
assert "skill-v1" in skills
assert "skill-v2" in skills
assert "other-skill" not in skills
def test_get_skill_respects_filtering(self, skills_dir: Path) -> None:
create_skill(skills_dir, "enabled-skill", "Enabled")
create_skill(skills_dir, "disabled-skill", "Disabled")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
disabled_skills=["disabled-skill"],
)
manager = SkillManager(lambda: config)
assert manager.get_skill("enabled-skill") is not None
assert manager.get_skill("disabled-skill") is None
class TestSkillUserInvocable:
def test_user_invocable_defaults_to_true(self, skills_dir: Path) -> None:
create_skill(skills_dir, "default-skill", "A default skill")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
)
manager = SkillManager(lambda: config)
skill = manager.get_skill("default-skill")
assert skill is not None
assert skill.user_invocable is True
def test_user_invocable_can_be_set_to_false(self, skills_dir: Path) -> None:
create_skill(skills_dir, "hidden-skill", "A hidden skill", user_invocable=False)
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
)
manager = SkillManager(lambda: config)
skill = manager.get_skill("hidden-skill")
assert skill is not None
assert skill.user_invocable is False
def test_user_invocable_can_be_explicitly_set_to_true(
self, skills_dir: Path
) -> None:
create_skill(
skills_dir, "explicit-skill", "An explicit skill", user_invocable=True
)
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
)
manager = SkillManager(lambda: config)
skill = manager.get_skill("explicit-skill")
assert skill is not None
assert skill.user_invocable is True
def test_mixed_user_invocable_skills(self, skills_dir: Path) -> None:
create_skill(skills_dir, "visible-skill", "Visible", user_invocable=True)
create_skill(skills_dir, "hidden-skill", "Hidden", user_invocable=False)
create_skill(skills_dir, "default-skill", "Default")
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
system_prompt_id="tests",
include_project_context=False,
skill_paths=[skills_dir],
)
manager = SkillManager(lambda: config)
skills = manager.available_skills
assert len(skills) == 3
assert skills["visible-skill"].user_invocable is True
assert skills["hidden-skill"].user_invocable is False
assert skills["default-skill"].user_invocable is True

View File

@@ -18,6 +18,7 @@ class TestSkillMetadata:
assert meta.compatibility is None
assert meta.metadata == {}
assert meta.allowed_tools == []
assert meta.user_invocable is True
def test_creates_with_all_fields(self) -> None:
meta = SkillMetadata(
@@ -27,6 +28,7 @@ class TestSkillMetadata:
compatibility="Requires git",
metadata={"author": "Test Author", "version": "1.0"},
allowed_tools=["bash", "read_file"],
user_invocable=False,
)
assert meta.name == "full-skill"
@@ -35,6 +37,7 @@ class TestSkillMetadata:
assert meta.compatibility == "Requires git"
assert meta.metadata == {"author": "Test Author", "version": "1.0"}
assert meta.allowed_tools == ["bash", "read_file"]
assert meta.user_invocable is False
def test_raises_error_for_uppercase_name(self) -> None:
with pytest.raises(ValidationError) as exc_info:
@@ -144,6 +147,7 @@ class TestSkillInfo:
compatibility="git, docker",
metadata={"author": "Test"},
allowed_tools=["bash"],
user_invocable=False,
skill_path=skill_path,
)
@@ -153,6 +157,7 @@ class TestSkillInfo:
assert info.compatibility == "git, docker"
assert info.metadata == {"author": "Test"}
assert info.allowed_tools == ["bash"]
assert info.user_invocable is False
assert info.skill_path == skill_path
assert info.skill_dir == skill_path.parent.resolve()
@@ -179,6 +184,7 @@ class TestSkillInfo:
compatibility="Requires Python 3.12",
metadata={"key": "value"},
allowed_tools=["bash", "grep"],
user_invocable=False,
)
info = SkillInfo.from_metadata(meta, skill_path)
@@ -186,3 +192,4 @@ class TestSkillInfo:
assert info.compatibility == meta.compatibility
assert info.metadata == meta.metadata
assert info.allowed_tools == meta.allowed_tools
assert info.user_invocable == meta.user_invocable

View File

@@ -0,0 +1,140 @@
<svg class="rich-terminal" viewBox="0 0 1482 538.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #3c3836 }
.terminal-r3 { fill: #fbf1c7 }
.terminal-r4 { fill: #a6a087 }
.terminal-r5 { fill: #a9a9a9 }
.terminal-r6 { fill: #85a598;font-weight: bold }
.terminal-r7 { fill: #7e7e7e }
.terminal-r8 { fill: #85a598 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1463.0" height="487.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">AskUserQuestionResultApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#282828" x="0" y="1.5" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="25.9" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="50.3" width="1439.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="1451.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="74.7" width="1439.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="1451.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="99.1" width="707.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="719.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="732" y="99.1" width="719.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="1451.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="123.5" width="1439.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="1451.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="147.9" width="1439.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="1451.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="172.3" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="196.7" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="24.4" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="36.6" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="48.8" y="221.1" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="500.2" y="221.1" width="963.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="245.5" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="269.9" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="294.3" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="318.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="318.7" width="1024.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="1024.8" y="318.7" width="439.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="343.1" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="343.1" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="367.5" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="24.4" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="36.6" y="391.9" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="219.6" y="391.9" width="1244.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="416.3" width="1464" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="440.7" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="158.6" y="440.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="158.6" y="440.7" width="1098" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="1256.6" y="440.7" width="207.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="0" y="465.1" width="1464" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r2" x="0" y="44.4" textLength="1464" 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="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r2" 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="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r2" 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="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r2" x="1451.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></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="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r2" x="1451.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r2" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r2" x="1451.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></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="0" y="190.8" textLength="1464" clip-path="url(#terminal-line-7)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</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-r3" x="48.8" y="239.6" textLength="451.4" clip-path="url(#terminal-line-9)">3&#160;answers&#160;received&#160;(ctrl+o&#160;to&#160;expand)</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r5" x="1024.8" y="337.2" textLength="439.2" clip-path="url(#terminal-line-13)">&#160;default&#160;agent&#160;(shift+tab&#160;to&#160;cycle)</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</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="0" y="386" textLength="1464" 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-r6" x="12.2" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">&gt;</text><text class="terminal-r7" x="36.6" y="410.4" textLength="183" clip-path="url(#terminal-line-16)">Ask&#160;anything...</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="0" y="434.8" textLength="1464" 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-r8" x="0" y="459.2" textLength="158.6" clip-path="url(#terminal-line-18)">/test/workdir</text><text class="terminal-r3" x="1256.6" y="459.2" textLength="207.4" clip-path="url(#terminal-line-18)">0%&#160;of&#160;200k&#160;tokens</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,137 @@
<svg class="rich-terminal" viewBox="0 0 994 538.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #8d8d8d }
.terminal-r2 { fill: #c5c8c6 }
.terminal-r3 { fill: #0178d4 }
.terminal-r4 { fill: #0178d4;font-weight: bold }
.terminal-r5 { fill: #e0e0e0 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="975.0" height="487.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">MultiQuestionApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="231.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="256.2" y="25.9" width="707.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="219.6" y="74.7" width="744.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="170.8" y="123.5" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="158.6" y="147.9" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="172.3" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="172.3" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="221.1" width="622.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="646.6" y="221.1" width="317.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r3" x="24.4" y="44.4" textLength="231.8" clip-path="url(#terminal-line-1)">&#160;DB&#160;&#160;&#160;&#160;[Framework]</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r4" x="24.4" y="93.2" textLength="195.2" clip-path="url(#terminal-line-3)">Which&#160;framework?</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r4" x="24.4" y="142" textLength="146.4" clip-path="url(#terminal-line-5)">&#160;1.&#160;FastAPI</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r5" x="24.4" y="166.4" textLength="134.2" clip-path="url(#terminal-line-6)">&#160;&#160;2.&#160;Django</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r5" x="24.4" y="190.8" textLength="292.8" clip-path="url(#terminal-line-7)">&#160;&#160;3.&#160;Type&#160;your&#160;answer...</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="24.4" y="239.6" textLength="622.2" clip-path="url(#terminal-line-9)">←→&#160;questions&#160;&#160;↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;Esc&#160;cancel</text><text class="terminal-r1" x="963.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="0" y="264" textLength="976" clip-path="url(#terminal-line-10)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,137 @@
<svg class="rich-terminal" viewBox="0 0 994 538.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #8d8d8d }
.terminal-r2 { fill: #c5c8c6 }
.terminal-r3 { fill: #0178d4 }
.terminal-r4 { fill: #0178d4;font-weight: bold }
.terminal-r5 { fill: #e0e0e0 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="975.0" height="487.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">MultiQuestionApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="231.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="256.2" y="25.9" width="707.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="219.6" y="74.7" width="744.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="170.8" y="123.5" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="158.6" y="147.9" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="172.3" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="172.3" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="221.1" width="622.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="646.6" y="221.1" width="317.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r3" x="24.4" y="44.4" textLength="231.8" clip-path="url(#terminal-line-1)">&#160;DB&#160;&#160;&#160;&#160;[Framework]</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r4" x="24.4" y="93.2" textLength="195.2" clip-path="url(#terminal-line-3)">Which&#160;framework?</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r4" x="24.4" y="142" textLength="146.4" clip-path="url(#terminal-line-5)">&#160;1.&#160;FastAPI</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r5" x="24.4" y="166.4" textLength="134.2" clip-path="url(#terminal-line-6)">&#160;&#160;2.&#160;Django</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r5" x="24.4" y="190.8" textLength="292.8" clip-path="url(#terminal-line-7)">&#160;&#160;3.&#160;Type&#160;your&#160;answer...</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="24.4" y="239.6" textLength="622.2" clip-path="url(#terminal-line-9)">←→&#160;questions&#160;&#160;↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;Esc&#160;cancel</text><text class="terminal-r1" x="963.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="0" y="264" textLength="976" clip-path="url(#terminal-line-10)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,137 @@
<svg class="rich-terminal" viewBox="0 0 994 538.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #8d8d8d }
.terminal-r2 { fill: #c5c8c6 }
.terminal-r3 { fill: #0178d4 }
.terminal-r4 { fill: #0178d4;font-weight: bold }
.terminal-r5 { fill: #e0e0e0 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="975.0" height="487.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">MultiQuestionApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="207.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="231.8" y="25.9" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="207.4" y="74.7" width="756.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="207.4" y="123.5" width="756.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="170.8" y="147.9" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="172.3" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="172.3" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="221.1" width="622.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="646.6" y="221.1" width="317.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r3" x="24.4" y="44.4" textLength="207.4" clip-path="url(#terminal-line-1)">[DB]&#160;&#160;&#160;Framework&#160;</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r4" x="24.4" y="93.2" textLength="183" clip-path="url(#terminal-line-3)">Which&#160;database?</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r4" x="24.4" y="142" textLength="183" clip-path="url(#terminal-line-5)">&#160;1.&#160;PostgreSQL</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r5" x="24.4" y="166.4" textLength="146.4" clip-path="url(#terminal-line-6)">&#160;&#160;2.&#160;MongoDB</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r5" x="24.4" y="190.8" textLength="292.8" clip-path="url(#terminal-line-7)">&#160;&#160;3.&#160;Type&#160;your&#160;answer...</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="24.4" y="239.6" textLength="622.2" clip-path="url(#terminal-line-9)">←→&#160;questions&#160;&#160;↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;Esc&#160;cancel</text><text class="terminal-r1" x="963.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="0" y="264" textLength="976" clip-path="url(#terminal-line-10)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,137 @@
<svg class="rich-terminal" viewBox="0 0 994 538.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #8d8d8d }
.terminal-r2 { fill: #c5c8c6 }
.terminal-r3 { fill: #0178d4 }
.terminal-r4 { fill: #0178d4;font-weight: bold }
.terminal-r5 { fill: #e0e0e0 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="975.0" height="487.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">MultiQuestionApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="207.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="231.8" y="25.9" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="219.6" y="74.7" width="744.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="170.8" y="123.5" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="158.6" y="147.9" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="172.3" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="172.3" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="221.1" width="622.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="646.6" y="221.1" width="317.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r3" x="24.4" y="44.4" textLength="207.4" clip-path="url(#terminal-line-1)">&#160;DB&#160;&#160;&#160;[Framework]</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r4" x="24.4" y="93.2" textLength="195.2" clip-path="url(#terminal-line-3)">Which&#160;framework?</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r4" x="24.4" y="142" textLength="146.4" clip-path="url(#terminal-line-5)">&#160;1.&#160;FastAPI</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r5" x="24.4" y="166.4" textLength="134.2" clip-path="url(#terminal-line-6)">&#160;&#160;2.&#160;Django</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r5" x="24.4" y="190.8" textLength="292.8" clip-path="url(#terminal-line-7)">&#160;&#160;3.&#160;Type&#160;your&#160;answer...</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="24.4" y="239.6" textLength="622.2" clip-path="url(#terminal-line-9)">←→&#160;questions&#160;&#160;↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;Esc&#160;cancel</text><text class="terminal-r1" x="963.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="0" y="264" textLength="976" clip-path="url(#terminal-line-10)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,137 @@
<svg class="rich-terminal" viewBox="0 0 994 538.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #8d8d8d }
.terminal-r2 { fill: #c5c8c6 }
.terminal-r3 { fill: #0178d4 }
.terminal-r4 { fill: #0178d4;font-weight: bold }
.terminal-r5 { fill: #e0e0e0 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="975.0" height="487.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">MultiQuestionApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="207.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="231.8" y="25.9" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="219.6" y="74.7" width="744.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="170.8" y="123.5" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="158.6" y="147.9" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="172.3" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="172.3" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="221.1" width="622.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="646.6" y="221.1" width="317.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r3" x="24.4" y="44.4" textLength="207.4" clip-path="url(#terminal-line-1)">&#160;DB&#160;&#160;&#160;[Framework]</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r4" x="24.4" y="93.2" textLength="195.2" clip-path="url(#terminal-line-3)">Which&#160;framework?</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r4" x="24.4" y="142" textLength="146.4" clip-path="url(#terminal-line-5)">&#160;1.&#160;FastAPI</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r5" x="24.4" y="166.4" textLength="134.2" clip-path="url(#terminal-line-6)">&#160;&#160;2.&#160;Django</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r5" x="24.4" y="190.8" textLength="292.8" clip-path="url(#terminal-line-7)">&#160;&#160;3.&#160;Type&#160;your&#160;answer...</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="24.4" y="239.6" textLength="622.2" clip-path="url(#terminal-line-9)">←→&#160;questions&#160;&#160;↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;Esc&#160;cancel</text><text class="terminal-r1" x="963.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="0" y="264" textLength="976" clip-path="url(#terminal-line-10)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,137 @@
<svg class="rich-terminal" viewBox="0 0 994 538.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #8d8d8d }
.terminal-r2 { fill: #c5c8c6 }
.terminal-r3 { fill: #0178d4 }
.terminal-r4 { fill: #0178d4;font-weight: bold }
.terminal-r5 { fill: #e0e0e0 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="975.0" height="487.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">MultiQuestionApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="207.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="231.8" y="25.9" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="207.4" y="74.7" width="756.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="207.4" y="123.5" width="756.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="170.8" y="147.9" width="793" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="172.3" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="172.3" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="221.1" width="622.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="646.6" y="221.1" width="317.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r3" x="24.4" y="44.4" textLength="207.4" clip-path="url(#terminal-line-1)">[DB]&#160;&#160;&#160;Framework&#160;</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r4" x="24.4" y="93.2" textLength="183" clip-path="url(#terminal-line-3)">Which&#160;database?</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r4" x="24.4" y="142" textLength="183" clip-path="url(#terminal-line-5)">&#160;1.&#160;PostgreSQL</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r5" x="24.4" y="166.4" textLength="146.4" clip-path="url(#terminal-line-6)">&#160;&#160;2.&#160;MongoDB</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r5" x="24.4" y="190.8" textLength="292.8" clip-path="url(#terminal-line-7)">&#160;&#160;3.&#160;Type&#160;your&#160;answer...</text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r1" x="24.4" y="239.6" textLength="622.2" clip-path="url(#terminal-line-9)">←→&#160;questions&#160;&#160;↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;Esc&#160;cancel</text><text class="terminal-r1" x="963.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"></text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="0" y="264" textLength="976" clip-path="url(#terminal-line-10)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,136 @@
<svg class="rich-terminal" viewBox="0 0 994 538.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #8d8d8d }
.terminal-r2 { fill: #c5c8c6 }
.terminal-r3 { fill: #0178d4;font-weight: bold }
.terminal-r4 { fill: #e0e0e0 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="975.0" height="487.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">SingleQuestionApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="585.6" y="25.9" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="74.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="99.1" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="414.8" y="99.1" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="366" y="123.5" width="597.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="147.9" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="196.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="196.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r3" x="24.4" y="44.4" textLength="561.2" clip-path="url(#terminal-line-1)">Which&#160;database&#160;should&#160;we&#160;use&#160;for&#160;this&#160;project?</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r3" x="24.4" y="93.2" textLength="451.4" clip-path="url(#terminal-line-3)">&#160;1.&#160;PostgreSQL&#160;-&#160;Relational&#160;database</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r4" x="24.4" y="117.6" textLength="390.4" clip-path="url(#terminal-line-4)">&#160;&#160;2.&#160;MongoDB&#160;-&#160;Document&#160;database</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r4" x="24.4" y="142" textLength="341.6" clip-path="url(#terminal-line-5)">&#160;&#160;3.&#160;Redis&#160;-&#160;In-memory&#160;store</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r4" x="24.4" y="166.4" textLength="292.8" clip-path="url(#terminal-line-6)">&#160;&#160;4.&#160;Type&#160;your&#160;answer...</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="24.4" y="215.2" textLength="451.4" clip-path="url(#terminal-line-8)">↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;Esc&#160;cancel</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="0" y="239.6" textLength="976" clip-path="url(#terminal-line-9)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,136 @@
<svg class="rich-terminal" viewBox="0 0 994 538.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #8d8d8d }
.terminal-r2 { fill: #c5c8c6 }
.terminal-r3 { fill: #0178d4;font-weight: bold }
.terminal-r4 { fill: #e0e0e0 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="975.0" height="487.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">SingleQuestionApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="585.6" y="25.9" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="74.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="99.1" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="414.8" y="99.1" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="366" y="123.5" width="597.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="147.9" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="196.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="196.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r3" x="24.4" y="44.4" textLength="561.2" clip-path="url(#terminal-line-1)">Which&#160;database&#160;should&#160;we&#160;use&#160;for&#160;this&#160;project?</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r4" x="24.4" y="93.2" textLength="451.4" clip-path="url(#terminal-line-3)">&#160;&#160;1.&#160;PostgreSQL&#160;-&#160;Relational&#160;database</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r3" x="24.4" y="117.6" textLength="390.4" clip-path="url(#terminal-line-4)">&#160;2.&#160;MongoDB&#160;-&#160;Document&#160;database</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r4" x="24.4" y="142" textLength="341.6" clip-path="url(#terminal-line-5)">&#160;&#160;3.&#160;Redis&#160;-&#160;In-memory&#160;store</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r4" x="24.4" y="166.4" textLength="292.8" clip-path="url(#terminal-line-6)">&#160;&#160;4.&#160;Type&#160;your&#160;answer...</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="24.4" y="215.2" textLength="451.4" clip-path="url(#terminal-line-8)">↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;Esc&#160;cancel</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="0" y="239.6" textLength="976" clip-path="url(#terminal-line-9)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,139 @@
<svg class="rich-terminal" viewBox="0 0 994 538.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #8d8d8d }
.terminal-r2 { fill: #c5c8c6 }
.terminal-r3 { fill: #0178d4;font-weight: bold }
.terminal-r4 { fill: #e0e0e0 }
.terminal-r5 { fill: #e0e0e0;font-weight: bold }
.terminal-r6 { fill: #121212 }
.terminal-r7 { fill: #6c6c6c }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="975.0" height="487.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">SingleQuestionApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="585.6" y="25.9" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="74.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="99.1" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="414.8" y="99.1" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="366" y="123.5" width="597.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#e0e0e0" x="85.4" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="97.6" y="147.9" width="219.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="147.9" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="951.6" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="196.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="196.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r3" x="24.4" y="44.4" textLength="561.2" clip-path="url(#terminal-line-1)">Which&#160;database&#160;should&#160;we&#160;use&#160;for&#160;this&#160;project?</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r4" x="24.4" y="93.2" textLength="451.4" clip-path="url(#terminal-line-3)">&#160;&#160;1.&#160;PostgreSQL&#160;-&#160;Relational&#160;database</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r4" x="24.4" y="117.6" textLength="390.4" clip-path="url(#terminal-line-4)">&#160;&#160;2.&#160;MongoDB&#160;-&#160;Document&#160;database</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r4" x="24.4" y="142" textLength="341.6" clip-path="url(#terminal-line-5)">&#160;&#160;3.&#160;Redis&#160;-&#160;In-memory&#160;store</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r5" x="24.4" y="166.4" textLength="61" clip-path="url(#terminal-line-6)">&#160;4.&#160;</text><text class="terminal-r6" x="85.4" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">T</text><text class="terminal-r7" x="97.6" y="166.4" textLength="219.6" clip-path="url(#terminal-line-6)">ype&#160;your&#160;answer...</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="24.4" y="215.2" textLength="451.4" clip-path="url(#terminal-line-8)">↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;Esc&#160;cancel</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="0" y="239.6" textLength="976" clip-path="url(#terminal-line-9)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,136 @@
<svg class="rich-terminal" viewBox="0 0 994 538.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #8d8d8d }
.terminal-r2 { fill: #c5c8c6 }
.terminal-r3 { fill: #0178d4;font-weight: bold }
.terminal-r4 { fill: #e0e0e0 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="975.0" height="487.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">SingleQuestionApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="585.6" y="25.9" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="74.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="99.1" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="414.8" y="99.1" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="366" y="123.5" width="597.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="147.9" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="196.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="196.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r3" x="24.4" y="44.4" textLength="561.2" clip-path="url(#terminal-line-1)">Which&#160;database&#160;should&#160;we&#160;use&#160;for&#160;this&#160;project?</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r4" x="24.4" y="93.2" textLength="451.4" clip-path="url(#terminal-line-3)">&#160;&#160;1.&#160;PostgreSQL&#160;-&#160;Relational&#160;database</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r4" x="24.4" y="117.6" textLength="390.4" clip-path="url(#terminal-line-4)">&#160;&#160;2.&#160;MongoDB&#160;-&#160;Document&#160;database</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r3" x="24.4" y="142" textLength="341.6" clip-path="url(#terminal-line-5)">&#160;3.&#160;Redis&#160;-&#160;In-memory&#160;store</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r4" x="24.4" y="166.4" textLength="292.8" clip-path="url(#terminal-line-6)">&#160;&#160;4.&#160;Type&#160;your&#160;answer...</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="24.4" y="215.2" textLength="451.4" clip-path="url(#terminal-line-8)">↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;Esc&#160;cancel</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="0" y="239.6" textLength="976" clip-path="url(#terminal-line-9)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,139 @@
<svg class="rich-terminal" viewBox="0 0 994 538.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #8d8d8d }
.terminal-r2 { fill: #c5c8c6 }
.terminal-r3 { fill: #0178d4;font-weight: bold }
.terminal-r4 { fill: #e0e0e0 }
.terminal-r5 { fill: #e0e0e0;font-weight: bold }
.terminal-r6 { fill: #121212 }
.terminal-r7 { fill: #6c6c6c }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="975.0" height="487.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">SingleQuestionApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="585.6" y="25.9" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="74.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="99.1" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="414.8" y="99.1" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="366" y="123.5" width="597.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#e0e0e0" x="85.4" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="97.6" y="147.9" width="219.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="317.2" y="147.9" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="951.6" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="196.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="196.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r3" x="24.4" y="44.4" textLength="561.2" clip-path="url(#terminal-line-1)">Which&#160;database&#160;should&#160;we&#160;use&#160;for&#160;this&#160;project?</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r4" x="24.4" y="93.2" textLength="451.4" clip-path="url(#terminal-line-3)">&#160;&#160;1.&#160;PostgreSQL&#160;-&#160;Relational&#160;database</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r4" x="24.4" y="117.6" textLength="390.4" clip-path="url(#terminal-line-4)">&#160;&#160;2.&#160;MongoDB&#160;-&#160;Document&#160;database</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r4" x="24.4" y="142" textLength="341.6" clip-path="url(#terminal-line-5)">&#160;&#160;3.&#160;Redis&#160;-&#160;In-memory&#160;store</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r5" x="24.4" y="166.4" textLength="61" clip-path="url(#terminal-line-6)">&#160;4.&#160;</text><text class="terminal-r6" x="85.4" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">T</text><text class="terminal-r7" x="97.6" y="166.4" textLength="219.6" clip-path="url(#terminal-line-6)">ype&#160;your&#160;answer...</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="24.4" y="215.2" textLength="451.4" clip-path="url(#terminal-line-8)">↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;Esc&#160;cancel</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="0" y="239.6" textLength="976" clip-path="url(#terminal-line-9)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,138 @@
<svg class="rich-terminal" viewBox="0 0 994 538.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #8d8d8d }
.terminal-r2 { fill: #c5c8c6 }
.terminal-r3 { fill: #0178d4;font-weight: bold }
.terminal-r4 { fill: #e0e0e0 }
.terminal-r5 { fill: #e0e0e0;font-weight: bold }
.terminal-r6 { fill: #121212 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="975.0" height="487.0" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="536" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">SingleQuestionApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="25.9" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="585.6" y="25.9" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="74.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="74.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="99.1" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="414.8" y="99.1" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="123.5" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="366" y="123.5" width="597.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="147.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="85.4" y="147.9" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#e0e0e0" x="158.6" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="170.8" y="147.9" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="951.6" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="172.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="196.7" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="475.8" y="196.7" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="963.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="976" clip-path="url(#terminal-line-0)">╭──────────────────────────────────────────────────────────────────────────────╮</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r3" x="24.4" y="44.4" textLength="561.2" clip-path="url(#terminal-line-1)">Which&#160;database&#160;should&#160;we&#160;use&#160;for&#160;this&#160;project?</text><text class="terminal-r1" x="963.8" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"></text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r1" x="963.8" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"></text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="0" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r4" x="24.4" y="93.2" textLength="451.4" clip-path="url(#terminal-line-3)">&#160;&#160;1.&#160;PostgreSQL&#160;-&#160;Relational&#160;database</text><text class="terminal-r1" x="963.8" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"></text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="0" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r4" x="24.4" y="117.6" textLength="390.4" clip-path="url(#terminal-line-4)">&#160;&#160;2.&#160;MongoDB&#160;-&#160;Document&#160;database</text><text class="terminal-r1" x="963.8" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"></text><text class="terminal-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="0" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r4" x="24.4" y="142" textLength="341.6" clip-path="url(#terminal-line-5)">&#160;&#160;3.&#160;Redis&#160;-&#160;In-memory&#160;store</text><text class="terminal-r1" x="963.8" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"></text><text class="terminal-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="0" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r5" x="24.4" y="166.4" textLength="61" clip-path="url(#terminal-line-6)">&#160;4.&#160;</text><text class="terminal-r4" x="85.4" y="166.4" textLength="73.2" clip-path="url(#terminal-line-6)">SQLite</text><text class="terminal-r1" x="963.8" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"></text><text class="terminal-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="0" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r1" x="963.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"></text><text class="terminal-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="0" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r1" x="24.4" y="215.2" textLength="451.4" clip-path="url(#terminal-line-8)">↑↓&#160;navigate&#160;&#160;Enter&#160;select&#160;&#160;Esc&#160;cancel</text><text class="terminal-r1" x="963.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"></text><text class="terminal-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="0" y="239.6" textLength="976" clip-path="url(#terminal-line-9)">╰──────────────────────────────────────────────────────────────────────────────╯</text><text class="terminal-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -3,10 +3,12 @@ from __future__ import annotations
from rich.style import Style
from textual.widgets.text_area import TextAreaTheme
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from tests.stubs.fake_backend import FakeBackend
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input import ChatTextArea
from vibe.core.agent import Agent
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import SessionLoggingConfig, VibeConfig
@@ -29,17 +31,27 @@ def default_config() -> VibeConfig:
class BaseSnapshotTestApp(VibeApp):
CSS_PATH = "../../vibe/cli/textual_ui/app.tcss"
_current_agent_name: str = BuiltinAgentName.DEFAULT
def __init__(self, config: VibeConfig | None = None, **kwargs):
def __init__(
self,
config: VibeConfig | None = None,
backend: FakeBackend | None = None,
**kwargs,
):
config = config or default_config()
super().__init__(config=config, **kwargs)
self.agent = Agent(
agent_loop = AgentLoop(
config,
mode=self._current_agent_mode,
enable_streaming=self.enable_streaming,
backend=FakeBackend(),
agent_name=self._current_agent_name,
enable_streaming=kwargs.get("enable_streaming", False),
backend=backend or FakeBackend(),
)
plan_offer_gateway = kwargs.pop("plan_offer_gateway", FakeWhoAmIGateway())
super().__init__(
agent_loop=agent_loop, plan_offer_gateway=plan_offer_gateway, **kwargs
)
async def on_mount(self) -> None:

View File

@@ -0,0 +1,80 @@
from __future__ import annotations
from textual.pilot import Pilot
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.snapshots.snap_compare import SnapCompare
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
from vibe.core.tools.builtins.ask_user_question import (
Answer,
AskUserQuestion,
AskUserQuestionResult,
)
from vibe.core.types import ToolResultEvent
class AskUserQuestionResultApp(BaseSnapshotTestApp):
"""Test app that displays an AskUserQuestion tool result."""
async def on_mount(self) -> None:
await super().on_mount()
result = AskUserQuestionResult(
answers=[
Answer(
question="What programming language are you currently working with?",
answer="Rust",
is_other=False,
),
Answer(
question="What type of project are you building?",
answer="Web Application",
is_other=False,
),
Answer(
question="What editor or IDE do you prefer?",
answer="VS Code",
is_other=True,
),
],
cancelled=False,
)
event = ToolResultEvent(
tool_name="ask_user_question",
tool_class=AskUserQuestion,
result=result,
tool_call_id="test_call_id",
)
messages_area = self.query_one("#messages")
tool_result = ToolResultMessage(event, collapsed=True)
await messages_area.mount(tool_result)
def test_snapshot_ask_user_question_collapsed(snap_compare: SnapCompare) -> None:
"""Test collapsed AskUserQuestion result shows summary."""
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_ask_user_question.py:AskUserQuestionResultApp",
terminal_size=(120, 20),
run_before=run_before,
)
def test_snapshot_ask_user_question_expanded(snap_compare: SnapCompare) -> None:
"""Test expanded AskUserQuestion result shows formatted Q&A pairs."""
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("ctrl+o")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_ask_user_question.py:AskUserQuestionResultApp",
terminal_size=(120, 30),
run_before=run_before,
)

View File

@@ -3,15 +3,13 @@ from __future__ import annotations
from textual.pilot import Pilot
from tests.mock.utils import mock_llm_chunk
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.snapshots.snap_compare import SnapCompare
from tests.stubs.fake_backend import FakeBackend
from vibe.core.agent import Agent
class SnapshotTestAppWithConversation(BaseSnapshotTestApp):
def __init__(self) -> None:
config = default_config()
fake_backend = FakeBackend(
mock_llm_chunk(
content="I'm the Vibe agent and I'm ready to help.",
@@ -19,13 +17,7 @@ class SnapshotTestAppWithConversation(BaseSnapshotTestApp):
completion_tokens=2_500,
)
)
super().__init__(config=config)
self.agent = Agent(
config,
mode=self._current_agent_mode,
enable_streaming=self.enable_streaming,
backend=fake_backend,
)
super().__init__(backend=fake_backend)
def test_snapshot_shows_basic_conversation(snap_compare: SnapCompare) -> None:

View File

@@ -0,0 +1,132 @@
from __future__ import annotations
import os
from pathlib import Path
import time
from unittest.mock import patch
from textual.pilot import Pilot
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
from tests.snapshots.snap_compare import SnapCompare
from tests.update_notifier.adapters.fake_update_cache_repository import (
FakeUpdateCacheRepository,
)
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
from vibe.cli.update_notifier import UpdateCache
class PlanOfferSnapshotApp(BaseSnapshotTestApp):
def __init__(self, gateway: FakeWhoAmIGateway):
self._previous_api_key = os.environ.get("MISTRAL_API_KEY")
os.environ["MISTRAL_API_KEY"] = "snapshot-api-key"
super().__init__(
config=default_config(),
plan_offer_gateway=gateway,
update_cache_repository=FakeUpdateCacheRepository(),
)
def on_unmount(self) -> None:
if self._previous_api_key is None:
os.environ.pop("MISTRAL_API_KEY", None)
else:
os.environ["MISTRAL_API_KEY"] = self._previous_api_key
return None
class SnapshotAppPlanOfferUpgrade(PlanOfferSnapshotApp):
def __init__(self) -> None:
gateway = FakeWhoAmIGateway(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=True,
prompt_switching_to_pro_plan=False,
)
)
super().__init__(gateway=gateway)
class SnapshotAppPlanOfferSwitchKey(PlanOfferSnapshotApp):
def __init__(self) -> None:
gateway = FakeWhoAmIGateway(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=False,
prompt_switching_to_pro_plan=True,
)
)
super().__init__(gateway=gateway)
class SnapshotAppPlanOfferNone(PlanOfferSnapshotApp):
def __init__(self) -> None:
gateway = FakeWhoAmIGateway(
WhoAmIResponse(
is_pro_plan=True,
advertise_pro_plan=False,
prompt_switching_to_pro_plan=False,
)
)
super().__init__(gateway=gateway)
class SnapshotAppWhatsNewAndPlanOffer(PlanOfferSnapshotApp):
def __init__(self) -> None:
gateway = FakeWhoAmIGateway(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=True,
prompt_switching_to_pro_plan=False,
)
)
super().__init__(gateway=gateway)
cache = UpdateCache(
latest_version="1.0.0",
stored_at_timestamp=int(time.time()),
seen_whats_new_version=None,
)
self._update_cache_repository = FakeUpdateCacheRepository(update_cache=cache)
self._current_version = "1.0.0"
async def _pause_for_plan_offer_task(pilot: Pilot) -> None:
await pilot.pause(0.1)
def test_snapshot_shows_upgrade_plan_offer(snap_compare: SnapCompare) -> None:
assert snap_compare(
"test_ui_snapshot_plan_offer.py:SnapshotAppPlanOfferUpgrade",
terminal_size=(120, 36),
run_before=_pause_for_plan_offer_task,
)
def test_snapshot_shows_switch_key_plan_offer(snap_compare: SnapCompare) -> None:
assert snap_compare(
"test_ui_snapshot_plan_offer.py:SnapshotAppPlanOfferSwitchKey",
terminal_size=(120, 36),
run_before=_pause_for_plan_offer_task,
)
def test_snapshot_shows_no_plan_offer(snap_compare: SnapCompare) -> None:
assert snap_compare(
"test_ui_snapshot_plan_offer.py:SnapshotAppPlanOfferNone",
terminal_size=(120, 36),
run_before=_pause_for_plan_offer_task,
)
def test_snapshot_shows_whats_new_and_plan_offer(
snap_compare: SnapCompare, tmp_path: Path
) -> None:
whats_new_file = tmp_path / "whats_new.md"
whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2")
with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
assert snap_compare(
"test_ui_snapshot_plan_offer.py:SnapshotAppWhatsNewAndPlanOffer",
terminal_size=(120, 36),
run_before=_pause_for_plan_offer_task,
)

View File

@@ -0,0 +1,365 @@
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.pilot import Pilot
from tests.snapshots.snap_compare import SnapCompare
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
Choice,
Question,
)
def single_question_args() -> AskUserQuestionArgs:
return AskUserQuestionArgs(
questions=[
Question(
question="Which database should we use for this project?",
header="Database",
options=[
Choice(label="PostgreSQL", description="Relational database"),
Choice(label="MongoDB", description="Document database"),
Choice(label="Redis", description="In-memory store"),
],
)
]
)
def multi_question_args() -> AskUserQuestionArgs:
return AskUserQuestionArgs(
questions=[
Question(
question="Which database?",
header="DB",
options=[Choice(label="PostgreSQL"), Choice(label="MongoDB")],
),
Question(
question="Which framework?",
header="Framework",
options=[Choice(label="FastAPI"), Choice(label="Django")],
),
]
)
def multi_select_args() -> AskUserQuestionArgs:
return AskUserQuestionArgs(
questions=[
Question(
question="Which features do you want to enable?",
header="Features",
options=[
Choice(label="Authentication", description="User login/logout"),
Choice(label="Caching", description="Redis caching layer"),
Choice(label="Logging", description="Structured logging"),
],
multi_select=True,
)
]
)
class QuestionAppTestApp(App):
CSS_PATH = "../../vibe/cli/textual_ui/app.tcss"
def __init__(self, args: AskUserQuestionArgs):
super().__init__()
self.question_args = args
def compose(self) -> ComposeResult:
with Container(id="bottom-app-container"):
yield QuestionApp(args=self.question_args)
class SingleQuestionApp(QuestionAppTestApp):
def __init__(self):
super().__init__(single_question_args())
class MultiQuestionApp(QuestionAppTestApp):
def __init__(self):
super().__init__(multi_question_args())
class MultiSelectApp(QuestionAppTestApp):
def __init__(self):
super().__init__(multi_select_args())
# Single question tests
def test_snapshot_question_app_initial(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:SingleQuestionApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_question_app_navigate_down(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("down")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:SingleQuestionApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_question_app_navigate_to_third_option(
snap_compare: SnapCompare,
) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("down")
await pilot.press("down")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:SingleQuestionApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_question_app_navigate_to_other(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("down")
await pilot.press("down")
await pilot.press("down")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:SingleQuestionApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_question_app_navigate_up_wraps(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("up")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:SingleQuestionApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_question_app_other_typing(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("down", "down", "down")
await pilot.press("enter")
await pilot.pause(0.1)
await pilot.press(*"SQLite")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:SingleQuestionApp",
terminal_size=(80, 20),
run_before=run_before,
)
# Multi-question tests
def test_snapshot_multi_question_initial(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:MultiQuestionApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_multi_question_tab_to_second(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("tab")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:MultiQuestionApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_multi_question_answer_first_advance(
snap_compare: SnapCompare,
) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("enter")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:MultiQuestionApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_multi_question_navigate_right(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("right")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:MultiQuestionApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_multi_question_navigate_left_wraps(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("left")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:MultiQuestionApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_multi_question_first_answered_checkmark(
snap_compare: SnapCompare,
) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("down")
await pilot.press("enter")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:MultiQuestionApp",
terminal_size=(80, 20),
run_before=run_before,
)
# Multi-select tests
def test_snapshot_multi_select_initial(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:MultiSelectApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_multi_select_toggle_first(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("enter")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:MultiSelectApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_multi_select_toggle_multiple(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("enter")
await pilot.press("down", "down")
await pilot.press("enter")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:MultiSelectApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_multi_select_navigate_to_submit(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("down", "down", "down", "down")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:MultiSelectApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_multi_select_other_with_text(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("down", "down", "down")
await pilot.press("enter")
await pilot.pause(0.1)
await pilot.press(*"Custom feature")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:MultiSelectApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_multi_select_mixed_selection(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("enter")
await pilot.press("down", "down")
await pilot.press("enter")
await pilot.press("down")
await pilot.press("enter")
await pilot.pause(0.1)
await pilot.press(*"Extra")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:MultiSelectApp",
terminal_size=(80, 20),
run_before=run_before,
)
def test_snapshot_multi_select_untoggle(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.1)
await pilot.press("enter")
await pilot.press("enter")
await pilot.pause(0.1)
assert snap_compare(
"test_ui_snapshot_question_app.py:MultiSelectApp",
terminal_size=(80, 20),
run_before=run_before,
)

View File

@@ -7,7 +7,7 @@ from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_
from tests.snapshots.snap_compare import SnapCompare
from tests.stubs.fake_backend import FakeBackend
from vibe.cli.textual_ui.widgets.messages import ReasoningMessage
from vibe.core.agent import Agent
from vibe.core.agent_loop import AgentLoop
class SnapshotTestAppWithReasoningContent(BaseSnapshotTestApp):
@@ -31,9 +31,9 @@ class SnapshotTestAppWithReasoningContent(BaseSnapshotTestApp):
]
)
super().__init__(config=config)
self.agent = Agent(
self.agent_loop = AgentLoop(
config,
mode=self._current_agent_mode,
agent_name=self._current_agent_name,
enable_streaming=True,
backend=fake_backend,
)
@@ -57,9 +57,9 @@ class SnapshotTestAppWithInterleavedReasoning(BaseSnapshotTestApp):
]
)
super().__init__(config=config)
self.agent = Agent(
self.agent_loop = AgentLoop(
config,
mode=self._current_agent_mode,
agent_name=self._current_agent_name,
enable_streaming=True,
backend=fake_backend,
)
@@ -123,9 +123,9 @@ class SnapshotTestAppWithBufferedReasoningTransition(BaseSnapshotTestApp):
]
)
super().__init__(config=config)
self.agent = Agent(
self.agent_loop = AgentLoop(
config,
mode=self._current_agent_mode,
agent_name=self._current_agent_name,
enable_streaming=True,
backend=fake_backend,
)

View File

@@ -1,5 +1,8 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from textual.pilot import Pilot
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
@@ -7,34 +10,36 @@ from tests.snapshots.snap_compare import SnapCompare
from tests.update_notifier.adapters.fake_update_cache_repository import (
FakeUpdateCacheRepository,
)
from tests.update_notifier.adapters.fake_version_update_gateway import (
FakeVersionUpdateGateway,
)
from vibe.cli.update_notifier import VersionUpdate
from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
from vibe.cli.update_notifier import Update
class SnapshotTestAppWithUpdate(BaseSnapshotTestApp):
def __init__(self):
config = default_config()
config.enable_update_checks = True
version_update_notifier = FakeVersionUpdateGateway(
update=VersionUpdate(latest_version="1000.2.0")
)
update_notifier = FakeUpdateGateway(update=Update(latest_version="1000.2.0"))
update_cache_repository = FakeUpdateCacheRepository()
super().__init__(
config=config,
version_update_notifier=version_update_notifier,
update_notifier=update_notifier,
update_cache_repository=update_cache_repository,
current_version="1.0.4",
)
def test_snapshot_shows_release_update_notification(snap_compare: SnapCompare) -> None:
def test_snapshot_shows_release_update_notification(
snap_compare: SnapCompare, tmp_path: Path
) -> None:
whats_new_file = tmp_path / "whats_new.md"
whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2")
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
assert snap_compare(
"test_ui_snapshot_release_update_notification.py:SnapshotTestAppWithUpdate",
terminal_size=(120, 36),
run_before=run_before,
)
with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
assert snap_compare(
"test_ui_snapshot_release_update_notification.py:SnapshotTestAppWithUpdate",
terminal_size=(120, 36),
run_before=run_before,
)

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from textual.pilot import Pilot
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.snapshots.snap_compare import SnapCompare
from vibe.core.types import FunctionCall, LLMMessage, Role, ToolCall
class SnapshotTestAppWithResumedSession(BaseSnapshotTestApp):
def __init__(self) -> None:
super().__init__()
# Simulate a previous session with messages
user_msg = LLMMessage(role=Role.user, content="Hello, how are you?")
assistant_msg = LLMMessage(
role=Role.assistant,
content="I'm doing well, thank you! Let me read that file for you.",
tool_calls=[
ToolCall(
id="tool_call_1",
index=0,
function=FunctionCall(
name="read_file", arguments='{"path": "test.txt"}'
),
)
],
)
tool_result_msg = LLMMessage(
role=Role.tool,
content="File content: This is a test file with some content.",
name="read_file",
tool_call_id="tool_call_1",
)
self.agent_loop.messages.extend([user_msg, assistant_msg, tool_result_msg])
def test_snapshot_shows_resumed_session_messages(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
# Wait for the app to initialize and rebuild history
await pilot.pause(0.5)
assert snap_compare(
"test_ui_snapshot_session_resume.py:SnapshotTestAppWithResumedSession",
terminal_size=(120, 36),
run_before=run_before,
)

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from pathlib import Path
import time
from unittest.mock import patch
from textual.pilot import Pilot
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
from tests.snapshots.snap_compare import SnapCompare
from tests.update_notifier.adapters.fake_update_cache_repository import (
FakeUpdateCacheRepository,
)
from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
from vibe.cli.update_notifier import UpdateCache
class SnapshotTestAppWithWhatsNew(BaseSnapshotTestApp):
def __init__(self):
config = default_config()
config.enable_update_checks = False
update_notifier = FakeUpdateGateway(update=None)
cache = UpdateCache(
latest_version="1.0.0",
stored_at_timestamp=int(time.time()),
seen_whats_new_version=None,
)
update_cache_repository = FakeUpdateCacheRepository(update_cache=cache)
super().__init__(
config=config,
update_notifier=update_notifier,
update_cache_repository=update_cache_repository,
current_version="1.0.0",
)
def test_snapshot_shows_whats_new_message(
snap_compare: SnapCompare, tmp_path: Path
) -> None:
# Create whats_new.md file before the app starts
whats_new_file = tmp_path / "whats_new.md"
whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2\n- Feature 3")
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.5)
with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
assert snap_compare(
"test_ui_snapshot_whats_new.py:SnapshotTestAppWithWhatsNew",
terminal_size=(120, 36),
run_before=run_before,
)

130
tests/stubs/fake_client.py Normal file
View File

@@ -0,0 +1,130 @@
from __future__ import annotations
from typing import Any
from acp import (
Agent as AcpAgent,
Client,
KillTerminalCommandResponse,
ReadTextFileResponse,
ReleaseTerminalResponse,
RequestPermissionResponse,
SessionNotification,
TerminalHandle,
TerminalOutputResponse,
WaitForTerminalExitResponse,
WriteTextFileResponse,
)
from acp.schema import (
AgentMessageChunk,
AgentPlanUpdate,
AgentThoughtChunk,
AvailableCommandsUpdate,
CurrentModeUpdate,
EnvVariable,
PermissionOption,
SessionInfoUpdate,
ToolCallProgress,
ToolCallStart,
ToolCallUpdate,
UserMessageChunk,
)
class FakeClient(Client):
agent: AcpAgent
def __init__(self) -> None:
self._session_updates = []
async def session_update(
self,
session_id: str,
update: UserMessageChunk
| AgentMessageChunk
| AgentThoughtChunk
| ToolCallStart
| ToolCallProgress
| AgentPlanUpdate
| AvailableCommandsUpdate
| CurrentModeUpdate
| SessionInfoUpdate,
**kwargs: Any,
) -> None:
self._session_updates.append(
SessionNotification(session_id=session_id, update=update)
)
async def request_permission(
self,
options: list[PermissionOption],
session_id: str,
tool_call: ToolCallUpdate,
**kwargs: Any,
) -> RequestPermissionResponse:
raise NotImplementedError()
async def read_text_file(
self,
path: str,
session_id: str,
limit: int | None = None,
line: int | None = None,
**kwargs: Any,
) -> ReadTextFileResponse:
raise NotImplementedError()
async def write_text_file(
self, content: str, path: str, session_id: str, **kwargs: Any
) -> WriteTextFileResponse | None:
raise NotImplementedError()
async def create_terminal(
self,
command: str,
session_id: str,
args: list[str] | None = None,
cwd: str | None = None,
env: list[EnvVariable] | None = None,
output_byte_limit: int | None = None,
**kwargs: Any,
) -> TerminalHandle:
raise NotImplementedError()
async def terminal_output(
self, session_id: str, terminal_id: str, **kwargs: Any
) -> TerminalOutputResponse:
raise NotImplementedError()
async def release_terminal(
self, session_id: str, terminal_id: str, **kwargs: Any
) -> ReleaseTerminalResponse | None:
raise NotImplementedError()
async def wait_for_terminal_exit(
self, session_id: str, terminal_id: str, **kwargs: Any
) -> WaitForTerminalExitResponse:
raise NotImplementedError()
async def kill_terminal(
self, session_id: str, terminal_id: str, **kwargs: Any
) -> KillTerminalCommandResponse | None:
raise NotImplementedError()
async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError()
async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
raise NotImplementedError()
async def close(self) -> None:
raise NotImplementedError()
def on_connect(self, conn: AcpAgent) -> None:
self.agent = conn
async def __aenter__(self) -> FakeClient:
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
await self.close()

View File

@@ -1,86 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from acp import (
Agent,
AgentSideConnection,
CreateTerminalRequest,
KillTerminalCommandRequest,
KillTerminalCommandResponse,
ReadTextFileRequest,
ReadTextFileResponse,
ReleaseTerminalRequest,
ReleaseTerminalResponse,
RequestPermissionRequest,
RequestPermissionResponse,
SessionNotification,
TerminalHandle,
TerminalOutputRequest,
TerminalOutputResponse,
WaitForTerminalExitRequest,
WaitForTerminalExitResponse,
WriteTextFileRequest,
WriteTextFileResponse,
)
class FakeAgentSideConnection(AgentSideConnection):
def __init__(self, to_agent: Callable[[AgentSideConnection], Agent]) -> None:
self._session_updates = []
to_agent(self)
async def sessionUpdate(self, params: SessionNotification) -> None:
self._session_updates.append(params)
async def requestPermission(
self, params: RequestPermissionRequest
) -> RequestPermissionResponse:
raise NotImplementedError()
async def readTextFile(self, params: ReadTextFileRequest) -> ReadTextFileResponse:
raise NotImplementedError()
async def writeTextFile(
self, params: WriteTextFileRequest
) -> WriteTextFileResponse | None:
raise NotImplementedError()
async def createTerminal(self, params: CreateTerminalRequest) -> TerminalHandle:
raise NotImplementedError()
async def terminalOutput(
self, params: TerminalOutputRequest
) -> TerminalOutputResponse:
raise NotImplementedError()
async def releaseTerminal(
self, params: ReleaseTerminalRequest
) -> ReleaseTerminalResponse | None:
raise NotImplementedError()
async def waitForTerminalExit(
self, params: WaitForTerminalExitRequest
) -> WaitForTerminalExitResponse:
raise NotImplementedError()
async def killTerminal(
self, params: KillTerminalCommandRequest
) -> KillTerminalCommandResponse | None:
raise NotImplementedError()
async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError()
async def extNotification(self, method: str, params: dict[str, Any]) -> None:
raise NotImplementedError()
async def close(self) -> None:
raise NotImplementedError()
async def __aenter__(self) -> AgentSideConnection:
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
await self.close()

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from pydantic import BaseModel
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState, InvokeContext
from vibe.core.types import ToolStreamEvent
class FakeToolArgs(BaseModel):
@@ -24,7 +27,9 @@ class FakeTool(BaseTool[FakeToolArgs, FakeToolResult, BaseToolConfig, FakeToolSt
def get_name(cls) -> str:
return "stub_tool"
async def run(self, args: FakeToolArgs) -> FakeToolResult:
async def run(
self, args: FakeToolArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | FakeToolResult, None]:
if self._exception_to_raise:
raise self._exception_to_raise
return FakeToolResult()
yield FakeToolResult()

View File

@@ -4,7 +4,7 @@ import pytest
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core.agent import Agent
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.types import (
AssistantEvent,
@@ -12,6 +12,7 @@ from vibe.core.types import (
CompactStartEvent,
LLMMessage,
Role,
UserMessageEvent,
)
@@ -29,18 +30,19 @@ async def test_auto_compact_triggers_and_batches_observer() -> None:
cfg = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False), auto_compact_threshold=1
)
agent = Agent(cfg, message_observer=observer, backend=backend)
agent = AgentLoop(cfg, message_observer=observer, backend=backend)
agent.stats.context_tokens = 2
events = [ev async for ev in agent.act("Hello")]
assert len(events) == 3
assert isinstance(events[0], CompactStartEvent)
assert isinstance(events[1], CompactEndEvent)
assert isinstance(events[2], AssistantEvent)
start: CompactStartEvent = events[0]
end: CompactEndEvent = events[1]
final: AssistantEvent = events[2]
assert len(events) == 4
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[1], CompactStartEvent)
assert isinstance(events[2], CompactEndEvent)
assert isinstance(events[3], AssistantEvent)
start: CompactStartEvent = events[1]
end: CompactEndEvent = events[2]
final: AssistantEvent = events[3]
assert start.current_context_tokens == 2
assert start.threshold == 1
assert end.old_context_tokens == 2
@@ -49,8 +51,5 @@ async def test_auto_compact_triggers_and_batches_observer() -> None:
roles = [r for r, _ in observed]
assert roles == [Role.system, Role.user, Role.assistant]
assert (
observed[1][1] is not None
and "Last request from user was: Hello" in observed[1][1]
)
assert observed[1][1] is not None and "<summary>" in observed[1][1]
assert observed[2][1] == "<final>"

View File

@@ -4,7 +4,7 @@ import pytest
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core.agent import Agent
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
@@ -16,7 +16,7 @@ def vibe_config() -> VibeConfig:
@pytest.mark.asyncio
async def test_passes_x_affinity_header_when_asking_an_answer(vibe_config: VibeConfig):
backend = FakeBackend([mock_llm_chunk(content="Response")])
agent = Agent(vibe_config, backend=backend)
agent = AgentLoop(vibe_config, backend=backend)
[_ async for _ in agent.act("Hello")]
@@ -32,7 +32,7 @@ async def test_passes_x_affinity_header_when_asking_an_answer_streaming(
vibe_config: VibeConfig,
):
backend = FakeBackend([mock_llm_chunk(content="Response")])
agent = Agent(vibe_config, backend=backend, enable_streaming=True)
agent = AgentLoop(vibe_config, backend=backend, enable_streaming=True)
[_ async for _ in agent.act("Hello")]
@@ -47,7 +47,7 @@ async def test_passes_x_affinity_header_when_asking_an_answer_streaming(
async def test_updates_tokens_stats_based_on_backend_response(vibe_config: VibeConfig):
chunk = mock_llm_chunk(content="Response", prompt_tokens=100, completion_tokens=50)
backend = FakeBackend([chunk])
agent = Agent(vibe_config, backend=backend)
agent = AgentLoop(vibe_config, backend=backend)
[_ async for _ in agent.act("Hello")]
@@ -62,7 +62,7 @@ async def test_updates_tokens_stats_based_on_backend_response_streaming(
content="Complete", prompt_tokens=200, completion_tokens=75
)
backend = FakeBackend([final_chunk])
agent = Agent(vibe_config, backend=backend, enable_streaming=True)
agent = AgentLoop(vibe_config, backend=backend, enable_streaming=True)
[_ async for _ in agent.act("Hello")]

View File

@@ -8,7 +8,8 @@ import pytest
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core.agent import Agent
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.middleware import (
ConversationContext,
@@ -17,7 +18,6 @@ from vibe.core.middleware import (
MiddlewareResult,
ResetReason,
)
from vibe.core.modes import AgentMode
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tools.builtins.todo import TodoArgs
from vibe.core.types import (
@@ -30,17 +30,18 @@ from vibe.core.types import (
ToolCall,
ToolCallEvent,
ToolResultEvent,
UserMessageEvent,
)
from vibe.core.utils import CancellationReason, get_user_cancellation_message
class InjectBeforeMiddleware:
injectedMessage = "<injected>"
injected_message = "<injected>"
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
"Inject a message just before the current step executes."
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.injectedMessage
action=MiddlewareAction.INJECT_MESSAGE, message=self.injected_message
)
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
@@ -89,7 +90,7 @@ async def test_act_flushes_batched_messages_with_injection_middleware(
observed, observer = observer_capture
backend = FakeBackend([mock_llm_chunk(content="I can write very efficient code.")])
agent = Agent(make_config(), message_observer=observer, backend=backend)
agent = AgentLoop(make_config(), message_observer=observer, backend=backend)
agent.middleware_pipeline.add(InjectBeforeMiddleware())
async for _ in agent.act("How can you help?"):
@@ -101,7 +102,7 @@ async def test_act_flushes_batched_messages_with_injection_middleware(
# injected content should be appended to the user's message before emission
assert (
observed[1][1]
== f"How can you help?\n\n{InjectBeforeMiddleware.injectedMessage}"
== f"How can you help?\n\n{InjectBeforeMiddleware.injected_message}"
)
assert observed[2][1] == "I can write very efficient code."
@@ -114,7 +115,7 @@ async def test_stop_action_flushes_user_msg_before_returning(observer_capture) -
backend = FakeBackend([
mock_llm_chunk(content="My response will never reach you...")
])
agent = Agent(
agent = AgentLoop(
make_config(), message_observer=observer, max_turns=0, backend=backend
)
@@ -133,7 +134,7 @@ async def test_act_emits_user_and_assistant_msgs(observer_capture) -> None:
observed, observer = observer_capture
backend = FakeBackend([mock_llm_chunk(content="Pong!")])
agent = Agent(make_config(), message_observer=observer, backend=backend)
agent = AgentLoop(make_config(), message_observer=observer, backend=backend)
async for _ in agent.act("Ping?"):
pass
@@ -155,12 +156,13 @@ async def test_act_streams_batched_chunks_in_order() -> None:
mock_llm_chunk(content=" and"),
mock_llm_chunk(content=" end"),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Stream, please.")]
assert len(events) == 2
assert [event.content for event in events if isinstance(event, AssistantEvent)] == [
assistant_events = [e for e in events if isinstance(e, AssistantEvent)]
assert len(assistant_events) == 2
assert [event.content for event in assistant_events] == [
"Hello from Vibe! More",
" and end",
]
@@ -182,33 +184,35 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None
],
[mock_llm_chunk(content="Done reviewing todos.")],
])
agent = Agent(
agent = AgentLoop(
make_config(
enabled_tools=["todo"],
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
),
backend=backend,
mode=AgentMode.AUTO_APPROVE,
agent_name=BuiltinAgentName.AUTO_APPROVE,
enable_streaming=True,
)
events = [event async for event in agent.act("What about my todos?")]
assert [type(event) for event in events] == [
UserMessageEvent,
AssistantEvent,
ToolCallEvent,
ToolResultEvent,
AssistantEvent,
]
assert isinstance(events[0], AssistantEvent)
assert events[0].content == "Checking your todos."
assert isinstance(events[1], ToolCallEvent)
assert events[1].tool_name == "todo"
assert isinstance(events[2], ToolResultEvent)
assert events[2].error is None
assert events[2].skipped is False
assert isinstance(events[3], AssistantEvent)
assert events[3].content == "Done reviewing todos."
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[1], AssistantEvent)
assert events[1].content == "Checking your todos."
assert isinstance(events[2], ToolCallEvent)
assert events[2].tool_name == "todo"
assert isinstance(events[3], ToolResultEvent)
assert events[3].error is None
assert events[3].skipped is False
assert isinstance(events[4], AssistantEvent)
assert events[4].content == "Done reviewing todos."
assert agent.messages[-1].content == "Done reviewing todos."
@@ -224,25 +228,27 @@ async def test_act_handles_tool_call_chunk_with_content() -> None:
mock_llm_chunk(content="todo request", tool_calls=[todo_tool_call]),
mock_llm_chunk(content=" complete"),
])
agent = Agent(
agent = AgentLoop(
make_config(
enabled_tools=["todo"],
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
),
backend=backend,
mode=AgentMode.AUTO_APPROVE,
agent_name=BuiltinAgentName.AUTO_APPROVE,
enable_streaming=True,
)
events = [event async for event in agent.act("Check todos with content.")]
assert [type(event) for event in events] == [
UserMessageEvent,
AssistantEvent,
ToolCallEvent,
ToolResultEvent,
]
assert isinstance(events[0], AssistantEvent)
assert events[0].content == "Preparing todo request complete"
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[1], AssistantEvent)
assert events[1].content == "Preparing todo request complete"
assert any(
m.role == Role.assistant and m.content == "Preparing todo request complete"
for m in agent.messages
@@ -266,31 +272,33 @@ async def test_act_merges_streamed_tool_call_arguments() -> None:
mock_llm_chunk(content="", tool_calls=[tool_call_part_one]),
mock_llm_chunk(content="", tool_calls=[tool_call_part_two]),
])
agent = Agent(
agent = AgentLoop(
make_config(
enabled_tools=["todo"],
tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)},
),
backend=backend,
mode=AgentMode.AUTO_APPROVE,
agent_name=BuiltinAgentName.AUTO_APPROVE,
enable_streaming=True,
)
events = [event async for event in agent.act("Merge streamed tool call args.")]
assert [type(event) for event in events] == [
UserMessageEvent,
AssistantEvent,
ToolCallEvent,
ToolResultEvent,
]
call_event = events[1]
assert isinstance(events[0], UserMessageEvent)
call_event = events[2]
assert isinstance(call_event, ToolCallEvent)
assert call_event.tool_call_id == "tc_merge"
call_args = cast(TodoArgs, call_event.args)
assert call_args.action == "read"
assert isinstance(events[2], ToolResultEvent)
assert events[2].error is None
assert events[2].skipped is False
assert isinstance(events[3], ToolResultEvent)
assert events[3].error is None
assert events[3].skipped is False
assistant_with_calls = next(
m for m in agent.messages if m.role == Role.assistant and m.tool_calls
)
@@ -328,13 +336,13 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
mock_llm_chunk(content="Preparing "),
mock_llm_chunk(content="todo request", tool_calls=[todo_tool_call]),
])
agent = Agent(
agent = AgentLoop(
make_config(
enabled_tools=["todo"],
tools={"todo": BaseToolConfig(permission=ToolPermission.ASK)},
),
backend=backend,
mode=AgentMode.DEFAULT,
agent_name=BuiltinAgentName.DEFAULT,
enable_streaming=True,
)
middleware = CountingMiddleware()
@@ -345,11 +353,12 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
str(get_user_cancellation_message(CancellationReason.OPERATION_CANCELLED)),
)
)
agent.interaction_logger.save_interaction = AsyncMock(return_value=None)
agent.session_logger.save_interaction = AsyncMock(return_value=None)
events = [event async for event in agent.act("Cancel mid stream?")]
assert [type(event) for event in events] == [
UserMessageEvent,
AssistantEvent,
ToolCallEvent,
ToolResultEvent,
@@ -360,23 +369,23 @@ async def test_act_handles_user_cancellation_during_streaming() -> None:
assert events[-1].skipped is True
assert events[-1].skip_reason is not None
assert "<user_cancellation>" in events[-1].skip_reason
assert agent.interaction_logger.save_interaction.await_count == 1
assert agent.session_logger.save_interaction.await_count == 1
@pytest.mark.asyncio
async def test_act_flushes_and_logs_when_streaming_errors(observer_capture) -> None:
observed, observer = observer_capture
backend = FakeBackend(exception_to_raise=RuntimeError("boom in streaming"))
agent = Agent(
agent = AgentLoop(
make_config(), backend=backend, message_observer=observer, enable_streaming=True
)
agent.interaction_logger.save_interaction = AsyncMock(return_value=None)
agent.session_logger.save_interaction = AsyncMock(return_value=None)
with pytest.raises(RuntimeError, match="boom in streaming"):
[_ async for _ in agent.act("Trigger stream failure")]
assert [role for role, _ in observed] == [Role.system, Role.user]
assert agent.interaction_logger.save_interaction.await_count == 1
assert agent.session_logger.save_interaction.await_count == 1
def _snapshot_events(events: list) -> list[tuple[str, str]]:
@@ -395,7 +404,7 @@ async def test_reasoning_buffer_yields_before_content_on_transition() -> None:
mock_llm_chunk(content="", reasoning_content=" problem..."),
mock_llm_chunk(content="The answer is 42."),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("What's the answer?")]
@@ -417,7 +426,7 @@ async def test_reasoning_buffer_yields_before_content_with_batching() -> None:
mock_llm_chunk(content="", reasoning_content=", Final"),
mock_llm_chunk(content="Done thinking!"),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Think step by step")]
@@ -438,7 +447,7 @@ async def test_content_buffer_yields_before_reasoning_on_transition() -> None:
mock_llm_chunk(content="", reasoning_content=" this approach..."),
mock_llm_chunk(content="Actually, the final answer."),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Give me an answer")]
@@ -459,7 +468,7 @@ async def test_interleaved_reasoning_content_preserves_order() -> None:
mock_llm_chunk(content="", reasoning_content="Think 3"),
mock_llm_chunk(content="Answer 3"),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Interleaved test")]
@@ -483,7 +492,7 @@ async def test_only_reasoning_chunks_yields_reasoning_event() -> None:
mock_llm_chunk(content="", reasoning_content="Just thinking..."),
mock_llm_chunk(content="", reasoning_content=" nothing to say yet."),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Silent thinking")]
@@ -498,7 +507,7 @@ async def test_final_buffers_flush_in_correct_order() -> None:
mock_llm_chunk(content="", reasoning_content="Final thought"),
mock_llm_chunk(content="Final words"),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("End buffers test")]
@@ -516,7 +525,7 @@ async def test_empty_content_chunks_do_not_trigger_false_yields() -> None:
mock_llm_chunk(content="", reasoning_content=" more reasoning"),
mock_llm_chunk(content="Actual content"),
])
agent = Agent(make_config(), backend=backend, enable_streaming=True)
agent = AgentLoop(make_config(), backend=backend, enable_streaming=True)
events = [event async for event in agent.act("Empty content test")]

View File

@@ -6,7 +6,8 @@ import pytest
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core.agent import Agent
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import (
Backend,
ModelConfig,
@@ -14,7 +15,6 @@ from vibe.core.config import (
SessionLoggingConfig,
VibeConfig,
)
from vibe.core.modes import AgentMode
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import (
AgentStats,
@@ -25,6 +25,7 @@ from vibe.core.types import (
LLMMessage,
Role,
ToolCall,
UserMessageEvent,
)
@@ -160,7 +161,7 @@ class TestReloadPreservesStats:
@pytest.mark.asyncio
async def test_reload_preserves_session_tokens(self) -> None:
backend = FakeBackend(mock_llm_chunk(content="First response"))
agent = Agent(make_config(), backend=backend)
agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -193,7 +194,9 @@ class TestReloadPreservesStats:
mock_llm_chunk(content="Done"),
])
config = make_config(enabled_tools=["todo"])
agent = Agent(config, mode=AgentMode.AUTO_APPROVE, backend=backend)
agent = AgentLoop(
config, agent_name=BuiltinAgentName.AUTO_APPROVE, backend=backend
)
async for _ in agent.act("Check todos"):
pass
@@ -212,7 +215,7 @@ class TestReloadPreservesStats:
[mock_llm_chunk(content="R1")],
[mock_llm_chunk(content="R2")],
])
agent = Agent(make_config(), backend=backend)
agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("First"):
pass
@@ -231,7 +234,7 @@ class TestReloadPreservesStats:
self,
) -> None:
backend = FakeBackend(mock_llm_chunk(content="Response"))
agent = Agent(make_config(), backend=backend)
agent = AgentLoop(make_config(), backend=backend)
[_ async for _ in agent.act("Hello")]
assert agent.stats.context_tokens > 0
initial_context_tokens = agent.stats.context_tokens
@@ -245,7 +248,7 @@ class TestReloadPreservesStats:
@pytest.mark.asyncio
async def test_reload_resets_context_tokens_when_no_messages(self) -> None:
backend = FakeBackend([])
agent = Agent(make_config(), backend=backend)
agent = AgentLoop(make_config(), backend=backend)
assert len(agent.messages) == 1
assert agent.stats.context_tokens == 0
@@ -261,13 +264,13 @@ class TestReloadPreservesStats:
backend = FakeBackend(mock_llm_chunk(content="Response"))
config1 = make_config(system_prompt_id="tests")
config2 = make_config(system_prompt_id="cli")
agent = Agent(config1, backend=backend)
agent = AgentLoop(config1, backend=backend)
[_ async for _ in agent.act("Hello")]
original_context_tokens = agent.stats.context_tokens
assert original_context_tokens > 0
assert len(agent.messages) > 1
await agent.reload_with_initial_messages(config=config2)
await agent.reload_with_initial_messages(base_config=config2)
assert len(agent.messages) > 1
assert agent.stats.context_tokens == original_context_tokens
@@ -278,7 +281,7 @@ class TestReloadPreservesStats:
backend = FakeBackend(mock_llm_chunk(content="Response"))
config_mistral = make_config(active_model="devstral-latest")
agent = Agent(config_mistral, backend=backend)
agent = AgentLoop(config_mistral, backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -287,7 +290,7 @@ class TestReloadPreservesStats:
assert agent.stats.output_price_per_million == 2.0
config_other = make_config(active_model="strawberry")
await agent.reload_with_initial_messages(config=config_other)
await agent.reload_with_initial_messages(base_config=config_other)
assert agent.stats.input_price_per_million == 2.5
assert agent.stats.output_price_per_million == 10.0
@@ -301,7 +304,7 @@ class TestReloadPreservesStats:
[mock_llm_chunk(content="After reload")],
])
config1 = make_config(active_model="devstral-latest")
agent = Agent(config1, backend=backend)
agent = AgentLoop(config1, backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -311,7 +314,7 @@ class TestReloadPreservesStats:
)
config2 = make_config(active_model="strawberry")
await agent.reload_with_initial_messages(config=config2)
await agent.reload_with_initial_messages(base_config=config2)
async for _ in agent.act("Continue"):
pass
@@ -326,7 +329,7 @@ class TestReloadPreservesMessages:
@pytest.mark.asyncio
async def test_reload_preserves_conversation_messages(self) -> None:
backend = FakeBackend(mock_llm_chunk(content="Response"))
agent = Agent(make_config(), backend=backend)
agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -348,7 +351,7 @@ class TestReloadPreservesMessages:
async def test_reload_updates_system_prompt_preserves_rest(self) -> None:
backend = FakeBackend(mock_llm_chunk(content="Response"))
config1 = make_config(system_prompt_id="tests")
agent = Agent(config1, backend=backend)
agent = AgentLoop(config1, backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -357,7 +360,7 @@ class TestReloadPreservesMessages:
old_user = agent.messages[1].content
config2 = make_config(system_prompt_id="cli")
await agent.reload_with_initial_messages(config=config2)
await agent.reload_with_initial_messages(base_config=config2)
assert agent.messages[0].content != old_system
assert agent.messages[1].content == old_user
@@ -365,7 +368,7 @@ class TestReloadPreservesMessages:
@pytest.mark.asyncio
async def test_reload_with_no_messages_stays_empty(self) -> None:
backend = FakeBackend([])
agent = Agent(make_config(), backend=backend)
agent = AgentLoop(make_config(), backend=backend)
assert len(agent.messages) == 1
@@ -380,7 +383,7 @@ class TestReloadPreservesMessages:
) -> None:
observed, observer = observer_capture
backend = FakeBackend(mock_llm_chunk(content="Response"))
agent = Agent(make_config(), message_observer=observer, backend=backend)
agent = AgentLoop(make_config(), message_observer=observer, backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -402,7 +405,7 @@ class TestCompactStatsHandling:
[mock_llm_chunk(content="First response")],
[mock_llm_chunk(content="<summary>")],
])
agent = Agent(make_config(), backend=backend)
agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("Build something"):
pass
@@ -424,7 +427,7 @@ class TestCompactStatsHandling:
[mock_llm_chunk(content="Long response " * 100)],
[mock_llm_chunk(content="<summary>")],
])
agent = Agent(make_config(), backend=backend)
agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("Do something complex"):
pass
@@ -456,7 +459,9 @@ class TestCompactStatsHandling:
[mock_llm_chunk(content="<summary>")],
])
config = make_config(enabled_tools=["todo"])
agent = Agent(config, mode=AgentMode.AUTO_APPROVE, backend=backend)
agent = AgentLoop(
config, agent_name=BuiltinAgentName.AUTO_APPROVE, backend=backend
)
async for _ in agent.act("Check todos"):
pass
@@ -473,10 +478,10 @@ class TestCompactStatsHandling:
[mock_llm_chunk(content="Long response " * 100)],
[mock_llm_chunk(content="<summary>")],
])
agent = Agent(make_config(disable_logging=False), backend=backend)
agent = AgentLoop(make_config(disable_logging=False), backend=backend)
original_session_id = agent.session_id
original_logger_session_id = agent.interaction_logger.session_id
original_logger_session_id = agent.session_logger.session_id
assert agent.session_id == original_logger_session_id
@@ -486,7 +491,7 @@ class TestCompactStatsHandling:
await agent.compact()
assert agent.session_id != original_session_id
assert agent.session_id == agent.interaction_logger.session_id
assert agent.session_id == agent.session_logger.session_id
class TestAutoCompactIntegration:
@@ -505,19 +510,20 @@ class TestAutoCompactIntegration:
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=1,
)
agent = Agent(cfg, message_observer=observer, backend=backend)
agent = AgentLoop(cfg, message_observer=observer, backend=backend)
agent.stats.context_tokens = 2
events = [ev async for ev in agent.act("Hello")]
assert len(events) == 3
assert isinstance(events[0], CompactStartEvent)
assert isinstance(events[1], CompactEndEvent)
assert isinstance(events[2], AssistantEvent)
assert len(events) == 4
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[1], CompactStartEvent)
assert isinstance(events[2], CompactEndEvent)
assert isinstance(events[3], AssistantEvent)
start: CompactStartEvent = events[0]
end: CompactEndEvent = events[1]
final: AssistantEvent = events[2]
start: CompactStartEvent = events[1]
end: CompactEndEvent = events[2]
final: AssistantEvent = events[3]
assert start.current_context_tokens == 2
assert start.threshold == 1
@@ -527,17 +533,14 @@ class TestAutoCompactIntegration:
roles = [r for r, _ in observed]
assert roles == [Role.system, Role.user, Role.assistant]
assert (
observed[1][1] is not None
and "Last request from user was: Hello" in observed[1][1]
)
assert observed[1][1] is not None and "<summary>" in observed[1][1]
class TestClearHistoryFullReset:
@pytest.mark.asyncio
async def test_clear_history_fully_resets_stats(self) -> None:
backend = FakeBackend(mock_llm_chunk(content="Response"))
agent = Agent(make_config(), backend=backend)
agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -555,7 +558,7 @@ class TestClearHistoryFullReset:
async def test_clear_history_preserves_pricing(self) -> None:
backend = FakeBackend(mock_llm_chunk(content="Response"))
config = make_config(input_price=0.4, output_price=2.0)
agent = Agent(config, backend=backend)
agent = AgentLoop(config, backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -568,7 +571,7 @@ class TestClearHistoryFullReset:
@pytest.mark.asyncio
async def test_clear_history_removes_messages(self) -> None:
backend = FakeBackend(mock_llm_chunk(content="Response"))
agent = Agent(make_config(), backend=backend)
agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -583,10 +586,10 @@ class TestClearHistoryFullReset:
@pytest.mark.asyncio
async def test_clear_history_resets_session_id(self) -> None:
backend = FakeBackend(mock_llm_chunk(content="Response"))
agent = Agent(make_config(disable_logging=False), backend=backend)
agent = AgentLoop(make_config(disable_logging=False), backend=backend)
original_session_id = agent.session_id
original_logger_session_id = agent.interaction_logger.session_id
original_logger_session_id = agent.session_logger.session_id
assert agent.session_id == original_logger_session_id
@@ -596,7 +599,7 @@ class TestClearHistoryFullReset:
await agent.clear_history()
assert agent.session_id != original_session_id
assert agent.session_id == agent.interaction_logger.session_id
assert agent.session_id == agent.session_logger.session_id
class TestStatsEdgeCases:
@@ -608,7 +611,7 @@ class TestStatsEdgeCases:
backend = FakeBackend(mock_llm_chunk(content="Response"))
config1 = make_config(active_model="devstral-latest")
agent = Agent(config1, backend=backend)
agent = AgentLoop(config1, backend=backend)
async for _ in agent.act("Hello"):
pass
@@ -616,7 +619,7 @@ class TestStatsEdgeCases:
cost_before = agent.stats.session_cost
config2 = make_config(active_model="strawberry")
await agent.reload_with_initial_messages(config=config2)
await agent.reload_with_initial_messages(base_config=config2)
cost_after = agent.stats.session_cost
@@ -629,7 +632,7 @@ class TestStatsEdgeCases:
[mock_llm_chunk(content="R2")],
[mock_llm_chunk(content="R3")],
])
agent = Agent(make_config(), backend=backend)
agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("First"):
pass
@@ -654,7 +657,7 @@ class TestStatsEdgeCases:
[mock_llm_chunk(content="<summary>")],
[mock_llm_chunk(content="After reload")],
])
agent = Agent(make_config(), backend=backend)
agent = AgentLoop(make_config(), backend=backend)
async for _ in agent.act("Build something"):
pass
@@ -675,9 +678,9 @@ class TestStatsEdgeCases:
async def test_reload_without_config_preserves_current(self) -> None:
backend = FakeBackend([])
original_config = make_config(active_model="devstral-latest")
agent = Agent(original_config, backend=backend)
agent = AgentLoop(original_config, backend=backend)
await agent.reload_with_initial_messages(config=None)
await agent.reload_with_initial_messages(base_config=None)
assert agent.config.active_model == "devstral-latest"
@@ -685,9 +688,9 @@ class TestStatsEdgeCases:
async def test_reload_with_new_config_updates_it(self) -> None:
backend = FakeBackend([])
original_config = make_config(active_model="devstral-latest")
agent = Agent(original_config, backend=backend)
agent = AgentLoop(original_config, backend=backend)
new_config = make_config(active_model="devstral-small")
await agent.reload_with_initial_messages(config=new_config)
await agent.reload_with_initial_messages(base_config=new_config)
assert agent.config.active_model == "devstral-small"

View File

@@ -9,9 +9,9 @@ import pytest
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_tool import FakeTool
from vibe.core.agent import Agent
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.modes import AgentMode
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tools.builtins.todo import TodoItem
from vibe.core.types import (
@@ -25,11 +25,12 @@ from vibe.core.types import (
ToolCall,
ToolCallEvent,
ToolResultEvent,
UserMessageEvent,
)
async def act_and_collect_events(agent: Agent, prompt: str) -> list[BaseEvent]:
return [ev async for ev in agent.act(prompt)]
async def act_and_collect_events(agent_loop: AgentLoop, prompt: str) -> list[BaseEvent]:
return [ev async for ev in agent_loop.act(prompt)]
def make_config(todo_permission: ToolPermission = ToolPermission.ALWAYS) -> VibeConfig:
@@ -53,20 +54,24 @@ def make_todo_tool_call(
)
def make_agent(
def make_agent_loop(
*,
auto_approve: bool = True,
todo_permission: ToolPermission = ToolPermission.ALWAYS,
backend: FakeBackend,
approval_callback: SyncApprovalCallback | None = None,
) -> Agent:
mode = AgentMode.AUTO_APPROVE if auto_approve else AgentMode.DEFAULT
agent = Agent(
make_config(todo_permission=todo_permission), mode=mode, backend=backend
) -> AgentLoop:
agent_name = (
BuiltinAgentName.AUTO_APPROVE if auto_approve else BuiltinAgentName.DEFAULT
)
agent_loop = AgentLoop(
make_config(todo_permission=todo_permission),
agent_name=agent_name,
backend=backend,
)
if approval_callback:
agent.set_approval_callback(approval_callback)
return agent
agent_loop.set_approval_callback(approval_callback)
return agent_loop
@pytest.mark.asyncio
@@ -77,28 +82,30 @@ async def test_single_tool_call_executes_under_auto_approve() -> None:
[mock_llm_chunk(content="Let me check your todos.", tool_calls=[tool_call])],
[mock_llm_chunk(content="I retrieved 0 todos.")],
])
agent = make_agent(auto_approve=True, backend=backend)
agent_loop = make_agent_loop(auto_approve=True, backend=backend)
events = await act_and_collect_events(agent, "What's my todo list?")
events = await act_and_collect_events(agent_loop, "What's my todo list?")
assert [type(e) for e in events] == [
UserMessageEvent,
AssistantEvent,
ToolCallEvent,
ToolResultEvent,
AssistantEvent,
]
assert isinstance(events[0], AssistantEvent)
assert events[0].content == "Let me check your todos."
assert isinstance(events[1], ToolCallEvent)
assert events[1].tool_name == "todo"
assert isinstance(events[2], ToolResultEvent)
assert events[2].error is None
assert events[2].skipped is False
assert events[2].result is not None
assert isinstance(events[3], AssistantEvent)
assert events[3].content == "I retrieved 0 todos."
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[1], AssistantEvent)
assert events[1].content == "Let me check your todos."
assert isinstance(events[2], ToolCallEvent)
assert events[2].tool_name == "todo"
assert isinstance(events[3], ToolResultEvent)
assert events[3].error is None
assert events[3].skipped is False
assert events[3].result is not None
assert isinstance(events[4], AssistantEvent)
assert events[4].content == "I retrieved 0 todos."
# check conversation history
tool_msgs = [m for m in agent.messages if m.role == Role.tool]
tool_msgs = [m for m in agent_loop.messages if m.role == Role.tool]
assert len(tool_msgs) == 1
assert tool_msgs[-1].tool_call_id == mocked_tool_call_id
assert "total_count" in (tool_msgs[-1].content or "")
@@ -106,7 +113,7 @@ async def test_single_tool_call_executes_under_auto_approve() -> None:
@pytest.mark.asyncio
async def test_tool_call_requires_approval_if_not_auto_approved() -> None:
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
backend=FakeBackend([
@@ -120,21 +127,23 @@ async def test_tool_call_requires_approval_if_not_auto_approved() -> None:
]),
)
events = await act_and_collect_events(agent, "What's my todo list?")
events = await act_and_collect_events(agent_loop, "What's my todo list?")
assert isinstance(events[1], ToolCallEvent)
assert events[1].tool_name == "todo"
assert isinstance(events[2], ToolResultEvent)
assert events[2].skipped is True
assert events[2].error is None
assert events[2].result is None
assert events[2].skip_reason is not None
assert "not permitted" in events[2].skip_reason.lower()
assert isinstance(events[3], AssistantEvent)
assert events[3].content == "I cannot execute the tool without approval."
assert agent.stats.tool_calls_rejected == 1
assert agent.stats.tool_calls_agreed == 0
assert agent.stats.tool_calls_succeeded == 0
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[1], AssistantEvent)
assert isinstance(events[2], ToolCallEvent)
assert events[2].tool_name == "todo"
assert isinstance(events[3], ToolResultEvent)
assert events[3].skipped is True
assert events[3].error is None
assert events[3].result is None
assert events[3].skip_reason is not None
assert "not permitted" in events[3].skip_reason.lower()
assert isinstance(events[4], AssistantEvent)
assert events[4].content == "I cannot execute the tool without approval."
assert agent_loop.stats.tool_calls_rejected == 1
assert agent_loop.stats.tool_calls_agreed == 0
assert agent_loop.stats.tool_calls_succeeded == 0
@pytest.mark.asyncio
@@ -144,7 +153,7 @@ async def test_tool_call_approved_by_callback() -> None:
) -> tuple[ApprovalResponse, str | None]:
return (ApprovalResponse.YES, None)
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
approval_callback=approval_callback,
@@ -159,15 +168,16 @@ async def test_tool_call_approved_by_callback() -> None:
]),
)
events = await act_and_collect_events(agent, "What's my todo list?")
events = await act_and_collect_events(agent_loop, "What's my todo list?")
assert isinstance(events[2], ToolResultEvent)
assert events[2].skipped is False
assert events[2].error is None
assert events[2].result is not None
assert agent.stats.tool_calls_agreed == 1
assert agent.stats.tool_calls_rejected == 0
assert agent.stats.tool_calls_succeeded == 1
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[3], ToolResultEvent)
assert events[3].skipped is False
assert events[3].error is None
assert events[3].result is not None
assert agent_loop.stats.tool_calls_agreed == 1
assert agent_loop.stats.tool_calls_rejected == 0
assert agent_loop.stats.tool_calls_succeeded == 1
@pytest.mark.asyncio
@@ -181,7 +191,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
) -> tuple[ApprovalResponse, str | None]:
return (ApprovalResponse.NO, custom_feedback)
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
approval_callback=approval_callback,
@@ -196,21 +206,22 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
]),
)
events = await act_and_collect_events(agent, "What's my todo list?")
events = await act_and_collect_events(agent_loop, "What's my todo list?")
assert isinstance(events[2], ToolResultEvent)
assert events[2].skipped is True
assert events[2].error is None
assert events[2].result is None
assert events[2].skip_reason == custom_feedback
assert agent.stats.tool_calls_rejected == 1
assert agent.stats.tool_calls_agreed == 0
assert agent.stats.tool_calls_succeeded == 0
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[3], ToolResultEvent)
assert events[3].skipped is True
assert events[3].error is None
assert events[3].result is None
assert events[3].skip_reason == custom_feedback
assert agent_loop.stats.tool_calls_rejected == 1
assert agent_loop.stats.tool_calls_agreed == 0
assert agent_loop.stats.tool_calls_succeeded == 0
@pytest.mark.asyncio
async def test_tool_call_skipped_when_permission_is_never() -> None:
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.NEVER,
backend=FakeBackend([
@@ -224,27 +235,30 @@ async def test_tool_call_skipped_when_permission_is_never() -> None:
]),
)
events = await act_and_collect_events(agent, "What's my todo list?")
events = await act_and_collect_events(agent_loop, "What's my todo list?")
assert isinstance(events[2], ToolResultEvent)
assert events[2].skipped is True
assert events[2].error is None
assert events[2].result is None
assert events[2].skip_reason is not None
assert "permanently disabled" in events[2].skip_reason.lower()
tool_msgs = [m for m in agent.messages if m.role == Role.tool and m.name == "todo"]
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[3], ToolResultEvent)
assert events[3].skipped is True
assert events[3].error is None
assert events[3].result is None
assert events[3].skip_reason is not None
assert "permanently disabled" in events[3].skip_reason.lower()
tool_msgs = [
m for m in agent_loop.messages if m.role == Role.tool and m.name == "todo"
]
assert len(tool_msgs) == 1
assert tool_msgs[0].name == "todo"
assert events[2].skip_reason in (tool_msgs[-1].content or "")
assert agent.stats.tool_calls_rejected == 1
assert agent.stats.tool_calls_agreed == 0
assert agent.stats.tool_calls_succeeded == 0
assert events[3].skip_reason in (tool_msgs[-1].content or "")
assert agent_loop.stats.tool_calls_rejected == 1
assert agent_loop.stats.tool_calls_agreed == 0
assert agent_loop.stats.tool_calls_succeeded == 0
@pytest.mark.asyncio
async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> None:
callback_invocations = []
agent_ref: Agent | None = None
agent_ref: AgentLoop | None = None
def approval_callback(
tool_name: str, _args: BaseModel, _tool_call_id: str
@@ -257,7 +271,7 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No
agent_ref.config.tools[tool_name].permission = ToolPermission.ALWAYS
return (ApprovalResponse.YES, None)
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=False,
todo_permission=ToolPermission.ASK,
approval_callback=approval_callback,
@@ -278,32 +292,34 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No
[mock_llm_chunk(content="Second done.")],
]),
)
agent_ref = agent
agent_ref = agent_loop
events1 = await act_and_collect_events(agent, "First request")
events2 = await act_and_collect_events(agent, "Second request")
events1 = await act_and_collect_events(agent_loop, "First request")
events2 = await act_and_collect_events(agent_loop, "Second request")
tool_config_todo = agent.tool_manager.get_tool_config("todo")
tool_config_todo = agent_loop.tool_manager.get_tool_config("todo")
assert tool_config_todo.permission is ToolPermission.ALWAYS
tool_config_help = agent.tool_manager.get_tool_config("bash")
tool_config_help = agent_loop.tool_manager.get_tool_config("bash")
assert tool_config_help.permission is not ToolPermission.ALWAYS
assert agent.auto_approve is False
assert agent_loop.auto_approve is False
assert len(callback_invocations) == 1
assert callback_invocations[0] == "todo"
assert isinstance(events1[2], ToolResultEvent)
assert events1[2].skipped is False
assert events1[2].result is not None
assert isinstance(events2[2], ToolResultEvent)
assert events2[2].skipped is False
assert events2[2].result is not None
assert agent.stats.tool_calls_rejected == 0
assert agent.stats.tool_calls_succeeded == 2
assert isinstance(events1[0], UserMessageEvent)
assert isinstance(events1[3], ToolResultEvent)
assert events1[3].skipped is False
assert events1[3].result is not None
assert isinstance(events2[0], UserMessageEvent)
assert isinstance(events2[3], ToolResultEvent)
assert events2[3].skipped is False
assert events2[3].result is not None
assert agent_loop.stats.tool_calls_rejected == 0
assert agent_loop.stats.tool_calls_succeeded == 2
@pytest.mark.asyncio
async def test_tool_call_with_invalid_action() -> None:
tool_call = make_todo_tool_call("call_5", arguments='{"action": "invalid_action"}')
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=True,
backend=FakeBackend([
[
@@ -315,13 +331,14 @@ async def test_tool_call_with_invalid_action() -> None:
]),
)
events = await act_and_collect_events(agent, "What's my todo list?")
events = await act_and_collect_events(agent_loop, "What's my todo list?")
assert isinstance(events[2], ToolResultEvent)
assert events[2].error is not None
assert events[2].result is None
assert "tool_error" in events[2].error.lower()
assert agent.stats.tool_calls_failed == 1
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[3], ToolResultEvent)
assert events[3].error is not None
assert events[3].result is None
assert "tool_error" in events[3].error.lower()
assert agent_loop.stats.tool_calls_failed == 1
@pytest.mark.asyncio
@@ -337,7 +354,7 @@ async def test_tool_call_with_duplicate_todo_ids() -> None:
"todos": [t.model_dump() for t in duplicate_todos],
}),
)
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=True,
backend=FakeBackend([
[mock_llm_chunk(content="Let me write todos.", tool_calls=[tool_call])],
@@ -345,13 +362,14 @@ async def test_tool_call_with_duplicate_todo_ids() -> None:
]),
)
events = await act_and_collect_events(agent, "Add todos")
events = await act_and_collect_events(agent_loop, "Add todos")
assert isinstance(events[2], ToolResultEvent)
assert events[2].error is not None
assert events[2].result is None
assert "unique" in events[2].error.lower()
assert agent.stats.tool_calls_failed == 1
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[3], ToolResultEvent)
assert events[3].error is not None
assert events[3].result is None
assert "unique" in events[3].error.lower()
assert agent_loop.stats.tool_calls_failed == 1
@pytest.mark.asyncio
@@ -364,7 +382,7 @@ async def test_tool_call_with_exceeding_max_todos() -> None:
"todos": [t.model_dump() for t in many_todos],
}),
)
agent = make_agent(
agent_loop = make_agent_loop(
auto_approve=True,
backend=FakeBackend([
[mock_llm_chunk(content="Let me write todos.", tool_calls=[tool_call])],
@@ -372,26 +390,23 @@ async def test_tool_call_with_exceeding_max_todos() -> None:
]),
)
events = await act_and_collect_events(agent, "Add todos")
events = await act_and_collect_events(agent_loop, "Add todos")
assert isinstance(events[2], ToolResultEvent)
assert events[2].error is not None
assert events[2].result is None
assert "100" in events[2].error
assert agent.stats.tool_calls_failed == 1
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[3], ToolResultEvent)
assert events[3].error is not None
assert events[3].result is None
assert "100" in events[3].error
assert agent_loop.stats.tool_calls_failed == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"exception_class",
[
pytest.param(KeyboardInterrupt, id="keyboard_interrupt"),
pytest.param(asyncio.CancelledError, id="asyncio_cancelled"),
],
)
async def test_tool_call_can_be_interrupted(
exception_class: type[BaseException],
) -> None:
async def test_tool_call_can_be_interrupted() -> None:
"""Test that tool calls can be interrupted via asyncio.CancelledError.
Note: KeyboardInterrupt is no longer handled here as ctrl+C now quits the app directly.
"""
exception_class = asyncio.CancelledError
tool_call = ToolCall(
id="call_8", index=0, function=FunctionCall(name="stub_tool", arguments="{}")
)
@@ -400,23 +415,23 @@ async def test_tool_call_can_be_interrupted(
auto_compact_threshold=0,
enabled_tools=["stub_tool"],
)
agent = Agent(
agent_loop = AgentLoop(
config,
mode=AgentMode.AUTO_APPROVE,
agent_name=BuiltinAgentName.AUTO_APPROVE,
backend=FakeBackend([
[mock_llm_chunk(content="Let me use the tool.", tool_calls=[tool_call])],
[mock_llm_chunk(content="Tool execution completed.")],
]),
)
# no dependency injection available => monkey patch
agent.tool_manager._available["stub_tool"] = FakeTool
stub_tool_instance = agent.tool_manager.get("stub_tool")
agent_loop.tool_manager._available["stub_tool"] = FakeTool
stub_tool_instance = agent_loop.tool_manager.get("stub_tool")
assert isinstance(stub_tool_instance, FakeTool)
stub_tool_instance._exception_to_raise = exception_class()
events: list[BaseEvent] = []
with pytest.raises(exception_class):
async for ev in agent.act("Execute tool"):
async for ev in agent_loop.act("Execute tool"):
events.append(ev)
tool_result_event = next(
@@ -429,9 +444,9 @@ async def test_tool_call_can_be_interrupted(
@pytest.mark.asyncio
async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
agent = Agent(
agent_loop = AgentLoop(
make_config(),
mode=AgentMode.AUTO_APPROVE,
agent_name=BuiltinAgentName.AUTO_APPROVE,
backend=FakeBackend(mock_llm_chunk(content="ok")),
)
tool_calls_messages = [
@@ -441,8 +456,8 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
assistant_msg = LLMMessage(
role=Role.assistant, content="Calling tools...", tool_calls=tool_calls_messages
)
agent.messages = [
agent.messages[0],
agent_loop.messages = [
agent_loop.messages[0],
assistant_msg,
# only one tool responded: the second is missing
LLMMessage(
@@ -450,9 +465,9 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
),
]
await act_and_collect_events(agent, "Proceed")
await act_and_collect_events(agent_loop, "Proceed")
tool_msgs = [m for m in agent.messages if m.role == Role.tool]
tool_msgs = [m for m in agent_loop.messages if m.role == Role.tool]
assert any(m.tool_call_id == "tc2" for m in tool_msgs)
# find placeholder message for tc2
placeholder = next(m for m in tool_msgs if m.tool_call_id == "tc2")
@@ -465,19 +480,19 @@ async def test_fill_missing_tool_responses_inserts_placeholders() -> None:
@pytest.mark.asyncio
async def test_ensure_assistant_after_tool_appends_understood() -> None:
agent = Agent(
agent_loop = AgentLoop(
make_config(),
mode=AgentMode.AUTO_APPROVE,
agent_name=BuiltinAgentName.AUTO_APPROVE,
backend=FakeBackend(mock_llm_chunk(content="ok")),
)
tool_msg = LLMMessage(
role=Role.tool, tool_call_id="tc_z", name="todo", content="Done"
)
agent.messages = [agent.messages[0], tool_msg]
agent_loop.messages = [agent_loop.messages[0], tool_msg]
await act_and_collect_events(agent, "Next")
await act_and_collect_events(agent_loop, "Next")
# find the seeded tool message and ensure the next message is "Understood."
idx = next(i for i, m in enumerate(agent.messages) if m.role == Role.tool)
assert agent.messages[idx + 1].role == Role.assistant
assert agent.messages[idx + 1].content == "Understood."
idx = next(i for i, m in enumerate(agent_loop.messages) if m.role == Role.tool)
assert agent_loop.messages[idx + 1].role == Role.assistant
assert agent_loop.messages[idx + 1].content == "Understood."

531
tests/test_agents.py Normal file
View File

@@ -0,0 +1,531 @@
from __future__ import annotations
import pytest
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import (
BUILTIN_AGENTS,
PLAN_AGENT_TOOLS,
AgentProfile,
AgentSafety,
AgentType,
BuiltinAgentName,
_deep_merge,
)
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.tools.base import ToolPermission
from vibe.core.types import (
FunctionCall,
LLMChunk,
LLMMessage,
LLMUsage,
Role,
ToolCall,
ToolResultEvent,
)
class TestDeepMerge:
def test_simple_merge(self) -> None:
base = {"a": 1, "b": 2}
override = {"c": 3}
result = _deep_merge(base, override)
assert result == {"a": 1, "b": 2, "c": 3}
def test_override_existing_key(self) -> None:
base = {"a": 1, "b": 2}
override = {"b": 3}
result = _deep_merge(base, override)
assert result == {"a": 1, "b": 3}
def test_nested_dict_merge(self) -> None:
base = {"a": {"x": 1, "y": 2}}
override = {"a": {"y": 3, "z": 4}}
result = _deep_merge(base, override)
assert result == {"a": {"x": 1, "y": 3, "z": 4}}
def test_deeply_nested_merge(self) -> None:
base = {"a": {"b": {"c": 1}}}
override = {"a": {"b": {"d": 2}}}
result = _deep_merge(base, override)
assert result == {"a": {"b": {"c": 1, "d": 2}}}
def test_override_dict_with_non_dict(self) -> None:
base = {"a": {"x": 1}}
override = {"a": "replaced"}
result = _deep_merge(base, override)
assert result == {"a": "replaced"}
def test_override_non_dict_with_dict(self) -> None:
base = {"a": "string"}
override = {"a": {"x": 1}}
result = _deep_merge(base, override)
assert result == {"a": {"x": 1}}
def test_preserves_original_base(self) -> None:
base = {"a": 1, "b": {"c": 2}}
override = {"b": {"d": 3}}
_deep_merge(base, override)
assert base == {"a": 1, "b": {"c": 2}}
def test_empty_override(self) -> None:
base = {"a": 1, "b": 2}
override: dict = {}
result = _deep_merge(base, override)
assert result == {"a": 1, "b": 2}
def test_empty_base(self) -> None:
base: dict = {}
override = {"a": 1}
result = _deep_merge(base, override)
assert result == {"a": 1}
def test_lists_are_overridden_not_merged(self) -> None:
"""Lists should be replaced entirely, not merged element-by-element."""
base = {"tools": ["read_file", "grep", "bash"]}
override = {"tools": ["write_file"]}
result = _deep_merge(base, override)
assert result == {"tools": ["write_file"]}
def test_nested_lists_are_overridden_not_merged(self) -> None:
"""Nested lists in dicts should also be replaced, not merged."""
base = {"config": {"enabled_tools": ["a", "b", "c"], "other": 1}}
override = {"config": {"enabled_tools": ["x", "y"]}}
result = _deep_merge(base, override)
assert result == {"config": {"enabled_tools": ["x", "y"], "other": 1}}
class TestAgentSafety:
def test_safety_enum_values(self) -> None:
assert AgentSafety.SAFE == "safe"
assert AgentSafety.NEUTRAL == "neutral"
assert AgentSafety.DESTRUCTIVE == "destructive"
assert AgentSafety.YOLO == "yolo"
def test_default_agent_is_neutral(self) -> None:
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].safety == AgentSafety.NEUTRAL
def test_auto_approve_agent_is_yolo(self) -> None:
assert BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].safety == AgentSafety.YOLO
def test_plan_agent_is_safe(self) -> None:
assert BUILTIN_AGENTS[BuiltinAgentName.PLAN].safety == AgentSafety.SAFE
def test_accept_edits_agent_is_destructive(self) -> None:
assert (
BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].safety
== AgentSafety.DESTRUCTIVE
)
class TestAgentProfile:
def test_all_builtin_agents_exist(self) -> None:
assert set(BUILTIN_AGENTS.keys()) == set(BuiltinAgentName)
def test_display_name_property(self) -> None:
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].display_name == "Default"
assert (
BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].display_name == "Auto Approve"
)
assert BUILTIN_AGENTS[BuiltinAgentName.PLAN].display_name == "Plan"
assert (
BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].display_name == "Accept Edits"
)
def test_description_property(self) -> None:
assert (
"approval" in BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].description.lower()
)
assert (
"auto" in BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].description.lower()
)
assert "read-only" in BUILTIN_AGENTS[BuiltinAgentName.PLAN].description.lower()
assert (
"edits" in BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].description.lower()
)
def test_explore_is_subagent(self) -> None:
assert BUILTIN_AGENTS[BuiltinAgentName.EXPLORE].agent_type == AgentType.SUBAGENT
def test_agents(self) -> None:
agents = [
name
for name, profile in BUILTIN_AGENTS.items()
if profile.agent_type == AgentType.AGENT
]
assert set(agents) == {
BuiltinAgentName.DEFAULT,
BuiltinAgentName.PLAN,
BuiltinAgentName.ACCEPT_EDITS,
BuiltinAgentName.AUTO_APPROVE,
}
class TestAgentProfileOverrides:
def test_default_agent_has_no_overrides(self) -> None:
assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides == {}
def test_auto_approve_agent_sets_auto_approve(self) -> None:
overrides = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].overrides
assert overrides.get("auto_approve") is True
def test_plan_agent_restricts_tools(self) -> None:
overrides = BUILTIN_AGENTS[BuiltinAgentName.PLAN].overrides
assert "enabled_tools" in overrides
assert overrides["enabled_tools"] == PLAN_AGENT_TOOLS
def test_accept_edits_agent_sets_tool_permissions(self) -> None:
overrides = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].overrides
assert "tools" in overrides
tools_config = overrides["tools"]
assert "write_file" in tools_config
assert "search_replace" in tools_config
assert tools_config["write_file"]["permission"] == "always"
assert tools_config["search_replace"]["permission"] == "always"
class TestAgentManagerCycling:
@pytest.fixture
def base_config(self) -> VibeConfig:
return VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=0,
include_project_context=False,
include_prompt_detail=False,
)
@pytest.fixture
def backend(self) -> FakeBackend:
return FakeBackend([
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Test response"),
usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
)
])
def test_get_agent_order_includes_primary_agents(
self, base_config: VibeConfig, backend: FakeBackend
) -> None:
agent = AgentLoop(
base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
)
order = agent.agent_manager.get_agent_order()
assert len(order) == 4
assert BuiltinAgentName.DEFAULT in order
assert BuiltinAgentName.AUTO_APPROVE in order
assert BuiltinAgentName.PLAN in order
assert BuiltinAgentName.ACCEPT_EDITS in order
def test_next_agent_cycles_through_all(
self, base_config: VibeConfig, backend: FakeBackend
) -> None:
agent = AgentLoop(
base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
)
order = agent.agent_manager.get_agent_order()
current = agent.agent_manager.active_profile
visited = [current.name]
for _ in range(len(order) - 1):
current = agent.agent_manager.next_agent(current)
visited.append(current.name)
assert len(set(visited)) == len(order)
def test_next_agent_wraps_around(
self, base_config: VibeConfig, backend: FakeBackend
) -> None:
agent = AgentLoop(
base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
)
order = agent.agent_manager.get_agent_order()
last_profile = agent.agent_manager.get_agent(order[-1])
first_profile = agent.agent_manager.get_agent(order[0])
assert agent.agent_manager.next_agent(last_profile).name == first_profile.name
class TestAgentProfileConfig:
def test_agent_profile_frozen(self) -> None:
profile = AgentProfile(
name="test",
display_name="Test",
description="Test agent",
safety=AgentSafety.NEUTRAL,
)
with pytest.raises(AttributeError):
profile.name = "changed" # pyright: ignore[reportAttributeAccessIssue]
class TestAgentSwitchAgent:
@pytest.fixture
def base_config(self) -> VibeConfig:
return VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=0,
include_project_context=False,
include_prompt_detail=False,
)
@pytest.fixture
def backend(self) -> FakeBackend:
return FakeBackend([
LLMChunk(
message=LLMMessage(role=Role.assistant, content="Test response"),
usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
)
])
@pytest.mark.asyncio
async def test_switch_to_plan_agent_restricts_tools(
self, base_config: VibeConfig, backend: FakeBackend
) -> None:
agent = AgentLoop(
base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
)
initial_tool_names = set(agent.tool_manager.available_tools.keys())
assert len(initial_tool_names) > len(PLAN_AGENT_TOOLS)
await agent.switch_agent(BuiltinAgentName.PLAN)
plan_tool_names = set(agent.tool_manager.available_tools.keys())
assert plan_tool_names == set(PLAN_AGENT_TOOLS)
assert agent.agent_profile.name == BuiltinAgentName.PLAN
@pytest.mark.asyncio
async def test_switch_from_plan_to_default_restores_tools(
self, base_config: VibeConfig, backend: FakeBackend
) -> None:
agent = AgentLoop(
base_config, agent_name=BuiltinAgentName.PLAN, backend=backend
)
assert len(agent.tool_manager.available_tools) == len(PLAN_AGENT_TOOLS)
await agent.switch_agent(BuiltinAgentName.DEFAULT)
assert len(agent.tool_manager.available_tools) > len(PLAN_AGENT_TOOLS)
assert agent.agent_profile.name == BuiltinAgentName.DEFAULT
@pytest.mark.asyncio
async def test_switch_agent_preserves_conversation_history(
self, base_config: VibeConfig, backend: FakeBackend
) -> None:
agent = AgentLoop(
base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
)
user_msg = LLMMessage(role=Role.user, content="Hello")
assistant_msg = LLMMessage(role=Role.assistant, content="Hi there")
agent.messages.append(user_msg)
agent.messages.append(assistant_msg)
await agent.switch_agent(BuiltinAgentName.PLAN)
assert len(agent.messages) == 3 # system + user + assistant
assert agent.messages[1].content == "Hello"
assert agent.messages[2].content == "Hi there"
@pytest.mark.asyncio
async def test_switch_to_same_agent_is_noop(
self, base_config: VibeConfig, backend: FakeBackend
) -> None:
agent = AgentLoop(
base_config, agent_name=BuiltinAgentName.DEFAULT, backend=backend
)
original_config = agent.config
await agent.switch_agent(BuiltinAgentName.DEFAULT)
assert agent.config is original_config
assert agent.agent_profile.name == BuiltinAgentName.DEFAULT
class TestAcceptEditsAgent:
def test_accept_edits_config_sets_write_file_always(self) -> None:
overrides = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].overrides
assert overrides["tools"]["write_file"]["permission"] == "always"
def test_accept_edits_config_sets_search_replace_always(self) -> None:
overrides = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].overrides
assert overrides["tools"]["search_replace"]["permission"] == "always"
@pytest.mark.asyncio
async def test_accept_edits_agent_auto_approves_write_file(self) -> None:
backend = FakeBackend([])
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=0,
enabled_tools=["write_file"],
)
agent = AgentLoop(
config, agent_name=BuiltinAgentName.ACCEPT_EDITS, backend=backend
)
perm = agent.tool_manager.get_tool_config("write_file").permission
assert perm == ToolPermission.ALWAYS
@pytest.mark.asyncio
async def test_accept_edits_agent_requires_approval_for_other_tools(self) -> None:
backend = FakeBackend([])
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=0,
enabled_tools=["bash"],
)
agent = AgentLoop(
config, agent_name=BuiltinAgentName.ACCEPT_EDITS, backend=backend
)
perm = agent.tool_manager.get_tool_config("bash").permission
assert perm == ToolPermission.ASK
class TestPlanAgentToolRestriction:
@pytest.mark.asyncio
async def test_plan_agent_only_exposes_read_tools_to_llm(self) -> None:
backend = FakeBackend([
LLMChunk(
message=LLMMessage(role=Role.assistant, content="ok"),
usage=LLMUsage(prompt_tokens=10, completion_tokens=5),
)
])
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=0,
)
agent = AgentLoop(config, agent_name=BuiltinAgentName.PLAN, backend=backend)
tool_names = set(agent.tool_manager.available_tools.keys())
assert "bash" not in tool_names
assert "write_file" not in tool_names
assert "search_replace" not in tool_names
for plan_tool in PLAN_AGENT_TOOLS:
assert plan_tool in tool_names
@pytest.mark.asyncio
async def test_plan_agent_rejects_non_plan_tool_call(self) -> None:
tool_call = ToolCall(
id="call_1",
index=0,
function=FunctionCall(name="bash", arguments='{"command": "ls"}'),
)
backend = FakeBackend([
mock_llm_chunk(content="Let me run bash", tool_calls=[tool_call]),
mock_llm_chunk(content="Tool not available"),
])
config = VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
auto_compact_threshold=0,
)
agent = AgentLoop(config, agent_name=BuiltinAgentName.PLAN, backend=backend)
events = [ev async for ev in agent.act("Run ls")]
tool_result = next((e for e in events if isinstance(e, ToolResultEvent)), None)
assert tool_result is not None
assert tool_result.error is not None
assert (
"not found" in tool_result.error.lower()
or "error" in tool_result.error.lower()
)
class TestAgentManagerFiltering:
def test_enabled_agents_filters_to_only_enabled(self) -> None:
config = VibeConfig(
include_project_context=False,
include_prompt_detail=False,
enabled_agents=["default", "plan"],
)
manager = AgentManager(lambda: config)
agents = manager.available_agents
assert len(agents) < len(manager._available)
assert "default" in agents
assert "plan" in agents
assert "auto-approve" not in agents
assert "accept-edits" not in agents
def test_disabled_agents_excludes_disabled(self) -> None:
config = VibeConfig(
include_project_context=False,
include_prompt_detail=False,
disabled_agents=["auto-approve", "accept-edits"],
)
manager = AgentManager(lambda: config)
agents = manager.available_agents
assert len(agents) < len(manager._available)
assert "default" in agents
assert "plan" in agents
assert "auto-approve" not in agents
assert "accept-edits" not in agents
def test_enabled_agents_takes_precedence_over_disabled(self) -> None:
config = VibeConfig(
include_project_context=False,
include_prompt_detail=False,
enabled_agents=["default"],
disabled_agents=["default"], # Should be ignored
)
manager = AgentManager(lambda: config)
agents = manager.available_agents
assert len(agents) == 1
assert "default" in agents
def test_glob_pattern_matching(self) -> None:
config = VibeConfig(
include_project_context=False,
include_prompt_detail=False,
disabled_agents=["auto-*", "accept-*"],
)
manager = AgentManager(lambda: config)
agents = manager.available_agents
assert "default" in agents
assert "plan" in agents
assert "auto-approve" not in agents
assert "accept-edits" not in agents
def test_regex_pattern_matching(self) -> None:
config = VibeConfig(
include_project_context=False,
include_prompt_detail=False,
enabled_agents=["re:^(default|plan)$"],
)
manager = AgentManager(lambda: config)
agents = manager.available_agents
assert len(agents) == 2
assert "default" in agents
assert "plan" in agents
def test_empty_enabled_agents_returns_all(self) -> None:
config = VibeConfig(
include_project_context=False,
include_prompt_detail=False,
enabled_agents=[],
)
manager = AgentManager(lambda: config)
agents = manager.available_agents
assert "default" in agents
assert "plan" in agents
assert "auto-approve" in agents
assert "explore" in agents
def test_get_subagents_respects_filtering(self) -> None:
config = VibeConfig(
include_project_context=False,
include_prompt_detail=False,
disabled_agents=["explore"],
)
manager = AgentManager(lambda: config)
subagents = manager.get_subagents()
names = [a.name for a in subagents]
assert "explore" not in names

162
tests/test_message_id.py Normal file
View File

@@ -0,0 +1,162 @@
from __future__ import annotations
import json
from uuid import UUID
from vibe.core.types import (
AssistantEvent,
LLMMessage,
ReasoningEvent,
Role,
UserMessageEvent,
)
class TestLLMMessageId:
def test_user_message_gets_message_id(self) -> None:
msg = LLMMessage(role=Role.user, content="Hello")
assert msg.message_id is not None
UUID(msg.message_id) # Validates it's a valid UUID
def test_assistant_message_gets_message_id(self) -> None:
msg = LLMMessage(role=Role.assistant, content="Hi there")
assert msg.message_id is not None
UUID(msg.message_id)
def test_system_message_gets_message_id(self) -> None:
msg = LLMMessage(role=Role.system, content="You are helpful")
assert msg.message_id is not None
UUID(msg.message_id)
def test_tool_message_does_not_get_message_id(self) -> None:
msg = LLMMessage(role=Role.tool, content="result", tool_call_id="tc_123")
assert msg.message_id is None
def test_each_message_gets_unique_id(self) -> None:
msg1 = LLMMessage(role=Role.user, content="First")
msg2 = LLMMessage(role=Role.user, content="Second")
assert msg1.message_id != msg2.message_id
def test_message_id_preserved_from_dict(self) -> None:
expected_id = "custom-message-id-123"
msg = LLMMessage.model_validate({
"role": "user",
"content": "Hello",
"message_id": expected_id,
})
assert msg.message_id == expected_id
def test_message_id_preserved_for_tool_from_dict(self) -> None:
expected_id = "tool-message-id"
msg = LLMMessage.model_validate({
"role": "tool",
"content": "result",
"tool_call_id": "tc_123",
"message_id": expected_id,
})
assert msg.message_id == expected_id
def test_tool_message_no_id_from_dict_without_id(self) -> None:
msg = LLMMessage.model_validate({
"role": "tool",
"content": "result",
"tool_call_id": "tc_123",
})
assert msg.message_id is None
class TestLLMMessageAccumulation:
def test_message_id_preserved_on_add(self) -> None:
msg1 = LLMMessage(role=Role.assistant, content="Hello")
msg2 = LLMMessage(role=Role.assistant, content=" world")
result = msg1 + msg2
assert result.message_id == msg1.message_id
assert result.content == "Hello world"
def test_message_id_preserved_after_multiple_adds(self) -> None:
msg1 = LLMMessage(role=Role.assistant, content="A")
msg2 = LLMMessage(role=Role.assistant, content="B")
msg3 = LLMMessage(role=Role.assistant, content="C")
result = msg1 + msg2 + msg3
assert result.message_id == msg1.message_id
assert result.content == "ABC"
class TestEventMessageId:
def test_user_message_event_has_message_id(self) -> None:
event = UserMessageEvent(content="Hello", message_id="user-msg-id")
assert event.message_id == "user-msg-id"
assert event.content == "Hello"
def test_assistant_event_has_message_id(self) -> None:
event = AssistantEvent(content="test", message_id="test-id")
assert event.message_id == "test-id"
def test_assistant_event_message_id_optional(self) -> None:
event = AssistantEvent(content="test")
assert event.message_id is None
def test_reasoning_event_has_message_id(self) -> None:
event = ReasoningEvent(content="thinking...", message_id="reason-id")
assert event.message_id == "reason-id"
def test_reasoning_event_message_id_optional(self) -> None:
event = ReasoningEvent(content="thinking...")
assert event.message_id is None
def test_assistant_event_add_preserves_message_id(self) -> None:
event1 = AssistantEvent(content="Hello", message_id="first-id")
event2 = AssistantEvent(content=" world", message_id="second-id")
result = event1 + event2
assert result.message_id == "first-id"
assert result.content == "Hello world"
class TestMessageIdExcludedFromAPI:
def test_message_id_excluded_with_exclude_param(self) -> None:
msg = LLMMessage(role=Role.user, content="Hello")
dumped = msg.model_dump(exclude_none=True, exclude={"message_id"})
assert "message_id" not in dumped
assert dumped["role"] == "user"
assert dumped["content"] == "Hello"
def test_message_id_included_in_normal_dump(self) -> None:
msg = LLMMessage(role=Role.user, content="Hello")
dumped = msg.model_dump(exclude_none=True)
assert "message_id" in dumped
assert dumped["message_id"] == msg.message_id
class TestMessageIdInLogs:
def test_message_id_in_json_dump(self) -> None:
msg = LLMMessage(role=Role.assistant, content="Response")
dumped = msg.model_dump(exclude_none=True)
json_str = json.dumps(dumped)
loaded = json.loads(json_str)
assert "message_id" in loaded
assert loaded["message_id"] == msg.message_id
def test_message_id_roundtrip(self) -> None:
original = LLMMessage(role=Role.user, content="Test")
original_id = original.message_id
dumped = original.model_dump(exclude_none=True)
restored = LLMMessage.model_validate(dumped)
assert restored.message_id == original_id
def test_tool_message_id_none_in_json(self) -> None:
msg = LLMMessage(role=Role.tool, content="result", tool_call_id="tc_1")
dumped = msg.model_dump(exclude_none=True)
assert "message_id" not in dumped

Some files were not shown because too many files have changed in this diff Show More