webbee 0.3.10__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.10 → webbee-0.3.12}/CHANGELOG.md +18 -0
- {webbee-0.3.10 → webbee-0.3.12}/PKG-INFO +1 -1
- {webbee-0.3.10 → webbee-0.3.12}/pyproject.toml +1 -1
- webbee-0.3.12/src/webbee/__init__.py +1 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/commands.py +19 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/frames.py +23 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/render.py +57 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/repl.py +14 -1
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/session.py +15 -8
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/tui.py +142 -24
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_commands.py +34 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_marathon.py +64 -1
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_render.py +92 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_repl.py +12 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_session.py +7 -0
- webbee-0.3.12/tests/test_tui.py +859 -0
- webbee-0.3.10/src/webbee/__init__.py +0 -1
- webbee-0.3.10/tests/test_tui.py +0 -414
- {webbee-0.3.10 → webbee-0.3.12}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/.gitignore +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/LICENSE +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/README.md +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/install.sh +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/account.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/boot.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/cli.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/config.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/consent.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/details.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/events.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/remote.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/repo.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/sessions.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/steer.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/stream.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/thread.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/tokens.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/tools.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/src/webbee/update.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/__init__.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_account.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_cli.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_clipboard.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_config.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_details.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_events.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_query.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_service.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_store.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_packaging.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_repo.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_sessions.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_steer.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_stream.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_thread.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_tokens.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_tools.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_update.py +0 -0
- {webbee-0.3.10 → webbee-0.3.12}/tests/test_version.py +0 -0
|
@@ -1,5 +1,23 @@
|
|
|
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
|
+
|
|
15
|
+
## 0.3.11
|
|
16
|
+
|
|
17
|
+
- Type-ahead queue: send follow-ups while Webbee is working — they queue and
|
|
18
|
+
run after the current turn; the toolbar shows how many are waiting.
|
|
19
|
+
- Up-arrow recalls your last message to edit and resend.
|
|
20
|
+
|
|
3
21
|
## 0.3.10
|
|
4
22
|
|
|
5
23
|
- Remote instructions picked up by an idle terminal now carry a dedup id, so
|
|
@@ -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
|
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
output_pane.py) fills the top; a bordered input box + toolbar are pinned at
|
|
3
3
|
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
|
-
pinned box when the input is empty and no turn is running
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
pinned box when the input is empty and no turn is running; in every other
|
|
6
|
+
state Up/Down recall submitted lines (readline-style), and Enter while a turn
|
|
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
|
|
11
|
+
unit-tested; the Application is TTY/headless-smoke verified. Grounded in
|
|
12
|
+
prompt_toolkit 3.0.52."""
|
|
8
13
|
import asyncio
|
|
9
14
|
import re
|
|
15
|
+
from collections import deque
|
|
10
16
|
|
|
11
17
|
from webbee.output_pane import OutputPane # noqa: F401 — re-exported (webbee.tui.OutputPane)
|
|
12
18
|
from webbee.render import _fmt_tokens
|
|
@@ -64,13 +70,17 @@ def next_mode(mode: str) -> str:
|
|
|
64
70
|
|
|
65
71
|
def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
|
|
66
72
|
current: str = "", elapsed: float = 0.0, tools: int = 0,
|
|
67
|
-
consent: bool = False) -> list:
|
|
73
|
+
consent: bool = False, queued: int = 0) -> list:
|
|
68
74
|
"""The status line under the pinned input box, as prompt_toolkit formatted
|
|
69
75
|
text (per-segment styled). Three states: consent (awaiting a reply), busy
|
|
70
76
|
(a turn is running — an ANIMATED coloured spinner + the current action in
|
|
71
77
|
accent, so it pops, not grey), and idle (mode value coloured PER MODE —
|
|
72
78
|
default cyan / plan purple / autopilot yellow — + SESSION spend + the
|
|
73
|
-
Shift + TAB hint).
|
|
79
|
+
Shift + TAB hint). `queued` = type-ahead lines waiting to run after the
|
|
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 []
|
|
74
84
|
if consent:
|
|
75
85
|
return [("class:tb.consent", " approve? type y / n / a reply · Enter to send")]
|
|
76
86
|
if busy:
|
|
@@ -79,13 +89,15 @@ def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
|
|
|
79
89
|
if current:
|
|
80
90
|
frags += [("class:tb.dim", " · "), ("class:tb.action", current)]
|
|
81
91
|
frags.append(("class:tb.dim",
|
|
82
|
-
f" · {elapsed:.0f}s · {tools} · {_fmt_tokens(tokens)} tok"
|
|
83
|
-
|
|
92
|
+
f" · {elapsed:.0f}s · {tools} · {_fmt_tokens(tokens)} tok"))
|
|
93
|
+
frags += q
|
|
94
|
+
frags.append(("class:tb.dim", " · Esc/Ctrl-C to stop"))
|
|
84
95
|
return frags
|
|
85
96
|
return [("class:tb.dim", " mode: "),
|
|
86
97
|
(f"class:tb.mode.{mode}", mode),
|
|
87
|
-
("class:tb.dim",
|
|
88
|
-
|
|
98
|
+
("class:tb.dim", f" · {_fmt_tokens(tokens)} tok · {_fmt_tokens(credits)} credits"),
|
|
99
|
+
*q,
|
|
100
|
+
("class:tb.dim", " · Shift + TAB: switch mode")]
|
|
89
101
|
|
|
90
102
|
|
|
91
103
|
def _escape_action(sel: dict, turn: dict, is_busy, stop_turn, event, buf=None) -> None:
|
|
@@ -105,6 +117,7 @@ def _escape_action(sel: dict, turn: dict, is_busy, stop_turn, event, buf=None) -
|
|
|
105
117
|
if t is not None and not t.done():
|
|
106
118
|
if stop_turn is not None:
|
|
107
119
|
event.app.create_background_task(stop_turn())
|
|
120
|
+
turn["stopped"] = True # user STOP → the type-ahead queue must NOT auto-run
|
|
108
121
|
t.cancel() # cancel the running turn; dock survives
|
|
109
122
|
return
|
|
110
123
|
sel["i"] = None
|
|
@@ -119,18 +132,96 @@ def _interrupt_action(turn: dict, is_busy, stop_turn, event) -> None:
|
|
|
119
132
|
if t is not None and not t.done():
|
|
120
133
|
if is_busy() and stop_turn is not None:
|
|
121
134
|
event.app.create_background_task(stop_turn())
|
|
135
|
+
turn["stopped"] = True # user STOP → the type-ahead queue must NOT auto-run
|
|
122
136
|
t.cancel() # cancel the running turn; dock survives
|
|
123
137
|
|
|
124
138
|
|
|
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"
|
|
150
|
+
buf.history.append_string(text)
|
|
151
|
+
if busy:
|
|
152
|
+
pending.append(text)
|
|
153
|
+
if queued_echo is not None:
|
|
154
|
+
queued_echo(text)
|
|
155
|
+
return "queued"
|
|
156
|
+
start(text)
|
|
157
|
+
return "started"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _drain_pending(pending, start, mark=None) -> bool:
|
|
161
|
+
"""Turn-completion drain: pop the OLDEST queued type-ahead line and hand
|
|
162
|
+
it to `start` — the SAME path a typed line takes, so a persistent
|
|
163
|
+
marathon receives it as a `new_task` into the running session. ONE item
|
|
164
|
+
per completion; the rest stay queued FIFO (each finished turn drains the
|
|
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."""
|
|
168
|
+
if not pending:
|
|
169
|
+
return False
|
|
170
|
+
text = pending.popleft()
|
|
171
|
+
if mark is not None:
|
|
172
|
+
mark(len(pending))
|
|
173
|
+
start(text)
|
|
174
|
+
return True
|
|
175
|
+
|
|
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
|
+
|
|
185
|
+
def _arrow_up_action(event, buf, sel: dict, n: int, busy: bool) -> None:
|
|
186
|
+
"""Up key. Step-navigation EXACTLY as before when steps exist + input is
|
|
187
|
+
empty + idle; in EVERY other state (busy, or text present, or no steps)
|
|
188
|
+
recall an older submitted line into the buffer (readline-style)."""
|
|
189
|
+
if n and not buf.text and not busy:
|
|
190
|
+
sel["i"] = (n - 1) if sel["i"] is None else max(0, sel["i"] - 1)
|
|
191
|
+
event.app.invalidate()
|
|
192
|
+
return
|
|
193
|
+
buf.history_backward()
|
|
194
|
+
event.app.invalidate()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _arrow_down_action(event, buf, sel: dict, n: int, busy: bool) -> None:
|
|
198
|
+
"""Down key — mirror of _arrow_up_action: step-nav on the same exact
|
|
199
|
+
gate, otherwise cycle history forward."""
|
|
200
|
+
if n and not buf.text and not busy:
|
|
201
|
+
sel["i"] = 0 if sel["i"] is None else min(n - 1, sel["i"] + 1)
|
|
202
|
+
event.app.invalidate()
|
|
203
|
+
return
|
|
204
|
+
buf.history_forward()
|
|
205
|
+
event.app.invalidate()
|
|
206
|
+
|
|
207
|
+
|
|
125
208
|
async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
126
209
|
is_busy, consent_pending, resolve_consent, steps_nav=None,
|
|
127
|
-
stop_turn=None
|
|
210
|
+
stop_turn=None, pending=None, queued_echo=None,
|
|
211
|
+
queued_run=None) -> bool:
|
|
128
212
|
"""The full-screen dock: `pane` fills the top (scrollable), a bordered input
|
|
129
213
|
box + toolbar are FIXED at the bottom. Enter either resolves a pending
|
|
130
214
|
consent reply (ICNLI: raw verbatim) or starts a turn as a BACKGROUND task
|
|
131
|
-
(the box stays fixed during it)
|
|
132
|
-
|
|
133
|
-
|
|
215
|
+
(the box stays fixed during it); while a turn runs, Enter QUEUES the line
|
|
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
|
|
222
|
+
is running, Up/Down move a step selection (toolbar shows `step k/N`) and
|
|
223
|
+
Enter expands it via `steps_nav["expand"]`; Esc clears it. In every other
|
|
224
|
+
state Up/Down recall submitted lines (readline-style history).
|
|
134
225
|
Returns True on clean exit; False if prompt_toolkit is unavailable (caller
|
|
135
226
|
uses the plain fallback loop)."""
|
|
136
227
|
try:
|
|
@@ -147,12 +238,32 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
|
147
238
|
|
|
148
239
|
buf = Buffer(multiline=False)
|
|
149
240
|
turn = {"task": None}
|
|
241
|
+
if pending is None:
|
|
242
|
+
pending = deque() # type-ahead queue: lines submitted while a turn runs
|
|
243
|
+
|
|
244
|
+
def _start_turn(text):
|
|
245
|
+
turn.pop("stopped", None) # a stale stop flag must never eat the next natural drain
|
|
246
|
+
turn["task"] = get_app().create_background_task(_run_turn(text))
|
|
150
247
|
|
|
151
248
|
async def _run_turn(text):
|
|
249
|
+
done = False
|
|
152
250
|
try:
|
|
153
251
|
await on_line(text)
|
|
252
|
+
done = True
|
|
154
253
|
finally:
|
|
155
254
|
turn["task"] = None
|
|
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)
|
|
156
267
|
get_app().invalidate()
|
|
157
268
|
|
|
158
269
|
kb = KeyBindings()
|
|
@@ -178,9 +289,21 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
|
178
289
|
idx, sel["i"] = sel["i"], None
|
|
179
290
|
event.app.create_background_task(steps_nav["expand"](idx))
|
|
180
291
|
return
|
|
181
|
-
if
|
|
292
|
+
if not text.strip():
|
|
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))
|
|
182
301
|
return
|
|
183
|
-
|
|
302
|
+
# Non-empty line: record for up-arrow recall, then queue-while-busy
|
|
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":
|
|
306
|
+
event.app.invalidate() # toolbar shows the new depth
|
|
184
307
|
|
|
185
308
|
@kb.add("s-tab")
|
|
186
309
|
def _cycle(event):
|
|
@@ -206,17 +329,11 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
|
206
329
|
|
|
207
330
|
@kb.add("up")
|
|
208
331
|
def _step_up(event):
|
|
209
|
-
|
|
210
|
-
if n and not buf.text and not _busy_live():
|
|
211
|
-
sel["i"] = (n - 1) if sel["i"] is None else max(0, sel["i"] - 1)
|
|
212
|
-
event.app.invalidate()
|
|
332
|
+
_arrow_up_action(event, buf, sel, _nav_count(), _busy_live())
|
|
213
333
|
|
|
214
334
|
@kb.add("down")
|
|
215
335
|
def _step_down(event):
|
|
216
|
-
|
|
217
|
-
if n and not buf.text and not _busy_live():
|
|
218
|
-
sel["i"] = 0 if sel["i"] is None else min(n - 1, sel["i"] + 1)
|
|
219
|
-
event.app.invalidate()
|
|
336
|
+
_arrow_down_action(event, buf, sel, _nav_count(), _busy_live())
|
|
220
337
|
|
|
221
338
|
@kb.add("escape")
|
|
222
339
|
def _step_clear(event):
|
|
@@ -239,7 +356,8 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
|
239
356
|
st = status()
|
|
240
357
|
return build_toolbar(mode_getter(), st["tokens"], st["credits"], busy=st["busy"],
|
|
241
358
|
current=st["current"], elapsed=st["elapsed"],
|
|
242
|
-
tools=st["tools"], consent=st["consent"]
|
|
359
|
+
tools=st["tools"], consent=st["consent"],
|
|
360
|
+
queued=len(pending))
|
|
243
361
|
|
|
244
362
|
# Dynamic height: EXACTLY the rows the wrapped input needs (1→10), so the box
|
|
245
363
|
# grows as you type and shrinks back — never a fixed huge block. Enter still
|
|
@@ -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 == []
|