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.
Files changed (51) hide show
  1. monkeybot_cli/__init__.py +3 -0
  2. monkeybot_cli/chat_renderer.py +87 -0
  3. monkeybot_cli/chat_session.py +911 -0
  4. monkeybot_cli/chat_status_bar.py +205 -0
  5. monkeybot_cli/chat_theme.py +91 -0
  6. monkeybot_cli/chat_tool_display.py +334 -0
  7. monkeybot_cli/chat_tui.py +1491 -0
  8. monkeybot_cli/chat_tui_widgets.py +996 -0
  9. monkeybot_cli/commands/__init__.py +1 -0
  10. monkeybot_cli/commands/chat.py +817 -0
  11. monkeybot_cli/commands/doctor.py +293 -0
  12. monkeybot_cli/commands/loop.py +207 -0
  13. monkeybot_cli/commands/new.py +207 -0
  14. monkeybot_cli/commands/run_cmd.py +41 -0
  15. monkeybot_cli/commands/talk.py +102 -0
  16. monkeybot_cli/commands/validate.py +385 -0
  17. monkeybot_cli/compat.py +7 -0
  18. monkeybot_cli/config_resolve.py +55 -0
  19. monkeybot_cli/exit_commands.py +13 -0
  20. monkeybot_cli/extras_catalog.py +95 -0
  21. monkeybot_cli/gateway_health.py +34 -0
  22. monkeybot_cli/main.py +38 -0
  23. monkeybot_cli/opensandbox_lifecycle.py +314 -0
  24. monkeybot_cli/output.py +110 -0
  25. monkeybot_cli/providers.py +112 -0
  26. monkeybot_cli/realtime/__init__.py +13 -0
  27. monkeybot_cli/realtime/audio_io.py +147 -0
  28. monkeybot_cli/realtime/client.py +17 -0
  29. monkeybot_cli/realtime/gateway_manager.py +142 -0
  30. monkeybot_cli/realtime/push_to_talk.py +128 -0
  31. monkeybot_cli/realtime/session.py +256 -0
  32. monkeybot_cli/realtime/session_controller.py +501 -0
  33. monkeybot_cli/realtime/talk_ui.py +243 -0
  34. monkeybot_cli/realtime/wire_encode.py +39 -0
  35. monkeybot_cli/runtime_python.py +91 -0
  36. monkeybot_cli/scaffold.py +287 -0
  37. monkeybot_cli/scaffold_defaults/AGENT.md +56 -0
  38. monkeybot_cli/scaffold_defaults/__init__.py +1 -0
  39. monkeybot_cli/scaffold_defaults/command_allowlist.yaml +57 -0
  40. monkeybot_cli/scaffold_defaults/env.example +35 -0
  41. monkeybot_cli/scaffold_defaults/mcp.json +49 -0
  42. monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +191 -0
  43. monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +57 -0
  44. monkeybot_cli/scaffold_defaults/permissions.yaml +32 -0
  45. monkeybot_cli/scaffold_defaults/setup-workspace.sh +24 -0
  46. monkeybot_cli/session_controller.py +7 -0
  47. monkeybot_cli/terminal_markdown.py +48 -0
  48. monkeybot_cli-0.2.1.dist-info/METADATA +10 -0
  49. monkeybot_cli-0.2.1.dist-info/RECORD +51 -0
  50. monkeybot_cli-0.2.1.dist-info/WHEEL +4 -0
  51. 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)