@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.
Files changed (135) hide show
  1. package/README.md +724 -0
  2. package/bin/reidx.js +33 -0
  3. package/package.json +40 -0
  4. package/pyproject.toml +45 -0
  5. package/scripts/find-python.js +24 -0
  6. package/scripts/postinstall.js +35 -0
  7. package/src/reidx/__init__.py +3 -0
  8. package/src/reidx/__main__.py +9 -0
  9. package/src/reidx/__pycache__/__init__.cpython-312.pyc +0 -0
  10. package/src/reidx/app/__init__.py +3 -0
  11. package/src/reidx/app/__pycache__/__init__.cpython-312.pyc +0 -0
  12. package/src/reidx/app/__pycache__/commands.cpython-312.pyc +0 -0
  13. package/src/reidx/app/commands.py +221 -0
  14. package/src/reidx/automation/__init__.py +3 -0
  15. package/src/reidx/automation/__pycache__/__init__.cpython-312.pyc +0 -0
  16. package/src/reidx/automation/__pycache__/exec.cpython-312.pyc +0 -0
  17. package/src/reidx/automation/exec.py +35 -0
  18. package/src/reidx/config/__init__.py +10 -0
  19. package/src/reidx/config/__pycache__/__init__.cpython-312.pyc +0 -0
  20. package/src/reidx/config/__pycache__/loader.cpython-312.pyc +0 -0
  21. package/src/reidx/config/__pycache__/models.cpython-312.pyc +0 -0
  22. package/src/reidx/config/__pycache__/settings.cpython-312.pyc +0 -0
  23. package/src/reidx/config/loader.py +104 -0
  24. package/src/reidx/config/models.py +49 -0
  25. package/src/reidx/config/settings.py +127 -0
  26. package/src/reidx/deepreid/__init__.py +8 -0
  27. package/src/reidx/deepreid/__pycache__/__init__.cpython-312.pyc +0 -0
  28. package/src/reidx/deepreid/__pycache__/pipeline.cpython-312.pyc +0 -0
  29. package/src/reidx/deepreid/pipeline.py +221 -0
  30. package/src/reidx/diagnostics/__init__.py +0 -0
  31. package/src/reidx/diagnostics/__pycache__/__init__.cpython-312.pyc +0 -0
  32. package/src/reidx/diagnostics/__pycache__/logger.cpython-312.pyc +0 -0
  33. package/src/reidx/diagnostics/logger.py +56 -0
  34. package/src/reidx/goals/__init__.py +21 -0
  35. package/src/reidx/goals/__pycache__/__init__.cpython-312.pyc +0 -0
  36. package/src/reidx/goals/__pycache__/models.cpython-312.pyc +0 -0
  37. package/src/reidx/goals/__pycache__/store.cpython-312.pyc +0 -0
  38. package/src/reidx/goals/models.py +101 -0
  39. package/src/reidx/goals/store.py +233 -0
  40. package/src/reidx/integrations/__init__.py +3 -0
  41. package/src/reidx/integrations/mcp.py +70 -0
  42. package/src/reidx/nyx/__init__.py +3 -0
  43. package/src/reidx/nyx/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/src/reidx/nyx/__pycache__/persona.cpython-312.pyc +0 -0
  45. package/src/reidx/nyx/persona.py +35 -0
  46. package/src/reidx/policy/__init__.py +15 -0
  47. package/src/reidx/policy/__pycache__/__init__.cpython-312.pyc +0 -0
  48. package/src/reidx/policy/__pycache__/engine.cpython-312.pyc +0 -0
  49. package/src/reidx/policy/__pycache__/models.cpython-312.pyc +0 -0
  50. package/src/reidx/policy/engine.py +101 -0
  51. package/src/reidx/policy/models.py +35 -0
  52. package/src/reidx/provider/__init__.py +16 -0
  53. package/src/reidx/provider/__pycache__/__init__.cpython-312.pyc +0 -0
  54. package/src/reidx/provider/__pycache__/_http.cpython-312.pyc +0 -0
  55. package/src/reidx/provider/__pycache__/anthropic.cpython-312.pyc +0 -0
  56. package/src/reidx/provider/__pycache__/base.cpython-312.pyc +0 -0
  57. package/src/reidx/provider/__pycache__/ollama.cpython-312.pyc +0 -0
  58. package/src/reidx/provider/__pycache__/openai.cpython-312.pyc +0 -0
  59. package/src/reidx/provider/__pycache__/registry.cpython-312.pyc +0 -0
  60. package/src/reidx/provider/__pycache__/store.cpython-312.pyc +0 -0
  61. package/src/reidx/provider/__pycache__/stub.cpython-312.pyc +0 -0
  62. package/src/reidx/provider/_http.py +29 -0
  63. package/src/reidx/provider/anthropic.py +176 -0
  64. package/src/reidx/provider/base.py +50 -0
  65. package/src/reidx/provider/ollama.py +109 -0
  66. package/src/reidx/provider/openai.py +144 -0
  67. package/src/reidx/provider/registry.py +88 -0
  68. package/src/reidx/provider/store.py +141 -0
  69. package/src/reidx/provider/stub.py +60 -0
  70. package/src/reidx/provider_manager/__init__.py +40 -0
  71. package/src/reidx/provider_manager/__pycache__/__init__.cpython-312.pyc +0 -0
  72. package/src/reidx/provider_manager/__pycache__/catalog.cpython-312.pyc +0 -0
  73. package/src/reidx/provider_manager/__pycache__/database.cpython-312.pyc +0 -0
  74. package/src/reidx/provider_manager/__pycache__/keychain.cpython-312.pyc +0 -0
  75. package/src/reidx/provider_manager/__pycache__/palette.cpython-312.pyc +0 -0
  76. package/src/reidx/provider_manager/catalog.py +330 -0
  77. package/src/reidx/provider_manager/database.py +234 -0
  78. package/src/reidx/provider_manager/keychain.py +102 -0
  79. package/src/reidx/provider_manager/palette.py +831 -0
  80. package/src/reidx/runtime/__init__.py +5 -0
  81. package/src/reidx/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
  82. package/src/reidx/runtime/__pycache__/agent.cpython-312.pyc +0 -0
  83. package/src/reidx/runtime/__pycache__/orchestrator.cpython-312.pyc +0 -0
  84. package/src/reidx/runtime/__pycache__/reasoning.cpython-312.pyc +0 -0
  85. package/src/reidx/runtime/__pycache__/state.cpython-312.pyc +0 -0
  86. package/src/reidx/runtime/__pycache__/subagent.cpython-312.pyc +0 -0
  87. package/src/reidx/runtime/agent.py +198 -0
  88. package/src/reidx/runtime/orchestrator.py +244 -0
  89. package/src/reidx/runtime/reasoning.py +77 -0
  90. package/src/reidx/runtime/state.py +32 -0
  91. package/src/reidx/runtime/subagent.py +174 -0
  92. package/src/reidx/session/__init__.py +4 -0
  93. package/src/reidx/session/__pycache__/__init__.cpython-312.pyc +0 -0
  94. package/src/reidx/session/__pycache__/models.cpython-312.pyc +0 -0
  95. package/src/reidx/session/__pycache__/store.cpython-312.pyc +0 -0
  96. package/src/reidx/session/models.py +43 -0
  97. package/src/reidx/session/store.py +101 -0
  98. package/src/reidx/tasks/__init__.py +4 -0
  99. package/src/reidx/tasks/__pycache__/__init__.cpython-312.pyc +0 -0
  100. package/src/reidx/tasks/__pycache__/models.cpython-312.pyc +0 -0
  101. package/src/reidx/tasks/__pycache__/store.cpython-312.pyc +0 -0
  102. package/src/reidx/tasks/models.py +44 -0
  103. package/src/reidx/tasks/store.py +86 -0
  104. package/src/reidx/tools/__init__.py +44 -0
  105. package/src/reidx/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  106. package/src/reidx/tools/__pycache__/base.cpython-312.pyc +0 -0
  107. package/src/reidx/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
  108. package/src/reidx/tools/__pycache__/registry.cpython-312.pyc +0 -0
  109. package/src/reidx/tools/__pycache__/shell_tool.cpython-312.pyc +0 -0
  110. package/src/reidx/tools/__pycache__/spawn_agent.cpython-312.pyc +0 -0
  111. package/src/reidx/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
  112. package/src/reidx/tools/base.py +84 -0
  113. package/src/reidx/tools/file_tools.py +222 -0
  114. package/src/reidx/tools/registry.py +45 -0
  115. package/src/reidx/tools/shell_tool.py +75 -0
  116. package/src/reidx/tools/spawn_agent.py +185 -0
  117. package/src/reidx/tools/web_tools.py +216 -0
  118. package/src/reidx/ui/__init__.py +41 -0
  119. package/src/reidx/ui/__pycache__/__init__.cpython-312.pyc +0 -0
  120. package/src/reidx/ui/__pycache__/app.cpython-312.pyc +0 -0
  121. package/src/reidx/ui/__pycache__/commands.cpython-312.pyc +0 -0
  122. package/src/reidx/ui/__pycache__/render.cpython-312.pyc +0 -0
  123. package/src/reidx/ui/__pycache__/repl.cpython-312.pyc +0 -0
  124. package/src/reidx/ui/__pycache__/theme.cpython-312.pyc +0 -0
  125. package/src/reidx/ui/app.py +1343 -0
  126. package/src/reidx/ui/commands.py +663 -0
  127. package/src/reidx/ui/render.py +517 -0
  128. package/src/reidx/ui/repl.py +14 -0
  129. package/src/reidx/ui/theme.py +126 -0
  130. package/src/reidx/workflows/__init__.py +4 -0
  131. package/src/reidx/workflows/__pycache__/__init__.cpython-312.pyc +0 -0
  132. package/src/reidx/workflows/__pycache__/models.cpython-312.pyc +0 -0
  133. package/src/reidx/workflows/__pycache__/store.cpython-312.pyc +0 -0
  134. package/src/reidx/workflows/models.py +19 -0
  135. 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