codegpt-ai 2.18.0 → 2.26.0

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.
Files changed (4) hide show
  1. package/chat.py +50 -5
  2. package/desktop.py +642 -177
  3. package/package.json +2 -1
  4. package/tui.py +436 -0
package/desktop.py CHANGED
@@ -1,18 +1,17 @@
1
- """CodeGPT Desktop — Claude Code + OpenClaw style GUI."""
1
+ """CodeGPT Desktop — Claude + ChatGPT + OpenClaw style GUI."""
2
2
  import json
3
- import threading
3
+ import time
4
4
  import requests
5
5
  import webview
6
6
  import os
7
- import time
8
7
  from pathlib import Path
8
+ from datetime import datetime
9
9
 
10
10
  # Config
11
11
  OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434/api/chat")
12
12
  MODEL = "llama3.2"
13
13
  SYSTEM = "You are a helpful AI assistant. Be concise and technical."
14
14
 
15
- # Load saved URL
16
15
  saved_url = Path.home() / ".codegpt" / "ollama_url"
17
16
  if saved_url.exists():
18
17
  url = saved_url.read_text().strip()
@@ -21,93 +20,359 @@ if saved_url.exists():
21
20
  if "/api/chat" not in OLLAMA_URL:
22
21
  OLLAMA_URL = OLLAMA_URL.rstrip("/") + "/api/chat"
23
22
 
24
- # Try connect
25
23
  def try_connect(url):
26
24
  try:
27
- base = url.replace("/api/chat", "/api/tags")
28
- r = requests.get(base, timeout=3)
25
+ r = requests.get(url.replace("/api/chat", "/api/tags"), timeout=3)
29
26
  return [m["name"] for m in r.json().get("models", [])]
30
27
  except:
31
28
  return []
32
29
 
33
- # Auto-detect Ollama
34
30
  if not try_connect(OLLAMA_URL):
35
- for fallback in ["http://localhost:11434/api/chat", "http://127.0.0.1:11434/api/chat"]:
36
- if try_connect(fallback):
37
- OLLAMA_URL = fallback
31
+ for fb in ["http://localhost:11434/api/chat", "http://127.0.0.1:11434/api/chat"]:
32
+ if try_connect(fb):
33
+ OLLAMA_URL = fb
38
34
  break
39
35
 
40
- # Load profile
41
36
  profile_file = Path.home() / ".codegpt" / "profiles" / "cli_profile.json"
42
37
  USERNAME = "User"
38
+ PERSONA = "default"
43
39
  if profile_file.exists():
44
40
  try:
45
41
  p = json.loads(profile_file.read_text())
46
42
  USERNAME = p.get("name", "User")
47
43
  MODEL = p.get("model", MODEL)
44
+ PERSONA = p.get("persona", "default")
48
45
  except:
49
46
  pass
50
47
 
51
48
  OLLAMA_BASE = OLLAMA_URL.replace("/api/chat", "")
49
+ CHATS_DIR = Path.home() / ".codegpt" / "desktop_chats"
50
+ CHATS_DIR.mkdir(parents=True, exist_ok=True)
51
+
52
+ PERSONAS = {
53
+ "default": "You are a helpful AI assistant. Be concise and technical.",
54
+ "hacker": "You are a cybersecurity expert. Technical jargon, CVEs, defensive security. Dark humor.",
55
+ "teacher": "You are a patient programming teacher. Step by step, analogies, examples.",
56
+ "roast": "You are a brutally sarcastic code reviewer. Roast bad code but always give the fix.",
57
+ "architect": "You are a senior system architect. Scalability, distributed systems, ASCII diagrams.",
58
+ "minimal": "Shortest possible answer. One line if possible. Code only.",
59
+ }
52
60
 
53
61
 
54
62
  class Api:
55
- """Python bridge — handles Ollama calls for the JS frontend."""
56
-
57
63
  def __init__(self):
58
64
  self.messages = []
59
65
  self.total_tokens = 0
66
+ self.model = MODEL
67
+ self.persona = PERSONA
68
+ self.system = PERSONAS.get(PERSONA, SYSTEM)
69
+ self.chat_id = datetime.now().strftime("%Y%m%d_%H%M%S")
60
70
 
61
71
  def check_status(self):
62
72
  try:
63
73
  r = requests.get(OLLAMA_BASE + "/api/tags", timeout=3)
64
74
  models = [m["name"] for m in r.json().get("models", [])]
65
- return json.dumps({"online": True, "models": models, "model": MODEL})
75
+ return json.dumps({"online": True, "models": models, "model": self.model, "persona": self.persona})
66
76
  except:
67
- return json.dumps({"online": False, "models": [], "model": MODEL})
77
+ return json.dumps({"online": False, "models": [], "model": self.model, "persona": self.persona})
68
78
 
69
79
  def send_message(self, text):
70
- self.messages.append({"role": "user", "content": text})
80
+ # Handle slash commands
81
+ if text.startswith("/"):
82
+ result = self._handle_command(text)
83
+ if result:
84
+ return json.dumps({"content": result, "tokens": 0, "elapsed": 0, "total_tokens": self.total_tokens, "is_system": True})
71
85
 
86
+ self.messages.append({"role": "user", "content": text})
72
87
  try:
73
88
  start = time.time()
74
- resp = requests.post(
75
- OLLAMA_URL,
76
- json={
77
- "model": MODEL,
78
- "messages": [{"role": "system", "content": SYSTEM}] + self.messages,
79
- "stream": False,
80
- },
81
- timeout=120,
82
- )
89
+ resp = requests.post(OLLAMA_URL, json={
90
+ "model": self.model,
91
+ "messages": [{"role": "system", "content": self.system}] + self.messages,
92
+ "stream": False,
93
+ }, timeout=120)
83
94
  data = resp.json()
84
95
  content = data.get("message", {}).get("content", "No response.")
85
96
  elapsed = round(time.time() - start, 1)
86
97
  tokens = data.get("eval_count", 0)
87
98
  self.total_tokens += tokens
88
99
  self.messages.append({"role": "assistant", "content": content})
89
-
90
- return json.dumps({
91
- "content": content,
92
- "tokens": tokens,
93
- "elapsed": elapsed,
94
- "total_tokens": self.total_tokens,
95
- })
100
+ self._auto_save()
101
+ return json.dumps({"content": content, "tokens": tokens, "elapsed": elapsed, "total_tokens": self.total_tokens})
96
102
  except Exception as e:
