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.
- chad_console-0.1.0.dist-info/METADATA +66 -0
- chad_console-0.1.0.dist-info/RECORD +10 -0
- chad_console-0.1.0.dist-info/WHEEL +4 -0
- chad_console-0.1.0.dist-info/licenses/LICENSE +21 -0
- chadconsole/__init__.py +79 -0
- chadconsole/assets/logo.png +0 -0
- chadconsole/core_interceptor.py +351 -0
- chadconsole/data_analyzer.py +90 -0
- chadconsole/ui_components.py +445 -0
- chadconsole/ui_engine.py +308 -0
|
@@ -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,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.
|
chadconsole/__init__.py
ADDED
|
@@ -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")
|
chadconsole/ui_engine.py
ADDED
|
@@ -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)
|