aloop 0.1.1__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.
- agent/__init__.py +0 -0
- agent/agent.py +182 -0
- agent/base.py +406 -0
- agent/context.py +126 -0
- agent/prompts/__init__.py +1 -0
- agent/todo.py +149 -0
- agent/tool_executor.py +54 -0
- agent/verification.py +135 -0
- aloop-0.1.1.dist-info/METADATA +252 -0
- aloop-0.1.1.dist-info/RECORD +66 -0
- aloop-0.1.1.dist-info/WHEEL +5 -0
- aloop-0.1.1.dist-info/entry_points.txt +2 -0
- aloop-0.1.1.dist-info/licenses/LICENSE +21 -0
- aloop-0.1.1.dist-info/top_level.txt +9 -0
- cli.py +19 -0
- config.py +146 -0
- interactive.py +865 -0
- llm/__init__.py +51 -0
- llm/base.py +26 -0
- llm/compat.py +226 -0
- llm/content_utils.py +309 -0
- llm/litellm_adapter.py +450 -0
- llm/message_types.py +245 -0
- llm/model_manager.py +265 -0
- llm/retry.py +95 -0
- main.py +246 -0
- memory/__init__.py +20 -0
- memory/compressor.py +554 -0
- memory/manager.py +538 -0
- memory/serialization.py +82 -0
- memory/short_term.py +88 -0
- memory/store/__init__.py +6 -0
- memory/store/memory_store.py +100 -0
- memory/store/yaml_file_memory_store.py +414 -0
- memory/token_tracker.py +203 -0
- memory/types.py +51 -0
- tools/__init__.py +6 -0
- tools/advanced_file_ops.py +557 -0
- tools/base.py +51 -0
- tools/calculator.py +50 -0
- tools/code_navigator.py +975 -0
- tools/explore.py +254 -0
- tools/file_ops.py +150 -0
- tools/git_tools.py +791 -0
- tools/notify.py +69 -0
- tools/parallel_execute.py +420 -0
- tools/session_manager.py +205 -0
- tools/shell.py +147 -0
- tools/shell_background.py +470 -0
- tools/smart_edit.py +491 -0
- tools/todo.py +130 -0
- tools/web_fetch.py +673 -0
- tools/web_search.py +61 -0
- utils/__init__.py +15 -0
- utils/logger.py +105 -0
- utils/model_pricing.py +49 -0
- utils/runtime.py +75 -0
- utils/terminal_ui.py +422 -0
- utils/tui/__init__.py +39 -0
- utils/tui/command_registry.py +49 -0
- utils/tui/components.py +306 -0
- utils/tui/input_handler.py +393 -0
- utils/tui/model_ui.py +204 -0
- utils/tui/progress.py +292 -0
- utils/tui/status_bar.py +178 -0
- utils/tui/theme.py +165 -0
utils/tui/model_ui.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""TUI helpers for model selection and configuration editing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Protocol, Sequence
|
|
12
|
+
|
|
13
|
+
import aiofiles.os
|
|
14
|
+
from prompt_toolkit.application import Application
|
|
15
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
16
|
+
from prompt_toolkit.layout import HSplit, Layout, Window
|
|
17
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
18
|
+
from prompt_toolkit.styles import Style
|
|
19
|
+
|
|
20
|
+
from utils.tui.theme import Theme
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Model(Protocol):
|
|
24
|
+
model_id: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _ModelManager(Protocol):
|
|
28
|
+
config_path: str
|
|
29
|
+
|
|
30
|
+
def list_models(self) -> Sequence[_Model]: ...
|
|
31
|
+
|
|
32
|
+
def get_current_model(self) -> _Model | None: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def open_in_editor(path: str) -> tuple[bool, bool]:
|
|
36
|
+
"""Open a file in an editor (best-effort).
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
(opened, waited): waited indicates we blocked until editing likely finished.
|
|
40
|
+
"""
|
|
41
|
+
path = str(Path(path))
|
|
42
|
+
|
|
43
|
+
editor = os.environ.get("EDITOR") or os.environ.get("VISUAL")
|
|
44
|
+
if editor:
|
|
45
|
+
cmd = shlex.split(editor) + [path]
|
|
46
|
+
try:
|
|
47
|
+
proc = await asyncio.create_subprocess_exec(*cmd)
|
|
48
|
+
except FileNotFoundError:
|
|
49
|
+
return False, False
|
|
50
|
+
return (await proc.wait()) == 0, True
|
|
51
|
+
|
|
52
|
+
if shutil.which("vi"):
|
|
53
|
+
proc = await asyncio.create_subprocess_exec("vi", path)
|
|
54
|
+
return (await proc.wait()) == 0, True
|
|
55
|
+
|
|
56
|
+
if shutil.which("code"):
|
|
57
|
+
# Don't use `-w` here; we prefer to return to the TUI after the file is saved
|
|
58
|
+
# (and auto-reloaded), without requiring the user to close the editor tab.
|
|
59
|
+
proc = await asyncio.create_subprocess_exec("code", "--reuse-window", path)
|
|
60
|
+
return (await proc.wait()) == 0, False
|
|
61
|
+
|
|
62
|
+
if sys.platform == "darwin" and shutil.which("open"):
|
|
63
|
+
# `open` returns immediately; we can't reliably wait for editing completion.
|
|
64
|
+
proc = await asyncio.create_subprocess_exec("open", "-t", path)
|
|
65
|
+
return (await proc.wait()) == 0, False
|
|
66
|
+
|
|
67
|
+
if shutil.which("xdg-open"):
|
|
68
|
+
# xdg-open returns immediately; we can't reliably wait for editing completion.
|
|
69
|
+
proc = await asyncio.create_subprocess_exec("xdg-open", path)
|
|
70
|
+
return (await proc.wait()) == 0, False
|
|
71
|
+
|
|
72
|
+
return False, False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def get_mtime(path: str) -> tuple[int, int] | None:
|
|
76
|
+
try:
|
|
77
|
+
stat = await aiofiles.os.stat(path)
|
|
78
|
+
return stat.st_mtime_ns, stat.st_size
|
|
79
|
+
except FileNotFoundError:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def wait_for_file_change(path: str, old_mtime: tuple[int, int] | None) -> None:
|
|
84
|
+
while True:
|
|
85
|
+
new_mtime = await get_mtime(path)
|
|
86
|
+
if old_mtime is None:
|
|
87
|
+
if new_mtime is not None:
|
|
88
|
+
return
|
|
89
|
+
elif new_mtime is not None and new_mtime != old_mtime:
|
|
90
|
+
return
|
|
91
|
+
await asyncio.sleep(0.25)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def open_config_and_wait_for_save(config_path: str) -> bool:
|
|
95
|
+
"""Open config file and return when it is likely saved at least once."""
|
|
96
|
+
before = await get_mtime(config_path)
|
|
97
|
+
ok, waited = await open_in_editor(config_path)
|
|
98
|
+
if not ok:
|
|
99
|
+
return False
|
|
100
|
+
if not waited:
|
|
101
|
+
await wait_for_file_change(config_path, before)
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def pick_model_id(model_manager: _ModelManager, title: str) -> str | None:
|
|
106
|
+
"""Pick a model_id using a keyboard-only list (Codex-style)."""
|
|
107
|
+
models = list(model_manager.list_models())
|
|
108
|
+
if not models:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
colors = Theme.get_colors()
|
|
112
|
+
current = model_manager.get_current_model()
|
|
113
|
+
current_id = current.model_id if current else None
|
|
114
|
+
|
|
115
|
+
selected_index = 0
|
|
116
|
+
if current_id:
|
|
117
|
+
for i, m in enumerate(models):
|
|
118
|
+
if m.model_id == current_id:
|
|
119
|
+
selected_index = i
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
kb = KeyBindings()
|
|
123
|
+
|
|
124
|
+
@kb.add("up")
|
|
125
|
+
@kb.add("k")
|
|
126
|
+
def _up(event) -> None:
|
|
127
|
+
nonlocal selected_index
|
|
128
|
+
selected_index = (selected_index - 1) % len(models)
|
|
129
|
+
|
|
130
|
+
@kb.add("down")
|
|
131
|
+
@kb.add("j")
|
|
132
|
+
def _down(event) -> None:
|
|
133
|
+
nonlocal selected_index
|
|
134
|
+
selected_index = (selected_index + 1) % len(models)
|
|
135
|
+
|
|
136
|
+
@kb.add("enter")
|
|
137
|
+
def _enter(event) -> None:
|
|
138
|
+
event.app.exit(result=models[selected_index].model_id)
|
|
139
|
+
|
|
140
|
+
@kb.add("escape")
|
|
141
|
+
@kb.add("c-c")
|
|
142
|
+
def _cancel(event) -> None:
|
|
143
|
+
event.app.exit(result=None)
|
|
144
|
+
|
|
145
|
+
def _render() -> list[tuple[str, str]]:
|
|
146
|
+
lines: list[tuple[str, str]] = []
|
|
147
|
+
lines.append(("class:title", f"{title}\n"))
|
|
148
|
+
lines.append(("class:hint", "Use ↑/↓ and Enter to select, Esc to cancel.\n\n"))
|
|
149
|
+
|
|
150
|
+
for idx, m in enumerate(models, start=1):
|
|
151
|
+
is_selected = (idx - 1) == selected_index
|
|
152
|
+
is_current = m.model_id == current_id
|
|
153
|
+
|
|
154
|
+
prefix = "› " if is_selected else " "
|
|
155
|
+
marker = "(current) " if is_current else ""
|
|
156
|
+
text = f"{prefix}{idx}. {marker}{m.model_id}\n"
|
|
157
|
+
style = "class:selected" if is_selected else "class:item"
|
|
158
|
+
lines.append((style, text))
|
|
159
|
+
|
|
160
|
+
return lines
|
|
161
|
+
|
|
162
|
+
control = FormattedTextControl(_render, focusable=True)
|
|
163
|
+
window = Window(content=control, dont_extend_height=True, always_hide_cursor=True)
|
|
164
|
+
layout = Layout(HSplit([window]))
|
|
165
|
+
|
|
166
|
+
style_dict = Theme.get_prompt_toolkit_style()
|
|
167
|
+
style_dict.update(
|
|
168
|
+
{
|
|
169
|
+
"title": f"{colors.primary} bold",
|
|
170
|
+
"hint": colors.text_muted,
|
|
171
|
+
"item": colors.text_primary,
|
|
172
|
+
"selected": f"bg:{colors.primary} {colors.bg_primary}",
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
app = Application(
|
|
177
|
+
layout=layout,
|
|
178
|
+
key_bindings=kb,
|
|
179
|
+
style=Style.from_dict(style_dict),
|
|
180
|
+
full_screen=False,
|
|
181
|
+
mouse_support=False,
|
|
182
|
+
)
|
|
183
|
+
return await app.run_async()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def mask_secret(value: str | None) -> str:
|
|
187
|
+
if not value:
|
|
188
|
+
return "(not set)"
|
|
189
|
+
v = value.strip()
|
|
190
|
+
if len(v) <= 8:
|
|
191
|
+
return "*" * len(v)
|
|
192
|
+
return f"{v[:4]}…{v[-4:]}"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def parse_kv_args(tokens: list[str]) -> tuple[dict[str, str], list[str]]:
|
|
196
|
+
kv: dict[str, str] = {}
|
|
197
|
+
rest: list[str] = []
|
|
198
|
+
for token in tokens:
|
|
199
|
+
if "=" in token:
|
|
200
|
+
k, _, v = token.partition("=")
|
|
201
|
+
kv[k.strip()] = v
|
|
202
|
+
else:
|
|
203
|
+
rest.append(token)
|
|
204
|
+
return kv, rest
|
utils/tui/progress.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Progress indicators and spinners for the TUI."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import Generator, Optional
|
|
6
|
+
|
|
7
|
+
from rich import box
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.live import Live
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.spinner import Spinner as RichSpinner
|
|
12
|
+
|
|
13
|
+
from utils.tui.theme import Theme
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Spinner:
|
|
17
|
+
"""Animated spinner with context information."""
|
|
18
|
+
|
|
19
|
+
# Spinner animation frames
|
|
20
|
+
FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
console: Console,
|
|
25
|
+
message: str = "Processing...",
|
|
26
|
+
show_duration: bool = True,
|
|
27
|
+
):
|
|
28
|
+
"""Initialize spinner.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
console: Rich console instance
|
|
32
|
+
message: Message to display with spinner
|
|
33
|
+
show_duration: Whether to show elapsed time
|
|
34
|
+
"""
|
|
35
|
+
self.console = console
|
|
36
|
+
self.message = message
|
|
37
|
+
self.show_duration = show_duration
|
|
38
|
+
self._start_time: Optional[float] = None
|
|
39
|
+
self._live: Optional[Live] = None
|
|
40
|
+
|
|
41
|
+
def _render(self) -> Panel:
|
|
42
|
+
"""Render the spinner panel.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Rich Panel with spinner content
|
|
46
|
+
"""
|
|
47
|
+
colors = Theme.get_colors()
|
|
48
|
+
|
|
49
|
+
# Build spinner text with optional duration
|
|
50
|
+
text = f" {self.message}"
|
|
51
|
+
if self.show_duration and self._start_time is not None:
|
|
52
|
+
elapsed = time.time() - self._start_time
|
|
53
|
+
text += f"\n └─ Duration: {elapsed:.1f}s"
|
|
54
|
+
|
|
55
|
+
# Create spinner with message
|
|
56
|
+
spinner = RichSpinner("dots", text=text, style=colors.primary)
|
|
57
|
+
|
|
58
|
+
return Panel(
|
|
59
|
+
spinner,
|
|
60
|
+
title=f"[{colors.thinking_accent}]Thinking[/{colors.thinking_accent}]",
|
|
61
|
+
title_align="left",
|
|
62
|
+
border_style=colors.text_muted,
|
|
63
|
+
box=box.ROUNDED,
|
|
64
|
+
padding=(0, 1),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@contextmanager
|
|
68
|
+
def __call__(self, message: Optional[str] = None) -> Generator[None, None, None]:
|
|
69
|
+
"""Context manager for spinner display.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
message: Optional message override
|
|
73
|
+
|
|
74
|
+
Yields:
|
|
75
|
+
None
|
|
76
|
+
"""
|
|
77
|
+
if message:
|
|
78
|
+
self.message = message
|
|
79
|
+
|
|
80
|
+
self._start_time = time.time()
|
|
81
|
+
self._live = Live(
|
|
82
|
+
self._render(),
|
|
83
|
+
console=self.console,
|
|
84
|
+
refresh_per_second=10,
|
|
85
|
+
transient=True,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
with self._live:
|
|
90
|
+
yield
|
|
91
|
+
finally:
|
|
92
|
+
self._start_time = None
|
|
93
|
+
self._live = None
|
|
94
|
+
|
|
95
|
+
def update_message(self, message: str) -> None:
|
|
96
|
+
"""Update the spinner message.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
message: New message to display
|
|
100
|
+
"""
|
|
101
|
+
self.message = message
|
|
102
|
+
if self._live is not None:
|
|
103
|
+
self._live.update(self._render())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ProgressContext:
|
|
107
|
+
"""Context manager for showing progress during long operations."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
console: Console,
|
|
112
|
+
title: str = "Processing",
|
|
113
|
+
show_steps: bool = True,
|
|
114
|
+
):
|
|
115
|
+
"""Initialize progress context.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
console: Rich console instance
|
|
119
|
+
title: Title for the progress display
|
|
120
|
+
show_steps: Whether to show step count
|
|
121
|
+
"""
|
|
122
|
+
self.console = console
|
|
123
|
+
self.title = title
|
|
124
|
+
self.show_steps = show_steps
|
|
125
|
+
self._current_step = 0
|
|
126
|
+
self._total_steps = 0
|
|
127
|
+
self._current_message = ""
|
|
128
|
+
self._start_time: Optional[float] = None
|
|
129
|
+
self._live: Optional[Live] = None
|
|
130
|
+
|
|
131
|
+
def _render(self) -> Panel:
|
|
132
|
+
"""Render the progress panel.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Rich Panel with progress content
|
|
136
|
+
"""
|
|
137
|
+
colors = Theme.get_colors()
|
|
138
|
+
|
|
139
|
+
lines = []
|
|
140
|
+
|
|
141
|
+
# Current message
|
|
142
|
+
lines.append(f" {self._current_message}")
|
|
143
|
+
|
|
144
|
+
# Step count
|
|
145
|
+
if self.show_steps and self._total_steps > 0:
|
|
146
|
+
lines.append(f" Step {self._current_step}/{self._total_steps}")
|
|
147
|
+
|
|
148
|
+
# Duration
|
|
149
|
+
if self._start_time is not None:
|
|
150
|
+
elapsed = time.time() - self._start_time
|
|
151
|
+
lines.append(f" [dim]Duration: {elapsed:.1f}s[/dim]")
|
|
152
|
+
|
|
153
|
+
content = "\n".join(lines)
|
|
154
|
+
|
|
155
|
+
return Panel(
|
|
156
|
+
content,
|
|
157
|
+
title=f"[{colors.primary}]{self.title}[/{colors.primary}]",
|
|
158
|
+
title_align="left",
|
|
159
|
+
border_style=colors.text_muted,
|
|
160
|
+
box=box.ROUNDED,
|
|
161
|
+
padding=(0, 1),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def set_total_steps(self, total: int) -> None:
|
|
165
|
+
"""Set the total number of steps.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
total: Total number of steps
|
|
169
|
+
"""
|
|
170
|
+
self._total_steps = total
|
|
171
|
+
|
|
172
|
+
def advance(self, message: str) -> None:
|
|
173
|
+
"""Advance to the next step.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
message: Message for this step
|
|
177
|
+
"""
|
|
178
|
+
self._current_step += 1
|
|
179
|
+
self._current_message = message
|
|
180
|
+
if self._live is not None:
|
|
181
|
+
self._live.update(self._render())
|
|
182
|
+
|
|
183
|
+
def update_message(self, message: str) -> None:
|
|
184
|
+
"""Update the current message without advancing.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
message: New message
|
|
188
|
+
"""
|
|
189
|
+
self._current_message = message
|
|
190
|
+
if self._live is not None:
|
|
191
|
+
self._live.update(self._render())
|
|
192
|
+
|
|
193
|
+
@contextmanager
|
|
194
|
+
def __call__(
|
|
195
|
+
self, message: str = "Starting...", total_steps: int = 0
|
|
196
|
+
) -> Generator["ProgressContext", None, None]:
|
|
197
|
+
"""Context manager for progress display.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
message: Initial message
|
|
201
|
+
total_steps: Total number of steps (0 for indeterminate)
|
|
202
|
+
|
|
203
|
+
Yields:
|
|
204
|
+
Self for updating progress
|
|
205
|
+
"""
|
|
206
|
+
self._current_message = message
|
|
207
|
+
self._total_steps = total_steps
|
|
208
|
+
self._current_step = 0
|
|
209
|
+
self._start_time = time.time()
|
|
210
|
+
|
|
211
|
+
self._live = Live(
|
|
212
|
+
self._render(),
|
|
213
|
+
console=self.console,
|
|
214
|
+
refresh_per_second=4,
|
|
215
|
+
transient=True,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
with self._live:
|
|
220
|
+
yield self
|
|
221
|
+
finally:
|
|
222
|
+
self._start_time = None
|
|
223
|
+
self._live = None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class AsyncSpinner:
|
|
227
|
+
"""Async-compatible spinner for use in async contexts."""
|
|
228
|
+
|
|
229
|
+
def __init__(
|
|
230
|
+
self,
|
|
231
|
+
console: Console,
|
|
232
|
+
message: str = "Processing...",
|
|
233
|
+
):
|
|
234
|
+
"""Initialize async spinner.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
console: Rich console instance
|
|
238
|
+
message: Message to display
|
|
239
|
+
"""
|
|
240
|
+
self.console = console
|
|
241
|
+
self.message = message
|
|
242
|
+
self._start_time: Optional[float] = None
|
|
243
|
+
self._live: Optional[Live] = None
|
|
244
|
+
self._running = False
|
|
245
|
+
|
|
246
|
+
def _render(self) -> Panel:
|
|
247
|
+
"""Render the spinner panel."""
|
|
248
|
+
colors = Theme.get_colors()
|
|
249
|
+
|
|
250
|
+
spinner = RichSpinner("dots", text=f" {self.message}", style=colors.primary)
|
|
251
|
+
|
|
252
|
+
return Panel(
|
|
253
|
+
spinner,
|
|
254
|
+
title=f"[{colors.thinking_accent}]Thinking[/{colors.thinking_accent}]",
|
|
255
|
+
title_align="left",
|
|
256
|
+
border_style=colors.text_muted,
|
|
257
|
+
box=box.ROUNDED,
|
|
258
|
+
padding=(0, 1),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
async def __aenter__(self) -> "AsyncSpinner":
|
|
262
|
+
"""Async context manager entry."""
|
|
263
|
+
if self.console.quiet:
|
|
264
|
+
return self
|
|
265
|
+
self._start_time = time.time()
|
|
266
|
+
self._running = True
|
|
267
|
+
self._live = Live(
|
|
268
|
+
self._render(),
|
|
269
|
+
console=self.console,
|
|
270
|
+
refresh_per_second=10,
|
|
271
|
+
transient=True,
|
|
272
|
+
)
|
|
273
|
+
self._live.start()
|
|
274
|
+
return self
|
|
275
|
+
|
|
276
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
277
|
+
"""Async context manager exit."""
|
|
278
|
+
self._running = False
|
|
279
|
+
if self._live is not None:
|
|
280
|
+
self._live.stop()
|
|
281
|
+
self._live = None
|
|
282
|
+
self._start_time = None
|
|
283
|
+
|
|
284
|
+
def update_message(self, message: str) -> None:
|
|
285
|
+
"""Update the spinner message.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
message: New message
|
|
289
|
+
"""
|
|
290
|
+
self.message = message
|
|
291
|
+
if self._live is not None and self._running:
|
|
292
|
+
self._live.update(self._render())
|
utils/tui/status_bar.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Persistent status bar for the TUI."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from rich import box
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.live import Live
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from utils.tui.theme import Theme
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class StatusBarState:
|
|
17
|
+
"""State for the status bar."""
|
|
18
|
+
|
|
19
|
+
mode: str = "REACT"
|
|
20
|
+
input_tokens: int = 0
|
|
21
|
+
output_tokens: int = 0
|
|
22
|
+
context_tokens: int = 0
|
|
23
|
+
cost: float = 0.0
|
|
24
|
+
is_processing: bool = False
|
|
25
|
+
status_message: str = ""
|
|
26
|
+
model_name: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StatusBar:
|
|
30
|
+
"""Persistent status bar displayed at the bottom of the terminal."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, console: Console):
|
|
33
|
+
"""Initialize status bar.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
console: Rich console instance
|
|
37
|
+
"""
|
|
38
|
+
self.console = console
|
|
39
|
+
self.state = StatusBarState()
|
|
40
|
+
self._live: Optional[Live] = None
|
|
41
|
+
|
|
42
|
+
def _format_tokens(self, count: int) -> str:
|
|
43
|
+
"""Format token count for display.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
count: Token count
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Formatted string (e.g., "12.5K" or "1.2M")
|
|
50
|
+
"""
|
|
51
|
+
if count >= 1_000_000:
|
|
52
|
+
return f"{count / 1_000_000:.1f}M"
|
|
53
|
+
elif count >= 1_000:
|
|
54
|
+
return f"{count / 1_000:.1f}K"
|
|
55
|
+
else:
|
|
56
|
+
return str(count)
|
|
57
|
+
|
|
58
|
+
def _render(self) -> Panel:
|
|
59
|
+
"""Render the status bar panel.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Rich Panel with status bar content
|
|
63
|
+
"""
|
|
64
|
+
colors = Theme.get_colors()
|
|
65
|
+
|
|
66
|
+
# Build status items
|
|
67
|
+
items = []
|
|
68
|
+
|
|
69
|
+
# Model name (if set)
|
|
70
|
+
if self.state.model_name:
|
|
71
|
+
items.append(
|
|
72
|
+
f"[{colors.text_secondary}]Model:[/{colors.text_secondary}] [{colors.primary}]{self.state.model_name}[/{colors.primary}]"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Mode
|
|
76
|
+
items.append(
|
|
77
|
+
f"[{colors.text_secondary}]Mode:[/{colors.text_secondary}] [{colors.primary}]{self.state.mode}[/{colors.primary}]"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Total Tokens (in/out)
|
|
81
|
+
total_in = self._format_tokens(self.state.input_tokens)
|
|
82
|
+
total_out = self._format_tokens(self.state.output_tokens)
|
|
83
|
+
items.append(
|
|
84
|
+
f"[{colors.text_secondary}]Total:[/{colors.text_secondary}] {total_in}↓ {total_out}↑"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Context Tokens
|
|
88
|
+
ctx_tokens = self._format_tokens(self.state.context_tokens)
|
|
89
|
+
items.append(f"[{colors.text_secondary}]Context:[/{colors.text_secondary}] {ctx_tokens}")
|
|
90
|
+
|
|
91
|
+
# Cost
|
|
92
|
+
items.append(
|
|
93
|
+
f"[{colors.text_secondary}]Cost:[/{colors.text_secondary}] ${self.state.cost:.4f}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Processing indicator
|
|
97
|
+
if self.state.is_processing:
|
|
98
|
+
items.append(f"[{colors.warning}]●[/{colors.warning}]")
|
|
99
|
+
else:
|
|
100
|
+
items.append(f"[{colors.success}]◉[/{colors.success}]")
|
|
101
|
+
|
|
102
|
+
# Join with separator
|
|
103
|
+
content = " │ ".join(items)
|
|
104
|
+
|
|
105
|
+
return Panel(
|
|
106
|
+
Text.from_markup(content),
|
|
107
|
+
box=box.DOUBLE,
|
|
108
|
+
border_style=colors.text_muted,
|
|
109
|
+
padding=(0, 1),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def update(
|
|
113
|
+
self,
|
|
114
|
+
mode: Optional[str] = None,
|
|
115
|
+
input_tokens: Optional[int] = None,
|
|
116
|
+
output_tokens: Optional[int] = None,
|
|
117
|
+
context_tokens: Optional[int] = None,
|
|
118
|
+
cost: Optional[float] = None,
|
|
119
|
+
is_processing: Optional[bool] = None,
|
|
120
|
+
status_message: Optional[str] = None,
|
|
121
|
+
model_name: Optional[str] = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Update status bar state.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
mode: Agent mode (REACT, PLAN, etc.)
|
|
127
|
+
input_tokens: Total input tokens used
|
|
128
|
+
output_tokens: Total output tokens used
|
|
129
|
+
context_tokens: Current context window tokens
|
|
130
|
+
cost: Current cost
|
|
131
|
+
is_processing: Whether currently processing
|
|
132
|
+
status_message: Optional status message
|
|
133
|
+
model_name: Current model name
|
|
134
|
+
"""
|
|
135
|
+
if mode is not None:
|
|
136
|
+
self.state.mode = mode
|
|
137
|
+
if input_tokens is not None:
|
|
138
|
+
self.state.input_tokens = input_tokens
|
|
139
|
+
if output_tokens is not None:
|
|
140
|
+
self.state.output_tokens = output_tokens
|
|
141
|
+
if context_tokens is not None:
|
|
142
|
+
self.state.context_tokens = context_tokens
|
|
143
|
+
if cost is not None:
|
|
144
|
+
self.state.cost = cost
|
|
145
|
+
if is_processing is not None:
|
|
146
|
+
self.state.is_processing = is_processing
|
|
147
|
+
if status_message is not None:
|
|
148
|
+
self.state.status_message = status_message
|
|
149
|
+
if model_name is not None:
|
|
150
|
+
self.state.model_name = model_name
|
|
151
|
+
|
|
152
|
+
# Refresh live display if active
|
|
153
|
+
if self._live is not None:
|
|
154
|
+
self._live.update(self._render())
|
|
155
|
+
|
|
156
|
+
def show(self) -> None:
|
|
157
|
+
"""Display the status bar (non-live version)."""
|
|
158
|
+
self.console.print(self._render())
|
|
159
|
+
|
|
160
|
+
def start_live(self) -> Live:
|
|
161
|
+
"""Start live updating status bar.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Live context manager
|
|
165
|
+
"""
|
|
166
|
+
self._live = Live(
|
|
167
|
+
self._render(),
|
|
168
|
+
console=self.console,
|
|
169
|
+
refresh_per_second=4,
|
|
170
|
+
transient=True,
|
|
171
|
+
)
|
|
172
|
+
return self._live
|
|
173
|
+
|
|
174
|
+
def stop_live(self) -> None:
|
|
175
|
+
"""Stop live updating."""
|
|
176
|
+
if self._live is not None:
|
|
177
|
+
self._live.stop()
|
|
178
|
+
self._live = None
|