tamfis-code 0.2.3__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.
- tamfis_code/__init__.py +58 -0
- tamfis_code/__main__.py +4 -0
- tamfis_code/agents.py +371 -0
- tamfis_code/api_client.py +461 -0
- tamfis_code/cli.py +2351 -0
- tamfis_code/completion.py +137 -0
- tamfis_code/config.py +199 -0
- tamfis_code/doctor.py +173 -0
- tamfis_code/enforcer.py +318 -0
- tamfis_code/indexer.py +567 -0
- tamfis_code/instructions.py +121 -0
- tamfis_code/interactive.py +985 -0
- tamfis_code/local_chat.py +117 -0
- tamfis_code/local_tools.py +93 -0
- tamfis_code/mcp.py +538 -0
- tamfis_code/metrics.py +105 -0
- tamfis_code/providers.py +423 -0
- tamfis_code/references.py +336 -0
- tamfis_code/render.py +575 -0
- tamfis_code/runner.py +635 -0
- tamfis_code/runner_local.py +364 -0
- tamfis_code/safety.py +164 -0
- tamfis_code/screenshot.py +382 -0
- tamfis_code/sessions.py +253 -0
- tamfis_code/state.py +449 -0
- tamfis_code/tasks.py +42 -0
- tamfis_code/workspace.py +329 -0
- tamfis_code-0.2.3.dist-info/METADATA +78 -0
- tamfis_code-0.2.3.dist-info/RECORD +32 -0
- tamfis_code-0.2.3.dist-info/WHEEL +5 -0
- tamfis_code-0.2.3.dist-info/entry_points.txt +4 -0
- tamfis_code-0.2.3.dist-info/top_level.txt +1 -0
tamfis_code/render.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"""Terminal rendering for the Remote event stream.
|
|
2
|
+
|
|
3
|
+
Mirrors the same event vocabulary and card concepts the web workspace's
|
|
4
|
+
RemoteMessageBubble/RemoteSuiteBubble cards render (plan/status lines, tool
|
|
5
|
+
call cards, command cards, file cards) -- see
|
|
6
|
+
tamfis-frontend/src/workspaces/remote/RemoteMessageBubble.tsx and
|
|
7
|
+
docs/REMOTE_AGENT_MASTER_SPEC.md Phase 7/9. Presentation only: no network
|
|
8
|
+
calls, no approval decisions -- see runner.py for that.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
from rich.console import Console, Group
|
|
20
|
+
from rich.live import Live
|
|
21
|
+
from rich.panel import Panel
|
|
22
|
+
from rich.text import Text
|
|
23
|
+
|
|
24
|
+
from .metrics import MetricsTracker
|
|
25
|
+
|
|
26
|
+
_TOOL_ANNOUNCE_RE = re.compile(r"Using tool:\s*(.+?)\.\.\.\s*$")
|
|
27
|
+
|
|
28
|
+
# Mirrors runner.py's own `phase_by_event` mapping (used there to persist
|
|
29
|
+
# SessionState.current_phase) purely for this renderer's live display -- kept
|
|
30
|
+
# as a separate copy rather than importing runner.py's dict so this module
|
|
31
|
+
# stays presentation-only and driven only by the events it already parses,
|
|
32
|
+
# per its module docstring.
|
|
33
|
+
_PHASE_BY_EVENT = {
|
|
34
|
+
# Submission/context/model lifecycle. These events are emitted by both
|
|
35
|
+
# the local provider loop and the remote SSE runner so the live card never
|
|
36
|
+
# sits at its constructor default while a network request is in flight.
|
|
37
|
+
"task_submitting": "submitting",
|
|
38
|
+
"task_submitted": "queued",
|
|
39
|
+
"task_started": "understand",
|
|
40
|
+
"context_loading": "understand",
|
|
41
|
+
"context_reused": "understand",
|
|
42
|
+
"context_rescanned": "understand",
|
|
43
|
+
"routing_started": "route",
|
|
44
|
+
"model_selected": "route",
|
|
45
|
+
"provider_request_started": "respond",
|
|
46
|
+
"assistant_delta": "respond",
|
|
47
|
+
"plan_created": "plan",
|
|
48
|
+
"tool_call_requested": "execute",
|
|
49
|
+
"tool_output": "execute",
|
|
50
|
+
"command_started": "execute",
|
|
51
|
+
"command_completed": "execute",
|
|
52
|
+
"command_failed": "execute",
|
|
53
|
+
"file_mutation": "execute",
|
|
54
|
+
"approval_required": "waiting_for_approval",
|
|
55
|
+
"task_diagnostics": "validate",
|
|
56
|
+
"ai_task_completed": "report",
|
|
57
|
+
"ai_task_failed": "report",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Chars-per-token is a rough English-text average, used only because
|
|
61
|
+
# assistant_delta payloads carry raw text, not a real token count -- the
|
|
62
|
+
# live panel labels this "~" to avoid presenting false precision.
|
|
63
|
+
_CHARS_PER_TOKEN_ESTIMATE = 4
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _tool_action_label(name: str, arguments: Optional[dict[str, Any]] = None, *, completed: bool = False) -> str:
|
|
67
|
+
"""Translate implementation identifiers into concise engineering actions."""
|
|
68
|
+
arguments = arguments or {}
|
|
69
|
+
normalized = (name or "tool").strip().lower().replace("-", "_").rsplit("/", 1)[-1]
|
|
70
|
+
target = str(
|
|
71
|
+
arguments.get("path") or arguments.get("file_path") or arguments.get("pattern")
|
|
72
|
+
or arguments.get("query") or arguments.get("command") or ""
|
|
73
|
+
).strip()
|
|
74
|
+
verbs = {
|
|
75
|
+
"read_file": ("Reading", "Read"),
|
|
76
|
+
"glob_files": ("Finding repository files", "Found repository files"),
|
|
77
|
+
"search_files": ("Searching repository files", "Searched repository files"),
|
|
78
|
+
"grep_files": ("Searching repository contents", "Searched repository contents"),
|
|
79
|
+
"edit_file": ("Editing", "Edited"),
|
|
80
|
+
"write_file": ("Writing", "Wrote"),
|
|
81
|
+
"remote_exec": ("Running command", "Ran command"),
|
|
82
|
+
"execute_command": ("Running command", "Ran command"),
|
|
83
|
+
"run_command": ("Running command", "Ran command"),
|
|
84
|
+
"web_search": ("Searching the web", "Searched the web"),
|
|
85
|
+
"list_directory": ("Inspecting directory", "Inspected directory"),
|
|
86
|
+
"create_directory": ("Creating directory", "Created directory"),
|
|
87
|
+
}
|
|
88
|
+
active, done = verbs.get(normalized, (normalized.replace("_", " ").strip().capitalize(), normalized.replace("_", " ").strip().capitalize()))
|
|
89
|
+
label = done if completed else active
|
|
90
|
+
if target:
|
|
91
|
+
compact = target if len(target) <= 120 else target[:117] + "…"
|
|
92
|
+
label += f" · {compact}"
|
|
93
|
+
return label
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _tool_result_message(payload: dict[str, Any]) -> tuple[str, bool]:
|
|
97
|
+
"""Render structured tool results without asking the model to infer status.
|
|
98
|
+
|
|
99
|
+
Backwards compatible with legacy payloads that only contain ``content``.
|
|
100
|
+
"""
|
|
101
|
+
result = payload.get("result") if isinstance(payload.get("result"), dict) else payload
|
|
102
|
+
success = result.get("ok")
|
|
103
|
+
if success is None:
|
|
104
|
+
success = result.get("success")
|
|
105
|
+
status = str(result.get("status") or "").strip().lower()
|
|
106
|
+
error_code = str(result.get("error_code") or "").strip()
|
|
107
|
+
message = str(result.get("message") or result.get("error") or "").strip()
|
|
108
|
+
content = str(result.get("content") or "").strip()
|
|
109
|
+
stdout = str(result.get("stdout") or "").strip()
|
|
110
|
+
stderr = str(result.get("stderr") or "").strip()
|
|
111
|
+
exit_code = result.get("exit_code")
|
|
112
|
+
path = str(result.get("resolved_path") or result.get("path") or result.get("requested_path") or "").strip()
|
|
113
|
+
|
|
114
|
+
canonical = {
|
|
115
|
+
"not_found": f"File not found: {path}" if path else "File not found",
|
|
116
|
+
"permission_denied": f"Permission denied: {path}" if path else "Permission denied",
|
|
117
|
+
"outside_allowed_scope": f"Path is outside the allowed workspace: {path}" if path else "Path is outside the allowed workspace",
|
|
118
|
+
"path_rejected": f"Path rejected: {path}" if path else "Path rejected",
|
|
119
|
+
"timed_out": message or "Command timed out",
|
|
120
|
+
"cancelled": message or "Command was cancelled",
|
|
121
|
+
"approval_rejected": message or "Command was rejected by the user",
|
|
122
|
+
"tool_unavailable": message or "Tool unavailable",
|
|
123
|
+
"provider_unavailable": message or "Provider unavailable",
|
|
124
|
+
"model_unavailable": message or "Model unavailable",
|
|
125
|
+
}
|
|
126
|
+
if status in canonical:
|
|
127
|
+
return canonical[status], True
|
|
128
|
+
if error_code == "FILE_NOT_FOUND":
|
|
129
|
+
return (message or (f"File not found: {path}" if path else "File not found")), True
|
|
130
|
+
if error_code == "PERMISSION_DENIED":
|
|
131
|
+
return (message or (f"Permission denied: {path}" if path else "Permission denied")), True
|
|
132
|
+
|
|
133
|
+
failed = success is False or status in {
|
|
134
|
+
"command_failed", "invalid_path", "not_a_file", "not_a_directory",
|
|
135
|
+
"internal_error", "failed", "error",
|
|
136
|
+
} or bool(error_code)
|
|
137
|
+
if failed:
|
|
138
|
+
if message:
|
|
139
|
+
return message, True
|
|
140
|
+
if stderr:
|
|
141
|
+
return stderr, True
|
|
142
|
+
if content:
|
|
143
|
+
return content, True
|
|
144
|
+
if exit_code is not None:
|
|
145
|
+
return f"Command failed with exit code {exit_code}", True
|
|
146
|
+
return "Tool operation failed", True
|
|
147
|
+
|
|
148
|
+
if content:
|
|
149
|
+
return content, False
|
|
150
|
+
body = "\n".join(part for part in (stdout, stderr) if part).strip()
|
|
151
|
+
if body:
|
|
152
|
+
return body, False
|
|
153
|
+
if status == "empty_success" or success is True or exit_code == 0:
|
|
154
|
+
return message or "Command completed successfully with no output", False
|
|
155
|
+
tool = str(payload.get("tool") or payload.get("name") or "tool")
|
|
156
|
+
return message or f"{tool} returned an invalid result envelope", True
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _bounded_preview(text: str, limit: int = 8_000) -> str:
|
|
160
|
+
if len(text) <= limit:
|
|
161
|
+
return text
|
|
162
|
+
head = int(limit * 0.7)
|
|
163
|
+
tail = limit - head
|
|
164
|
+
return f"{text[:head]}\n\n… {len(text) - limit:,} characters omitted …\n\n{text[-tail:]}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _format_diagnostics_line(payload: dict[str, Any]) -> str:
|
|
168
|
+
"""One-line summary of a task_diagnostics event (see PHASE 17 note in
|
|
169
|
+
tier_ii_gateway/api/remote.py's _run_remote_ai_task_background) -- the
|
|
170
|
+
self-diagnostic surface for a single turn: what context it reused/
|
|
171
|
+
rescanned, which provider/model answered, how many tool calls it made
|
|
172
|
+
and how many of those failed, how many artifacts it produced, and how
|
|
173
|
+
it ended. Pulled out as a pure function so it's testable without a
|
|
174
|
+
Console."""
|
|
175
|
+
parts = []
|
|
176
|
+
reused = payload.get("context_reused")
|
|
177
|
+
if reused is True:
|
|
178
|
+
parts.append("context reused")
|
|
179
|
+
elif reused is False:
|
|
180
|
+
parts.append(f"context rescanned ({payload.get('rescan_reason') or 'unknown'})")
|
|
181
|
+
provider = payload.get("provider")
|
|
182
|
+
model = payload.get("model")
|
|
183
|
+
if provider or model:
|
|
184
|
+
parts.append(f"{provider or '?'}/{model or '?'}")
|
|
185
|
+
tool_calls = payload.get("tool_calls") or []
|
|
186
|
+
if tool_calls:
|
|
187
|
+
failed = sum(1 for tc in tool_calls if tc.get("success") is False)
|
|
188
|
+
tool_text = f"{len(tool_calls)} tool call{'s' if len(tool_calls) != 1 else ''}"
|
|
189
|
+
if failed:
|
|
190
|
+
tool_text += f" ({failed} failed)"
|
|
191
|
+
parts.append(tool_text)
|
|
192
|
+
artifacts = payload.get("artifacts") or []
|
|
193
|
+
if artifacts:
|
|
194
|
+
parts.append(f"{len(artifacts)} artifact{'s' if len(artifacts) != 1 else ''}")
|
|
195
|
+
parts.append(f"status={payload.get('completion_status') or 'unknown'}")
|
|
196
|
+
return "Diagnostics: " + ", ".join(parts)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class StreamRenderer:
|
|
200
|
+
def __init__(self, console: Console, objective: str = ""):
|
|
201
|
+
self.console = console
|
|
202
|
+
self._assistant_open = False
|
|
203
|
+
self._tool_names_by_call_id: dict[str, str] = {}
|
|
204
|
+
self._selected_provider: Optional[str] = None
|
|
205
|
+
self.streamed_final_text = False # True once any assistant_delta content is shown
|
|
206
|
+
self.debug = os.environ.get("TAMFIS_CODE_DEBUG", "").lower() in {"1", "true", "yes"}
|
|
207
|
+
|
|
208
|
+
# Live task-visibility panel -- gated on the console actually being a
|
|
209
|
+
# TTY so redirected/piped output (`tamfis-code agent "..." > out.txt`)
|
|
210
|
+
# keeps today's clean plain-text behaviour untouched.
|
|
211
|
+
self._objective = objective
|
|
212
|
+
self._phase = "idle"
|
|
213
|
+
self._plan_steps: list[dict[str, Any]] = []
|
|
214
|
+
self._task_start = time.monotonic()
|
|
215
|
+
self._metrics = MetricsTracker()
|
|
216
|
+
self._is_tty = bool(getattr(console, "is_terminal", False))
|
|
217
|
+
self._live: Optional[Live] = None
|
|
218
|
+
if self._is_tty:
|
|
219
|
+
self._live = Live(self._build_panel(), console=self.console, refresh_per_second=8, transient=True)
|
|
220
|
+
self._live.start()
|
|
221
|
+
|
|
222
|
+
def _build_panel(self) -> Panel:
|
|
223
|
+
lines = []
|
|
224
|
+
if self._objective:
|
|
225
|
+
lines.append(f"[bold]{self._objective}[/bold]")
|
|
226
|
+
lines.append(f"[dim]phase:[/dim] {self._phase}")
|
|
227
|
+
for step in self._plan_steps:
|
|
228
|
+
status = step.get("status")
|
|
229
|
+
marker = "[green]✓[/green]" if status == "completed" else (
|
|
230
|
+
"[yellow]◉[/yellow]" if status == "in_progress" else "[dim]○[/dim]"
|
|
231
|
+
)
|
|
232
|
+
# No per-step completion event exists yet (see state.py's
|
|
233
|
+
# update_plan_steps docstring) -- statuses beyond the initial
|
|
234
|
+
# plan_created payload are a best-effort approximation, so this
|
|
235
|
+
# is labelled "~" rather than presented as precise.
|
|
236
|
+
lines.append(f" {marker} {'~ ' if status == 'in_progress' else ''}{step.get('step') or ''}")
|
|
237
|
+
elapsed = time.monotonic() - self._task_start
|
|
238
|
+
tokens = self._metrics.metrics.tokens_used
|
|
239
|
+
if tokens:
|
|
240
|
+
lines.append(
|
|
241
|
+
f"[dim]~{tokens:,} tok (est.) · {self._metrics.metrics.tokens_per_second:.1f} tok/s · "
|
|
242
|
+
f"{elapsed:.1f}s[/dim]"
|
|
243
|
+
)
|
|
244
|
+
else:
|
|
245
|
+
lines.append(f"[dim]{elapsed:.1f}s[/dim]")
|
|
246
|
+
return Panel(Group(*(Text.from_markup(line) for line in lines)), border_style="cyan", expand=False)
|
|
247
|
+
|
|
248
|
+
def _refresh_live(self) -> None:
|
|
249
|
+
if self._live is not None:
|
|
250
|
+
self._live.update(self._build_panel())
|
|
251
|
+
|
|
252
|
+
def _stop_live(self) -> None:
|
|
253
|
+
"""End the progress display before ordinary streamed output begins."""
|
|
254
|
+
if self._live is not None:
|
|
255
|
+
self._live.stop()
|
|
256
|
+
self._live = None
|
|
257
|
+
|
|
258
|
+
def _record_tokens(self, content: str) -> None:
|
|
259
|
+
if not content:
|
|
260
|
+
return
|
|
261
|
+
estimated_tokens = max(1, len(content) // _CHARS_PER_TOKEN_ESTIMATE)
|
|
262
|
+
elapsed_ms = (time.monotonic() - self._task_start) * 1000
|
|
263
|
+
self._metrics.record(estimated_tokens, elapsed_ms)
|
|
264
|
+
|
|
265
|
+
def _close_assistant(self) -> None:
|
|
266
|
+
if self._assistant_open:
|
|
267
|
+
self.console.print()
|
|
268
|
+
self._assistant_open = False
|
|
269
|
+
|
|
270
|
+
def handle_event(self, event: dict[str, Any]) -> None:
|
|
271
|
+
event_type = event.get("event_type") or event.get("event") or event.get("type")
|
|
272
|
+
payload = event.get("payload") or {}
|
|
273
|
+
|
|
274
|
+
if event_type in _PHASE_BY_EVENT and self._phase != _PHASE_BY_EVENT[event_type]:
|
|
275
|
+
self._phase = _PHASE_BY_EVENT[event_type]
|
|
276
|
+
self._refresh_live()
|
|
277
|
+
|
|
278
|
+
if event_type == "assistant_delta":
|
|
279
|
+
content = str(payload.get("content", ""))
|
|
280
|
+
if not self._assistant_open:
|
|
281
|
+
self._stop_live()
|
|
282
|
+
self.console.print("[bold cyan]Assistant[/bold cyan]")
|
|
283
|
+
self._assistant_open = True
|
|
284
|
+
# Whitespace/reasoning-only provider frames are not a visible
|
|
285
|
+
# final answer. Marking them as displayed suppresses cli.py's
|
|
286
|
+
# authoritative persisted-summary fallback and leaves a blank
|
|
287
|
+
# terminal even though the task completed with real text.
|
|
288
|
+
if content.strip():
|
|
289
|
+
self.streamed_final_text = True
|
|
290
|
+
self._record_tokens(content)
|
|
291
|
+
self._refresh_live()
|
|
292
|
+
self.console.print(content, end="")
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
if event_type == "plan_created":
|
|
296
|
+
self._close_assistant()
|
|
297
|
+
stage = payload.get("stage")
|
|
298
|
+
content = str(payload.get("content", ""))
|
|
299
|
+
items = payload.get("items") if isinstance(payload.get("items"), list) else []
|
|
300
|
+
if items:
|
|
301
|
+
self._plan_steps = [item for item in items if isinstance(item, dict) and item.get("status") != "context"]
|
|
302
|
+
self._refresh_live()
|
|
303
|
+
# The live panel (when active) already shows this step list
|
|
304
|
+
# in place, updating as later events refresh it -- printing
|
|
305
|
+
# it again as a static scroll-log entry would just duplicate
|
|
306
|
+
# it. Non-TTY output (no live panel) keeps today's one-shot
|
|
307
|
+
# print, since that's the only place this ever showed up.
|
|
308
|
+
if self._live is None:
|
|
309
|
+
self.console.print(f"[bold cyan]{payload.get('title') or 'Plan'}[/bold cyan]")
|
|
310
|
+
for item in self._plan_steps:
|
|
311
|
+
marker = "◉" if item.get("status") == "in_progress" else "○"
|
|
312
|
+
self.console.print(f" [cyan]{marker}[/cyan] {item.get('step') or ''}")
|
|
313
|
+
return
|
|
314
|
+
if stage == "tool_execution":
|
|
315
|
+
match = _TOOL_ANNOUNCE_RE.search(content)
|
|
316
|
+
tool = str(payload.get("tool") or (match.group(1) if match else "tool"))
|
|
317
|
+
call_id = payload.get("tool_call_id")
|
|
318
|
+
if call_id:
|
|
319
|
+
self._tool_names_by_call_id[str(call_id)] = tool
|
|
320
|
+
args = payload.get("arguments") or {}
|
|
321
|
+
arg_text = ", ".join(f"{k}={v}" for k, v in args.items() if v not in (None, "")) if isinstance(args, dict) else ""
|
|
322
|
+
label = f"[bold yellow]→ {_tool_action_label(tool, args)}[/bold yellow]"
|
|
323
|
+
if self.debug and arg_text:
|
|
324
|
+
label += f" [dim]{arg_text}[/dim]"
|
|
325
|
+
self.console.print(label)
|
|
326
|
+
else:
|
|
327
|
+
# Tier V's compatibility router can describe its internal
|
|
328
|
+
# adapter slot (for example "nim") even though Tier IV is
|
|
329
|
+
# honoring an explicit end-provider such as Ollama. Once an
|
|
330
|
+
# authoritative model_selected event exists, never display a
|
|
331
|
+
# contradictory provider claim in progress output.
|
|
332
|
+
if self._selected_provider and content.lower().startswith("executing with "):
|
|
333
|
+
content = f"Executing with {self._selected_provider}..."
|
|
334
|
+
self.console.print(f"[dim]· {content}[/dim]")
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
if event_type == "tool_call_requested":
|
|
338
|
+
self._close_assistant()
|
|
339
|
+
name = str(payload.get("name") or payload.get("tool") or "tool")
|
|
340
|
+
args = payload.get("arguments") if isinstance(payload.get("arguments"), dict) else {}
|
|
341
|
+
self.console.print(f"[bold yellow]→ {_tool_action_label(name, args)}[/bold yellow]")
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
if event_type == "tool_output":
|
|
345
|
+
self._close_assistant()
|
|
346
|
+
tool = str(payload.get("tool", "tool"))
|
|
347
|
+
result_envelope = payload.get("result") if isinstance(payload.get("result"), dict) else payload
|
|
348
|
+
# Command/file events already carry the useful result. Some
|
|
349
|
+
# canonical tool-completion envelopes contain only a tool name
|
|
350
|
+
# and success flag; rendering those produced the misleading,
|
|
351
|
+
# repetitive "Tool completed without a structured result" card.
|
|
352
|
+
if not any(
|
|
353
|
+
result_envelope.get(key) not in (None, "", [], {})
|
|
354
|
+
for key in (
|
|
355
|
+
"content", "stdout", "stderr", "message", "error",
|
|
356
|
+
"error_code", "status", "exit_code", "resolved_path",
|
|
357
|
+
"path", "requested_path",
|
|
358
|
+
)
|
|
359
|
+
):
|
|
360
|
+
return
|
|
361
|
+
content, failed = _tool_result_message(payload)
|
|
362
|
+
style = "red" if failed else "green"
|
|
363
|
+
result = result_envelope
|
|
364
|
+
args = payload.get("arguments") if isinstance(payload.get("arguments"), dict) else {}
|
|
365
|
+
if not args and isinstance(result, dict):
|
|
366
|
+
args = {
|
|
367
|
+
"path": result.get("resolved_path") or result.get("path"),
|
|
368
|
+
"command": result.get("command"),
|
|
369
|
+
}
|
|
370
|
+
title = (
|
|
371
|
+
f"{_tool_action_label(tool, args, completed=True)} — failed"
|
|
372
|
+
if failed else _tool_action_label(tool, args, completed=True)
|
|
373
|
+
)
|
|
374
|
+
self.console.print(Panel(content, title=title, border_style=style, expand=False))
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
if event_type in ("artifact_generated", "diff_available"):
|
|
378
|
+
self._close_assistant()
|
|
379
|
+
filename = payload.get("filename") or "generated file"
|
|
380
|
+
size = payload.get("size_bytes")
|
|
381
|
+
size_text = f" ({size} bytes)" if size else ""
|
|
382
|
+
url = payload.get("download_url") or payload.get("file_url")
|
|
383
|
+
body = f"{filename}{size_text}"
|
|
384
|
+
if url:
|
|
385
|
+
body += f"\n{url}"
|
|
386
|
+
validated = payload.get("validated")
|
|
387
|
+
title = "File generated" + (" · validated" if validated is True else "")
|
|
388
|
+
self.console.print(Panel(body, title=title, border_style="blue", expand=False))
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
if event_type == "file_mutation":
|
|
392
|
+
self._close_assistant()
|
|
393
|
+
path = payload.get("path", "?")
|
|
394
|
+
added, removed = payload.get("lines_added", 0), payload.get("lines_removed", 0)
|
|
395
|
+
mutation_id = payload.get("mutation_id", "?")
|
|
396
|
+
self.console.print(
|
|
397
|
+
f"[bold blue]✎ {path}[/bold blue] [dim]+{added}/-{removed} "
|
|
398
|
+
f"(revert with: /revert {mutation_id})[/dim]"
|
|
399
|
+
)
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
if event_type == "file_mutation_reverted":
|
|
403
|
+
self._close_assistant()
|
|
404
|
+
self.console.print(f"[green]↺ Reverted {payload.get('path', '?')}[/green]")
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
if event_type == "command_started":
|
|
408
|
+
self._close_assistant()
|
|
409
|
+
self.console.print(f"[bold]$[/bold] {payload.get('command', '')}")
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
if event_type in ("command_completed", "command_failed"):
|
|
413
|
+
self._close_assistant()
|
|
414
|
+
stdout = str(payload.get("stdout", ""))
|
|
415
|
+
stderr = str(payload.get("stderr", ""))
|
|
416
|
+
exit_code = payload.get("exit_code")
|
|
417
|
+
style = "green" if event_type == "command_completed" and exit_code == 0 else "red"
|
|
418
|
+
body = stdout.strip()
|
|
419
|
+
if stderr.strip():
|
|
420
|
+
body = (body + "\n" + stderr.strip()).strip()
|
|
421
|
+
if not body:
|
|
422
|
+
body = (
|
|
423
|
+
"Command completed successfully with no output"
|
|
424
|
+
if event_type == "command_completed" and exit_code == 0
|
|
425
|
+
else f"Command failed with exit code {exit_code}"
|
|
426
|
+
)
|
|
427
|
+
self.console.print(Panel(body, title=f"exit {exit_code}", border_style=style, expand=False))
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
if event_type == "approval_required":
|
|
431
|
+
self._close_assistant()
|
|
432
|
+
# payload["command"] is the command TEXT (a plain string), not
|
|
433
|
+
# an object -- see the matching comment in runner.py's approval
|
|
434
|
+
# handling for why that distinction matters.
|
|
435
|
+
text = payload.get("command")
|
|
436
|
+
risk = payload.get("risk_level", "?")
|
|
437
|
+
command_id = payload.get("command_id")
|
|
438
|
+
cwd = str(payload.get('cwd') or payload.get('working_directory') or '?')
|
|
439
|
+
reason = str(payload.get('reason') or 'The agent requested this command.')
|
|
440
|
+
command_text = _bounded_preview(str(text or ''))
|
|
441
|
+
body = (
|
|
442
|
+
f"Command:\n{command_text}\n\n"
|
|
443
|
+
f"Working directory:\n{cwd}\n\n"
|
|
444
|
+
f"Reason:\n{reason}\n\n"
|
|
445
|
+
f"Risk:\n{risk}"
|
|
446
|
+
)
|
|
447
|
+
# This event fires identically whether a human is actively
|
|
448
|
+
# attached to answer it (attach's own interactive prompt handles
|
|
449
|
+
# that case) or the task is backgrounded/being watched read-only
|
|
450
|
+
# via `logs --follow` -- in the latter case there is no other
|
|
451
|
+
# way to discover the id `tamfis-code approve`/`reject` need.
|
|
452
|
+
# Before this fix, a backgrounded approval was fully opaque:
|
|
453
|
+
# visible that SOMETHING needs approval, with no way to act on
|
|
454
|
+
# it short of reading the database directly.
|
|
455
|
+
if command_id is not None:
|
|
456
|
+
body += f"\n\ntamfis-code approve {command_id}\ntamfis-code reject {command_id}"
|
|
457
|
+
self.console.print(
|
|
458
|
+
Panel(
|
|
459
|
+
body,
|
|
460
|
+
title=f"Approval required — risk: {risk}" + (f" (id {command_id})" if command_id is not None else ""),
|
|
461
|
+
border_style="magenta",
|
|
462
|
+
expand=False,
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
if event_type in ("context_reused", "context_rescanned"):
|
|
468
|
+
self._close_assistant()
|
|
469
|
+
reason = payload.get("reason", "unknown")
|
|
470
|
+
if event_type == "context_reused":
|
|
471
|
+
self.console.print("[dim]· Reusing workspace context — repository unchanged since last turn[/dim]")
|
|
472
|
+
else:
|
|
473
|
+
self.console.print(f"[dim]· Workspace rescanned (reason: {reason})[/dim]")
|
|
474
|
+
return
|
|
475
|
+
|
|
476
|
+
if event_type == "task_diagnostics":
|
|
477
|
+
self._close_assistant()
|
|
478
|
+
self.console.print(f"[dim]· {_format_diagnostics_line(payload)}[/dim]")
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
if event_type == "model_selected":
|
|
482
|
+
self._close_assistant()
|
|
483
|
+
provider = payload.get("provider") or "unknown"
|
|
484
|
+
self._selected_provider = str(provider)
|
|
485
|
+
model = payload.get("model") or "unknown"
|
|
486
|
+
reason = payload.get("selection_reason") or "explicit selection or orchestration routing"
|
|
487
|
+
self.console.print(f"[dim]· Provider: {provider} · Model: {model} · {reason}[/dim]")
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
if event_type == "ai_task_failed":
|
|
491
|
+
self._close_assistant()
|
|
492
|
+
self.console.print(f"[bold red]Task failed:[/bold red] {payload.get('error', 'unknown error')}")
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
if event_type in ("ai_task_completed", "assistant_message", "task_cancelled", "heartbeat", "stream_closed"):
|
|
496
|
+
return # runner.py owns lifecycle decisions for these; nothing new to print
|
|
497
|
+
|
|
498
|
+
# Unrecognised event type: show it plainly rather than silently
|
|
499
|
+
# dropping it -- a gap in this renderer should be visible, not hidden.
|
|
500
|
+
self._close_assistant()
|
|
501
|
+
content = payload.get("content") or payload.get("status") or ""
|
|
502
|
+
if content:
|
|
503
|
+
self.console.print(f"[dim]· {event_type}: {content}[/dim]")
|
|
504
|
+
if self.debug:
|
|
505
|
+
self.console.print(json.dumps(event, indent=2, default=str, ensure_ascii=False))
|
|
506
|
+
|
|
507
|
+
def finish(self) -> None:
|
|
508
|
+
self._close_assistant()
|
|
509
|
+
if self._live is not None:
|
|
510
|
+
self._live.stop()
|
|
511
|
+
self._live = None
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def print_banner(console: Console, *, host: str, workspace_root: str, mode: str, approval_policy: str) -> None:
|
|
515
|
+
console.print(Text("TamfisGPT Code", style="bold cyan"))
|
|
516
|
+
console.print(f"[dim]Workspace:[/dim] {workspace_root}")
|
|
517
|
+
console.print(f"[dim]Mode:[/dim] {mode} [dim]Approval:[/dim] {approval_policy} [dim]Host:[/dim] {host}")
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def print_error(console: Console, message: str) -> None:
|
|
521
|
+
console.print(f"[bold red]Error:[/bold red] {message}")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def print_recent_thread(console: Console, messages: list[dict[str, Any]], limit: int = 6) -> None:
|
|
525
|
+
"""Prints the tail of GET /thread's message list -- used by `/resume`
|
|
526
|
+
and `tamfis-code resume` so switching sessions doesn't drop the user
|
|
527
|
+
into a blank prompt with no memory of what was being worked on."""
|
|
528
|
+
if not messages:
|
|
529
|
+
return
|
|
530
|
+
|
|
531
|
+
turns: dict[str, dict[str, Optional[str]]] = {}
|
|
532
|
+
order: list[str] = []
|
|
533
|
+
for message in messages: # oldest-first, per GET /thread's own ordering
|
|
534
|
+
key = str(message.get("task_id") or message.get("id"))
|
|
535
|
+
if key not in turns:
|
|
536
|
+
turns[key] = {"objective": None, "answer": None}
|
|
537
|
+
order.append(key)
|
|
538
|
+
if message.get("role") == "user":
|
|
539
|
+
turns[key]["objective"] = message.get("visible_content")
|
|
540
|
+
else:
|
|
541
|
+
turns[key]["answer"] = message.get("visible_content")
|
|
542
|
+
|
|
543
|
+
shown = [key for key in order if turns[key]["objective"] or turns[key]["answer"]][-limit:]
|
|
544
|
+
if not shown:
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
console.print("[bold]Recent history[/bold]")
|
|
548
|
+
for key in shown:
|
|
549
|
+
turn = turns[key]
|
|
550
|
+
if turn["objective"]:
|
|
551
|
+
console.print(f"[dim]›[/dim] {turn['objective']}")
|
|
552
|
+
if turn["answer"]:
|
|
553
|
+
console.print(f" {turn['answer']}")
|
|
554
|
+
console.print()
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def print_unified_diff(console: Console, diff_text: str, *, title: str = "Changes") -> None:
|
|
558
|
+
"""Render a unified diff semantically; Console owns colour fallback."""
|
|
559
|
+
no_color = bool(getattr(console, "no_color", False))
|
|
560
|
+
console.print(title if no_color else f"[bold]{title}[/bold]")
|
|
561
|
+
if not diff_text.strip():
|
|
562
|
+
console.print("(empty diff)" if no_color else "[dim](empty diff)[/dim]")
|
|
563
|
+
return
|
|
564
|
+
for line in diff_text.splitlines():
|
|
565
|
+
if line.startswith("+++") or line.startswith("---"):
|
|
566
|
+
style = "bold"
|
|
567
|
+
elif line.startswith("+"):
|
|
568
|
+
style = "green"
|
|
569
|
+
elif line.startswith("-"):
|
|
570
|
+
style = "red"
|
|
571
|
+
elif line.startswith("@@"):
|
|
572
|
+
style = "cyan"
|
|
573
|
+
else:
|
|
574
|
+
style = "dim"
|
|
575
|
+
console.print(Text(line, style=None if no_color else style), soft_wrap=True)
|