monkeybot-cli 0.2.1__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.
- monkeybot_cli/__init__.py +3 -0
- monkeybot_cli/chat_renderer.py +87 -0
- monkeybot_cli/chat_session.py +911 -0
- monkeybot_cli/chat_status_bar.py +205 -0
- monkeybot_cli/chat_theme.py +91 -0
- monkeybot_cli/chat_tool_display.py +334 -0
- monkeybot_cli/chat_tui.py +1491 -0
- monkeybot_cli/chat_tui_widgets.py +996 -0
- monkeybot_cli/commands/__init__.py +1 -0
- monkeybot_cli/commands/chat.py +817 -0
- monkeybot_cli/commands/doctor.py +293 -0
- monkeybot_cli/commands/loop.py +207 -0
- monkeybot_cli/commands/new.py +207 -0
- monkeybot_cli/commands/run_cmd.py +41 -0
- monkeybot_cli/commands/talk.py +102 -0
- monkeybot_cli/commands/validate.py +385 -0
- monkeybot_cli/compat.py +7 -0
- monkeybot_cli/config_resolve.py +55 -0
- monkeybot_cli/exit_commands.py +13 -0
- monkeybot_cli/extras_catalog.py +95 -0
- monkeybot_cli/gateway_health.py +34 -0
- monkeybot_cli/main.py +38 -0
- monkeybot_cli/opensandbox_lifecycle.py +314 -0
- monkeybot_cli/output.py +110 -0
- monkeybot_cli/providers.py +112 -0
- monkeybot_cli/realtime/__init__.py +13 -0
- monkeybot_cli/realtime/audio_io.py +147 -0
- monkeybot_cli/realtime/client.py +17 -0
- monkeybot_cli/realtime/gateway_manager.py +142 -0
- monkeybot_cli/realtime/push_to_talk.py +128 -0
- monkeybot_cli/realtime/session.py +256 -0
- monkeybot_cli/realtime/session_controller.py +501 -0
- monkeybot_cli/realtime/talk_ui.py +243 -0
- monkeybot_cli/realtime/wire_encode.py +39 -0
- monkeybot_cli/runtime_python.py +91 -0
- monkeybot_cli/scaffold.py +287 -0
- monkeybot_cli/scaffold_defaults/AGENT.md +56 -0
- monkeybot_cli/scaffold_defaults/__init__.py +1 -0
- monkeybot_cli/scaffold_defaults/command_allowlist.yaml +57 -0
- monkeybot_cli/scaffold_defaults/env.example +35 -0
- monkeybot_cli/scaffold_defaults/mcp.json +49 -0
- monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +191 -0
- monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +57 -0
- monkeybot_cli/scaffold_defaults/permissions.yaml +32 -0
- monkeybot_cli/scaffold_defaults/setup-workspace.sh +24 -0
- monkeybot_cli/session_controller.py +7 -0
- monkeybot_cli/terminal_markdown.py +48 -0
- monkeybot_cli-0.2.1.dist-info/METADATA +10 -0
- monkeybot_cli-0.2.1.dist-info/RECORD +51 -0
- monkeybot_cli-0.2.1.dist-info/WHEEL +4 -0
- monkeybot_cli-0.2.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Context-window ring formatting for ``monkeybot chat``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
_DIM = "\x1b[2m"
|
|
8
|
+
_GREEN = "\x1b[32m"
|
|
9
|
+
_YELLOW = "\x1b[33m"
|
|
10
|
+
_RED = "\x1b[31m"
|
|
11
|
+
_RESET = "\x1b[0m"
|
|
12
|
+
|
|
13
|
+
DEFAULT_CONTEXT_WINDOW = 200_000
|
|
14
|
+
_RING_GLYPHS = ("○", "◔", "◑", "◕", "●")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class SessionUsageView:
|
|
19
|
+
input_tokens: int = 0
|
|
20
|
+
output_tokens: int = 0
|
|
21
|
+
cost_usd: float = 0.0
|
|
22
|
+
last_prompt_tokens: int = 0
|
|
23
|
+
estimated_prompt_tokens: int = 0
|
|
24
|
+
summarization_threshold_tokens: int = 0
|
|
25
|
+
context_window_tokens: int = DEFAULT_CONTEXT_WINDOW
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_usage_response(data: dict[str, object]) -> SessionUsageView:
|
|
29
|
+
def _int(key: str, default: int = 0) -> int:
|
|
30
|
+
val = data.get(key, default)
|
|
31
|
+
return int(val) if isinstance(val, (int, float)) else default
|
|
32
|
+
|
|
33
|
+
def _float(key: str, default: float = 0.0) -> float:
|
|
34
|
+
val = data.get(key, default)
|
|
35
|
+
return float(val) if isinstance(val, (int, float)) else default
|
|
36
|
+
|
|
37
|
+
cap = _int("context_window_tokens", DEFAULT_CONTEXT_WINDOW)
|
|
38
|
+
thresh = _int("summarization_threshold_tokens", 0)
|
|
39
|
+
if thresh <= 0:
|
|
40
|
+
thresh = max(1, int(cap * 0.85))
|
|
41
|
+
return SessionUsageView(
|
|
42
|
+
input_tokens=_int("input_tokens"),
|
|
43
|
+
output_tokens=_int("output_tokens"),
|
|
44
|
+
cost_usd=_float("cost_usd"),
|
|
45
|
+
last_prompt_tokens=_int("last_prompt_tokens"),
|
|
46
|
+
estimated_prompt_tokens=_int("estimated_prompt_tokens"),
|
|
47
|
+
summarization_threshold_tokens=thresh,
|
|
48
|
+
context_window_tokens=max(1, cap),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _ring_glyph(pct_used: int) -> str:
|
|
53
|
+
idx = min(len(_RING_GLYPHS) - 1, max(0, (pct_used + 12) // 25))
|
|
54
|
+
return _RING_GLYPHS[idx]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ring_color(ring_numerator: int, threshold: int) -> str:
|
|
58
|
+
if ring_numerator >= threshold:
|
|
59
|
+
return _RED
|
|
60
|
+
if ring_numerator >= int(threshold * 0.75):
|
|
61
|
+
return _YELLOW
|
|
62
|
+
return _GREEN
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _ring_markup_color(ring_numerator: int, threshold: int) -> str:
|
|
66
|
+
"""Rich markup color name for the same thresholds as :func:`_ring_color`."""
|
|
67
|
+
if ring_numerator >= threshold:
|
|
68
|
+
return "red"
|
|
69
|
+
if ring_numerator >= int(threshold * 0.75):
|
|
70
|
+
return "yellow"
|
|
71
|
+
return "green"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ring_parts(
|
|
75
|
+
*,
|
|
76
|
+
estimated_prompt_tokens: int,
|
|
77
|
+
last_prompt_tokens: int,
|
|
78
|
+
context_window_tokens: int,
|
|
79
|
+
summarization_threshold_tokens: int,
|
|
80
|
+
) -> tuple[str, int, int, int]:
|
|
81
|
+
"""Return ``(glyph, pct_used, ring_numerator, thresh)``."""
|
|
82
|
+
cap = max(1, context_window_tokens)
|
|
83
|
+
ring_numerator = estimated_prompt_tokens if estimated_prompt_tokens > 0 else last_prompt_tokens
|
|
84
|
+
thresh = max(
|
|
85
|
+
1,
|
|
86
|
+
summarization_threshold_tokens
|
|
87
|
+
if summarization_threshold_tokens > 0
|
|
88
|
+
else int(cap * 0.85),
|
|
89
|
+
)
|
|
90
|
+
pct_used = min(100, max(0, round((ring_numerator / cap) * 100)))
|
|
91
|
+
return _ring_glyph(pct_used), pct_used, ring_numerator, thresh
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def format_context_ring(
|
|
95
|
+
*,
|
|
96
|
+
estimated_prompt_tokens: int,
|
|
97
|
+
last_prompt_tokens: int,
|
|
98
|
+
context_window_tokens: int,
|
|
99
|
+
summarization_threshold_tokens: int,
|
|
100
|
+
) -> str:
|
|
101
|
+
glyph, pct_used, ring_numerator, thresh = _ring_parts(
|
|
102
|
+
estimated_prompt_tokens=estimated_prompt_tokens,
|
|
103
|
+
last_prompt_tokens=last_prompt_tokens,
|
|
104
|
+
context_window_tokens=context_window_tokens,
|
|
105
|
+
summarization_threshold_tokens=summarization_threshold_tokens,
|
|
106
|
+
)
|
|
107
|
+
color = _ring_color(ring_numerator, thresh)
|
|
108
|
+
return f"{color}{glyph} {pct_used}%{_RESET}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def format_context_ring_plain(usage: SessionUsageView | None) -> str:
|
|
112
|
+
"""Ring text without ANSI (for plain / non-color contexts)."""
|
|
113
|
+
if usage is None:
|
|
114
|
+
return "○ 0%"
|
|
115
|
+
glyph, pct_used, _, _ = _ring_parts(
|
|
116
|
+
estimated_prompt_tokens=usage.estimated_prompt_tokens,
|
|
117
|
+
last_prompt_tokens=usage.last_prompt_tokens,
|
|
118
|
+
context_window_tokens=usage.context_window_tokens,
|
|
119
|
+
summarization_threshold_tokens=usage.summarization_threshold_tokens,
|
|
120
|
+
)
|
|
121
|
+
return f"{glyph} {pct_used}%"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def format_context_ring_markup(usage: SessionUsageView | None) -> str:
|
|
125
|
+
"""Ring text with Rich markup colors for the Textual status bar."""
|
|
126
|
+
if usage is None:
|
|
127
|
+
return "[dim]○ 0%[/]"
|
|
128
|
+
glyph, pct_used, ring_numerator, thresh = _ring_parts(
|
|
129
|
+
estimated_prompt_tokens=usage.estimated_prompt_tokens,
|
|
130
|
+
last_prompt_tokens=usage.last_prompt_tokens,
|
|
131
|
+
context_window_tokens=usage.context_window_tokens,
|
|
132
|
+
summarization_threshold_tokens=usage.summarization_threshold_tokens,
|
|
133
|
+
)
|
|
134
|
+
color = _ring_markup_color(ring_numerator, thresh)
|
|
135
|
+
return f"[{color}]{glyph} {pct_used}%[/]"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def format_status_line(usage: SessionUsageView | None, *, width: int) -> str:
|
|
139
|
+
if usage is None:
|
|
140
|
+
ring = f"{_DIM}○ 0%{_RESET}"
|
|
141
|
+
else:
|
|
142
|
+
ring = format_context_ring(
|
|
143
|
+
estimated_prompt_tokens=usage.estimated_prompt_tokens,
|
|
144
|
+
last_prompt_tokens=usage.last_prompt_tokens,
|
|
145
|
+
context_window_tokens=usage.context_window_tokens,
|
|
146
|
+
summarization_threshold_tokens=usage.summarization_threshold_tokens,
|
|
147
|
+
)
|
|
148
|
+
line = f" {ring}"
|
|
149
|
+
visible_budget = max(20, width - 1)
|
|
150
|
+
return line[:visible_budget]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class UsageStore:
|
|
154
|
+
"""Holds session usage for footer / plain status (no DECSTBM)."""
|
|
155
|
+
|
|
156
|
+
def __init__(self) -> None:
|
|
157
|
+
self._usage: SessionUsageView | None = None
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def usage(self) -> SessionUsageView | None:
|
|
161
|
+
return self._usage
|
|
162
|
+
|
|
163
|
+
def update(self, usage: SessionUsageView) -> None:
|
|
164
|
+
self._usage = usage
|
|
165
|
+
|
|
166
|
+
def update_context_hint(
|
|
167
|
+
self,
|
|
168
|
+
*,
|
|
169
|
+
estimated_prompt_tokens: int,
|
|
170
|
+
context_window_tokens: int = 0,
|
|
171
|
+
summarization_threshold_tokens: int = 0,
|
|
172
|
+
) -> None:
|
|
173
|
+
base = self._usage or SessionUsageView()
|
|
174
|
+
cap = context_window_tokens if context_window_tokens > 0 else base.context_window_tokens
|
|
175
|
+
thresh = (
|
|
176
|
+
summarization_threshold_tokens
|
|
177
|
+
if summarization_threshold_tokens > 0
|
|
178
|
+
else base.summarization_threshold_tokens
|
|
179
|
+
)
|
|
180
|
+
self._usage = SessionUsageView(
|
|
181
|
+
input_tokens=base.input_tokens,
|
|
182
|
+
output_tokens=base.output_tokens,
|
|
183
|
+
cost_usd=base.cost_usd,
|
|
184
|
+
last_prompt_tokens=base.last_prompt_tokens,
|
|
185
|
+
estimated_prompt_tokens=estimated_prompt_tokens,
|
|
186
|
+
summarization_threshold_tokens=thresh,
|
|
187
|
+
context_window_tokens=cap,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def format_voice_status(state: str, level_db: float | None = None) -> str:
|
|
192
|
+
"""Rich markup voice indicator for the chat status bar."""
|
|
193
|
+
labels = {
|
|
194
|
+
"listening": "[green]● listening[/]",
|
|
195
|
+
"muted": "[dim]◌ muted[/]",
|
|
196
|
+
"ptt_held": "[cyan]⏺ PTT[/]",
|
|
197
|
+
"speaking": "[magenta]▶ speaking[/]",
|
|
198
|
+
}
|
|
199
|
+
base = labels.get(state, f"[dim]{state}[/]")
|
|
200
|
+
if level_db is None:
|
|
201
|
+
return base
|
|
202
|
+
# Map -40..0 dBFS to 0..4 bars
|
|
203
|
+
bars = max(0, min(4, int((level_db + 40) / 10)))
|
|
204
|
+
meter = "▁▂▃▄▅"[bars] if bars < 5 else "▅"
|
|
205
|
+
return f"{base} [dim]{meter}[/]"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Monkeybot chat TUI themes (dark / light)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from textual.theme import Theme
|
|
8
|
+
|
|
9
|
+
THEME_DARK = "monkeybot-dark"
|
|
10
|
+
THEME_LIGHT = "monkeybot-light"
|
|
11
|
+
|
|
12
|
+
_DARK_VARS = {
|
|
13
|
+
"muted": "#71717a",
|
|
14
|
+
"disabled": "#52525b",
|
|
15
|
+
"border": "#2a2f3a",
|
|
16
|
+
"scrollbar": "#3d4450",
|
|
17
|
+
"hitl-surface": "#1a1810",
|
|
18
|
+
"hitl-text": "#fde68a",
|
|
19
|
+
"assistant": "#d4d4d8",
|
|
20
|
+
"tool-error": "#a16207",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_LIGHT_VARS = {
|
|
24
|
+
"muted": "#52525b",
|
|
25
|
+
"disabled": "#a1a1aa",
|
|
26
|
+
"border": "#d4d4d8",
|
|
27
|
+
"scrollbar": "#a1a1aa",
|
|
28
|
+
"hitl-surface": "#fef9c3",
|
|
29
|
+
"hitl-text": "#854d0e",
|
|
30
|
+
"assistant": "#3f3f46",
|
|
31
|
+
"tool-error": "#a16207",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
MONKEYBOT_DARK = Theme(
|
|
35
|
+
name=THEME_DARK,
|
|
36
|
+
primary="#5b7cfa",
|
|
37
|
+
secondary="#a1a1aa",
|
|
38
|
+
accent="#5b7cfa",
|
|
39
|
+
foreground="#e4e4e7",
|
|
40
|
+
background="#0f1115",
|
|
41
|
+
surface="#151820",
|
|
42
|
+
panel="#151820",
|
|
43
|
+
success="#22c55e",
|
|
44
|
+
warning="#eab308",
|
|
45
|
+
error="#f87171",
|
|
46
|
+
dark=True,
|
|
47
|
+
variables=_DARK_VARS,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
MONKEYBOT_LIGHT = Theme(
|
|
51
|
+
name=THEME_LIGHT,
|
|
52
|
+
primary="#4f6ef7",
|
|
53
|
+
secondary="#52525b",
|
|
54
|
+
accent="#4f6ef7",
|
|
55
|
+
foreground="#18181b",
|
|
56
|
+
background="#f4f4f5",
|
|
57
|
+
surface="#ffffff",
|
|
58
|
+
panel="#ffffff",
|
|
59
|
+
success="#16a34a",
|
|
60
|
+
warning="#ca8a04",
|
|
61
|
+
error="#dc2626",
|
|
62
|
+
dark=False,
|
|
63
|
+
variables=_LIGHT_VARS,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _colorfgbg_prefers_light() -> bool | None:
|
|
68
|
+
"""Return True/False if COLORFGBG indicates light/dark bg, else None."""
|
|
69
|
+
raw = os.environ.get("COLORFGBG", "").strip()
|
|
70
|
+
if not raw or ";" not in raw:
|
|
71
|
+
return None
|
|
72
|
+
# Format: foreground;background (decimal ANSI color indices).
|
|
73
|
+
# Light backgrounds are typically 7/15 (white) or high-intensity.
|
|
74
|
+
try:
|
|
75
|
+
bg = int(raw.rsplit(";", 1)[-1])
|
|
76
|
+
except ValueError:
|
|
77
|
+
return None
|
|
78
|
+
return bg in {7, 15}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_theme_name(choice: str = "auto") -> str:
|
|
82
|
+
"""Map ``auto|dark|light`` to a registered theme name."""
|
|
83
|
+
normalized = (choice or "auto").strip().lower()
|
|
84
|
+
if normalized == "light":
|
|
85
|
+
return THEME_LIGHT
|
|
86
|
+
if normalized == "dark":
|
|
87
|
+
return THEME_DARK
|
|
88
|
+
prefers_light = _colorfgbg_prefers_light()
|
|
89
|
+
if prefers_light is True:
|
|
90
|
+
return THEME_LIGHT
|
|
91
|
+
return THEME_DARK
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Tool activity display helpers for ``monkeybot chat``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_SUBAGENT_HINT_MAX = 60
|
|
8
|
+
_TITLE_HINT_MAX = 72
|
|
9
|
+
_DETAIL_MAX = 8000
|
|
10
|
+
_SHELL_TAIL_LINES = 40
|
|
11
|
+
|
|
12
|
+
_TOOL_KIND: dict[str, str] = {
|
|
13
|
+
"run_command": "Shell",
|
|
14
|
+
"execute": "Shell",
|
|
15
|
+
"shell": "Shell",
|
|
16
|
+
"bash": "Shell",
|
|
17
|
+
"read_file": "Read",
|
|
18
|
+
"read": "Read",
|
|
19
|
+
"write_file": "Write",
|
|
20
|
+
"write": "Write",
|
|
21
|
+
"edit_file": "Edit",
|
|
22
|
+
"apply_patch": "Edit",
|
|
23
|
+
"str_replace": "Edit",
|
|
24
|
+
"grep": "Search",
|
|
25
|
+
"search": "Search",
|
|
26
|
+
"web_search": "Search",
|
|
27
|
+
"glob": "Glob",
|
|
28
|
+
"list_dir": "List",
|
|
29
|
+
"list_directory": "List",
|
|
30
|
+
"task": "Task",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_SHELL_TOOLS = frozenset({"run_command", "execute", "shell", "bash"})
|
|
34
|
+
_READ_TOOLS = frozenset({"read_file", "read"})
|
|
35
|
+
_EDIT_TOOLS = frozenset(
|
|
36
|
+
{"write_file", "write", "edit_file", "apply_patch", "str_replace"}
|
|
37
|
+
)
|
|
38
|
+
_PATH_TOOLS = _READ_TOOLS | _EDIT_TOOLS
|
|
39
|
+
_SEARCH_TOOLS = frozenset({"grep", "search", "web_search"})
|
|
40
|
+
_HINT_KEYS = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"argv",
|
|
43
|
+
"command",
|
|
44
|
+
"args",
|
|
45
|
+
"arguments",
|
|
46
|
+
"shell",
|
|
47
|
+
"script",
|
|
48
|
+
"path",
|
|
49
|
+
"query",
|
|
50
|
+
"url",
|
|
51
|
+
"task",
|
|
52
|
+
"instructions",
|
|
53
|
+
"prompt",
|
|
54
|
+
"objective",
|
|
55
|
+
"subagent_type",
|
|
56
|
+
"type",
|
|
57
|
+
"persona",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
_LANG_BY_SUFFIX: dict[str, str] = {
|
|
62
|
+
".py": "python",
|
|
63
|
+
".ts": "typescript",
|
|
64
|
+
".tsx": "tsx",
|
|
65
|
+
".js": "javascript",
|
|
66
|
+
".jsx": "jsx",
|
|
67
|
+
".json": "json",
|
|
68
|
+
".md": "markdown",
|
|
69
|
+
".yaml": "yaml",
|
|
70
|
+
".yml": "yaml",
|
|
71
|
+
".toml": "toml",
|
|
72
|
+
".rs": "rust",
|
|
73
|
+
".go": "go",
|
|
74
|
+
".sh": "bash",
|
|
75
|
+
".css": "css",
|
|
76
|
+
".html": "html",
|
|
77
|
+
".sql": "sql",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def collapse_hint(text: str) -> str:
|
|
82
|
+
return " ".join(text.split())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def truncate_subagent_hint(text: str) -> str:
|
|
86
|
+
collapsed = collapse_hint(text)
|
|
87
|
+
if len(collapsed) <= _SUBAGENT_HINT_MAX:
|
|
88
|
+
return collapsed
|
|
89
|
+
return collapsed[:_SUBAGENT_HINT_MAX] + "…"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def tool_hint(args: dict[str, object]) -> str:
|
|
93
|
+
"""Summary of tool args for status lines."""
|
|
94
|
+
argv = args.get("argv")
|
|
95
|
+
if isinstance(argv, list) and argv:
|
|
96
|
+
return collapse_hint(" ".join(str(x) for x in argv))
|
|
97
|
+
|
|
98
|
+
cmd = args.get("command")
|
|
99
|
+
if isinstance(cmd, str) and cmd.strip():
|
|
100
|
+
extra = args.get("args")
|
|
101
|
+
if extra is None:
|
|
102
|
+
extra = args.get("arguments")
|
|
103
|
+
if isinstance(extra, list) and extra:
|
|
104
|
+
line = " ".join([cmd.strip(), *[str(x) for x in extra]])
|
|
105
|
+
else:
|
|
106
|
+
line = cmd.strip()
|
|
107
|
+
return collapse_hint(line)
|
|
108
|
+
|
|
109
|
+
for key in ("shell", "script", "path", "query", "url"):
|
|
110
|
+
val = args.get(key)
|
|
111
|
+
if isinstance(val, str) and val.strip():
|
|
112
|
+
return collapse_hint(val.strip())
|
|
113
|
+
keys = list(args.keys())
|
|
114
|
+
if len(keys) == 1:
|
|
115
|
+
key = keys[0]
|
|
116
|
+
val = args[key]
|
|
117
|
+
if isinstance(val, (str, int, bool, float)):
|
|
118
|
+
return collapse_hint(f"{key}: {val}")
|
|
119
|
+
if keys:
|
|
120
|
+
return f"{len(keys)} arg{'s' if len(keys) != 1 else ''}"
|
|
121
|
+
return ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def task_subagent_label(args: dict[str, object]) -> str:
|
|
125
|
+
for key in ("subagent_type", "type", "persona"):
|
|
126
|
+
val = args.get(key)
|
|
127
|
+
if isinstance(val, str) and val.strip():
|
|
128
|
+
return f"subagent:{val.strip()}"
|
|
129
|
+
return "subagent"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def task_hint(args: dict[str, object]) -> str:
|
|
133
|
+
for key in ("task", "instructions", "prompt", "objective"):
|
|
134
|
+
val = args.get(key)
|
|
135
|
+
if isinstance(val, str) and val.strip():
|
|
136
|
+
return truncate_subagent_hint(val.strip())
|
|
137
|
+
return ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def tool_kind_label(tool: str) -> str:
|
|
141
|
+
key = tool.strip().lower().replace("-", "_")
|
|
142
|
+
if key in _TOOL_KIND:
|
|
143
|
+
return _TOOL_KIND[key]
|
|
144
|
+
cleaned = tool.strip().replace("_", " ").replace("-", " ")
|
|
145
|
+
return cleaned.title() if cleaned else "Tool"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resolve_tool_hint(tool: str, label: str, args: dict[str, object]) -> str:
|
|
149
|
+
"""Shared hint used by collapsed titles and plain-path display."""
|
|
150
|
+
if tool == "task":
|
|
151
|
+
hint = task_hint(args)
|
|
152
|
+
if not hint and label.strip() and label.strip() != tool:
|
|
153
|
+
return truncate_subagent_hint(label.strip())
|
|
154
|
+
return hint
|
|
155
|
+
hint = tool_hint(args)
|
|
156
|
+
if not hint and label.strip() and label.strip() != tool:
|
|
157
|
+
return collapse_hint(label.strip())
|
|
158
|
+
return hint
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def tool_collapsed_title(tool: str, label: str, args: dict[str, object]) -> str:
|
|
162
|
+
"""Short Cursor/Claude-style title: ``Shell git status``."""
|
|
163
|
+
hint = resolve_tool_hint(tool, label, args)
|
|
164
|
+
if tool == "task":
|
|
165
|
+
base = task_subagent_label(args)
|
|
166
|
+
kind = "Task" if base == "subagent" else base
|
|
167
|
+
else:
|
|
168
|
+
kind = tool_kind_label(tool)
|
|
169
|
+
if hint and len(hint) > _TITLE_HINT_MAX:
|
|
170
|
+
hint = hint[:_TITLE_HINT_MAX] + "…"
|
|
171
|
+
return f"{kind} {hint}" if hint else kind
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def tool_display(tool: str, label: str, args: dict[str, object]) -> str:
|
|
175
|
+
"""Plain-path activity line: ``run_command — echo hi``."""
|
|
176
|
+
hint = resolve_tool_hint(tool, label, args)
|
|
177
|
+
if tool == "task":
|
|
178
|
+
base = task_subagent_label(args)
|
|
179
|
+
return base + (f" — {hint}" if hint else "")
|
|
180
|
+
return tool + (f" — {hint}" if hint else "")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def tool_spinner_prefix(tool: str, label: str, args: dict[str, object]) -> str:
|
|
184
|
+
if tool == "task":
|
|
185
|
+
hint = resolve_tool_hint(tool, label, args)
|
|
186
|
+
base = "spawning " + task_subagent_label(args)
|
|
187
|
+
return base + (f" — {hint}" if hint else "")
|
|
188
|
+
return tool_display(tool, label, args)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _tool_key(tool: str) -> str:
|
|
192
|
+
return tool.strip().lower().replace("-", "_")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _primary_section_label(tool: str) -> str:
|
|
196
|
+
key = _tool_key(tool)
|
|
197
|
+
if key in _SHELL_TOOLS:
|
|
198
|
+
return "Command"
|
|
199
|
+
if key in _PATH_TOOLS:
|
|
200
|
+
return "Path"
|
|
201
|
+
if key in _SEARCH_TOOLS:
|
|
202
|
+
return "Query"
|
|
203
|
+
if key == "task":
|
|
204
|
+
return "Task"
|
|
205
|
+
return "Input"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _path_from_args(args: dict[str, object]) -> str:
|
|
209
|
+
path = args.get("path")
|
|
210
|
+
return path.strip() if isinstance(path, str) else ""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _language_for_path(path: str) -> str:
|
|
214
|
+
if not path:
|
|
215
|
+
return ""
|
|
216
|
+
return _LANG_BY_SUFFIX.get(Path(path).suffix.lower(), "")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _looks_like_diff(text: str) -> bool:
|
|
220
|
+
has_hunk = False
|
|
221
|
+
has_file = False
|
|
222
|
+
for line in text.splitlines()[:40]:
|
|
223
|
+
if line.startswith("@@"):
|
|
224
|
+
has_hunk = True
|
|
225
|
+
if line.startswith("--- ") or line.startswith("+++ "):
|
|
226
|
+
has_file = True
|
|
227
|
+
if has_hunk and has_file:
|
|
228
|
+
return True
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _truncate(text: str, max_chars: int) -> str:
|
|
233
|
+
if len(text) <= max_chars:
|
|
234
|
+
return text
|
|
235
|
+
return text[:max_chars] + "\n…"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _tail_lines(text: str, n: int = _SHELL_TAIL_LINES) -> str:
|
|
239
|
+
lines = text.splitlines()
|
|
240
|
+
if len(lines) <= n:
|
|
241
|
+
return text
|
|
242
|
+
omitted = len(lines) - n
|
|
243
|
+
return f"… ({omitted} earlier lines)\n" + "\n".join(lines[-n:])
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _fence(body: str, lang: str = "") -> str:
|
|
247
|
+
fence = "```"
|
|
248
|
+
while fence in body:
|
|
249
|
+
fence += "`"
|
|
250
|
+
return f"{fence}{lang}\n{body.rstrip()}\n{fence}"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _format_search_result(result: str, max_chars: int) -> str:
|
|
254
|
+
lines: list[str] = []
|
|
255
|
+
for raw in result.splitlines():
|
|
256
|
+
line = raw.strip()
|
|
257
|
+
if not line:
|
|
258
|
+
continue
|
|
259
|
+
if line.startswith("http://") or line.startswith("https://"):
|
|
260
|
+
lines.append(f"- <{line}>")
|
|
261
|
+
elif "://" in line and " " in line:
|
|
262
|
+
# "Title https://..." style
|
|
263
|
+
parts = line.rsplit(None, 1)
|
|
264
|
+
if len(parts) == 2 and parts[1].startswith("http"):
|
|
265
|
+
lines.append(f"- [{parts[0]}]({parts[1]})")
|
|
266
|
+
else:
|
|
267
|
+
lines.append(f"- `{line}`")
|
|
268
|
+
else:
|
|
269
|
+
lines.append(f"- `{line}`")
|
|
270
|
+
if sum(len(x) for x in lines) > max_chars:
|
|
271
|
+
lines.append("- …")
|
|
272
|
+
break
|
|
273
|
+
return "\n".join(lines) if lines else "_empty_"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _extras_markdown(args: dict[str, object]) -> list[str]:
|
|
277
|
+
extras = [
|
|
278
|
+
f"- `{key}`: `{val}`"
|
|
279
|
+
if isinstance(val, (str, int, bool, float)) or val is None
|
|
280
|
+
else f"- `{key}`: `{val!r}`"[:200]
|
|
281
|
+
for key, val in args.items()
|
|
282
|
+
if key not in _HINT_KEYS
|
|
283
|
+
]
|
|
284
|
+
if not extras:
|
|
285
|
+
return []
|
|
286
|
+
return ["", "**Args**", *extras]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def format_tool_expand_body(
|
|
290
|
+
tool: str,
|
|
291
|
+
args: dict[str, object],
|
|
292
|
+
*,
|
|
293
|
+
result: str = "",
|
|
294
|
+
error: object = None,
|
|
295
|
+
max_chars: int = _DETAIL_MAX,
|
|
296
|
+
) -> str:
|
|
297
|
+
"""Human-readable expand body as markdown (not raw JSON dump)."""
|
|
298
|
+
key = _tool_key(tool)
|
|
299
|
+
parts: list[str] = []
|
|
300
|
+
primary = resolve_tool_hint(tool, tool, args)
|
|
301
|
+
if primary:
|
|
302
|
+
parts.append(f"**{_primary_section_label(tool)}**")
|
|
303
|
+
parts.append(f"`{primary}`")
|
|
304
|
+
|
|
305
|
+
parts.extend(_extras_markdown(args))
|
|
306
|
+
|
|
307
|
+
if error:
|
|
308
|
+
if parts:
|
|
309
|
+
parts.append("")
|
|
310
|
+
parts.append("**Error**")
|
|
311
|
+
parts.append(f"```\n{error}\n```")
|
|
312
|
+
return "\n".join(parts)
|
|
313
|
+
|
|
314
|
+
if not result:
|
|
315
|
+
return "\n".join(parts) if parts else "(no details)"
|
|
316
|
+
|
|
317
|
+
if parts:
|
|
318
|
+
parts.append("")
|
|
319
|
+
parts.append("**Result**")
|
|
320
|
+
|
|
321
|
+
if key in _READ_TOOLS:
|
|
322
|
+
lang = _language_for_path(_path_from_args(args))
|
|
323
|
+
parts.append(_fence(_truncate(result, max_chars), lang))
|
|
324
|
+
elif key in _EDIT_TOOLS and _looks_like_diff(result):
|
|
325
|
+
parts.append(_fence(_truncate(result, max_chars), "diff"))
|
|
326
|
+
elif key in _SHELL_TOOLS:
|
|
327
|
+
parts.append(_fence(_truncate(_tail_lines(result), max_chars), ""))
|
|
328
|
+
elif key in _SEARCH_TOOLS:
|
|
329
|
+
parts.append(_format_search_result(result, max_chars))
|
|
330
|
+
else:
|
|
331
|
+
text = _truncate(result, max_chars)
|
|
332
|
+
parts.append(_fence(text, ""))
|
|
333
|
+
|
|
334
|
+
return "\n".join(parts)
|