mita-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mita/__init__.py +5 -0
- mita/__main__.py +5 -0
- mita/agent/__init__.py +1 -0
- mita/agent/context.py +43 -0
- mita/agent/conversation.py +101 -0
- mita/agent/loop.py +594 -0
- mita/agent/system_prompt.py +75 -0
- mita/cli.py +940 -0
- mita/config/__init__.py +6 -0
- mita/config/defaults.py +41 -0
- mita/config/loader.py +53 -0
- mita/config/schema.py +131 -0
- mita/hooks/__init__.py +1 -0
- mita/hooks/manager.py +94 -0
- mita/hooks/runner.py +145 -0
- mita/index/__init__.py +1 -0
- mita/index/embeddings.py +49 -0
- mita/index/manager.py +170 -0
- mita/index/parser.py +331 -0
- mita/index/retriever.py +53 -0
- mita/index/store.py +143 -0
- mita/llm/__init__.py +1 -0
- mita/llm/client.py +86 -0
- mita/llm/instructor.py +80 -0
- mita/llm/streaming.py +58 -0
- mita/memory/__init__.py +6 -0
- mita/memory/discovery.py +61 -0
- mita/memory/loader.py +76 -0
- mita/memory/manager.py +117 -0
- mita/models/__init__.py +13 -0
- mita/models/hardware.py +289 -0
- mita/models/manager.py +268 -0
- mita/models/ollama_client.py +104 -0
- mita/models/recommender.py +88 -0
- mita/models/registry.py +167 -0
- mita/models/server.py +262 -0
- mita/plugins/__init__.py +1 -0
- mita/plugins/client.py +152 -0
- mita/plugins/manager.py +210 -0
- mita/py.typed +0 -0
- mita/skills/__init__.py +1 -0
- mita/skills/executor.py +84 -0
- mita/skills/loader.py +117 -0
- mita/skills/manager.py +129 -0
- mita/tools/__init__.py +1 -0
- mita/tools/builtins/__init__.py +28 -0
- mita/tools/builtins/file_edit.py +71 -0
- mita/tools/builtins/file_read.py +74 -0
- mita/tools/builtins/file_write.py +42 -0
- mita/tools/builtins/git.py +112 -0
- mita/tools/builtins/glob_tool.py +67 -0
- mita/tools/builtins/grep_tool.py +93 -0
- mita/tools/builtins/shell.py +83 -0
- mita/tools/executor.py +80 -0
- mita/tools/registry.py +69 -0
- mita/tools/safety.py +91 -0
- mita/tools/schema.py +87 -0
- mita/ui/__init__.py +1 -0
- mita/ui/display.py +139 -0
- mita/ui/repl.py +88 -0
- mita/ui/spinner.py +48 -0
- mita/ui/theme.py +23 -0
- mita_code-0.1.0.dist-info/METADATA +227 -0
- mita_code-0.1.0.dist-info/RECORD +67 -0
- mita_code-0.1.0.dist-info/WHEEL +4 -0
- mita_code-0.1.0.dist-info/entry_points.txt +3 -0
- mita_code-0.1.0.dist-info/licenses/LICENSE +201 -0
mita/tools/safety.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Destructive action detection and banned command checking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from mita.config.schema import ToolSettings
|
|
8
|
+
from mita.tools.schema import ToolCall, ToolDefinition
|
|
9
|
+
|
|
10
|
+
# Patterns that indicate destructive shell commands
|
|
11
|
+
DESTRUCTIVE_PATTERNS: list[re.Pattern[str]] = [
|
|
12
|
+
re.compile(r"\brm\s+(-[a-zA-Z]*f|-[a-zA-Z]*r)", re.IGNORECASE),
|
|
13
|
+
re.compile(r"\bgit\s+(push\s+--force|reset\s+--hard|clean\s+-[a-zA-Z]*f)", re.IGNORECASE),
|
|
14
|
+
re.compile(r"\bchmod\s+-R\s+0?7", re.IGNORECASE),
|
|
15
|
+
re.compile(r"\bchown\s+-R\b", re.IGNORECASE),
|
|
16
|
+
re.compile(r"\b(truncate|shred)\b", re.IGNORECASE),
|
|
17
|
+
re.compile(r">\s*/dev/\w+", re.IGNORECASE),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# Git subcommands that are destructive and need confirmation
|
|
21
|
+
DESTRUCTIVE_GIT_PATTERNS: list[re.Pattern[str]] = [
|
|
22
|
+
re.compile(r"^push\s+--force", re.IGNORECASE),
|
|
23
|
+
re.compile(r"^push\s+-f\b", re.IGNORECASE),
|
|
24
|
+
re.compile(r"^reset\s+--hard", re.IGNORECASE),
|
|
25
|
+
re.compile(r"^clean\s+-[a-zA-Z]*f", re.IGNORECASE),
|
|
26
|
+
re.compile(r"^checkout\s+--\s", re.IGNORECASE),
|
|
27
|
+
re.compile(r"^branch\s+-[dD]\b", re.IGNORECASE),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_command_banned(command: str, banned_commands: list[str]) -> bool:
|
|
32
|
+
"""Check if a shell command matches any banned command pattern."""
|
|
33
|
+
normalized = command.strip()
|
|
34
|
+
for banned in banned_commands:
|
|
35
|
+
if banned in normalized:
|
|
36
|
+
return True
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_command_destructive(command: str) -> bool:
|
|
41
|
+
"""Check if a shell command looks destructive."""
|
|
42
|
+
for pattern in DESTRUCTIVE_PATTERNS:
|
|
43
|
+
if pattern.search(command):
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_git_command_destructive(subcommand: str) -> bool:
|
|
49
|
+
"""Check if a git subcommand is destructive."""
|
|
50
|
+
stripped = subcommand.strip()
|
|
51
|
+
for pattern in DESTRUCTIVE_GIT_PATTERNS:
|
|
52
|
+
if pattern.search(stripped):
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def needs_confirmation(
|
|
58
|
+
tool_call: ToolCall,
|
|
59
|
+
tool_def: ToolDefinition,
|
|
60
|
+
settings: ToolSettings,
|
|
61
|
+
) -> bool:
|
|
62
|
+
"""Determine if a tool call needs user confirmation before execution.
|
|
63
|
+
|
|
64
|
+
Returns True if:
|
|
65
|
+
- The tool is marked destructive AND confirm_destructive is on
|
|
66
|
+
AND the tool is not in auto_approve.
|
|
67
|
+
- Or the tool is 'shell' and the command looks destructive.
|
|
68
|
+
- Or the tool is 'git' and the subcommand is destructive.
|
|
69
|
+
"""
|
|
70
|
+
if not settings.confirm_destructive:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
if tool_call.name in settings.auto_approve:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
if tool_def.destructive:
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
# Extra check for shell commands that look destructive
|
|
80
|
+
if tool_call.name == "shell":
|
|
81
|
+
command = tool_call.arguments.get("command", "")
|
|
82
|
+
if is_command_destructive(command):
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
# Check git subcommands for destructive operations
|
|
86
|
+
if tool_call.name == "git":
|
|
87
|
+
subcommand = tool_call.arguments.get("subcommand", "")
|
|
88
|
+
if is_git_command_destructive(subcommand):
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
return False
|
mita/tools/schema.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Pydantic models for tool definitions, calls, and results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ToolParameter(BaseModel):
|
|
11
|
+
"""A parameter in a tool definition."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
type: str # "string", "integer", "boolean", "array", "object"
|
|
15
|
+
description: str
|
|
16
|
+
required: bool = True
|
|
17
|
+
default: Any | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ToolDefinition(BaseModel):
|
|
21
|
+
"""Schema sent to the LLM so it knows what tools are available."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
description: str
|
|
25
|
+
parameters: list[ToolParameter]
|
|
26
|
+
destructive: bool = False # triggers confirmation flow
|
|
27
|
+
source: str = "builtin" # "builtin" | "mcp:<plugin_name>"
|
|
28
|
+
|
|
29
|
+
def to_openai_schema(self) -> dict[str, Any]:
|
|
30
|
+
"""Convert to OpenAI-compatible function schema for LiteLLM."""
|
|
31
|
+
properties: dict[str, Any] = {}
|
|
32
|
+
required: list[str] = []
|
|
33
|
+
|
|
34
|
+
for param in self.parameters:
|
|
35
|
+
prop: dict[str, Any] = {
|
|
36
|
+
"type": param.type,
|
|
37
|
+
"description": param.description,
|
|
38
|
+
}
|
|
39
|
+
if param.default is not None:
|
|
40
|
+
prop["default"] = param.default
|
|
41
|
+
properties[param.name] = prop
|
|
42
|
+
if param.required:
|
|
43
|
+
required.append(param.name)
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
"type": "function",
|
|
47
|
+
"function": {
|
|
48
|
+
"name": self.name,
|
|
49
|
+
"description": self.description,
|
|
50
|
+
"parameters": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": properties,
|
|
53
|
+
"required": required,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ToolCall(BaseModel):
|
|
60
|
+
"""Parsed from LLM output."""
|
|
61
|
+
|
|
62
|
+
id: str
|
|
63
|
+
name: str
|
|
64
|
+
arguments: dict[str, Any]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ToolResult(BaseModel):
|
|
68
|
+
"""Returned after executing a tool."""
|
|
69
|
+
|
|
70
|
+
tool_call_id: str
|
|
71
|
+
success: bool
|
|
72
|
+
output: str = ""
|
|
73
|
+
error: str | None = None
|
|
74
|
+
truncated: bool = False
|
|
75
|
+
|
|
76
|
+
# Max output size before truncation (10K chars)
|
|
77
|
+
MAX_OUTPUT_SIZE: int = Field(default=10_000, exclude=True)
|
|
78
|
+
|
|
79
|
+
def truncate_output(self) -> ToolResult:
|
|
80
|
+
"""Return a copy with output truncated if too long."""
|
|
81
|
+
if len(self.output) <= self.MAX_OUTPUT_SIZE:
|
|
82
|
+
return self
|
|
83
|
+
truncated_output = (
|
|
84
|
+
self.output[: self.MAX_OUTPUT_SIZE]
|
|
85
|
+
+ f"\n\n... [truncated, {len(self.output):,} chars total]"
|
|
86
|
+
)
|
|
87
|
+
return self.model_copy(update={"output": truncated_output, "truncated": True})
|
mita/ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Terminal UI: REPL, Rich rendering, spinners, themes."""
|
mita/ui/display.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Rich terminal rendering: markdown, code blocks, diffs, tool calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.markdown import Markdown
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
from mita.tools.schema import ToolCall, ToolResult
|
|
10
|
+
from mita.ui.theme import MITA_THEME
|
|
11
|
+
|
|
12
|
+
# Module-level console with mita theme
|
|
13
|
+
_console: Console | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_console() -> Console:
|
|
17
|
+
"""Get or create the themed console."""
|
|
18
|
+
global _console # noqa: PLW0603
|
|
19
|
+
if _console is None:
|
|
20
|
+
_console = Console(theme=MITA_THEME)
|
|
21
|
+
return _console
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def display_welcome(console: Console) -> None:
|
|
25
|
+
"""Display the welcome banner."""
|
|
26
|
+
console.print(
|
|
27
|
+
Panel(
|
|
28
|
+
"[mita.prompt]mita[/mita.prompt] — local-first coding assistant\n"
|
|
29
|
+
"[mita.dim]Type your prompt, or /quit to exit.[/mita.dim]",
|
|
30
|
+
border_style="mita.info",
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def display_goodbye(console: Console) -> None:
|
|
36
|
+
"""Display the exit message."""
|
|
37
|
+
console.print("[mita.dim]Goodbye![/mita.dim]")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def display_markdown(console: Console, text: str) -> None:
|
|
41
|
+
"""Render markdown text in the terminal."""
|
|
42
|
+
if not text.strip():
|
|
43
|
+
return
|
|
44
|
+
md = Markdown(text)
|
|
45
|
+
console.print(md)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def display_streaming_token(console: Console, token: str) -> None:
|
|
49
|
+
"""Display a single streaming token (no newline)."""
|
|
50
|
+
console.print(token, end="", highlight=False)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def display_streaming_end(console: Console) -> None:
|
|
54
|
+
"""End a streaming output block."""
|
|
55
|
+
console.print() # final newline
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def display_tool_call(console: Console, tool_call: ToolCall) -> None:
|
|
59
|
+
"""Display a tool call that is about to be executed."""
|
|
60
|
+
args_str = ", ".join(f"{k}={v!r}" for k, v in tool_call.arguments.items())
|
|
61
|
+
# Truncate long args for display
|
|
62
|
+
if len(args_str) > 200:
|
|
63
|
+
args_str = args_str[:200] + "..."
|
|
64
|
+
console.print(
|
|
65
|
+
f" [mita.tool_name]{tool_call.name}[/mita.tool_name]({args_str})",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def display_tool_result(console: Console, result: ToolResult) -> None:
|
|
70
|
+
"""Display the result of a tool execution."""
|
|
71
|
+
if result.success:
|
|
72
|
+
if result.output:
|
|
73
|
+
# Show truncated output
|
|
74
|
+
output = result.output
|
|
75
|
+
if len(output) > 500:
|
|
76
|
+
output = output[:500] + "\n..."
|
|
77
|
+
console.print(f" [mita.dim]{output}[/mita.dim]")
|
|
78
|
+
else:
|
|
79
|
+
console.print(f" [mita.tool_error]Error: {result.error}[/mita.tool_error]")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def display_error(console: Console, message: str) -> None:
|
|
83
|
+
"""Display an error message."""
|
|
84
|
+
console.print(f"[mita.error]{message}[/mita.error]")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def display_warning(console: Console, message: str) -> None:
|
|
88
|
+
"""Display a warning message."""
|
|
89
|
+
console.print(f"[mita.warning]{message}[/mita.warning]")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def display_token_usage(console: Console, total_tokens: int) -> None:
|
|
93
|
+
"""Display token usage after a response (estimated)."""
|
|
94
|
+
console.print(f"[mita.token_count]({total_tokens:,} tokens)[/mita.token_count]")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def display_response_stats(
|
|
98
|
+
console: Console,
|
|
99
|
+
prompt_tokens: int,
|
|
100
|
+
completion_tokens: int,
|
|
101
|
+
total_time: float,
|
|
102
|
+
ttft: float | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Display detailed response statistics after an LLM call."""
|
|
105
|
+
parts: list[str] = []
|
|
106
|
+
|
|
107
|
+
parts.append(f"prompt: {prompt_tokens:,}")
|
|
108
|
+
parts.append(f"completion: {completion_tokens:,}")
|
|
109
|
+
|
|
110
|
+
if ttft is not None:
|
|
111
|
+
parts.append(f"ttft: {ttft:.1f}s")
|
|
112
|
+
|
|
113
|
+
parts.append(f"total: {total_time:.1f}s")
|
|
114
|
+
|
|
115
|
+
if completion_tokens > 0 and total_time > 0:
|
|
116
|
+
tps = completion_tokens / total_time
|
|
117
|
+
parts.append(f"{tps:.1f} tok/s")
|
|
118
|
+
|
|
119
|
+
console.print(f"[mita.token_count]({' · '.join(parts)})[/mita.token_count]")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def display_error_with_suggestion(console: Console, error: str, suggestion: str) -> None:
|
|
123
|
+
"""Display an error with an actionable suggestion."""
|
|
124
|
+
console.print(f"[red]{error}[/red]")
|
|
125
|
+
console.print(f"[dim] → {suggestion}[/dim]")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def prompt_user_confirm(console: Console, question: str) -> bool:
|
|
129
|
+
"""Ask the user for confirmation before a destructive action.
|
|
130
|
+
|
|
131
|
+
Returns True if the user approves, False otherwise.
|
|
132
|
+
"""
|
|
133
|
+
console.print(f"[mita.warning]{question}[/mita.warning] ", end="")
|
|
134
|
+
try:
|
|
135
|
+
response = console.input("[y/N] ")
|
|
136
|
+
return response.strip().lower() in ("y", "yes")
|
|
137
|
+
except (EOFError, KeyboardInterrupt):
|
|
138
|
+
console.print()
|
|
139
|
+
return False
|
mita/ui/repl.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Interactive REPL input loop with prompt_toolkit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Coroutine
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit import PromptSession
|
|
9
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from mita.ui.display import display_goodbye, display_welcome
|
|
13
|
+
|
|
14
|
+
# Built-in REPL commands that should NOT be treated as skill invocations.
|
|
15
|
+
_BUILTIN_COMMANDS = frozenset({"/quit", "/exit", "/q", "/clear"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def repl_loop(
|
|
19
|
+
console: Console,
|
|
20
|
+
on_input: Callable[[str], Coroutine[Any, Any, None]],
|
|
21
|
+
on_clear: Callable[[], None] | None = None,
|
|
22
|
+
skills_paths: list[str] | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Run the interactive REPL.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
console: Rich console for output.
|
|
28
|
+
on_input: Async callback called with each user input line.
|
|
29
|
+
on_clear: Callback invoked when the user types /clear.
|
|
30
|
+
skills_paths: Skill search paths for ``/`` prefix invocation.
|
|
31
|
+
"""
|
|
32
|
+
display_welcome(console)
|
|
33
|
+
|
|
34
|
+
session: PromptSession[str] = PromptSession(history=InMemoryHistory())
|
|
35
|
+
|
|
36
|
+
while True:
|
|
37
|
+
try:
|
|
38
|
+
user_input = await session.prompt_async("mita> ")
|
|
39
|
+
except (EOFError, KeyboardInterrupt):
|
|
40
|
+
display_goodbye(console)
|
|
41
|
+
break
|
|
42
|
+
|
|
43
|
+
user_input = user_input.strip()
|
|
44
|
+
if not user_input:
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
if user_input.lower() in ("/quit", "/exit", "/q"):
|
|
48
|
+
display_goodbye(console)
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
if user_input.lower() == "/clear":
|
|
52
|
+
if on_clear is not None:
|
|
53
|
+
on_clear()
|
|
54
|
+
console.print("[mita.dim]Conversation cleared.[/mita.dim]")
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
# Skill invocation: /name [args]
|
|
58
|
+
if user_input.startswith("/") and user_input.split()[0].lower() not in _BUILTIN_COMMANDS:
|
|
59
|
+
rendered = _try_render_skill(user_input, skills_paths or [], console)
|
|
60
|
+
if rendered is not None:
|
|
61
|
+
try:
|
|
62
|
+
await on_input(rendered)
|
|
63
|
+
except KeyboardInterrupt:
|
|
64
|
+
console.print("\n[Interrupted]")
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
await on_input(user_input)
|
|
69
|
+
except KeyboardInterrupt:
|
|
70
|
+
console.print("\n[Interrupted]")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _try_render_skill(user_input: str, skills_paths: list[str], console: Console) -> str | None:
|
|
74
|
+
"""Attempt to find and render a skill from user input.
|
|
75
|
+
|
|
76
|
+
Returns the rendered prompt string, or ``None`` if no matching skill was found.
|
|
77
|
+
"""
|
|
78
|
+
from mita.skills.executor import render_skill
|
|
79
|
+
from mita.skills.loader import discover_skills, find_skill
|
|
80
|
+
|
|
81
|
+
skills = discover_skills(skills_paths)
|
|
82
|
+
skill = find_skill(skills, user_input)
|
|
83
|
+
if skill is None:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
rendered = render_skill(skill, user_input)
|
|
87
|
+
console.print(f"[dim]Running skill: {skill.frontmatter.name}[/dim]")
|
|
88
|
+
return rendered
|
mita/ui/spinner.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Thinking/loading indicators for the terminal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
|
|
9
|
+
from rich.console import Console, RenderableType
|
|
10
|
+
from rich.live import Live
|
|
11
|
+
from rich.spinner import Spinner as RichSpinner
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _TimedSpinner:
|
|
17
|
+
"""A spinner that shows elapsed time alongside the message."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str = "Thinking...") -> None:
|
|
20
|
+
self._message = message
|
|
21
|
+
self._start = time.monotonic()
|
|
22
|
+
self._spinner = RichSpinner("dots")
|
|
23
|
+
|
|
24
|
+
def __rich__(self) -> RenderableType:
|
|
25
|
+
elapsed = time.monotonic() - self._start
|
|
26
|
+
table = Table.grid(padding=(0, 1))
|
|
27
|
+
table.add_row(
|
|
28
|
+
self._spinner,
|
|
29
|
+
Text(self._message, style="mita.dim"),
|
|
30
|
+
Text(f"({elapsed:.0f}s)", style="dim"),
|
|
31
|
+
)
|
|
32
|
+
return table
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@contextmanager
|
|
36
|
+
def thinking_spinner(
|
|
37
|
+
console: Console,
|
|
38
|
+
message: str = "Thinking...",
|
|
39
|
+
) -> Generator[Live, None, None]:
|
|
40
|
+
"""Display a spinner with elapsed time while the agent is thinking.
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
with thinking_spinner(console):
|
|
44
|
+
await long_operation()
|
|
45
|
+
"""
|
|
46
|
+
spinner = _TimedSpinner(message)
|
|
47
|
+
with Live(spinner, console=console, transient=True, refresh_per_second=4) as live:
|
|
48
|
+
yield live
|
mita/ui/theme.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Color scheme and style constants for terminal UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.style import Style
|
|
6
|
+
from rich.theme import Theme
|
|
7
|
+
|
|
8
|
+
# Style constants
|
|
9
|
+
STYLES = {
|
|
10
|
+
"mita.prompt": Style(color="cyan", bold=True),
|
|
11
|
+
"mita.assistant": Style(color="white"),
|
|
12
|
+
"mita.tool_name": Style(color="yellow", bold=True),
|
|
13
|
+
"mita.tool_result": Style(color="green"),
|
|
14
|
+
"mita.tool_error": Style(color="red"),
|
|
15
|
+
"mita.info": Style(color="blue"),
|
|
16
|
+
"mita.warning": Style(color="yellow"),
|
|
17
|
+
"mita.error": Style(color="red", bold=True),
|
|
18
|
+
"mita.dim": Style(dim=True),
|
|
19
|
+
"mita.success": Style(color="green", bold=True),
|
|
20
|
+
"mita.token_count": Style(color="cyan", dim=True),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
MITA_THEME = Theme(STYLES)
|