97
- return json.dumps({
98
- "content": f"Error: {str(e)}",
99
- "tokens": 0,
100
- "elapsed": 0,
101
- "total_tokens": self.total_tokens,
102
- })
103
+ return json.dumps({"content": f"Error: {e}", "tokens": 0, "elapsed": 0, "total_tokens": self.total_tokens})
104
+
105
+ def _handle_command(self, text):
106
+ """Handle slash commands in the desktop app."""
107
+ global OLLAMA_URL, OLLAMA_BASE
108
+ cmd = text.split()[0].lower()
109
+ args = text[len(cmd):].strip()
110
+
111
+ if cmd == "/help":
112
+ return (
113
+ "**Commands:**\n"
114
+ "`/new` — New conversation\n"
115
+ "`/model <name>` — Switch model\n"
116
+ "`/models` — List models\n"
117
+ "`/persona <name>` — Switch persona (default, hacker, teacher, roast, architect, minimal)\n"
118
+ "`/clear` — Clear chat\n"
119
+ "`/think` — Toggle deep thinking\n"
120
+ "`/temp <0-2>` — Set temperature\n"
121
+ "`/system <prompt>` — Set system prompt\n"
122
+ "`/tokens` — Show token count\n"
123
+ "`/history` — Show message count\n"
124
+ "`/server` — Show server info\n"
125
+ "`/connect <ip>` — Connect to remote Ollama\n"
126
+ "`/export` — Export chat as text\n"
127
+ "`/weather <city>` — Get weather\n"
128
+ "`/open <url>` — Open URL in browser\n"
129
+ "`/agent <name> <task>` — Run an AI agent\n"
130
+ "`/browse <url>` — Fetch and summarize a URL\n"
131
+ )
132
+
133
+ elif cmd == "/new":
134
+ self.messages = []
135
+ self.chat_id = datetime.now().strftime("%Y%m%d_%H%M%S")
136
+ return "New conversation started."
137
+
138
+ elif cmd == "/model":
139
+ if args:
140
+ self.model = args
141
+ return f"Model switched to **{self.model}**"
142
+ else:
143
+ try:
144
+ r = requests.get(OLLAMA_BASE + "/api/tags", timeout=3)
145
+ models = [m["name"] for m in r.json().get("models", [])]
146
+ return "**Models:**\n" + "\n".join(f"- {'**'+m+'**' if m == self.model else m}" for m in models)
147
+ except:
148
+ return "Cannot reach Ollama."
149
+
150
+ elif cmd == "/models":
151
+ try:
152
+ r = requests.get(OLLAMA_BASE + "/api/tags", timeout=3)
153
+ models = [m["name"] for m in r.json().get("models", [])]
154
+ return "**Available models:**\n" + "\n".join(f"- {'**'+m+'** (active)' if m == self.model else m}" for m in models)
155
+ except:
156
+ return "Cannot reach Ollama."
157
+
158
+ elif cmd == "/persona":
159
+ if args and args in PERSONAS:
160
+ self.persona = args
161
+ self.system = PERSONAS[args]
162
+ return f"Persona switched to **{args}**"
163
+ elif args:
164
+ return f"Unknown persona. Available: {', '.join(PERSONAS.keys())}"
165
+ else:
166
+ return f"Current: **{self.persona}**\nAvailable: {', '.join(PERSONAS.keys())}"
167
+
168
+ elif cmd == "/think":
169
+ if "think step-by-step" in self.system:
170
+ self.system = PERSONAS.get(self.persona, SYSTEM)
171
+ return "Deep thinking **OFF**"
172
+ else:
173
+ self.system += "\n\nIMPORTANT: Think through this step-by-step. Show your reasoning."
174
+ return "Deep thinking **ON** — AI will show reasoning."
175
+
176
+ elif cmd == "/temp":
177
+ if args:
178
+ try:
179
+ t = float(args)
180
+ if 0 <= t <= 2:
181
+ return f"Temperature set to **{t}** (note: applied via system prompt guidance)"
182
+ except:
183
+ pass
184
+ return "Usage: /temp 0.7 (range 0.0 to 2.0)"
185
+ return "Usage: /temp 0.7"
186
+
187
+ elif cmd == "/system":
188
+ if args:
189
+ self.system = args
190
+ return f"System prompt updated."
191
+ return f"Current: {self.system[:100]}..."
192
+
193
+ elif cmd == "/tokens":
194
+ return f"**Session:** {self.total_tokens:,} tokens\n**Messages:** {len(self.messages)}"
195
+
196
+ elif cmd == "/history":
197
+ return f"**{len(self.messages)}** messages in current chat."
198
+
199
+ elif cmd == "/server":
200
+ try:
201
+ r = requests.get(OLLAMA_BASE + "/api/tags", timeout=3)
202
+ models = r.json().get("models", [])
203
+ return f"**Server:** {OLLAMA_BASE}\n**Status:** online\n**Models:** {len(models)}\n**Active:** {self.model}"
204
+ except:
205
+ return f"**Server:** {OLLAMA_BASE}\n**Status:** offline"
206
+
207
+ elif cmd == "/connect":
208
+ if args:
209
+ url = args if args.startswith("http") else "http://" + args
210
+ if ":" not in url.split("//")[1]:
211
+ url += ":11434"
212
+ test_url = url.rstrip("/") + "/api/chat"
213
+ if try_connect(test_url):
214
+ OLLAMA_URL = test_url
215
+ OLLAMA_BASE = test_url.replace("/api/chat", "")
216
+ return f"**Connected** to {OLLAMA_BASE}"
217
+ return f"Cannot reach {url}"
218
+ return "Usage: /connect 192.168.1.100"
219
+
220
+ elif cmd == "/export":
221
+ lines = []
222
+ for m in self.messages:
223
+ role = "You" if m["role"] == "user" else "AI"
224
+ lines.append(f"**{role}:** {m['content']}\n")
225
+ return "**Chat Export:**\n\n" + "\n".join(lines) if lines else "Nothing to export."
226
+
227
+ elif cmd == "/clear":
228
+ self.messages = []
229
+ return "Chat cleared."
230
+
231
+ elif cmd == "/weather":
232
+ city = args or "London"
233
+ try:
234
+ r = requests.get(f"https://wttr.in/{city}?format=j1", timeout=10)
235
+ d = r.json()
236
+ c = d["current_condition"][0]
237
+ return (
238
+ f"**{city}**\n"
239
+ f"- {c['weatherDesc'][0]['value']}\n"
240
+ f"- {c['temp_C']}°C (feels {c['FeelsLikeC']}°C)\n"
241
+ f"- Humidity: {c['humidity']}%\n"
242
+ f"- Wind: {c['windspeedMiles']} mph {c['winddir16Point']}"
243
+ )
244
+ except:
245
+ return f"Cannot get weather for {city}."
246
+
247
+ elif cmd == "/open":
248
+ if args:
249
+ import webbrowser
250
+ url = args if args.startswith("http") else "https://" + args
251
+ webbrowser.open(url)
252
+ return f"Opened: {url}"
253
+ return "Usage: /open google.com"
254
+
255
+ elif cmd == "/browse":
256
+ if args:
257
+ url = args if args.startswith("http") else "https://" + args
258
+ try:
259
+ import re as _re
260
+ r = requests.get(url, timeout=15, headers={"User-Agent": "CodeGPT/2.0"})
261
+ text = _re.sub(r'<script[^>]*>.*?</script>', '', r.text, flags=_re.DOTALL)
262
+ text = _re.sub(r'<style[^>]*>.*?</style>', '', text, flags=_re.DOTALL)
263
+ text = _re.sub(r'<[^>]+>', ' ', text)
264
+ text = _re.sub(r'\s+', ' ', text).strip()[:3000]
265
+
266
+ resp = requests.post(OLLAMA_URL, json={
267
+ "model": self.model,
268
+ "messages": [
269
+ {"role": "system", "content": "Summarize in 3-5 bullet points."},
270
+ {"role": "user", "content": f"URL: {url}\n\n{text}"},
271
+ ], "stream": False,
272
+ }, timeout=60)
273
+ return resp.json().get("message", {}).get("content", text[:500])
274
+ except Exception as e:
275
+ return f"Cannot fetch: {e}"
276
+ return "Usage: /browse github.com"
277
+
278
+ elif cmd == "/agent":
279
+ parts = args.split(maxsplit=1)
280
+ if len(parts) >= 2:
281
+ agent_name, task = parts
282
+ agents = {
283
+ "coder": "You are an expert programmer. Write clean, working code.",
284
+ "debugger": "You are a debugging expert. Find and fix bugs.",
285
+ "reviewer": "You are a code reviewer. Check for bugs, security, performance.",
286
+ "architect": "You are a system architect. Design with ASCII diagrams.",
287
+ "pentester": "You are an ethical pentester. Find vulnerabilities. Defensive only.",
288
+ "explainer": "You are a teacher. Explain simply with analogies.",
289
+ "optimizer": "You are a performance engineer. Optimize code.",
290
+ "researcher": "You are a research analyst. Deep-dive into topics.",
291
+ }
292
+ if agent_name in agents:
293
+ try:
294
+ resp = requests.post(OLLAMA_URL, json={
295
+ "model": self.model,
296
+ "messages": [
297
+ {"role": "system", "content": agents[agent_name]},
298
+ {"role": "user", "content": task},
299
+ ], "stream": False,
300
+ }, timeout=90)
301
+ content = resp.json().get("message", {}).get("content", "")
302
+ self.messages.append({"role": "user", "content": f"[agent:{agent_name}] {task}"})
303
+ self.messages.append({"role": "assistant", "content": content})
304
+ return f"**Agent: {agent_name}**\n\n{content}"
305
+ except Exception as e:
306
+ return f"Agent error: {e}"
307
+ return f"Unknown agent. Available: {', '.join(agents.keys())}"
308
+ return "Usage: /agent coder build a flask API"
309
+
310
+ return None # Not a command — send as regular message
103
311
 
