@agxnte/reidx 2.0.2
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.
- package/README.md +724 -0
- package/bin/reidx.js +33 -0
- package/package.json +40 -0
- package/pyproject.toml +45 -0
- package/scripts/find-python.js +24 -0
- package/scripts/postinstall.js +35 -0
- package/src/reidx/__init__.py +3 -0
- package/src/reidx/__main__.py +9 -0
- package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/app/__init__.py +3 -0
- package/src/reidx/app/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/app/__pycache__/commands.cpython-312.pyc +0 -0
- package/src/reidx/app/commands.py +221 -0
- package/src/reidx/automation/__init__.py +3 -0
- package/src/reidx/automation/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/automation/__pycache__/exec.cpython-312.pyc +0 -0
- package/src/reidx/automation/exec.py +35 -0
- package/src/reidx/config/__init__.py +10 -0
- package/src/reidx/config/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/loader.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/config/__pycache__/settings.cpython-312.pyc +0 -0
- package/src/reidx/config/loader.py +104 -0
- package/src/reidx/config/models.py +49 -0
- package/src/reidx/config/settings.py +127 -0
- package/src/reidx/deepreid/__init__.py +8 -0
- package/src/reidx/deepreid/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/deepreid/__pycache__/pipeline.cpython-312.pyc +0 -0
- package/src/reidx/deepreid/pipeline.py +221 -0
- package/src/reidx/diagnostics/__init__.py +0 -0
- package/src/reidx/diagnostics/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/diagnostics/__pycache__/logger.cpython-312.pyc +0 -0
- package/src/reidx/diagnostics/logger.py +56 -0
- package/src/reidx/goals/__init__.py +21 -0
- package/src/reidx/goals/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/goals/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/goals/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/goals/models.py +101 -0
- package/src/reidx/goals/store.py +233 -0
- package/src/reidx/integrations/__init__.py +3 -0
- package/src/reidx/integrations/mcp.py +70 -0
- package/src/reidx/nyx/__init__.py +3 -0
- package/src/reidx/nyx/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/nyx/__pycache__/persona.cpython-312.pyc +0 -0
- package/src/reidx/nyx/persona.py +35 -0
- package/src/reidx/policy/__init__.py +15 -0
- package/src/reidx/policy/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/policy/__pycache__/engine.cpython-312.pyc +0 -0
- package/src/reidx/policy/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/policy/engine.py +101 -0
- package/src/reidx/policy/models.py +35 -0
- package/src/reidx/provider/__init__.py +16 -0
- package/src/reidx/provider/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/base.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/ollama.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/openai.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/registry.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/provider/__pycache__/stub.cpython-312.pyc +0 -0
- package/src/reidx/provider/_http.py +29 -0
- package/src/reidx/provider/anthropic.py +176 -0
- package/src/reidx/provider/base.py +50 -0
- package/src/reidx/provider/ollama.py +109 -0
- package/src/reidx/provider/openai.py +144 -0
- package/src/reidx/provider/registry.py +88 -0
- package/src/reidx/provider/store.py +141 -0
- package/src/reidx/provider/stub.py +60 -0
- package/src/reidx/provider_manager/__init__.py +40 -0
- package/src/reidx/provider_manager/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/catalog.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/database.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/keychain.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/__pycache__/palette.cpython-312.pyc +0 -0
- package/src/reidx/provider_manager/catalog.py +330 -0
- package/src/reidx/provider_manager/database.py +234 -0
- package/src/reidx/provider_manager/keychain.py +102 -0
- package/src/reidx/provider_manager/palette.py +831 -0
- package/src/reidx/runtime/__init__.py +5 -0
- package/src/reidx/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/agent.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/orchestrator.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/reasoning.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/state.cpython-312.pyc +0 -0
- package/src/reidx/runtime/__pycache__/subagent.cpython-312.pyc +0 -0
- package/src/reidx/runtime/agent.py +198 -0
- package/src/reidx/runtime/orchestrator.py +244 -0
- package/src/reidx/runtime/reasoning.py +77 -0
- package/src/reidx/runtime/state.py +32 -0
- package/src/reidx/runtime/subagent.py +174 -0
- package/src/reidx/session/__init__.py +4 -0
- package/src/reidx/session/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/session/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/session/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/session/models.py +43 -0
- package/src/reidx/session/store.py +101 -0
- package/src/reidx/tasks/__init__.py +4 -0
- package/src/reidx/tasks/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/tasks/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/tasks/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/tasks/models.py +44 -0
- package/src/reidx/tasks/store.py +86 -0
- package/src/reidx/tools/__init__.py +44 -0
- package/src/reidx/tools/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/base.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/registry.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/shell_tool.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/spawn_agent.cpython-312.pyc +0 -0
- package/src/reidx/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
- package/src/reidx/tools/base.py +84 -0
- package/src/reidx/tools/file_tools.py +222 -0
- package/src/reidx/tools/registry.py +45 -0
- package/src/reidx/tools/shell_tool.py +75 -0
- package/src/reidx/tools/spawn_agent.py +185 -0
- package/src/reidx/tools/web_tools.py +216 -0
- package/src/reidx/ui/__init__.py +41 -0
- package/src/reidx/ui/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/app.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/render.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/repl.cpython-312.pyc +0 -0
- package/src/reidx/ui/__pycache__/theme.cpython-312.pyc +0 -0
- package/src/reidx/ui/app.py +1343 -0
- package/src/reidx/ui/commands.py +663 -0
- package/src/reidx/ui/render.py +517 -0
- package/src/reidx/ui/repl.py +14 -0
- package/src/reidx/ui/theme.py +126 -0
- package/src/reidx/workflows/__init__.py +4 -0
- package/src/reidx/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/reidx/workflows/__pycache__/models.cpython-312.pyc +0 -0
- package/src/reidx/workflows/__pycache__/store.cpython-312.pyc +0 -0
- package/src/reidx/workflows/models.py +19 -0
- package/src/reidx/workflows/store.py +56 -0
|
@@ -0,0 +1,1343 @@
|
|
|
1
|
+
"""Full-screen chat TUI: a real split-pane layout, not an inline redraw hack.
|
|
2
|
+
|
|
3
|
+
A `prompt_toolkit` full-screen `Application` owns the whole terminal (like
|
|
4
|
+
`vim`/`htop`, alternate screen — native scrollback is untouched and restored
|
|
5
|
+
on exit). Layout: a scrollable output pane on top, and a footer permanently
|
|
6
|
+
pinned to the last rows — spinner row, input box, status line. Because
|
|
7
|
+
`prompt_toolkit` owns the screen entirely, it handles cursor tracking and
|
|
8
|
+
resize itself; nothing fights it (unlike the reverted VT100 scroll-region
|
|
9
|
+
approach, which manually repositioned the cursor behind Rich/prompt_toolkit's
|
|
10
|
+
backs and corrupted rendering).
|
|
11
|
+
|
|
12
|
+
Rendering reuse: rather than reimplementing Rich's markdown/table/panel
|
|
13
|
+
rendering in prompt_toolkit's own formatting, `render.console` (the
|
|
14
|
+
module-level Rich `Console` almost everything in `ui/render.py` and
|
|
15
|
+
`ui/commands.py` already prints through) is temporarily swapped for one
|
|
16
|
+
backed by an in-memory buffer. Every existing `render.print_*` call and
|
|
17
|
+
`ui.commands.handle` keep working completely unmodified; their ANSI output is
|
|
18
|
+
drained and appended into the output pane's fragment list.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import functools
|
|
24
|
+
import io
|
|
25
|
+
import random
|
|
26
|
+
import shutil
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
|
|
31
|
+
from prompt_toolkit.application import Application
|
|
32
|
+
from prompt_toolkit.buffer import Buffer
|
|
33
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
34
|
+
from prompt_toolkit.filters import Condition
|
|
35
|
+
from prompt_toolkit.formatted_text import ANSI, to_formatted_text
|
|
36
|
+
from prompt_toolkit.formatted_text.utils import split_lines
|
|
37
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
38
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
39
|
+
from prompt_toolkit.keys import Keys
|
|
40
|
+
from prompt_toolkit.layout import Float, FloatContainer, HSplit, Layout, VSplit, Window
|
|
41
|
+
from prompt_toolkit.layout.containers import ConditionalContainer
|
|
42
|
+
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
43
|
+
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
44
|
+
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
|
|
45
|
+
from prompt_toolkit.styles import Style
|
|
46
|
+
from rich.console import Console
|
|
47
|
+
from rich.text import Text
|
|
48
|
+
|
|
49
|
+
from reidx.deepreid import format_markdown, run_deepreid, save_deepreid_result
|
|
50
|
+
from reidx.diagnostics.logger import get_logger
|
|
51
|
+
from reidx.provider_manager import (
|
|
52
|
+
ACCENT,
|
|
53
|
+
BG,
|
|
54
|
+
BORDER,
|
|
55
|
+
ProviderDatabase,
|
|
56
|
+
ProviderPalette,
|
|
57
|
+
)
|
|
58
|
+
from reidx.runtime.orchestrator import Orchestrator
|
|
59
|
+
from reidx.ui import render
|
|
60
|
+
from reidx.ui.commands import (
|
|
61
|
+
_EFFORT_LEVELS,
|
|
62
|
+
ARG_CHOICES,
|
|
63
|
+
GOAL_SUBCOMMANDS,
|
|
64
|
+
SLASH_COMMANDS,
|
|
65
|
+
WORKFLOW_SUBCOMMANDS,
|
|
66
|
+
)
|
|
67
|
+
from reidx.ui.commands import handle as handle_command
|
|
68
|
+
from reidx.ui.render import _GERUNDS, _STAR_FRAMES, _bullet_grid
|
|
69
|
+
from reidx.ui.theme import (
|
|
70
|
+
APP_NAME,
|
|
71
|
+
BULLET,
|
|
72
|
+
DANGER,
|
|
73
|
+
DIM,
|
|
74
|
+
PRIMARY,
|
|
75
|
+
SPARKLE,
|
|
76
|
+
SUCCESS,
|
|
77
|
+
TREE,
|
|
78
|
+
WARN,
|
|
79
|
+
context_window_for,
|
|
80
|
+
fmt_tokens,
|
|
81
|
+
short_path,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
log = get_logger("reidx.ui")
|
|
85
|
+
|
|
86
|
+
# A paste collapses to a placeholder when it's multi-line (a single-line
|
|
87
|
+
# input box can't display embedded newlines sanely) or long enough to make
|
|
88
|
+
# the box unreadable. Same idea as Claude Code's own input box.
|
|
89
|
+
_PASTE_COLLAPSE_CHARS = 300
|
|
90
|
+
|
|
91
|
+
# Typing one of these at the very start of the box turns it green and routes
|
|
92
|
+
# the submission through the real Researcher->Planner->Critic DeepReid
|
|
93
|
+
# pipeline (deepreid/pipeline.py) instead of a normal turn — the trigger word
|
|
94
|
+
# itself is stripped before the task is handed to the pipeline.
|
|
95
|
+
_DEEPREID_TRIGGERS = ("deepread", "deep read", "deepreid", "deep reid")
|
|
96
|
+
|
|
97
|
+
# Box border/caret color. Normal is a flat color; DeepReid cycles through
|
|
98
|
+
# these shades over time (see _box_color/_deepread_pulse_active) for an
|
|
99
|
+
# actual pulse, not just a static color swap.
|
|
100
|
+
_BOX_COLOR_NORMAL = "#ff5f5f"
|
|
101
|
+
_DEEPREID_PULSE_SHADES = ("#5fd75f", "#7fe77f", "#9ff09f", "#7fe77f")
|
|
102
|
+
|
|
103
|
+
_MODE_COLOR = {
|
|
104
|
+
"strict": "#ff5555",
|
|
105
|
+
"balanced": "#ffd75f",
|
|
106
|
+
"autonomous": "#5fd75f",
|
|
107
|
+
"custom": "#d75fd7",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Themed style for the "/" completion menu + its scrollbar. Without this,
|
|
111
|
+
# prompt_toolkit falls back to its built-in grey/blue menu (which clashes with
|
|
112
|
+
# the red skin and renders black-on-grey descriptions). Classes map to the
|
|
113
|
+
# fragments menus.py emits; output-pane/status fragments carry absolute colors
|
|
114
|
+
# so this global style never touches them.
|
|
115
|
+
_MENU_BG = "#1c1818"
|
|
116
|
+
_MENU_BG_ALT = "#262220"
|
|
117
|
+
_UI_STYLE = Style.from_dict(
|
|
118
|
+
{
|
|
119
|
+
"completion-menu": f"bg:{_MENU_BG} {PRIMARY}",
|
|
120
|
+
"completion-menu.completion": f"bg:{_MENU_BG} #d0d0d0",
|
|
121
|
+
"completion-menu.completion.current": f"bg:{PRIMARY} {_MENU_BG} bold",
|
|
122
|
+
"completion-menu.meta.completion": f"bg:{_MENU_BG_ALT} #8a7a7a",
|
|
123
|
+
"completion-menu.meta.completion.current": f"bg:#d75f5f {_MENU_BG}",
|
|
124
|
+
"scrollbar.background": f"bg:{_MENU_BG_ALT}",
|
|
125
|
+
"scrollbar.button": f"bg:{PRIMARY}",
|
|
126
|
+
"scrollbar.arrow": f"bg:{_MENU_BG} {PRIMARY}",
|
|
127
|
+
"palette-border": f"{ACCENT}",
|
|
128
|
+
"palette-bg": f"bg:{BG}",
|
|
129
|
+
"palette-header": f"bg:#1a1010 bold {ACCENT}",
|
|
130
|
+
"palette-footer": f"bg:{BG} {DIM}",
|
|
131
|
+
"palette-search": f"bg:{BG} #d0d0d0",
|
|
132
|
+
"palette-search-label": f"bg:{BG} {ACCENT}",
|
|
133
|
+
"palette-sep": f"bg:{BG} {BORDER}",
|
|
134
|
+
"dim-overlay": "bg:#080808",
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _ConsoleCapture:
|
|
140
|
+
"""A Rich Console backed by an in-memory buffer, so existing render.py /
|
|
141
|
+
commands.py code keeps writing ANSI-styled output unmodified — it just
|
|
142
|
+
lands in a buffer we drain instead of stdout."""
|
|
143
|
+
|
|
144
|
+
def __init__(self) -> None:
|
|
145
|
+
cols, _rows = shutil.get_terminal_size(fallback=(100, 30))
|
|
146
|
+
self._buf = io.StringIO()
|
|
147
|
+
self.console = Console(
|
|
148
|
+
file=self._buf,
|
|
149
|
+
width=max(40, cols - 2),
|
|
150
|
+
force_terminal=True,
|
|
151
|
+
color_system="truecolor",
|
|
152
|
+
highlight=False,
|
|
153
|
+
soft_wrap=False,
|
|
154
|
+
)
|
|
155
|
+
self._pos = 0
|
|
156
|
+
|
|
157
|
+
def drain(self) -> str:
|
|
158
|
+
text = self._buf.getvalue()
|
|
159
|
+
new = text[self._pos :]
|
|
160
|
+
self._pos = len(text)
|
|
161
|
+
return new
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class _Block:
|
|
165
|
+
"""One unit of output pane content.
|
|
166
|
+
|
|
167
|
+
Most blocks are static (fixed fragments). Thinking and tool-call blocks
|
|
168
|
+
are collapsible: two ANSI variants are rendered once (at turn-completion
|
|
169
|
+
time, never replayed) and the pane picks whichever the global Ctrl+O
|
|
170
|
+
toggle currently wants at assembly time — no re-rendering, no re-running
|
|
171
|
+
any side-effecting code (slash commands, etc.) on toggle.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
__slots__ = ("fragments", "collapsed", "expanded")
|
|
175
|
+
|
|
176
|
+
def __init__(self, fragments=None, collapsed=None, expanded=None) -> None: # type: ignore[no-untyped-def]
|
|
177
|
+
self.fragments = fragments
|
|
178
|
+
self.collapsed = collapsed
|
|
179
|
+
self.expanded = expanded
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def is_collapsible(self) -> bool:
|
|
183
|
+
return self.collapsed is not None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class _OutputPane:
|
|
187
|
+
"""Accumulated blocks for the scrollable output window.
|
|
188
|
+
|
|
189
|
+
Tail-to-bottom auto-follow uses prompt_toolkit's documented
|
|
190
|
+
`[SetCursorPosition]` sentinel mechanism (see widgets.base.Label): the
|
|
191
|
+
renderer scrolls to keep whichever fragment carries that marker visible.
|
|
192
|
+
Scrolling manually (PageUp/PageDown, mouse wheel) works by *relocating*
|
|
193
|
+
that marker to the target line rather than fighting the renderer's
|
|
194
|
+
per-frame scroll recomputation — `Window._scroll_up/_down` (used by the
|
|
195
|
+
default page-navigation bindings and the default mouse wheel handler)
|
|
196
|
+
only adjust `vertical_scroll` directly, which gets silently overwritten
|
|
197
|
+
right back by that same per-frame recomputation as long as the marker
|
|
198
|
+
stays fixed at the bottom; relocating the marker is what actually moves
|
|
199
|
+
the view. While `pinned` is True (the default, and whenever scrolled back
|
|
200
|
+
down to the last line) the marker tracks the newest line automatically —
|
|
201
|
+
"locked to bottom" exactly as before. Scrolling up unpins so new output
|
|
202
|
+
appends below the fold without disturbing what's being read.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__(self) -> None:
|
|
206
|
+
self._blocks: list[_Block] = []
|
|
207
|
+
self.expanded = False # global Ctrl+O toggle for collapsible blocks
|
|
208
|
+
self.pinned = True
|
|
209
|
+
self._cursor_line = 0 # only meaningful while not pinned
|
|
210
|
+
|
|
211
|
+
def append_static(self, ansi_text: str) -> None:
|
|
212
|
+
if not ansi_text:
|
|
213
|
+
return
|
|
214
|
+
self._blocks.append(_Block(fragments=to_formatted_text(ANSI(ansi_text))))
|
|
215
|
+
|
|
216
|
+
def append_collapsible(self, collapsed_ansi: str, expanded_ansi: str) -> None:
|
|
217
|
+
self._blocks.append(
|
|
218
|
+
_Block(
|
|
219
|
+
collapsed=to_formatted_text(ANSI(collapsed_ansi)),
|
|
220
|
+
expanded=to_formatted_text(ANSI(expanded_ansi)),
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def toggle_expanded(self) -> None:
|
|
225
|
+
self.expanded = not self.expanded
|
|
226
|
+
|
|
227
|
+
def reset(self) -> None:
|
|
228
|
+
self._blocks = []
|
|
229
|
+
self.pinned = True
|
|
230
|
+
self._cursor_line = 0
|
|
231
|
+
|
|
232
|
+
def _all_fragments(self): # type: ignore[no-untyped-def]
|
|
233
|
+
out: list = []
|
|
234
|
+
for block in self._blocks:
|
|
235
|
+
if block.is_collapsible:
|
|
236
|
+
out.extend(block.expanded if self.expanded else block.collapsed)
|
|
237
|
+
else:
|
|
238
|
+
out.extend(block.fragments)
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
def scroll_up(self, lines: int = 3) -> None:
|
|
242
|
+
total = max(1, len(list(split_lines(self._all_fragments()))))
|
|
243
|
+
base = (total - 1) if self.pinned else self._cursor_line
|
|
244
|
+
self._cursor_line = max(0, base - lines)
|
|
245
|
+
self.pinned = False
|
|
246
|
+
|
|
247
|
+
def scroll_down(self, lines: int = 3) -> None:
|
|
248
|
+
if self.pinned:
|
|
249
|
+
return
|
|
250
|
+
total = max(1, len(list(split_lines(self._all_fragments()))))
|
|
251
|
+
self._cursor_line = min(total - 1, self._cursor_line + lines)
|
|
252
|
+
if self._cursor_line >= total - 1:
|
|
253
|
+
self.pinned = True
|
|
254
|
+
|
|
255
|
+
def scroll_to_top(self) -> None:
|
|
256
|
+
self._cursor_line = 0
|
|
257
|
+
self.pinned = False
|
|
258
|
+
|
|
259
|
+
def scroll_to_bottom(self) -> None:
|
|
260
|
+
self.pinned = True
|
|
261
|
+
|
|
262
|
+
def bottom_line(self, total: int) -> int:
|
|
263
|
+
"""Logical line index that should sit at the bottom of the viewport.
|
|
264
|
+
|
|
265
|
+
Pinned tracks the newest line; otherwise it's the scrolled-to cursor.
|
|
266
|
+
`_OutputWindow` turns this into a concrete (wrap-aware) vertical scroll.
|
|
267
|
+
"""
|
|
268
|
+
if total <= 0:
|
|
269
|
+
return 0
|
|
270
|
+
return (total - 1) if self.pinned else min(self._cursor_line, total - 1)
|
|
271
|
+
|
|
272
|
+
def get_fragments(self): # type: ignore[no-untyped-def]
|
|
273
|
+
# No [SetCursorPosition] marker: `_OutputWindow` computes the vertical
|
|
274
|
+
# scroll directly from `bottom_line`, which gives continuous,
|
|
275
|
+
# bottom-anchored scrolling that offset/cursor tricks can't (the
|
|
276
|
+
# renderer refuses to leave blank space below the last line).
|
|
277
|
+
lines = list(split_lines(self._all_fragments()))
|
|
278
|
+
total = len(lines)
|
|
279
|
+
if total == 0:
|
|
280
|
+
return []
|
|
281
|
+
out: list = []
|
|
282
|
+
for i, line in enumerate(lines):
|
|
283
|
+
out.extend(line)
|
|
284
|
+
if i != total - 1:
|
|
285
|
+
out.append(("", "\n"))
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class _ScrollableOutputControl(FormattedTextControl):
|
|
290
|
+
"""FormattedTextControl that routes mouse wheel scroll to callbacks.
|
|
291
|
+
|
|
292
|
+
The default `Window._mouse_handler` fallback for scroll events just
|
|
293
|
+
nudges `vertical_scroll` by +-1, which is exactly what gets fought and
|
|
294
|
+
reverted by the cursor-follow recomputation described on `_OutputPane`.
|
|
295
|
+
Intercepting here lets scroll wheel drive the same marker-relocation
|
|
296
|
+
logic as the PageUp/PageDown key bindings.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
def __init__(self, get_fragments, on_scroll_up, on_scroll_down, **kwargs) -> None: # type: ignore[no-untyped-def]
|
|
300
|
+
super().__init__(get_fragments, **kwargs)
|
|
301
|
+
self._on_scroll_up = on_scroll_up
|
|
302
|
+
self._on_scroll_down = on_scroll_down
|
|
303
|
+
|
|
304
|
+
def mouse_handler(self, mouse_event: MouseEvent): # type: ignore[no-untyped-def]
|
|
305
|
+
if mouse_event.event_type == MouseEventType.SCROLL_UP:
|
|
306
|
+
self._on_scroll_up()
|
|
307
|
+
return None
|
|
308
|
+
if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
|
|
309
|
+
self._on_scroll_down()
|
|
310
|
+
return None
|
|
311
|
+
return super().mouse_handler(mouse_event)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class _OutputWindow(Window):
|
|
315
|
+
"""Output pane that computes its own vertical scroll from the pane state.
|
|
316
|
+
|
|
317
|
+
prompt_toolkit's built-in scrolling keeps a cursor/marker merely *visible*
|
|
318
|
+
with minimal movement, which produces a dead-zone (the view doesn't move
|
|
319
|
+
for the first few scroll steps out of the pinned bottom, then jumps and
|
|
320
|
+
re-anchors to the top). Forcing it with scroll offsets instead pushes short
|
|
321
|
+
content — like the startup banner — off the top of the pane.
|
|
322
|
+
|
|
323
|
+
Overriding the wrap-aware scroll pass lets us bottom-anchor a chosen line
|
|
324
|
+
directly: `_OutputPane.bottom_line` picks the logical line that should sit
|
|
325
|
+
on the last row, and we walk up (honoring line wrapping) to find the top
|
|
326
|
+
line. Short content lands at scroll 0 (top-aligned); overflowing content is
|
|
327
|
+
bottom-anchored; scrolling moves the view by exactly the requested lines
|
|
328
|
+
with no jump.
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
def __init__(self, pane: _OutputPane, **kwargs) -> None: # type: ignore[no-untyped-def]
|
|
332
|
+
super().__init__(**kwargs)
|
|
333
|
+
self._pane = pane
|
|
334
|
+
|
|
335
|
+
def _scroll_when_linewrapping(self, ui_content, width, height): # type: ignore[no-untyped-def]
|
|
336
|
+
self.horizontal_scroll = 0
|
|
337
|
+
self.vertical_scroll_2 = 0
|
|
338
|
+
total = ui_content.line_count
|
|
339
|
+
if total <= 0 or width <= 0 or height <= 0:
|
|
340
|
+
self.vertical_scroll = 0
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
bottom = min(self._pane.bottom_line(total), total - 1)
|
|
344
|
+
|
|
345
|
+
# Walk up from the bottom-anchored line, summing wrapped heights, until
|
|
346
|
+
# the viewport is full; the last line that still fits is the top.
|
|
347
|
+
used = 0
|
|
348
|
+
top = bottom
|
|
349
|
+
for lineno in range(bottom, -1, -1):
|
|
350
|
+
used += ui_content.get_height_for_line(lineno, width, self.get_line_prefix)
|
|
351
|
+
if used > height:
|
|
352
|
+
break
|
|
353
|
+
top = lineno
|
|
354
|
+
self.vertical_scroll = top
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class SlashCommandCompleter(Completer):
|
|
358
|
+
"""Completion menu for the input box: typing "/" lists every command
|
|
359
|
+
from `ui.commands.SLASH_COMMANDS` (the same source `/help` renders from,
|
|
360
|
+
so the two can't drift apart); typing "/workflow " lists its
|
|
361
|
+
subcommands from `WORKFLOW_SUBCOMMANDS`. Returns nothing for anything
|
|
362
|
+
else, so it's invisible while typing a normal prompt.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
def get_completions(self, document, complete_event): # type: ignore[no-untyped-def]
|
|
366
|
+
text = document.text_before_cursor
|
|
367
|
+
if not text.startswith("/"):
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
if text.startswith("/workflow "):
|
|
371
|
+
prefix = text[len("/workflow ") :]
|
|
372
|
+
if " " in prefix:
|
|
373
|
+
return
|
|
374
|
+
for name, args, desc in WORKFLOW_SUBCOMMANDS:
|
|
375
|
+
if name.startswith(prefix):
|
|
376
|
+
display = f"{name} {args}".rstrip()
|
|
377
|
+
yield Completion(name, start_position=-len(prefix), display=display, display_meta=desc)
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
if text.startswith("/goal "):
|
|
381
|
+
prefix = text[len("/goal ") :]
|
|
382
|
+
if " " in prefix:
|
|
383
|
+
return
|
|
384
|
+
for name, args, desc in GOAL_SUBCOMMANDS:
|
|
385
|
+
if name.startswith(prefix):
|
|
386
|
+
display = f"{name} {args}".rstrip()
|
|
387
|
+
yield Completion(name, start_position=-len(prefix), display=display, display_meta=desc)
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
# Enum-valued commands: "/effort " -> low|medium|high|xhigh, etc. Offers
|
|
391
|
+
# the choice list from ARG_CHOICES as soon as the command + space is
|
|
392
|
+
# typed, so values don't have to be memorized.
|
|
393
|
+
for cmd, choices in ARG_CHOICES.items():
|
|
394
|
+
marker = f"{cmd} "
|
|
395
|
+
if text.startswith(marker):
|
|
396
|
+
arg = text[len(marker) :]
|
|
397
|
+
if " " in arg:
|
|
398
|
+
return
|
|
399
|
+
for value, desc in choices:
|
|
400
|
+
if value.startswith(arg):
|
|
401
|
+
yield Completion(value, start_position=-len(arg), display=value, display_meta=desc)
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
word = text[1:]
|
|
405
|
+
if " " in word:
|
|
406
|
+
return
|
|
407
|
+
for cmd, args, desc, _group in SLASH_COMMANDS:
|
|
408
|
+
token = cmd[1:]
|
|
409
|
+
if token.startswith(word):
|
|
410
|
+
display = f"{cmd} {args}".rstrip()
|
|
411
|
+
yield Completion(f"/{token}", start_position=-len(text), display=display, display_meta=desc)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class ChatApp:
|
|
415
|
+
"""Owns the full-screen layout, input handling, and turn dispatch."""
|
|
416
|
+
|
|
417
|
+
def __init__(self, orchestrator: Orchestrator, initial_prompt: str | None = None) -> None:
|
|
418
|
+
self.orchestrator = orchestrator
|
|
419
|
+
self.capture = _ConsoleCapture()
|
|
420
|
+
self.output = _OutputPane()
|
|
421
|
+
self._history = InMemoryHistory()
|
|
422
|
+
self._thinking = {"flag": False, "start": 0.0, "gerund": "", "last_swap": 0.0}
|
|
423
|
+
self._cancel_event: threading.Event | None = None
|
|
424
|
+
self._approving: dict = {"flag": False, "prompt": "", "result": False, "event": None}
|
|
425
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
426
|
+
self._initial_prompt = (initial_prompt or "").strip()
|
|
427
|
+
self._pastes: dict[str, str] = {}
|
|
428
|
+
self._paste_counter = 0
|
|
429
|
+
self._deepreid_running = False
|
|
430
|
+
|
|
431
|
+
self._buf = Buffer(
|
|
432
|
+
history=self._history,
|
|
433
|
+
multiline=False,
|
|
434
|
+
read_only=Condition(lambda: self._approving["flag"]),
|
|
435
|
+
completer=SlashCommandCompleter(),
|
|
436
|
+
complete_while_typing=True,
|
|
437
|
+
)
|
|
438
|
+
self._palette: ProviderPalette | None = None
|
|
439
|
+
self._palette_search_control: BufferControl | None = None
|
|
440
|
+
self._palette_input_control: BufferControl | None = None
|
|
441
|
+
self._init_palette()
|
|
442
|
+
self.app: Application = Application(
|
|
443
|
+
layout=self._build_layout(),
|
|
444
|
+
key_bindings=self._build_key_bindings(),
|
|
445
|
+
full_screen=True,
|
|
446
|
+
mouse_support=True,
|
|
447
|
+
style=_UI_STYLE,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# --- setup -----------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
def start(self) -> None:
|
|
453
|
+
if self.orchestrator.state is None:
|
|
454
|
+
self.orchestrator.start_session(title="interactive")
|
|
455
|
+
self._append_output(render.banner)
|
|
456
|
+
|
|
457
|
+
def _init_palette(self) -> None:
|
|
458
|
+
if self._palette is not None:
|
|
459
|
+
return
|
|
460
|
+
from pathlib import Path
|
|
461
|
+
storage_root = self.orchestrator.config.storage_root or (Path.home() / ".reidx")
|
|
462
|
+
db = ProviderDatabase(Path(storage_root))
|
|
463
|
+
self._palette = ProviderPalette(
|
|
464
|
+
db=db,
|
|
465
|
+
orchestrator=self.orchestrator,
|
|
466
|
+
on_close=self._on_palette_close,
|
|
467
|
+
on_invalidate=lambda: self.app.invalidate() if self.app.is_running else None,
|
|
468
|
+
)
|
|
469
|
+
self._palette_search_control = BufferControl(
|
|
470
|
+
buffer=self._palette.search_buf,
|
|
471
|
+
input_processors=[],
|
|
472
|
+
)
|
|
473
|
+
self._palette_input_control = BufferControl(
|
|
474
|
+
buffer=self._palette.input_buf,
|
|
475
|
+
input_processors=[],
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def _activate_palette(self) -> None:
|
|
479
|
+
self._init_palette()
|
|
480
|
+
assert self._palette is not None
|
|
481
|
+
self._palette.activate()
|
|
482
|
+
assert self._palette_search_control is not None
|
|
483
|
+
self.app.layout.focus(self._palette_search_control)
|
|
484
|
+
self.app.invalidate()
|
|
485
|
+
|
|
486
|
+
def _on_palette_close(self, message: str) -> None:
|
|
487
|
+
self.app.layout.focus(self._buf)
|
|
488
|
+
if message:
|
|
489
|
+
self._append_output(lambda: render.print_info(message))
|
|
490
|
+
else:
|
|
491
|
+
self._append_output(lambda: render.print_info("provider palette closed"))
|
|
492
|
+
self.app.invalidate()
|
|
493
|
+
|
|
494
|
+
def _sync_palette_focus(self) -> None:
|
|
495
|
+
if self._palette is None or not self._palette.active:
|
|
496
|
+
self.app.layout.focus(self._buf)
|
|
497
|
+
return
|
|
498
|
+
if self._palette.is_input_screen():
|
|
499
|
+
assert self._palette_input_control is not None
|
|
500
|
+
self.app.layout.focus(self._palette_input_control)
|
|
501
|
+
else:
|
|
502
|
+
assert self._palette_search_control is not None
|
|
503
|
+
self.app.layout.focus(self._palette_search_control)
|
|
504
|
+
|
|
505
|
+
def _palette_border_top(self) -> list[tuple[str, str]]:
|
|
506
|
+
if self._palette is None:
|
|
507
|
+
return []
|
|
508
|
+
return self._palette.border_top_fragments()
|
|
509
|
+
|
|
510
|
+
def _palette_border_bottom(self) -> list[tuple[str, str]]:
|
|
511
|
+
if self._palette is None:
|
|
512
|
+
return []
|
|
513
|
+
return self._palette.border_bottom_fragments()
|
|
514
|
+
|
|
515
|
+
def _palette_sep(self) -> list[tuple[str, str]]:
|
|
516
|
+
if self._palette is None:
|
|
517
|
+
return []
|
|
518
|
+
return self._palette.separator_fragments()
|
|
519
|
+
|
|
520
|
+
async def main(self) -> int:
|
|
521
|
+
self._loop = asyncio.get_running_loop()
|
|
522
|
+
self.app.create_background_task(self._spinner_ticker())
|
|
523
|
+
if self._initial_prompt:
|
|
524
|
+
# Run as a background task rather than awaiting inline, so the app
|
|
525
|
+
# starts rendering (banner, empty input box) immediately instead
|
|
526
|
+
# of appearing to hang until the injected prompt's turn finishes.
|
|
527
|
+
self.app.create_background_task(self._submit_text(self._initial_prompt))
|
|
528
|
+
result = await self.app.run_async()
|
|
529
|
+
return result or 0
|
|
530
|
+
|
|
531
|
+
async def _spinner_ticker(self) -> None:
|
|
532
|
+
while True:
|
|
533
|
+
await asyncio.sleep(0.125)
|
|
534
|
+
# Prune finished subagent rows past their linger window so the panel
|
|
535
|
+
# actually shrinks when children complete.
|
|
536
|
+
try:
|
|
537
|
+
self.orchestrator.subagents.prune_finished()
|
|
538
|
+
except AttributeError:
|
|
539
|
+
pass
|
|
540
|
+
if (
|
|
541
|
+
self._thinking["flag"]
|
|
542
|
+
or self._approving["flag"]
|
|
543
|
+
or self._deepread_pulse_active()
|
|
544
|
+
or self._subagent_rows_visible()
|
|
545
|
+
):
|
|
546
|
+
self.app.invalidate()
|
|
547
|
+
|
|
548
|
+
# --- subagent panel --------------------------------------------------
|
|
549
|
+
|
|
550
|
+
_SUBAGENT_PANEL_MAX = 5
|
|
551
|
+
|
|
552
|
+
def _subagent_rows_visible(self) -> bool:
|
|
553
|
+
"""True when there's anything to render in the panel (running or lingering)."""
|
|
554
|
+
try:
|
|
555
|
+
return bool(self.orchestrator.subagents.visible_rows())
|
|
556
|
+
except AttributeError:
|
|
557
|
+
return False
|
|
558
|
+
|
|
559
|
+
def _subagent_fragments(self): # type: ignore[no-untyped-def]
|
|
560
|
+
try:
|
|
561
|
+
return self._build_subagent_fragments()
|
|
562
|
+
except Exception: # noqa: BLE001 - cosmetic; never break the render loop
|
|
563
|
+
log.exception("subagent panel render failed")
|
|
564
|
+
return [("#9e9e9e", " subagents: (render error)")]
|
|
565
|
+
|
|
566
|
+
def _build_subagent_fragments(self): # type: ignore[no-untyped-def]
|
|
567
|
+
rows = self.orchestrator.subagents.visible_rows()
|
|
568
|
+
if not rows:
|
|
569
|
+
return [("", "")]
|
|
570
|
+
# Cap displayed rows; overflow gets a "+N more" line.
|
|
571
|
+
shown = rows[: self._SUBAGENT_PANEL_MAX]
|
|
572
|
+
overflow = len(rows) - len(shown)
|
|
573
|
+
|
|
574
|
+
status_glyph = {
|
|
575
|
+
"running": ("#ffd75f", "◐"),
|
|
576
|
+
"done": ("#5fd75f", "●"),
|
|
577
|
+
"error": ("#ff5f5f", "●"),
|
|
578
|
+
}
|
|
579
|
+
frags: list = []
|
|
580
|
+
for i, row in enumerate(shown):
|
|
581
|
+
color, glyph = status_glyph.get(row.status, ("#9e9e9e", "○"))
|
|
582
|
+
elapsed = int(row.elapsed_seconds)
|
|
583
|
+
name = row.name[:16].ljust(16)
|
|
584
|
+
status_text = row.status.ljust(7)
|
|
585
|
+
action = (row.error or row.last_action or "").strip()
|
|
586
|
+
action = action[:60]
|
|
587
|
+
frags += [
|
|
588
|
+
(color, f" {glyph} "),
|
|
589
|
+
("#ffffff bold", name),
|
|
590
|
+
(" "),
|
|
591
|
+
(f"{color}", status_text),
|
|
592
|
+
("#9e9e9e", f" {elapsed}s"),
|
|
593
|
+
]
|
|
594
|
+
if action:
|
|
595
|
+
frags += [("#6c6c6c", " · "), ("#9e9e9e", action)]
|
|
596
|
+
if i != len(shown) - 1 or overflow > 0:
|
|
597
|
+
frags.append(("", "\n"))
|
|
598
|
+
if overflow > 0:
|
|
599
|
+
frags.append(("#9e9e9e", f" … +{overflow} more subagent(s)"))
|
|
600
|
+
return frags
|
|
601
|
+
|
|
602
|
+
def _deepread_pulse_active(self) -> bool:
|
|
603
|
+
"""Whether the box border should be pulsing right now — either the
|
|
604
|
+
trigger word is currently typed (not yet submitted) or the pipeline
|
|
605
|
+
is actively running. Without this, `_box_color()` would only ever be
|
|
606
|
+
re-evaluated on buffer-edit events, so it'd show one static shade
|
|
607
|
+
instead of animating while just sitting there."""
|
|
608
|
+
return bool(self._deepread_prefix_len()) or self._deepreid_running
|
|
609
|
+
|
|
610
|
+
# --- rendering bridge --------------------------------------------------
|
|
611
|
+
|
|
612
|
+
def _append_output(self, fn: Callable[[], None]) -> None:
|
|
613
|
+
fn()
|
|
614
|
+
self.output.append_static(self.capture.drain())
|
|
615
|
+
if self.app.is_running:
|
|
616
|
+
self.app.invalidate()
|
|
617
|
+
|
|
618
|
+
def _render_thinking_variants(self, text: str, seconds: int) -> tuple[str, str]:
|
|
619
|
+
"""Render both display variants of the chain-of-thought block once.
|
|
620
|
+
|
|
621
|
+
Only called when the model actually produced reasoning (see
|
|
622
|
+
`_emit_turn_result`) — an empty turn no longer renders a filler
|
|
623
|
+
block. Collapsed: a single grayed-out "Thought for Ns" header
|
|
624
|
+
matching the spinner's elapsed-time readout. Expanded: the same
|
|
625
|
+
header plus the full thinking text beneath it. Neither variant is
|
|
626
|
+
ever re-rendered — Ctrl+O just picks which was already captured.
|
|
627
|
+
"""
|
|
628
|
+
header = Text(f" {SPARKLE} Thought for {seconds}s", style=DIM)
|
|
629
|
+
|
|
630
|
+
render.console.print(header)
|
|
631
|
+
collapsed = self.capture.drain()
|
|
632
|
+
|
|
633
|
+
render.console.print(header)
|
|
634
|
+
render.print_thinking(text)
|
|
635
|
+
expanded = self.capture.drain()
|
|
636
|
+
return collapsed, expanded
|
|
637
|
+
|
|
638
|
+
def _render_tool_call_variants(self, entry: dict) -> tuple[str, str]:
|
|
639
|
+
"""Render both display variants of one tool-call log entry.
|
|
640
|
+
|
|
641
|
+
Collapsed: header line (name + args) with an inline ok/error status.
|
|
642
|
+
Expanded: today's two-line layout — header, then a tree-connector
|
|
643
|
+
result line beneath it.
|
|
644
|
+
"""
|
|
645
|
+
name = entry["name"]
|
|
646
|
+
ok = entry["ok"]
|
|
647
|
+
error = entry.get("error", "")
|
|
648
|
+
args = entry.get("args", {})
|
|
649
|
+
args_str = ", ".join(f"{k}={v!r}" for k, v in args.items()) if args else ""
|
|
650
|
+
header = Text.assemble((name, "bold"), ("(", DIM), (args_str, DIM), (")", DIM))
|
|
651
|
+
|
|
652
|
+
status = Text(" ok", style=SUCCESS) if ok else Text(" error", style=DANGER)
|
|
653
|
+
collapsed_line = Text.assemble(header, status, (" (ctrl+o)", DIM))
|
|
654
|
+
render.console.print(_bullet_grid(Text(BULLET, style=PRIMARY), collapsed_line))
|
|
655
|
+
collapsed = self.capture.drain()
|
|
656
|
+
|
|
657
|
+
render.console.print(_bullet_grid(Text(BULLET, style=PRIMARY), header))
|
|
658
|
+
result = Text("ok", style=SUCCESS) if ok else Text(f"Error: {error}", style=DANGER)
|
|
659
|
+
render.console.print(Text.assemble((" ", ""), (TREE, DIM), (" ", ""), result))
|
|
660
|
+
expanded = self.capture.drain()
|
|
661
|
+
|
|
662
|
+
return collapsed, expanded
|
|
663
|
+
|
|
664
|
+
def _emit_turn_result(self, result: dict, thinking_seconds: int) -> None:
|
|
665
|
+
# Only render the thinking block when the model actually produced
|
|
666
|
+
# reasoning. Empty <think> content gets suppressed entirely rather
|
|
667
|
+
# than leaving a "(model returned no reasoning for this turn)" line
|
|
668
|
+
# floating above the answer, which was noisy and confusing when the
|
|
669
|
+
# provider skipped CoT (short answers, safety refusals, low-effort
|
|
670
|
+
# runs).
|
|
671
|
+
thinking_text = (result.get("thinking") or "").strip()
|
|
672
|
+
if thinking_text:
|
|
673
|
+
thinking_variants = self._render_thinking_variants(thinking_text, thinking_seconds)
|
|
674
|
+
self.output.append_collapsible(*thinking_variants)
|
|
675
|
+
|
|
676
|
+
for entry in result.get("tools", []):
|
|
677
|
+
self.output.append_collapsible(*self._render_tool_call_variants(entry))
|
|
678
|
+
|
|
679
|
+
render.print_assistant(result["text"])
|
|
680
|
+
self.output.append_static(self.capture.drain())
|
|
681
|
+
|
|
682
|
+
if self.app.is_running:
|
|
683
|
+
self.app.invalidate()
|
|
684
|
+
|
|
685
|
+
# --- scrolling (mouse wheel only) ---------------------------------------
|
|
686
|
+
|
|
687
|
+
def _scroll_up(self) -> None:
|
|
688
|
+
self.output.scroll_up(3)
|
|
689
|
+
self.app.invalidate()
|
|
690
|
+
|
|
691
|
+
def _scroll_down(self) -> None:
|
|
692
|
+
self.output.scroll_down(3)
|
|
693
|
+
self.app.invalidate()
|
|
694
|
+
|
|
695
|
+
# --- status / spinner content -----------------------------------------
|
|
696
|
+
|
|
697
|
+
def _estimate_tokens(self) -> int:
|
|
698
|
+
st = self.orchestrator.state
|
|
699
|
+
if st is None:
|
|
700
|
+
return 0
|
|
701
|
+
# Prefer real usage from the provider's last response over a guess —
|
|
702
|
+
# StubProvider (and any provider that doesn't report usage) leaves
|
|
703
|
+
# these at 0, so the char-based estimate below is still the fallback
|
|
704
|
+
# for it, but real providers (e.g. Anthropic) report actual token
|
|
705
|
+
# counts and that's what's shown once at least one turn has run.
|
|
706
|
+
real = st.last_usage_prompt_tokens + st.last_usage_completion_tokens
|
|
707
|
+
if real > 0:
|
|
708
|
+
return real
|
|
709
|
+
try:
|
|
710
|
+
chars = sum(len(m.content or "") for m in list(st.messages))
|
|
711
|
+
except (RuntimeError, AttributeError):
|
|
712
|
+
return 0
|
|
713
|
+
return max(1, chars // 4)
|
|
714
|
+
|
|
715
|
+
def _status(self) -> dict:
|
|
716
|
+
st = self.orchestrator.state
|
|
717
|
+
if st is None:
|
|
718
|
+
return {
|
|
719
|
+
"mode": "—", "model": "—", "effort": "—",
|
|
720
|
+
"tokens_used": 0, "context_window": 0,
|
|
721
|
+
"workspace": "—", "tasks": 0,
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
"mode": st.effective_mode.value,
|
|
725
|
+
"model": st.session.model,
|
|
726
|
+
"effort": st.session.reasoning_effort,
|
|
727
|
+
"tokens_used": self._estimate_tokens(),
|
|
728
|
+
"context_window": context_window_for(st.session.model),
|
|
729
|
+
"workspace": str(st.session.workspace),
|
|
730
|
+
"tasks": len(self.orchestrator.list_tasks()),
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
def _status_fragments(self): # type: ignore[no-untyped-def]
|
|
734
|
+
# Called on every redraw by prompt_toolkit's core render loop, with no
|
|
735
|
+
# error boundary above it — an uncaught exception here kills the whole
|
|
736
|
+
# app (that's what happened when tasks.json read failed). Never let a
|
|
737
|
+
# status-computation bug take the TUI down.
|
|
738
|
+
try:
|
|
739
|
+
return self._build_status_fragments()
|
|
740
|
+
except Exception: # noqa: BLE001 - cosmetic; never break the render loop
|
|
741
|
+
log.exception("status bar render failed")
|
|
742
|
+
return [("#9e9e9e", " status unavailable")]
|
|
743
|
+
|
|
744
|
+
def _build_status_fragments(self): # type: ignore[no-untyped-def]
|
|
745
|
+
status = self._status()
|
|
746
|
+
window = status.get("context_window", 0)
|
|
747
|
+
used = status.get("tokens_used", 0)
|
|
748
|
+
pct = f"{(used / window * 100):.0f}%" if window else "—"
|
|
749
|
+
usage = f"{fmt_tokens(used)}/{fmt_tokens(window)} ({pct})" if window else fmt_tokens(used)
|
|
750
|
+
mode = status.get("mode", "—")
|
|
751
|
+
mode_color = _MODE_COLOR.get(mode, "#9e9e9e")
|
|
752
|
+
sep = ("#6c6c6c", " · ")
|
|
753
|
+
frags = [
|
|
754
|
+
("#ff5f5f bold", f" {APP_NAME}"), sep,
|
|
755
|
+
(f"{mode_color} bold", mode), sep,
|
|
756
|
+
("#9e9e9e", status.get("model", "—")), sep,
|
|
757
|
+
("#9e9e9e", f"effort:{status.get('effort', '—')}"), sep,
|
|
758
|
+
("#9e9e9e", usage), sep,
|
|
759
|
+
("#9e9e9e", short_path(status.get("workspace", "—"))), sep,
|
|
760
|
+
("#9e9e9e", f"{status.get('tasks', 0)} tasks"),
|
|
761
|
+
]
|
|
762
|
+
if not self.output.pinned:
|
|
763
|
+
frags += [sep, ("#ffd75f bold", "scrolled ↑ (scroll down to return)")]
|
|
764
|
+
return frags
|
|
765
|
+
|
|
766
|
+
def _spinner_fragments(self): # type: ignore[no-untyped-def]
|
|
767
|
+
# Same rationale as _status_fragments: this runs every redraw with no
|
|
768
|
+
# error boundary above it in prompt_toolkit's render loop.
|
|
769
|
+
try:
|
|
770
|
+
return self._build_spinner_fragments()
|
|
771
|
+
except Exception: # noqa: BLE001 - cosmetic; never break the render loop
|
|
772
|
+
log.exception("spinner render failed")
|
|
773
|
+
return [("#9e9e9e", " …")]
|
|
774
|
+
|
|
775
|
+
def _build_spinner_fragments(self): # type: ignore[no-untyped-def]
|
|
776
|
+
if self._approving["flag"]:
|
|
777
|
+
prompt_text = self._approving.get("prompt", "")
|
|
778
|
+
return [("#ffd75f bold", f" {prompt_text} allow? [y/N]")]
|
|
779
|
+
if not self._thinking["flag"]:
|
|
780
|
+
return [("", "")]
|
|
781
|
+
if self._cancel_event is not None and self._cancel_event.is_set():
|
|
782
|
+
return [("#ffd75f bold", " ◐ stopping… "), ("#9e9e9e", "(esc pressed, finishing current step)")]
|
|
783
|
+
now = time.monotonic()
|
|
784
|
+
if now - self._thinking["last_swap"] > 8.0:
|
|
785
|
+
self._thinking["gerund"] = random.choice(_GERUNDS)
|
|
786
|
+
self._thinking["last_swap"] = now
|
|
787
|
+
elapsed = int(now - self._thinking["start"])
|
|
788
|
+
star = _STAR_FRAMES[int(now * 6) % len(_STAR_FRAMES)]
|
|
789
|
+
frags = [
|
|
790
|
+
("#ff5f5f", f" {star} "),
|
|
791
|
+
("#ff5f5f", f"{self._thinking['gerund']}… "),
|
|
792
|
+
("#9e9e9e", f"({elapsed}s"),
|
|
793
|
+
("#9e9e9e", f" · ↑ {fmt_tokens(self._estimate_tokens())} tokens"),
|
|
794
|
+
("#9e9e9e", ")"),
|
|
795
|
+
]
|
|
796
|
+
return frags
|
|
797
|
+
|
|
798
|
+
# --- layout --------------------------------------------------------
|
|
799
|
+
|
|
800
|
+
def _build_layout(self) -> Layout:
|
|
801
|
+
output_window = _OutputWindow(
|
|
802
|
+
self.output,
|
|
803
|
+
content=_ScrollableOutputControl(
|
|
804
|
+
self.output.get_fragments,
|
|
805
|
+
on_scroll_up=self._scroll_up,
|
|
806
|
+
on_scroll_down=self._scroll_down,
|
|
807
|
+
focusable=False,
|
|
808
|
+
),
|
|
809
|
+
wrap_lines=True,
|
|
810
|
+
)
|
|
811
|
+
spinner_window = Window(content=FormattedTextControl(self._spinner_fragments), height=1)
|
|
812
|
+
|
|
813
|
+
# Box border/caret color is a callable, not a static style, so it
|
|
814
|
+
# re-evaluates every render — that's what makes it turn green live as
|
|
815
|
+
# soon as the buffer starts with a DeepReid trigger word.
|
|
816
|
+
def corner(ch: str) -> Window:
|
|
817
|
+
return Window(FormattedTextControl(lambda: [(self._box_color(), ch)]), width=1, height=1)
|
|
818
|
+
|
|
819
|
+
def hline() -> Window:
|
|
820
|
+
return Window(char="─", style=self._box_color, height=1)
|
|
821
|
+
|
|
822
|
+
input_window = Window(BufferControl(buffer=self._buf), wrap_lines=False, height=1)
|
|
823
|
+
|
|
824
|
+
box = HSplit(
|
|
825
|
+
[
|
|
826
|
+
VSplit([corner("╭"), hline(), corner("╮")], height=1),
|
|
827
|
+
VSplit(
|
|
828
|
+
[
|
|
829
|
+
Window(FormattedTextControl(lambda: [(self._box_color(), "│")]), width=1, height=1),
|
|
830
|
+
Window(FormattedTextControl(lambda: [(f"{self._box_color()} bold", " › ")]), width=3, height=1),
|
|
831
|
+
input_window,
|
|
832
|
+
Window(FormattedTextControl(lambda: [(self._box_color(), "│")]), width=1, height=1),
|
|
833
|
+
],
|
|
834
|
+
height=1,
|
|
835
|
+
),
|
|
836
|
+
VSplit([corner("╰"), hline(), corner("╯")], height=1),
|
|
837
|
+
]
|
|
838
|
+
)
|
|
839
|
+
status_window = Window(content=FormattedTextControl(self._status_fragments), height=1)
|
|
840
|
+
|
|
841
|
+
# Subagent panel: appears directly under the input box (pushing it up
|
|
842
|
+
# visually because HSplit re-layouts) whenever there are running or
|
|
843
|
+
# recently-finished subagents. Sits above the status line so the
|
|
844
|
+
# footer's app/mode/model/tokens readout stays the last row.
|
|
845
|
+
subagent_panel = ConditionalContainer(
|
|
846
|
+
content=Window(content=FormattedTextControl(self._subagent_fragments)),
|
|
847
|
+
filter=Condition(self._subagent_rows_visible),
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
root = HSplit([output_window, spinner_window, box, subagent_panel, status_window])
|
|
851
|
+
floated = FloatContainer(
|
|
852
|
+
content=root,
|
|
853
|
+
floats=[
|
|
854
|
+
Float(
|
|
855
|
+
xcursor=True,
|
|
856
|
+
ycursor=True,
|
|
857
|
+
content=CompletionsMenu(max_height=10, scroll_offset=1, display_arrows=True),
|
|
858
|
+
),
|
|
859
|
+
Float(
|
|
860
|
+
left=0,
|
|
861
|
+
top=0,
|
|
862
|
+
width=None,
|
|
863
|
+
height=None,
|
|
864
|
+
content=ConditionalContainer(
|
|
865
|
+
content=self._build_palette_overlay(),
|
|
866
|
+
filter=Condition(lambda: self._palette is not None and self._palette.active),
|
|
867
|
+
),
|
|
868
|
+
),
|
|
869
|
+
],
|
|
870
|
+
)
|
|
871
|
+
return Layout(floated, focused_element=input_window)
|
|
872
|
+
|
|
873
|
+
# --- palette overlay --------------------------------------------------
|
|
874
|
+
|
|
875
|
+
def _build_palette_overlay(self):
|
|
876
|
+
return self._build_palette_box()
|
|
877
|
+
|
|
878
|
+
def _build_palette_box(self):
|
|
879
|
+
p = self._palette
|
|
880
|
+
|
|
881
|
+
is_search = Condition(lambda: p is not None and p.is_search_screen())
|
|
882
|
+
is_input = Condition(lambda: p is not None and p.is_input_screen())
|
|
883
|
+
|
|
884
|
+
def header_frags():
|
|
885
|
+
return p.header_fragments() if p is not None else []
|
|
886
|
+
|
|
887
|
+
def content_frags():
|
|
888
|
+
return p.content_fragments() if p is not None else []
|
|
889
|
+
|
|
890
|
+
def footer_frags():
|
|
891
|
+
return p.footer_fragments() if p is not None else []
|
|
892
|
+
|
|
893
|
+
top_border = Window(
|
|
894
|
+
FormattedTextControl(self._palette_border_top),
|
|
895
|
+
height=1,
|
|
896
|
+
style="class:palette-border",
|
|
897
|
+
)
|
|
898
|
+
header = Window(
|
|
899
|
+
FormattedTextControl(header_frags),
|
|
900
|
+
height=1,
|
|
901
|
+
style="class:palette-header",
|
|
902
|
+
)
|
|
903
|
+
sep = Window(
|
|
904
|
+
FormattedTextControl(self._palette_sep),
|
|
905
|
+
height=1,
|
|
906
|
+
style="class:palette-sep",
|
|
907
|
+
)
|
|
908
|
+
search_line = ConditionalContainer(
|
|
909
|
+
content=VSplit([
|
|
910
|
+
Window(
|
|
911
|
+
FormattedTextControl(lambda: [("class:palette-search-label", f"│{p.search_label()}")]),
|
|
912
|
+
width=5,
|
|
913
|
+
height=1,
|
|
914
|
+
),
|
|
915
|
+
Window(
|
|
916
|
+
self._palette_search_control,
|
|
917
|
+
height=1,
|
|
918
|
+
style="class:palette-search",
|
|
919
|
+
),
|
|
920
|
+
Window(
|
|
921
|
+
FormattedTextControl(lambda: [("class:palette-search", "│")]),
|
|
922
|
+
width=1,
|
|
923
|
+
height=1,
|
|
924
|
+
),
|
|
925
|
+
]),
|
|
926
|
+
filter=is_search,
|
|
927
|
+
)
|
|
928
|
+
input_line = ConditionalContainer(
|
|
929
|
+
content=VSplit([
|
|
930
|
+
Window(
|
|
931
|
+
FormattedTextControl(lambda: [("class:palette-search-label", f"│{p.input_label()}")]),
|
|
932
|
+
width=14,
|
|
933
|
+
height=1,
|
|
934
|
+
),
|
|
935
|
+
Window(
|
|
936
|
+
self._palette_input_control,
|
|
937
|
+
height=1,
|
|
938
|
+
style="class:palette-search",
|
|
939
|
+
),
|
|
940
|
+
Window(
|
|
941
|
+
FormattedTextControl(lambda: [("class:palette-search", "│")]),
|
|
942
|
+
width=1,
|
|
943
|
+
height=1,
|
|
944
|
+
),
|
|
945
|
+
]),
|
|
946
|
+
filter=is_input,
|
|
947
|
+
)
|
|
948
|
+
filler = ConditionalContainer(
|
|
949
|
+
content=Window(
|
|
950
|
+
FormattedTextControl(lambda: [("class:palette-bg", f"│{' ' * (p.inner_width())}│")]),
|
|
951
|
+
height=1,
|
|
952
|
+
style="class:palette-bg",
|
|
953
|
+
),
|
|
954
|
+
filter=~is_search & ~is_input,
|
|
955
|
+
)
|
|
956
|
+
content = Window(
|
|
957
|
+
FormattedTextControl(content_frags),
|
|
958
|
+
style="class:palette-bg",
|
|
959
|
+
)
|
|
960
|
+
footer = Window(
|
|
961
|
+
FormattedTextControl(footer_frags),
|
|
962
|
+
height=1,
|
|
963
|
+
style="class:palette-footer",
|
|
964
|
+
)
|
|
965
|
+
bottom_border = Window(
|
|
966
|
+
FormattedTextControl(self._palette_border_bottom),
|
|
967
|
+
height=1,
|
|
968
|
+
style="class:palette-border",
|
|
969
|
+
)
|
|
970
|
+
return HSplit([
|
|
971
|
+
top_border,
|
|
972
|
+
header,
|
|
973
|
+
sep,
|
|
974
|
+
search_line,
|
|
975
|
+
input_line,
|
|
976
|
+
filler,
|
|
977
|
+
sep,
|
|
978
|
+
content,
|
|
979
|
+
sep,
|
|
980
|
+
footer,
|
|
981
|
+
bottom_border,
|
|
982
|
+
])
|
|
983
|
+
|
|
984
|
+
# --- input handling --------------------------------------------------
|
|
985
|
+
|
|
986
|
+
def _build_key_bindings(self) -> KeyBindings:
|
|
987
|
+
kb = KeyBindings()
|
|
988
|
+
is_thinking = Condition(lambda: self._thinking["flag"])
|
|
989
|
+
is_approving = Condition(lambda: self._approving["flag"])
|
|
990
|
+
is_buffer_empty = Condition(lambda: not self._buf.text)
|
|
991
|
+
is_palette = Condition(lambda: self._palette is not None and self._palette.active)
|
|
992
|
+
|
|
993
|
+
@kb.add("up", filter=is_palette)
|
|
994
|
+
def _palette_up(event) -> None: # type: ignore[no-untyped-def]
|
|
995
|
+
self._palette.on_up()
|
|
996
|
+
self._sync_palette_focus()
|
|
997
|
+
|
|
998
|
+
@kb.add("down", filter=is_palette)
|
|
999
|
+
def _palette_down(event) -> None: # type: ignore[no-untyped-def]
|
|
1000
|
+
self._palette.on_down()
|
|
1001
|
+
self._sync_palette_focus()
|
|
1002
|
+
|
|
1003
|
+
@kb.add("enter", filter=is_palette)
|
|
1004
|
+
def _palette_enter(event) -> None: # type: ignore[no-untyped-def]
|
|
1005
|
+
self._palette.on_enter()
|
|
1006
|
+
self._sync_palette_focus()
|
|
1007
|
+
|
|
1008
|
+
@kb.add("escape", filter=is_palette)
|
|
1009
|
+
def _palette_escape(event) -> None: # type: ignore[no-untyped-def]
|
|
1010
|
+
self._palette.on_escape()
|
|
1011
|
+
self._sync_palette_focus()
|
|
1012
|
+
|
|
1013
|
+
@kb.add("c-c", filter=is_palette)
|
|
1014
|
+
def _palette_cancel(event) -> None: # type: ignore[no-untyped-def]
|
|
1015
|
+
self._palette.deactivate()
|
|
1016
|
+
self._sync_palette_focus()
|
|
1017
|
+
|
|
1018
|
+
@kb.add("enter", filter=~is_thinking & ~is_approving & ~is_palette)
|
|
1019
|
+
async def _submit(event) -> None: # type: ignore[no-untyped-def]
|
|
1020
|
+
buf = event.current_buffer
|
|
1021
|
+
if buf.complete_state is not None:
|
|
1022
|
+
# A completion menu is open — Enter accepts the highlighted
|
|
1023
|
+
# entry (or just closes the menu if nothing's highlighted
|
|
1024
|
+
# yet), matching every other tool's "/" menu. It does not
|
|
1025
|
+
# submit; that needs a second Enter once the text is filled in.
|
|
1026
|
+
completion = buf.complete_state.current_completion
|
|
1027
|
+
if completion is not None:
|
|
1028
|
+
buf.apply_completion(completion)
|
|
1029
|
+
else:
|
|
1030
|
+
buf.cancel_completion()
|
|
1031
|
+
return
|
|
1032
|
+
await self._on_submit()
|
|
1033
|
+
|
|
1034
|
+
@kb.add("y", filter=is_approving)
|
|
1035
|
+
@kb.add("Y", filter=is_approving)
|
|
1036
|
+
def _approve_yes(event) -> None: # type: ignore[no-untyped-def]
|
|
1037
|
+
self._resolve_approval(True)
|
|
1038
|
+
|
|
1039
|
+
@kb.add("n", filter=is_approving)
|
|
1040
|
+
@kb.add("N", filter=is_approving)
|
|
1041
|
+
@kb.add("enter", filter=is_approving)
|
|
1042
|
+
def _approve_no(event) -> None: # type: ignore[no-untyped-def]
|
|
1043
|
+
self._resolve_approval(False)
|
|
1044
|
+
|
|
1045
|
+
@kb.add("escape", filter=is_thinking & ~is_palette)
|
|
1046
|
+
def _cancel_turn(event) -> None: # type: ignore[no-untyped-def]
|
|
1047
|
+
# Stops the in-flight response, not the session — the running turn
|
|
1048
|
+
# ends at its next safe point (see Agent.run_turn's `cancel` polling)
|
|
1049
|
+
# instead of the whole app exiting, matching Claude Code's Escape.
|
|
1050
|
+
if self._cancel_event is not None:
|
|
1051
|
+
self._cancel_event.set()
|
|
1052
|
+
|
|
1053
|
+
@kb.add("c-c", filter=~is_palette)
|
|
1054
|
+
def _clear_line(event) -> None: # type: ignore[no-untyped-def]
|
|
1055
|
+
if self._buf.text:
|
|
1056
|
+
self._buf.reset()
|
|
1057
|
+
|
|
1058
|
+
@kb.add("c-d", filter=~is_palette)
|
|
1059
|
+
def _exit(event) -> None: # type: ignore[no-untyped-def]
|
|
1060
|
+
self.app.exit(result=0)
|
|
1061
|
+
|
|
1062
|
+
@kb.add("c-o", filter=~is_palette)
|
|
1063
|
+
def _toggle_collapse(event) -> None: # type: ignore[no-untyped-def]
|
|
1064
|
+
self.output.toggle_expanded()
|
|
1065
|
+
self.app.invalidate()
|
|
1066
|
+
|
|
1067
|
+
# Keyboard scrollback for the transcript pane. A page is roughly the
|
|
1068
|
+
# output window's height; we don't have it here without the renderer,
|
|
1069
|
+
# so a fixed page of 15 lines keeps PageUp/PageDown predictable.
|
|
1070
|
+
@kb.add("pageup", filter=~is_approving & ~is_palette)
|
|
1071
|
+
def _page_up(event) -> None: # type: ignore[no-untyped-def]
|
|
1072
|
+
self.output.scroll_up(15)
|
|
1073
|
+
self.app.invalidate()
|
|
1074
|
+
|
|
1075
|
+
@kb.add("pagedown", filter=~is_approving & ~is_palette)
|
|
1076
|
+
def _page_down(event) -> None: # type: ignore[no-untyped-def]
|
|
1077
|
+
self.output.scroll_down(15)
|
|
1078
|
+
self.app.invalidate()
|
|
1079
|
+
|
|
1080
|
+
@kb.add("s-up", filter=~is_approving & ~is_palette)
|
|
1081
|
+
def _line_up(event) -> None: # type: ignore[no-untyped-def]
|
|
1082
|
+
self.output.scroll_up(1)
|
|
1083
|
+
self.app.invalidate()
|
|
1084
|
+
|
|
1085
|
+
@kb.add("s-down", filter=~is_approving & ~is_palette)
|
|
1086
|
+
def _line_down(event) -> None: # type: ignore[no-untyped-def]
|
|
1087
|
+
self.output.scroll_down(1)
|
|
1088
|
+
self.app.invalidate()
|
|
1089
|
+
|
|
1090
|
+
@kb.add(Keys.BracketedPaste, filter=~is_approving & ~is_palette)
|
|
1091
|
+
def _paste(event) -> None: # type: ignore[no-untyped-def]
|
|
1092
|
+
data = event.data
|
|
1093
|
+
if "\n" in data or len(data) > _PASTE_COLLAPSE_CHARS:
|
|
1094
|
+
event.current_buffer.insert_text(self._collapse_paste(data))
|
|
1095
|
+
else:
|
|
1096
|
+
event.current_buffer.insert_text(data)
|
|
1097
|
+
|
|
1098
|
+
@kb.add("left", filter=is_buffer_empty & ~is_thinking & ~is_approving & ~is_palette)
|
|
1099
|
+
def _effort_prev(event) -> None: # type: ignore[no-untyped-def]
|
|
1100
|
+
self._cycle_effort(-1)
|
|
1101
|
+
|
|
1102
|
+
@kb.add("right", filter=is_buffer_empty & ~is_thinking & ~is_approving & ~is_palette)
|
|
1103
|
+
def _effort_next(event) -> None: # type: ignore[no-untyped-def]
|
|
1104
|
+
self._cycle_effort(1)
|
|
1105
|
+
|
|
1106
|
+
return kb
|
|
1107
|
+
|
|
1108
|
+
def _cycle_effort(self, delta: int) -> None:
|
|
1109
|
+
if self.orchestrator.state is None:
|
|
1110
|
+
return
|
|
1111
|
+
session = self.orchestrator.state.session
|
|
1112
|
+
try:
|
|
1113
|
+
idx = _EFFORT_LEVELS.index(session.reasoning_effort)
|
|
1114
|
+
except ValueError:
|
|
1115
|
+
idx = 0
|
|
1116
|
+
session.reasoning_effort = _EFFORT_LEVELS[(idx + delta) % len(_EFFORT_LEVELS)]
|
|
1117
|
+
self.orchestrator.session_store.update(session)
|
|
1118
|
+
self.app.invalidate()
|
|
1119
|
+
|
|
1120
|
+
def _deepread_prefix_len(self) -> int:
|
|
1121
|
+
"""Length of a DeepReid trigger word at the start of the buffer, or 0
|
|
1122
|
+
if there isn't one — 0 also means "not triggered", so this doubles as
|
|
1123
|
+
the truthiness check. Requires a word boundary right after the
|
|
1124
|
+
trigger (end-of-text or whitespace) so "deepreading..." doesn't
|
|
1125
|
+
false-positive on "deepread"."""
|
|
1126
|
+
text = self._buf.text.lstrip()
|
|
1127
|
+
lead = len(self._buf.text) - len(text)
|
|
1128
|
+
lowered = text.lower()
|
|
1129
|
+
for trigger in _DEEPREID_TRIGGERS:
|
|
1130
|
+
if lowered.startswith(trigger):
|
|
1131
|
+
rest = text[len(trigger) :]
|
|
1132
|
+
if not rest or rest[0].isspace():
|
|
1133
|
+
return lead + len(trigger)
|
|
1134
|
+
return 0
|
|
1135
|
+
|
|
1136
|
+
def _box_color(self) -> str:
|
|
1137
|
+
# Faster pulse while the pipeline is actually working, gentler pulse
|
|
1138
|
+
# while just sitting there with the trigger typed but not submitted.
|
|
1139
|
+
if self._deepreid_running:
|
|
1140
|
+
idx = int(time.monotonic() * 6) % len(_DEEPREID_PULSE_SHADES)
|
|
1141
|
+
return _DEEPREID_PULSE_SHADES[idx]
|
|
1142
|
+
if self._deepread_prefix_len():
|
|
1143
|
+
idx = int(time.monotonic() * 2) % len(_DEEPREID_PULSE_SHADES)
|
|
1144
|
+
return _DEEPREID_PULSE_SHADES[idx]
|
|
1145
|
+
return _BOX_COLOR_NORMAL
|
|
1146
|
+
|
|
1147
|
+
def _collapse_paste(self, data: str) -> str:
|
|
1148
|
+
"""Store a large/multi-line paste and return a short placeholder for
|
|
1149
|
+
the input box — same idea as Claude Code's own `[Pasted text]`
|
|
1150
|
+
collapse. The full text is substituted back in at submit time."""
|
|
1151
|
+
self._paste_counter += 1
|
|
1152
|
+
lines = data.count("\n") + 1
|
|
1153
|
+
label = f"[Pasted text #{self._paste_counter} +{lines} lines]" if lines > 1 else f"[Pasted text #{self._paste_counter} +{len(data)} chars]"
|
|
1154
|
+
self._pastes[label] = data
|
|
1155
|
+
return label
|
|
1156
|
+
|
|
1157
|
+
def _expand_pastes(self, text: str) -> str:
|
|
1158
|
+
for label, data in self._pastes.items():
|
|
1159
|
+
text = text.replace(label, data)
|
|
1160
|
+
return text
|
|
1161
|
+
|
|
1162
|
+
async def _on_submit(self) -> None:
|
|
1163
|
+
text = self._buf.text
|
|
1164
|
+
if not text.strip():
|
|
1165
|
+
return
|
|
1166
|
+
prefix_len = self._deepread_prefix_len()
|
|
1167
|
+
self._buf.reset()
|
|
1168
|
+
if prefix_len:
|
|
1169
|
+
task = self._expand_pastes(text[prefix_len:].lstrip())
|
|
1170
|
+
self._pastes.clear()
|
|
1171
|
+
await self._run_deepreid(task)
|
|
1172
|
+
return
|
|
1173
|
+
text = self._expand_pastes(text)
|
|
1174
|
+
self._pastes.clear()
|
|
1175
|
+
await self._submit_text(text)
|
|
1176
|
+
|
|
1177
|
+
async def _run_deepreid(self, task: str) -> None:
|
|
1178
|
+
"""Run the real Researcher->Planner->Critic pipeline (deepreid/pipeline.py)
|
|
1179
|
+
and render its Markdown output, instead of a normal single-agent turn."""
|
|
1180
|
+
if not task.strip():
|
|
1181
|
+
return
|
|
1182
|
+
self._append_output(lambda: render.console.print(Text(f" DeepReid: {task}", style="bold #5fd75f")))
|
|
1183
|
+
self._deepreid_running = True
|
|
1184
|
+
self.app.invalidate()
|
|
1185
|
+
|
|
1186
|
+
assert self._loop is not None
|
|
1187
|
+
loop = self._loop
|
|
1188
|
+
|
|
1189
|
+
def progress(stage: str) -> None:
|
|
1190
|
+
loop.call_soon_threadsafe(lambda: self._append_output(lambda: render.print_info(f" {stage}...")))
|
|
1191
|
+
|
|
1192
|
+
try:
|
|
1193
|
+
result = await loop.run_in_executor(
|
|
1194
|
+
None,
|
|
1195
|
+
functools.partial(
|
|
1196
|
+
run_deepreid,
|
|
1197
|
+
self.orchestrator.config,
|
|
1198
|
+
self.orchestrator.provider,
|
|
1199
|
+
self.orchestrator.state.session.workspace,
|
|
1200
|
+
task,
|
|
1201
|
+
on_progress=progress,
|
|
1202
|
+
),
|
|
1203
|
+
)
|
|
1204
|
+
except Exception as exc: # noqa: BLE001 - the TUI must not die on runtime errors
|
|
1205
|
+
log.exception("deepreid failed")
|
|
1206
|
+
error_text = str(exc)
|
|
1207
|
+
self._append_output(lambda: render.print_error(error_text))
|
|
1208
|
+
else:
|
|
1209
|
+
path = save_deepreid_result(self.orchestrator.config, result)
|
|
1210
|
+
self._append_output(lambda: render.print_assistant(format_markdown(result)))
|
|
1211
|
+
self._append_output(lambda: render.print_info(f"saved to {path}"))
|
|
1212
|
+
finally:
|
|
1213
|
+
self._deepreid_running = False
|
|
1214
|
+
self.app.invalidate()
|
|
1215
|
+
|
|
1216
|
+
async def _submit_text(self, text: str) -> None:
|
|
1217
|
+
"""Run one turn for `text` — shared by the Enter key binding and by
|
|
1218
|
+
an injected initial prompt (`reid "<prompt>"` / piped stdin)."""
|
|
1219
|
+
if not text.strip():
|
|
1220
|
+
return
|
|
1221
|
+
|
|
1222
|
+
if text.startswith("/"):
|
|
1223
|
+
outcome = self._run_slash(text)
|
|
1224
|
+
if outcome == "exit":
|
|
1225
|
+
self.app.exit(result=0)
|
|
1226
|
+
elif outcome == "connect":
|
|
1227
|
+
self._activate_palette()
|
|
1228
|
+
elif outcome.startswith("workflow-run:"):
|
|
1229
|
+
await self._run_workflow(outcome.split(":", 1)[1])
|
|
1230
|
+
return
|
|
1231
|
+
|
|
1232
|
+
self._append_output(lambda: render.print_user(text))
|
|
1233
|
+
self._thinking["flag"] = True
|
|
1234
|
+
self._thinking["start"] = time.monotonic()
|
|
1235
|
+
self._thinking["gerund"] = random.choice(_GERUNDS)
|
|
1236
|
+
self._thinking["last_swap"] = self._thinking["start"]
|
|
1237
|
+
self.app.invalidate()
|
|
1238
|
+
|
|
1239
|
+
cancel_event = threading.Event()
|
|
1240
|
+
self._cancel_event = cancel_event
|
|
1241
|
+
approver = self._make_approver()
|
|
1242
|
+
assert self._loop is not None
|
|
1243
|
+
try:
|
|
1244
|
+
result = await self._loop.run_in_executor(
|
|
1245
|
+
None,
|
|
1246
|
+
functools.partial(
|
|
1247
|
+
self.orchestrator.submit_task, text, approver=approver, cancel=cancel_event.is_set
|
|
1248
|
+
),
|
|
1249
|
+
)
|
|
1250
|
+
except Exception as exc: # noqa: BLE001 - the TUI must not die on runtime errors
|
|
1251
|
+
log.exception("turn failed")
|
|
1252
|
+
error_text = str(exc)
|
|
1253
|
+
self._append_output(lambda: render.print_error(error_text))
|
|
1254
|
+
else:
|
|
1255
|
+
seconds = int(time.monotonic() - self._thinking["start"])
|
|
1256
|
+
self._emit_turn_result(result, seconds)
|
|
1257
|
+
finally:
|
|
1258
|
+
self._thinking["flag"] = False
|
|
1259
|
+
self._cancel_event = None
|
|
1260
|
+
self.app.invalidate()
|
|
1261
|
+
|
|
1262
|
+
def _run_slash(self, text: str) -> str:
|
|
1263
|
+
if text.strip() == "/clear":
|
|
1264
|
+
self.output.reset()
|
|
1265
|
+
if self.app.is_running:
|
|
1266
|
+
self.app.invalidate()
|
|
1267
|
+
return "continue"
|
|
1268
|
+
outcome = "continue"
|
|
1269
|
+
|
|
1270
|
+
def _do() -> None:
|
|
1271
|
+
nonlocal outcome
|
|
1272
|
+
outcome = handle_command(self.orchestrator, text)
|
|
1273
|
+
|
|
1274
|
+
self._append_output(_do)
|
|
1275
|
+
return outcome
|
|
1276
|
+
|
|
1277
|
+
async def _run_workflow(self, name: str) -> None:
|
|
1278
|
+
"""Run a saved workflow's steps in sequence through `_submit_text`,
|
|
1279
|
+
so each step gets identical treatment to typing it in directly —
|
|
1280
|
+
slash commands and prompts both work, spinner/approval included."""
|
|
1281
|
+
workflow = self.orchestrator.workflow_store.get(name)
|
|
1282
|
+
if workflow is None:
|
|
1283
|
+
self._append_output(lambda: render.print_error(f"no such workflow: {name}"))
|
|
1284
|
+
return
|
|
1285
|
+
self._append_output(
|
|
1286
|
+
lambda: render.print_info(f"running workflow '{name}' ({len(workflow.steps)} steps)")
|
|
1287
|
+
)
|
|
1288
|
+
for step in workflow.steps:
|
|
1289
|
+
await self._submit_text(step)
|
|
1290
|
+
|
|
1291
|
+
# --- approval bridge (worker thread <-> main loop thread) --------------
|
|
1292
|
+
|
|
1293
|
+
def _make_approver(self) -> Callable[[str], bool]:
|
|
1294
|
+
loop = self._loop
|
|
1295
|
+
assert loop is not None
|
|
1296
|
+
|
|
1297
|
+
def approve(prompt_text: str) -> bool:
|
|
1298
|
+
done = threading.Event()
|
|
1299
|
+
|
|
1300
|
+
def _show() -> None:
|
|
1301
|
+
self._approving["prompt"] = prompt_text
|
|
1302
|
+
self._approving["result"] = False
|
|
1303
|
+
self._approving["event"] = done
|
|
1304
|
+
self._approving["flag"] = True
|
|
1305
|
+
self._append_output(lambda: render.console.print(Text(prompt_text, style=f"bold {WARN}")))
|
|
1306
|
+
|
|
1307
|
+
loop.call_soon_threadsafe(_show)
|
|
1308
|
+
done.wait()
|
|
1309
|
+
return bool(self._approving["result"])
|
|
1310
|
+
|
|
1311
|
+
return approve
|
|
1312
|
+
|
|
1313
|
+
def _resolve_approval(self, value: bool) -> None:
|
|
1314
|
+
self._approving["result"] = value
|
|
1315
|
+
self._approving["flag"] = False
|
|
1316
|
+
event: threading.Event | None = self._approving["event"]
|
|
1317
|
+
self._approving["event"] = None
|
|
1318
|
+
self.app.invalidate()
|
|
1319
|
+
if event is not None:
|
|
1320
|
+
event.set()
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def run(orchestrator: Orchestrator, initial_prompt: str | None = None) -> int:
|
|
1324
|
+
"""Entry point: build and run the full-screen chat app.
|
|
1325
|
+
|
|
1326
|
+
Reuses an already-resumed session or starts fresh (matching the prior
|
|
1327
|
+
ui/repl.py::repl behavior). Returns 0 on a clean exit.
|
|
1328
|
+
|
|
1329
|
+
If `initial_prompt` is given, it's submitted as the first turn as soon as
|
|
1330
|
+
the app starts rendering — the interactive equivalent of typing it into
|
|
1331
|
+
the box and pressing Enter, so the session stays open afterward (unlike
|
|
1332
|
+
`reid exec`, which runs one prompt headless and exits).
|
|
1333
|
+
"""
|
|
1334
|
+
chat = ChatApp(orchestrator, initial_prompt=initial_prompt)
|
|
1335
|
+
original_console = render.console
|
|
1336
|
+
render.console = chat.capture.console
|
|
1337
|
+
try:
|
|
1338
|
+
chat.start()
|
|
1339
|
+
code = asyncio.run(chat.main())
|
|
1340
|
+
finally:
|
|
1341
|
+
render.console = original_console
|
|
1342
|
+
render.console.print(Text("bye.", style="dim"))
|
|
1343
|
+
return code or 0
|