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/__init__.py +4 -0
- closecode/__main__.py +6 -0
- closecode/agent/__init__.py +79 -0
- closecode/app.py +691 -0
- closecode/auth.py +22 -0
- closecode/config.py +97 -0
- closecode/llmesh/__init__.py +91 -0
- closecode/llmesh/client.py +221 -0
- closecode/llmesh/streaming.py +47 -0
- closecode/py.typed +0 -0
- closecode/sessions.py +176 -0
- closecode/ui/__init__.py +1 -0
- closecode/ui/screens/__init__.py +1 -0
- closecode/ui/screens/main.py +125 -0
- closecode/ui/screens/onboarding.py +101 -0
- closecode/ui/theme.tcss +374 -0
- closecode/ui/widgets/__init__.py +1 -0
- closecode/ui/widgets/chat.py +105 -0
- closecode/ui/widgets/composer.py +52 -0
- closecode/ui/widgets/header.py +53 -0
- closecode/ui/widgets/infopanel.py +101 -0
- closecode/ui/widgets/statusbar.py +60 -0
- closecode-0.1.0.dist-info/METADATA +404 -0
- closecode-0.1.0.dist-info/RECORD +28 -0
- closecode-0.1.0.dist-info/WHEEL +5 -0
- closecode-0.1.0.dist-info/entry_points.txt +2 -0
- closecode-0.1.0.dist-info/licenses/LICENSE +21 -0
- closecode-0.1.0.dist-info/top_level.txt +1 -0
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()
|