webbee 0.3.11__tar.gz → 0.3.12__tar.gz
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.
- {webbee-0.3.11 → webbee-0.3.12}/CHANGELOG.md +12 -0
- {webbee-0.3.11 → webbee-0.3.12}/PKG-INFO +1 -1
- {webbee-0.3.11 → webbee-0.3.12}/pyproject.toml +1 -1
- webbee-0.3.12/src/webbee/__init__.py +1 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/commands.py +19 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/frames.py +23 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/render.py +57 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/repl.py +14 -1
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/session.py +15 -8
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/tui.py +79 -30
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_commands.py +34 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_marathon.py +64 -1
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_render.py +92 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_repl.py +12 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_session.py +7 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_tui.py +226 -0
- webbee-0.3.11/src/webbee/__init__.py +0 -1
- {webbee-0.3.11 → webbee-0.3.12}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/.gitignore +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/LICENSE +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/README.md +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/install.sh +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/account.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/boot.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/cli.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/config.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/consent.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/details.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/events.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/remote.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/repo.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/sessions.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/steer.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/stream.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/thread.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/tokens.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/tools.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/src/webbee/update.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/__init__.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_account.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_cli.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_clipboard.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_config.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_details.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_events.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_query.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_service.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_store.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_packaging.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_repo.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_sessions.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_steer.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_stream.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_thread.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_tokens.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_tools.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_update.py +0 -0
- {webbee-0.3.11 → webbee-0.3.12}/tests/test_version.py +0 -0
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.12
|
|
4
|
+
|
|
5
|
+
- Queued messages are now visible — when you type while Webbee is working,
|
|
6
|
+
your message shows as `⋯ queued: …` right away, the toolbar shows the count,
|
|
7
|
+
and it runs (clearly) after the current turn. New `/queue` and `/queue clear`
|
|
8
|
+
to see and manage what's waiting — they work even mid-turn.
|
|
9
|
+
- Stopping a turn (Esc/Ctrl-C) no longer auto-runs the queue: it stays put,
|
|
10
|
+
visible, until you clear it or a next turn finishes naturally.
|
|
11
|
+
- Task lists now render in full — every to-do with its status (✓ done ·
|
|
12
|
+
▶ doing · ○ next), redrawn on each update, so you always see the whole plan
|
|
13
|
+
and where Webbee is in it.
|
|
14
|
+
|
|
3
15
|
## 0.3.11
|
|
4
16
|
|
|
5
17
|
- Type-ahead queue: send follow-ups while Webbee is working — they queue and
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.12"
|
|
@@ -10,6 +10,7 @@ _HELP = """Commands:
|
|
|
10
10
|
/mode [default|plan|autopilot] consent mode (no arg — show current)
|
|
11
11
|
/cost (=/usage) tokens + credits this session
|
|
12
12
|
/status cwd · git · surface · tokens · version
|
|
13
|
+
/queue [clear] messages queued while Webbee works (clear drops them all)
|
|
13
14
|
/steps [N] last turn's steps; N expands one (also: Up/Down + Enter)
|
|
14
15
|
/checkpoints the reversibility time machine — list workspace checkpoints
|
|
15
16
|
/rollback <ref> restore the workspace to a checkpoint (id, cp-N or N)
|
|
@@ -30,6 +31,7 @@ class CommandContext:
|
|
|
30
31
|
session_tokens: int
|
|
31
32
|
session_credits: int
|
|
32
33
|
git_branch: str
|
|
34
|
+
queued: tuple = () # snapshot of the dock's type-ahead queue, oldest first
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
@dataclass(frozen=True)
|
|
@@ -78,6 +80,23 @@ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
|
|
|
78
80
|
return SlashResult(handled=True, action="sessions")
|
|
79
81
|
if cmd == "/logout-others":
|
|
80
82
|
return SlashResult(handled=True, action="logout_others")
|
|
83
|
+
if cmd == "/queue":
|
|
84
|
+
# ctx.queued is the live deque's snapshot (threaded by the repl the
|
|
85
|
+
# same way /status reads session state); the repl clears the actual
|
|
86
|
+
# deque on action="queue_clear" — the message here already carries
|
|
87
|
+
# the honest drop count.
|
|
88
|
+
if args and args[0].lower() == "clear":
|
|
89
|
+
n = len(ctx.queued)
|
|
90
|
+
return SlashResult(handled=True, action="queue_clear",
|
|
91
|
+
message=(f"Queue cleared ({n} dropped)." if n
|
|
92
|
+
else "Queue is already empty."))
|
|
93
|
+
if not ctx.queued:
|
|
94
|
+
return SlashResult(handled=True, action="queue",
|
|
95
|
+
message="Queue is empty — press Enter while Webbee is working to queue a message.")
|
|
96
|
+
lines = "\n".join(f" {i}. {t}" for i, t in enumerate(ctx.queued, 1))
|
|
97
|
+
return SlashResult(handled=True, action="queue",
|
|
98
|
+
message=(f"Queued ({len(ctx.queued)}) — runs in order, "
|
|
99
|
+
f"one per finished turn:\n{lines}"))
|
|
81
100
|
if cmd == "/steps":
|
|
82
101
|
if args:
|
|
83
102
|
return SlashResult(handled=True, action="step_detail", arg=args[0])
|
|
@@ -250,3 +250,26 @@ def marathon_note(frame: dict) -> str:
|
|
|
250
250
|
head = f"📋 Todos {done}/{total}"
|
|
251
251
|
return f"{head} — now: {current}" if current else head
|
|
252
252
|
return str(frame.get("type", ""))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def render_todo_frame(frame: dict, sink) -> None:
|
|
256
|
+
"""Full-checklist render for a `todo` FACT (I-WEBBEE-CODE-TODO-SCRATCHPAD).
|
|
257
|
+
The kernel republishes the WHOLE list on every todo_write, so route it to
|
|
258
|
+
sink.todos(items, total, done) — the sink redraws the complete current
|
|
259
|
+
checklist inline (Claude-Code-style: the latest full list always sits in
|
|
260
|
+
the scrollback). Guarded like the other optional sink hooks: a minimal
|
|
261
|
+
sink without `todos` degrades to the old one-line marathon_note, and a
|
|
262
|
+
render error must never break the frame loop in session.run()."""
|
|
263
|
+
todos = frame.get("todos") if isinstance(frame.get("todos"), list) else []
|
|
264
|
+
total = frame.get("total", len(todos))
|
|
265
|
+
done = frame.get("completed", 0)
|
|
266
|
+
render = getattr(sink, "todos", None)
|
|
267
|
+
try:
|
|
268
|
+
if render is not None:
|
|
269
|
+
render(todos, total, done)
|
|
270
|
+
else:
|
|
271
|
+
note = getattr(sink, "note", None)
|
|
272
|
+
if note is not None:
|
|
273
|
+
note(marathon_note(frame))
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
@@ -215,6 +215,40 @@ class RichSink:
|
|
|
215
215
|
self.console.print(_pad(Text(_clean(message), style=_BEE)))
|
|
216
216
|
self._nudge()
|
|
217
217
|
|
|
218
|
+
def todos(self, items: list, total: int, done: int) -> None:
|
|
219
|
+
"""Claude-Code-style checklist for the coding TODO scratchpad. The
|
|
220
|
+
kernel republishes the FULL list on every todo_write, so each update
|
|
221
|
+
re-renders the whole current plan inline — the latest complete list
|
|
222
|
+
always sits in the scrollback (deliberately NOT a pinned sticky panel:
|
|
223
|
+
inline re-render can't fight the dock layout). Order preserved, one
|
|
224
|
+
line per item: ✓ completed (dim, struck) · ▶ in progress (bold, so
|
|
225
|
+
"what's happening now" pops) · ○ pending (muted). Defensive: malformed
|
|
226
|
+
items are skipped, unknown statuses render as pending, bad counts
|
|
227
|
+
degrade to a bare header — never raises."""
|
|
228
|
+
try:
|
|
229
|
+
head = f"📋 Todos ({int(done)}/{int(total)})"
|
|
230
|
+
except (TypeError, ValueError):
|
|
231
|
+
head = "📋 Todos"
|
|
232
|
+
body = Text() # empty base style — segments below
|
|
233
|
+
body.append(head, style=f"bold {_BEE}") # carry their OWN, no bleed-through
|
|
234
|
+
for item in (items if isinstance(items, (list, tuple)) else ()):
|
|
235
|
+
if not isinstance(item, dict):
|
|
236
|
+
continue # malformed entry — skip, never raise
|
|
237
|
+
content = _clean(item.get("content", "")).strip()
|
|
238
|
+
if not content:
|
|
239
|
+
continue
|
|
240
|
+
status = str(item.get("status", "") or "")
|
|
241
|
+
if status == "completed":
|
|
242
|
+
glyph, g_style, t_style = "✓", "green", "dim strike"
|
|
243
|
+
elif status == "in_progress":
|
|
244
|
+
glyph, g_style, t_style = "▶", f"bold {_BEE}", "bold"
|
|
245
|
+
else: # pending / unknown -> not started yet
|
|
246
|
+
glyph, g_style, t_style = "○", "grey66", "grey66"
|
|
247
|
+
body.append(f"\n {glyph} ", style=g_style)
|
|
248
|
+
body.append(content, style=t_style)
|
|
249
|
+
self.console.print(_pad(body))
|
|
250
|
+
self._nudge()
|
|
251
|
+
|
|
218
252
|
def user_echo(self, text: str) -> None:
|
|
219
253
|
"""Commit the just-sent user message as its own clearly-readable line
|
|
220
254
|
with a background bar (NOT boxed like the live input) so it stands out
|
|
@@ -222,6 +256,29 @@ class RichSink:
|
|
|
222
256
|
self.console.print(_pad(Text(" ❯ " + _clean(text) + " ", style="bold white on grey30")))
|
|
223
257
|
self._nudge()
|
|
224
258
|
|
|
259
|
+
def queued_echo(self, text: str) -> None:
|
|
260
|
+
"""Type-ahead visibility (the #1 queue fix): the line you pressed
|
|
261
|
+
Enter on while a turn was running, committed to the scrollback the
|
|
262
|
+
MOMENT it queues — so you SEE exactly what's waiting, in order.
|
|
263
|
+
Muted-but-visible (quieter than user_echo's bar, louder than dim
|
|
264
|
+
chrome). When it drains, queued_run + the normal user_echo mark it
|
|
265
|
+
as the active turn."""
|
|
266
|
+
self.console.print(_pad(Text.assemble(
|
|
267
|
+
("⋯ queued: ", f"bold {_BEE}"),
|
|
268
|
+
(_clean(text), "italic grey66"))))
|
|
269
|
+
self._nudge()
|
|
270
|
+
|
|
271
|
+
def queued_run(self, remaining: int) -> None:
|
|
272
|
+
"""The tiny lifecycle marker printed right before a drained queued
|
|
273
|
+
line starts: `▶ running queued message` (+ how many still wait) — a
|
|
274
|
+
drain is never a silent start; the drained text's normal ❯ user_echo
|
|
275
|
+
follows immediately."""
|
|
276
|
+
tail = f" · {remaining} still queued" if remaining else ""
|
|
277
|
+
self.console.print(_pad(Text.assemble(
|
|
278
|
+
("▶ ", f"bold {_BEE}"),
|
|
279
|
+
(f"running queued message{tail}", "italic grey66"))))
|
|
280
|
+
self._nudge()
|
|
281
|
+
|
|
225
282
|
def foreign_turn(self, surface: str, role: str, text: str) -> None:
|
|
226
283
|
"""One tagged line for a turn that lives on ANOTHER surface (a
|
|
227
284
|
Telegram/panel-steered turn on the shared stream, or a replayed prior
|
|
@@ -2,6 +2,7 @@ import asyncio
|
|
|
2
2
|
import contextlib
|
|
3
3
|
import os
|
|
4
4
|
import sys
|
|
5
|
+
from collections import deque
|
|
5
6
|
|
|
6
7
|
from webbee import __version__
|
|
7
8
|
from webbee.account import login_device_flow
|
|
@@ -80,6 +81,10 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
80
81
|
# inject sink/read_line and take the plain fallback loop.
|
|
81
82
|
use_dock = sink is None and read_line is input and sys.stdin.isatty()
|
|
82
83
|
state = {"mode": mode, "logged_in": False}
|
|
84
|
+
# Type-ahead queue: OWNED here and shared with tui.run_session, so /queue
|
|
85
|
+
# and /queue clear (dispatched through CommandContext.queued, same
|
|
86
|
+
# mechanism /status uses for session state) see the live deque.
|
|
87
|
+
pending_queue: deque = deque()
|
|
83
88
|
_sink = None # assigned by _boot
|
|
84
89
|
agent = None # assigned by _boot
|
|
85
90
|
intel = None # assigned by _boot -- IntelService, or None (off/base-install/boot failure)
|
|
@@ -96,7 +101,8 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
96
101
|
surface="terminal", logged_in=state["logged_in"],
|
|
97
102
|
session_tokens=getattr(_sink, "session_tokens", 0),
|
|
98
103
|
session_credits=getattr(_sink, "session_credits", 0),
|
|
99
|
-
git_branch=state.get("git_branch", "-")
|
|
104
|
+
git_branch=state.get("git_branch", "-"),
|
|
105
|
+
queued=tuple(pending_queue))
|
|
100
106
|
|
|
101
107
|
async def _handle(line: str) -> str:
|
|
102
108
|
"""Process one input line. Returns 'exit' or 'continue'."""
|
|
@@ -198,6 +204,11 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
198
204
|
except Exception as e:
|
|
199
205
|
_sink.note(f"Remote control unavailable: {type(e).__name__}")
|
|
200
206
|
return "continue"
|
|
207
|
+
if res.action == "queue_clear":
|
|
208
|
+
# dispatch built the message (with the drop count) from the
|
|
209
|
+
# ctx snapshot; here we drop the live deque — the toolbar
|
|
210
|
+
# count follows on the sink's redraw below.
|
|
211
|
+
pending_queue.clear()
|
|
201
212
|
if res.action == "clear":
|
|
202
213
|
_sink.clear()
|
|
203
214
|
_sink.note(res.message)
|
|
@@ -314,6 +325,8 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
314
325
|
"expand": lambda i: _handle(f"/steps {i + 1}"),
|
|
315
326
|
},
|
|
316
327
|
stop_turn=lambda: agent.stop(),
|
|
328
|
+
pending=pending_queue, queued_echo=_sink.queued_echo,
|
|
329
|
+
queued_run=_sink.queued_run,
|
|
317
330
|
)
|
|
318
331
|
finally:
|
|
319
332
|
_cancel_background()
|
|
@@ -15,6 +15,7 @@ from webbee.frames import (
|
|
|
15
15
|
handle_tool_request,
|
|
16
16
|
marathon_note,
|
|
17
17
|
render_foreign_frame,
|
|
18
|
+
render_todo_frame,
|
|
18
19
|
)
|
|
19
20
|
|
|
20
21
|
|
|
@@ -226,14 +227,20 @@ class AgentSession:
|
|
|
226
227
|
elif ftype == "marathon_complete": # U4 — the whole GOAL is done: terminal
|
|
227
228
|
return frame.get("text", "")
|
|
228
229
|
|
|
229
|
-
elif ftype in _MARATHON_FACT_TYPES: # U4 — marathon plan/milestone/pause
|
|
230
|
-
# Facts-only
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
230
|
+
elif ftype in _MARATHON_FACT_TYPES: # U4 — marathon plan/milestone/pause/todo
|
|
231
|
+
# Facts-only. A `todo` fact carries the FULL list -> the
|
|
232
|
+
# dedicated full-checklist render (falls back to the old
|
|
233
|
+
# one-line note on a minimal sink). The other facts render
|
|
234
|
+
# ONE human-readable line. Guarded: a minimal sink (no
|
|
235
|
+
# `note`) simply drops the fact rather than crashing the
|
|
236
|
+
# turn (the stream reader already tolerates unknown frame
|
|
237
|
+
# types by ignoring them).
|
|
238
|
+
if ftype == "todo":
|
|
239
|
+
render_todo_frame(frame, sink)
|
|
240
|
+
else:
|
|
241
|
+
_note = getattr(sink, "note", None)
|
|
242
|
+
if _note is not None:
|
|
243
|
+
_note(marathon_note(frame))
|
|
237
244
|
if ftype == "marathon_paused":
|
|
238
245
|
# Parked (out-of-credits / consent / runaway) -> end the
|
|
239
246
|
# turn so the dock leaves "working"; the note shows why, and
|
|
@@ -4,8 +4,10 @@ the very bottom and never move while the output scrolls (mouse wheel /
|
|
|
4
4
|
PageUp). `run_session` also drives step-navigation (Up/Down + Enter) over the
|
|
5
5
|
pinned box when the input is empty and no turn is running; in every other
|
|
6
6
|
state Up/Down recall submitted lines (readline-style), and Enter while a turn
|
|
7
|
-
runs QUEUES the line (Claude-Code type-ahead:
|
|
8
|
-
|
|
7
|
+
runs QUEUES the line (Claude-Code type-ahead: echoed AT ONCE as `⋯ queued:`,
|
|
8
|
+
counted in the toolbar, run after the current turn — natural completion only,
|
|
9
|
+
a user STOP preserves the queue; /queue lists it, /queue clear drops it).
|
|
10
|
+
Pure helpers (next_mode/build_toolbar/the *_action functions) are
|
|
9
11
|
unit-tested; the Application is TTY/headless-smoke verified. Grounded in
|
|
10
12
|
prompt_toolkit 3.0.52."""
|
|
11
13
|
import asyncio
|
|
@@ -75,9 +77,10 @@ def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
|
|
|
75
77
|
accent, so it pops, not grey), and idle (mode value coloured PER MODE —
|
|
76
78
|
default cyan / plan purple / autopilot yellow — + SESSION spend + the
|
|
77
79
|
Shift + TAB hint). `queued` = type-ahead lines waiting to run after the
|
|
78
|
-
current turn; when >0
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
current turn; when >0 the `⋯N queued` segment renders in the ACCENT class
|
|
81
|
+
(tb.working, NOT dim) in the busy AND idle states, so the depth is
|
|
82
|
+
noticeable at a glance. Style classes are defined in run_session's Style."""
|
|
83
|
+
q = [("class:tb.working", f" · ⋯{queued} queued")] if queued else []
|
|
81
84
|
if consent:
|
|
82
85
|
return [("class:tb.consent", " approve? type y / n / a reply · Enter to send")]
|
|
83
86
|
if busy:
|
|
@@ -86,13 +89,15 @@ def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
|
|
|
86
89
|
if current:
|
|
87
90
|
frags += [("class:tb.dim", " · "), ("class:tb.action", current)]
|
|
88
91
|
frags.append(("class:tb.dim",
|
|
89
|
-
f" · {elapsed:.0f}s · {tools} · {_fmt_tokens(tokens)} tok
|
|
90
|
-
|
|
92
|
+
f" · {elapsed:.0f}s · {tools} · {_fmt_tokens(tokens)} tok"))
|
|
93
|
+
frags += q
|
|
94
|
+
frags.append(("class:tb.dim", " · Esc/Ctrl-C to stop"))
|
|
91
95
|
return frags
|
|
92
96
|
return [("class:tb.dim", " mode: "),
|
|
93
97
|
(f"class:tb.mode.{mode}", mode),
|
|
94
|
-
("class:tb.dim",
|
|
95
|
-
|
|
98
|
+
("class:tb.dim", f" · {_fmt_tokens(tokens)} tok · {_fmt_tokens(credits)} credits"),
|
|
99
|
+
*q,
|
|
100
|
+
("class:tb.dim", " · Shift + TAB: switch mode")]
|
|
96
101
|
|
|
97
102
|
|
|
98
103
|
def _escape_action(sel: dict, turn: dict, is_busy, stop_turn, event, buf=None) -> None:
|
|
@@ -112,6 +117,7 @@ def _escape_action(sel: dict, turn: dict, is_busy, stop_turn, event, buf=None) -
|
|
|
112
117
|
if t is not None and not t.done():
|
|
113
118
|
if stop_turn is not None:
|
|
114
119
|
event.app.create_background_task(stop_turn())
|
|
120
|
+
turn["stopped"] = True # user STOP → the type-ahead queue must NOT auto-run
|
|
115
121
|
t.cancel() # cancel the running turn; dock survives
|
|
116
122
|
return
|
|
117
123
|
sel["i"] = None
|
|
@@ -126,35 +132,56 @@ def _interrupt_action(turn: dict, is_busy, stop_turn, event) -> None:
|
|
|
126
132
|
if t is not None and not t.done():
|
|
127
133
|
if is_busy() and stop_turn is not None:
|
|
128
134
|
event.app.create_background_task(stop_turn())
|
|
135
|
+
turn["stopped"] = True # user STOP → the type-ahead queue must NOT auto-run
|
|
129
136
|
t.cancel() # cancel the running turn; dock survives
|
|
130
137
|
|
|
131
138
|
|
|
132
|
-
def _submit_line(text: str, buf, pending, busy: bool, start) -> str:
|
|
133
|
-
"""Route ONE
|
|
134
|
-
|
|
135
|
-
(up-arrow recall), then:
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
def _submit_line(text: str, buf, pending, busy: bool, start, queued_echo=None) -> str:
|
|
140
|
+
"""Route ONE submitted line (dependency-injected, same testing philosophy
|
|
141
|
+
as _escape_action). Whitespace never queues nor starts ("ignored"). A real
|
|
142
|
+
line is recorded into the buffer's history first (up-arrow recall), then:
|
|
143
|
+
busy → QUEUE it (Claude-Code type-ahead — the line is never erased or
|
|
144
|
+
dropped; it runs after the current turn) and echo it into the transcript
|
|
145
|
+
via `queued_echo` (sink.queued_echo, EXACTLY once per queue action — the
|
|
146
|
+
queue must be visible, never a silent deque); idle → `start(text)`
|
|
147
|
+
(today's normal submit). Returns "ignored" | "queued" | "started"."""
|
|
148
|
+
if not text.strip():
|
|
149
|
+
return "ignored"
|
|
138
150
|
buf.history.append_string(text)
|
|
139
151
|
if busy:
|
|
140
152
|
pending.append(text)
|
|
153
|
+
if queued_echo is not None:
|
|
154
|
+
queued_echo(text)
|
|
141
155
|
return "queued"
|
|
142
156
|
start(text)
|
|
143
157
|
return "started"
|
|
144
158
|
|
|
145
159
|
|
|
146
|
-
def _drain_pending(pending, start) -> bool:
|
|
160
|
+
def _drain_pending(pending, start, mark=None) -> bool:
|
|
147
161
|
"""Turn-completion drain: pop the OLDEST queued type-ahead line and hand
|
|
148
162
|
it to `start` — the SAME path a typed line takes, so a persistent
|
|
149
163
|
marathon receives it as a `new_task` into the running session. ONE item
|
|
150
164
|
per completion; the rest stay queued FIFO (each finished turn drains the
|
|
151
|
-
next).
|
|
165
|
+
next). `mark` (sink.queued_run) announces the handoff — `▶ running queued
|
|
166
|
+
message` — right before the drained line's normal ❯ user-echo, so a drain
|
|
167
|
+
is never a silent start. Returns True when a drain happened."""
|
|
152
168
|
if not pending:
|
|
153
169
|
return False
|
|
154
|
-
|
|
170
|
+
text = pending.popleft()
|
|
171
|
+
if mark is not None:
|
|
172
|
+
mark(len(pending))
|
|
173
|
+
start(text)
|
|
155
174
|
return True
|
|
156
175
|
|
|
157
176
|
|
|
177
|
+
def _is_queue_command(text: str) -> bool:
|
|
178
|
+
"""PURE. `/queue` and its subcommands MANAGE the type-ahead queue, so they
|
|
179
|
+
must run exactly when the queue matters — mid-turn. The Enter handler
|
|
180
|
+
routes them past the busy gate (they never type-ahead-queue themselves)."""
|
|
181
|
+
parts = (text or "").strip().lower().split()
|
|
182
|
+
return bool(parts) and parts[0] == "/queue"
|
|
183
|
+
|
|
184
|
+
|
|
158
185
|
def _arrow_up_action(event, buf, sel: dict, n: int, busy: bool) -> None:
|
|
159
186
|
"""Up key. Step-navigation EXACTLY as before when steps exist + input is
|
|
160
187
|
empty + idle; in EVERY other state (busy, or text present, or no steps)
|
|
@@ -180,13 +207,18 @@ def _arrow_down_action(event, buf, sel: dict, n: int, busy: bool) -> None:
|
|
|
180
207
|
|
|
181
208
|
async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
182
209
|
is_busy, consent_pending, resolve_consent, steps_nav=None,
|
|
183
|
-
stop_turn=None
|
|
210
|
+
stop_turn=None, pending=None, queued_echo=None,
|
|
211
|
+
queued_run=None) -> bool:
|
|
184
212
|
"""The full-screen dock: `pane` fills the top (scrollable), a bordered input
|
|
185
213
|
box + toolbar are FIXED at the bottom. Enter either resolves a pending
|
|
186
214
|
consent reply (ICNLI: raw verbatim) or starts a turn as a BACKGROUND task
|
|
187
215
|
(the box stays fixed during it); while a turn runs, Enter QUEUES the line
|
|
188
|
-
(type-ahead —
|
|
189
|
-
`⋯N queued`
|
|
216
|
+
(type-ahead — `queued_echo` commits it to the transcript at once, the
|
|
217
|
+
toolbar shows `⋯N queued` in accent, and it starts right after the current
|
|
218
|
+
turn with a `queued_run` marker). `pending` is the queue deque — the repl
|
|
219
|
+
passes its OWN so /queue and /queue clear (dispatched through the normal
|
|
220
|
+
command layer) see and manage the live queue; /queue itself always runs
|
|
221
|
+
immediately, even mid-turn. When `steps_nav` is given and the input is empty and no turn
|
|
190
222
|
is running, Up/Down move a step selection (toolbar shows `step k/N`) and
|
|
191
223
|
Enter expands it via `steps_nav["expand"]`; Esc clears it. In every other
|
|
192
224
|
state Up/Down recall submitted lines (readline-style history).
|
|
@@ -206,9 +238,11 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
|
206
238
|
|
|
207
239
|
buf = Buffer(multiline=False)
|
|
208
240
|
turn = {"task": None}
|
|
209
|
-
pending
|
|
241
|
+
if pending is None:
|
|
242
|
+
pending = deque() # type-ahead queue: lines submitted while a turn runs
|
|
210
243
|
|
|
211
244
|
def _start_turn(text):
|
|
245
|
+
turn.pop("stopped", None) # a stale stop flag must never eat the next natural drain
|
|
212
246
|
turn["task"] = get_app().create_background_task(_run_turn(text))
|
|
213
247
|
|
|
214
248
|
async def _run_turn(text):
|
|
@@ -218,12 +252,18 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
|
218
252
|
done = True
|
|
219
253
|
finally:
|
|
220
254
|
turn["task"] = None
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
255
|
+
# DRAIN RULE: natural completion ONLY. A user STOP (Esc/Ctrl-C sets
|
|
256
|
+
# turn["stopped"] before cancelling; repl._run_turn absorbs the
|
|
257
|
+
# cancel so on_line still returns) means "I'm taking control" — the
|
|
258
|
+
# queue is PRESERVED, stays visible (toolbar accent + /queue), and
|
|
259
|
+
# never auto-runs; /queue clear drops it, and the next NATURAL
|
|
260
|
+
# completion resumes draining. A propagating exception (dock
|
|
261
|
+
# teardown) also leaves the queue be.
|
|
262
|
+
stopped = turn.pop("stopped", False)
|
|
263
|
+
if done and not stopped:
|
|
264
|
+
# Submit the oldest queued line through the SAME path a typed
|
|
265
|
+
# line takes; queued_run announces it — never a silent start.
|
|
266
|
+
_drain_pending(pending, _start_turn, mark=queued_run)
|
|
227
267
|
get_app().invalidate()
|
|
228
268
|
|
|
229
269
|
kb = KeyBindings()
|
|
@@ -251,9 +291,18 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
|
251
291
|
return
|
|
252
292
|
if not text.strip():
|
|
253
293
|
return
|
|
294
|
+
if _is_queue_command(text):
|
|
295
|
+
# Queue MANAGEMENT runs NOW, even mid-turn (it never queues
|
|
296
|
+
# itself): a display-only background task — the handler only
|
|
297
|
+
# reads/clears the shared deque and prints, so it can't collide
|
|
298
|
+
# with the live turn and never touches turn["task"].
|
|
299
|
+
buf.history.append_string(text)
|
|
300
|
+
event.app.create_background_task(on_line(text))
|
|
301
|
+
return
|
|
254
302
|
# Non-empty line: record for up-arrow recall, then queue-while-busy
|
|
255
|
-
# (type-ahead —
|
|
256
|
-
|
|
303
|
+
# (type-ahead — echoed AT ONCE as `⋯ queued:` + accent toolbar depth,
|
|
304
|
+
# runs after the current turn) or start a turn now.
|
|
305
|
+
if _submit_line(text, buf, pending, _busy_live(), _start_turn, queued_echo) == "queued":
|
|
257
306
|
event.app.invalidate() # toolbar shows the new depth
|
|
258
307
|
|
|
259
308
|
@kb.add("s-tab")
|
|
@@ -152,3 +152,37 @@ def test_notify_command_dispatches():
|
|
|
152
152
|
def test_help_mentions_notify():
|
|
153
153
|
from webbee.commands import dispatch
|
|
154
154
|
assert "/notify" in dispatch("/help", _anyctx()).message
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── /queue — manage the dock's type-ahead queue (0.3.12) ─────────────────────
|
|
158
|
+
# The queue snapshot rides in CommandContext.queued (threaded by the repl the
|
|
159
|
+
# same way /status reads session state); dispatch stays pure.
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_queue_lists_pending_numbered_in_order():
|
|
163
|
+
r = dispatch("/queue", _ctx(queued=("fix the tests", "deploy it")))
|
|
164
|
+
assert r.handled and r.action == "queue"
|
|
165
|
+
assert "1. fix the tests" in r.message and "2. deploy it" in r.message
|
|
166
|
+
assert r.message.index("fix the tests") < r.message.index("deploy it")
|
|
167
|
+
assert not NO_CYRILLIC.search(r.message)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_queue_empty_shows_hint():
|
|
171
|
+
r = dispatch("/queue", _ctx())
|
|
172
|
+
assert r.handled and r.action == "queue" and "empty" in r.message.lower()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_queue_clear_reports_drop_count():
|
|
176
|
+
r = dispatch("/queue clear", _ctx(queued=("a", "b", "c")))
|
|
177
|
+
assert r.handled and r.action == "queue_clear"
|
|
178
|
+
assert "3 dropped" in r.message
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_queue_clear_when_already_empty():
|
|
182
|
+
r = dispatch("/queue clear", _ctx())
|
|
183
|
+
assert r.handled and r.action == "queue_clear"
|
|
184
|
+
assert "already empty" in r.message.lower()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_help_mentions_queue():
|
|
188
|
+
assert "/queue" in dispatch("/help", _ctx()).message
|
|
@@ -152,8 +152,9 @@ def _run_marathon_capture_post(monkeypatch, tmp_path, goal="build X", frames=Non
|
|
|
152
152
|
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
|
|
153
153
|
|
|
154
154
|
class NoteSink:
|
|
155
|
-
def __init__(self): self.notes = []
|
|
155
|
+
def __init__(self): self.notes = []; self.todo_lists = []
|
|
156
156
|
def note(self, msg): self.notes.append(msg)
|
|
157
|
+
def todos(self, items, total, done): self.todo_lists.append((items, total, done))
|
|
157
158
|
def thinking(self, msg): self.thinks = getattr(self, "thinks", []) + [msg]
|
|
158
159
|
def tool_start(self, *a): ...
|
|
159
160
|
def tool_result(self, *a): ...
|
|
@@ -293,6 +294,7 @@ def test_normal_run_omits_marathon_fields(monkeypatch, tmp_path):
|
|
|
293
294
|
def panel_release(self, *a): ...
|
|
294
295
|
def progress(self, *a): ...
|
|
295
296
|
def usage(self, *a): ...
|
|
297
|
+
def todos(self, items, total, done): self.todo_lists = getattr(self, "todo_lists", []) + [(items, total, done)]
|
|
296
298
|
|
|
297
299
|
async def token_provider(): return "tok"
|
|
298
300
|
|
|
@@ -339,3 +341,64 @@ def test_todo_fact_renders_progress_line():
|
|
|
339
341
|
def test_todo_fact_degrades_on_missing_fields():
|
|
340
342
|
from webbee.frames import marathon_note
|
|
341
343
|
assert "0/0" in marathon_note({"type": "todo"}) # never crashes
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# --- todo full-checklist routing (render_todo_frame) --------------------------
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_todo_fact_streams_full_list_to_sink_todos(monkeypatch, tmp_path):
|
|
350
|
+
# A streamed `todo` FACT routes to sink.todos with the FULL ordered list +
|
|
351
|
+
# correct done/total — NOT the old one-line note.
|
|
352
|
+
items = [
|
|
353
|
+
{"content": "map the repo", "status": "completed"},
|
|
354
|
+
{"content": "fix the bug", "status": "in_progress"},
|
|
355
|
+
{"content": "run tests", "status": "pending"},
|
|
356
|
+
]
|
|
357
|
+
_, _, sink = _run_marathon_capture_post(monkeypatch, tmp_path, frames=[
|
|
358
|
+
{"type": "todo", "task_id": "OURS", "total": 3, "completed": 1, "todos": items},
|
|
359
|
+
{"type": "marathon_complete", "task_id": "OURS", "text": "done"},
|
|
360
|
+
])
|
|
361
|
+
assert sink.todo_lists == [(items, 3, 1)]
|
|
362
|
+
assert not any("📋" in n for n in sink.notes) # no duplicate one-liner
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_render_todo_frame_falls_back_to_note_without_todos_hook():
|
|
366
|
+
# Back-compat: a minimal sink WITHOUT `todos` still gets the old one-line note.
|
|
367
|
+
from webbee.frames import render_todo_frame
|
|
368
|
+
|
|
369
|
+
class NoteOnly:
|
|
370
|
+
def __init__(self): self.notes = []
|
|
371
|
+
def note(self, m): self.notes.append(m)
|
|
372
|
+
|
|
373
|
+
s = NoteOnly()
|
|
374
|
+
render_todo_frame({"type": "todo", "total": 2, "completed": 1, "todos": [
|
|
375
|
+
{"content": "x", "status": "completed"},
|
|
376
|
+
{"content": "y", "status": "in_progress"}]}, s)
|
|
377
|
+
assert s.notes == ["📋 Todos 1/2 — now: y"]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_render_todo_frame_never_breaks_the_frame_loop():
|
|
381
|
+
from webbee.frames import render_todo_frame
|
|
382
|
+
|
|
383
|
+
class Boom:
|
|
384
|
+
def todos(self, *a): raise RuntimeError("ui bug")
|
|
385
|
+
|
|
386
|
+
render_todo_frame({"type": "todo"}, Boom()) # must not raise
|
|
387
|
+
|
|
388
|
+
class Bare: # neither todos nor note
|
|
389
|
+
...
|
|
390
|
+
|
|
391
|
+
render_todo_frame({"type": "todo", "todos": "junk"}, Bare()) # must not raise
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def test_marathon_plan_and_milestone_still_one_line_notes(monkeypatch, tmp_path):
|
|
395
|
+
# The split is todo-ONLY: plan/milestone/pause stay on the note path even
|
|
396
|
+
# when the sink HAS a todos hook.
|
|
397
|
+
_, _, sink = _run_marathon_capture_post(monkeypatch, tmp_path, frames=[
|
|
398
|
+
{"type": "marathon_plan", "task_id": "OURS", "milestone_count": 1, "goal": "g"},
|
|
399
|
+
{"type": "milestone", "task_id": "OURS", "index": 1, "title": "t", "status": "done"},
|
|
400
|
+
{"type": "marathon_complete", "task_id": "OURS", "text": "done"},
|
|
401
|
+
])
|
|
402
|
+
assert any("Marathon plan" in n for n in sink.notes)
|
|
403
|
+
assert any("Milestone" in n for n in sink.notes)
|
|
404
|
+
assert sink.todo_lists == []
|