File size: 6,575 Bytes
5ef3875
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
"""Built-in slash commands, handled locally (never sent to the model)."""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable

from rich.text import Text

from ycode.config import APPROVAL_MODES
from ycode.tools import git

if TYPE_CHECKING:
    from ycode.app import YCodeApp

_COMMAND_RE = re.compile(r"^/([a-zA-Z][\w-]*)(?:\s+(.*))?$", re.DOTALL)


@dataclass(frozen=True)
class Command:
    name: str
    usage: str
    help: str
    handler: Callable[["YCodeApp", str], None]


def _help(app: "YCodeApp", arg: str) -> None:
    app.ui.console.print(Text("  Commands", style="key"))
    app.ui.print_table([(c.usage, c.help) for c in COMMANDS.values() if c.name != "quit"])
    app.ui.console.print()
    app.ui.info("Anything else is sent to the agent as a task. Ctrl+C interrupts the agent;")
    app.ui.info("Ctrl+D or /exit quits. Esc+Enter inserts a newline.")


def _clear(app: "YCodeApp", arg: str) -> None:
    app.agent.reset()
    app.ui.console.clear()
    app.ui.info("Conversation cleared. The agent has forgotten previous messages.")


def _status(app: "YCodeApp", arg: str) -> None:
    ws = app.workspace
    rows = [("Project", ws.name or str(ws)), ("Directory", str(ws))]
    if app.context.is_git_repo:
        rows.append(("Branch", git.current_branch(ws) or "(unknown)"))
        rows.append(("Modified files", str(len(git.status_entries(ws)))))
    else:
        rows.append(("Git", "not a repository"))
    usage = app.agent.usage
    rows += [
        ("Model", app.provider.display_name),
        ("Steps used", f"{app.agent.last_steps}/{app.agent.max_steps} (last task), {app.agent.total_steps} total"),
        ("Tokens", f"{usage.input_tokens:,} in, {usage.output_tokens:,} out, "
                   f"{usage.cache_read_tokens:,} cache reads"),
        ("Approvals", app.permissions.mode),
        ("Messages", str(len(app.agent.messages))),
        ("Files changed", str(len(app.tool_context.changed_files)) + " this session"),
    ]
    if app.context.instructions_path:
        rows.append(("Instructions", app.context.instructions_path))
    app.ui.print_table(rows)


def _diff(app: "YCodeApp", arg: str) -> None:
    if not app.context.is_git_repo:
        app.ui.info("Not a git repository.")
        return
    args = arg.split()
    full = "full" in args
    paths = [a for a in args if a != "full"]
    diff = git.diff_text(app.workspace, paths=paths or None)
    untracked = [p for p in git.untracked_files(app.workspace) if not paths or p in paths]
    if not diff.strip() and not untracked:
        app.ui.info("No changes.")
        return
    lines = diff.splitlines()
    if diff.strip():
        if len(lines) > 400 and not full:
            app.ui.console.print(git.diff_text(app.workspace, paths=paths or None, stat=True).rstrip())
            app.ui.info(f"Diff is {len(lines)} lines. Use `/diff full` or `/diff <path>` to see it.")
        else:
            app.ui.print_diff(diff)
    if untracked:
        app.ui.console.print(Text("  Untracked files:", style="key"))
        for path in untracked:
            app.ui.console.print(Text(f"   A {path}", style="ok"))


def _files(app: "YCodeApp", arg: str) -> None:
    session = sorted(app.tool_context.changed_files)
    if app.context.is_git_repo:
        entries = git.status_entries(app.workspace)
        if entries:
            app.ui.console.print(Text("  Working tree changes:", style="key"))
            app.ui.print_changes(entries)
        else:
            app.ui.info("Working tree clean.")
    if session:
        app.ui.console.print(Text("  Written by YCode this session:", style="key"))
        for path in session:
            app.ui.console.print(f"   {path}")
    elif not app.context.is_git_repo:
        app.ui.info("No files changed this session.")


def _model(app: "YCodeApp", arg: str) -> None:
    name = arg.strip()
    if app.provider.name == "local":
        app.ui.info(f"Model: {app.provider.display_name}")
        app.ui.info("Start YCode with `--local <model dir>` to use a different local model.")
        return
    if not name:
        app.ui.info(f"Model: {app.provider.display_name}")
        app.ui.info("Switch with /model <model-id>, e.g. /model claude-sonnet-5-5")
        return
    app.provider.model = name
    app.ui.info(f"Model set to {name} for this session.")


def _plan(app: "YCodeApp", arg: str) -> None:
    app.ui.print_plan(app.agent.plan)


def _approval(app: "YCodeApp", arg: str) -> None:
    mode = arg.strip().lower()
    if not mode:
        app.ui.info(f"Approval mode: {app.permissions.mode} (options: {', '.join(APPROVAL_MODES)})")
        return
    if mode not in APPROVAL_MODES:
        app.ui.info(f"Unknown mode {mode!r}. Options: {', '.join(APPROVAL_MODES)}")
        return
    app.permissions.mode = mode
    note = " Blocked commands stay blocked." if mode == "auto" else ""
    app.ui.info(f"Approval mode set to {mode}.{note}")


def _exit(app: "YCodeApp", arg: str) -> None:
    app.should_exit = True


COMMANDS: dict[str, Command] = {
    c.name: c
    for c in [
        Command("help", "/help", "Show this help", _help),
        Command("clear", "/clear", "Clear the conversation and the screen", _clear),
        Command("status", "/status", "Project, branch, model, steps and token usage", _status),
        Command("diff", "/diff [full] [path...]", "Show the current git diff", _diff),
        Command("files", "/files", "List changed files", _files),
        Command("model", "/model [id]", "Show or switch the model", _model),
        Command("plan", "/plan", "Show the current plan", _plan),
        Command("approval", "/approval [mode]", "Show or set approval mode (strict|normal|auto)", _approval),
        Command("exit", "/exit", "Quit YCode (also /quit, Ctrl+D)", _exit),
        Command("quit", "/quit", "Quit YCode", _exit),
    ]
}


def command_names() -> list[str]:
    return [f"/{name}" for name in COMMANDS]


def is_command(text: str) -> bool:
    match = _COMMAND_RE.match(text.strip())
    return bool(match)


def handle_command(app: "YCodeApp", text: str) -> bool:
    """Run a slash command. Returns False if it was not recognised."""
    match = _COMMAND_RE.match(text.strip())
    if not match:
        return False
    name, arg = match.group(1).lower(), match.group(2) or ""
    command = COMMANDS.get(name)
    if command is None:
        app.ui.info(f"Unknown command /{name}. Type /help for the list of commands.")
        return False
    command.handler(app, arg)
    return True