closecode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,125 @@
1
+ """Close Code Main Screen — welcome view transitions to 4:1 chat layout."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.screen import Screen
5
+ from textual.widgets import Static, Input
6
+ from textual.containers import Vertical, Horizontal, Center
7
+
8
+ from closecode.ui.widgets.header import CloseCodeHeader
9
+ from closecode.ui.widgets.chat import CloseCodeChat
10
+ from closecode.ui.widgets.composer import CloseCodeComposer, ComposerSubmit
11
+ from closecode.ui.widgets.statusbar import CloseCodeStatusBar
12
+ from closecode.ui.widgets.infopanel import CloseCodeInfoPanel
13
+ from closecode.agent import AgentState
14
+
15
+
16
+ class MainScreen(Screen):
17
+ """Main application screen with two modes:
18
+
19
+ 1. Welcome mode: centered greeting + input
20
+ 2. Chat mode: 4:1 split (chat | info panel)
21
+
22
+ Transitions from welcome → chat on first message.
23
+ """
24
+
25
+ BINDINGS = [
26
+ ("ctrl+l", "clear_chat", "Clear"),
27
+ ("ctrl+n", "new_session", "New session"),
28
+ ("ctrl+c", "cancel", "Cancel"),
29
+ ]
30
+
31
+ def __init__(self):
32
+ super().__init__()
33
+ self._in_welcome = True
34
+
35
+ def compose(self) -> ComposeResult:
36
+ yield CloseCodeHeader()
37
+
38
+ # Welcome view (shown initially, hidden after first message)
39
+ with Center(id="welcome-view"):
40
+ with Vertical(id="welcome-container"):
41
+ yield Static("⬡", id="welcome-logo")
42
+ yield Static("Yo, How can I help you today?", id="welcome-greeting")
43
+ yield Static("", id="welcome-model-tag")
44
+ yield Input(
45
+ placeholder="Ask me anything…",
46
+ id="welcome-input",
47
+ )
48
+
49
+ # Chat view (hidden initially, shown after first message)
50
+ with Horizontal(id="chat-view"):
51
+ with Vertical(id="chat-column"):
52
+ yield CloseCodeChat()
53
+ yield CloseCodeComposer()
54
+ yield CloseCodeInfoPanel()
55
+
56
+ yield CloseCodeStatusBar()
57
+
58
+ def on_mount(self):
59
+ """Show welcome, hide chat."""
60
+ self.query_one("#chat-view").display = False
61
+ self.query_one("#welcome-input", Input).focus()
62
+
63
+ def on_input_submitted(self, event: Input.Submitted):
64
+ """Handle Enter on the welcome input — transition to chat mode."""
65
+ if event.input.id == "welcome-input":
66
+ text = event.value.strip()
67
+ if text:
68
+ self._transition_to_chat()
69
+ # Forward the message to the app as a ComposerSubmit
70
+ self.app.on_composer_submit(ComposerSubmit(text))
71
+
72
+ def _transition_to_chat(self):
73
+ """Switch from welcome view to chat view."""
74
+ if not self._in_welcome:
75
+ return
76
+ self._in_welcome = False
77
+ self.query_one("#welcome-view").display = False
78
+ self.query_one("#chat-view").display = True
79
+ try:
80
+ self.query_one(CloseCodeComposer).set_focus()
81
+ except Exception:
82
+ pass
83
+
84
+ def ensure_chat_mode(self):
85
+ """Ensure we're in chat mode (called when restoring sessions etc.)."""
86
+ self._transition_to_chat()
87
+
88
+ def update_state(self, state: AgentState, session=None):
89
+ """Update all widgets from agent state."""
90
+ try:
91
+ session_title = session.title if session else ""
92
+ self.query_one(CloseCodeStatusBar).update_state(state, session_title)
93
+ self.query_one(CloseCodeHeader).update_state(state)
94
+ # Update info panel
95
+ model_tag = ""
96
+ if hasattr(self.app, '_model_status_tag'):
97
+ model_tag = self.app._model_status_tag
98
+ self.query_one(CloseCodeInfoPanel).update_state(state, model_tag)
99
+ except Exception:
100
+ pass
101
+
102
+ # Update welcome model tag if still in welcome mode
103
+ if self._in_welcome and state.model:
104
+ try:
105
+ name = state.model
106
+ if "/" in name:
107
+ name = name.split("/", 1)[1]
108
+ if ":free" in name:
109
+ name = name.replace(":free", " · free")
110
+ self.query_one("#welcome-model-tag", Static).update(
111
+ f"[#71717a]Model: [#00f5d4]{name}[/][/]"
112
+ )
113
+ except Exception:
114
+ pass
115
+
116
+ # ── Actions (delegated to app) ──────────────────────────────
117
+
118
+ def action_clear_chat(self):
119
+ self.app.action_clear_chat()
120
+
121
+ def action_new_session(self):
122
+ self.app.action_new_session()
123
+
124
+ def action_cancel(self):
125
+ self.app.action_cancel_generation()
@@ -0,0 +1,101 @@
1
+ """Close Code Onboarding Screen — first-run setup."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.screen import Screen
5
+ from textual.widgets import Static, Input, Button
6
+ from textual.containers import Vertical
7
+ from textual.message import Message
8
+
9
+
10
+ class OnboardingComplete(Message):
11
+ """Posted when onboarding is done."""
12
+ def __init__(self, api_url: str, api_key: str):
13
+ self.api_url = api_url
14
+ self.api_key = api_key
15
+ super().__init__()
16
+
17
+
18
+ class OnboardingScreen(Screen):
19
+ """First-run screen: connect to LLMesh."""
20
+
21
+ BINDINGS = [("escape", "quit", "Exit")]
22
+
23
+ def __init__(self, default_url: str = "http://localhost:8087", default_key: str = ""):
24
+ super().__init__()
25
+ self._default_url = default_url
26
+ self._default_key = default_key
27
+
28
+ def compose(self) -> ComposeResult:
29
+ with Vertical(id="onboarding"):
30
+ with Vertical(id="onboarding-container"):
31
+ yield Static("⬡ Close Code", id="onboarding-title")
32
+ yield Static("Powered by LLMesh", id="onboarding-subtitle")
33
+ yield Static("")
34
+ yield Static(
35
+ "Add your LLMesh API key to get started.\n"
36
+ "Get yours from the LLMesh web dashboard.",
37
+ classes="onboarding-label",
38
+ )
39
+ yield Static("")
40
+ yield Static("LLMesh Endpoint:", classes="onboarding-label")
41
+ yield Input(
42
+ value=self._default_url,
43
+ placeholder="http://localhost:8087",
44
+ id="onboarding-url",
45
+ classes="onboarding-input",
46
+ )
47
+ yield Static("API Key:", classes="onboarding-label")
48
+ yield Input(
49
+ value=self._default_key,
50
+ placeholder="Paste your LLMesh API key here",
51
+ id="onboarding-key",
52
+ password=True,
53
+ classes="onboarding-input",
54
+ )
55
+ yield Static("")
56
+ yield Button("Add API Key & Connect", id="connect-btn", variant="primary")
57
+ yield Static("", id="onboarding-status")
58
+
59
+ def on_mount(self):
60
+ # Focus the key input if we already have a URL
61
+ if self._default_url and not self._default_key:
62
+ self.query_one("#onboarding-key", Input).focus()
63
+ else:
64
+ self.query_one("#onboarding-url", Input).focus()
65
+
66
+ def on_button_pressed(self, event: Button.Pressed):
67
+ if event.button.id == "connect-btn":
68
+ self._try_connect()
69
+
70
+ def on_key(self, event):
71
+ if event.key == "enter":
72
+ self._try_connect()
73
+
74
+ def _try_connect(self):
75
+ """Attempt to connect to LLMesh."""
76
+ api_url = self.query_one("#onboarding-url", Input).value.strip()
77
+ api_key = self.query_one("#onboarding-key", Input).value.strip()
78
+ status = self.query_one("#onboarding-status", Static)
79
+
80
+ if not api_url:
81
+ status.update("[red]Please enter a LLMesh endpoint[/]")
82
+ return
83
+
84
+ if not api_key:
85
+ status.update("[red]Please enter your API key[/]")
86
+ return
87
+
88
+ status.update("Connecting…")
89
+
90
+ # Post the connect event — the app will handle async verification
91
+ self.post_message(OnboardingComplete(api_url, api_key))
92
+
93
+ def show_status(self, text: str):
94
+ """Update status text from the app."""
95
+ try:
96
+ self.query_one("#onboarding-status", Static).update(text)
97
+ except Exception:
98
+ pass
99
+
100
+ def action_quit(self):
101
+ self.app.exit()
@@ -0,0 +1,374 @@
1
+ /* Close Code — Textual CSS Theme
2
+ * #0a0a0b bg, #00f5d4 accent, bubble-style chat
3
+ */
4
+
5
+ /* ── Global ─────────────────────────────────────────── */
6
+
7
+ Screen {
8
+ background: #0a0a0b;
9
+ color: #fafafa;
10
+ }
11
+
12
+ /* ── Onboarding Screen ──────────────────────────────── */
13
+
14
+ #onboarding {
15
+ align: center middle;
16
+ }
17
+
18
+ #onboarding-container {
19
+ width: 56;
20
+ height: auto;
21
+ max-height: 32;
22
+ border: round #27272a;
23
+ padding: 1 2;
24
+ background: #18181b;
25
+ }
26
+
27
+ #onboarding-title {
28
+ text-align: center;
29
+ color: #00f5d4;
30
+ text-style: bold;
31
+ margin-bottom: 1;
32
+ }
33
+
34
+ #onboarding-subtitle {
35
+ text-align: center;
36
+ color: #71717a;
37
+ margin-bottom: 1;
38
+ }
39
+
40
+ #onboarding-status {
41
+ margin-top: 1;
42
+ height: auto;
43
+ color: #a1a1aa;
44
+ }
45
+
46
+ .onboarding-label {
47
+ color: #a1a1aa;
48
+ margin-top: 1;
49
+ }
50
+
51
+ .onboarding-input {
52
+ margin-bottom: 1;
53
+ border: tall #27272a;
54
+ background: #0a0a0b;
55
+ color: #fafafa;
56
+ }
57
+
58
+ .onboarding-input:focus {
59
+ border: tall #00f5d4;
60
+ }
61
+
62
+ #connect-btn {
63
+ width: 100%;
64
+ margin-top: 1;
65
+ background: #00f5d4;
66
+ color: #0a0a0b;
67
+ text-style: bold;
68
+ border: none;
69
+ }
70
+
71
+ #connect-btn:hover {
72
+ background: #00d4b4;
73
+ }
74
+
75
+ /* ── Header ─────────────────────────────────────────── */
76
+
77
+ CloseCodeHeader {
78
+ dock: top;
79
+ height: 1;
80
+ background: #18181b;
81
+ color: #fafafa;
82
+ }
83
+
84
+ #header-bar {
85
+ width: 100%;
86
+ height: 1;
87
+ layout: horizontal;
88
+ }
89
+
90
+ #header-title {
91
+ width: auto;
92
+ color: #00f5d4;
93
+ text-style: bold;
94
+ padding: 0 1;
95
+ }
96
+
97
+ #header-model {
98
+ width: auto;
99
+ color: #a1a1aa;
100
+ }
101
+
102
+ #header-spacer {
103
+ width: 1fr;
104
+ }
105
+
106
+ #header-connection {
107
+ width: auto;
108
+ padding: 0 1;
109
+ color: #71717a;
110
+ }
111
+
112
+ /* ── Welcome View ───────────────────────────────────── */
113
+
114
+ #welcome-view {
115
+ height: 1fr;
116
+ width: 100%;
117
+ align: center middle;
118
+ }
119
+
120
+ #welcome-container {
121
+ width: 60;
122
+ height: auto;
123
+ align: center middle;
124
+ padding: 2 4;
125
+ }
126
+
127
+ #welcome-logo {
128
+ text-align: center;
129
+ color: #00f5d4;
130
+ text-style: bold;
131
+ width: 100%;
132
+ margin-bottom: 1;
133
+ }
134
+
135
+ #welcome-greeting {
136
+ text-align: center;
137
+ color: #fafafa;
138
+ text-style: bold;
139
+ width: 100%;
140
+ margin-bottom: 1;
141
+ }
142
+
143
+ #welcome-model-tag {
144
+ text-align: center;
145
+ width: 100%;
146
+ margin-bottom: 2;
147
+ color: #71717a;
148
+ }
149
+
150
+ #welcome-input {
151
+ width: 100%;
152
+ border: tall #27272a;
153
+ background: #18181b;
154
+ color: #fafafa;
155
+ padding: 0 1;
156
+ }
157
+
158
+ #welcome-input:focus {
159
+ border: tall #00f5d4;
160
+ }
161
+
162
+ /* ── Chat View (4:1 split) ──────────────────────────── */
163
+
164
+ #chat-view {
165
+ height: 1fr;
166
+ width: 100%;
167
+ layout: horizontal;
168
+ }
169
+
170
+ #chat-column {
171
+ width: 4fr;
172
+ height: 100%;
173
+ }
174
+
175
+ /* ── Chat Area ──────────────────────────────────────── */
176
+
177
+ CloseCodeChat {
178
+ height: 1fr;
179
+ padding: 0 1;
180
+ scrollbar-color: #27272a;
181
+ scrollbar-color-hover: #00f5d4;
182
+ scrollbar-color-active: #00f5d4;
183
+ }
184
+
185
+ /* Message rows */
186
+ .msg-row {
187
+ width: 100%;
188
+ height: auto;
189
+ margin: 1 0 0 0;
190
+ layout: horizontal;
191
+ }
192
+
193
+ .msg-spacer {
194
+ width: 1fr;
195
+ }
196
+
197
+ /* User bubbles — right aligned */
198
+ .msg-bubble-user {
199
+ width: auto;
200
+ max-width: 70%;
201
+ min-width: 20;
202
+ background: #1a3a3a;
203
+ color: #fafafa;
204
+ padding: 0 2;
205
+ border: round #00f5d4 40%;
206
+ margin-right: 1;
207
+ }
208
+
209
+ /* Assistant bubbles — left aligned */
210
+ .msg-bubble-assistant {
211
+ width: auto;
212
+ max-width: 75%;
213
+ background: #18181b;
214
+ color: #fafafa;
215
+ padding: 1 2;
216
+ border: round #27272a;
217
+ margin-left: 0;
218
+ }
219
+
220
+ /* Avatar */
221
+ .msg-avatar {
222
+ width: 3;
223
+ height: 1;
224
+ color: #00f5d4;
225
+ text-style: bold;
226
+ margin-top: 1;
227
+ }
228
+
229
+ /* Status messages */
230
+ .msg-status {
231
+ color: #71717a;
232
+ text-align: center;
233
+ width: 100%;
234
+ margin: 0 0;
235
+ }
236
+
237
+ /* ── Info Panel (1 part of 4:1) ─────────────────────── */
238
+
239
+ CloseCodeInfoPanel {
240
+ width: 1fr;
241
+ height: 100%;
242
+ background: #111113;
243
+ border-left: solid #27272a;
244
+ padding: 1;
245
+ }
246
+
247
+ #info-panel-inner {
248
+ width: 100%;
249
+ height: 100%;
250
+ }
251
+
252
+ .info-section-title {
253
+ color: #00f5d4;
254
+ text-style: bold;
255
+ margin-bottom: 1;
256
+ }
257
+
258
+ .info-stat {
259
+ color: #a1a1aa;
260
+ margin-bottom: 1;
261
+ padding-left: 1;
262
+ }
263
+
264
+ .info-divider {
265
+ color: #27272a;
266
+ margin: 1 0;
267
+ }
268
+
269
+ #info-tasks-list {
270
+ height: 1fr;
271
+ scrollbar-color: #27272a;
272
+ }
273
+
274
+ .info-task {
275
+ padding-left: 1;
276
+ margin-bottom: 0;
277
+ }
278
+
279
+ .info-task-done {
280
+ color: #71717a;
281
+ }
282
+
283
+ .info-task-pending {
284
+ color: #a1a1aa;
285
+ }
286
+
287
+ /* ── Composer ───────────────────────────────────────── */
288
+
289
+ CloseCodeComposer {
290
+ dock: bottom;
291
+ height: auto;
292
+ min-height: 3;
293
+ max-height: 10;
294
+ padding: 0 1;
295
+ background: #18181b;
296
+ border-top: solid #27272a;
297
+ }
298
+
299
+ #composer-container {
300
+ height: auto;
301
+ width: 100%;
302
+ }
303
+
304
+ #composer-input {
305
+ height: auto;
306
+ min-height: 1;
307
+ max-height: 8;
308
+ width: 1fr;
309
+ border: tall #27272a;
310
+ background: #0a0a0b;
311
+ color: #fafafa;
312
+ padding: 0 1;
313
+ }
314
+
315
+ #composer-input:focus {
316
+ border: tall #00f5d4;
317
+ }
318
+
319
+ #composer-send {
320
+ width: 5;
321
+ min-width: 5;
322
+ height: 3;
323
+ margin-left: 1;
324
+ background: #00f5d4;
325
+ color: #0a0a0b;
326
+ border: none;
327
+ }
328
+
329
+ #composer-send:hover {
330
+ background: #00d4b4;
331
+ }
332
+
333
+ /* ── Status Bar ─────────────────────────────────────── */
334
+
335
+ CloseCodeStatusBar {
336
+ dock: bottom;
337
+ height: 1;
338
+ background: #18181b;
339
+ border-top: solid #27272a;
340
+ color: #71717a;
341
+ }
342
+
343
+ #statusbar-content {
344
+ width: 100%;
345
+ height: 1;
346
+ layout: horizontal;
347
+ padding: 0 1;
348
+ }
349
+
350
+ .status-item {
351
+ width: auto;
352
+ padding: 0 1;
353
+ color: #71717a;
354
+ }
355
+
356
+ .status-sep {
357
+ color: #27272a;
358
+ }
359
+
360
+ .status-session {
361
+ color: #a1a1aa;
362
+ }
363
+
364
+ .status-model {
365
+ color: #fafafa;
366
+ }
367
+
368
+ .status-connected {
369
+ color: #22c55e;
370
+ }
371
+
372
+ .status-disconnected {
373
+ color: #ef4444;
374
+ }
@@ -0,0 +1 @@
1
+ """Close Code UI widgets."""
@@ -0,0 +1,105 @@
1
+ """Close Code Chat widget — scrollable conversation with bubble-style messages."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.widget import Widget
5
+ from textual.widgets import Static, Markdown
6
+ from textual.containers import VerticalScroll, Horizontal
7
+
8
+
9
+ class MessageBubble(Static):
10
+ """A single chat message bubble with role-based alignment."""
11
+ pass
12
+
13
+
14
+ class CloseCodeChat(VerticalScroll):
15
+ """Scrollable conversation area with left/right aligned messages.
16
+
17
+ User messages → right side
18
+ Assistant messages → left side
19
+ """
20
+
21
+ def __init__(self):
22
+ super().__init__()
23
+ self._streaming_widget: Static | None = None
24
+ self._streaming_text: str = ""
25
+
26
+ def add_user_message(self, content: str):
27
+ """Add a user-aligned message (right side)."""
28
+ spacer = Static("", classes="msg-spacer")
29
+ bubble = Static(content, classes="msg-bubble msg-bubble-user")
30
+ row = Horizontal(spacer, bubble, classes="msg-row msg-row-user")
31
+ self.mount(row)
32
+ self.scroll_end(animate=False)
33
+
34
+ def add_assistant_start(self):
35
+ """Start a new assistant message (left side, for streaming)."""
36
+ label = Static("⬡", classes="msg-avatar msg-avatar-assistant")
37
+ widget = Static("", classes="msg-bubble msg-bubble-assistant")
38
+ spacer = Static("", classes="msg-spacer")
39
+ row = Horizontal(label, widget, spacer, classes="msg-row msg-row-assistant")
40
+ self.mount(row)
41
+ self._streaming_widget = widget
42
+ self._streaming_text = ""
43
+ self.scroll_end(animate=False)
44
+
45
+ def add_streaming_token(self, token: str):
46
+ """Append a token to the current streaming message."""
47
+ if self._streaming_widget is not None:
48
+ self._streaming_text += token
49
+ self._streaming_widget.update(self._streaming_text)
50
+ self.scroll_end(animate=False)
51
+
52
+ def finish_streaming(self, full_content: str = ""):
53
+ """Finish streaming — replace the Static with rendered Markdown."""
54
+ if self._streaming_widget is not None:
55
+ if full_content:
56
+ parent = self._streaming_widget.parent
57
+ self._streaming_widget.remove()
58
+ md = Markdown(full_content, classes="msg-bubble msg-bubble-assistant")
59
+ if parent:
60
+ # Insert before the spacer (last child)
61
+ children = list(parent.children)
62
+ if children:
63
+ parent.mount(md, before=children[-1])
64
+ else:
65
+ parent.mount(md)
66
+ else:
67
+ self.mount(md)
68
+ self._streaming_widget = None
69
+ self._streaming_text = ""
70
+ self.scroll_end(animate=False)
71
+
72
+ def add_assistant_message(self, content: str):
73
+ """Add a complete assistant message (left side)."""
74
+ label = Static("⬡", classes="msg-avatar msg-avatar-assistant")
75
+ bubble = Markdown(content, classes="msg-bubble msg-bubble-assistant")
76
+ spacer = Static("", classes="msg-spacer")
77
+ row = Horizontal(label, bubble, spacer, classes="msg-row msg-row-assistant")
78
+ self.mount(row)
79
+ self.scroll_end(animate=False)
80
+
81
+ def add_status_message(self, message: str, icon: str = "●"):
82
+ """Add a centered status indicator."""
83
+ self.mount(Static(f" {icon} {message}", classes="msg-status"))
84
+ self.scroll_end(animate=False)
85
+
86
+ def add_usage_info(self, tokens: int = 0, model: str = "", latency_ms: float = 0):
87
+ """Add token usage info after a response."""
88
+ parts = []
89
+ if tokens:
90
+ parts.append(f"{tokens:,} tokens")
91
+ if latency_ms:
92
+ parts.append(f"{latency_ms:.0f}ms")
93
+ if model:
94
+ short = model.split("/", 1)[-1] if "/" in model else model
95
+ parts.append(short)
96
+ if parts:
97
+ self.mount(Static(f" · {' · '.join(parts)}", classes="msg-status"))
98
+ self.scroll_end(animate=False)
99
+
100
+ def clear_chat(self):
101
+ """Clear all messages."""
102
+ for child in list(self.children):
103
+ child.remove()
104
+ self._streaming_widget = None
105
+ self._streaming_text = ""