closecode-ai 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.
ui.py ADDED
@@ -0,0 +1,422 @@
1
+
2
+ import select
3
+ import sys
4
+ import threading
5
+
6
+ from datetime import datetime
7
+ from getpass import getpass
8
+
9
+ from rich.console import Console, Group
10
+ from rich.live import Live
11
+ from rich.markdown import Markdown
12
+ from rich.panel import Panel
13
+ from rich.rule import Rule
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+
17
+ import pyfiglet
18
+
19
+ try:
20
+ import termios
21
+ import tty
22
+ _HAS_TERMIOS = True
23
+ except ImportError: # pragma: no cover - Windows fallback
24
+ _HAS_TERMIOS = False
25
+
26
+ try:
27
+ import msvcrt
28
+ _HAS_MSVCRT = True
29
+ except ImportError:
30
+ _HAS_MSVCRT = False
31
+
32
+ console = Console()
33
+
34
+ # Simple, solid block wordmark for "OGBOT" — no fancy/decorative figlet
35
+ # fonts, just clean filled rectangles. Falls back to a plain bold pyfiglet
36
+ # render for any other banner text.
37
+ _BLOCK_GLYPHS = {
38
+ "O": ["█████", "█ █", "█ █", "█ █", "█████"],
39
+ "G": ["█████", "█ ", "█ ██", "█ █", "█████"],
40
+ "B": ["████ ", "█ █", "████ ", "█ █", "████ "],
41
+ "T": ["█████", " █ ", " █ ", " █ ", " █ "],
42
+ }
43
+
44
+ # ---- opencode default theme (dark variant) --------------------------------
45
+ BG = "#0a0a0a"
46
+ BG_PANEL = "#141414"
47
+ PRIMARY = "#fab283" # peach -> agent label / write arrows
48
+ SECONDARY = "#5c9cf5" # blue -> "you" label
49
+ ACCENT = "#9d7cd8" # purple -> mode accents
50
+ ERROR = "#e06c75"
51
+ WARNING = "#f5a742"
52
+ SUCCESS = "#7fd88f"
53
+ INFO = "#56b6c2" # cyan -> read arrows
54
+ TEXT = "#eeeeee"
55
+ TEXT_MUTED = "#808080"
56
+ BORDER = "#484848"
57
+ BORDER_SUBTLE = "#3c3c3c"
58
+
59
+ # Tool names containing these treat as read-only (->), everything else is
60
+ # a write (<-). Mirrors opencode's arrow direction for tool calls.
61
+ _READ_KINDS = ("read", "list", "status", "diff", "log", "show", "grep", "search")
62
+
63
+ # Map the loose rich color names used by callers onto the opencode palette.
64
+ _NAMED_STYLES = {
65
+ "dim": TEXT_MUTED,
66
+ "yellow": WARNING,
67
+ "red": ERROR,
68
+ "green": SUCCESS,
69
+ "cyan": INFO,
70
+ "magenta": ACCENT,
71
+ "blue": SECONDARY,
72
+ }
73
+
74
+ _context_mode = "build"
75
+ _context_model = ""
76
+
77
+
78
+ def set_context(mode: str, model_name: str) -> None:
79
+ """Remember the current agent/mode and model for labels and markers."""
80
+ global _context_mode, _context_model
81
+ _context_mode = mode
82
+ _context_model = model_name
83
+
84
+
85
+ def _banner_art(text: str, font: str = "standard") -> Text:
86
+ """Simple, solid banner. If every character in `text` has a hand-drawn
87
+ block glyph (currently just what's needed for "OGBOT"), render clean
88
+ filled rectangles. Otherwise fall back to a plain pyfiglet font for
89
+ arbitrary text."""
90
+ if text and all(ch in _BLOCK_GLYPHS for ch in text.upper()):
91
+ rows = ["" for _ in range(5)]
92
+ for ch in text.upper():
93
+ glyph = _BLOCK_GLYPHS[ch]
94
+ for i in range(5):
95
+ rows[i] += glyph[i] + " "
96
+ lines = [row.rstrip() for row in rows]
97
+ else:
98
+ art = pyfiglet.figlet_format(text, font=font)
99
+ lines = art.rstrip("\n").split("\n")
100
+
101
+ result = Text()
102
+ for line in lines:
103
+ result.append(line, style=f"bold {PRIMARY}")
104
+ result.append("\n")
105
+ return result
106
+
107
+
108
+ def print_banner(model_name: str, sandbox_path: str, tool_names: list[str]) -> None:
109
+ console.print()
110
+ console.print(_banner_art("OGBOT"), justify="center")
111
+ console.print()
112
+
113
+ info = Text()
114
+ info.append(f"model {model_name}\n", style=TEXT_MUTED)
115
+ info.append(f"workdir {sandbox_path}\n", style=TEXT_MUTED)
116
+ info.append(f"tools {', '.join(tool_names)}", style=TEXT_MUTED)
117
+ console.print(info, justify="center")
118
+
119
+ console.print()
120
+ console.print(Text("type a task, or 'exit' to quit.", style=TEXT_MUTED), justify="center")
121
+ console.print()
122
+ console.print(Rule(style=BORDER_SUBTLE))
123
+ console.print()
124
+
125
+
126
+ def print_user_message(content: str) -> None:
127
+ console.print()
128
+ console.print(Text("you", style=f"bold {SECONDARY}"))
129
+ console.print(Text(content))
130
+
131
+
132
+ def _agent_header() -> Text:
133
+ head = Text()
134
+ head.append(_context_mode, style=f"bold {PRIMARY}")
135
+ return head
136
+
137
+
138
+ def _agent_renderable(text_so_far: str):
139
+ if text_so_far:
140
+ body = Markdown(text_so_far, style=TEXT)
141
+ else:
142
+ body = Text("")
143
+ return Group(_agent_header(), body)
144
+
145
+
146
+ def stream_start() -> Live:
147
+ """Call when the first real content token of a response arrives.
148
+ Returns a Live object — pass it to stream_update() for each subsequent
149
+ chunk, and stream_stop() when the message is complete."""
150
+ live = Live(_agent_renderable(""), console=console, refresh_per_second=12)
151
+ live.start()
152
+ return live
153
+
154
+
155
+ def stream_update(live: Live, text_so_far: str) -> None:
156
+ live.update(_agent_renderable(text_so_far))
157
+
158
+
159
+ def stream_stop(live: Live) -> None:
160
+ live.stop()
161
+ console.print()
162
+
163
+
164
+ def print_thinking() -> None:
165
+ line = Text()
166
+ line.append("thinking\u2026 ", style=TEXT_MUTED)
167
+ line.append("(esc to interrupt)", style=TEXT_MUTED)
168
+ console.print(line)
169
+
170
+
171
+ def _direction(name: str) -> str:
172
+ return "read" if any(k in name for k in _READ_KINDS) else "write"
173
+
174
+
175
+ def print_tool_call(name: str, args: dict) -> None:
176
+ line = Text()
177
+ if _direction(name) == "read":
178
+ line.append("\u2192 ", style=f"bold {INFO}")
179
+ else:
180
+ line.append("\u2190 ", style=f"bold {PRIMARY}")
181
+ line.append(name, style=f"bold {TEXT}")
182
+ args_str = ", ".join(f"{k}={v!r}" for k, v in args.items())
183
+ if len(args_str) > 140:
184
+ args_str = args_str[:140] + "\u2026"
185
+ if args_str:
186
+ line.append(f"({args_str})", style=TEXT_MUTED)
187
+ console.print(line)
188
+
189
+
190
+ def print_tool_result(content: str) -> None:
191
+ preview = content.strip().splitlines()[0] if content.strip() else ""
192
+ if len(preview) > 120:
193
+ preview = preview[:120] + "\u2026"
194
+ console.print(Text(preview, style=TEXT_MUTED))
195
+
196
+
197
+ def print_turn_complete(duration: float) -> None:
198
+ console.print()
199
+ marker = Text()
200
+ marker.append("\u25a3 ", style=TEXT_MUTED)
201
+ marker.append(_context_mode, style=f"bold {PRIMARY}")
202
+ marker.append(f" \u00b7 {duration:.1f}s", style=TEXT_MUTED)
203
+ console.print(marker)
204
+
205
+
206
+ def print_token_usage(summary: str) -> None:
207
+ console.print(Text(summary, style=TEXT_MUTED))
208
+
209
+
210
+ def print_notice(text: str, style: str = "dim") -> None:
211
+ color = _NAMED_STYLES.get(style, style)
212
+ console.print(f"[{color}]{text}[/{color}]", overflow="ellipsis")
213
+
214
+
215
+ def print_sessions(sessions: list) -> None:
216
+ """Render saved sessions with their metadata. `sessions` items are
217
+ session.SessionInfo dataclasses."""
218
+ if not sessions:
219
+ console.print(Text("No saved sessions yet.", style=TEXT_MUTED))
220
+ return
221
+ table = Table(title="sessions", title_justify="left",
222
+ border_style=BORDER_SUBTLE, pad_edge=False)
223
+ table.add_column("#", justify="right", style=TEXT_MUTED, width=3)
224
+ table.add_column("name", no_wrap=True, style=TEXT, max_width=42, overflow="ellipsis")
225
+ table.add_column("mode", justify="center", style=ACCENT)
226
+ table.add_column("model", style=TEXT_MUTED, max_width=24, overflow="ellipsis")
227
+ table.add_column("msgs", justify="right", style=INFO)
228
+ table.add_column("updated", style=TEXT_MUTED)
229
+ for s in sessions:
230
+ when = datetime.fromtimestamp(s.updated_at).strftime("%b %d %H:%M")
231
+ table.add_row(
232
+ str(s.id),
233
+ s.name or "(unnamed)",
234
+ s.mode or "build",
235
+ s.model or "-",
236
+ str(s.message_count),
237
+ when,
238
+ )
239
+ console.print(table)
240
+ console.print(Text("/resume <id> to switch \u00b7 /delete <id> to remove",
241
+ style=TEXT_MUTED))
242
+
243
+
244
+ def print_help() -> None:
245
+ text = (
246
+ "/plan switch to read-only plan mode (explore only, no writes/commits)\n"
247
+ "/build switch to build mode (all tools enabled)\n"
248
+ "/models [q] list OpenRouter models (● = current); --refresh updates\n"
249
+ "/model <n|id> switch model by list number or any OpenRouter model id\n"
250
+ "/key paste a new OpenRouter API key (optionally saved to .env)\n"
251
+ "/sessions list saved sessions (SQLite) with metadata\n"
252
+ "/resume <id> resume a saved conversation\n"
253
+ "/delete <id> delete a saved session\n"
254
+ "/usage show cumulative token usage this session\n"
255
+ "/clear clear conversation history (session file untouched)\n"
256
+ "/help show this message\n"
257
+ "esc interrupt the agent mid-turn\n"
258
+ "exit / quit quit"
259
+ )
260
+ console.print(
261
+ Panel(Text(text), title="commands", title_align="left",
262
+ border_style=BORDER_SUBTLE)
263
+ )
264
+
265
+
266
+ def prompt_api_key() -> str:
267
+ """Ask the user to paste their OpenRouter API key without echoing it
268
+ to the terminal. Returns the stripped key, or "" if nothing entered."""
269
+ console.print()
270
+ console.print(Text("Paste your OpenRouter API key (input is hidden).", style=f"bold {WARNING}"))
271
+ console.print(Text("Get one at https://openrouter.ai/settings/keys", style=TEXT_MUTED))
272
+ try:
273
+ key = getpass("key: ")
274
+ except Exception:
275
+ # getpass can fail when stdin isn't a real TTY — fall back to a
276
+ # visible prompt rather than crashing.
277
+ key = console.input("key: ")
278
+ return (key or "").strip()
279
+
280
+
281
+ def confirm_save_key() -> bool:
282
+ """Ask whether the just-pasted key should persist into .env."""
283
+ answer = console.input("Save this key to .env for next time? [y/N] ").strip().lower()
284
+ return answer in ("y", "yes")
285
+
286
+
287
+ _MODELS_DISPLAY_LIMIT = 80
288
+
289
+
290
+ def print_models(models: list, current: str, source: str = "live", query: str = None) -> None:
291
+ """Render a model list (from /models), starring the active model.
292
+
293
+ The live OpenRouter list has hundreds of entries, so only the first
294
+ _MODELS_DISPLAY_LIMIT rows are shown — the footer says how to narrow
295
+ it. Numbering matches list position so `/model <number>` picks the
296
+ row the user sees.
297
+ """
298
+ total = len(models)
299
+ shown = models[:_MODELS_DISPLAY_LIMIT]
300
+ table = Table(title="models", title_justify="left",
301
+ border_style=BORDER_SUBTLE, pad_edge=False)
302
+ table.add_column("#", justify="right", style=TEXT_MUTED, width=4)
303
+ table.add_column("", width=2)
304
+ table.add_column("model id", style=TEXT, no_wrap=True, max_width=44, overflow="ellipsis")
305
+ table.add_column("notes", style=TEXT_MUTED, max_width=40, overflow="ellipsis")
306
+ for i, (mid, note) in enumerate(shown, 1):
307
+ marker = Text("●", style=PRIMARY) if mid == current else Text(" ")
308
+ table.add_row(str(i), marker, mid, note)
309
+ console.print(table)
310
+
311
+ footer = Text()
312
+ if source == "cache":
313
+ footer.append("from cache (24h) · ", style=TEXT_MUTED)
314
+ elif source == "fallback":
315
+ footer.append("offline — showing curated shortlist · ", style=WARNING)
316
+ if query:
317
+ footer.append(f"{total} match '{query}'", style=TEXT_MUTED)
318
+ else:
319
+ footer.append(f"showing {len(shown)} of {total}", style=TEXT_MUTED)
320
+ console.print(footer)
321
+ hints = Text("/model <number> to switch · /models <query> to filter · /models --refresh to update",
322
+ style=TEXT_MUTED)
323
+ console.print(hints)
324
+
325
+
326
+ class EscListener:
327
+ """Watches stdin for an Esc keypress on a background thread while a turn
328
+ is streaming, without blocking the asyncio event loop.
329
+
330
+ Terminal input is a blocking, thread-only affair (raw/cbreak mode via
331
+ termios), so this runs on its own thread and hands control back to the
332
+ event loop by calling `event.set()` through `loop.call_soon_threadsafe`.
333
+ Safe to call `start()`/`stop()` even when stdin isn't a real TTY (e.g.
334
+ piped input, some CI environments) — it just no-ops in that case.
335
+ """
336
+
337
+ def __init__(self, loop, event) -> None:
338
+ self._loop = loop
339
+ self._event = event
340
+ self._stop = threading.Event()
341
+ self._thread: threading.Thread | None = None
342
+
343
+ def start(self) -> None:
344
+ if not sys.stdin.isatty():
345
+ return
346
+ if not _HAS_TERMIOS and not _HAS_MSVCRT:
347
+ return
348
+ self._stop.clear()
349
+ self._thread = threading.Thread(target=self._watch, daemon=True)
350
+ self._thread.start()
351
+
352
+ def _signal_esc(self) -> None:
353
+ self._loop.call_soon_threadsafe(self._event.set)
354
+
355
+ def _watch(self) -> None:
356
+ if _HAS_TERMIOS:
357
+ self._watch_termios()
358
+ elif _HAS_MSVCRT:
359
+ self._watch_msvcrt()
360
+
361
+ def _watch_termios(self) -> None:
362
+ fd = sys.stdin.fileno()
363
+ old_settings = termios.tcgetattr(fd)
364
+ try:
365
+ tty.setcbreak(fd)
366
+ while not self._stop.is_set():
367
+ ready, _, _ = select.select([sys.stdin], [], [], 0.1)
368
+ if ready:
369
+ ch = sys.stdin.read(1)
370
+ if ch == "\x1b":
371
+ self._signal_esc()
372
+ return
373
+ finally:
374
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
375
+
376
+ def _watch_msvcrt(self) -> None: # pragma: no cover - Windows only
377
+ while not self._stop.is_set():
378
+ if msvcrt.kbhit():
379
+ ch = msvcrt.getch()
380
+ if ch == b"\x1b":
381
+ self._signal_esc()
382
+ return
383
+ else:
384
+ self._stop.wait(0.1)
385
+
386
+ def stop(self) -> None:
387
+ self._stop.set()
388
+ if self._thread is not None:
389
+ self._thread.join(timeout=0.3)
390
+ self._thread = None
391
+
392
+
393
+ def confirm(question: str) -> str:
394
+ """Permission prompt styled like an opencode permission dialog.
395
+ Returns "allow", "always", or "deny"."""
396
+ console.print()
397
+ body = Text()
398
+ body.append("\u25b3 ", style=f"bold {WARNING}")
399
+ body.append(f"Allow agent to {question}?", style=TEXT)
400
+ console.print(Panel(body, title="permission", title_align="left",
401
+ border_style=BORDER_SUBTLE, padding=(0, 1)))
402
+ opts = Text()
403
+ opts.append("1", style=f"bold {SUCCESS}"); opts.append(") Allow ")
404
+ opts.append("2", style=f"bold {WARNING}"); opts.append(") Always Allow ")
405
+ opts.append("3", style=f"bold {ERROR}"); opts.append(") Don't Allow")
406
+ console.print(opts)
407
+ answer = console.input("[1/2/3] ").strip()
408
+ if answer == "1":
409
+ return "allow"
410
+ if answer == "2":
411
+ return "always"
412
+ return "deny"
413
+
414
+
415
+ def user_prompt(mode: str) -> str:
416
+ console.print()
417
+ console.print(Rule(style=BORDER_SUBTLE))
418
+ prompt = Text()
419
+ prompt.append("> ", style=f"bold {TEXT}")
420
+ prompt.append(mode, style=TEXT_MUTED)
421
+ prompt.append(" ")
422
+ return console.input(prompt).strip()