codicent-cli 0.8.4__tar.gz → 0.8.6__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codicent-cli
3
- Version: 0.8.4
3
+ Version: 0.8.6
4
4
  Summary: Command-line interface for the Codicent API
5
5
  Home-page: https://github.com/izaxon/codicent-cli
6
6
  Author: Johan Isaksson
@@ -123,6 +123,23 @@ TERMINAL (Web Terminal Host):
123
123
  codicent terminal bash
124
124
  codicent terminal zsh
125
125
 
126
+ TUI (Text User Interface):
127
+ tui Open a split-screen terminal UI with a logbook home panel on
128
+ the left (recent messages + compose) and an AI chat panel on
129
+ the right. Uses the same authentication as other commands.
130
+
131
+ Key bindings:
132
+ Tab / Shift+Tab Switch focus between compose and chat inputs
133
+ Enter Send the current input
134
+ Escape + Enter Insert a newline in the active input
135
+ Ctrl+R Refresh message list
136
+ F1 or Alt+1 Focus compose input
137
+ F2 or Alt+2 Focus chat input
138
+ Ctrl+C Quit
139
+
140
+ Example:
141
+ codicent tui
142
+
126
143
  EXAMPLES:
127
144
  codicent auth
128
145
  codicent "What is Python?"
@@ -133,6 +150,7 @@ EXAMPLES:
133
150
  codicent wrap claude
134
151
  codicent terminal
135
152
  codicent daemon
153
+ codicent tui
136
154
  codicent update
137
155
  codicent update --install
138
156
  echo "Help me debug this" | codicent
@@ -1015,6 +1033,31 @@ def main():
1015
1033
  project=wrap_project,
1016
1034
  )
1017
1035
 
1036
+ # Handle tui command
1037
+ if len(sys.argv) > 1 and sys.argv[1] == "tui":
1038
+ from tui import run_tui
1039
+
1040
+ tui_token = auth.get_cached_token()
1041
+ if not tui_token:
1042
+ tui_token = os.getenv("CODICENT_TOKEN")
1043
+ if not tui_token:
1044
+ console.print("[red]❌ No authentication found.[/red]")
1045
+ console.print("[dim]Run 'codicent auth' to authenticate, or set CODICENT_TOKEN[/dim]")
1046
+ return 1
1047
+
1048
+ try:
1049
+ tui_payload = tui_token.split(".")[1]
1050
+ tui_payload += '=' * (-len(tui_payload) % 4)
1051
+ tui_jwt = json.loads(base64.urlsafe_b64decode(tui_payload).decode('utf-8'))
1052
+ tui_project = tui_jwt["project"]
1053
+ except Exception as e:
1054
+ console.print(f"[red]❌ Failed to extract project from token: {e}[/red]")
1055
+ return 1
1056
+
1057
+ tui_codicent = Codicent(tui_token)
1058
+ run_tui(tui_codicent, tui_project)
1059
+ return 0
1060
+
1018
1061
  # Handle terminal command
1019
1062
  if len(sys.argv) > 1 and sys.argv[1] == "terminal":
1020
1063
  from terminal import run_terminal_host
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codicent-cli
3
- Version: 0.8.4
3
+ Version: 0.8.6
4
4
  Summary: Command-line interface for the Codicent API
5
5
  Home-page: https://github.com/izaxon/codicent-cli
6
6
  Author: Johan Isaksson
@@ -4,6 +4,7 @@ app.py
4
4
  auth.py
5
5
  setup.py
6
6
  terminal.py
7
+ tui.py
7
8
  wrap.py
8
9
  codicent_cli.egg-info/PKG-INFO
9
10
  codicent_cli.egg-info/SOURCES.txt
@@ -2,4 +2,5 @@ app
2
2
  auth
3
3
  templates
4
4
  terminal
5
+ tui
5
6
  wrap
@@ -9,7 +9,7 @@ except FileNotFoundError:
9
9
 
