regcode 0.1.0__py3-none-any.whl → 0.2.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.
- regcode/cli.py +8 -35
- regcode/main.py +63 -3
- regcode/tools/__init__.py +2 -0
- regcode/tools/builtins.py +35 -34
- regcode/tui.py +592 -263
- {regcode-0.1.0.dist-info → regcode-0.2.0.dist-info}/METADATA +2 -1
- {regcode-0.1.0.dist-info → regcode-0.2.0.dist-info}/RECORD +9 -9
- {regcode-0.1.0.dist-info → regcode-0.2.0.dist-info}/WHEEL +0 -0
- {regcode-0.1.0.dist-info → regcode-0.2.0.dist-info}/entry_points.txt +0 -0
regcode/cli.py
CHANGED
|
@@ -41,14 +41,14 @@ def cli(ctx):
|
|
|
41
41
|
help="Disable colored output",
|
|
42
42
|
)
|
|
43
43
|
def chat(model, config_path, stream, full, no_color):
|
|
44
|
-
"""Chat with the agent."""
|
|
44
|
+
"""Chat with the agent using a Textual TUI with sticky bottom bar."""
|
|
45
45
|
config = regcode.Config.load(config_path)
|
|
46
46
|
if model:
|
|
47
47
|
config.provider.model = model
|
|
48
48
|
if full:
|
|
49
49
|
config.permissions.append(AgentPermission.WRITE)
|
|
50
50
|
|
|
51
|
-
# Initialize the TUI
|
|
51
|
+
# Initialize the TUI (Textual-based)
|
|
52
52
|
force_color = not no_color and sys.stdout.isatty()
|
|
53
53
|
ui = ChatUI(force_color=force_color)
|
|
54
54
|
|
|
@@ -56,41 +56,14 @@ def chat(model, config_path, stream, full, no_color):
|
|
|
56
56
|
config=config,
|
|
57
57
|
status_callback=ui.handle_status,
|
|
58
58
|
result_callback=ui.handle_result,
|
|
59
|
+
context_callback=ui.set_context_progress,
|
|
59
60
|
)
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if msg is None:
|
|
67
|
-
break # EOF
|
|
68
|
-
if msg.lower() in ("exit", "quit", "q"):
|
|
69
|
-
ui.print_exit()
|
|
70
|
-
break
|
|
71
|
-
if not msg:
|
|
72
|
-
continue
|
|
73
|
-
|
|
74
|
-
# Ensure agent response starts on a fresh line
|
|
75
|
-
click.echo()
|
|
76
|
-
|
|
77
|
-
# Always stream in TUI so text appears progressively rather than
|
|
78
|
-
# all at once after the full tool loop completes.
|
|
79
|
-
ui.start_streaming()
|
|
80
|
-
agent.chat(msg, stream=True, on_text_chunk=ui.print_text_chunk)
|
|
81
|
-
ui.end_streaming()
|
|
82
|
-
|
|
83
|
-
ui.print_round_complete()
|
|
84
|
-
|
|
85
|
-
except KeyboardInterrupt:
|
|
86
|
-
ui.print_interrupted()
|
|
87
|
-
break
|
|
88
|
-
except EOFError:
|
|
89
|
-
ui.print_exit()
|
|
90
|
-
break
|
|
91
|
-
except Exception as e:
|
|
92
|
-
ui.formatter.error(str(e))
|
|
93
|
-
break
|
|
62
|
+
# Attach agent to the Textual app
|
|
63
|
+
ui._app.run_agent(agent)
|
|
64
|
+
|
|
65
|
+
# Run the Textual app (replaces the old click-based chat loop)
|
|
66
|
+
ui.run()
|
|
94
67
|
|
|
95
68
|
|
|
96
69
|
@cli.command()
|
regcode/main.py
CHANGED
|
@@ -62,6 +62,7 @@ class Agent:
|
|
|
62
62
|
| None
|
|
63
63
|
)
|
|
64
64
|
| None = None,
|
|
65
|
+
context_callback: Callable[[int, int], None] | None = None,
|
|
65
66
|
) -> None:
|
|
66
67
|
self.config = config or Config.load()
|
|
67
68
|
self.system_prompt = self.config.system_prompt
|
|
@@ -69,6 +70,7 @@ class Agent:
|
|
|
69
70
|
self.registry = ToolRegistry()
|
|
70
71
|
self._status_callback = status_callback
|
|
71
72
|
self._result_callback = result_callback
|
|
73
|
+
self._context_callback = context_callback
|
|
72
74
|
self._allowed_dir = directory
|
|
73
75
|
self._tool_budget = self.config.tool_budget
|
|
74
76
|
self._tool_budget_remaining = self._tool_budget
|
|
@@ -76,6 +78,7 @@ class Agent:
|
|
|
76
78
|
|
|
77
79
|
# Context window manager
|
|
78
80
|
self.context_manager = ConversationManager()
|
|
81
|
+
self._context_window = self.config.agent.context_window
|
|
79
82
|
|
|
80
83
|
# Review note tools - only register when compaction is enabled
|
|
81
84
|
if self.config.agent.enable_compaction:
|
|
@@ -356,7 +359,6 @@ class Agent:
|
|
|
356
359
|
AgentStatus.TOOL_RESULT, result_str, is_error, tool_call_obj
|
|
357
360
|
)
|
|
358
361
|
|
|
359
|
-
# Deduct one tool budget from remaining budget
|
|
360
362
|
self._tool_budget_remaining -= 1
|
|
361
363
|
if self._tool_budget_remaining <= 0:
|
|
362
364
|
self._tool_budget_exhausted = True
|
|
@@ -372,8 +374,6 @@ class Agent:
|
|
|
372
374
|
"content": str(result),
|
|
373
375
|
}
|
|
374
376
|
)
|
|
375
|
-
# Add explicit message telling the agent to stop using
|
|
376
|
-
# tools and provide its final response
|
|
377
377
|
messages.append(
|
|
378
378
|
{
|
|
379
379
|
"role": "user",
|
|
@@ -399,13 +399,36 @@ class Agent:
|
|
|
399
399
|
}
|
|
400
400
|
)
|
|
401
401
|
total_tool_calls += 1
|
|
402
|
+
self._update_context_progress(messages)
|
|
402
403
|
# Safety: if model keeps calling tools without text, force break
|
|
403
404
|
if total_tool_calls >= 30:
|
|
404
405
|
break
|
|
405
406
|
continue
|
|
406
407
|
|
|
408
|
+
# Model returned neither text content nor tool calls — re-prompt it
|
|
409
|
+
# to force an answer with the current context state.
|
|
410
|
+
if not message_obj.content and (
|
|
411
|
+
not message_obj.tool_calls or len(message_obj.tool_calls) == 0
|
|
412
|
+
):
|
|
413
|
+
if total_tool_calls >= 5:
|
|
414
|
+
# After 5 empty responses, just give up and return empty
|
|
415
|
+
break
|
|
416
|
+
messages.append(
|
|
417
|
+
{
|
|
418
|
+
"role": "user",
|
|
419
|
+
"content": (
|
|
420
|
+
"You returned an empty response with no tool calls. "
|
|
421
|
+
"Please provide your answer now based on the current "
|
|
422
|
+
"conversation state. Respond in plain text only."
|
|
423
|
+
),
|
|
424
|
+
}
|
|
425
|
+
)
|
|
426
|
+
total_tool_calls += 1
|
|
427
|
+
continue
|
|
428
|
+
|
|
407
429
|
break
|
|
408
430
|
|
|
431
|
+
self._update_context_progress(messages)
|
|
409
432
|
self._history = [m for m in messages if m.get("role") != "system"]
|
|
410
433
|
return final_text or response_message.get("content", "") or ""
|
|
411
434
|
|
|
@@ -681,15 +704,52 @@ class Agent:
|
|
|
681
704
|
}
|
|
682
705
|
)
|
|
683
706
|
total_tool_calls += 1
|
|
707
|
+
self._update_context_progress(messages)
|
|
684
708
|
if total_tool_calls >= 30:
|
|
685
709
|
break
|
|
686
710
|
continue
|
|
687
711
|
|
|
712
|
+
# Model returned neither text content nor tool calls — re-prompt it
|
|
713
|
+
# to force an answer with the current context state.
|
|
714
|
+
if not full_text and (
|
|
715
|
+
not response_message.get("tool_calls") or
|
|
716
|
+
len(response_message.get("tool_calls", [])) == 0
|
|
717
|
+
):
|
|
718
|
+
if total_tool_calls >= 5:
|
|
719
|
+
# After 5 empty responses, just give up and return empty
|
|
720
|
+
break
|
|
721
|
+
messages.append(
|
|
722
|
+
{
|
|
723
|
+
"role": "user",
|
|
724
|
+
"content": (
|
|
725
|
+
"You returned an empty response with no tool calls. "
|
|
726
|
+
"Please provide your answer now based on the current "
|
|
727
|
+
"conversation state. Respond in plain text only."
|
|
728
|
+
),
|
|
729
|
+
}
|
|
730
|
+
)
|
|
731
|
+
total_tool_calls += 1
|
|
732
|
+
continue
|
|
733
|
+
|
|
688
734
|
break
|
|
689
735
|
|
|
736
|
+
self._update_context_progress(messages)
|
|
690
737
|
self._history = [m for m in messages if m.get("role") != "system"]
|
|
691
738
|
return final_text or response_message.get("content", "") or ""
|
|
692
739
|
|
|
740
|
+
def _update_context_progress(self, messages: list[dict[str, Any]]) -> None:
|
|
741
|
+
"""Estimate token usage and report progress via context_callback."""
|
|
742
|
+
if self._context_callback is None:
|
|
743
|
+
return
|
|
744
|
+
try:
|
|
745
|
+
token_count = litellm.token_counter(
|
|
746
|
+
model=self.config.provider.model,
|
|
747
|
+
messages=messages,
|
|
748
|
+
)
|
|
749
|
+
self._context_callback(token_count, self._context_window)
|
|
750
|
+
except Exception:
|
|
751
|
+
pass
|
|
752
|
+
|
|
693
753
|
def reset(self) -> None:
|
|
694
754
|
"""Clear conversation history."""
|
|
695
755
|
self._history = []
|
regcode/tools/__init__.py
CHANGED
regcode/tools/builtins.py
CHANGED
|
@@ -791,10 +791,18 @@ class SearchDirTool(BaseTool):
|
|
|
791
791
|
|
|
792
792
|
|
|
793
793
|
class RunPythonScriptTool(BaseTool):
|
|
794
|
-
"""
|
|
794
|
+
"""Execute a Python script file in an isolated Monty sandbox.
|
|
795
|
+
|
|
796
|
+
Reads the script file and executes its contents through the
|
|
797
|
+
Monty sandbox (Rust-based security, resource limits) rather than
|
|
798
|
+
spawning a subprocess on the host.
|
|
799
|
+
"""
|
|
795
800
|
|
|
796
801
|
name = "run_script"
|
|
797
|
-
description =
|
|
802
|
+
description = (
|
|
803
|
+
"Execute a Python script file in an isolated Monty sandbox "
|
|
804
|
+
"with Rust-based security and resource limits."
|
|
805
|
+
)
|
|
798
806
|
|
|
799
807
|
@property
|
|
800
808
|
def params(self) -> list[ToolParam]:
|
|
@@ -805,13 +813,6 @@ class RunPythonScriptTool(BaseTool):
|
|
|
805
813
|
"Path to the Python script file",
|
|
806
814
|
required=True,
|
|
807
815
|
),
|
|
808
|
-
ToolParam(
|
|
809
|
-
"args",
|
|
810
|
-
"string",
|
|
811
|
-
"Command-line arguments for the script",
|
|
812
|
-
required=False,
|
|
813
|
-
default="",
|
|
814
|
-
),
|
|
815
816
|
ToolParam(
|
|
816
817
|
"timeout",
|
|
817
818
|
"number",
|
|
@@ -823,52 +824,52 @@ class RunPythonScriptTool(BaseTool):
|
|
|
823
824
|
|
|
824
825
|
def execute(self, **kwargs: Any) -> ToolResult:
|
|
825
826
|
script_path = kwargs.get("script_path")
|
|
826
|
-
args = kwargs.get("args", "")
|
|
827
827
|
timeout = kwargs.get("timeout", 10)
|
|
828
828
|
if not script_path:
|
|
829
829
|
return ToolResult(
|
|
830
830
|
output="Missing required parameter: script_path",
|
|
831
831
|
error=True,
|
|
832
832
|
)
|
|
833
|
+
# Coerce timeout to float (LLM may send string like "15")
|
|
834
|
+
try:
|
|
835
|
+
timeout = float(timeout)
|
|
836
|
+
except (TypeError, ValueError):
|
|
837
|
+
timeout = 10.0
|
|
838
|
+
|
|
833
839
|
sandbox = MontySandbox()
|
|
834
840
|
try:
|
|
835
|
-
|
|
836
|
-
|
|
841
|
+
# Read script file from the host filesystem
|
|
842
|
+
p = Path(script_path)
|
|
843
|
+
if not p.exists():
|
|
837
844
|
return ToolResult(
|
|
838
845
|
output=f"Script not found: {script_path}",
|
|
839
846
|
error=True,
|
|
840
847
|
exit_code=1,
|
|
841
848
|
)
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
cwd=str(sandbox.sandbox_root),
|
|
851
|
-
capture_output=True,
|
|
852
|
-
text=True,
|
|
853
|
-
timeout=timeout,
|
|
854
|
-
)
|
|
849
|
+
try:
|
|
850
|
+
code = p.read_text()
|
|
851
|
+
except Exception as e:
|
|
852
|
+
return ToolResult(
|
|
853
|
+
output=f"Error reading script: {str(e)}",
|
|
854
|
+
error=True,
|
|
855
|
+
exit_code=1,
|
|
856
|
+
)
|
|
855
857
|
|
|
858
|
+
# Execute script contents through Monty sandbox (Rust-based isolation)
|
|
859
|
+
result = sandbox.run_python(code, timeout=timeout)
|
|
856
860
|
return ToolResult(
|
|
857
861
|
output=(
|
|
858
862
|
f"stdout: {result.stdout}\n"
|
|
859
863
|
f"stderr: {result.stderr}\n"
|
|
860
|
-
f"exit_code: {result.
|
|
864
|
+
f"exit_code: {result.exit_code}\n"
|
|
865
|
+
f"time: {result.execution_time:.2f}s\n"
|
|
866
|
+
f"result: {result.result}"
|
|
861
867
|
),
|
|
862
|
-
error=result.
|
|
868
|
+
error=not result.success,
|
|
863
869
|
stdout=result.stdout,
|
|
864
870
|
stderr=result.stderr,
|
|
865
|
-
exit_code=result.
|
|
866
|
-
|
|
867
|
-
except subprocess.TimeoutExpired:
|
|
868
|
-
return ToolResult(
|
|
869
|
-
output="SANDBOX_ERROR: Execution timed out",
|
|
870
|
-
error=True,
|
|
871
|
-
exit_code=-1,
|
|
871
|
+
exit_code=result.exit_code,
|
|
872
|
+
result=result.result,
|
|
872
873
|
)
|
|
873
874
|
finally:
|
|
874
875
|
sandbox.cleanup()
|
regcode/tui.py
CHANGED
|
@@ -1,331 +1,660 @@
|
|
|
1
|
-
"""Terminal UI for the RegCode agent CLI.
|
|
1
|
+
"""Terminal UI for the RegCode agent CLI using Textual.
|
|
2
2
|
|
|
3
|
-
Provides
|
|
4
|
-
|
|
3
|
+
Provides a clean, modern chat interface with:
|
|
4
|
+
- Formatted message history with visual separation
|
|
5
|
+
- Sticky bottom bar with progress and input
|
|
6
|
+
- Message queuing during agent execution
|
|
5
7
|
"""
|
|
6
8
|
|
|
7
9
|
from __future__ import annotations
|
|
8
10
|
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
from enum import Enum
|
|
12
|
-
from typing import IO
|
|
11
|
+
import re
|
|
12
|
+
import threading
|
|
13
13
|
|
|
14
|
-
import
|
|
14
|
+
from rich.console import Group
|
|
15
|
+
from rich.markdown import Markdown
|
|
16
|
+
from rich.markup import escape
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
from textual.app import App, ComposeResult
|
|
19
|
+
from textual.binding import Binding
|
|
20
|
+
from textual.containers import Container, Vertical
|
|
21
|
+
from textual.widgets import Input, RichLog, Static
|
|
15
22
|
|
|
23
|
+
from regcode import __version__
|
|
16
24
|
from regcode.main import ToolCall
|
|
17
25
|
|
|
18
|
-
# ANSI color codes
|
|
19
|
-
_RESET = "\033[0m"
|
|
20
|
-
_BOLD = "\033[1m"
|
|
21
|
-
_DIM = "\033[2m"
|
|
22
|
-
_UNDERLINE = "\033[4m"
|
|
23
|
-
|
|
24
|
-
# Foreground colors
|
|
25
|
-
_CYAN = "\033[36m"
|
|
26
|
-
_GREEN = "\033[32m"
|
|
27
|
-
_YELLOW = "\033[33m"
|
|
28
|
-
_MAGENTA = "\033[35m"
|
|
29
|
-
_BLUE = "\033[34m"
|
|
30
|
-
_RED = "\033[31m"
|
|
31
|
-
_GRAY = "\033[90m"
|
|
32
|
-
_WHITE = "\033[97m"
|
|
33
|
-
_ORANGE = "\033[38;5;208m"
|
|
34
|
-
|
|
35
|
-
# Status icons (simple text alternatives for terminal compatibility)
|
|
36
|
-
_ICONS = {
|
|
37
|
-
"tool_call": "[TOOL]",
|
|
38
|
-
"tool_result": "[RESULT]",
|
|
39
|
-
"idle": "[IDLE]",
|
|
40
|
-
"budget_exhausted": "[BUDGET]",
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
# Separator characters
|
|
44
|
-
_SEP_CHAR = "─"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _wrap_text(text: str, width: int = 72, indent: str = " ") -> str:
|
|
48
|
-
"""Wrap text with an indent for readable terminal output."""
|
|
49
|
-
wrapped = textwrap.fill(text, width=width)
|
|
50
|
-
return "\n".join(f"{indent}{line}" for line in wrapped.splitlines())
|
|
51
26
|
|
|
27
|
+
class ChatApp(App):
|
|
28
|
+
"""Textual chat app with clean modern UI."""
|
|
29
|
+
|
|
30
|
+
TITLE = f"RegCode {__version__}"
|
|
31
|
+
CSS = """
|
|
32
|
+
Screen {
|
|
33
|
+
background: $panel;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#chat-pane {
|
|
37
|
+
width: 100%;
|
|
38
|
+
height: 1fr;
|
|
39
|
+
padding: 0;
|
|
40
|
+
margin: 0;
|
|
41
|
+
border: round $accent;
|
|
42
|
+
border-title-color: $accent;
|
|
43
|
+
border-title-align: left;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#history {
|
|
47
|
+
width: 100%;
|
|
48
|
+
height: 1fr;
|
|
49
|
+
padding: 0 1;
|
|
50
|
+
margin: 0;
|
|
51
|
+
background: transparent;
|
|
52
|
+
border: none;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#history RichLog {
|
|
56
|
+
color: $text;
|
|
57
|
+
background: transparent;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#bottom-bar {
|
|
61
|
+
dock: bottom;
|
|
62
|
+
layout: horizontal;
|
|
63
|
+
height: 5;
|
|
64
|
+
padding: 0 1;
|
|
65
|
+
background: $surface;
|
|
66
|
+
border: round $accent;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#live-response {
|
|
70
|
+
display: none;
|
|
71
|
+
width: 100%;
|
|
72
|
+
padding: 0 1;
|
|
73
|
+
margin: 0;
|
|
74
|
+
background: transparent;
|
|
75
|
+
color: $text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#progress-bar {
|
|
79
|
+
width: 22;
|
|
80
|
+
content-align: center middle;
|
|
81
|
+
padding: 0 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#input-box {
|
|
85
|
+
width: 1fr;
|
|
86
|
+
height: 3;
|
|
87
|
+
min-height: 3;
|
|
88
|
+
margin: 0 0 0 1;
|
|
89
|
+
padding: 0 2;
|
|
90
|
+
background: $boost;
|
|
91
|
+
color: $text;
|
|
92
|
+
border: round $warning;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
Input:focus {
|
|
96
|
+
border: round $success;
|
|
97
|
+
text-style: bold;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
Footer {
|
|
101
|
+
display: none;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
Header {
|
|
105
|
+
display: none;
|
|
106
|
+
}
|
|
107
|
+
"""
|
|
52
108
|
|
|
53
|
-
|
|
54
|
-
|
|
109
|
+
BINDINGS = [
|
|
110
|
+
Binding("q", "quit", "Quit"),
|
|
111
|
+
Binding("c", "clear", "Clear"),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
def __init__(self) -> None:
|
|
115
|
+
super().__init__()
|
|
116
|
+
self._msg_queue: list[str] = []
|
|
117
|
+
self._queue_lock = threading.Lock()
|
|
118
|
+
self._agent_running = False
|
|
119
|
+
self._agent = None
|
|
120
|
+
self._lock = threading.Lock()
|
|
121
|
+
self._current_response: list[str] = []
|
|
122
|
+
self._streaming_started = False
|
|
123
|
+
self._stream_pending = ""
|
|
124
|
+
self._stream_has_label = False
|
|
125
|
+
self._stream_line_open = False
|
|
126
|
+
self._stream_trim_leading = True
|
|
127
|
+
self._live_stream_text = ""
|
|
128
|
+
|
|
129
|
+
def compose(self) -> ComposeResult:
|
|
130
|
+
yield Vertical(
|
|
131
|
+
RichLog(
|
|
132
|
+
id="history",
|
|
133
|
+
markup=True,
|
|
134
|
+
max_lines=500,
|
|
135
|
+
auto_scroll=True,
|
|
136
|
+
wrap=True,
|
|
137
|
+
),
|
|
138
|
+
Static(id="live-response"),
|
|
139
|
+
id="chat-pane",
|
|
140
|
+
)
|
|
141
|
+
yield Container(
|
|
142
|
+
Static(id="progress-bar", content="Ready"),
|
|
143
|
+
Input(id="input-box", placeholder="Type message..."),
|
|
144
|
+
id="bottom-bar",
|
|
145
|
+
)
|
|
55
146
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
self.
|
|
59
|
-
|
|
147
|
+
def on_mount(self) -> None:
|
|
148
|
+
"""Initialize the app when mounted."""
|
|
149
|
+
self._update_progress(0, 0)
|
|
150
|
+
log = self.query_one("#history", RichLog)
|
|
151
|
+
log.write(f"[bold cyan]RegCode {__version__}[/]\n")
|
|
152
|
+
log.write("[dim]Made by Bijay Regmi in Bonn ❤️[/]\n")
|
|
153
|
+
log.write("[dim]Type your message and press Enter.[/]\n")
|
|
154
|
+
log.write("[dim]────────────────────────────────────────────────────[/]\n")
|
|
155
|
+
self.set_timer(0.1, self._focus_input)
|
|
156
|
+
|
|
157
|
+
def _focus_input(self) -> None:
|
|
158
|
+
input_widget = self.query_one("#input-box", Input)
|
|
159
|
+
input_widget.focus()
|
|
160
|
+
|
|
161
|
+
def on_key(self, event) -> None:
|
|
162
|
+
"""Handle keyboard shortcuts."""
|
|
163
|
+
if event.key == "q":
|
|
164
|
+
self.exit()
|
|
165
|
+
elif event.key == "c":
|
|
166
|
+
self.query_one("#history", RichLog).clear()
|
|
167
|
+
self._current_response.clear()
|
|
168
|
+
self._stream_pending = ""
|
|
169
|
+
self._stream_has_label = False
|
|
170
|
+
|
|
171
|
+
# ── Agent callbacks (called from agent thread) ──────────────────
|
|
172
|
+
|
|
173
|
+
def on_status(self, status, tool_call_info=None):
|
|
174
|
+
"""Handle agent status updates from any thread."""
|
|
175
|
+
self.call_from_thread(self._render_status, status, tool_call_info)
|
|
176
|
+
|
|
177
|
+
def _render_status(self, status, tool_call_info) -> None:
|
|
178
|
+
"""Render status updates on the main thread."""
|
|
179
|
+
log = self.query_one("#history", RichLog)
|
|
180
|
+
if status.value == "tool_call" and tool_call_info is not None:
|
|
181
|
+
tool_name = escape(str(tool_call_info.tool_name))
|
|
182
|
+
parts = [f"[dim]⚙[/dim] [blue]{tool_name}[/blue]"]
|
|
183
|
+
if tool_call_info.tool_args:
|
|
184
|
+
for k, v in tool_call_info.tool_args.items():
|
|
185
|
+
val = escape(str(v))
|
|
186
|
+
parts.append(f" [dim]{escape(str(k))}=[/dim]{val}")
|
|
187
|
+
log.write(" ".join(parts) + "\n")
|
|
188
|
+
elif status.value == "budget_exhausted":
|
|
189
|
+
log.write("[red]Tool budget exhausted.[/]\n")
|
|
190
|
+
elif status.value == "idle":
|
|
191
|
+
log.write("[dim]Agent is idle. Waiting for your input.[/]\n")
|
|
60
192
|
|
|
61
|
-
def
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
"""
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
193
|
+
def on_result(
|
|
194
|
+
self,
|
|
195
|
+
status,
|
|
196
|
+
result_str: str,
|
|
197
|
+
error: bool,
|
|
198
|
+
tool_call: ToolCall | None = None,
|
|
199
|
+
):
|
|
200
|
+
"""Handle tool results - silences them."""
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
def on_context(self, current: int, total: int):
|
|
204
|
+
"""Handle context token usage updates."""
|
|
205
|
+
self.call_from_thread(self._update_progress, current, total)
|
|
206
|
+
|
|
207
|
+
def _update_progress(self, current: int, total: int) -> None:
|
|
208
|
+
"""Update the progress bar on the bottom bar."""
|
|
209
|
+
if total <= 0:
|
|
210
|
+
total = 1
|
|
211
|
+
pct = min(100, int(current / total * 100))
|
|
212
|
+
bar_width = 11
|
|
213
|
+
filled = max(0, min(bar_width, round(bar_width * pct / 100)))
|
|
214
|
+
empty = bar_width - filled
|
|
215
|
+
bar = "█" * filled + "░" * empty
|
|
216
|
+
|
|
217
|
+
if pct >= 90:
|
|
218
|
+
color = "red"
|
|
219
|
+
elif pct >= 70:
|
|
220
|
+
color = "orange"
|
|
221
|
+
elif pct >= 40:
|
|
222
|
+
color = "yellow"
|
|
80
223
|
else:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
224
|
+
color = "green"
|
|
225
|
+
|
|
226
|
+
pct_str = f" {pct}%"
|
|
227
|
+
tokens_str = f" {current}/{total}"
|
|
228
|
+
label = f"[bold {color}]{bar}[/] {pct_str} [dim]{tokens_str}"
|
|
229
|
+
|
|
230
|
+
progress_widget = self.query_one("#progress-bar", Static)
|
|
231
|
+
progress_widget.update(label)
|
|
232
|
+
|
|
233
|
+
def on_text_chunk(self, chunk: str) -> None:
|
|
234
|
+
"""Buffer chunks and update live preview during streaming."""
|
|
235
|
+
self._current_response.append(chunk)
|
|
236
|
+
self._streaming_started = True
|
|
237
|
+
self.call_from_thread(self._update_live_stream, chunk)
|
|
238
|
+
|
|
239
|
+
def _update_live_stream(self, chunk: str) -> None:
|
|
240
|
+
"""Show in-progress assistant text as markdown in real time."""
|
|
241
|
+
if self._stream_trim_leading:
|
|
242
|
+
chunk = chunk.lstrip("\n")
|
|
243
|
+
self._stream_trim_leading = False
|
|
244
|
+
if not chunk:
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
self._live_stream_text += chunk
|
|
248
|
+
history = self.query_one("#history", RichLog)
|
|
249
|
+
live = self.query_one("#live-response", Static)
|
|
250
|
+
if not live.display:
|
|
251
|
+
live.display = True
|
|
252
|
+
history.styles.height = "auto"
|
|
253
|
+
|
|
254
|
+
md_preview = re.sub(r"\n{3,}", "\n\n", self._live_stream_text)
|
|
255
|
+
live.update(
|
|
256
|
+
Group(
|
|
257
|
+
Text("AGENT:", style="bold blue"),
|
|
258
|
+
Markdown(md_preview),
|
|
90
259
|
)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def _render_stream_chunk(self, chunk: str) -> None:
|
|
263
|
+
"""Render streamed chunks directly in history in readable batches."""
|
|
264
|
+
if not self._streaming_started:
|
|
265
|
+
self._streaming_started = True
|
|
266
|
+
self._stream_pending += chunk
|
|
267
|
+
self._flush_stream_buffer(flush_partial=False)
|
|
268
|
+
|
|
269
|
+
def _emit_stream_text(self, text: str) -> None:
|
|
270
|
+
"""Emit a streamed text segment to history with AGENT label once."""
|
|
271
|
+
if not text:
|
|
272
|
+
return
|
|
273
|
+
if self._stream_trim_leading:
|
|
274
|
+
text = text.lstrip("\n")
|
|
275
|
+
self._stream_trim_leading = False
|
|
276
|
+
if not text:
|
|
277
|
+
return
|
|
278
|
+
log = self.query_one("#history", RichLog)
|
|
279
|
+
if not self._stream_has_label:
|
|
280
|
+
log.write(f"[bold blue]AGENT:[/] {escape(text)}")
|
|
281
|
+
self._stream_has_label = True
|
|
91
282
|
else:
|
|
92
|
-
|
|
93
|
-
self.
|
|
283
|
+
log.write(escape(text))
|
|
284
|
+
self._stream_line_open = not text.endswith("\n")
|
|
285
|
+
|
|
286
|
+
def _ensure_stream_line_break(self) -> None:
|
|
287
|
+
"""Ensure subsequent non-stream lines start on a fresh line."""
|
|
288
|
+
if self._stream_has_label and self._stream_line_open:
|
|
289
|
+
self.query_one("#history", RichLog).write("\n")
|
|
290
|
+
self._stream_line_open = False
|
|
291
|
+
|
|
292
|
+
def _flush_stream_for_status(self) -> None:
|
|
293
|
+
"""Flush stream text before status output with compact spacing."""
|
|
294
|
+
if self._stream_pending:
|
|
295
|
+
pending = self._stream_pending
|
|
296
|
+
# Avoid large visual gaps before tool/status lines.
|
|
297
|
+
pending = pending.rstrip("\n")
|
|
298
|
+
self._stream_pending = ""
|
|
299
|
+
if pending:
|
|
300
|
+
self._emit_stream_text(pending)
|
|
301
|
+
self._ensure_stream_line_break()
|
|
302
|
+
|
|
303
|
+
def _flush_stream_buffer(self, flush_partial: bool) -> None:
|
|
304
|
+
"""Flush buffered stream text by line; optionally flush remainder."""
|
|
305
|
+
while "\n" in self._stream_pending:
|
|
306
|
+
line, self._stream_pending = self._stream_pending.split("\n", 1)
|
|
307
|
+
self._emit_stream_text(line + "\n")
|
|
308
|
+
|
|
309
|
+
if flush_partial and self._stream_pending:
|
|
310
|
+
self._emit_stream_text(self._stream_pending)
|
|
311
|
+
self._stream_pending = ""
|
|
312
|
+
elif len(self._stream_pending) >= 120:
|
|
313
|
+
# Keep streaming responsive even when the model emits long lines.
|
|
314
|
+
self._emit_stream_text(self._stream_pending)
|
|
315
|
+
self._stream_pending = ""
|
|
316
|
+
|
|
317
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
318
|
+
"""Process input when user presses Enter."""
|
|
319
|
+
message = event.value.strip()
|
|
320
|
+
if not message:
|
|
321
|
+
return
|
|
322
|
+
input_widget = self.query_one("#input-box", Input)
|
|
323
|
+
input_widget.value = ""
|
|
324
|
+
if message.lower() in ("exit", "quit"):
|
|
325
|
+
self.exit()
|
|
326
|
+
if self._agent_running:
|
|
327
|
+
self._msg_queue.append(message)
|
|
328
|
+
self.notify(f"Message queued: {message[:60]}...")
|
|
329
|
+
return
|
|
330
|
+
self._process_message(message)
|
|
331
|
+
|
|
332
|
+
def _process_message(self, message: str) -> None:
|
|
333
|
+
"""Process a user message by running the agent in a background thread."""
|
|
334
|
+
self._agent_running = True
|
|
335
|
+
self._streaming_started = False
|
|
336
|
+
self._current_response.clear()
|
|
337
|
+
self._stream_pending = ""
|
|
338
|
+
self._stream_has_label = False
|
|
339
|
+
self._stream_line_open = False
|
|
340
|
+
self._stream_trim_leading = True
|
|
341
|
+
self._live_stream_text = ""
|
|
342
|
+
history = self.query_one("#history", RichLog)
|
|
343
|
+
history.styles.height = "1fr"
|
|
344
|
+
live = self.query_one("#live-response", Static)
|
|
345
|
+
live.update("")
|
|
346
|
+
live.display = False
|
|
347
|
+
input_widget = self.query_one("#input-box", Input)
|
|
348
|
+
input_widget.placeholder = "Agent running... (Enter queues)"
|
|
349
|
+
|
|
350
|
+
log = self.query_one("#history", RichLog)
|
|
351
|
+
log.write(f"[bold green]YOU:[/] [bold]{escape(message)}[/]\n")
|
|
352
|
+
|
|
353
|
+
t = threading.Thread(target=self._run_agent_sync, args=(message,), daemon=True)
|
|
354
|
+
t.start()
|
|
355
|
+
|
|
356
|
+
def _run_agent_sync(self, message: str) -> None:
|
|
357
|
+
"""Run agent.chat synchronously in a background thread."""
|
|
358
|
+
try:
|
|
359
|
+
with self._lock:
|
|
360
|
+
if self._agent:
|
|
361
|
+
result = self._agent.chat(
|
|
362
|
+
message, stream=True, on_text_chunk=self.on_text_chunk
|
|
363
|
+
)
|
|
364
|
+
# Fallback: if no chunks were emitted, render final text.
|
|
365
|
+
if result and not self._current_response:
|
|
366
|
+
self.call_from_thread(self._set_fallback_response, result)
|
|
367
|
+
else:
|
|
368
|
+
self.call_from_thread(
|
|
369
|
+
self._write_error_line, "Agent not initialized!"
|
|
370
|
+
)
|
|
371
|
+
except Exception:
|
|
372
|
+
self.call_from_thread(self._write_error_line, "Agent error!")
|
|
373
|
+
finally:
|
|
374
|
+
self.call_from_thread(self._finish_message)
|
|
375
|
+
|
|
376
|
+
def _write_error_line(self, message: str) -> None:
|
|
377
|
+
"""Write an error line safely on the UI thread."""
|
|
378
|
+
history = self.query_one("#history", RichLog)
|
|
379
|
+
history.styles.height = "1fr"
|
|
380
|
+
live = self.query_one("#live-response", Static)
|
|
381
|
+
live.update("")
|
|
382
|
+
live.display = False
|
|
383
|
+
self.query_one("#history", RichLog).write(f"[red]{escape(message)}[/]\n")
|
|
384
|
+
|
|
385
|
+
def _set_fallback_response(self, response: str) -> None:
|
|
386
|
+
"""Set fallback response when streaming callback emitted no chunks."""
|
|
387
|
+
if not self._current_response:
|
|
388
|
+
self._current_response.append(response)
|
|
389
|
+
self._streaming_started = False
|
|
390
|
+
|
|
391
|
+
def _finish_message(self) -> None:
|
|
392
|
+
"""Called on main thread after agent finishes."""
|
|
393
|
+
self._agent_running = False
|
|
394
|
+
try:
|
|
395
|
+
if self._current_response:
|
|
396
|
+
log = self.query_one("#history", RichLog)
|
|
397
|
+
response = "".join(self._current_response).lstrip("\n")
|
|
398
|
+
# Keep markdown readable but avoid huge accidental vertical gaps.
|
|
399
|
+
response = re.sub(r"\n{3,}", "\n\n", response)
|
|
400
|
+
history = self.query_one("#history", RichLog)
|
|
401
|
+
history.styles.height = "1fr"
|
|
402
|
+
live = self.query_one("#live-response", Static)
|
|
403
|
+
live.update("")
|
|
404
|
+
live.display = False
|
|
405
|
+
log.write("[bold blue]AGENT:[/]")
|
|
406
|
+
log.write(Markdown(response))
|
|
407
|
+
log.write(
|
|
408
|
+
"[dim]────────────────────────────────────────────────────[/]\n"
|
|
409
|
+
)
|
|
410
|
+
except Exception:
|
|
411
|
+
self._write_error_line("Failed to render agent response.")
|
|
412
|
+
finally:
|
|
413
|
+
self._current_response.clear()
|
|
414
|
+
self._stream_pending = ""
|
|
415
|
+
self._stream_has_label = False
|
|
416
|
+
self._stream_line_open = False
|
|
417
|
+
self._stream_trim_leading = True
|
|
418
|
+
input_widget = self.query_one("#input-box", Input)
|
|
419
|
+
input_widget.placeholder = "Type message..."
|
|
420
|
+
input_widget.focus()
|
|
421
|
+
self._process_next_queued()
|
|
422
|
+
|
|
423
|
+
def _process_next_queued(self) -> None:
|
|
424
|
+
"""Process the next message from the queue."""
|
|
425
|
+
with self._queue_lock:
|
|
426
|
+
if not self._msg_queue:
|
|
427
|
+
return
|
|
428
|
+
message = self._msg_queue.pop(0)
|
|
429
|
+
self._process_message(message)
|
|
430
|
+
|
|
431
|
+
def run_agent(self, agent) -> None:
|
|
432
|
+
"""Attach the agent instance so the app can use it."""
|
|
433
|
+
self._agent = agent
|
|
434
|
+
|
|
435
|
+
# ── Backward-compatible API ─────────────────────────────────────
|
|
94
436
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
parts = [self.colored(_YELLOW + _BOLD, tool_name)]
|
|
99
|
-
if tool_args:
|
|
100
|
-
for key, value in tool_args.items():
|
|
101
|
-
val_str = str(value)
|
|
102
|
-
if len(val_str) > 40:
|
|
103
|
-
val_str = val_str[:37] + "..."
|
|
104
|
-
parts.append(f"{key}={val_str}")
|
|
105
|
-
line = f" {header} " + " ".join(parts)
|
|
106
|
-
self._stream.write(line + "\n")
|
|
107
|
-
self._stream.flush()
|
|
108
|
-
|
|
109
|
-
def tool_result(self, tool_name: str, result: str, error: bool = False) -> None:
|
|
110
|
-
"""Print a tool result in magenta (or red if error)."""
|
|
111
|
-
color = _RED if error else _MAGENTA
|
|
112
|
-
header = self.colored(color + _BOLD, _ICONS["tool_result"])
|
|
113
|
-
self._stream.write(f"\n{header} {self.colored(color + _BOLD, tool_name)}\n")
|
|
114
|
-
truncated = result[:500] + ("..." if len(result) > 500 else "")
|
|
115
|
-
self._stream.write(_wrap_text(truncated) + "\n")
|
|
116
|
-
self._stream.flush()
|
|
117
|
-
|
|
118
|
-
def status_update(self, status: Enum, tool_call_info=None) -> None:
|
|
119
|
-
"""Print a status update in gray dim text at the bottom area."""
|
|
120
|
-
color = _GRAY
|
|
121
|
-
if status.value == "budget_exhausted":
|
|
122
|
-
color = _RED
|
|
123
|
-
elif status.value == "idle":
|
|
124
|
-
color = _DIM
|
|
437
|
+
@property
|
|
438
|
+
def formatter(self) -> _ColorFormatter:
|
|
439
|
+
return _ColorFormatter(True)
|
|
125
440
|
|
|
126
|
-
|
|
127
|
-
|
|
441
|
+
@property
|
|
442
|
+
def agent_running(self) -> bool:
|
|
443
|
+
return self._agent_running
|
|
128
444
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
status_text = self.colored(color, "Agent is idle. Waiting for your input.")
|
|
134
|
-
self._stream.write(f"\n{header} {status_text}\n")
|
|
135
|
-
else:
|
|
136
|
-
status_text = self.colored(color, "Agent is working...")
|
|
137
|
-
self._stream.write(f"\n{header} {status_text}\n")
|
|
138
|
-
self._stream.flush()
|
|
139
|
-
|
|
140
|
-
def separator(self) -> None:
|
|
141
|
-
"""Print a visual separator line."""
|
|
142
|
-
if self._force_color:
|
|
143
|
-
self._stream.write(
|
|
144
|
-
f"{self.colored(_DIM, _SEP_CHAR * 72)}\n"
|
|
145
|
-
)
|
|
146
|
-
else:
|
|
147
|
-
self._stream.write(f"{_SEP_CHAR * 72}\n")
|
|
148
|
-
self._stream.flush()
|
|
445
|
+
def print_welcome(self, version: str = "0.1.0") -> None:
|
|
446
|
+
log = self.query_one("#history", RichLog)
|
|
447
|
+
log.write(f"[bold cyan]RegCode - Minimalistic Coding Agent v{version}[/]\n")
|
|
448
|
+
log.write("[dim]Type your message and press Enter.[/]\n")
|
|
149
449
|
|
|
150
|
-
def
|
|
151
|
-
""
|
|
152
|
-
|
|
153
|
-
|
|
450
|
+
def print_separator(self) -> None:
|
|
451
|
+
self.query_one("#history", RichLog).write(
|
|
452
|
+
"[dim]────────────────────────────────────────────────────[/]\n"
|
|
453
|
+
)
|
|
154
454
|
|
|
155
|
-
def
|
|
156
|
-
""
|
|
157
|
-
|
|
158
|
-
|
|
455
|
+
def print_user_message(self, message: str) -> None:
|
|
456
|
+
self.query_one("#history", RichLog).write(
|
|
457
|
+
f"[bold green]YOU:[/] [bold]{escape(message)}[/]\n"
|
|
458
|
+
)
|
|
159
459
|
|
|
160
|
-
def
|
|
161
|
-
|
|
162
|
-
|
|
460
|
+
def print_assistant_message(self, message: str) -> None:
|
|
461
|
+
log = self.query_one("#history", RichLog)
|
|
462
|
+
log.write("[bold blue]AGENT:[/]")
|
|
463
|
+
log.write(Markdown(message))
|
|
163
464
|
|
|
164
|
-
def
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
465
|
+
def print_tool_call(self, tool_name: str, tool_args: dict | None = None) -> None:
|
|
466
|
+
parts = [f"[dim]⚙[/dim] [blue]{escape(str(tool_name))}[/blue]"]
|
|
467
|
+
if tool_args:
|
|
468
|
+
for k, v in tool_args.items():
|
|
469
|
+
val = escape(str(v))
|
|
470
|
+
parts.append(f" [dim]{escape(str(k))}=[/dim]{val}")
|
|
471
|
+
self.query_one("#history", RichLog).write(" ".join(parts) + "\n")
|
|
169
472
|
|
|
170
|
-
def
|
|
171
|
-
|
|
172
|
-
self._stream.write(f"{self.colored(_GREEN, text)}\n")
|
|
173
|
-
self._stream.flush()
|
|
473
|
+
def print_tool_result(self, tool_name, result, error=False):
|
|
474
|
+
pass
|
|
174
475
|
|
|
476
|
+
def handle_status(self, status, tool_call_info=None):
|
|
477
|
+
self.on_status(status, tool_call_info)
|
|
175
478
|
|
|
176
|
-
|
|
177
|
-
|
|
479
|
+
def handle_result(self, status, result_str, error, tool_call=None):
|
|
480
|
+
self.on_result(status, result_str, error, tool_call)
|
|
178
481
|
|
|
179
|
-
|
|
180
|
-
|
|
482
|
+
def print_text_chunk(self, chunk: str) -> None:
|
|
483
|
+
self.on_text_chunk(chunk)
|
|
484
|
+
|
|
485
|
+
def get_user_input(self) -> str | None:
|
|
486
|
+
return None
|
|
181
487
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
ui.print_heading("RegCode v0.1.0")
|
|
185
|
-
ui.print_separator()
|
|
488
|
+
def print_exit(self) -> None:
|
|
489
|
+
self.query_one("#history", RichLog).write("[dim]Goodbye![/]\n")
|
|
186
490
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
status_callback=ui.handle_status,
|
|
491
|
+
def print_round_complete(self) -> None:
|
|
492
|
+
self.query_one("#history", RichLog).write(
|
|
493
|
+
"[dim]────────────────────────────────────────────────────[/]\n"
|
|
191
494
|
)
|
|
192
495
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
496
|
+
def print_interrupted(self) -> None:
|
|
497
|
+
self.query_one("#history", RichLog).write(
|
|
498
|
+
"[bold red]Interrupted by user. Goodbye![/]\n"
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
def start_streaming(self) -> None:
|
|
502
|
+
log = self.query_one("#history", RichLog)
|
|
503
|
+
log.write("[dim]...[/]\n")
|
|
504
|
+
|
|
505
|
+
def end_streaming(self) -> None:
|
|
506
|
+
self.query_one("#history", RichLog).write("\n")
|
|
198
507
|
|
|
199
|
-
def
|
|
200
|
-
|
|
508
|
+
def print_prompt(self) -> None:
|
|
509
|
+
pass
|
|
510
|
+
|
|
511
|
+
def clear(self) -> None:
|
|
512
|
+
self.query_one("#history", RichLog).clear()
|
|
513
|
+
self._current_response.clear()
|
|
514
|
+
|
|
515
|
+
def set_context_progress(self, current: int, total: int) -> None:
|
|
516
|
+
self.on_context(current, total)
|
|
517
|
+
|
|
518
|
+
def queue_message(self, message: str) -> None:
|
|
519
|
+
with self._queue_lock:
|
|
520
|
+
self._msg_queue.append(message)
|
|
521
|
+
|
|
522
|
+
def get_queued_messages(self) -> list[str]:
|
|
523
|
+
with self._queue_lock:
|
|
524
|
+
messages = list(self._msg_queue)
|
|
525
|
+
self._msg_queue.clear()
|
|
526
|
+
return messages
|
|
527
|
+
|
|
528
|
+
def is_queue_empty(self) -> bool:
|
|
529
|
+
with self._queue_lock:
|
|
530
|
+
return len(self._msg_queue) == 0
|
|
531
|
+
|
|
532
|
+
def set_agent_running(self, running: bool) -> None:
|
|
533
|
+
self._agent_running = running
|
|
534
|
+
|
|
535
|
+
def start_input_thread(self) -> None:
|
|
536
|
+
pass
|
|
537
|
+
|
|
538
|
+
def stop_input_thread(self) -> None:
|
|
539
|
+
pass
|
|
540
|
+
|
|
541
|
+
def run_app(self) -> None:
|
|
542
|
+
"""Run the Textual application."""
|
|
543
|
+
self.run()
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class _ColorFormatter:
|
|
547
|
+
"""Color formatter for CLI output, with Textual integration."""
|
|
548
|
+
|
|
549
|
+
def __init__(self, stream=None, force_color: bool = True, textui=None) -> None:
|
|
201
550
|
self._stream = stream
|
|
202
|
-
self.
|
|
551
|
+
self._force_color = force_color
|
|
552
|
+
self._textui = textui
|
|
553
|
+
|
|
554
|
+
def colored(self, color: str, text: str) -> str:
|
|
555
|
+
return f"{color}{text}\033[0m" if self._force_color else text
|
|
556
|
+
|
|
557
|
+
def context_progress(self, current: int, total: int) -> None:
|
|
558
|
+
if self._stream is not None:
|
|
559
|
+
pct = min(100, int(current / max(total, 1) * 100))
|
|
560
|
+
bar_width = 11
|
|
561
|
+
filled = max(0, min(bar_width, round(bar_width * pct / 100)))
|
|
562
|
+
empty = bar_width - filled
|
|
563
|
+
bar = self.colored("green", "━" * filled) + self.colored("dim", "━" * empty)
|
|
564
|
+
pct_str = self.colored("yellow", f" {pct}%")
|
|
565
|
+
tokens_str = self.colored("dim", f" {current}/{total} tokens")
|
|
566
|
+
self._stream.write(f"{bar}{pct_str}{tokens_str}\n")
|
|
567
|
+
|
|
568
|
+
if self._textui is not None:
|
|
569
|
+
self._textui.set_context_progress(current, total)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class ChatUI:
|
|
573
|
+
"""Facade that bridges the old CLI code to the new Textual implementation."""
|
|
574
|
+
|
|
575
|
+
def __init__(self, force_color: bool = True) -> None:
|
|
576
|
+
self._app = ChatApp()
|
|
203
577
|
|
|
204
578
|
@property
|
|
205
579
|
def formatter(self) -> _ColorFormatter:
|
|
206
|
-
|
|
207
|
-
return self._formatter
|
|
580
|
+
return self._app.formatter
|
|
208
581
|
|
|
209
|
-
|
|
582
|
+
@property
|
|
583
|
+
def agent_running(self) -> bool:
|
|
584
|
+
return self._app.agent_running
|
|
210
585
|
|
|
211
586
|
def print_welcome(self, version: str = "0.1.0") -> None:
|
|
212
|
-
|
|
213
|
-
self._formatter.heading(f"RegCode - Minimalistic Coding Agent v{version}")
|
|
214
|
-
self._formatter.info(
|
|
215
|
-
"Type your message and press Enter. Agent will respond with full "
|
|
216
|
-
"tool access."
|
|
217
|
-
)
|
|
218
|
-
self._formatter.info("Press Ctrl+C to exit.")
|
|
219
|
-
self._stream.write("\n")
|
|
220
|
-
self._formatter.flush()
|
|
587
|
+
self._app.print_welcome(version)
|
|
221
588
|
|
|
222
589
|
def print_separator(self) -> None:
|
|
223
|
-
|
|
224
|
-
self._formatter.separator()
|
|
590
|
+
self._app.print_separator()
|
|
225
591
|
|
|
226
592
|
def print_user_message(self, message: str) -> None:
|
|
227
|
-
|
|
228
|
-
self._formatter.user_message(message)
|
|
593
|
+
self._app.print_user_message(message)
|
|
229
594
|
|
|
230
595
|
def print_assistant_message(self, message: str) -> None:
|
|
231
|
-
|
|
232
|
-
self._formatter.assistant_message(message)
|
|
596
|
+
self._app.print_assistant_message(message)
|
|
233
597
|
|
|
234
598
|
def print_tool_call(self, tool_name: str, tool_args: dict | None = None) -> None:
|
|
235
|
-
|
|
236
|
-
self._formatter.tool_call(tool_name, tool_args)
|
|
237
|
-
|
|
238
|
-
def print_tool_result(
|
|
239
|
-
self, tool_name: str, result: str, error: bool = False
|
|
240
|
-
) -> None:
|
|
241
|
-
"""Print a tool result."""
|
|
242
|
-
self._formatter.tool_result(tool_name, result, error)
|
|
599
|
+
self._app.print_tool_call(tool_name, tool_args)
|
|
243
600
|
|
|
244
|
-
def
|
|
245
|
-
|
|
601
|
+
def print_tool_result(self, tool_name, result, error=False):
|
|
602
|
+
self._app.print_tool_result(tool_name, result, error)
|
|
246
603
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
"""
|
|
250
|
-
if status.value == "tool_call" and tool_call_info is not None:
|
|
251
|
-
self.print_tool_call(
|
|
252
|
-
tool_call_info.tool_name,
|
|
253
|
-
tool_call_info.tool_args,
|
|
254
|
-
)
|
|
255
|
-
elif status.value == "tool_result" and tool_call_info is not None:
|
|
256
|
-
# Skip - tool results are printed via result_callback
|
|
257
|
-
pass
|
|
258
|
-
elif status.value == "budget_exhausted":
|
|
259
|
-
self._formatter.status_update(status)
|
|
260
|
-
elif status.value == "idle":
|
|
261
|
-
self._formatter.status_update(status)
|
|
604
|
+
def handle_status(self, status, tool_call_info=None):
|
|
605
|
+
self._app.handle_status(status, tool_call_info)
|
|
262
606
|
|
|
263
|
-
def handle_result(
|
|
264
|
-
self,
|
|
265
|
-
status,
|
|
266
|
-
result_str: str,
|
|
267
|
-
error: bool,
|
|
268
|
-
tool_call: ToolCall | None = None,
|
|
269
|
-
) -> None:
|
|
270
|
-
"""Handle tool result callback.
|
|
271
|
-
Tool results are NOT displayed - only tool calls are shown via status_callback.
|
|
272
|
-
"""
|
|
273
|
-
pass # Silence tool results - user only needs to see tool being called
|
|
607
|
+
def handle_result(self, status, result_str, error, tool_call=None):
|
|
608
|
+
self._app.handle_result(status, result_str, error, tool_call)
|
|
274
609
|
|
|
275
610
|
def print_text_chunk(self, chunk: str) -> None:
|
|
276
|
-
|
|
277
|
-
sys.stdout.write(chunk)
|
|
278
|
-
sys.stdout.flush()
|
|
611
|
+
self._app.print_text_chunk(chunk)
|
|
279
612
|
|
|
280
613
|
def get_user_input(self) -> str | None:
|
|
281
|
-
|
|
282
|
-
try:
|
|
283
|
-
msg = click.prompt("YOU", prompt_suffix="> ", show_default=False)
|
|
284
|
-
except EOFError:
|
|
285
|
-
return None
|
|
286
|
-
return msg
|
|
614
|
+
return None
|
|
287
615
|
|
|
288
616
|
def print_exit(self) -> None:
|
|
289
|
-
|
|
290
|
-
self._formatter.info("Goodbye!")
|
|
617
|
+
self._app.print_exit()
|
|
291
618
|
|
|
292
619
|
def print_round_complete(self) -> None:
|
|
293
|
-
|
|
294
|
-
if self._formatter._force_color:
|
|
295
|
-
self._stream.write(f"{self._formatter.colored(_DIM, '─' * 72)}\n")
|
|
296
|
-
else:
|
|
297
|
-
self._stream.write(f"{'─' * 72}\n")
|
|
298
|
-
self._stream.flush()
|
|
620
|
+
self._app.print_round_complete()
|
|
299
621
|
|
|
300
622
|
def print_interrupted(self) -> None:
|
|
301
|
-
|
|
302
|
-
self._formatter.info("Interrupted by user. Goodbye!")
|
|
623
|
+
self._app.print_interrupted()
|
|
303
624
|
|
|
304
625
|
def start_streaming(self) -> None:
|
|
305
|
-
|
|
306
|
-
self._streaming = True
|
|
307
|
-
self._formatter.streaming_indicator()
|
|
308
|
-
self._stream.flush()
|
|
626
|
+
self._app.start_streaming()
|
|
309
627
|
|
|
310
628
|
def end_streaming(self) -> None:
|
|
311
|
-
|
|
312
|
-
self._streaming = False
|
|
313
|
-
self._stream.write("\n")
|
|
314
|
-
self._stream.flush()
|
|
629
|
+
self._app.end_streaming()
|
|
315
630
|
|
|
316
631
|
def print_prompt(self) -> None:
|
|
317
|
-
|
|
318
|
-
if self._formatter._force_color:
|
|
319
|
-
you_label = self._formatter.colored(_GREEN + _BOLD, "YOU")
|
|
320
|
-
self._stream.write(f" {you_label} ")
|
|
321
|
-
else:
|
|
322
|
-
self._stream.write(" YOU ")
|
|
323
|
-
self._stream.flush()
|
|
632
|
+
self._app.print_prompt()
|
|
324
633
|
|
|
325
634
|
def clear(self) -> None:
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
635
|
+
self._app.clear()
|
|
636
|
+
|
|
637
|
+
def set_context_progress(self, current: int, total: int) -> None:
|
|
638
|
+
self._app.set_context_progress(current, total)
|
|
639
|
+
|
|
640
|
+
def queue_message(self, message: str) -> None:
|
|
641
|
+
self._app.queue_message(message)
|
|
642
|
+
|
|
643
|
+
def get_queued_messages(self) -> list[str]:
|
|
644
|
+
return self._app.get_queued_messages()
|
|
645
|
+
|
|
646
|
+
def is_queue_empty(self) -> bool:
|
|
647
|
+
return self._app.is_queue_empty()
|
|
648
|
+
|
|
649
|
+
def set_agent_running(self, running: bool) -> None:
|
|
650
|
+
self._app.set_agent_running(running)
|
|
651
|
+
|
|
652
|
+
def start_input_thread(self) -> None:
|
|
653
|
+
self._app.start_input_thread()
|
|
654
|
+
|
|
655
|
+
def stop_input_thread(self) -> None:
|
|
656
|
+
self._app.stop_input_thread()
|
|
657
|
+
|
|
658
|
+
def run(self) -> None:
|
|
659
|
+
"""Run the Textual app."""
|
|
660
|
+
self._app.run()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: regcode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Minimalistic coding agent with Python API and CLI
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Requires-Dist: click>=8.0
|
|
@@ -10,6 +10,7 @@ Requires-Dist: pydantic-settings>=2.14.2
|
|
|
10
10
|
Requires-Dist: pytest>=8.0
|
|
11
11
|
Requires-Dist: pyyaml>=6.0
|
|
12
12
|
Requires-Dist: ruff>=0.8
|
|
13
|
+
Requires-Dist: textual>=8.2.7
|
|
13
14
|
Provides-Extra: dev
|
|
14
15
|
Description-Content-Type: text/markdown
|
|
15
16
|
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
regcode/__init__.py,sha256=2Cv_d6ciJzl49HiCT7_xpCw0efN5gtoYM7_MPnYZRE8,168
|
|
2
|
-
regcode/cli.py,sha256=
|
|
2
|
+
regcode/cli.py,sha256=065RqyRFJSbiqpFae1fvy84ngto6KZw4tVksnypg6FA,4637
|
|
3
3
|
regcode/config.py,sha256=c640TNa4-6AU0AK1iCPc9ESNacrTT5MG1w_zYwdzLDY,5583
|
|
4
4
|
regcode/conversation_manager.py,sha256=7b1hB7881ydPkCvePJIZ-HgtUi95FglJ7MyFAYw2H1o,5636
|
|
5
|
-
regcode/main.py,sha256=
|
|
5
|
+
regcode/main.py,sha256=6MOYRZNnFuqE85TmxeqfwJgFweU9ISD3ZVLzYTdDPXU,40084
|
|
6
6
|
regcode/monty_sandbox.py,sha256=UxTFmczQr5F4nlDe1gJ5Ul6GX_5KJkVYekVia-JKKvw,12930
|
|
7
7
|
regcode/permissions.py,sha256=9R1dz6ypxBq_tNj-l4ixq_KJ-kAbXHarOey5EmnZzgU,784
|
|
8
8
|
regcode/sandbox.py,sha256=v7oyl4PZVs4uMGGJRQC7YdQNLf7pbtRpVMXuNfnslJI,11414
|
|
9
|
-
regcode/tui.py,sha256=
|
|
10
|
-
regcode/tools/__init__.py,sha256=
|
|
9
|
+
regcode/tui.py,sha256=RHVnmsaE7f32mvDmbKAo7YbBVBWRZ6Zm0ENihc0Pcgk,22949
|
|
10
|
+
regcode/tools/__init__.py,sha256=GQmpdlMT_xw3sKkK5hNKfB2IBQLUNAurV4HN6QWJRT0,348
|
|
11
11
|
regcode/tools/base.py,sha256=D4oalxZb-cKvCMFeghyKSoLy_M7LUVPMeFTUPmrcuyY,3538
|
|
12
|
-
regcode/tools/builtins.py,sha256
|
|
12
|
+
regcode/tools/builtins.py,sha256=-Q4OIW6WpnbdT6CxTi0rn98xxfKEZK3wsP4lzUgTASY,30920
|
|
13
13
|
regcode/tools/registry.py,sha256=jrGtKLlRPgfFQFGlCAaxEfoR0ZXForQUmZexM5-HnEo,2638
|
|
14
14
|
regcode/tools/review_notes.py,sha256=zdbKTKv2hlnfd7Q-F0xlg8n2IHt7h0KssS0OyyY2gcM,3743
|
|
15
|
-
regcode-0.
|
|
16
|
-
regcode-0.
|
|
17
|
-
regcode-0.
|
|
18
|
-
regcode-0.
|
|
15
|
+
regcode-0.2.0.dist-info/METADATA,sha256=HCCJK8N1Bgrzp_biDrh2vGO4jus_tlmSdBq9R63LsLc,5069
|
|
16
|
+
regcode-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
17
|
+
regcode-0.2.0.dist-info/entry_points.txt,sha256=DzSdoP-DefYTNnnuwctq7neSx19XjfB78Jzk-c1UcsQ,44
|
|
18
|
+
regcode-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|