Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions libs/deepagents-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,51 @@ deepagents skills create my-skill
deepagents skills info web-research
```

## VS Code Diff Preview

The CLI supports optional VS Code integration to preview code changes as side-by-side diffs before approval.

### Requirements

- VS Code installed with the `code` command in your PATH
- Enable via `--vscode-diff` flag when starting the CLI

### Usage

Start the CLI with VS Code diff preview enabled:

```bash
deepagents --vscode-diff
```

### How It Works

When the agent suggests file changes (write_file or edit_file operations):

1. VS Code automatically opens showing a side-by-side diff
2. You can review the changes in VS Code with full syntax highlighting and editor features
3. Return to the terminal to approve or reject the changes
4. Temporary files are automatically cleaned up

### Benefits

- **Visual Feedback**: See proposed changes with syntax highlighting and editor context
- **Editor Features**: Use VS Code's search, navigation, and inspection tools while reviewing
- **Side-by-Side Comparison**: Clearly see what's being added, removed, or modified
- **Faster Review**: Review changes in your familiar editor environment

### Example

```bash
# Start CLI with VS Code diff preview
deepagents --vscode-diff

# When agent suggests a file change:
# 1. VS Code opens with diff view
# 2. Terminal shows approval prompt
# 3. Review in VS Code, then approve/reject in terminal
```

## Development

### Running Tests
Expand Down
13 changes: 12 additions & 1 deletion libs/deepagents-cli/deepagents_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,9 +337,15 @@ def ensure_project_skills_dir(self) -> Path | None:
class SessionState:
"""Holds mutable session state (auto-approve mode, etc)."""

def __init__(self, auto_approve: bool = False, no_splash: bool = False) -> None:
def __init__(
self,
auto_approve: bool = False,
no_splash: bool = False,
vscode_diff_preview: bool = False,
) -> None:
self.auto_approve = auto_approve
self.no_splash = no_splash
self.vscode_diff_preview = vscode_diff_preview
self.exit_hint_until: float | None = None
self.exit_hint_handle = None
self.thread_id = str(uuid.uuid4())
Expand All @@ -349,6 +355,11 @@ def toggle_auto_approve(self) -> bool:
self.auto_approve = not self.auto_approve
return self.auto_approve

def toggle_vscode_diff_preview(self) -> bool:
"""Toggle VS Code diff preview and return new state."""
self.vscode_diff_preview = not self.vscode_diff_preview
return self.vscode_diff_preview


def get_default_coding_instructions() -> str:
"""Get the default coding agent instructions.
Expand Down
230 changes: 156 additions & 74 deletions libs/deepagents-cli/deepagents_cli/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,17 @@
import asyncio
import json
import sys
import termios
import tty
from pathlib import Path

from deepagents.backends.utils import perform_string_replacement

# Unix-only terminal control modules (not available on Windows)
try:
import termios
import tty
HAS_TERMIOS = True
except ImportError:
HAS_TERMIOS = False

from langchain.agents.middleware.human_in_the_loop import (
ActionRequest,
Expand All @@ -22,7 +31,7 @@
from rich.panel import Panel

from deepagents_cli.config import COLORS, console
from deepagents_cli.file_ops import FileOpTracker, build_approval_preview
from deepagents_cli.file_ops import FileOpTracker, build_approval_preview, resolve_physical_path
from deepagents_cli.input import parse_file_mentions
from deepagents_cli.ui import (
TokenTracker,
Expand All @@ -32,16 +41,23 @@
render_file_operation,
render_todo_list,
)
from deepagents_cli.vscode_integration import open_diff_in_vscode

_HITL_REQUEST_ADAPTER = TypeAdapter(HITLRequest)


def prompt_for_tool_approval(
action_request: ActionRequest,
assistant_id: str | None,
vscode_diff_preview: bool = False,
) -> Decision | dict:
"""Prompt user to approve/reject a tool action with arrow key navigation.

Args:
action_request: The action request to approve.
assistant_id: The assistant ID for path resolution.
vscode_diff_preview: If True, open diff in VS Code before prompting.

Returns:
Decision (ApproveDecision or RejectDecision) OR
dict with {"type": "auto_approve_all"} to switch to auto-approve mode
Expand All @@ -51,6 +67,58 @@ def prompt_for_tool_approval(
args = action_request["args"]
preview = build_approval_preview(name, args, assistant_id) if name else None

# If VS Code diff preview is enabled and this is a file operation, open diff in VS Code
if vscode_diff_preview and name in {"write_file", "edit_file"} and preview:
file_path_str = str(args.get("file_path") or args.get("path") or "")
if file_path_str:
physical_path = resolve_physical_path(file_path_str, assistant_id)

# Get before and after content
if name == "write_file":
before_content = ""
if physical_path and physical_path.exists():
try:
before_content = physical_path.read_text()
except (OSError, UnicodeDecodeError):
before_content = ""
after_content = str(args.get("content", ""))

elif name == "edit_file":
before_content = ""
if physical_path and physical_path.exists():
try:
before_content = physical_path.read_text()
except (OSError, UnicodeDecodeError):
before_content = ""

# Apply the edit to get after_content with validation
old_string = str(args.get("old_string", ""))
new_string = str(args.get("new_string", ""))
replace_all = args.get("replace_all", False)

# Use perform_string_replacement to validate and apply the edit
# This ensures the diff preview matches what will actually be applied
replacement = perform_string_replacement(before_content, old_string, new_string, replace_all)

# If replacement failed, skip VS Code diff preview
if isinstance(replacement, str):
# replacement is an error message - don't show diff for invalid edits
after_content = None
else:
# replacement is (after_content, occurrences) tuple
after_content, occurrences = replacement

# Open diff in VS Code (skip if validation failed)
if after_content is not None and (before_content or after_content):
success = open_diff_in_vscode(
Path(file_path_str),
before_content,
after_content,
wait=False,
)
# open_diff_in_vscode prints its own error messages
# Silent failure is acceptable for this optional preview feature

body_lines = []
if preview:
body_lines.append(f"[bold]{preview.title}[/bold]")
Expand All @@ -77,86 +145,99 @@ def prompt_for_tool_approval(
options = ["approve", "reject", "auto-accept all going forward"]
selected = 0 # Start with approve selected

try:
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)

if HAS_TERMIOS:
try:
tty.setraw(fd)
# Hide cursor during menu interaction
sys.stdout.write("\033[?25l")
sys.stdout.flush()
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)

try:
tty.setraw(fd)
# Hide cursor during menu interaction
sys.stdout.write("\033[?25l")
sys.stdout.flush()

# Initial render flag
first_render = True
# Initial render flag
first_render = True

while True:
if not first_render:
# Move cursor back to start of menu (up 3 lines, then to start of line)
sys.stdout.write("\033[3A\r")
while True:
if not first_render:
# Move cursor back to start of menu (up 3 lines, then to start of line)
sys.stdout.write("\033[3A\r")

first_render = False
first_render = False

# Display options vertically with ANSI color codes
for i, option in enumerate(options):
sys.stdout.write("\r\033[K") # Clear line from cursor to end
# Display options vertically with ANSI color codes
for i, option in enumerate(options):
sys.stdout.write("\r\033[K") # Clear line from cursor to end

if i == selected:
if option == "approve":
# Green bold with filled checkbox
sys.stdout.write("\033[1;32mβ˜‘ Approve\033[0m\n")
if i == selected:
if option == "approve":
# Green bold with filled checkbox
sys.stdout.write("\033[1;32mβ˜‘ Approve\033[0m\n")
elif option == "reject":
# Red bold with filled checkbox
sys.stdout.write("\033[1;31mβ˜‘ Reject\033[0m\n")
else:
# Blue bold with filled checkbox for auto-accept
sys.stdout.write("\033[1;34mβ˜‘ Auto-accept all going forward\033[0m\n")
elif option == "approve":
# Dim with empty checkbox
sys.stdout.write("\033[2m☐ Approve\033[0m\n")
elif option == "reject":
# Red bold with filled checkbox
sys.stdout.write("\033[1;31mβ˜‘ Reject\033[0m\n")
# Dim with empty checkbox
sys.stdout.write("\033[2m☐ Reject\033[0m\n")
else:
# Blue bold with filled checkbox for auto-accept
sys.stdout.write("\033[1;34mβ˜‘ Auto-accept all going forward\033[0m\n")
elif option == "approve":
# Dim with empty checkbox
sys.stdout.write("\033[2m☐ Approve\033[0m\n")
elif option == "reject":
# Dim with empty checkbox
sys.stdout.write("\033[2m☐ Reject\033[0m\n")
else:
# Dim with empty checkbox
sys.stdout.write("\033[2m☐ Auto-accept all going forward\033[0m\n")

# Dim with empty checkbox
sys.stdout.write("\033[2m☐ Auto-accept all going forward\033[0m\n")

sys.stdout.flush()

# Read key
char = sys.stdin.read(1)

if char == "\x1b": # ESC sequence (arrow keys)
next1 = sys.stdin.read(1)
next2 = sys.stdin.read(1)
if next1 == "[":
if next2 == "B": # Down arrow
selected = (selected + 1) % len(options)
elif next2 == "A": # Up arrow
selected = (selected - 1) % len(options)
elif char in {"\r", "\n"}: # Enter
sys.stdout.write("\r\n") # Move to start of line and add newline
break
elif char == "\x03": # Ctrl+C
sys.stdout.write("\r\n") # Move to start of line and add newline
raise KeyboardInterrupt
elif char.lower() == "a":
selected = 0
sys.stdout.write("\r\n") # Move to start of line and add newline
break
elif char.lower() == "r":
selected = 1
sys.stdout.write("\r\n") # Move to start of line and add newline
break

finally:
# Show cursor again
sys.stdout.write("\033[?25h")
sys.stdout.flush()

# Read key
char = sys.stdin.read(1)

if char == "\x1b": # ESC sequence (arrow keys)
next1 = sys.stdin.read(1)
next2 = sys.stdin.read(1)
if next1 == "[":
if next2 == "B": # Down arrow
selected = (selected + 1) % len(options)
elif next2 == "A": # Up arrow
selected = (selected - 1) % len(options)
elif char in {"\r", "\n"}: # Enter
sys.stdout.write("\r\n") # Move to start of line and add newline
break
elif char == "\x03": # Ctrl+C
sys.stdout.write("\r\n") # Move to start of line and add newline
raise KeyboardInterrupt
elif char.lower() == "a":
selected = 0
sys.stdout.write("\r\n") # Move to start of line and add newline
break
elif char.lower() == "r":
selected = 1
sys.stdout.write("\r\n") # Move to start of line and add newline
break

finally:
# Show cursor again
sys.stdout.write("\033[?25h")
sys.stdout.flush()
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)

except (termios.error, AttributeError):
# Fallback for non-Unix systems
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)

except (termios.error, AttributeError):
# Fallback for non-Unix systems
console.print(" ☐ (A)pprove (default)")
console.print(" ☐ (R)eject")
console.print(" ☐ (Auto)-accept all going forward")
choice = input("\nChoice (A/R/Auto, default=Approve): ").strip().lower()
if choice in {"r", "reject"}:
selected = 1
elif choice in {"auto", "auto-accept"}:
selected = 2
else:
selected = 0
else:
# Fallback for Windows (no termios available)
console.print(" ☐ (A)pprove (default)")
console.print(" ☐ (R)eject")
console.print(" ☐ (Auto)-accept all going forward")
Expand Down Expand Up @@ -569,6 +650,7 @@ def flush_text_buffer(*, final: bool = False) -> None:
decision = prompt_for_tool_approval(
action_request,
assistant_id,
vscode_diff_preview=session_state.vscode_diff_preview,
)

# Check if user wants to switch to auto-approve mode
Expand Down
11 changes: 10 additions & 1 deletion libs/deepagents-cli/deepagents_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ def parse_args():
action="store_true",
help="Disable the startup splash screen",
)
parser.add_argument(
"--vscode-diff",
action="store_true",
help="Enable VS Code diff preview for code changes (requires VS Code CLI)",
)

return parser.parse_args()

Expand Down Expand Up @@ -404,7 +409,11 @@ def cli_main() -> None:
execute_skills_command(args)
else:
# Create session state from args
session_state = SessionState(auto_approve=args.auto_approve, no_splash=args.no_splash)
session_state = SessionState(
auto_approve=args.auto_approve,
no_splash=args.no_splash,
vscode_diff_preview=args.vscode_diff,
)

# API key validation happens in create_model()
asyncio.run(
Expand Down
Loading