104
312
  def new_chat(self):
313
+ self._auto_save()
105
314
  self.messages = []
315
+ self.chat_id = datetime.now().strftime("%Y%m%d_%H%M%S")
106
316
  return "ok"
107
317
 
318
+ def set_model(self, model):
319
+ self.model = model
320
+ return json.dumps({"model": self.model})
321
+
322
+ def set_persona(self, persona):
323
+ self.persona = persona
324
+ self.system = PERSONAS.get(persona, SYSTEM)
325
+ return json.dumps({"persona": self.persona})
326
+
327
+ def get_models(self):
328
+ try:
329
+ r = requests.get(OLLAMA_BASE + "/api/tags", timeout=3)
330
+ return json.dumps([m["name"] for m in r.json().get("models", [])])
331
+ except:
332
+ return json.dumps([])
333
+
334
+ def get_personas(self):
335
+ return json.dumps(list(PERSONAS.keys()))
336
+
108
337
  def get_username(self):
109
338
  return USERNAME
110
339
 
340
+ def get_chat_history(self):
341
+ chats = []
342
+ for f in sorted(CHATS_DIR.glob("*.json"), reverse=True)[:20]:
343
+ try:
344
+ data = json.loads(f.read_text())
345
+ first_msg = ""
346
+ for m in data.get("messages", []):
347
+ if m["role"] == "user":
348
+ first_msg = m["content"][:40]
349
+ break
350
+ chats.append({"id": f.stem, "title": first_msg or "Empty chat", "date": f.stem[:8]})
351
+ except:
352
+ pass
353
+ return json.dumps(chats)
354
+
355
+ def load_chat(self, chat_id):
356
+ f = CHATS_DIR / f"{chat_id}.json"
357
+ if f.exists():
358
+ data = json.loads(f.read_text())
359
+ self.messages = data.get("messages", [])
360
+ self.chat_id = chat_id
361
+ self.model = data.get("model", self.model)
362
+ return json.dumps({"messages": self.messages, "model": self.model})
363
+ return json.dumps({"messages": [], "model": self.model})
364
+
365
+ def delete_chat(self, chat_id):
366
+ f = CHATS_DIR / f"{chat_id}.json"
367
+ if f.exists():
368
+ f.unlink()
369
+ return "ok"
370
+
371
+ def _auto_save(self):
372
+ if self.messages:
373
+ data = {"messages": self.messages, "model": self.model, "saved": datetime.now().isoformat()}
374
+ (CHATS_DIR / f"{self.chat_id}.json").write_text(json.dumps(data))
375
+
111
376
 
112
377
  HTML = """
113
378
  <!DOCTYPE html>
@@ -116,168 +381,243 @@ HTML = """
116
381
  <meta charset="utf-8">
117
382
  <style>
118
383
  * { margin: 0; padding: 0; box-sizing: border-box; }
119
-
120
384
  :root {
121
- --bg: #0d1117;
122
- --surface: #161b22;
123
- --border: #30363d;
124
- --text: #e6edf3;
125
- --dim: #7d8590;
126
- --accent: #58a6ff;
127
- --red: #f85149;
128
- --green: #3fb950;
129
- --user-bg: #1c2128;
385
+ --bg: #0d1117; --surface: #161b22; --border: #30363d; --text: #e6edf3;
386
+ --dim: #7d8590; --accent: #58a6ff; --red: #f85149; --green: #3fb950;
387
+ --user-bg: #1c2128; --sidebar: #0d1117; --hover: #1c2128;
130
388
  }
