comet-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cli/render.py ADDED
@@ -0,0 +1,420 @@
1
+ """Streaming event rendering for the interactive shell."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from dataclasses import dataclass
8
+
9
+ from rich.console import Console
10
+ from rich.console import Group
11
+ from rich.markdown import Markdown
12
+ from rich.panel import Panel
13
+ from rich.text import Text
14
+
15
+ from schemas.events import EventType, StreamEvent
16
+
17
+ _TOOL_DISPLAY: dict[str, tuple[str, str | None]] = {
18
+ "list_files": ("List", "path"),
19
+ "search_text": ("Search", "pattern"),
20
+ "find_files": ("Find", "pattern"),
21
+ "print_tree": ("Tree", "path"),
22
+ "read_file": ("Read", "path"),
23
+ "read_range": ("Read", "path"),
24
+ "write_file": ("Write", "path"),
25
+ "replace_text": ("Edit", "path"),
26
+ }
27
+
28
+
29
+ @dataclass
30
+ class ToolHistoryEntry:
31
+ tool_name: str
32
+ args_json: str
33
+ reason: str | None
34
+ status: str = "running"
35
+ preview: str | None = None
36
+ error: str | None = None
37
+
38
+ def to_dict(self) -> dict:
39
+ return {
40
+ "tool_name": self.tool_name,
41
+ "args_json": self.args_json,
42
+ "reason": self.reason,
43
+ "status": self.status,
44
+ "preview": self.preview,
45
+ "error": self.error,
46
+ }
47
+
48
+
49
+ class EventRenderer:
50
+ def __init__(
51
+ self,
52
+ console: Console,
53
+ collapsed_tools: bool = True,
54
+ ) -> None:
55
+ self.console = console
56
+ self._buffer: list[str] = []
57
+ self._collapsed_tools = collapsed_tools
58
+ self._tool_history: list[ToolHistoryEntry] = []
59
+ self._active_tool_idx: int | None = None
60
+ self._status_text = Text("☄ actualizing star map...", style="bright_cyan")
61
+ self._show_post_tool_transition = False
62
+ self._persisted_tool_history_count = 0
63
+ self._token_count: int = 0
64
+ self._start_time: float = time.monotonic()
65
+
66
+ def get_tool_history(self) -> list[dict]:
67
+ return [item.to_dict() for item in self._tool_history]
68
+
69
+ def get_elapsed_str(self) -> str:
70
+ secs = int(time.monotonic() - self._start_time)
71
+ if secs >= 60:
72
+ return f"{secs // 60}m {secs % 60:02d}s"
73
+ return f"{secs}s"
74
+
75
+ def get_status_text(self) -> Text:
76
+ txt = self._status_text.copy()
77
+ elapsed = self.get_elapsed_str()
78
+ # Separator before token count / elapsed time for clarity
79
+ if self._token_count > 0:
80
+ txt.append(" |", style="dim")
81
+ n = self._token_count
82
+ label = f"~{n/1000:.1f}k" if n >= 1000 else f"~{n}"
83
+ txt.append(f" {label} tokens", style="dim")
84
+ txt.append(" |", style="dim")
85
+ else:
86
+ txt.append(" |", style="dim")
87
+ txt.append(f" {elapsed}", style="dim")
88
+ return txt
89
+
90
+ def persist_tool_history_snapshot(self) -> None:
91
+ if not self._collapsed_tools:
92
+ return
93
+ if self._persisted_tool_history_count >= len(self._tool_history):
94
+ return
95
+ self.console.print()
96
+ self.console.print(self._build_collapsed_tool_summary(start_idx=self._persisted_tool_history_count))
97
+ self._persisted_tool_history_count = len(self._tool_history)
98
+
99
+ def should_render_live_tool_row(self) -> bool:
100
+ return self._collapsed_tools and bool(self._tool_history)
101
+
102
+ def build_live_tool_renderable(self, now: float | None = None) -> Group | Text:
103
+ if not self.should_render_live_tool_row():
104
+ return Text("")
105
+
106
+ visible_idx = self._visible_tool_index()
107
+ if visible_idx is None:
108
+ return Text("")
109
+
110
+ entry = self._tool_history[visible_idx]
111
+ human_name, key_arg = self._format_tool_display(entry)
112
+ lines: list[Text] = [
113
+ Text(" tool", style="dim"),
114
+ Text.assemble(
115
+ (" ", "default"),
116
+ (f"{self._status_dot(entry.status)} ", self._status_style(entry.status)),
117
+ (human_name, "bold bright_cyan"),
118
+ (" ", "default"),
119
+ (key_arg, "white"),
120
+ (" ", "default"),
121
+ (entry.status, "dim"),
122
+ ),
123
+ ]
124
+ preview = self._tool_secondary_line(entry)
125
+ if preview is not None:
126
+ lines.append(preview)
127
+ if self._show_post_tool_transition:
128
+ lines.append(
129
+ Text.assemble(
130
+ (" ", "default"),
131
+ ("────", "dim"),
132
+ )
133
+ )
134
+ return Group(*lines)
135
+
136
+ def render(self, event: StreamEvent) -> None:
137
+ if event.type == EventType.TOKEN:
138
+ self._render_token(event)
139
+ elif event.type == EventType.TOOL_START:
140
+ self._render_tool_start(event)
141
+ elif event.type == EventType.TOOL_END:
142
+ self._render_tool_end(event)
143
+ elif event.type == EventType.LIMIT:
144
+ self._render_limit(event)
145
+ elif event.type == EventType.ATTEMPT_RETRY:
146
+ self._render_attempt_retry(event)
147
+ elif event.type == EventType.USAGE:
148
+ self._render_usage(event)
149
+ elif event.type == EventType.ERROR:
150
+ self.console.print(f"\n [bold red]error:[/bold red] {event.error}\n")
151
+ elif event.type == EventType.FINAL:
152
+ self._render_final(event)
153
+ self._update_status_for_event(event)
154
+
155
+ def _render_token(self, event: StreamEvent) -> None:
156
+ text = event.text or ""
157
+ if text:
158
+ self._buffer.append(text)
159
+ self._token_count += max(1, len(text) // 4)
160
+ if self._tool_history:
161
+ self._show_post_tool_transition = True
162
+
163
+ def _render_tool_start(self, event: StreamEvent) -> None:
164
+ args = event.args or {}
165
+ args_str = json.dumps(args, ensure_ascii=True) if args else "{}"
166
+ reason = (event.reason or "").strip() or None
167
+ if reason and len(reason) > 160:
168
+ reason = reason[:157].rstrip() + "..."
169
+
170
+ entry = ToolHistoryEntry(
171
+ tool_name=event.tool_name or "unknown",
172
+ args_json=args_str,
173
+ reason=reason,
174
+ status="running",
175
+ )
176
+ self._tool_history.append(entry)
177
+ self._active_tool_idx = len(self._tool_history) - 1
178
+ self._show_post_tool_transition = False
179
+
180
+ if self._collapsed_tools:
181
+ return
182
+
183
+ human_name, key_arg = self._format_tool_display(entry)
184
+ self.console.print(
185
+ Text.assemble(
186
+ (" tool ", "dim"),
187
+ (human_name, "bold bright_cyan"),
188
+ (" ", "default"),
189
+ (key_arg, "white"),
190
+ )
191
+ )
192
+ if entry.reason:
193
+ self.console.print(Text.assemble((" why ", "dim"), (entry.reason, "italic dim")))
194
+
195
+ def _render_tool_end(self, event: StreamEvent) -> None:
196
+ entry: ToolHistoryEntry | None = None
197
+ if self._active_tool_idx is not None and 0 <= self._active_tool_idx < len(self._tool_history):
198
+ entry = self._tool_history[self._active_tool_idx]
199
+
200
+ if entry is None:
201
+ args = event.args or {}
202
+ args_str = json.dumps(args, ensure_ascii=True) if args else "{}"
203
+ entry = ToolHistoryEntry(
204
+ tool_name=event.tool_name or "unknown",
205
+ args_json=args_str,
206
+ reason=None,
207
+ status="done",
208
+ )
209
+ self._tool_history.append(entry)
210
+
211
+ if event.error:
212
+ entry.status = "error"
213
+ entry.error = event.error
214
+ self._active_tool_idx = None
215
+ self._show_post_tool_transition = True
216
+ if not self._collapsed_tools:
217
+ self.console.print(f" [red]error:[/red] {event.error}")
218
+ return
219
+
220
+ if event.output:
221
+ self._token_count += max(1, len(event.output) // 4)
222
+ lines = event.output.splitlines() or [event.output]
223
+ shown = lines[:2]
224
+ remaining = max(len(lines) - 2, 0)
225
+ preview = "\n".join(shown).strip() or "[no output]"
226
+ if remaining > 0:
227
+ preview = f"{preview}\n… +{remaining} more line(s)"
228
+ entry.preview = preview
229
+ entry.status = "done"
230
+
231
+ if not self._collapsed_tools:
232
+ self.console.print(
233
+ Panel(
234
+ preview,
235
+ border_style="bright_black",
236
+ padding=(0, 1),
237
+ expand=True,
238
+ )
239
+ )
240
+ else:
241
+ entry.status = "done"
242
+ self._active_tool_idx = None
243
+ self._show_post_tool_transition = True
244
+
245
+ def _render_final(self, event: StreamEvent) -> None:
246
+ text = "".join(self._buffer).strip()
247
+ if not text:
248
+ text = (event.text or "").strip()
249
+ if not text:
250
+ text = "No final response text was produced."
251
+
252
+ if self._collapsed_tools and self._persisted_tool_history_count < len(self._tool_history):
253
+ self.console.print()
254
+ self.console.print(self._build_collapsed_tool_summary(start_idx=self._persisted_tool_history_count))
255
+ self._persisted_tool_history_count = len(self._tool_history)
256
+
257
+ self.console.print()
258
+ self.console.print(
259
+ Panel(
260
+ Markdown(text),
261
+ title="[bold bright_cyan]response[/bold bright_cyan]",
262
+ border_style="bright_blue",
263
+ padding=(1, 2),
264
+ expand=True,
265
+ )
266
+ )
267
+ self.console.print()
268
+ self._buffer.clear()
269
+ self._show_post_tool_transition = False
270
+
271
+ def _render_limit(self, event: StreamEvent) -> None:
272
+ self._show_post_tool_transition = bool(self._tool_history)
273
+ return
274
+
275
+ def _render_attempt_retry(self, event: StreamEvent) -> None:
276
+ self._show_post_tool_transition = bool(self._tool_history)
277
+ reason = event.reason or "Attempt did not converge."
278
+ self.console.print(
279
+ Text.assemble(
280
+ (" retry ", "dim"),
281
+ ("trying a different approach", "bold bright_magenta"),
282
+ (" — ", "dim"),
283
+ (reason, "dim white"),
284
+ )
285
+ )
286
+
287
+ def _render_usage(self, event: StreamEvent) -> None:
288
+ total = event.total_tokens or 0
289
+ if total <= 0:
290
+ return
291
+ label = f"↓ ~{total:,} tokens"
292
+ if event.estimated:
293
+ label += " (est.)"
294
+ self.console.print(Text(label, style="dim"))
295
+
296
+ def _visible_tool_index(self) -> int | None:
297
+ if not self._tool_history:
298
+ return None
299
+ if self._active_tool_idx is not None and 0 <= self._active_tool_idx < len(self._tool_history):
300
+ return self._active_tool_idx
301
+ return len(self._tool_history) - 1
302
+
303
+ def _build_collapsed_tool_summary(self, start_idx: int = 0) -> Group:
304
+ lines = [Text(" tool", style="dim")]
305
+ for entry in self._tool_history[start_idx:]:
306
+ human_name, key_arg = self._format_tool_display(entry)
307
+ lines.append(
308
+ Text.assemble(
309
+ (" ", "default"),
310
+ (f"{self._status_dot(entry.status)} ", self._status_style(entry.status)),
311
+ (human_name, "bold bright_cyan"),
312
+ (" ", "default"),
313
+ (key_arg, "white"),
314
+ (" ", "default"),
315
+ (entry.status, "dim"),
316
+ )
317
+ )
318
+ preview = self._tool_secondary_line(entry)
319
+ if preview is not None:
320
+ lines.append(preview)
321
+ return Group(*lines)
322
+
323
+ def _format_tool_display(self, entry: ToolHistoryEntry) -> tuple[str, str]:
324
+ """Return (human_label, key_arg_value) for clean title rendering."""
325
+ human_label, primary_key = _TOOL_DISPLAY.get(entry.tool_name, (entry.tool_name, None))
326
+ args = self._load_args(entry.args_json)
327
+ key_value = ""
328
+ if primary_key and primary_key in args:
329
+ raw = self._format_arg(primary_key, args[primary_key])
330
+ key_value = raw.split("=", 1)[1].strip('"') if "=" in raw else raw
331
+ elif args:
332
+ first_key, first_val = next(iter(args.items()))
333
+ raw = self._format_arg(first_key, first_val)
334
+ key_value = raw.split("=", 1)[1].strip('"') if "=" in raw else raw
335
+ return human_label, key_value
336
+
337
+ def _format_tool_invocation(self, entry: ToolHistoryEntry) -> str:
338
+ args = self._load_args(entry.args_json)
339
+ if not args:
340
+ return entry.tool_name
341
+
342
+ parts = [entry.tool_name]
343
+ for key, value in args.items():
344
+ parts.append(self._format_arg(key, value))
345
+ return " ".join(parts)
346
+
347
+ def _tool_secondary_line(self, entry: ToolHistoryEntry) -> Text | None:
348
+ detail: str | None = None
349
+ style = "dim"
350
+ if entry.error:
351
+ detail = entry.error
352
+ style = "red"
353
+ elif entry.preview:
354
+ detail = entry.preview.splitlines()[0].strip()
355
+ elif entry.reason:
356
+ detail = entry.reason
357
+
358
+ if not detail:
359
+ return None
360
+
361
+ if len(detail) > 120:
362
+ detail = detail[:117].rstrip() + "..."
363
+ return Text.assemble(
364
+ (" ", "default"),
365
+ ("└ ", "dim"),
366
+ (detail, style),
367
+ )
368
+
369
+ def _load_args(self, args_json: str) -> dict[str, object]:
370
+ try:
371
+ loaded = json.loads(args_json)
372
+ except json.JSONDecodeError:
373
+ return {}
374
+ return loaded if isinstance(loaded, dict) else {}
375
+
376
+ def _format_arg(self, key: str, value: object) -> str:
377
+ if isinstance(value, str):
378
+ rendered = value if " " not in value else json.dumps(value, ensure_ascii=True)
379
+ return f"{key}={rendered}"
380
+ if isinstance(value, bool):
381
+ return f"{key}={'true' if value else 'false'}"
382
+ if isinstance(value, (int, float)):
383
+ return f"{key}={value}"
384
+ return f"{key}={json.dumps(value, ensure_ascii=True)}"
385
+
386
+ def _status_dot(self, status: str) -> str:
387
+ if status == "error":
388
+ return "●"
389
+ if status == "running":
390
+ return "●"
391
+ return "●"
392
+
393
+ def _status_style(self, status: str) -> str:
394
+ if status == "error":
395
+ return "red"
396
+ if status == "running":
397
+ return "yellow"
398
+ return "green"
399
+
400
+ def _update_status_for_event(self, event: StreamEvent) -> None:
401
+ if event.type == EventType.TOOL_START:
402
+ self._status_text = Text("☄ running tool...", style="bright_cyan")
403
+ return
404
+ if event.type == EventType.TOOL_END:
405
+ self._status_text = Text("☄ mapping starlines...", style="bright_cyan")
406
+ return
407
+ if event.type == EventType.TOKEN:
408
+ self._status_text = Text("☄ composing answer...", style="bright_cyan")
409
+ return
410
+ if event.type == EventType.FINAL:
411
+ self._status_text = Text("☄ docking complete", style="bright_cyan")
412
+ return
413
+ if event.type == EventType.LIMIT:
414
+ self._status_text = Text("☄ wrapping up an answer...", style="bright_cyan")
415
+ return
416
+ if event.type == EventType.ATTEMPT_RETRY:
417
+ self._status_text = Text("☄ switching approach...", style="bright_magenta")
418
+ return
419
+ if event.type == EventType.ERROR:
420
+ self._status_text = Text("☄ run failed", style="red")
cli/state.py ADDED
@@ -0,0 +1,18 @@
1
+ """Mutable shell state — current mode, current model, etc."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from llm.models import DEFAULT_MODEL, ModelInfo
8
+ from schemas.task import TaskMode
9
+
10
+
11
+ @dataclass
12
+ class ShellState:
13
+ """State carried across slash-command invocations within one shell session."""
14
+
15
+ mode: TaskMode = TaskMode.EXPLAIN
16
+ model: ModelInfo = DEFAULT_MODEL
17
+ tool_view_collapsed: bool = True
18
+ last_tool_history: list[dict] = field(default_factory=list)