closecode 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.
closecode/app.py ADDED
@@ -0,0 +1,691 @@
1
+ """Close Code — Main Application.
2
+
3
+ Entry point: `closecode` command launches this.
4
+ Streaming chat with session persistence and model management.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import asyncio
11
+ from typing import List, Optional
12
+
13
+ from textual.app import App
14
+ from textual.worker import Worker
15
+
16
+ from closecode import __version__
17
+ from closecode.config import CloseCodeConfig, CLOSECODE_DIR, SESSIONS_DIR, LOGS_DIR
18
+ from closecode.auth import store_api_key, mask_key
19
+ from closecode.agent import AgentState, AgentStatus, ChatMessage
20
+ from closecode.llmesh import ModelInfo, ModelStatus
21
+ from closecode.llmesh.client import LLMeshClient, APIError
22
+ from closecode.llmesh.streaming import StreamError
23
+ from closecode.sessions import SessionManager, Session, SessionMessage
24
+
25
+ from closecode.ui.screens.onboarding import OnboardingScreen, OnboardingComplete
26
+ from closecode.ui.screens.main import MainScreen
27
+ from closecode.ui.widgets.chat import CloseCodeChat
28
+ from closecode.ui.widgets.composer import CloseCodeComposer, ComposerSubmit
29
+ from closecode.ui.widgets.statusbar import CloseCodeStatusBar
30
+ from closecode.ui.widgets.header import CloseCodeHeader
31
+ from closecode.ui.widgets.infopanel import CloseCodeInfoPanel
32
+
33
+
34
+ SYSTEM_PROMPT = (
35
+ "You are Close Code, a helpful AI assistant powered by LLMesh. "
36
+ "Give clear, concise answers. Use markdown formatting when helpful."
37
+ )
38
+
39
+
40
+ class CloseCodeApp(App):
41
+ """Close Code — Terminal AI assistant powered by LLMesh."""
42
+
43
+ TITLE = "Close Code"
44
+ CSS_PATH = "ui/theme.tcss"
45
+
46
+ # ── State ───────────────────────────────────────────────────
47
+
48
+ def __init__(self):
49
+ super().__init__()
50
+ self.config = CloseCodeConfig.load()
51
+ self.state = AgentState()
52
+ self.client: Optional[LLMeshClient] = None
53
+ self.models: List[ModelInfo] = []
54
+ self.session_mgr = SessionManager()
55
+ self.current_session: Optional[Session] = None
56
+ self._generation_worker: Optional[Worker] = None
57
+ self._cancel_flag = False
58
+ self._model_status_tag = "" # Status tag for the info panel
59
+
60
+ # ── Lifecycle ───────────────────────────────────────────────
61
+
62
+ def on_mount(self):
63
+ """Start with onboarding or main screen."""
64
+ CLOSECODE_DIR.mkdir(parents=True, exist_ok=True)
65
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
66
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
67
+
68
+ if self.config.is_configured():
69
+ self.client = LLMeshClient(self.config.api_url, self.config.api_key)
70
+ self.push_screen(MainScreen())
71
+ self.run_worker(self._verify_and_load, thread=False)
72
+ else:
73
+ self.push_screen(OnboardingScreen(
74
+ default_url=self.config.api_url,
75
+ default_key=self.config.api_key,
76
+ ))
77
+
78
+ async def on_unmount(self):
79
+ self._auto_save_session()
80
+ if self.client:
81
+ await self.client.close()
82
+
83
+ # ── Onboarding ──────────────────────────────────────────────
84
+
85
+ def on_onboarding_complete(self, event: OnboardingComplete):
86
+ async def run_onboarding():
87
+ await self._do_onboarding(event.api_url, event.api_key)
88
+ self.run_worker(run_onboarding, thread=False)
89
+
90
+ async def _do_onboarding(self, api_url: str, api_key: str):
91
+ screen = self.screen
92
+ if not isinstance(screen, OnboardingScreen):
93
+ return
94
+
95
+ screen.show_status("Connecting…")
96
+ self.client = LLMeshClient(api_url, api_key)
97
+
98
+ connected = await self.client.verify_connection()
99
+ if not connected:
100
+ screen.show_status("[red]✗ Cannot reach LLMesh endpoint[/]")
101
+ return
102
+
103
+ screen.show_status("✓ Endpoint reachable\nLoading models…")
104
+
105
+ try:
106
+ self.models = await self.client.list_models()
107
+ except Exception as e:
108
+ screen.show_status(f"[red]✗ Failed to load models: {e}[/]")
109
+ return
110
+
111
+ screen.show_status(
112
+ f"✓ Connected\n"
113
+ f"✓ {len(self.models)} models loaded\n\n"
114
+ f"Welcome to Close Code."
115
+ )
116
+
117
+ self.config.api_url = api_url
118
+ store_api_key(self.config, api_key)
119
+ self._select_default_model()
120
+ self.state.connected = True
121
+
122
+ await asyncio.sleep(1.0)
123
+
124
+ self.pop_screen()
125
+ self.push_screen(MainScreen())
126
+ self._start_health_check()
127
+ self._refresh_ui()
128
+
129
+ # ── Verify (returning user) ─────────────────────────────────
130
+
131
+ async def _verify_and_load(self):
132
+ if not self.client:
133
+ return
134
+
135
+ connected = await self.client.verify_connection()
136
+ self.state.connected = connected
137
+
138
+ if connected:
139
+ try:
140
+ self.models = await self.client.list_models()
141
+ except Exception:
142
+ pass
143
+
144
+ if self.config.current_model:
145
+ available_names = [m.name for m in self.models if m.model_status.selectable]
146
+ if self.config.current_model in available_names:
147
+ self.state.model = self.config.current_model
148
+ self.state.provider = self.config.current_provider
149
+ else:
150
+ old = self.config.current_model
151
+ self._select_default_model()
152
+ self._chat_status(
153
+ f"⚠ Model '{old}' is no longer available. "
154
+ f"Switched to {self.state.model}."
155
+ )
156
+ else:
157
+ self._select_default_model()
158
+
159
+ # Probe the current model to check if upstream key works
160
+ self.run_worker(self._probe_model, thread=False)
161
+
162
+ self._start_health_check()
163
+ self._refresh_ui()
164
+
165
+ def _select_default_model(self):
166
+ """Select the first active model as default."""
167
+ active = [m for m in self.models if m.model_status.selectable]
168
+ if active:
169
+ model = active[0]
170
+ self.state.model = model.name
171
+ self.state.provider = model.provider or ""
172
+ if model.context_window:
173
+ self.state.context_total = model.context_window
174
+ self.config.current_model = self.state.model
175
+ self.config.current_provider = self.state.provider
176
+ self.config.save()
177
+
178
+ # ── Model Probe (check upstream API key) ────────────────────
179
+
180
+ async def _probe_model(self):
181
+ """Quick probe: send a tiny request to check if the upstream key works."""
182
+ if not self.client or not self.state.model:
183
+ return
184
+
185
+ try:
186
+ result = await self.client.chat(
187
+ messages=[{"role": "user", "content": "hi"}],
188
+ model=self.state.model,
189
+ max_tokens=1,
190
+ )
191
+ self._model_status_tag = "[#22c55e]● Key Valid[/]"
192
+ except APIError as e:
193
+ if e.status_code == 401 or e.status_code == 403:
194
+ self._model_status_tag = "[#ef4444]✗ API Key Invalid[/]"
195
+ elif e.status_code == 429:
196
+ self._model_status_tag = "[#f59e0b]⚠ Rate Limited[/]"
197
+ elif e.status_code == 404:
198
+ self._model_status_tag = "[#ef4444]✗ Model Not Found[/]"
199
+ else:
200
+ self._model_status_tag = f"[#f59e0b]⚠ Error {e.status_code}[/]"
201
+ except Exception:
202
+ self._model_status_tag = "[#f59e0b]⚠ Probe Failed[/]"
203
+
204
+ self._refresh_ui()
205
+
206
+ # ── Health Check ────────────────────────────────────────────
207
+
208
+ def _start_health_check(self):
209
+ self.set_interval(30, self._check_health)
210
+
211
+ async def _check_health(self):
212
+ if self.client:
213
+ connected = await self.client.health_check()
214
+ if connected != self.state.connected:
215
+ self.state.connected = connected
216
+ self._refresh_ui()
217
+
218
+ # ── Message Handling ────────────────────────────────────────
219
+
220
+ def on_composer_submit(self, event: ComposerSubmit):
221
+ text = event.text
222
+ if text.startswith("/"):
223
+ self._handle_command(text)
224
+ return
225
+ self._send_message(text)
226
+
227
+ def _send_message(self, text: str):
228
+ if not self.state.model:
229
+ self._chat_status("No model selected. Use /models to see available models.")
230
+ return
231
+ if not self.state.connected:
232
+ self._chat_status("Not connected to LLMesh.")
233
+ return
234
+ if self.state.status == AgentStatus.STREAMING:
235
+ self._chat_status("Already generating. Press Ctrl+C to cancel.")
236
+ return
237
+
238
+ # Transition from welcome → chat if needed
239
+ if isinstance(self.screen, MainScreen):
240
+ self.screen.ensure_chat_mode()
241
+
242
+ # Ensure we have a session
243
+ if self.current_session is None:
244
+ self.current_session = self.session_mgr.create(model=self.state.model)
245
+
246
+ self.state.add_message("user", text)
247
+
248
+ try:
249
+ self.screen.query_one(CloseCodeChat).add_user_message(text)
250
+ except Exception:
251
+ pass
252
+
253
+ # Add task to info panel
254
+ try:
255
+ short = text[:40] + "…" if len(text) > 40 else text
256
+ self.screen.query_one(CloseCodeInfoPanel).add_task(short)
257
+ except Exception:
258
+ pass
259
+
260
+ self._cancel_flag = False
261
+ self.state.status = AgentStatus.STREAMING
262
+ self._refresh_ui()
263
+ self._generation_worker = self.run_worker(
264
+ self._stream_response, thread=False
265
+ )
266
+
267
+ async def _stream_response(self):
268
+ """Stream a response from LLMesh."""
269
+ try:
270
+ chat = self.screen.query_one(CloseCodeChat)
271
+ except Exception:
272
+ return
273
+
274
+ chat.add_assistant_start()
275
+
276
+ messages = self.state.get_messages_for_api(SYSTEM_PROMPT)
277
+ full_content = ""
278
+
279
+ try:
280
+ async for token in self.client.chat_stream(
281
+ messages=messages,
282
+ model=self.state.model,
283
+ ):
284
+ if self._cancel_flag:
285
+ chat.add_status_message("Generation cancelled", "✗")
286
+ break
287
+
288
+ full_content += token
289
+ chat.add_streaming_token(token)
290
+
291
+ chat.finish_streaming(full_content)
292
+
293
+ if full_content and not self._cancel_flag:
294
+ self.state.add_message("assistant", full_content, model=self.state.model)
295
+
296
+ est_tokens = len(full_content) // 4
297
+ self.state.context_used += est_tokens
298
+ chat.add_usage_info(tokens=est_tokens, model=self.state.model)
299
+
300
+ # Mark last task as done
301
+ try:
302
+ panel = self.screen.query_one(CloseCodeInfoPanel)
303
+ if panel._tasks:
304
+ panel.mark_task_done(len(panel._tasks) - 1)
305
+ except Exception:
306
+ pass
307
+
308
+ self._auto_save_session()
309
+
310
+ except APIError as e:
311
+ chat.finish_streaming()
312
+ if e.status_code in (404, 410):
313
+ chat.add_status_message(
314
+ f"Model '{self.state.model}' is no longer available. Use /models to switch.", "✗"
315
+ )
316
+ self._model_status_tag = "[#ef4444]✗ Model Unavailable[/]"
317
+ for m in self.models:
318
+ if m.name == self.state.model:
319
+ m.status = False
320
+ break
321
+ elif e.status_code in (401, 403):
322
+ chat.add_status_message(
323
+ f"API key for '{self.state.model}' is invalid or expired. Check your .env config.", "✗"
324
+ )
325
+ self._model_status_tag = "[#ef4444]✗ API Key Invalid[/]"
326
+ elif e.status_code == 429:
327
+ chat.add_status_message(
328
+ f"Rate limited on '{self.state.model}'. Wait a moment or switch models (/models).", "⚠"
329
+ )
330
+ self._model_status_tag = "[#f59e0b]⚠ Rate Limited[/]"
331
+ else:
332
+ chat.add_status_message(f"API error: {e.message}", "✗")
333
+ except StreamError as e:
334
+ chat.finish_streaming()
335
+ chat.add_status_message(f"Stream error: {e}", "✗")
336
+ except Exception as e:
337
+ chat.finish_streaming()
338
+ chat.add_status_message(f"Error: {str(e)[:100]}", "✗")
339
+
340
+ self.state.status = AgentStatus.IDLE
341
+ self._refresh_ui()
342
+
343
+ # ── Session Management ──────────────────────────────────────
344
+
345
+ def _auto_save_session(self):
346
+ """Save the current session to disk."""
347
+ if self.current_session is None or not self.state.messages:
348
+ return
349
+
350
+ self.current_session.messages = [
351
+ SessionMessage(
352
+ role=m.role,
353
+ content=m.content,
354
+ timestamp=m.timestamp.isoformat() if hasattr(m.timestamp, 'isoformat') else str(m.timestamp),
355
+ model=m.model,
356
+ )
357
+ for m in self.state.messages
358
+ ]
359
+ self.current_session.model = self.state.model
360
+
361
+ if self.current_session.title == "New session" and self.current_session.messages:
362
+ self.current_session.title = self.session_mgr.auto_title(
363
+ self.current_session.messages
364
+ )
365
+
366
+ self.session_mgr.save(self.current_session)
367
+
368
+ def _restore_session(self, session: Session):
369
+ """Restore a session: load messages into state and render in chat."""
370
+ self._auto_save_session()
371
+ self.state.clear_conversation()
372
+
373
+ # Ensure chat mode
374
+ if isinstance(self.screen, MainScreen):
375
+ self.screen.ensure_chat_mode()
376
+
377
+ try:
378
+ self.screen.query_one(CloseCodeChat).clear_chat()
379
+ except Exception:
380
+ pass
381
+
382
+ self.current_session = session
383
+
384
+ available_names = [m.name for m in self.models if m.model_status.selectable]
385
+ if session.model and session.model in available_names:
386
+ self.state.model = session.model
387
+ for m in self.models:
388
+ if m.name == session.model:
389
+ self.state.provider = m.provider or ""
390
+ break
391
+ elif session.model:
392
+ self._chat_status(
393
+ f"⚠ Session model '{session.model}' is no longer available. "
394
+ f"Using {self.state.model} instead."
395
+ )
396
+
397
+ for msg in session.messages:
398
+ self.state.add_message(msg.role, msg.content, model=msg.model)
399
+
400
+ try:
401
+ chat = self.screen.query_one(CloseCodeChat)
402
+ for msg in session.messages:
403
+ if msg.role == "user":
404
+ chat.add_user_message(msg.content)
405
+ elif msg.role == "assistant":
406
+ chat.add_assistant_message(msg.content)
407
+ chat.add_status_message(
408
+ f"Session restored: {session.title} ({session.message_count} messages)", "✓"
409
+ )
410
+ except Exception:
411
+ pass
412
+
413
+ # Rebuild task list from user messages
414
+ try:
415
+ panel = self.screen.query_one(CloseCodeInfoPanel)
416
+ tasks = []
417
+ for msg in session.messages:
418
+ if msg.role == "user":
419
+ short = msg.content[:40] + "…" if len(msg.content) > 40 else msg.content
420
+ tasks.append((short, True))
421
+ panel.set_tasks(tasks)
422
+ except Exception:
423
+ pass
424
+
425
+ self._refresh_ui()
426
+
427
+ # ── Command Handling ────────────────────────────────────────
428
+
429
+ def _handle_command(self, text: str):
430
+ # Ensure we're in chat mode for command output
431
+ if isinstance(self.screen, MainScreen):
432
+ self.screen.ensure_chat_mode()
433
+
434
+ parts = text.strip().split(maxsplit=1)
435
+ cmd = parts[0].lower()
436
+ arg = parts[1] if len(parts) > 1 else ""
437
+
438
+ handlers = {
439
+ "/help": lambda: self._cmd_help(),
440
+ "/models": lambda: self._cmd_models(),
441
+ "/model": lambda: self._cmd_model_select(arg),
442
+ "/sessions": lambda: self._cmd_sessions(),
443
+ "/session": lambda: self._cmd_session_select(arg),
444
+ "/save": lambda: self._cmd_save(),
445
+ "/delete": lambda: self._cmd_delete(arg),
446
+ "/rename": lambda: self._cmd_rename(arg),
447
+ "/new": lambda: self.action_new_session(),
448
+ "/clear": lambda: self.action_clear_chat(),
449
+ "/status": lambda: self._cmd_status(),
450
+ "/exit": lambda: self.exit(),
451
+ }
452
+
453
+ handler = handlers.get(cmd)
454
+ if handler:
455
+ handler()
456
+ else:
457
+ self._chat_status(f"Unknown command: {cmd}. Type /help for commands.")
458
+
459
+ def _cmd_help(self):
460
+ help_text = (
461
+ "**Commands**\n\n"
462
+ "| Command | Description |\n"
463
+ "|---------|-------------|\n"
464
+ "| `/help` | Show this help |\n"
465
+ "| `/models` | List available models |\n"
466
+ "| `/model N` | Select model by number |\n"
467
+ "| `/sessions` | List saved sessions |\n"
468
+ "| `/session N` | Resume session by number |\n"
469
+ "| `/save` | Force save current session |\n"
470
+ "| `/rename TEXT` | Rename current session |\n"
471
+ "| `/delete N` | Delete a saved session |\n"
472
+ "| `/new` | New session |\n"
473
+ "| `/clear` | Clear chat display |\n"
474
+ "| `/status` | Connection status |\n"
475
+ "| `/exit` | Exit Close Code |\n\n"
476
+ "**Keys**: Enter → send · Ctrl+C → cancel · Ctrl+N → new session"
477
+ )
478
+ try:
479
+ self.screen.query_one(CloseCodeChat).add_assistant_message(help_text)
480
+ except Exception:
481
+ pass
482
+
483
+ def _cmd_models(self):
484
+ if not self.models:
485
+ self._chat_status("No models loaded. Check LLMesh connection.")
486
+ return
487
+
488
+ lines = ["**Available Models**\n"]
489
+ for i, m in enumerate(self.models, 1):
490
+ ms = m.model_status
491
+ active = " ◀" if m.name == self.state.model else ""
492
+ provider = m.provider or "—"
493
+ lines.append(
494
+ f"{i}. {ms.icon} **{m.display_name}**{active} — {provider} · {ms.label}"
495
+ )
496
+ lines.append(f"\n`/model N` to select (e.g. `/model 1`)")
497
+
498
+ try:
499
+ self.screen.query_one(CloseCodeChat).add_assistant_message("\n".join(lines))
500
+ except Exception:
501
+ pass
502
+
503
+ def _cmd_model_select(self, arg: str):
504
+ if not arg:
505
+ self._cmd_models()
506
+ return
507
+
508
+ try:
509
+ idx = int(arg) - 1
510
+ if 0 <= idx < len(self.models):
511
+ model = self.models[idx]
512
+ if not model.model_status.selectable:
513
+ self._chat_status(
514
+ f"Cannot select '{model.display_name}' — "
515
+ f"status: {model.model_status.label}. Choose an active model."
516
+ )
517
+ return
518
+
519
+ self.state.model = model.name
520
+ self.state.provider = model.provider or ""
521
+ if model.context_window:
522
+ self.state.context_total = model.context_window
523
+ self.config.current_model = self.state.model
524
+ self.config.current_provider = self.state.provider
525
+ self.config.save()
526
+ self._chat_status(f"Model: {model.display_name} ({model.provider or '—'})")
527
+
528
+ # Re-probe the new model
529
+ self._model_status_tag = "[#71717a]● Checking…[/]"
530
+ self._refresh_ui()
531
+ self.run_worker(self._probe_model, thread=False)
532
+ else:
533
+ self._chat_status(f"Invalid number. Use /models to see the list.")
534
+ except ValueError:
535
+ self._chat_status("Usage: `/model N` (e.g. `/model 1`)")
536
+
537
+ def _cmd_sessions(self):
538
+ sessions = self.session_mgr.list_sessions(limit=10)
539
+ if not sessions:
540
+ self._chat_status("No saved sessions. Start chatting to create one!")
541
+ return
542
+
543
+ lines = ["**Saved Sessions**\n"]
544
+ for i, s in enumerate(sessions, 1):
545
+ current = " ◀" if self.current_session and s.id == self.current_session.id else ""
546
+ lines.append(
547
+ f"{i}. **{s.title}**{current} — {s.age_label} · "
548
+ f"{s.message_count} msgs · {s.model or '—'}"
549
+ )
550
+ lines.append(f"\n`/session N` to resume · `/delete N` to remove")
551
+
552
+ try:
553
+ self.screen.query_one(CloseCodeChat).add_assistant_message("\n".join(lines))
554
+ except Exception:
555
+ pass
556
+
557
+ def _cmd_session_select(self, arg: str):
558
+ if not arg:
559
+ self._cmd_sessions()
560
+ return
561
+
562
+ sessions = self.session_mgr.list_sessions(limit=20)
563
+ try:
564
+ idx = int(arg) - 1
565
+ if 0 <= idx < len(sessions):
566
+ session = self.session_mgr.load(sessions[idx].id)
567
+ if session:
568
+ self._restore_session(session)
569
+ else:
570
+ self._chat_status("Failed to load session.")
571
+ else:
572
+ self._chat_status("Invalid session number. Use /sessions to see the list.")
573
+ except ValueError:
574
+ self._chat_status("Usage: `/session N` (e.g. `/session 1`)")
575
+
576
+ def _cmd_save(self):
577
+ if self.current_session and self.state.messages:
578
+ self._auto_save_session()
579
+ self._chat_status(f"Session saved: {self.current_session.title}")
580
+ else:
581
+ self._chat_status("Nothing to save. Start chatting first.")
582
+
583
+ def _cmd_delete(self, arg: str):
584
+ if not arg:
585
+ self._chat_status("Usage: `/delete N` (e.g. `/delete 3`)")
586
+ return
587
+
588
+ sessions = self.session_mgr.list_sessions(limit=20)
589
+ try:
590
+ idx = int(arg) - 1
591
+ if 0 <= idx < len(sessions):
592
+ target = sessions[idx]
593
+ if self.current_session and target.id == self.current_session.id:
594
+ self._chat_status("Cannot delete the active session. Use /new first.")
595
+ return
596
+ self.session_mgr.delete(target.id)
597
+ self._chat_status(f"Deleted session: {target.title}")
598
+ else:
599
+ self._chat_status("Invalid session number.")
600
+ except ValueError:
601
+ self._chat_status("Usage: `/delete N`")
602
+
603
+ def _cmd_rename(self, arg: str):
604
+ if not arg:
605
+ self._chat_status("Usage: `/rename My Project`")
606
+ return
607
+ if self.current_session:
608
+ self.current_session.title = arg.strip()
609
+ self._auto_save_session()
610
+ self._chat_status(f"Session renamed to: {arg.strip()}")
611
+ self._refresh_ui()
612
+ else:
613
+ self._chat_status("No active session to rename.")
614
+
615
+ def _cmd_status(self):
616
+ status = "Connected" if self.state.connected else "Disconnected"
617
+ session_info = self.current_session.title if self.current_session else "None"
618
+ self._chat_status(
619
+ f"Server: {self.config.api_url} · {status}\n"
620
+ f"Model: {self.state.model or 'None'} · Provider: {self.state.provider or '—'}\n"
621
+ f"Session: {session_info}"
622
+ )
623
+
624
+ # ── Actions ─────────────────────────────────────────────────
625
+
626
+ def action_clear_chat(self):
627
+ try:
628
+ self.screen.query_one(CloseCodeChat).clear_chat()
629
+ except Exception:
630
+ pass
631
+
632
+ def action_new_session(self):
633
+ self._auto_save_session()
634
+ self.state.clear_conversation()
635
+ self.current_session = None
636
+ self._model_status_tag = ""
637
+
638
+ if isinstance(self.screen, MainScreen):
639
+ self.screen.ensure_chat_mode()
640
+
641
+ try:
642
+ self.screen.query_one(CloseCodeChat).clear_chat()
643
+ except Exception:
644
+ pass
645
+
646
+ try:
647
+ self.screen.query_one(CloseCodeInfoPanel).set_tasks([])
648
+ except Exception:
649
+ pass
650
+
651
+ self._chat_status("New session started.")
652
+ self._refresh_ui()
653
+
654
+ def action_cancel_generation(self):
655
+ if self.state.status == AgentStatus.STREAMING:
656
+ self._cancel_flag = True
657
+ else:
658
+ self.exit()
659
+
660
+ # ── UI Helpers ──────────────────────────────────────────────
661
+
662
+ def _refresh_ui(self):
663
+ if isinstance(self.screen, MainScreen):
664
+ self.screen.update_state(self.state, self.current_session)
665
+
666
+ def _chat_status(self, message: str):
667
+ try:
668
+ self.screen.query_one(CloseCodeChat).add_status_message(message, "·")
669
+ except Exception:
670
+ pass
671
+
672
+
673
+ def main():
674
+ """Entry point for the `closecode` command."""
675
+ parser = argparse.ArgumentParser(
676
+ prog="closecode",
677
+ description="Close Code — a terminal-native AI coding agent powered by LLMesh.",
678
+ epilog="Run with no arguments to launch the TUI. "
679
+ "Configuration lives in ~/.closecode/config.json.",
680
+ )
681
+ parser.add_argument(
682
+ "--version", action="version", version=f"closecode {__version__}"
683
+ )
684
+ parser.parse_args()
685
+
686
+ app = CloseCodeApp()
687
+ app.run()
688
+
689
+
690
+ if __name__ == "__main__":
691
+ main()