389
+ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', -apple-system, sans-serif; height: 100vh; display: flex; overflow: hidden; }
131
390
 
132
- body {
133
- background: var(--bg);
134
- color: var(--text);
135
- font-family: 'Segoe UI', -apple-system, sans-serif;
136
- height: 100vh;
137
- display: flex;
138
- flex-direction: column;
391
+ /* Sidebar */
392
+ .sidebar {
393
+ width: 260px; background: var(--sidebar); border-right: 1px solid var(--border);
394
+ display: flex; flex-direction: column; flex-shrink: 0;
395
+ }
396
+ .sidebar-header { padding: 16px; border-bottom: 1px solid var(--border); }
397
+ .sidebar-header h2 { font-size: 14px; color: var(--dim); margin-bottom: 10px; }
398
+ .new-btn {
399
+ width: 100%; background: var(--surface); border: 1px solid var(--border);
400
+ border-radius: 8px; color: var(--text); padding: 10px; font-size: 13px;
401
+ cursor: pointer; text-align: left; display: flex; align-items: center; gap: 8px;
402
+ }
403
+ .new-btn:hover { border-color: var(--accent); }
404
+ .chat-list { flex: 1; overflow-y: auto; padding: 8px; }
405
+ .chat-item {
406
+ padding: 10px 12px; border-radius: 8px; cursor: pointer; margin-bottom: 2px;
407
+ font-size: 13px; color: var(--dim); display: flex; justify-content: space-between; align-items: center;
139
408
  }
409
+ .chat-item:hover { background: var(--hover); color: var(--text); }
410
+ .chat-item.active { background: var(--hover); color: var(--text); }
411
+ .chat-item .del { opacity: 0; color: var(--red); cursor: pointer; font-size: 11px; }
412
+ .chat-item:hover .del { opacity: 1; }
413
+
414
+ /* Bottom controls */
415
+ .sidebar-bottom { padding: 12px; border-top: 1px solid var(--border); }
416
+ .select-row { display: flex; gap: 6px; margin-bottom: 6px; }
417
+ .select-row select {
418
+ flex: 1; background: var(--surface); border: 1px solid var(--border);
419
+ border-radius: 6px; color: var(--text); padding: 6px 8px; font-size: 12px; outline: none;
420
+ }
421
+ .sidebar-info { font-size: 11px; color: var(--dim); text-align: center; }
140
422
 
423
+ /* Main */
424
+ .main { flex: 1; display: flex; flex-direction: column; }
141
425
  .header {
142
- background: var(--surface);
143
- border-bottom: 1px solid var(--border);
144
- padding: 12px 20px;
145
- display: flex;
146
- align-items: center;
147
- justify-content: space-between;
426
+ background: var(--surface); border-bottom: 1px solid var(--border);
427
+ padding: 12px 20px; display: flex; align-items: center; justify-content: space-between;
148
428
  }
149
-
150
429
  .header h1 { font-size: 16px; font-weight: 600; }
151
- .header h1 .code { color: var(--red); }
152
- .header h1 .gpt { color: var(--accent); }
153
- .header-info { font-size: 12px; color: var(--dim); }
154
- .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 6px; }
155
- .dot.on { background: var(--green); }
156
- .dot.off { background: var(--red); }
157
-
158
- .messages {
159
- flex: 1;
160
- overflow-y: auto;
161
- padding: 16px 20px;
162
- }
163
-
164
- .msg { margin-bottom: 16px; animation: fadeIn 0.2s ease; }
430
+ .header .code { color: var(--red); }
431
+ .header .gpt { color: var(--accent); }
432
+ .header-info { font-size: 12px; color: var(--dim); display: flex; align-items: center; gap: 8px; }
433
+ .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
434
+ .dot.on { background: var(--green); } .dot.off { background: var(--red); }
435
+
436
+ .messages { flex: 1; overflow-y: auto; padding: 20px; }
437
+ .msg { margin-bottom: 20px; animation: fadeIn 0.2s ease; max-width: 800px; }
165
438
  @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; } }
166
-
167
- .msg .role {
168
- font-size: 12px; font-weight: 600; margin-bottom: 4px;
169
- display: flex; align-items: center; gap: 6px;
170
- }
171
- .msg .icon {
172
- width: 18px; height: 18px; border-radius: 4px;
173
- display: inline-flex; align-items: center; justify-content: center; font-size: 10px;
174
- }
175
- .msg.user .role { color: var(--accent); }
176
- .msg.user .icon { background: var(--accent); color: var(--bg); }
177
- .msg.ai .role { color: var(--green); }
178
- .msg.ai .icon { background: var(--green); color: var(--bg); }
179
-
180
- .msg .body {
181
- font-size: 14px; line-height: 1.6; padding: 10px 14px;
182
- white-space: pre-wrap; word-wrap: break-word;
183
- }
184
- .msg.user .body { background: var(--user-bg); border: 1px solid var(--border); border-radius: 8px; }
185
- .msg.ai .body { border-left: 2px solid var(--green); padding-left: 14px; }
186
-
187
- .msg .body code { background: var(--surface); padding: 2px 6px; border-radius: 4px; font-family: 'Cascadia Code', monospace; font-size: 13px; }
188
- .msg .body pre { background: var(--surface); padding: 12px; border-radius: 8px; margin: 8px 0; border: 1px solid var(--border); overflow-x: auto; }
439
+ .msg .role { font-size: 12px; font-weight: 600; margin-bottom: 6px; display: flex; align-items: center; gap: 6px; }
440
+ .msg .icon { width: 20px; height: 20px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; }
441
+ .msg.user .role { color: var(--accent); } .msg.user .icon { background: var(--accent); color: var(--bg); }
442
+ .msg.ai .role { color: var(--green); } .msg.ai .icon { background: var(--green); color: var(--bg); }
443
+ .msg .body { font-size: 14px; line-height: 1.7; padding: 12px 16px; white-space: pre-wrap; word-wrap: break-word; }
444
+ .msg.user .body { background: var(--user-bg); border: 1px solid var(--border); border-radius: 10px; }
445
+ .msg.ai .body { border-left: 2px solid var(--green); padding-left: 16px; }
446
+ .msg .body code { background: var(--surface); padding: 2px 6px; border-radius: 4px; font-family: 'Cascadia Code', 'Fira Code', monospace; font-size: 13px; }
447
+ .msg .body pre { background: var(--surface); padding: 12px 16px; border-radius: 8px; margin: 10px 0; border: 1px solid var(--border); overflow-x: auto; position: relative; }
189
448
  .msg .body pre code { background: none; padding: 0; }
190
- .msg .stats { font-size: 11px; color: var(--dim); margin-top: 4px; }
191
-
449
+ .copy-btn { position: absolute; top: 8px; right: 8px; background: var(--border); border: none; border-radius: 4px; color: var(--dim); padding: 4px 8px; font-size: 11px; cursor: pointer; opacity: 0; transition: opacity 0.2s; }
450
+ pre:hover .copy-btn { opacity: 1; }
451
+ .copy-btn:hover { color: var(--text); }
452
+ .msg .stats { font-size: 11px; color: var(--dim); margin-top: 6px; }
192
453
  .thinking { display: none; color: var(--dim); font-size: 13px; margin-bottom: 16px; }
193
- .thinking.on { display: block; }
194
- .thinking .d span { animation: blink 1.4s infinite; }
195
- .thinking .d span:nth-child(2) { animation-delay: 0.2s; }
196
- .thinking .d span:nth-child(3) { animation-delay: 0.4s; }
197
- @keyframes blink { 0%,100% { opacity: 0.2; } 50% { opacity: 1; } }
198
-
199
- .input-area { background: var(--surface); border-top: 1px solid var(--border); padding: 12px 20px; }
200
- .input-wrap { display: flex; gap: 8px; align-items: flex-end; }
201
- .input-wrap textarea {
202
- flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
203
- color: var(--text); padding: 10px 14px; font-size: 14px; font-family: inherit;
204
- resize: none; outline: none; min-height: 42px; max-height: 120px;
205
- }
454
+ .thinking.on { display: flex; align-items: center; gap: 8px; }
455
+ .spinner { width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
456
+ @keyframes spin { to { transform: rotate(360deg); } }
457
+
458
+ .welcome { text-align: center; padding: 80px 20px; }
459
+ .welcome h2 { font-size: 28px; margin-bottom: 8px; }
460
+ .welcome p { color: var(--dim); margin-bottom: 24px; }
461
+ .chips { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; max-width: 500px; margin: 0 auto; }
462
+ .chips button { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; color: var(--text); padding: 10px 16px; font-size: 13px; cursor: pointer; transition: all 0.2s; }
463
+ .chips button:hover { border-color: var(--accent); color: var(--accent); transform: translateY(-1px); }
464
+
465
+ .input-area { background: var(--surface); border-top: 1px solid var(--border); padding: 16px 20px; }
466
+ .input-wrap { display: flex; gap: 8px; align-items: flex-end; max-width: 800px; }
467
+ .input-wrap textarea { flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 10px; color: var(--text); padding: 12px 16px; font-size: 14px; font-family: inherit; resize: none; outline: none; min-height: 44px; max-height: 150px; transition: border-color 0.2s; }
206
468
  .input-wrap textarea:focus { border-color: var(--accent); }
207
- .input-wrap button {
208
- background: var(--accent); border: none; border-radius: 8px; color: white;
209
- padding: 10px 16px; font-size: 14px; cursor: pointer; font-weight: 600;
469
+ .input-wrap button { background: var(--accent); border: none; border-radius: 10px; color: white; padding: 12px 18px; font-size: 14px; cursor: pointer; font-weight: 600; transition: opacity 0.2s; }
470
+ .input-wrap button:hover { opacity: 0.9; } .input-wrap button:disabled { opacity: 0.3; }
471
+ .footer { padding: 6px 20px; font-size: 11px; color: var(--dim); display: flex; justify-content: space-between; }
472
+
473
+ /* Welcome modal */
474
+ .modal-overlay {
475
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
476
+ background: rgba(0,0,0,0.7); z-index: 200;
477
+ display: flex; align-items: center; justify-content: center;
478
+ animation: fadeIn 0.3s ease;
210
479
  }
211
- .input-wrap button:hover { opacity: 0.9; }
212
- .input-wrap button:disabled { opacity: 0.4; cursor: default; }
213
-
214
- .footer {
215
- background: var(--surface); border-top: 1px solid var(--border);
216
- padding: 6px 20px; font-size: 11px; color: var(--dim);
217
- display: flex; justify-content: space-between;
480
+ .modal {
481
+ background: var(--surface); border: 1px solid var(--border); border-radius: 16px;
482
+ padding: 32px 40px; max-width: 480px; width: 90%; text-align: center;
483
+ animation: modalIn 0.3s ease;
218
484
  }
219
-
220
- .welcome { text-align: center; padding: 60px 20px; color: var(--dim); }
221
- .welcome h2 { font-size: 24px; margin-bottom: 8px; color: var(--text); }
222
- .chips { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 24px; }
223
- .chips button {
224
- background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
225
- color: var(--text); padding: 8px 14px; font-size: 13px; cursor: pointer;
485
+ @keyframes modalIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
486
+ .modal h2 { font-size: 22px; margin-bottom: 4px; }
487
+ .modal h2 .code { color: var(--red); } .modal h2 .gpt { color: var(--accent); }
488
+ .modal .ver { color: var(--dim); font-size: 13px; margin-bottom: 20px; }
489
+ .modal .features { text-align: left; margin: 16px 0; }
490
+ .modal .feat { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 14px; }
491
+ .modal .feat .num { color: var(--accent); font-weight: 700; min-width: 30px; }
492
+ .modal .feat .label { color: var(--dim); }
493
+ .modal .start-btn {
494
+ background: var(--accent); border: none; border-radius: 10px; color: white;
495
+ padding: 12px 32px; font-size: 15px; cursor: pointer; font-weight: 600;
496
+ margin-top: 20px; transition: opacity 0.2s;
226
497
  }
227
- .chips button:hover { border-color: var(--accent); color: var(--accent); }
228
-
229
- .new-btn {
230
- background: transparent; border: 1px solid var(--border); border-radius: 6px;
231
- color: var(--dim); padding: 4px 10px; font-size: 11px; cursor: pointer;
232
- }
233
- .new-btn:hover { border-color: var(--accent); color: var(--accent); }
498
+ .modal .start-btn:hover { opacity: 0.9; }
499
+ .modal .tip { color: var(--dim); font-size: 12px; margin-top: 12px; }
500
+
501
+ /* Command autocomplete */
502
+ .cmd-menu { display: none; position: absolute; bottom: 100%; left: 0; right: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; max-height: 250px; overflow-y: auto; margin-bottom: 4px; z-index: 100; }
503
+ .cmd-menu.show { display: block; }
504
+ .cmd-item { padding: 8px 14px; cursor: pointer; display: flex; justify-content: space-between; font-size: 13px; }
505
+ .cmd-item:hover, .cmd-item.sel { background: var(--hover); }
506
+ .cmd-item .name { color: var(--accent); }
507
+ .cmd-item .desc { color: var(--dim); font-size: 12px; }
508
+ .kbd { background: var(--surface); border: 1px solid var(--border); border-radius: 3px; padding: 1px 5px; font-size: 10px; }
234
509
  </style>
235
510
  </head>
236
511
  <body>
237
512
 
238
- <div class="header">
239
- <h1><span class="code">Code</span><span class="gpt">GPT</span> <span style="color:#7d8590;font-weight:400;font-size:13px">Desktop</span></h1>
240
- <div class="header-info">
241
- <span class="dot" id="dot"></span><span id="st"></span>
242
- &middot; <span id="mn"></span>
243
- &middot; <button class="new-btn" onclick="newChat()">New Chat</button>
513
+ <div class="sidebar">
514
+ <div class="sidebar-header">
515
+ <h2>CHATS</h2>
516
+ <button class="new-btn" onclick="newChat()">+ New Chat</button>
517
+ </div>
518
+ <div class="chat-list" id="chatList"></div>
519
+ <div class="sidebar-bottom">
520
+ <div class="select-row">
521
+ <select id="modelSelect" onchange="setModel(this.value)"></select>
522
+ </div>
523
+ <div class="select-row">
524
+ <select id="personaSelect" onchange="setPersona(this.value)"></select>
525
+ </div>
526
+ <div class="sidebar-info" id="sidebarInfo">CodeGPT v2.0</div>
244
527
  </div>
245
528
  </div>
246
529
 
247
- <div class="messages" id="msgs">
248
- <div class="welcome" id="welcome">
249
- <h2 id="greeting"></h2>
250
- <p>Type a message or pick a suggestion.</p>
251
- <div class="chips">
252
- <button onclick="go('Explain how REST APIs work')">REST APIs</button>
253
- <button onclick="go('Write a Python function to find prime numbers')">Prime numbers</button>
254
- <button onclick="go('What are the OWASP top 10?')">OWASP Top 10</button>
255
- <button onclick="go('Design a login system with JWT')">JWT Auth</button>
530
+ <div class="main">
531
+ <div class="header">
532
+ <h1><span class="code">Code</span><span class="gpt">GPT</span></h1>
533
+ <div class="header-info">
534
+ <span class="dot" id="dot"></span>
535
+ <span id="statusText"></span>
256
536
  </div>
257
537
  </div>
258
- <div class="thinking" id="think"><span class="d">Thinking<span>.</span><span>.</span><span>.</span></span></div>
259
- </div>
260
538
 
261
- <div class="input-area">
262
- <div class="input-wrap">
263
- <textarea id="inp" placeholder="Type a message..." rows="1" onkeydown="key(event)" autofocus></textarea>
264
- <button onclick="send()" id="btn">Send</button>
539
+ <div class="messages" id="msgs">
540
+ <div class="welcome" id="welcome">
541
+ <h2 id="greeting"></h2>
542
+ <p>How can I help you today?</p>
543
+ <div class="chips">
544
+ <button onclick="go('Explain how REST APIs work')">Explain REST APIs</button>
545
+ <button onclick="go('Write a Python function to find primes')">Prime numbers</button>
546
+ <button onclick="go('What are the OWASP top 10?')">OWASP Top 10</button>
547
+ <button onclick="go('Design a login system with JWT')">JWT Auth</button>
548
+ <button onclick="go('Compare Flask vs FastAPI')">Flask vs FastAPI</button>
549
+ <button onclick="go('Write a bash script to backup files')">Backup script</button>
550
+ </div>
551
+ </div>
552
+ <div class="thinking" id="think"><div class="spinner"></div> Thinking...</div>
553
+ </div>
554
+
555
+ <div class="input-area" style="position:relative">
556
+ <div class="cmd-menu" id="cmdMenu"></div>
557
+ <div class="input-wrap">
558
+ <textarea id="inp" placeholder="Message CodeGPT... (type / for commands)" rows="1" onkeydown="key(event)" oninput="onInput(event)" autofocus></textarea>
559
+ <button onclick="send()" id="btn">Send</button>
560
+ </div>
561
+ </div>
562
+ <div class="footer">
563
+ <span id="tc">0 tokens</span>
564
+ <span><span class="kbd">Enter</span> send &middot; <span class="kbd">Shift+Enter</span> new line &middot; <span class="kbd">Ctrl+N</span> new chat</span>
265
565
  </div>
266
566
  </div>
267
567
 
268
- <div class="footer">
269
- <span id="tc">0 tokens</span>
270
- <span>CodeGPT v2.0 &middot; Local AI &middot; Ollama</span>
568
+ <div id="welcomeModal" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:200;display:flex;align-items:center;justify-content:center">
569
+ <div style="background:#161b22;border:1px solid #30363d;border-radius:16px;padding:32px 40px;max-width:480px;width:90%;text-align:center">
570
+ <h2 style="font-size:22px;margin-bottom:4px"><span style="color:#f85149">Code</span><span style="color:#58a6ff">GPT</span> Desktop</h2>
571
+ <p style="color:#7d8590;font-size:13px;margin-bottom:20px">v2.0 &middot; Local AI &middot; Powered by Ollama</p>
572
+ <div style="text-align:left;margin:16px 0">
573
+ <p style="padding:6px 0;font-size:14px"><span style="color:#58a6ff;font-weight:700;margin-right:10px">123</span> commands <span style="color:#7d8590">type / to see all</span></p>
574
+ <p style="padding:6px 0;font-size:14px"><span style="color:#58a6ff;font-weight:700;margin-right:10px">8</span> AI agents <span style="color:#7d8590">coder, reviewer, architect</span></p>
575
+ <p style="padding:6px 0;font-size:14px"><span style="color:#58a6ff;font-weight:700;margin-right:10px">26</span> tools <span style="color:#7d8590">Claude, Codex, Gemini</span></p>
576
+ <p style="padding:6px 0;font-size:14px"><span style="color:#58a6ff;font-weight:700;margin-right:10px">6</span> personas <span style="color:#7d8590">hacker, teacher, minimal</span></p>
577
+ </div>
578
+ <p style="color:#7d8590;font-size:12px;margin-top:16px">Click anywhere or wait to start</p>
579
+ </div>
271
580
  </div>
272
581
 
273
582
  <script>
274
583
  let busy = false;
275
584
 
585
+ // Welcome — auto-dismiss after 3 seconds, or click/key
586
+ var _welcomed = false;
587
+ function _dismissWelcome() {
588
+ if (_welcomed) return;
589
+ _welcomed = true;
590
+ var m = document.getElementById('welcomeModal');
591
+ if (m) {
592
+ m.style.display = 'none';
593
+ m.style.visibility = 'hidden';
594
+ m.style.pointerEvents = 'none';
595
+ m.style.opacity = '0';
596
+ try { m.parentNode.removeChild(m); } catch(e) {}
597
+ }
598
+ // Clean up listeners
599
+ document.removeEventListener('mousedown', _dismissWelcome);
600
+ document.removeEventListener('keydown', _dismissWelcome);
601
+ if (_dismissTimer) clearTimeout(_dismissTimer);
602
+ // Focus input
603
+ setTimeout(function() {
604
+ var inp = document.getElementById('inp');
605
+ if (inp) inp.focus();
606
+ }, 100);
607
+ }
608
+ var _dismissTimer = setTimeout(_dismissWelcome, 3000);
609
+ document.addEventListener('mousedown', _dismissWelcome);
610
+ document.addEventListener('keydown', _dismissWelcome);
611
+
276
612
  async function init() {
277
613
  const name = await pywebview.api.get_username();
278
614
  const h = new Date().getHours();
279
615
  const g = h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
280
- document.getElementById('greeting').textContent = g + ', ' + name + '.';
616
+ document.getElementById('greeting').textContent = g + ', ' + name;
617
+
618
+ await loadModels();
619
+ await loadPersonas();
620
+ await loadChats();
281
621
  checkStatus();
282
622
  setInterval(checkStatus, 30000);
283
623
  }
@@ -285,18 +625,72 @@ async function init() {
285
625
  async function checkStatus() {
286
626
  const r = JSON.parse(await pywebview.api.check_status());
287
627
  document.getElementById('dot').className = 'dot ' + (r.online ? 'on' : 'off');
288
- document.getElementById('st').textContent = r.online ? 'connected' : 'offline';
289
- document.getElementById('mn').textContent = r.model;
628
+ document.getElementById('statusText').textContent = r.online ? r.model : 'offline';
629
+ }
630
+
631
+ async function loadModels() {
632
+ const models = JSON.parse(await pywebview.api.get_models());
633
+ const sel = document.getElementById('modelSelect');
634
+ sel.innerHTML = models.map(m => '<option value="'+m+'"'+(m===MODEL?' selected':'')+'>'+m+'</option>').join('');
635
+ }
636
+
637
+ async function loadPersonas() {
638
+ const personas = JSON.parse(await pywebview.api.get_personas());
639
+ const sel = document.getElementById('personaSelect');
640
+ sel.innerHTML = personas.map(p => '<option value="'+p+'">'+p+'</option>').join('');
641
+ }
642
+
643
+ async function loadChats() {
644
+ const chats = JSON.parse(await pywebview.api.get_chat_history());
645
+ const list = document.getElementById('chatList');
646
+ list.innerHTML = chats.map(c =>
647
+ '<div class="chat-item" onclick="loadChat(\''+c.id+'\')">' +
648
+ '<span>'+c.title+'</span>' +
649
+ '<span class="del" onclick="event.stopPropagation();deleteChat(\''+c.id+'\')">x</span>' +
650
+ '</div>'
651
+ ).join('') || '<div style="padding:20px;text-align:center;color:var(--dim);font-size:12px">No chats yet</div>';
652
+ }
653
+
654
+ async function loadChat(id) {
655
+ const r = JSON.parse(await pywebview.api.load_chat(id));
656
+ clearMessages();
657
+ r.messages.forEach(m => addMsg(m.role === 'user' ? 'user' : 'ai', m.content));
658
+ document.getElementById('modelSelect').value = r.model;
659
+ }
660
+
661
+ async function deleteChat(id) {
662
+ await pywebview.api.delete_chat(id);
663
+ loadChats();
290
664
  }
291
665
 
292
666
  function esc(t) {
293
667
  t = t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
294
- t = t.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, '<pre><code>$2</code></pre>');
668
+ // Code blocks with copy button
669
+ t = t.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, function(m, lang, code) {
670
+ return '<pre><code>'+code+'</code><button class="copy-btn" onclick="copyCode(this)">Copy</button></pre>';
671
+ });
295
672
  t = t.replace(/`([^`]+)`/g, '<code>$1</code>');
296
673
  t = t.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
674
+ t = t.replace(/^### (.+)$/gm, '<h3 style="margin:8px 0 4px;font-size:15px">$1</h3>');
675
+ t = t.replace(/^## (.+)$/gm, '<h3 style="margin:8px 0 4px;font-size:16px">$1</h3>');
676
+ t = t.replace(/^- (.+)$/gm, '&bull; $1');
297
677
  return t;
298
678
  }
299
679
 
680
+ function copyCode(btn) {
681
+ const code = btn.previousElementSibling.textContent;
682
+ navigator.clipboard.writeText(code);
683
+ btn.textContent = 'Copied!';
684
+ setTimeout(() => btn.textContent = 'Copy', 1500);
685
+ }
686
+
687
+ function clearMessages() {
688
+ const w = document.getElementById('welcome');
689
+ if (w) w.style.display = 'none';
690
+ const msgs = document.getElementById('msgs');
691
+ msgs.querySelectorAll('.msg').forEach(m => m.remove());
692
+ }
693
+
300
694
  function addMsg(role, content, stats) {
301
695
  const w = document.getElementById('welcome');
302
696
  if (w) w.style.display = 'none';
@@ -304,19 +698,83 @@ function addMsg(role, content, stats) {
304
698
  d.className = 'msg ' + role;
305
699
  const icon = role === 'user' ? 'U' : 'AI';
306
700
  const label = role === 'user' ? 'You' : 'CodeGPT';
307
- d.innerHTML = '<div class="role"><span class="icon">' + icon + '</span>' + label + '</div>'
308
- + '<div class="body">' + esc(content) + '</div>'
309
- + (stats ? '<div class="stats">' + stats + '</div>' : '');
701
+ d.innerHTML = '<div class="role"><span class="icon">'+icon+'</span>'+label+'</div>'
702
+ + '<div class="body">'+esc(content)+'</div>'
703
+ + (stats ? '<div class="stats">'+stats+'</div>' : '');
310
704
  const c = document.getElementById('msgs');
311
705
  c.insertBefore(d, document.getElementById('think'));
312
706
  c.scrollTop = c.scrollHeight;
313
707
  }
314
708
 
709
+ const CMDS = [
710
+ {name: '/help', desc: 'Show all commands'},
711
+ {name: '/new', desc: 'New conversation'},
712
+ {name: '/model', desc: 'Switch model'},
713
+ {name: '/models', desc: 'List all models'},
714
+ {name: '/persona', desc: 'Switch persona'},
715
+ {name: '/think', desc: 'Toggle deep thinking'},
716
+ {name: '/temp', desc: 'Set temperature (0-2)'},
717
+ {name: '/system', desc: 'Set system prompt'},
718
+ {name: '/tokens', desc: 'Show token count'},
719
+ {name: '/clear', desc: 'Clear chat'},
720
+ {name: '/server', desc: 'Server info'},
721
+ {name: '/connect', desc: 'Connect to remote Ollama'},
722
+ {name: '/export', desc: 'Export chat'},
723
+ {name: '/agent', desc: 'Run an AI agent'},
724
+ {name: '/browse', desc: 'Fetch and summarize URL'},
725
+ {name: '/open', desc: 'Open URL in browser'},
726
+ {name: '/weather', desc: 'Get weather'},
727
+ {name: '/history', desc: 'Message count'},
728
+ ];
729
+ let cmdIdx = -1;
730
+
731
+ function onInput(e) {
732
+ const val = e.target.value;
733
+ const menu = document.getElementById('cmdMenu');
734
+
735
+ // Show commands when typing / (before a space)
736
+ if (val.startsWith('/') && val.indexOf(' ') === -1) {
737
+ const typed = val.toLowerCase();
738
+ // Just "/" shows all, otherwise filter
739
+ const matches = typed === '/' ? CMDS : CMDS.filter(c => c.name.startsWith(typed));
740
+ if (matches.length > 0) {
741
+ cmdIdx = 0;
742
+ menu.innerHTML = matches.map((c, i) =>
743
+ '<div class="cmd-item'+(i===0?' sel':'')+'" onclick="pickCmd(\\''+c.name+'\\')"><span class="name">'+c.name+'</span><span class="desc">'+c.desc+'</span></div>'
744
+ ).join('');
745
+ menu.className = 'cmd-menu show';
746
+ } else {
747
+ menu.className = 'cmd-menu';
748
+ }
749
+ } else {
750
+ menu.className = 'cmd-menu';
751
+ }
752
+ }
753
+
754
+ function pickCmd(cmd) {
755
+ document.getElementById('inp').value = cmd + ' ';
756
+ document.getElementById('cmdMenu').className = 'cmd-menu';
757
+ document.getElementById('inp').focus();
758
+ }
759
+
315
760
  function key(e) {
761
+ const menu = document.getElementById('cmdMenu');
762
+ if (menu.classList.contains('show')) {
763
+ const items = menu.querySelectorAll('.cmd-item');
764
+ if (e.key === 'ArrowDown') { e.preventDefault(); cmdIdx = Math.min(cmdIdx+1, items.length-1); items.forEach((it,i) => it.classList.toggle('sel', i===cmdIdx)); }
765
+ else if (e.key === 'ArrowUp') { e.preventDefault(); cmdIdx = Math.max(cmdIdx-1, 0); items.forEach((it,i) => it.classList.toggle('sel', i===cmdIdx)); }
766
+ else if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
767
+ e.preventDefault();
768
+ if (items[cmdIdx]) { pickCmd(items[cmdIdx].querySelector('.name').textContent); }
769
+ }
770
+ else if (e.key === 'Escape') { menu.className = 'cmd-menu'; }
771
+ return;
772
+ }
316
773
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
774
+ if (e.ctrlKey && e.key === 'n') { e.preventDefault(); newChat(); }
317
775
  const el = e.target;
318
776
  el.style.height = 'auto';
319
- el.style.height = Math.min(el.scrollHeight, 120) + 'px';
777
+ el.style.height = Math.min(el.scrollHeight, 150) + 'px';
320
778
  }
321
779
 
322
780
  function go(t) { document.getElementById('inp').value = t; send(); }
@@ -324,10 +782,15 @@ function go(t) { document.getElementById('inp').value = t; send(); }
324
782
  async function newChat() {
325
783
  await pywebview.api.new_chat();
326
784
  document.getElementById('msgs').innerHTML =
327
- '<div class="welcome" id="welcome"><h2 id="greeting">New conversation</h2><p>Type a message.</p></div>'
328
- + '<div class="thinking" id="think"><span class="d">Thinking<span>.</span><span>.</span><span>.</span></span></div>';
785
+ '<div class="welcome" id="welcome"><h2>New conversation</h2><p>How can I help?</p></div>'
786
+ + '<div class="thinking" id="think"><div class="spinner"></div> Thinking...</div>';
787
+ loadChats();
788
+ document.getElementById('inp').focus();
329
789
  }
330
790
 
791
+ async function setModel(m) { await pywebview.api.set_model(m); checkStatus(); }
792
+ async function setPersona(p) { await pywebview.api.set_persona(p); }
793
+
331
794
  async function send() {
332
795
  const inp = document.getElementById('inp');
333
796
  const text = inp.value.trim();
@@ -340,15 +803,17 @@ async function send() {
340
803
 
341
804
  const r = JSON.parse(await pywebview.api.send_message(text));
342
805
  document.getElementById('think').className = 'thinking';
343
- addMsg('ai', r.content, r.tokens + ' tokens · ' + r.elapsed + 's');
806
+ addMsg('ai', r.content, r.tokens + ' tokens &middot; ' + r.elapsed + 's');
344
807
  document.getElementById('tc').textContent = r.total_tokens.toLocaleString() + ' tokens';
345
808
 
346
809
  busy = false;
347
810
  document.getElementById('btn').disabled = false;
348
811
  inp.focus();
812
+ loadChats();
349
813
  }
350
814
 
351
815
  window.addEventListener('pywebviewready', init);
816
+ const MODEL = '""" + MODEL + """';
352
817
  </script>
353
818
  </body>
354
819
  </html>
@@ -361,9 +826,9 @@ def main():
361
826
  "CodeGPT",
362
827
  html=HTML,
363
828
  js_api=api,
364
- width=800,
365
- height=650,
366
- min_size=(400, 400),
829
+ width=1000,
830
+ height=700,
831
+ min_size=(600, 400),
367
832
  background_color="#0d1117",
368
833
  text_select=True,
369
834
  )