chad-console 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,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: chad-console
3
+ Version: 0.1.0
4
+ Summary: Intercepts print() and input() calls, routing them to a beautiful dark-mode GUI console.
5
+ Project-URL: Homepage, https://github.com/m-ali04/chad-console
6
+ Project-URL: Repository, https://github.com/m-ali04/chad-console
7
+ Project-URL: Issues, https://github.com/m-ali04/chad-console/issues
8
+ Author-email: m-ali04 <m-ali04@users.noreply.github.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: chad-console,console,customtkinter,developer-tools,gui,pretty-print
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: Software Development :: User Interfaces
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: customtkinter>=5.2
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7.0; extra == 'dev'
27
+ Requires-Dist: ruff>=0.4; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # ✦ ChadConsole
31
+
32
+ > Intercepts `print()` and `input()` — routes them to a beautiful dark-mode GUI.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install chadconsole
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ ```python
43
+ import chadconsole # That's it. All prints now go to the GUI.
44
+
45
+ print("Hello, world!")
46
+ print({"key": "value", "nested": [1, 2, 3]})
47
+
48
+ for i in range(5):
49
+ print("*" * (i + 1))
50
+
51
+ name = input("What's your name? ")
52
+ print(f"Welcome, {name}!")
53
+ ```
54
+
55
+ ## Features
56
+
57
+ - **Zero config** — just `import chadconsole` at the top of your script
58
+ - **Auto type detection** — lists, dicts, tuples get special formatted rendering
59
+ - **Loop grouping** — `print()` calls inside `for` loops are automatically batched into a single visual block (bytecode-based detection, no `time.sleep()` needed)
60
+ - **Input interception** — `input()` calls display a floating entry field in the GUI
61
+ - **Dark neumorphic UI** — premium design with blue accents and monospace code rendering
62
+ - **Thread-safe** — your script runs normally; the GUI runs in a background thread
63
+
64
+ ## License
65
+
66
+ MIT
@@ -0,0 +1,10 @@
1
+ chadconsole/__init__.py,sha256=ncnH2Dj1Y9mDLahJJSSc-H4iwqlbIu4IjPY0iK9Qj3U,2613
2
+ chadconsole/core_interceptor.py,sha256=JurD1j66pcKwXrKt9_Kn0AEYQggsInLhpuYr5twP3GI,13245
3
+ chadconsole/data_analyzer.py,sha256=4Zx0yLxcSyD1JjEyBdHSQ9WU5eayS0cCU4IGEo5O-os,3072
4
+ chadconsole/ui_components.py,sha256=rOf_FXFvPJLg4LewvUS6z3mxsmG1ASg-7UJg-_R1FWo,14453
5
+ chadconsole/ui_engine.py,sha256=_nEvyXDX8zkxkaS_A7adJwfkVuBYxUK7vrYqbK8DSRQ,10699
6
+ chadconsole/assets/logo.png,sha256=Gc1zW-mumiYsqRcAa66ejMGkQ-jKEUWg1xZ8zHSESi0,257314
7
+ chad_console-0.1.0.dist-info/METADATA,sha256=zWGoMHsbdla0kcRP5W8U005H32W5_IpXSmXPxWf-pnU,2347
8
+ chad_console-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ chad_console-0.1.0.dist-info/licenses/LICENSE,sha256=Pkuua4s8oOqYuuNcR-OCMhrsdMtcFKryxaaWBD8Ab-A,1081
10
+ chad_console-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ali
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,79 @@
1
+ """
2
+ chadconsole
3
+ ~~~~~~~~~~~
4
+
5
+ Just ``import chadconsole`` and all your print() and input() calls
6
+ are routed to a beautiful dark-mode GUI window.
7
+
8
+ Architecture:
9
+ 1. A shared queue.Queue connects the script thread to the UI thread.
10
+ 2. core_interceptor replaces sys.stdout, builtins.print, builtins.input.
11
+ 3. ui_engine.PrettyConsoleApp runs Tk mainloop in a NON-daemon thread
12
+ so the window stays alive even after the user's script finishes.
13
+ 4. The user's script continues executing on the main thread.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ __version__ = "0.1.0"
19
+ __all__: list[str] = []
20
+
21
+ import atexit
22
+ import os
23
+ import queue
24
+ import sys
25
+ import threading
26
+
27
+ from chadconsole.core_interceptor import install, uninstall
28
+ from chadconsole.ui_engine import PrettyConsoleApp
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Shared communication channel
33
+ # ---------------------------------------------------------------------------
34
+
35
+ _output_queue: queue.Queue = queue.Queue()
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Install interceptors
39
+ # ---------------------------------------------------------------------------
40
+
41
+ _input_handler = install(_output_queue)
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Launch the UI in a NON-daemon thread (keeps process alive until window closes)
45
+ # ---------------------------------------------------------------------------
46
+
47
+ _app: PrettyConsoleApp | None = None
48
+ _ui_ready = threading.Event()
49
+
50
+
51
+ def _run_ui() -> None:
52
+ """Entry point for the UI thread.
53
+
54
+ This is a NON-daemon thread so the window stays open even after the
55
+ user's main script finishes executing. The process exits only when
56
+ the user closes the GUI window.
57
+ """
58
+ global _app
59
+ try:
60
+ _app = PrettyConsoleApp(_output_queue)
61
+ _app.set_input_handler(_input_handler)
62
+ _ui_ready.set()
63
+ _app.mainloop()
64
+ except Exception:
65
+ import traceback
66
+ sys.__stderr__.write(f"[ChadConsole] UI thread crashed:\n{traceback.format_exc()}\n")
67
+ _ui_ready.set() # Unblock main thread even on failure
68
+ finally:
69
+ # When the window is closed (mainloop exits), clean up and exit
70
+ uninstall()
71
+ os._exit(0) # Clean exit — suppresses Tcl_AsyncDelete noise
72
+
73
+
74
+ # NON-daemon thread: process stays alive until this thread ends (window closed)
75
+ _ui_thread = threading.Thread(target=_run_ui, daemon=False, name="ChadConsole-UI")
76
+ _ui_thread.start()
77
+
78
+ # Wait for the UI to be ready before the script proceeds
79
+ _ui_ready.wait(timeout=5.0)
Binary file
@@ -0,0 +1,351 @@
1
+ """
2
+ chadconsole.core_interceptor
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+
5
+ Hijacks sys.stdout, builtins.print, and builtins.input to route all I/O
6
+ through a thread-safe queue to the PrettyConsole UI.
7
+
8
+ Key features:
9
+ - ConsoleWriter replaces sys.stdout (captures raw write() calls)
10
+ - custom_print replaces builtins.print with structural loop detection
11
+ - custom_input replaces builtins.input (blocks caller, signals UI for input field)
12
+
13
+ Loop Detection Strategy (bytecode-based, no timers needed):
14
+ On each print() call, we inspect the caller's call-stack frames using
15
+ ``dis.get_instructions()`` to check whether the print was called from
16
+ inside a ``for`` loop body.
17
+
18
+ For each frame we:
19
+ 1. Collect all ``FOR_ITER`` instructions and their loop-end offsets.
20
+ 2. Collect all ``JUMP_BACKWARD`` instructions and which ``FOR_ITER`` they
21
+ jump back to.
22
+ 3. If the frame's ``f_lasti`` (last executed bytecode offset) falls
23
+ within [FOR_ITER+2 .. JUMP_BACKWARD], we are in a loop body.
24
+ 4. Return a key ``(filename, code_id, for_iter_offset)`` that uniquely
25
+ identifies this specific loop.
26
+
27
+ Consecutive prints sharing the same key are accumulated into a single
28
+ buffer and emitted as one ``loop_pattern`` payload when the key changes
29
+ or a non-loop print arrives.
30
+
31
+ Prints that are NOT inside any loop → emitted immediately, zero latency.
32
+ No ``time.sleep()`` required anywhere in user code.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import builtins
38
+ import dis
39
+ import opcode
40
+ import queue
41
+ import sys
42
+ import threading
43
+ from typing import Any, TextIO
44
+
45
+ from chadconsole.data_analyzer import ErrorPayload, InputRequest, Payload, analyze
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Bytecode constants (resolved once at import time for this Python version)
50
+ # ---------------------------------------------------------------------------
51
+
52
+ _FOR_ITER = opcode.opmap.get("FOR_ITER")
53
+ _JUMP_BACKWARD = opcode.opmap.get("JUMP_BACKWARD")
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Loop detection helpers
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def _is_frame_in_for_loop(frame) -> tuple | None:
61
+ """Check if *frame* is currently executing inside a ``for`` loop body.
62
+
63
+ Returns a hashable key ``(filename, code_obj_id, for_iter_offset)``
64
+ uniquely identifying this loop, or ``None`` if the frame is not in a loop.
65
+ """
66
+ if _FOR_ITER is None or _JUMP_BACKWARD is None:
67
+ return None # Unsupported Python version — degrade gracefully
68
+
69
+ code = frame.f_code
70
+ current_offset = frame.f_lasti
71
+
72
+ try:
73
+ instructions = list(dis.get_instructions(code))
74
+ except Exception:
75
+ return None
76
+
77
+ # Map: FOR_ITER.offset -> END_FOR offset (via FOR_ITER.argval)
78
+ # Map: FOR_ITER.offset -> JUMP_BACKWARD.offset (who jumps back here)
79
+ for_iters: dict[int, int] = {} # for_iter_offset -> end_for_offset
80
+ jump_backs: dict[int, int] = {} # for_iter_offset -> jump_backward_offset
81
+
82
+ for instr in instructions:
83
+ if instr.opcode == _FOR_ITER:
84
+ for_iters[instr.offset] = instr.argval # argval = END_FOR target
85
+ elif instr.opcode == _JUMP_BACKWARD:
86
+ # argval is the target offset (the FOR_ITER we jump back to)
87
+ jump_backs[instr.argval] = instr.offset
88
+
89
+ for for_iter_off, end_for_off in for_iters.items():
90
+ jump_back_off = jump_backs.get(for_iter_off)
91
+ if jump_back_off is None:
92
+ continue
93
+ # Loop body occupies instructions from (for_iter_off + 2) to jump_back_off
94
+ body_start = for_iter_off + 2
95
+ body_end = jump_back_off
96
+ if body_start <= current_offset <= body_end:
97
+ return (code.co_filename, id(code), for_iter_off)
98
+
99
+ return None
100
+
101
+
102
+ def _caller_loop_key(skip: int = 2) -> tuple | None:
103
+ """Walk the call stack starting *skip* frames up and find the first
104
+ frame that is executing inside a ``for`` loop.
105
+
106
+ Returns the loop key, or ``None`` if no enclosing loop is found.
107
+ We search up to 8 frames deep to handle prints inside helper functions
108
+ that are called from within a loop.
109
+ """
110
+ try:
111
+ frame = sys._getframe(skip)
112
+ except ValueError:
113
+ return None
114
+
115
+ for _ in range(8):
116
+ if frame is None:
117
+ break
118
+ key = _is_frame_in_for_loop(frame)
119
+ if key is not None:
120
+ return key
121
+ frame = frame.f_back
122
+
123
+ return None
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # ConsoleWriter — replacement for sys.stdout
128
+ # ---------------------------------------------------------------------------
129
+
130
+ class ConsoleWriter:
131
+ """A file-like object that captures direct sys.stdout.write() calls.
132
+
133
+ Any code doing ``sys.stdout.write(...)`` directly (e.g. logging, third-party
134
+ libs) is captured here and emitted as a standard payload immediately.
135
+ """
136
+
137
+ def __init__(self, output_queue: queue.Queue, original: TextIO) -> None:
138
+ self._queue = output_queue
139
+ self._original = original
140
+ self.encoding = getattr(original, "encoding", "utf-8")
141
+
142
+ def write(self, text: str) -> int:
143
+ """Capture direct stdout writes as standard payloads."""
144
+ if text and text.strip():
145
+ payload = Payload(tag="standard", content=text.rstrip("\n"))
146
+ self._queue.put(payload)
147
+ return len(text)
148
+
149
+ def flush(self) -> None:
150
+ """No-op — the GUI doesn't need flushing."""
151
+
152
+ def fileno(self) -> int:
153
+ """Delegate to original stdout for compatibility."""
154
+ return self._original.fileno()
155
+
156
+ def isatty(self) -> bool:
157
+ return False
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # LoopGrouper — structural, deterministic loop grouping
162
+ # ---------------------------------------------------------------------------
163
+
164
+ class _LoopGrouper:
165
+ """Groups consecutive print() calls that originate from the SAME ``for`` loop.
166
+
167
+ Algorithm:
168
+ 1. On each print(), inspect the call stack to get a ``loop_key``.
169
+ 2. ``loop_key is None`` → not in a loop → flush any pending buffer, emit immediately.
170
+ 3. ``loop_key == self._current_key`` → same loop, same iteration → append to buffer.
171
+ 4. ``loop_key != self._current_key`` → new/different loop → flush old buffer, start new.
172
+
173
+ When the buffer is flushed, it becomes a single ``loop_pattern`` Payload.
174
+
175
+ Thread-safe via a Lock (multiple threads can call print() concurrently).
176
+ """
177
+
178
+ def __init__(self, output_queue: queue.Queue) -> None:
179
+ self._queue = output_queue
180
+ self._lock = threading.Lock()
181
+ self._buffer: list[str] = []
182
+ self._current_key: tuple | None = None
183
+
184
+ def submit(self, payload: Payload) -> None:
185
+ """Route a payload — either buffer it (loop) or emit immediately (non-loop)."""
186
+ # skip=3: submit() → custom_print() → caller's frame
187
+ loop_key = _caller_loop_key(skip=3)
188
+
189
+ with self._lock:
190
+ if loop_key is None:
191
+ # ── Not in a for-loop ─────────────────────────────────
192
+ self._flush_locked()
193
+ self._queue.put(payload)
194
+
195
+ elif loop_key == self._current_key:
196
+ # ── Same loop — accumulate ────────────────────────────
197
+ self._buffer.append(payload.content)
198
+
199
+ else:
200
+ # ── New loop (or first loop print) ────────────────────
201
+ self._flush_locked()
202
+ self._current_key = loop_key
203
+ self._buffer.append(payload.content)
204
+
205
+ def flush(self) -> None:
206
+ """Public flush — call this before input() or at script end."""
207
+ with self._lock:
208
+ self._flush_locked()
209
+
210
+ def _flush_locked(self) -> None:
211
+ """Emit buffer as a single loop_pattern payload. Must hold ``_lock``."""
212
+ if not self._buffer:
213
+ self._current_key = None
214
+ return
215
+ content = "\n".join(self._buffer)
216
+ self._buffer.clear()
217
+ self._current_key = None
218
+ self._queue.put(Payload(tag="loop_pattern", content=content))
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # Input handler
223
+ # ---------------------------------------------------------------------------
224
+
225
+ class _InputHandler:
226
+ """Manages the input() interception lifecycle.
227
+
228
+ When input() is called:
229
+ 1. Flush any pending loop buffer (so loop output appears before the prompt).
230
+ 2. Push an InputRequest to the queue (tells UI to show entry field).
231
+ 3. Block the calling thread on a threading.Event.
232
+ 4. The UI calls .resolve(value) when the user hits Enter.
233
+ 5. The Event is released and the value is returned to the caller.
234
+ """
235
+
236
+ def __init__(self, output_queue: queue.Queue, grouper: _LoopGrouper) -> None:
237
+ self._queue = output_queue
238
+ self._grouper = grouper
239
+ self._event = threading.Event()
240
+ self._response: str = ""
241
+ self._lock = threading.Lock()
242
+
243
+ def request(self, prompt: str = "") -> str:
244
+ """Called from the user's script thread — blocks until UI responds."""
245
+ # Flush loop buffer so loop output isn't stuck behind the prompt
246
+ self._grouper.flush()
247
+
248
+ with self._lock:
249
+ self._event.clear()
250
+ self._response = ""
251
+
252
+ # Signal the UI
253
+ req = InputRequest(prompt=prompt, handler=self)
254
+ self._queue.put(req)
255
+
256
+ # Block until the UI resolves
257
+ self._event.wait()
258
+ return self._response
259
+
260
+ def resolve(self, value: str) -> None:
261
+ """Called from the UI thread when the user submits input."""
262
+ self._response = value
263
+ self._event.set()
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Installer — wires everything up
268
+ # ---------------------------------------------------------------------------
269
+
270
+ _original_print = builtins.print
271
+ _original_input = builtins.input
272
+ _original_stdout = sys.stdout
273
+ _original_excepthook = sys.excepthook
274
+
275
+
276
+ def install(output_queue: queue.Queue) -> _InputHandler:
277
+ """Install all interceptors. Returns the InputHandler for UI wiring.
278
+
279
+ Call this once during chadconsole initialization.
280
+ """
281
+ # 1. Replace sys.stdout
282
+ writer = ConsoleWriter(output_queue, _original_stdout)
283
+ sys.stdout = writer # type: ignore[assignment]
284
+
285
+ # 2. Build the loop grouper (structural, zero-latency, no timers)
286
+ grouper = _LoopGrouper(output_queue)
287
+
288
+ # Flush any buffered loop output when the user's script finishes.
289
+ # Without this, a for-loop at the END of a script (or as the ONLY thing)
290
+ # would never flush — the output would be silently lost.
291
+ #
292
+ # Why not atexit? atexit handlers only fire after ALL non-daemon threads
293
+ # finish, but the UI thread is non-daemon (so it blocks atexit). And when
294
+ # the window closes, os._exit(0) skips atexit entirely.
295
+ #
296
+ # Solution: a tiny daemon thread that waits for the main thread to end,
297
+ # then flushes the grouper. The data lands in the queue and the UI thread
298
+ # (still alive) picks it up and renders it.
299
+ def _flush_on_main_exit() -> None:
300
+ threading.main_thread().join()
301
+ grouper.flush()
302
+
303
+ threading.Thread(
304
+ target=_flush_on_main_exit, daemon=True, name="ChadConsole-Flush"
305
+ ).start()
306
+
307
+ # 3. Replace builtins.print
308
+ def custom_print(*args: Any, sep: str = " ", end: str = "\n", **kwargs: Any) -> None:
309
+ """Loop-aware, zero-latency print() replacement."""
310
+ payload = analyze(*args, sep=sep)
311
+ grouper.submit(payload)
312
+
313
+ builtins.print = custom_print # type: ignore[assignment]
314
+
315
+ # 4. Replace builtins.input
316
+ handler = _InputHandler(output_queue, grouper)
317
+
318
+ def custom_input(prompt: str = "") -> str:
319
+ """Blocking input() replacement — signals UI for entry field."""
320
+ return handler.request(prompt)
321
+
322
+ builtins.input = custom_input # type: ignore[assignment]
323
+
324
+ # 5. Install sys.excepthook to catch ALL unhandled exceptions
325
+ # and display them as red ErrorBlocks in the GUI.
326
+ import traceback as _tb
327
+
328
+ def custom_excepthook(exc_type, exc_value, exc_tb) -> None:
329
+ """Intercept unhandled exceptions — flush loop buffer then send ErrorPayload."""
330
+ # Flush any buffered loop lines so they appear before the error
331
+ grouper.flush()
332
+ # Format the full traceback text
333
+ full_tb = "".join(_tb.format_exception(exc_type, exc_value, exc_tb)).rstrip()
334
+ err = ErrorPayload(
335
+ error_type=exc_type.__name__,
336
+ message=str(exc_value),
337
+ traceback=full_tb,
338
+ )
339
+ output_queue.put(err)
340
+
341
+ sys.excepthook = custom_excepthook
342
+
343
+ return handler
344
+
345
+
346
+ def uninstall() -> None:
347
+ """Restore original print, input, stdout, and excepthook."""
348
+ builtins.print = _original_print
349
+ builtins.input = _original_input
350
+ sys.stdout = _original_stdout
351
+ sys.excepthook = _original_excepthook
@@ -0,0 +1,90 @@
1
+ """
2
+ chadconsole.data_analyzer
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+
5
+ Inspects raw print() arguments, detects their types, formats structured
6
+ data with pprint, and returns tagged Payload objects for the UI layer.
7
+
8
+ Tags:
9
+ "standard" — plain text (str, int, float, bool, etc.)
10
+ "list" — a list object
11
+ "dictionary" — a dict object
12
+ "tuple" — a tuple object
13
+ "loop_pattern" — multiple lines from a loop (grouped by core_interceptor)
14
+ "input" — signals the UI to show an entry field (InputRequest)
15
+ "error" — unhandled exception (ErrorPayload)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import pprint
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Payload — the single data unit that flows through the queue
27
+ # ---------------------------------------------------------------------------
28
+
29
+ @dataclass(slots=True)
30
+ class Payload:
31
+ """A tagged chunk of output destined for the UI."""
32
+
33
+ tag: str # "standard" | "list" | "dictionary" | "tuple" | "loop_pattern"
34
+ content: str # The formatted string to display
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class InputRequest:
39
+ """Signals the UI to show an input entry field."""
40
+
41
+ prompt: str
42
+ handler: Any = None
43
+
44
+
45
+ @dataclass(slots=True)
46
+ class ErrorPayload:
47
+ """Signals the UI to display a red error block for an unhandled exception."""
48
+
49
+ error_type: str # e.g. "NameError", "ZeroDivisionError", "SyntaxError"
50
+ message: str # str(exception) — the short human-readable message
51
+ traceback: str = "" # Full formatted traceback (empty for simple stderr errors)
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Tag mapping — structural types only
56
+ # ---------------------------------------------------------------------------
57
+
58
+ _TYPE_TAG_MAP: dict[type, str] = {
59
+ list: "list",
60
+ dict: "dictionary",
61
+ tuple: "tuple",
62
+ }
63
+
64
+ _PRETTY_PRINTER = pprint.PrettyPrinter(indent=2, width=80, sort_dicts=False)
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Public API
69
+ # ---------------------------------------------------------------------------
70
+
71
+ def analyze(*args: Any, sep: str = " ") -> Payload:
72
+ """Inspect raw print() arguments and return a tagged Payload.
73
+
74
+ Strategy:
75
+ - Exactly ONE argument that is a list, dict, or tuple → structured tag + pformat.
76
+ - Everything else → join with *sep* and tag as "standard".
77
+
78
+ Note: loop detection is handled upstream in core_interceptor, NOT here.
79
+ This function is purely about *what* is being printed, not *how many times*.
80
+ """
81
+ if len(args) == 1:
82
+ obj = args[0]
83
+ tag = _TYPE_TAG_MAP.get(type(obj))
84
+ if tag is not None:
85
+ content = _PRETTY_PRINTER.pformat(obj)
86
+ return Payload(tag=tag, content=content)
87
+
88
+ # Fallback: join everything as plain text
89
+ content = sep.join(str(a) for a in args)
90
+ return Payload(tag="standard", content=content)
@@ -0,0 +1,445 @@
1
+ """
2
+ chadconsole.ui_components
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+
5
+ Custom CTkFrame-based visual blocks for each output type.
6
+ Every component is packed with pady=15 for distinct visual spacing.
7
+
8
+ Design: Dark neumorphic with deep blue accents and purple loop containers.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import customtkinter as ctk
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Color Palette — Dark Neumorphic Blue
18
+ # ---------------------------------------------------------------------------
19
+
20
+ class Colors:
21
+ """Centralized color tokens for the entire UI."""
22
+
23
+ BG_MAIN = "#0D1117" # Deep dark background
24
+ BG_CARD_NAVBAR = "#0c0e12" # Card / frame background
25
+ BG_CARD = "#161B22" # Card / frame background
26
+ BG_CARD_ALT = "#1C2333" # Alternate card — structured data
27
+ BG_LOOP = "#1A1232" # Loop container — purple-tinted dark
28
+ BG_INPUT = "#0F1923" # Input block background
29
+
30
+ ACCENT_BLUE = "#58A6FF" # Primary accent — borders, cursor, highlights
31
+ ACCENT_PURPLE = "#8B5CF6" # Loop container border & accent
32
+ ACCENT_GREEN = "#3FB950" # Success / type badges
33
+ ACCENT_AMBER = "#D29922" # Warning / tuple badge
34
+
35
+ TEXT_PRIMARY = "#E6EDF3" # Main text
36
+ TEXT_SECONDARY = "#8B949E" # Timestamps, labels
37
+ TEXT_MONO = "#79C0FF" # Monospace text in loop containers
38
+ TEXT_DIM = "#484F58" # Very subtle text
39
+
40
+ BORDER_SUBTLE = "#30363D" # Subtle card borders
41
+ BORDER_BLUE = "#1F6FEB" # Blue accent border
42
+ BORDER_PURPLE = "#6D28D9" # Purple accent border (loops)
43
+ BORDER_RED = "#7B1A1A" # Error block inner border
44
+
45
+ ACCENT_RED = "#FF6B6B" # Error accent — badge, left bar
46
+ BG_ERROR = "#160A0A" # Error block background (dark blood-red tint)
47
+ TEXT_ERROR = "#FF8585" # Error message / traceback text
48
+
49
+ INPUT_BG = "#0D1117" # Entry field background
50
+ INPUT_BORDER = "#58A6FF" # Entry field border
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Font constants
55
+ # ---------------------------------------------------------------------------
56
+
57
+ FONT_BODY = ("Inter", 14)
58
+ FONT_BODY_BOLD = ("Inter", 14, "bold")
59
+ FONT_MONO = ("Consolas", 13)
60
+ FONT_MONO_LG = ("Consolas", 14)
61
+ FONT_BADGE = ("Inter", 11, "bold")
62
+ FONT_LABEL = ("Inter", 12)
63
+ FONT_HEADER = ("Inter", 16, "bold")
64
+ FONT_TITLE = ("Inter", 16, "bold")
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Base Block
69
+ # ---------------------------------------------------------------------------
70
+
71
+ class _BaseBlock(ctk.CTkFrame):
72
+ """Base class for all output blocks.
73
+
74
+ Provides consistent card styling with a colored left accent border.
75
+ """
76
+
77
+ PACK_PADY = 8 # Enforced gap between blocks
78
+
79
+ def __init__(
80
+ self,
81
+ master: ctk.CTkBaseClass,
82
+ accent_color: str = Colors.ACCENT_BLUE,
83
+ bg_color: str = Colors.BG_CARD,
84
+ **kwargs,
85
+ ) -> None:
86
+ super().__init__(
87
+ master,
88
+ fg_color=bg_color,
89
+ corner_radius=10,
90
+ border_width=0,
91
+ **kwargs,
92
+ )
93
+
94
+ # Outer container with left accent stripe
95
+ self._accent_bar = ctk.CTkFrame(
96
+ self,
97
+ fg_color=accent_color,
98
+ width=4,
99
+ height=0,
100
+ corner_radius=2,
101
+ )
102
+ self._accent_bar.pack(side="left", fill="y", padx=(0, 0), pady=4)
103
+
104
+ # Content area
105
+ self._content = ctk.CTkFrame(self, fg_color="transparent")
106
+ self._content.pack(side="left", fill="both", expand=False, padx=(12, 16), pady=4)
107
+
108
+ def pack(self, **kwargs) -> None:
109
+ """Override pack to enforce the required 15px vertical gap."""
110
+ kwargs.setdefault("pady", self.PACK_PADY)
111
+ kwargs.setdefault("padx", 16)
112
+ kwargs.setdefault("anchor", "w")
113
+ super().pack(**kwargs)
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # StandardBlock — plain text output
118
+ # ---------------------------------------------------------------------------
119
+
120
+ class StandardBlock(_BaseBlock):
121
+ """Renders standard text output in a clean card."""
122
+
123
+ def __init__(self, master: ctk.CTkBaseClass, text: str, **kwargs) -> None:
124
+ super().__init__(master, accent_color=Colors.ACCENT_BLUE, bg_color=Colors.BG_CARD, **kwargs)
125
+
126
+ label = ctk.CTkLabel(
127
+ self._content,
128
+ text=text,
129
+ font=FONT_BODY,
130
+ text_color=Colors.TEXT_PRIMARY,
131
+ anchor="w",
132
+ justify="left",
133
+ wraplength=600,
134
+ )
135
+ label.pack(fill="x", anchor="w")
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # StructuredBlock — list, dict, tuple
140
+ # ---------------------------------------------------------------------------
141
+
142
+ # Badge colors per type
143
+ _BADGE_STYLES: dict[str, tuple[str, str]] = {
144
+ "list": (Colors.ACCENT_BLUE, "LIST"),
145
+ "dictionary": (Colors.ACCENT_GREEN, "DICT"),
146
+ "tuple": (Colors.ACCENT_AMBER, "TUPLE"),
147
+ }
148
+
149
+
150
+ class StructuredBlock(_BaseBlock):
151
+ """Renders structured data (list/dict/tuple) with a type badge
152
+ and monospace formatted content.
153
+ """
154
+
155
+ def __init__(
156
+ self,
157
+ master: ctk.CTkBaseClass,
158
+ text: str,
159
+ data_type: str = "dictionary",
160
+ **kwargs,
161
+ ) -> None:
162
+ badge_color, badge_label = _BADGE_STYLES.get(data_type, (Colors.ACCENT_BLUE, "DATA"))
163
+ super().__init__(master, accent_color=badge_color, bg_color=Colors.BG_CARD_ALT, **kwargs)
164
+
165
+ # Type badge
166
+ badge_frame = ctk.CTkFrame(
167
+ self._content,
168
+ fg_color=badge_color,
169
+ corner_radius=6,
170
+ height=22,
171
+ )
172
+ badge_frame.pack(anchor="w", pady=(0, 8))
173
+
174
+ badge_text = ctk.CTkLabel(
175
+ badge_frame,
176
+ text=f" {badge_label} ",
177
+ font=FONT_BADGE,
178
+ text_color=Colors.BG_MAIN,
179
+ height=22,
180
+ )
181
+ badge_text.pack(padx=2, pady=1)
182
+
183
+ # Formatted content in monospace
184
+ content_frame = ctk.CTkFrame(
185
+ self._content,
186
+ fg_color=Colors.BG_CARD,
187
+ corner_radius=8,
188
+ border_width=1,
189
+ border_color=Colors.BORDER_SUBTLE,
190
+ )
191
+ content_frame.pack(fill="x", pady=(0, 0))
192
+
193
+ content_label = ctk.CTkLabel(
194
+ content_frame,
195
+ text=text,
196
+ font=FONT_MONO,
197
+ text_color=Colors.TEXT_PRIMARY,
198
+ anchor="nw",
199
+ justify="left",
200
+ wraplength=580,
201
+ )
202
+ content_label.pack(fill="x", padx=12, pady=10, anchor="w")
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # LoopPatternBlock — grouped rapid-fire prints
207
+ # ---------------------------------------------------------------------------
208
+
209
+ class LoopPatternBlock(_BaseBlock):
210
+ """Renders grouped loop output in a distinct purple-accented container
211
+ with strict monospace font for perfect ASCII alignment.
212
+ """
213
+
214
+ def __init__(self, master: ctk.CTkBaseClass, text: str, **kwargs) -> None:
215
+ super().__init__(
216
+ master,
217
+ accent_color=Colors.ACCENT_PURPLE,
218
+ bg_color=Colors.BG_LOOP,
219
+ **kwargs,
220
+ )
221
+
222
+ # Header badge
223
+ header_frame = ctk.CTkFrame(self._content, fg_color="transparent")
224
+ header_frame.pack(fill="x", pady=(0, 8))
225
+
226
+ badge = ctk.CTkFrame(
227
+ header_frame,
228
+ fg_color=Colors.ACCENT_PURPLE,
229
+ corner_radius=6,
230
+ height=22,
231
+ )
232
+ badge.pack(side="left")
233
+
234
+ badge_label = ctk.CTkLabel(
235
+ badge,
236
+ text=" LOOP OUTPUT ",
237
+ font=FONT_BADGE,
238
+ text_color="#FFFFFF",
239
+ height=22,
240
+ )
241
+ badge_label.pack(padx=2, pady=1)
242
+
243
+ # Line count indicator
244
+ line_count = text.count("\n") + 1
245
+ count_label = ctk.CTkLabel(
246
+ header_frame,
247
+ text=f"{line_count} lines",
248
+ font=FONT_LABEL,
249
+ text_color=Colors.TEXT_SECONDARY,
250
+ )
251
+ count_label.pack(side="left", padx=(10, 0))
252
+
253
+ # Monospace content area
254
+ mono_frame = ctk.CTkFrame(
255
+ self._content,
256
+ fg_color="#0D0D1A",
257
+ corner_radius=8,
258
+ border_width=1,
259
+ border_color=Colors.BORDER_PURPLE,
260
+ )
261
+ mono_frame.pack(fill="x")
262
+
263
+ mono_label = ctk.CTkLabel(
264
+ mono_frame,
265
+ text=text,
266
+ font=FONT_MONO_LG,
267
+ text_color=Colors.TEXT_MONO,
268
+ anchor="nw",
269
+ justify="left",
270
+ )
271
+ mono_label.pack(fill="x", padx=14, pady=12, anchor="w")
272
+
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # InputBlock — floating input entry field
276
+ # ---------------------------------------------------------------------------
277
+
278
+ class InputBlock(_BaseBlock):
279
+ """Renders an input prompt with an entry field.
280
+
281
+ When the user presses Enter, calls the provided callback with the value.
282
+ """
283
+
284
+ def __init__(
285
+ self,
286
+ master: ctk.CTkBaseClass,
287
+ prompt: str = "",
288
+ on_submit: callable = None,
289
+ **kwargs,
290
+ ) -> None:
291
+ super().__init__(
292
+ master,
293
+ accent_color=Colors.ACCENT_BLUE,
294
+ bg_color=Colors.BG_INPUT,
295
+ **kwargs,
296
+ )
297
+ self._on_submit = on_submit
298
+ self._submitted = False
299
+
300
+ # Prompt label
301
+ if prompt:
302
+ prompt_label = ctk.CTkLabel(
303
+ self._content,
304
+ text=prompt,
305
+ font=FONT_BODY_BOLD,
306
+ text_color=Colors.ACCENT_BLUE,
307
+ anchor="w",
308
+ )
309
+ prompt_label.pack(fill="x", pady=(0, 8))
310
+
311
+ # Input row: entry + submit button
312
+ input_row = ctk.CTkFrame(self._content, fg_color="transparent")
313
+ input_row.pack(fill="x")
314
+
315
+ self._entry = ctk.CTkEntry(
316
+ input_row,
317
+ font=FONT_MONO,
318
+ fg_color=Colors.INPUT_BG,
319
+ text_color=Colors.TEXT_PRIMARY,
320
+ border_color=Colors.INPUT_BORDER,
321
+ border_width=2,
322
+ corner_radius=8,
323
+ height=40,
324
+ placeholder_text="Type here and press Enter...",
325
+ placeholder_text_color=Colors.TEXT_DIM,
326
+ )
327
+ self._entry.pack(side="left", fill="x", expand=True, padx=(0, 8))
328
+ self._entry.bind("<Return>", self._handle_submit)
329
+
330
+ self._submit_btn = ctk.CTkButton(
331
+ input_row,
332
+ text="Submit",
333
+ font=FONT_BADGE,
334
+ fg_color=Colors.ACCENT_BLUE,
335
+ hover_color=Colors.BORDER_BLUE,
336
+ text_color="#FFFFFF",
337
+ corner_radius=8,
338
+ width=80,
339
+ height=40,
340
+ command=lambda: self._handle_submit(None),
341
+ )
342
+ self._submit_btn.pack(side="right")
343
+
344
+ # Auto-focus the entry
345
+ self._entry.after(100, self._entry.focus_set)
346
+
347
+ def _handle_submit(self, event) -> None:
348
+ """Handle Enter key or button click."""
349
+ if self._submitted:
350
+ return
351
+ self._submitted = True
352
+
353
+ value = self._entry.get()
354
+
355
+ # Visual feedback: disable entry, change accent to green
356
+ self._entry.configure(state="disabled", border_color=Colors.ACCENT_GREEN)
357
+ self._submit_btn.configure(state="disabled", fg_color=Colors.BORDER_SUBTLE)
358
+ self._accent_bar.configure(fg_color=Colors.ACCENT_GREEN)
359
+
360
+ if self._on_submit:
361
+ self._on_submit(value)
362
+
363
+
364
+ # ---------------------------------------------------------------------------
365
+ # ErrorBlock — unhandled exception display
366
+ # ---------------------------------------------------------------------------
367
+
368
+ class ErrorBlock(_BaseBlock):
369
+ """Renders an unhandled exception as a red-accented error card.
370
+
371
+ Shows:
372
+ - A red badge with the exception type (e.g. "NameError")
373
+ - The short error message in bold
374
+ - The full traceback in a dark monospace code box (when available)
375
+ """
376
+
377
+ def __init__(
378
+ self,
379
+ master: ctk.CTkBaseClass,
380
+ error_type: str,
381
+ message: str,
382
+ traceback: str = "",
383
+ **kwargs,
384
+ ) -> None:
385
+ super().__init__(
386
+ master,
387
+ accent_color=Colors.ACCENT_RED,
388
+ bg_color=Colors.BG_ERROR,
389
+ **kwargs,
390
+ )
391
+
392
+ # ── Error type badge ───────────────────────────────────────────
393
+ header_row = ctk.CTkFrame(self._content, fg_color="transparent")
394
+ header_row.pack(fill="x", pady=(0, 8))
395
+
396
+ badge_frame = ctk.CTkFrame(
397
+ header_row,
398
+ fg_color=Colors.ACCENT_RED,
399
+ corner_radius=6,
400
+ height=22,
401
+ )
402
+ badge_frame.pack(side="left")
403
+
404
+ badge_text = ctk.CTkLabel(
405
+ badge_frame,
406
+ text=f" ✕ {error_type} ",
407
+ font=FONT_BADGE,
408
+ text_color="#FFFFFF",
409
+ height=22,
410
+ )
411
+ badge_text.pack(padx=2, pady=1)
412
+
413
+ # ── Error message ──────────────────────────────────────────────
414
+ if message:
415
+ msg_label = ctk.CTkLabel(
416
+ self._content,
417
+ text=message,
418
+ font=FONT_BODY_BOLD,
419
+ text_color=Colors.TEXT_ERROR,
420
+ anchor="w",
421
+ justify="left",
422
+ wraplength=580,
423
+ )
424
+ msg_label.pack(fill="x", pady=(0, 8) if traceback else (0, 2))
425
+
426
+ # ── Traceback code box (shown only when traceback is present) ──
427
+ if traceback:
428
+ tb_frame = ctk.CTkFrame(
429
+ self._content,
430
+ fg_color="#0D0404",
431
+ corner_radius=8,
432
+ border_width=1,
433
+ border_color=Colors.BORDER_RED,
434
+ )
435
+ tb_frame.pack(fill="x")
436
+
437
+ tb_label = ctk.CTkLabel(
438
+ tb_frame,
439
+ text=traceback,
440
+ font=FONT_MONO,
441
+ text_color=Colors.TEXT_ERROR,
442
+ anchor="nw",
443
+ justify="left",
444
+ )
445
+ tb_label.pack(fill="x", padx=12, pady=10, anchor="w")
@@ -0,0 +1,308 @@
1
+ """
2
+ chadconsole.ui_engine
3
+ ~~~~~~~~~~~~~~~~~~~~~
4
+
5
+ Main CustomTkinter window with queue-driven rendering loop.
6
+ Polls the shared queue every 50ms and instantiates the appropriate
7
+ ui_components widget based on the payload tag.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import queue
13
+ import sys
14
+ import traceback
15
+ from pathlib import Path
16
+
17
+ try:
18
+ from PIL import Image
19
+ except ImportError:
20
+ Image = None
21
+
22
+ import customtkinter as ctk
23
+
24
+ from chadconsole.data_analyzer import ErrorPayload, InputRequest, Payload
25
+ from chadconsole.ui_components import (
26
+ Colors,
27
+ ErrorBlock,
28
+ FONT_BODY,
29
+ FONT_HEADER,
30
+ FONT_LABEL,
31
+ FONT_TITLE,
32
+ InputBlock,
33
+ LoopPatternBlock,
34
+ StandardBlock,
35
+ StructuredBlock,
36
+ )
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Assets
40
+ # ---------------------------------------------------------------------------
41
+ _ASSETS_DIR = Path(__file__).parent / "assets"
42
+ _LOGO_PATH = _ASSETS_DIR / "logo.png"
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Tag → Component mapping
47
+ # ---------------------------------------------------------------------------
48
+
49
+ _TAG_WIDGET_MAP = {
50
+ "standard": StandardBlock,
51
+ "list": StructuredBlock,
52
+ "dictionary": StructuredBlock,
53
+ "tuple": StructuredBlock,
54
+ "loop_pattern": LoopPatternBlock,
55
+ }
56
+
57
+ # Original stderr for error logging (never intercepted)
58
+ _stderr = sys.__stderr__
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # PrettyConsoleApp — The Main Window
63
+ # ---------------------------------------------------------------------------
64
+
65
+ class PrettyConsoleApp(ctk.CTk):
66
+ """Premium dark-mode console window.
67
+
68
+ Continuously polls a thread-safe queue and renders payloads as
69
+ visual blocks in a scrollable canvas.
70
+ """
71
+
72
+ POLL_INTERVAL_MS = 50 # ~20 fps
73
+ MAX_DRAIN = 10 # Max items per poll tick
74
+
75
+ def __init__(self, output_queue: queue.Queue) -> None:
76
+ super().__init__()
77
+
78
+ self._queue = output_queue
79
+ self._input_handler = None # Set later via set_input_handler()
80
+ self._closed = False
81
+
82
+ # ── Window configuration ──────────────────────────────────────
83
+ ctk.set_appearance_mode("dark")
84
+ ctk.set_default_color_theme("blue")
85
+
86
+ self.title("✦ Chad Console")
87
+ self.geometry("780x500")
88
+ self.minsize(520, 400)
89
+ self.configure(fg_color=Colors.BG_MAIN)
90
+
91
+ # ── Header bar ────────────────────────────────────────────────
92
+ header = ctk.CTkFrame(self, fg_color=Colors.BG_CARD_NAVBAR, height=60, corner_radius=0)
93
+ header.pack(fill="x", side="top")
94
+ header.pack_propagate(False)
95
+
96
+ # App icon + title
97
+ title_frame = ctk.CTkFrame(header, fg_color="transparent")
98
+ title_frame.place(relx=0.5, rely=0.5, anchor="center")
99
+
100
+ # Try to load premium logo image, fallback to text icon if not found
101
+ icon_label = None
102
+ if _LOGO_PATH.exists():
103
+ try:
104
+ pil_img = Image.open(_LOGO_PATH)
105
+ logo_img = ctk.CTkImage(light_image=pil_img, dark_image=pil_img, size=(40, 40))
106
+ icon_label = ctk.CTkLabel(
107
+ title_frame,
108
+ image=logo_img,
109
+ text="",
110
+ )
111
+ except Exception:
112
+ pass
113
+
114
+ if icon_label is None:
115
+ icon_label = ctk.CTkLabel(
116
+ title_frame,
117
+ text="✦",
118
+ font=("Inter", 22),
119
+ text_color=Colors.ACCENT_BLUE,
120
+ )
121
+ icon_label.pack(side="left", padx=(0, 1))
122
+
123
+ text_frame = ctk.CTkFrame(title_frame, fg_color="transparent")
124
+ text_frame.pack(side="left")
125
+
126
+ title_label = ctk.CTkLabel(
127
+ text_frame,
128
+ text="Chad Console",
129
+ font=FONT_TITLE,
130
+ text_color=Colors.TEXT_PRIMARY,
131
+ )
132
+ title_label.pack(anchor="w")
133
+
134
+ slogan_label = ctk.CTkLabel(
135
+ text_frame,
136
+ text="Charming Python Console",
137
+ font=("Inter", 11),
138
+ text_color=Colors.TEXT_DIM,
139
+ )
140
+ slogan_label.pack(anchor="w", pady=(0, 0))
141
+
142
+ # Subtle version badge
143
+
144
+
145
+ # ── Separator line ────────────────────────────────────────────
146
+
147
+
148
+ # ── Scrollable content area ───────────────────────────────────
149
+ self._scroll_frame = ctk.CTkScrollableFrame(
150
+ self,
151
+ fg_color=Colors.BG_MAIN,
152
+ corner_radius=0,
153
+ scrollbar_button_color=Colors.BORDER_SUBTLE,
154
+ scrollbar_button_hover_color=Colors.TEXT_DIM,
155
+ )
156
+ self._scroll_frame.pack(fill="both", expand=True, padx=0, pady=0)
157
+
158
+ # ── Status bar ────────────────────────────────────────────────
159
+ self._status_bar = ctk.CTkFrame(self, fg_color=Colors.BG_CARD, height=30, corner_radius=0)
160
+ self._status_bar.pack(fill="x", side="bottom")
161
+ self._status_bar.pack_propagate(False)
162
+
163
+ self._status_label = ctk.CTkLabel(
164
+ self._status_bar,
165
+ text="● Connected",
166
+ font=FONT_LABEL,
167
+ text_color=Colors.ACCENT_GREEN,
168
+ )
169
+ self._status_label.pack(side="left", padx=16, pady=4)
170
+
171
+ self._block_count = 0
172
+ self._count_label = ctk.CTkLabel(
173
+ self._status_bar,
174
+ text="0 blocks",
175
+ font=FONT_LABEL,
176
+ text_color=Colors.TEXT_SECONDARY,
177
+ )
178
+ self._count_label.pack(side="right", padx=16, pady=4)
179
+
180
+ # ── Lifecycle ─────────────────────────────────────────────────
181
+ self.protocol("WM_DELETE_WINDOW", self._on_close)
182
+
183
+ # Start the rendering loop
184
+ self.after(self.POLL_INTERVAL_MS, self._poll_queue)
185
+
186
+ # ------------------------------------------------------------------
187
+ # Public API
188
+ # ------------------------------------------------------------------
189
+
190
+ def set_input_handler(self, handler) -> None:
191
+ """Wire the input handler so InputBlock can resolve responses."""
192
+ self._input_handler = handler
193
+
194
+ # ------------------------------------------------------------------
195
+ # Queue polling & rendering
196
+ # ------------------------------------------------------------------
197
+
198
+ def _poll_queue(self) -> None:
199
+ """Drain up to MAX_DRAIN items from the queue and render them."""
200
+ if self._closed:
201
+ return
202
+
203
+ try:
204
+ items_processed = 0
205
+ while items_processed < self.MAX_DRAIN:
206
+ try:
207
+ item = self._queue.get_nowait()
208
+ except queue.Empty:
209
+ break
210
+
211
+ self._render_item(item)
212
+ items_processed += 1
213
+ except Exception:
214
+ _stderr.write(f"[ChadConsole] poll error:\n{traceback.format_exc()}\n")
215
+
216
+ # Always schedule next poll (even after errors)
217
+ if not self._closed:
218
+ self.after(self.POLL_INTERVAL_MS, self._poll_queue)
219
+
220
+ def _render_item(self, item) -> None:
221
+ """Instantiate the correct widget for a queue item."""
222
+ try:
223
+ if isinstance(item, InputRequest):
224
+ self._render_input(item)
225
+ elif isinstance(item, ErrorPayload):
226
+ self._render_error(item)
227
+ elif isinstance(item, Payload):
228
+ self._render_payload(item)
229
+ except Exception:
230
+ _stderr.write(f"[ChadConsole] render error:\n{traceback.format_exc()}\n")
231
+
232
+ def _render_payload(self, payload: Payload) -> None:
233
+ """Render a tagged Payload as the appropriate visual block."""
234
+ widget_cls = _TAG_WIDGET_MAP.get(payload.tag, StandardBlock)
235
+
236
+ if widget_cls is StructuredBlock:
237
+ widget = widget_cls(
238
+ self._scroll_frame,
239
+ text=payload.content,
240
+ data_type=payload.tag,
241
+ )
242
+ else:
243
+ widget = widget_cls(
244
+ self._scroll_frame,
245
+ text=payload.content,
246
+ )
247
+
248
+ widget.pack()
249
+ self._block_count += 1
250
+ self._count_label.configure(text=f"{self._block_count} blocks")
251
+
252
+ # Auto-scroll to bottom
253
+ self._scroll_to_bottom()
254
+
255
+ def _render_input(self, req: InputRequest) -> None:
256
+ """Render an InputBlock and wire its submit callback."""
257
+ handler = req.handler or self._input_handler
258
+
259
+ def on_submit(value: str) -> None:
260
+ if handler:
261
+ handler.resolve(value)
262
+ self._block_count += 1
263
+ self._count_label.configure(text=f"{self._block_count} blocks")
264
+
265
+ widget = InputBlock(
266
+ self._scroll_frame,
267
+ prompt=req.prompt,
268
+ on_submit=on_submit,
269
+ )
270
+ widget.pack()
271
+
272
+ # Auto-scroll to bottom
273
+ self._scroll_to_bottom()
274
+
275
+ def _render_error(self, err: ErrorPayload) -> None:
276
+ """Render an ErrorBlock for an unhandled exception."""
277
+ widget = ErrorBlock(
278
+ self._scroll_frame,
279
+ error_type=err.error_type,
280
+ message=err.message,
281
+ traceback=err.traceback,
282
+ )
283
+ widget.pack()
284
+ self._block_count += 1
285
+ self._count_label.configure(text=f"{self._block_count} blocks")
286
+ self._scroll_to_bottom()
287
+
288
+ def _scroll_to_bottom(self) -> None:
289
+ """Schedule a scroll-to-bottom after the widget is rendered."""
290
+ def _do_scroll():
291
+ try:
292
+ self._scroll_frame._parent_canvas.yview_moveto(1.0)
293
+ except Exception:
294
+ pass # Graceful fallback if internal API changes
295
+ self.after(30, _do_scroll)
296
+
297
+ # ------------------------------------------------------------------
298
+ # Lifecycle
299
+ # ------------------------------------------------------------------
300
+
301
+ def _on_close(self) -> None:
302
+ """Handle window close — restore originals and destroy."""
303
+ self._closed = True
304
+ from chadconsole.core_interceptor import uninstall
305
+ uninstall()
306
+
307
+ self._status_label.configure(text="● Disconnected", text_color=Colors.TEXT_DIM)
308
+ self.after(100, self.destroy)