10
10
  setup(
11
11
  name='codicent-cli',
12
- version='0.8.4',
12
+ version='0.8.6',
13
13
  author='Johan Isaksson',
14
14
  author_email='johan@izaxon.com',
15
15
  description='Command-line interface for the Codicent API',
@@ -35,7 +35,7 @@ setup(
35
35
  'Programming Language :: Python :: 3.12',
36
36
  ],
37
37
  keywords='codicent cli api chat ai',
38
- py_modules=['app', 'auth', 'wrap', 'terminal'],
38
+ py_modules=['app', 'auth', 'wrap', 'terminal', 'tui'],
39
39
  packages=['templates'],
40
40
  package_data={'templates': ['*.json']},
41
41
  python_requires='>=3.6',
@@ -0,0 +1,1169 @@
1
+ """
2
+ Codicent TUI — responsive terminal user interface.
3
+
4
+ Wide terminals (≥80 cols) — split-screen:
5
+ Left panel: Logbook — messages list (↑/↓ navigate when input is empty)
6
+ Right panel: Context-aware — three modes:
7
+ "ai-chat" — New chat (nothing selected)
8
+ "thread" — Full conversation thread (#chat/#chat2 selected)
9
+ "detail" — Full message content (any other selection)
10
+
11
+ Narrow terminals (<80 cols) — single-pane swap:
12
+ Tab swaps between Logbook and the right pane. The chat input stays visible
13
+ at the bottom regardless of which pane is showing.
14
+
15
+ Input — single field, label reflects what Enter will do:
16
+ "New chat ›" no selection → new AI chat
17
+ "Reply ›" #chat/#chat2 selected → reply in thread
18
+ "Update ›" other message selected → post child update (parent_id set)
19
+
20
+ Slash commands (typed in the input, mobile-friendly alternates to Ctrl-combos):
21
+ /post [text] plain post (parent-linked if a message is selected)
22
+ /spawn [text] fire-and-forget chat
23
+ /new [text] clear selection; if text given, send as new chat
24
+ /refresh reload logbook
25
+ /help (or /?) help overlay
26
+ /quit exit
27
+ //foo literal: send the text "/foo"
28
+
29
+ Key bindings:
30
+ Enter send (per current mode)
31
+ Tab (narrow only) swap between Logbook and right pane
32
+ ? (input empty) toggle help overlay
33
+ Esc close help / clear input / deselect
34
+ Ctrl+P post (same as /post)
35
+ Ctrl+J spawn (same as /spawn)
36
+ Ctrl+N new chat (same as /new)
37
+ Ctrl+R refresh
38
+ Ctrl+C quit
39
+ ↑/↓ navigate logbook (input empty) or scroll right panel (input has text)
40
+ """
41
+
42
+ import re
43
+ import sys
44
+ import threading
45
+ from datetime import datetime
46
+
47
+ import requests as _requests
48
+
49
+ from prompt_toolkit import Application
50
+ from prompt_toolkit.application.current import get_app
51
+ from prompt_toolkit.buffer import Buffer
52
+ from prompt_toolkit.filters import Condition
53
+ from prompt_toolkit.input.defaults import create_input
54
+ from prompt_toolkit.key_binding import KeyBindings
55
+ from prompt_toolkit.layout.containers import (
56
+ DynamicContainer,
57
+ HSplit,
58
+ VSplit,
59
+ Window,
60
+ WindowAlign,
61
+ )
62
+ from prompt_toolkit.layout.controls import FormattedTextControl
63
+ from prompt_toolkit.layout.layout import Layout
64
+ from prompt_toolkit.output.color_depth import ColorDepth
65
+ from prompt_toolkit.output.defaults import create_output
66
+ from prompt_toolkit.output.vt100 import Vt100_Output
67
+ if sys.platform == "win32":
68
+ from prompt_toolkit.output.win32 import NoConsoleScreenBufferError
69
+ else:
70
+ class NoConsoleScreenBufferError(Exception): # noqa: N818 - mirror upstream name
71
+ """Stub for non-Windows platforms; never actually raised."""
72
+ from prompt_toolkit.styles import Style
73
+ from prompt_toolkit.utils import get_bell_environment_variable, get_term_environment_variable
74
+ from prompt_toolkit.widgets import Frame, TextArea
75
+
76
+
77
+ def _pick_tty_stream(*streams):
78
+ for stream in streams:
79
+ try:
80
+ if stream is not None and stream.isatty():
81
+ return stream
82
+ except Exception:
83
+ pass
84
+ return None
85
+
86
+
87
+ def _create_prompt_io():
88
+ input_stream = _pick_tty_stream(sys.stdin, sys.stdout, sys.stderr)
89
+ output_stream = _pick_tty_stream(sys.stdout, sys.stderr, sys.__stdout__)
90
+
91
+ pt_input = create_input(stdin=input_stream, always_prefer_tty=True)
92
+ try:
93
+ pt_output = create_output(stdout=output_stream, always_prefer_tty=True)
94
+ except NoConsoleScreenBufferError:
95
+ if output_stream is None:
96
+ raise
97
+ pt_output = Vt100_Output.from_pty(
98
+ output_stream,
99
+ term=get_term_environment_variable(),
100
+ default_color_depth=ColorDepth.from_env(),
101
+ enable_bell=get_bell_environment_variable(),
102
+ )
103
+
104
+ return pt_input, pt_output
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Styles
108
+ # ---------------------------------------------------------------------------
109
+
110
+ STYLE = Style.from_dict(
111
+ {
112
+ # Chrome
113
+ "title": "bold #00d7ff",
114
+ "status-bar": "bg:#1a1a2e #888888",
115
+ "status-key": "bg:#1a1a2e bold #ffffff",
116
+ "status-mode": "bg:#1a1a2e bold #00d7ff",
117
+ "frame-label": "bold #00d7ff",
118
+ "focused-frame": "bold #00d7ff",
119
+ # Logbook list
120
+ "msg-tags": "#00d7af",
121
+ "msg-time": "#555555",
122
+ "msg-content": "#cccccc",
123
+ "msg-selected": "bg:#263366 bold",
124
+ "msg-selected-tag": "bg:#263366 #00d7af bold",
125
+ "msg-file": "#ffaf5f",
126
+ # Right panel — ai-chat mode
127
+ "chat-you": "#87d7ff bold",
128
+ "chat-ai": "#98fb98",
129
+ "chat-thinking": "italic #888888",
130
+ # Right panel — thread / detail mode
131
+ "detail-label": "bold #aaaaaa",
132
+ "detail-body": "#dddddd",
133
+ "detail-url": "underline #87d7ff",
134
+ "md-h1": "bold #ffffff",
135
+ "md-h2": "bold #e0e0e0",
136
+ "md-h3": "bold #cccccc",
137
+ "md-bold": "bold",
138
+ "md-bullet": "#888888",
139
+ "md-code": "bg:#1e1e1e #ffaf5f",
140
+ # Inputs
141
+ "compose-input": "",
142
+ "chat-input": "",
143
+ }
144
+ )
145
+
146
+ _MAX_VISIBLE = 12 # ceiling for messages rendered in the logbook slice
147
+ _LINES_PER_MSG = 3 # newlines per message in FormattedTextControl output
148
+ NARROW_WIDTH_THRESHOLD = 80 # below this column count, switch to single-pane layout
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Pure helpers
152
+ # ---------------------------------------------------------------------------
153
+
154
+ _FILE_RE = re.compile(r"#file:([0-9a-fA-F-]{36})")
155
+ _TAG_RE = re.compile(r"#(\w[\w-]*)")
156
+ _BOLD_RE = re.compile(r"\*\*(.+?)\*\*|__(.+?)__")
157
+ _CODE_RE = re.compile(r"`([^`]+)`")
158
+
159
+
160
+ def _md_line_fragments(line: str, base_style: str) -> list[tuple[str, str]]:
161
+ """Convert one line of markdown to a list of (style, text) fragments."""
162
+ stripped = line.rstrip()
163
+
164
+ # Headings
165
+ if stripped.startswith("### "):
166
+ return [("class:md-h3", " " + stripped[4:] + "\n")]
167
+ if stripped.startswith("## "):
168
+ return [("class:md-h2", " " + stripped[3:] + "\n")]
169
+ if stripped.startswith("# "):
170
+ return [("class:md-h1", " " + stripped[2:] + "\n")]
171
+
172
+ # Bullet points
173
+ bullet_match = re.match(r"^(\s*)([-*+]|\d+\.) (.+)", stripped)
174
+ if bullet_match:
175
+ indent = bullet_match.group(1)
176
+ rest = bullet_match.group(3)
177
+ frags = [("class:md-bullet", " " + indent + "• ")]
178
+ frags += _md_inline_fragments(rest, base_style)
179
+ frags.append(("class:detail-body", "\n"))
180
+ return frags
181
+
182
+ # Blank line
183
+ if not stripped:
184
+ return [("class:detail-body", "\n")]
185
+
186
+ # Normal line with inline formatting
187
+ frags = [("class:detail-body", " ")]
188
+ frags += _md_inline_fragments(stripped, base_style)
189
+ frags.append(("class:detail-body", "\n"))
190
+ return frags
191
+
192
+
193
+ def _md_inline_fragments(text: str, base_style: str) -> list[tuple[str, str]]:
194
+ """Parse bold (**…**/__…__) and inline code (`…`) within a text span."""
195
+ frags: list[tuple[str, str]] = []
196
+ combined = re.compile(r"\*\*(.+?)\*\*|__(.+?)__|`([^`]+)`")
197
+ pos = 0
198
+ for m in combined.finditer(text):
199
+ if m.start() > pos:
200
+ frags.append((base_style, text[pos:m.start()]))
201
+ if m.group(1) is not None or m.group(2) is not None:
202
+ frags.append(("class:md-bold", m.group(1) or m.group(2)))
203
+ else:
204
+ frags.append(("class:md-code", m.group(3)))
205
+ pos = m.end()
206
+ if pos < len(text):
207
+ frags.append((base_style, text[pos:]))
208
+ return frags or [(base_style, text)]
209
+
210
+
211
+ def _fmt_time(created_at: str) -> str:
212
+ """Return a short HH:MM time string from an ISO timestamp."""
213
+ try:
214
+ dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
215
+ return dt.strftime("%H:%M")
216
+ except Exception:
217
+ return ""
218
+
219
+
220
+ def _extract_tags(content: str) -> list[str]:
221
+ return _TAG_RE.findall(content)
222
+
223
+
224
+ def _strip_prefix(content: str) -> str:
225
+ """Strip leading @mention and #tag tokens from the first line; keep the rest."""
226
+ import re
227
+ lines = content.splitlines()
228
+ if not lines:
229
+ return content
230
+ # Remove any run of @word or #word tokens at the start of the first line
231
+ first_body = re.sub(r"^([@#]\S+\s*)+", "", lines[0]).strip()
232
+ rest = lines[1:]
233
+ parts = ([first_body] if first_body else []) + rest
234
+ return "\n".join(parts).strip()
235
+
236
+
237
+ def _is_chat_message(content: str) -> bool:
238
+ tags = _extract_tags(content)
239
+ return "chat" in tags or "chat2" in tags
240
+
241
+
242
+ _CHAT_MSG_TYPES = {"ai-chat-answer", "ai-chat-answer2", "ai-chat-prompt", "ai-chat-prompt2"}
243
+
244
+
245
+ def _deduplicate_chat(msgs: list[dict]) -> list[dict]:
246
+ """Collapse chat-type messages with the same originalMessageId to one entry.
247
+
248
+ Messages arrive oldest-first after the caller reverses the API list.
249
+ Each successive message with the same root is newer, so we replace the
250
+ previous entry in-place — position stays stable, content is freshest.
251
+ """
252
+ result: list[dict] = []
253
+ # maps originalMessageId (str) -> index in result
254
+ seen: dict[str, int] = {}
255
+
256
+ for msg in msgs:
257
+ msg_type = (msg.get("type") or msg.get("Type", "")).lower()
258
+ content = msg.get("content") or msg.get("Content", "")
259
+ is_chat = msg_type in _CHAT_MSG_TYPES or _is_chat_message(content)
260
+
261
+ if not is_chat:
262
+ result.append(msg)
263
+ continue
264
+
265
+ raw_orig = msg.get("originalMessageId") or msg.get("OriginalMessageId")
266
+ if not raw_orig:
267
+ result.append(msg)
268
+ continue
269
+
270
+ orig_id = str(raw_orig)
271
+ if orig_id in seen:
272
+ result[seen[orig_id]] = msg # replace with newer entry
273
+ else:
274
+ seen[orig_id] = len(result)
275
+ result.append(msg)
276
+
277
+ return result
278
+
279
+
280
+ def _has_file_ref(content: str) -> bool:
281
+ return bool(_FILE_RE.search(content))
282
+
283
+
284
+ def _resolve_file_refs(content: str, base_url: str) -> str:
285
+ """Replace #file:GUID with a download URL."""
286
+ def _replace(m):
287
+ guid = m.group(1)
288
+ url = f"{base_url.rstrip('/')}/app/DownloadFile?id={guid}"
289
+ return f"↓ {url}"
290
+ return _FILE_RE.sub(_replace, content)
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # TUI class
295
+ # ---------------------------------------------------------------------------
296
+
297
+
298
+ class CodicentTUI:
299
+
300
+ def __init__(self, codicent, project: str):
301
+ self.codicent = codicent
302
+ self.project = project
303
+
304
+ # --- Logbook state ---
305
+ self._messages: list[dict] = []
306
+ self._selected_index: int | None = None
307
+ self._view_top: int = 0 # first rendered msg in viewport slice
308
+ self._messages_scroll_pos: int = 9999 # Window vertical_scroll target
309
+
310
+ # --- Right-panel state ---
311
+ # mode: "ai-chat" | "thread" | "detail"
312
+ self._right_mode: str = "ai-chat"
313
+ self._thread_messages: list[dict] = []
314
+ self._right_scroll_pos: int = 0 # scroll position when in manual mode
315
+ self._right_manual_scroll: bool = False # True = use _right_scroll_pos; False = auto-bottom
316
+ self._right_total_lines: int = 0 # line count of last rendered right panel
317
+
318
+ # --- AI-chat state ---
319
+ self._chat_lines: list[tuple[str, str]] = [] # (role, text)
320
+ self._conversation_id = None
321
+ self._ai_thinking = False
322
+
323
+ # --- Status ---
324
+ self._status_text = "Ready"
325
+
326
+ # --- Responsive layout state ---
327
+ # Which pane is visible in narrow mode. Default "right" so the input is
328
+ # immediately usable; selecting a logbook row drills into "right".
329
+ self._narrow_active: str = "right"
330
+ self._show_help: bool = False
331
+
332
+ self._build_ui()
333
+
334
+ # ------------------------------------------------------------------
335
+ # Data helpers
336
+ # ------------------------------------------------------------------
337
+
338
+ def _load_messages(self):
339
+ """Fetch recent messages. Runs in background thread."""
340
+ try:
341
+ self._status_text = "Refreshing…"
342
+ self._refresh_display()
343
+ msgs = self.codicent.get_messages(start=0, length=20)
344
+ # Reverse so oldest first, newest last (bottom of list)
345
+ raw = list(reversed(msgs if isinstance(msgs, list) else []))
346
+ self._messages = _deduplicate_chat(raw)
347
+ except Exception as e:
348
+ self._status_text = f"Refresh error: {e}"
349
+ else:
350
+ self._status_text = f"Loaded {len(self._messages)} message(s)"
351
+ self._adjust_viewport()
352
+ self._refresh_display()
353
+
354
+ def _post_message(self, text: str, parent_id=None):
355
+ """Post a message (or update an existing one). Runs in background thread."""
356
+ text = text.strip()
357
+ if not text:
358
+ return
359
+ # For edits, remember the originalMessageId so we can re-select after reload.
360
+ orig_id = None
361
+ if parent_id and self._selected_index is not None:
362
+ msg = self._messages[self._selected_index]
363
+ raw = msg.get("originalMessageId") or msg.get("OriginalMessageId") or \
364
+ msg.get("id") or msg.get("Id")
365
+ orig_id = str(raw) if raw else None
366
+ try:
367
+ self._status_text = "Posting…"
368
+ self._refresh_display()
369
+ self.codicent.post_message(text, parent_id=parent_id, type="info")
370
+ self._status_text = "Updated!" if parent_id else "Posted!"
371
+ except Exception as e:
372
+ self._status_text = f"Post error: {e}"
373
+ if parent_id:
374
+ # Old message is versioned out; clear stale index before reload
375
+ self._selected_index = None
376
+ self._right_mode = "ai-chat"
377
+ self._conversation_id = None
378
+ self._load_messages()
379
+ if orig_id:
380
+ self._auto_select_by_orig_id(orig_id)
381
+
382
+ def _auto_select_by_orig_id(self, orig_id: str):
383
+ """Select the logbook row whose originalMessageId matches orig_id."""
384
+ for i, msg in enumerate(self._messages):
385
+ omid = str(msg.get("originalMessageId") or msg.get("OriginalMessageId") or
386
+ msg.get("id") or msg.get("Id", ""))
387
+ if omid == orig_id:
388
+ self._selected_index = i
389
+ self._adjust_viewport()
390
+ self._on_selection_changed()
391
+ return
392
+
393
+ def _send_chat(self, text: str):
394
+ """Send chat + get AI reply, then reload logbook. Runs in background thread."""
395
+ text = text.strip()
396
+ if not text:
397
+ return
398
+ was_new_chat = (self._right_mode == "ai-chat")
399
+ self._chat_lines.append(("you", text))
400
+ # Immediately show the user's message in the right panel
401
+ if self._right_mode == "thread":
402
+ self._thread_messages.append({
403
+ "type": "ai-chat-prompt",
404
+ "content": text,
405
+ "createdAt": datetime.utcnow().isoformat() + "Z",
406
+ })
407
+ self._right_manual_scroll = False # snap to bottom
408
+ elif was_new_chat:
409
+ # Switch immediately to thread mode so new chat shows as a conversation
410
+ self._thread_messages = [{
411
+ "type": "ai-chat-prompt",
412
+ "content": text,
413
+ "createdAt": datetime.utcnow().isoformat() + "Z",
414
+ }]
415
+ self._right_mode = "thread"
416
+ self._right_manual_scroll = False
417
+ self._ai_thinking = True
418
+ self._status_text = "Thinking…"
419
+ self._refresh_display()
420
+ try:
421
+ resp = self.codicent.post_chat_reply(text, self._conversation_id)
422
+ self._conversation_id = resp["id"]
423
+ ai_text = resp.get("content", "")
424
+ self._chat_lines.append(("ai", ai_text))
425
+ # Add AI reply to thread view immediately
426
+ self._thread_messages.append({
427
+ "type": "ai-chat-answer",
428
+ "content": ai_text,
429
+ "createdAt": datetime.utcnow().isoformat() + "Z",
430
+ })
431
+ self._right_manual_scroll = False
432
+ self._status_text = "Done"
433
+ except Exception as e:
434
+ self._chat_lines.append(("ai", f"[Error: {e}]"))
435
+ self._status_text = f"Chat error: {e}"
436
+ finally:
437
+ self._ai_thinking = False
438
+ self._refresh_display()
439
+ # Reload logbook so new chat message appears, then auto-select it
440
+ self._load_messages()
441
+ self._auto_select_conversation()
442
+
443
+ def _spawn_chat(self, text: str):
444
+ """Fire-and-forget: start a new background chat without changing the view."""
445
+ text = text.strip()
446
+ if not text:
447
+ return
448
+ self._status_text = "Spawning…"
449
+ self._refresh_display()
450
+ try:
451
+ resp = self.codicent.post_chat_reply(text, None)
452
+ self._status_text = "Spawned!"
453
+ except Exception as e:
454
+ self._status_text = f"Spawn error: {e}"
455
+ # Reload logbook so the new thread appears; do NOT auto-select it
456
+ self._load_messages()
457
+
458
+ def _fetch_thread(self, msg_id: str):
459
+ """Fetch full conversation thread. Runs in background thread."""
460
+ try:
461
+ self._status_text = f"Loading thread {msg_id[:8]}…"
462
+ self._refresh_display()
463
+ url = f"{self.codicent.base_url.rstrip('/')}/api/GetMessageHistory"
464
+ resp = _requests.get(
465
+ url,
466
+ params={"messageId": msg_id},
467
+ headers={"Authorization": f"Bearer {self.codicent.token}"},
468
+ verify=self.codicent.verify_https,
469
+ timeout=15,
470
+ )
471
+ if not resp.ok:
472
+ snippet = resp.text[:120].replace("\n", " ")
473
+ self._thread_messages = []
474
+ self._status_text = f"Thread HTTP {resp.status_code}: {snippet}"
475
+ self._refresh_display()
476
+ return
477
+ text = resp.text.strip()
478
+ if not text:
479
+ self._thread_messages = []
480
+ self._status_text = "Thread: empty response"
481
+ self._refresh_display()
482
+ return
483
+ data = resp.json()
484
+ self._thread_messages = data if isinstance(data, list) else []
485
+ self._status_text = f"Thread: {len(self._thread_messages)} message(s)"
486
+ except Exception as e:
487
+ self._thread_messages = []
488
+ self._status_text = f"Thread error: {e}"
489
+ self._refresh_display()
490
+
491
+ # ------------------------------------------------------------------
492
+ # Terminal size helpers
493
+ # ------------------------------------------------------------------
494
+
495
+ def _term_size(self):
496
+ """Return (cols, rows) reading the current Application output. Safe to
497
+ call before .run() — falls back to (80, 24) if the app isn't ready."""
498
+ try:
499
+ size = get_app().output.get_size()
500
+ return size.columns, size.rows
501
+ except Exception:
502
+ return 80, 24
503
+
504
+ def _is_narrow(self) -> bool:
505
+ cols, _ = self._term_size()
506
+ return cols < NARROW_WIDTH_THRESHOLD
507
+
508
+ def _max_visible(self) -> int:
509
+ """Cap the logbook slice at _MAX_VISIBLE but shrink for short terminals.
510
+ Reserves rows for title, frame borders, input (up to 5), and status."""
511
+ _, rows = self._term_size()
512
+ return min(_MAX_VISIBLE, max(3, rows - 8))
513
+
514
+ # ------------------------------------------------------------------
515
+ # Viewport helper
516
+ # ------------------------------------------------------------------
517
+
518
+ def _adjust_viewport(self):
519
+ """Scroll _view_top so the selected row (or newest) is visible."""
520
+ n = len(self._messages)
521
+ if n == 0:
522
+ self._view_top = 0
523
+ return
524
+ max_visible = self._max_visible()
525
+ if self._selected_index is None:
526
+ # No selection → show the newest messages (bottom of list)
527
+ self._view_top = max(0, n - max_visible)
528
+ else:
529
+ # Clamp selection first
530
+ idx = max(0, min(n - 1, self._selected_index))
531
+ # Scroll up if selected row is above viewport
532
+ if idx < self._view_top:
533
+ self._view_top = idx
534
+ # Scroll down if selected row is below viewport
535
+ elif idx >= self._view_top + max_visible:
536
+ self._view_top = idx - max_visible + 1
537
+ # Safety clamp
538
+ self._view_top = max(0, min(self._view_top, max(0, n - max_visible)))
539
+
540
+ # Update Window scroll to make the right content visible.
541
+ # Use the actual rendered line count (not 9999) so prompt_toolkit clamps correctly.
542
+ n_shown = min(n, max_visible)
543
+ header = 1 if self._view_top > 0 else 0
544
+ total_rendered = header + n_shown * _LINES_PER_MSG
545
+ if self._selected_index is None:
546
+ # No selection → show newest (bottom of rendered content)
547
+ self._messages_scroll_pos = total_rendered
548
+ else:
549
+ pos_in_slice = self._selected_index - self._view_top
550
+ # Scroll selected item to top of visible area
551
+ self._messages_scroll_pos = max(0, header + pos_in_slice * _LINES_PER_MSG)
552
+
553
+ # ------------------------------------------------------------------
554
+ # Selection / mode helpers
555
+ # ------------------------------------------------------------------
556
+
557
+ def _on_selection_changed(self):
558
+ """Called whenever _selected_index changes. Updates right-panel mode."""
559
+ if self._selected_index is None:
560
+ self._right_mode = "ai-chat"
561
+ self._right_scroll_pos = 9999
562
+ self._refresh_display()
563
+ return
564
+
565
+ # Drill-in: in narrow mode, selecting from the logbook surfaces the
566
+ # right pane so the user can read/reply without an extra Tab.
567
+ if self._is_narrow():
568
+ self._narrow_active = "right"
569
+
570
+ msg = self._messages[self._selected_index]
571
+ content = msg.get("content") or msg.get("Content", "")
572
+ msg_id = str(msg.get("id") or msg.get("Id", ""))
573
+ msg_type = (msg.get("type") or msg.get("Type", "")).lower()
574
+ # Use originalMessageId as the thread root (AI replies carry it).
575
+ # Only fall back to msg_id if originalMessageId is absent or falsy (0/null).
576
+ raw_orig = msg.get("originalMessageId") or msg.get("OriginalMessageId")
577
+ orig_id = str(raw_orig) if raw_orig else msg_id
578
+
579
+ is_chat = _is_chat_message(content) or msg_type in _CHAT_MSG_TYPES
580
+ if is_chat:
581
+ self._right_mode = "thread"
582
+ self._right_manual_scroll = False # auto-scroll to bottom
583
+ self._conversation_id = orig_id # Ask › replies into this thread
584
+ threading.Thread(
585
+ target=self._fetch_thread, args=(orig_id,), daemon=True
586
+ ).start()
587
+ else:
588
+ self._right_mode = "detail"
589
+ self._right_manual_scroll = True # manual so we start at pos 0 (top)
590
+ self._right_scroll_pos = 0
591
+ self._thread_messages = []
592
+ self._refresh_display()
593
+
594
+ def _move_selection(self, delta: int):
595
+ if not self._messages:
596
+ return
597
+ if self._selected_index is None:
598
+ # ↓ starts at newest (bottom), ↑ starts at newest too
599
+ self._selected_index = len(self._messages) - 1 if delta > 0 else len(self._messages) - 1
600
+ else:
601
+ self._selected_index = max(
602
+ 0, min(len(self._messages) - 1, self._selected_index + delta)
603
+ )
604
+ self._adjust_viewport()
605
+ self._on_selection_changed()
606
+
607
+ def _clear_selection(self):
608
+ self._selected_index = None
609
+ self._right_mode = "ai-chat"
610
+ self._conversation_id = None
611
+ self._right_manual_scroll = False # back to auto-bottom
612
+ self._show_help = False
613
+ # Pop back to the logbook in narrow mode so deselect feels like "back".
614
+ if self._is_narrow():
615
+ self._narrow_active = "logbook"
616
+ self._adjust_viewport()
617
+ self._refresh_display()
618
+
619
+ def _auto_select_conversation(self):
620
+ """After a chat reply, find the conversation in the logbook and select it."""
621
+ if not self._conversation_id or not self._messages:
622
+ return
623
+ cid = str(self._conversation_id)
624
+ for i, msg in enumerate(self._messages):
625
+ mid = str(msg.get("id") or msg.get("Id", ""))
626
+ omid = str(msg.get("originalMessageId") or msg.get("OriginalMessageId", ""))
627
+ if cid in (mid, omid):
628
+ if i != self._selected_index:
629
+ self._selected_index = i
630
+ self._adjust_viewport()
631
+ self._on_selection_changed()
632
+ return
633
+ # Conversation not found by id — fall back to selecting newest chat message
634
+ for i in range(len(self._messages) - 1, -1, -1):
635
+ content = self._messages[i].get("content") or self._messages[i].get("Content", "")
636
+ if _is_chat_message(content):
637
+ self._selected_index = i
638
+ self._adjust_viewport()
639
+ self._on_selection_changed()
640
+ return
641
+
642
+ # ------------------------------------------------------------------
643
+ # Formatted-text builders
644
+ # ------------------------------------------------------------------
645
+
646
+ def _build_messages_text(self):
647
+ result = []
648
+ n = len(self._messages)
649
+ if n == 0:
650
+ result.append(("class:msg-content", " No messages yet.\n"))
651
+ return result
652
+
653
+ start = self._view_top
654
+ end = min(n, start + self._max_visible())
655
+
656
+ # "── N older ──" header
657
+ if start > 0:
658
+ result.append(("class:msg-time", f" ── {start} older ──\n"))
659
+
660
+ for i in range(start, end):
661
+ msg = self._messages[i]
662
+ content = msg.get("content") or msg.get("Content", "")
663
+ created = msg.get("createdAt") or msg.get("CreatedAt", "")
664
+ selected = (i == self._selected_index)
665
+
666
+ tags = _extract_tags(content)
667
+ body = _strip_prefix(content)
668
+ time_str = _fmt_time(created)
669
+ has_file = _has_file_ref(content)
670
+
671
+ tag_style = "class:msg-selected-tag" if selected else "class:msg-tags"
672
+ time_style = "class:msg-selected" if selected else "class:msg-time"
673
+ body_style = "class:msg-selected" if selected else "class:msg-content"
674
+
675
+ tag_str = " ".join(f"#{t}" for t in tags[:5])
676
+ if has_file:
677
+ tag_str += " 📎"
678
+ if selected:
679
+ tag_str = "▶ " + tag_str
680
+
681
+ result.append((tag_style, f" {tag_str}"))
682
+ if time_str:
683
+ result.append((time_style, f" {time_str}"))
684
+ result.append(("", "\n"))
685
+
686
+ if body:
687
+ display = body[:80] + ("…" if len(body) > 80 else "")
688
+ result.append((body_style, f" {display}\n"))
689
+
690
+ result.append(("class:msg-time", " " + "─" * 40 + "\n"))
691
+
692
+ # "── N newer ──" footer
693
+ remaining = n - end
694
+ if remaining > 0:
695
+ result.append(("class:msg-time", f" ── {remaining} newer ──\n"))
696
+
697
+ return result
698
+
699
+ def _input_prompt_text(self) -> str:
700
+ """Dynamic label for the single unified input field."""
701
+ labels = {
702
+ "ai-chat": "New chat › ",
703
+ "thread": "Reply › ",
704
+ "detail": "Update › ",
705
+ }
706
+ return labels.get(self._right_mode, " › ")
707
+
708
+ def _build_right_panel_text(self):
709
+ """Build formatted text for the right panel according to current mode."""
710
+ if self._show_help:
711
+ frags = self._build_help_text()
712
+ else:
713
+ mode = self._right_mode
714
+ if mode == "thread":
715
+ frags = self._build_thread_text()
716
+ elif mode == "detail":
717
+ frags = self._build_detail_text()
718
+ else:
719
+ frags = self._build_ai_chat_text()
720
+ self._right_total_lines = sum(text.count("\n") for _, text in frags)
721
+ return frags
722
+
723
+ def _build_help_text(self):
724
+ """Static help overlay shown when _show_help is True."""
725
+ rows = [
726
+ ("class:md-h2", " Codicent TUI — help\n"),
727
+ ("class:detail-body", "\n"),
728
+ ("class:detail-label"," Enter (per current mode)\n"),
729
+ ("class:detail-body", " no selection → start a new AI chat\n"),
730
+ ("class:detail-body", " chat selected → reply in thread\n"),
731
+ ("class:detail-body", " other selected → post child update\n"),
732
+ ("class:detail-body", "\n"),
733
+ ("class:detail-label"," Slash commands (type into the input)\n"),
734
+ ("class:detail-body", " /post [text] plain post (parent-linked if selected)\n"),
735
+ ("class:detail-body", " /spawn [text] fire-and-forget chat\n"),
736
+ ("class:detail-body", " /new [text] clear selection (and send if text)\n"),
737
+ ("class:detail-body", " /refresh reload logbook\n"),
738
+ ("class:detail-body", " /help, /? toggle this help\n"),
739
+ ("class:detail-body", " /quit exit\n"),
740
+ ("class:detail-body", " //foo literal: send the text \"/foo\"\n"),
741
+ ("class:detail-body", "\n"),
742
+ ("class:detail-label"," Keys\n"),
743
+ ("class:detail-body", " Tab (narrow) swap Logbook ↔ right pane\n"),
744
+ ("class:detail-body", " ? (input empty) toggle help\n"),
745
+ ("class:detail-body", " Esc close help / clear input / deselect\n"),
746
+ ("class:detail-body", " Ctrl+P post Ctrl+J spawn\n"),
747
+ ("class:detail-body", " Ctrl+N new chat Ctrl+R refresh\n"),
748
+ ("class:detail-body", " Ctrl+C quit\n"),
749
+ ("class:detail-body", " ↑/↓ navigate logbook (input empty) or scroll right\n"),
750
+ ("class:detail-body", "\n"),
751
+ ("class:msg-time", " Press ? or Esc to close.\n"),
752
+ ]
753
+ return rows
754
+
755
+ def _build_ai_chat_text(self):
756
+ result = []
757
+ if not self._chat_lines:
758
+ result.append(("class:msg-content", " No chat history yet.\n"))
759
+ return result
760
+ for role, text in self._chat_lines[-30:]:
761
+ if role == "you":
762
+ result.append(("class:chat-you", " You: "))
763
+ result.append(("class:msg-content", text + "\n"))
764
+ else:
765
+ result.append(("class:chat-ai", " AI: "))
766
+ result.append(("class:msg-content", text + "\n"))
767
+ result.append(("class:msg-time", "\n"))
768
+ if self._ai_thinking:
769
+ result.append(("class:chat-thinking", " AI is thinking…\n"))
770
+ return result
771
+
772
+ def _build_thread_text(self):
773
+ result = []
774
+ if not self._thread_messages:
775
+ result.append(("class:chat-thinking", " Loading thread…\n"))
776
+ return result
777
+ for msg in self._thread_messages:
778
+ content = msg.get("content") or msg.get("Content", "")
779
+ created = msg.get("createdAt") or msg.get("CreatedAt", "")
780
+ msg_type = (msg.get("type") or msg.get("Type", "")).lower()
781
+ time_str = _fmt_time(created)
782
+
783
+ # Skip function/tool call messages — only show user prompts and AI answers
784
+ is_ai = msg_type in ("ai-chat-answer", "ai-chat-answer2")
785
+ is_user = msg_type in ("ai-chat-prompt", "ai-chat-prompt2")
786
+ if not is_ai and not is_user:
787
+ continue
788
+
789
+ role_label = " AI: " if is_ai else " You: "
790
+ role_style = "class:chat-ai" if is_ai else "class:chat-you"
791
+ body_style = "class:detail-body"
792
+
793
+ body = _strip_prefix(content) or content.strip()
794
+ if not body:
795
+ continue
796
+
797
+ result.append((role_style, role_label))
798
+ if time_str:
799
+ result.append(("class:msg-time", f"[{time_str}]\n"))
800
+ else:
801
+ result.append(("class:detail-body", "\n"))
802
+ for line in body.splitlines():
803
+ result.extend(_md_line_fragments(line, body_style))
804
+ result.append(("class:msg-time", " " + "─" * 40 + "\n\n"))
805
+
806
+ if self._ai_thinking:
807
+ result.append(("class:chat-thinking", " AI is thinking…\n"))
808
+ return result
809
+
810
+ def _build_detail_text(self):
811
+ result = []
812
+ if self._selected_index is None or not self._messages:
813
+ return result
814
+
815
+ msg = self._messages[self._selected_index]
816
+ content = msg.get("content") or msg.get("Content", "")
817
+ created = msg.get("createdAt") or msg.get("CreatedAt", "")
818
+
819
+ tags = _extract_tags(content)
820
+ body = _strip_prefix(content)
821
+ time_str = _fmt_time(created)
822
+
823
+ if tags:
824
+ result.append(("class:detail-label", " Tags: "))
825
+ result.append(("class:msg-tags", " ".join(f"#{t}" for t in tags) + "\n"))
826
+ if time_str:
827
+ result.append(("class:detail-label", " Time: "))
828
+ result.append(("class:msg-time", time_str + "\n"))
829
+ result.append(("class:msg-time", "\n"))
830
+
831
+ if body:
832
+ resolved = _resolve_file_refs(body, self.codicent.base_url)
833
+ for line in resolved.splitlines():
834
+ if line.startswith("↓ "):
835
+ result.append(("class:detail-label", " "))
836
+ result.append(("class:detail-url", line + "\n"))
837
+ else:
838
+ result.extend(_md_line_fragments(line, "class:detail-body"))
839
+ else:
840
+ result.append(("class:msg-time", " (no body)\n"))
841
+
842
+ return result
843
+
844
+ def _build_status_text(self):
845
+ mode_labels = {"ai-chat": "AI-CHAT", "thread": "THREAD", "detail": "DETAIL"}
846
+ if self._is_narrow():
847
+ project = (self.project or "")[:8]
848
+ pane = "LOG" if self._narrow_active == "logbook" else "MSG"
849
+ hint = " [Tab] swap [?] help [Enter] send"
850
+ return [
851
+ ("class:status-key", f" {project} "),
852
+ ("class:status-mode", f" {pane} "),
853
+ ("class:status-bar", self._status_text),
854
+ ("class:status-bar", hint),
855
+ ]
856
+ hint = (
857
+ " [\u2191\u2193] navigate [Esc] clear/deselect "
858
+ "[Enter] send [Ctrl+P] post [Ctrl+J] spawn "
859
+ "[Ctrl+N] new chat [Ctrl+R] refresh [?] help [Ctrl+C] quit"
860
+ )
861
+ return [
862
+ ("class:status-key", f" {self.project} "),
863
+ ("class:status-bar", " │ "),
864
+ ("class:status-mode", f" {mode_labels.get(self._right_mode, '')} "),
865
+ ("class:status-bar", " │ "),
866
+ ("class:status-bar", self._status_text),
867
+ ("class:status-bar", hint),
868
+ ]
869
+
870
+ def _right_panel_title(self):
871
+ if self._show_help:
872
+ return "Help"
873
+ return {"ai-chat": "Chat", "thread": "Thread", "detail": "Detail"}.get(
874
+ self._right_mode, "Chat"
875
+ )
876
+
877
+ # ------------------------------------------------------------------
878
+ # UI construction
879
+ # ------------------------------------------------------------------
880
+
881
+ def _build_ui(self):
882
+ # --- Left panel (Logbook) ---
883
+ self._messages_control = FormattedTextControl(
884
+ text=self._build_messages_text,
885
+ focusable=False,
886
+ )
887
+ messages_window = Window(
888
+ content=self._messages_control,
889
+ wrap_lines=True,
890
+ get_vertical_scroll=lambda win: self._messages_scroll_pos,
891
+ )
892
+ self._left_panel = Frame(messages_window, title="Logbook",
893
+ style="class:focused-frame")
894
+
895
+ # --- Right panel (frame only — no input nested inside) ---
896
+ self._right_control = FormattedTextControl(
897
+ text=self._build_right_panel_text,
898
+ focusable=False,
899
+ )
900
+ right_window = Window(
901
+ content=self._right_control,
902
+ wrap_lines=True,
903
+ get_vertical_scroll=lambda win: self._right_scroll_pos if self._right_manual_scroll else 9999,
904
+ )
905
+ self._right_panel = Frame(right_window, title=self._right_panel_title,
906
+ style="class:focused-frame")
907
+
908
+ # --- Input — lifted to root level so it stays visible in narrow mode
909
+ # regardless of which pane is showing. Single-line keeps Enter=send. ---
910
+ self.chat_input = TextArea(
911
+ prompt=self._input_prompt_text,
912
+ multiline=False,
913
+ height=1,
914
+ style="class:chat-input",
915
+ accept_handler=self._on_chat_accept,
916
+ focus_on_click=True,
917
+ )
918
+
919
+ # Cache the wide-mode body so DynamicContainer doesn't rebuild it every
920
+ # frame.
921
+ self._wide_body = VSplit([self._left_panel, self._right_panel], padding=1)
922
+
923
+ # --- Root ---
924
+ title_bar = Window(
925
+ content=FormattedTextControl(
926
+ lambda: [("class:title", f" Codicent TUI @{self.project}")]
927
+ ),
928
+ height=1,
929
+ align=WindowAlign.LEFT,
930
+ )
931
+ status_bar = Window(
932
+ content=FormattedTextControl(text=self._build_status_text),
933
+ height=1,
934
+ style="class:status-bar",
935
+ )
936
+ body = DynamicContainer(self._get_body_container)
937
+ root = HSplit([title_bar, body, self.chat_input, status_bar])
938
+
939
+ self._layout = Layout(root, focused_element=self.chat_input)
940
+ self._kb = self._build_keybindings()
941
+
942
+ def _get_body_container(self):
943
+ """Pick the body layout each render based on terminal width."""
944
+ if self._is_narrow():
945
+ return self._left_panel if self._narrow_active == "logbook" else self._right_panel
946
+ return self._wide_body
947
+
948
+ def _build_keybindings(self) -> KeyBindings:
949
+ kb = KeyBindings()
950
+
951
+ @kb.add("up")
952
+ def _up(event):
953
+ if not self.chat_input.text:
954
+ if self._selected_index is not None and self._selected_index == 0:
955
+ event.app.output.bell()
956
+ else:
957
+ self._move_selection(-1)
958
+ else:
959
+ # Input has text → scroll right panel instead
960
+ if not self._right_manual_scroll:
961
+ self._right_manual_scroll = True
962
+ self._right_scroll_pos = max(0, self._right_total_lines - 5)
963
+ self._right_scroll_pos = max(0, self._right_scroll_pos - 3)
964
+ self._refresh_display()
965
+
966
+ @kb.add("down")
967
+ def _down(event):
968
+ if not self.chat_input.text:
969
+ self._move_selection(1)
970
+ else:
971
+ if self._right_manual_scroll:
972
+ self._right_scroll_pos += 3
973
+ if self._right_scroll_pos >= max(0, self._right_total_lines - 2):
974
+ self._right_manual_scroll = False
975
+ self._refresh_display()
976
+
977
+ @kb.add("escape")
978
+ def _escape(event):
979
+ if self._show_help:
980
+ self._show_help = False
981
+ self._refresh_display()
982
+ elif self.chat_input.text:
983
+ self.chat_input.text = ""
984
+ elif self._selected_index is not None:
985
+ self._clear_selection()
986
+
987
+ @kb.add("tab", filter=Condition(lambda: self._is_narrow()))
988
+ def _tab(event):
989
+ """Swap between Logbook and right pane in narrow mode."""
990
+ self._narrow_active = "logbook" if self._narrow_active == "right" else "right"
991
+ self._refresh_display()
992
+
993
+ @kb.add("?", filter=Condition(lambda: not self.chat_input.text))
994
+ def _help(event):
995
+ """Toggle help overlay (only when input is empty so '?' can be typed)."""
996
+ self._show_help = not self._show_help
997
+ if self._show_help and self._is_narrow():
998
+ self._narrow_active = "right"
999
+ self._refresh_display()
1000
+
1001
+ @kb.add("c-p")
1002
+ def _ctrl_p(event):
1003
+ text = self.chat_input.text
1004
+ if text.strip():
1005
+ self.chat_input.text = ""
1006
+ parent_id = None
1007
+ if self._right_mode == "detail" and self._selected_index is not None:
1008
+ msg = self._messages[self._selected_index]
1009
+ raw = msg.get("id") or msg.get("Id")
1010
+ parent_id = str(raw) if raw else None
1011
+ threading.Thread(
1012
+ target=self._post_message, args=(text, parent_id), daemon=True
1013
+ ).start()
1014
+
1015
+ @kb.add("c-n")
1016
+ def _ctrl_n(event):
1017
+ """Jump to new-chat mode from anywhere."""
1018
+ self.chat_input.text = ""
1019
+ self._clear_selection()
1020
+
1021
+ @kb.add("c-j")
1022
+ def _ctrl_j(event):
1023
+ text = self.chat_input.text
1024
+ if text.strip():
1025
+ self.chat_input.text = ""
1026
+ threading.Thread(
1027
+ target=self._spawn_chat, args=(text,), daemon=True
1028
+ ).start()
1029
+
1030
+ @kb.add("c-r")
1031
+ def _ctrl_r(event):
1032
+ threading.Thread(target=self._load_messages, daemon=True).start()
1033
+
1034
+ @kb.add("c-c")
1035
+ def _ctrl_c(event):
1036
+ event.app.exit()
1037
+
1038
+ return kb
1039
+
1040
+ # ------------------------------------------------------------------
1041
+ # Accept handlers
1042
+ # ------------------------------------------------------------------
1043
+
1044
+ def _on_chat_accept(self, buf: Buffer):
1045
+ text = buf.text
1046
+ stripped = text.strip()
1047
+
1048
+ # //foo escape → strip one slash and send the rest as plain text.
1049
+ if stripped.startswith("//"):
1050
+ text = text.replace("//", "/", 1)
1051
+ stripped = text.strip()
1052
+ # Fall through to normal send below.
1053
+
1054
+ # /command → dispatch table; consumes the input.
1055
+ elif stripped.startswith("/"):
1056
+ buf.reset()
1057
+ self._handle_slash(stripped)
1058
+ return True
1059
+
1060
+ if not stripped:
1061
+ return True
1062
+
1063
+ buf.reset()
1064
+
1065
+ # Detail mode → post a child update of the selected message.
1066
+ if self._right_mode == "detail" and self._selected_index is not None:
1067
+ msg = self._messages[self._selected_index]
1068
+ raw = msg.get("id") or msg.get("Id")
1069
+ parent_id = str(raw) if raw else None
1070
+ threading.Thread(
1071
+ target=self._post_message, args=(text, parent_id), daemon=True
1072
+ ).start()
1073
+ return True
1074
+
1075
+ # ai-chat / thread → send to the AI conversation.
1076
+ threading.Thread(
1077
+ target=self._send_chat, args=(text,), daemon=True
1078
+ ).start()
1079
+ return True
1080
+
1081
+ def _handle_slash(self, cmd_text: str):
1082
+ """Parse and dispatch a /command entered via the chat input.
1083
+
1084
+ Format: /word [optional body]. The body becomes the message for /post,
1085
+ /spawn, and /new (when applicable)."""
1086
+ cmd, _, body = cmd_text[1:].partition(" ")
1087
+ cmd = cmd.lower().strip()
1088
+ body = body.strip()
1089
+
1090
+ if cmd == "post":
1091
+ parent_id = None
1092
+ if self._right_mode == "detail" and self._selected_index is not None:
1093
+ msg = self._messages[self._selected_index]
1094
+ raw = msg.get("id") or msg.get("Id")
1095
+ parent_id = str(raw) if raw else None
1096
+ if not body:
1097
+ self._status_text = "Usage: /post <text>"
1098
+ self._refresh_display()
1099
+ return
1100
+ threading.Thread(
1101
+ target=self._post_message, args=(body, parent_id), daemon=True
1102
+ ).start()
1103
+
1104
+ elif cmd == "spawn":
1105
+ if not body:
1106
+ self._status_text = "Usage: /spawn <text>"
1107
+ self._refresh_display()
1108
+ return
1109
+ threading.Thread(
1110
+ target=self._spawn_chat, args=(body,), daemon=True
1111
+ ).start()
1112
+
1113
+ elif cmd == "new":
1114
+ self._clear_selection()
1115
+ if body:
1116
+ threading.Thread(
1117
+ target=self._send_chat, args=(body,), daemon=True
1118
+ ).start()
1119
+
1120
+ elif cmd == "refresh":
1121
+ threading.Thread(target=self._load_messages, daemon=True).start()
1122
+
1123
+ elif cmd in ("help", "?"):
1124
+ self._show_help = not self._show_help
1125
+ if self._show_help and self._is_narrow():
1126
+ self._narrow_active = "right"
1127
+ self._refresh_display()
1128
+
1129
+ elif cmd == "quit":
1130
+ try:
1131
+ self._app.exit()
1132
+ except Exception:
1133
+ pass
1134
+
1135
+ else:
1136
+ self._status_text = f"Unknown command: /{cmd}"
1137
+ self._refresh_display()
1138
+
1139
+ # ------------------------------------------------------------------
1140
+ # Helpers
1141
+ # ------------------------------------------------------------------
1142
+
1143
+ def _refresh_display(self):
1144
+ try:
1145
+ self._app.invalidate()
1146
+ except Exception:
1147
+ pass
1148
+
1149
+ # ------------------------------------------------------------------
1150
+ # Entry point
1151
+ # ------------------------------------------------------------------
1152
+
1153
+ def run(self):
1154
+ app_input, app_output = _create_prompt_io()
1155
+ self._app = Application(
1156
+ layout=self._layout,
1157
+ key_bindings=self._kb,
1158
+ style=STYLE,
1159
+ full_screen=True,
1160
+ mouse_support=True,
1161
+ input=app_input,
1162
+ output=app_output,
1163
+ )
1164
+ threading.Thread(target=self._load_messages, daemon=True).start()
1165
+ self._app.run()
1166
+
1167
+ def run_tui(codicent, project: str):
1168
+ """Entry point called from app.py."""
1169
+ CodicentTUI(codicent, project).run()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes