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.
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/PKG-INFO +1 -1
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/app.py +43 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/codicent_cli.egg-info/PKG-INFO +1 -1
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/codicent_cli.egg-info/SOURCES.txt +1 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/codicent_cli.egg-info/top_level.txt +1 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/setup.py +2 -2
- codicent_cli-0.8.6/tui.py +1169 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/LICENSE +0 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/README.md +0 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/auth.py +0 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/codicent_cli.egg-info/dependency_links.txt +0 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/codicent_cli.egg-info/entry_points.txt +0 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/codicent_cli.egg-info/requires.txt +0 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/setup.cfg +0 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/templates/__init__.py +0 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/templates/default.json +0 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/templates/document-processing.json +0 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/templates/koi-studio.json +0 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/terminal.py +0 -0
- {codicent_cli-0.8.4 → codicent_cli-0.8.6}/wrap.py +0 -0
|
@@ -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
|
|
@@ -9,7 +9,7 @@ except FileNotFoundError:
|
|
|
9
9
|
|
|
10
10
|
setup(
|
|
11
11
|
name='codicent-cli',
|
|
12
|
-
version='0.8.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|