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/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)