webbee 0.3.23__tar.gz → 0.3.24__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.23 → webbee-0.3.24}/CHANGELOG.md +15 -0
- {webbee-0.3.23 → webbee-0.3.24}/PKG-INFO +1 -1
- {webbee-0.3.23 → webbee-0.3.24}/pyproject.toml +1 -1
- webbee-0.3.24/src/webbee/__init__.py +1 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/repl.py +9 -1
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/slots.py +4 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/tabs.py +38 -23
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/tui.py +55 -25
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_home.py +46 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_tabs.py +84 -28
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_tui.py +146 -22
- webbee-0.3.23/src/webbee/__init__.py +0 -1
- {webbee-0.3.23 → webbee-0.3.24}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/.gitignore +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/LICENSE +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/README.md +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/install.sh +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/account.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/active_sessions.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/boot.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/cli.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/commands.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/config.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/consent.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/details.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/events.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/frames.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/home.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/http.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/mode_store.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/queue_panel.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/reflow.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/remote.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/render.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/repo.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/selection.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/session.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/sessions.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/sizing.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/steer.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/stream.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/thread.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/todo_panel.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/tokens.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/tools.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/update.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/src/webbee/worktrees.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/__init__.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/conftest.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_account.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_active_sessions.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_cli.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_clipboard.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_commands.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_config.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_details.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_events.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_query.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_service.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_store.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_marathon.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_mode_store.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_packaging.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_reflow.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_render.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_repl.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_repo.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_session.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_sessions.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_sizing.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_slots.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_steer.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_stream.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_thread.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_todo_panel.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_tokens.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_tools.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_update.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_version.py +0 -0
- {webbee-0.3.23 → webbee-0.3.24}/tests/test_worktrees.py +0 -0
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.24
|
|
4
|
+
|
|
5
|
+
The tab bar got a real design: the active tab is a solid highlighted chip,
|
|
6
|
+
tabs are clearly separated, and spacing is uniform — plus a message typed
|
|
7
|
+
on Home no longer shows up twice in the new tab.
|
|
8
|
+
|
|
9
|
+
- Tabs now render as padded chips with uniform spacing, a clear dim
|
|
10
|
+
separator between every pair, and the active tab as a solid
|
|
11
|
+
bee-yellow block — unmistakable at a glance, no more squinting to find
|
|
12
|
+
which tab you're on.
|
|
13
|
+
- Typing a task on Home and landing in the new session tab no longer
|
|
14
|
+
double-prints your first message in that tab's transcript.
|
|
15
|
+
- Each tab now keeps its own input draft — switch away and back, your
|
|
16
|
+
unsent text is right where you left it.
|
|
17
|
+
|
|
3
18
|
## 0.3.23
|
|
4
19
|
|
|
5
20
|
If a task on your session is waiting for your terminal — sent from the panel
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.24"
|
|
@@ -573,11 +573,19 @@ async def _home_input(text: str, *, slots: SlotManager, cfg, token_provider, mod
|
|
|
573
573
|
ui_hooks.get("switch", slots.switch)(idx)
|
|
574
574
|
if spawn_poller is not None:
|
|
575
575
|
spawn_poller(new_slot)
|
|
576
|
-
new_slot.sink.user_echo(text)
|
|
577
576
|
start_turn_in = ui_hooks.get("start_turn_in")
|
|
578
577
|
if start_turn_in is not None:
|
|
578
|
+
# 0.3.24 fix: the dock's start-turn seam threads through on_line ->
|
|
579
|
+
# `_handle`, which ALREADY echoes the typed line itself for every
|
|
580
|
+
# non-command line (the SAME canonical echo every other typed line
|
|
581
|
+
# in the dock gets) -- echoing here too doubled it up in the new
|
|
582
|
+
# tab's transcript (Valentin, live on 0.3.22/0.3.23).
|
|
579
583
|
start_turn_in(new_slot, text)
|
|
580
584
|
else:
|
|
585
|
+
# No dock: `run_turn` (repl's `_run_turn`) never echoes on its own
|
|
586
|
+
# (it only runs the agent turn), so this IS the one echo the typed
|
|
587
|
+
# line gets.
|
|
588
|
+
new_slot.sink.user_echo(text)
|
|
581
589
|
await run_turn(new_slot, text)
|
|
582
590
|
|
|
583
591
|
|
|
@@ -27,6 +27,10 @@ class SessionSlot:
|
|
|
27
27
|
pending: deque = field(default_factory=deque)
|
|
28
28
|
turn: dict = field(default_factory=lambda: {"task": None})
|
|
29
29
|
pulled: dict = field(default_factory=lambda: {"text": "", "iid": ""})
|
|
30
|
+
draft: str = "" # 0.3.24: per-tab unsent input -- stashed on switch-away,
|
|
31
|
+
# restored on switch-back (browser-tab model: each tab
|
|
32
|
+
# keeps its own form state); cleared on genuine submit.
|
|
33
|
+
draft_cursor: int = 0 # cursor position within `draft`, restored alongside it
|
|
30
34
|
qp_ui: dict = field(default_factory=lambda: {"collapsed": False})
|
|
31
35
|
tp_ui: dict = field(default_factory=lambda: {"collapsed": False})
|
|
32
36
|
mode: str = "default"
|
|
@@ -1,17 +1,29 @@
|
|
|
1
|
-
"""The tab bar — THE visible piece of the browser-in-terminal (W4a Task 4
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
`
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
"""The tab bar — THE visible piece of the browser-in-terminal (W4a Task 4;
|
|
2
|
+
chip redesign 0.3.24 — Valentin: tabs were hard to notice and hard to
|
|
3
|
+
control, needed clear separators and uniform spacing). `tab_fragments`
|
|
4
|
+
is a PURE builder, queue_panel discipline (unit-tested without an
|
|
5
|
+
Application, no prompt_toolkit import at module top — only inside the
|
|
6
|
+
mouse handlers): it renders ONE row of padded CHIPS, each `" {glyph}
|
|
7
|
+
{label} "` — ONE leading + ONE trailing space baked INSIDE the styled
|
|
8
|
+
fragment itself, so every chip carries identical breathing room regardless
|
|
9
|
+
of style. Home is always first (glyph fixed ◆, label "Home", NEVER a close
|
|
10
|
+
✕); every session slot is numbered by its own SlotManager list index (the
|
|
11
|
+
same index `slots.switch(idx)`/`slots.close(idx)` take) and shaped
|
|
12
|
+
`" {marker} {idx}·{label} {glyph} "` — `marker` (●/○) is THIS tab's own
|
|
13
|
+
active/inactive dot, `glyph` is `slot.status_glyph()` (⚠/▶/○). The ACTIVE
|
|
14
|
+
tab's chip is a SOLID bee-yellow block (`class:tab.active` — background,
|
|
15
|
+
not just text, so it's unmistakable at a glance); a NON-active session
|
|
16
|
+
tab whose glyph is ⚠ gets `class:tab.alert` (yellow text, no bg — only the
|
|
17
|
+
active chip owns a background, so the alert never competes with it). A
|
|
18
|
+
dim `" │ "` (`class:tab.sep`) sits between every pair of tabs — exactly
|
|
19
|
+
one, never at the ends — so the eye always finds the boundary. Each tab's
|
|
20
|
+
body is a 3-tuple fragment (MOUSE_UP -> on_switch(idx), NotImplemented
|
|
21
|
+
otherwise — wheel keeps working, same event discipline as
|
|
22
|
+
queue_panel._item_handler); each SESSION tab's trailing `" ✕ "` is its OWN
|
|
23
|
+
padded fragment (MOUSE_UP -> on_close(idx)), styled `class:tab.close.
|
|
24
|
+
active` instead of the plain dim `class:tab.close` when its tab is the
|
|
25
|
+
active one — same bg as its chip, so the close glyph reads as part of the
|
|
26
|
+
SAME contiguous block rather than a separate dim afterthought. Unlike the
|
|
15
27
|
queue/todo panels this bar is NEVER hidden — even a single slot (Home
|
|
16
28
|
alone) renders it; it IS the new look."""
|
|
17
29
|
|
|
@@ -19,6 +31,8 @@ TAB_STYLE_ACTIVE = "class:tab.active"
|
|
|
19
31
|
TAB_STYLE_IDLE = "class:tab"
|
|
20
32
|
TAB_STYLE_ALERT = "class:tab.alert"
|
|
21
33
|
TAB_STYLE_CLOSE = "class:tab.close"
|
|
34
|
+
TAB_STYLE_CLOSE_ACTIVE = "class:tab.close.active"
|
|
35
|
+
TAB_STYLE_SEP = "class:tab.sep"
|
|
22
36
|
|
|
23
37
|
_SEP = " │ "
|
|
24
38
|
_MIN_LABEL = 8
|
|
@@ -76,11 +90,11 @@ def tab_fragments(slots, *, on_switch, on_close, width: int = 0, forward=None):
|
|
|
76
90
|
`SlotManager` (or anything shaped like one — `.slots` + `.active_idx`);
|
|
77
91
|
slot 0 is always treated as Home. `width` (0 = unknown/no truncation —
|
|
78
92
|
headless or pre-first-render) is divided evenly across every SESSION
|
|
79
|
-
label after subtracting Home's own fixed text, the ` │ ` separators
|
|
80
|
-
each session tab's own marker/index/glyph/close overhead,
|
|
81
|
-
label is `_fit` to its share. Returns [] only when `slots` has
|
|
82
|
-
at all (never happens in practice — Home always occupies index
|
|
83
|
-
`forward` (FIX6, optional — `None` preserves every existing caller's
|
|
93
|
+
label after subtracting Home's own fixed chip text, the ` │ ` separators
|
|
94
|
+
and each session tab's own marker/index/glyph/padding/close overhead,
|
|
95
|
+
then each label is `_fit` to its share. Returns [] only when `slots` has
|
|
96
|
+
no slots at all (never happens in practice — Home always occupies index
|
|
97
|
+
0). `forward` (FIX6, optional — `None` preserves every existing caller's
|
|
84
98
|
behavior verbatim) is threaded into EVERY mouse handler this function
|
|
85
99
|
hands out (Home's own switch handler included) so a drag armed in the
|
|
86
100
|
output pane just below gets first refusal on every tab-bar click."""
|
|
@@ -91,14 +105,14 @@ def tab_fragments(slots, *, on_switch, on_close, width: int = 0, forward=None):
|
|
|
91
105
|
home = slot_list[0]
|
|
92
106
|
sessions = list(enumerate(slot_list[1:], start=1))
|
|
93
107
|
|
|
94
|
-
home_text = f"◆ {home.label or 'Home'}"
|
|
108
|
+
home_text = f" ◆ {home.label or 'Home'} "
|
|
95
109
|
seps = len(sessions) # one separator before each session tab
|
|
96
110
|
|
|
97
111
|
pieces = [] # (idx, slot, prefix, suffix, close_text)
|
|
98
112
|
for idx, slot in sessions:
|
|
99
113
|
marker = "●" if idx == active_idx else "○"
|
|
100
114
|
glyph = slot.status_glyph()
|
|
101
|
-
pieces.append((idx, slot, f"{marker}{idx}
|
|
115
|
+
pieces.append((idx, slot, f" {marker} {idx}·", f" {glyph} ", " ✕ "))
|
|
102
116
|
|
|
103
117
|
budget = 0
|
|
104
118
|
if width > 0 and pieces:
|
|
@@ -111,7 +125,7 @@ def tab_fragments(slots, *, on_switch, on_close, width: int = 0, forward=None):
|
|
|
111
125
|
frags.append((home_style, home_text, _switch_handler(on_switch, 0, forward)))
|
|
112
126
|
|
|
113
127
|
for idx, slot, prefix, suffix, close_text in pieces:
|
|
114
|
-
frags.append((
|
|
128
|
+
frags.append((TAB_STYLE_SEP, _SEP))
|
|
115
129
|
is_active = idx == active_idx
|
|
116
130
|
glyph = suffix.strip()
|
|
117
131
|
label = slot.label or ""
|
|
@@ -120,6 +134,7 @@ def tab_fragments(slots, *, on_switch, on_close, width: int = 0, forward=None):
|
|
|
120
134
|
style = TAB_STYLE_ACTIVE if is_active else (
|
|
121
135
|
TAB_STYLE_ALERT if glyph == "⚠" else TAB_STYLE_IDLE)
|
|
122
136
|
frags.append((style, f"{prefix}{label}{suffix}", _switch_handler(on_switch, idx, forward)))
|
|
123
|
-
|
|
137
|
+
close_style = TAB_STYLE_CLOSE_ACTIVE if is_active else TAB_STYLE_CLOSE
|
|
138
|
+
frags.append((close_style, close_text, _close_handler(on_close, idx, forward)))
|
|
124
139
|
|
|
125
140
|
return frags
|
|
@@ -432,6 +432,19 @@ def _swap_history(buf, slot) -> None:
|
|
|
432
432
|
buf.history = slot.history
|
|
433
433
|
|
|
434
434
|
|
|
435
|
+
def _restore_draft(buf, slot) -> None:
|
|
436
|
+
"""0.3.24 (per-tab drafts — the browser-tab model: each tab keeps its own
|
|
437
|
+
form state): `buf.reset()` must run FIRST (it clears the history-load
|
|
438
|
+
task state -- unchanged from before this fix), THEN the buffer's live
|
|
439
|
+
text/cursor are set from `slot`'s OWN stashed draft -- restoring exactly
|
|
440
|
+
what was mid-type in THIS tab the last time it was left, never another
|
|
441
|
+
tab's. `min(...)` guards a shrunk/replaced draft never leaving the
|
|
442
|
+
cursor past the end of the text it's now landing on."""
|
|
443
|
+
buf.reset()
|
|
444
|
+
buf.text = slot.draft
|
|
445
|
+
buf.cursor_position = min(slot.draft_cursor, len(buf.text))
|
|
446
|
+
|
|
447
|
+
|
|
435
448
|
async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
|
|
436
449
|
stop_turn=None, queued_run=None, inject=None,
|
|
437
450
|
home_input=None, cancel_slot=None, ui_hooks=None,
|
|
@@ -656,6 +669,12 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
|
|
|
656
669
|
slot = _a()
|
|
657
670
|
text = _rewrap_pulled(slot.pulled, scrub_mouse_residue(buf.text))
|
|
658
671
|
buf.reset()
|
|
672
|
+
# 0.3.24: a genuine Enter retires THIS slot's own stashed draft too
|
|
673
|
+
# -- otherwise the NEXT switch-away-then-back (nothing typed in
|
|
674
|
+
# between) would restore text that was already sent, resurrecting a
|
|
675
|
+
# message the user believes is long gone.
|
|
676
|
+
slot.draft = ""
|
|
677
|
+
slot.draft_cursor = 0
|
|
659
678
|
cp = _sink_attr("consent_pending")
|
|
660
679
|
if cp and cp():
|
|
661
680
|
rc = _sink_attr("resolve_consent")
|
|
@@ -933,22 +952,28 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
|
|
|
933
952
|
# it does, neither the history swap nor the redraw happen, so a
|
|
934
953
|
# click on the active tab is a true no-op, never a crash. `prev`
|
|
935
954
|
# captured BEFORE the switch (FIX7b) -- it's the slot we're LEAVING.
|
|
955
|
+
# 0.3.24: stashed UNCONDITIONALLY, before the switch even resolves --
|
|
956
|
+
# harmless on a no-op switch (prev IS the still-active slot, so this
|
|
957
|
+
# is just re-saving its own current text over itself).
|
|
936
958
|
prev = slots.active()
|
|
959
|
+
prev.draft = buf.text
|
|
960
|
+
prev.draft_cursor = buf.cursor_position
|
|
937
961
|
if slots.switch(idx):
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
#
|
|
941
|
-
#
|
|
942
|
-
#
|
|
943
|
-
#
|
|
944
|
-
#
|
|
945
|
-
#
|
|
946
|
-
#
|
|
947
|
-
#
|
|
948
|
-
#
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
962
|
+
entering = slots.active()
|
|
963
|
+
_swap_history(buf, entering)
|
|
964
|
+
# 0.3.24 (per-tab drafts, product decision -- was FIX7b's "drafts
|
|
965
|
+
# dropped on switch"): a draft mid-type belongs to the tab you
|
|
966
|
+
# typed it into, browser-tab style -- switching away no longer
|
|
967
|
+
# destroys it, it comes right back when you switch back to THIS
|
|
968
|
+
# tab (`_restore_draft`, which still runs the history-load
|
|
969
|
+
# `buf.reset()` first). The leaving slot's own pulled-queue-item
|
|
970
|
+
# carry (↑ pull-to-edit, see _rewrap_pulled) is no longer cleared
|
|
971
|
+
# here either -- it now travels WITH the draft on its own slot,
|
|
972
|
+
# so resubmitting it unedited after a round trip still dedups
|
|
973
|
+
# correctly against a landed twin; `_rewrap_pulled`'s one-shot
|
|
974
|
+
# consume (on the Enter that actually resubmits, in whichever
|
|
975
|
+
# slot is active then) is what retires it, not a switch.
|
|
976
|
+
_restore_draft(buf, entering)
|
|
952
977
|
if on_switch is not None:
|
|
953
978
|
on_switch(idx)
|
|
954
979
|
get_app().invalidate()
|
|
@@ -961,12 +986,14 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
|
|
|
961
986
|
# FIX7d: the SURVIVOR (post-close active) slot's own history takes
|
|
962
987
|
# over the shared input buffer, exactly like any other switch — a
|
|
963
988
|
# closed tab's history dies with it, so the buffer must never keep
|
|
964
|
-
# pointing at it
|
|
965
|
-
#
|
|
966
|
-
# tab
|
|
989
|
+
# pointing at it. 0.3.24: the buffer now loads the SURVIVOR's own
|
|
990
|
+
# draft (same `_restore_draft` a plain switch uses) instead of a
|
|
991
|
+
# bare reset -- the closed tab's own draft is simply gone with it,
|
|
992
|
+
# nothing to stash (it's not coming back).
|
|
967
993
|
if close_active(slots, cancel_slot):
|
|
968
|
-
|
|
969
|
-
buf
|
|
994
|
+
survivor = slots.active()
|
|
995
|
+
_swap_history(buf, survivor)
|
|
996
|
+
_restore_draft(buf, survivor)
|
|
970
997
|
get_app().invalidate()
|
|
971
998
|
return True
|
|
972
999
|
return False
|
|
@@ -981,8 +1008,9 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
|
|
|
981
1008
|
# per-tab idx of their own, so they keep meaning "close what I'm
|
|
982
1009
|
# looking at" via `_close_flow`/`close_active` below.
|
|
983
1010
|
if close_at(slots, idx, cancel_slot):
|
|
984
|
-
|
|
985
|
-
buf
|
|
1011
|
+
survivor = slots.active()
|
|
1012
|
+
_swap_history(buf, survivor) # FIX7d, same as _close_flow above
|
|
1013
|
+
_restore_draft(buf, survivor) # 0.3.24, same as _close_flow above
|
|
986
1014
|
get_app().invalidate()
|
|
987
1015
|
return True
|
|
988
1016
|
return False
|
|
@@ -1036,10 +1064,12 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
|
|
|
1036
1064
|
style = Style.from_dict({
|
|
1037
1065
|
"frame.border": "#5f5f5f", # muted grey chrome — furniture, not focus
|
|
1038
1066
|
"prompt": "#00afd7 bold", # cyan ❯ — the interactive accent
|
|
1039
|
-
"tab": "#8a8a8a", # idle
|
|
1040
|
-
"tab.active": "
|
|
1041
|
-
"tab.alert": "#e8a317 bold
|
|
1042
|
-
"tab.close": "#8a8a8a", # the ✕ — dim, closing is never the default action
|
|
1067
|
+
"tab": "#8a8a8a", # idle chip — dim text, no bg
|
|
1068
|
+
"tab.active": "bg:#e8a317 #1c1c1c bold", # the ACTIVE chip — solid bee-yellow bg, dark text: unmistakable
|
|
1069
|
+
"tab.alert": "#e8a317 bold", # ⚠ consent waiting in a BACKGROUND tab — yellow text, no bg (only the active chip owns one)
|
|
1070
|
+
"tab.close": "#8a8a8a", # the ✕ on a background tab — dim, closing is never the default action
|
|
1071
|
+
"tab.close.active": "bg:#e8a317 #1c1c1c", # the ✕ on the ACTIVE tab — same bg as its chip, reads as one contiguous block
|
|
1072
|
+
"tab.sep": "#3a3a3a", # the │ between tabs — dim, consistent, exactly one per pair, none at the ends
|
|
1043
1073
|
"tb.dim": "#8a8a8a", # idle chrome / secondary bits — dim
|
|
1044
1074
|
"tb.spin": "#e8a317 bold", # animated spinner — bee-yellow, pops
|
|
1045
1075
|
"tb.working": "#e8a317", # 'working' — yellow
|
|
@@ -357,6 +357,52 @@ def test_home_input_new_slot_sink_records_echo(monkeypatch):
|
|
|
357
357
|
assert slots.slots[1].sink.echoed == ["hello"]
|
|
358
358
|
|
|
359
359
|
|
|
360
|
+
def test_home_input_dock_path_echoes_exactly_once():
|
|
361
|
+
"""Regression pin (0.3.24, Valentin live on 0.3.22/0.3.23): typing on Home
|
|
362
|
+
opened a new session tab whose transcript showed the first message TWICE.
|
|
363
|
+
Root cause was `_home_input` echoing the typed line itself AND handing off
|
|
364
|
+
to `ui_hooks["start_turn_in"]` (tui's `_start_turn_in` -> `_run_turn` ->
|
|
365
|
+
`on_line` -> repl's `_handle`), which echoes AGAIN for every non-command
|
|
366
|
+
typed line (`_handle`'s own `slot.sink.user_echo(line)` -- the canonical
|
|
367
|
+
echo every OTHER typed line in the dock already goes through). This fake
|
|
368
|
+
`start_turn_in` mirrors that real seam (it echoes, exactly like `_handle`
|
|
369
|
+
does) so the count below actually exercises the double-echo path, not
|
|
370
|
+
just the echo-free fallback branch `test_home_input_new_slot_sink_records_
|
|
371
|
+
echo` above already covers."""
|
|
372
|
+
slots = SlotManager()
|
|
373
|
+
slots.add(_home_slot(workspace="/cwd"))
|
|
374
|
+
|
|
375
|
+
async def fake_make_session_slot(cfg, tp, ws, mode, *, resources, shared_client,
|
|
376
|
+
agent_factory, intel_factory, shadow_factory, first):
|
|
377
|
+
s = _session_slot(workspace=ws)
|
|
378
|
+
s.sink = _EchoSink()
|
|
379
|
+
return s
|
|
380
|
+
|
|
381
|
+
import webbee.repl as repl_mod
|
|
382
|
+
orig = repl_mod._make_session_slot
|
|
383
|
+
repl_mod._make_session_slot = fake_make_session_slot
|
|
384
|
+
|
|
385
|
+
def fake_start_turn_in(slot, text):
|
|
386
|
+
# Stands in for tui._start_turn_in's REAL chain (on_line -> _handle),
|
|
387
|
+
# which echoes the line itself for every non-command typed line.
|
|
388
|
+
slot.sink.user_echo(text)
|
|
389
|
+
|
|
390
|
+
async def never_called(slot, text):
|
|
391
|
+
raise AssertionError("fallback run_turn must not fire when start_turn_in is wired")
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
asyncio.run(_home_input(
|
|
395
|
+
"hello", slots=slots, cfg=None, token_provider=None, mode="default",
|
|
396
|
+
resources=WorkspaceResources(), shared_client=None, agent_factory=None,
|
|
397
|
+
intel_factory=None, shadow_factory=None, workspace="/cwd",
|
|
398
|
+
ui_hooks={"start_turn_in": fake_start_turn_in}, run_turn=never_called))
|
|
399
|
+
finally:
|
|
400
|
+
repl_mod._make_session_slot = orig
|
|
401
|
+
|
|
402
|
+
new_slot = slots.slots[1]
|
|
403
|
+
assert new_slot.sink.echoed == ["hello"] # exactly ONE echo -- not two
|
|
404
|
+
|
|
405
|
+
|
|
360
406
|
# ---- _schedule_home_refill --------------------------------------------------
|
|
361
407
|
|
|
362
408
|
def test_schedule_home_refill_ignores_non_home_idx():
|
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
"""tab_fragments (W4a Task 4) — the browser tab bar as
|
|
2
|
-
builder, queue_panel discipline: unit-tested without a
|
|
3
|
-
Application. Home always first, glyph fixed ◆, never
|
|
4
|
-
tabs carry their own switch/close mouse handlers and
|
|
5
|
-
labels to fit a live width.
|
|
1
|
+
"""tab_fragments (W4a Task 4; chip redesign 0.3.24) — the browser tab bar as
|
|
2
|
+
a PURE fragment builder, queue_panel discipline: unit-tested without a
|
|
3
|
+
prompt_toolkit Application. Home always first, glyph fixed ◆, never
|
|
4
|
+
closable; session tabs carry their own switch/close mouse handlers and
|
|
5
|
+
middle-truncate their labels to fit a live width. Every tab renders as a
|
|
6
|
+
padded CHIP (`" {glyph} {label} "` — one leading + one trailing space baked
|
|
7
|
+
into the styled fragment itself), separated by exactly one dim `" │ "`
|
|
8
|
+
between each pair (none at the ends); the ACTIVE tab's chip carries
|
|
9
|
+
`class:tab.active` (resolved to a solid bg in tui.py's Style dict) and its
|
|
10
|
+
`✕` carries `class:tab.close.active` so the whole row reads as one
|
|
11
|
+
contiguous block."""
|
|
6
12
|
from prompt_toolkit.data_structures import Point
|
|
7
13
|
from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType
|
|
8
14
|
|
|
9
15
|
from webbee.slots import SessionSlot, SlotManager
|
|
10
|
-
from webbee.tabs import
|
|
16
|
+
from webbee.tabs import (TAB_STYLE_ACTIVE, TAB_STYLE_ALERT, TAB_STYLE_CLOSE,
|
|
17
|
+
TAB_STYLE_CLOSE_ACTIVE, TAB_STYLE_IDLE, TAB_STYLE_SEP,
|
|
18
|
+
_fit, tab_fragments)
|
|
11
19
|
|
|
12
20
|
|
|
13
21
|
class _FakeSink:
|
|
@@ -64,33 +72,64 @@ def test_active_tab_gets_accent_and_active_marker():
|
|
|
64
72
|
frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
|
|
65
73
|
text = _text(frags)
|
|
66
74
|
styles = {f[1]: f[0] for f in frags}
|
|
67
|
-
assert "●1
|
|
68
|
-
assert "○2
|
|
69
|
-
assert styles["●1
|
|
70
|
-
assert styles["○2
|
|
75
|
+
assert " ● 1·alpha ○ " in text # active session marked ●N, padded chip
|
|
76
|
+
assert " ○ 2·beta ○ " in text # inactive session marked ○N, padded chip
|
|
77
|
+
assert styles[" ● 1·alpha ○ "] == TAB_STYLE_ACTIVE
|
|
78
|
+
assert styles[" ○ 2·beta ○ "] == TAB_STYLE_IDLE
|
|
71
79
|
|
|
72
80
|
|
|
73
81
|
def test_home_active_gets_accent_style_but_glyph_and_label_stay_fixed():
|
|
74
82
|
slots = _mk_slots(("alpha", _FakeSink()), active_idx=0)
|
|
75
83
|
frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
|
|
76
84
|
home_frag = frags[0]
|
|
77
|
-
assert home_frag[1] == "◆ Home"
|
|
78
|
-
assert home_frag[0] ==
|
|
85
|
+
assert home_frag[1] == " ◆ Home "
|
|
86
|
+
assert home_frag[0] == TAB_STYLE_ACTIVE
|
|
79
87
|
|
|
80
88
|
|
|
81
89
|
def test_home_inactive_is_idle_style():
|
|
82
90
|
slots = _mk_slots(("alpha", _FakeSink()), active_idx=1)
|
|
83
91
|
frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
|
|
84
92
|
home_frag = frags[0]
|
|
85
|
-
assert home_frag[1] == "◆ Home"
|
|
86
|
-
assert home_frag[0] ==
|
|
93
|
+
assert home_frag[1] == " ◆ Home "
|
|
94
|
+
assert home_frag[0] == TAB_STYLE_IDLE
|
|
87
95
|
|
|
88
96
|
|
|
89
97
|
def test_glyphs_come_from_status_glyph_busy_and_idle():
|
|
90
98
|
slots = _mk_slots(("alpha", _FakeSink(busy=True)), ("beta", _FakeSink()), active_idx=1)
|
|
91
99
|
text = _text(tab_fragments(slots, on_switch=_noop, on_close=_noop))
|
|
92
|
-
assert "alpha ▶" in text
|
|
93
|
-
assert "beta ○" in text
|
|
100
|
+
assert " ● 1·alpha ▶ " in text
|
|
101
|
+
assert " ○ 2·beta ○ " in text
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── chip padding + separators (0.3.24 redesign) ─────────────────────────────
|
|
105
|
+
|
|
106
|
+
def test_every_chip_body_has_uniform_single_space_padding():
|
|
107
|
+
# Home + every session body fragment (never the sep/close pieces) starts
|
|
108
|
+
# and ends with EXACTLY one space, not zero and not two.
|
|
109
|
+
slots = _mk_slots(("alpha", _FakeSink()), ("beta", _FakeSink()), active_idx=1)
|
|
110
|
+
frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
|
|
111
|
+
bodies = [f[1] for f in frags if f[0] not in (TAB_STYLE_SEP, TAB_STYLE_CLOSE, TAB_STYLE_CLOSE_ACTIVE)]
|
|
112
|
+
assert bodies == [" ◆ Home ", " ● 1·alpha ○ ", " ○ 2·beta ○ "]
|
|
113
|
+
for body in bodies:
|
|
114
|
+
assert body.startswith(" ") and not body.startswith(" ")
|
|
115
|
+
assert body.endswith(" ") and not body.endswith(" ")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_exactly_one_separator_between_each_pair_of_tabs_none_at_ends():
|
|
119
|
+
slots = _mk_slots(("alpha", _FakeSink()), ("beta", _FakeSink()), ("gamma", _FakeSink()), active_idx=1)
|
|
120
|
+
frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
|
|
121
|
+
seps = [f for f in frags if f[0] == TAB_STYLE_SEP]
|
|
122
|
+
assert len(seps) == 3 # one before EACH session tab
|
|
123
|
+
assert all(f[1] == " │ " for f in seps)
|
|
124
|
+
assert frags[0][0] != TAB_STYLE_SEP # never at the very start (Home's own chip)
|
|
125
|
+
assert frags[-1][0] != TAB_STYLE_SEP # never at the very end (last tab's close)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_close_fragment_is_uniformly_padded():
|
|
129
|
+
slots = _mk_slots(("alpha", _FakeSink()), active_idx=1)
|
|
130
|
+
frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
|
|
131
|
+
close = [f[1] for f in frags if f[0] in (TAB_STYLE_CLOSE, TAB_STYLE_CLOSE_ACTIVE)]
|
|
132
|
+
assert close == [" ✕ "]
|
|
94
133
|
|
|
95
134
|
|
|
96
135
|
# ── ⚠ background consent badge ──────────────────────────────────────────────
|
|
@@ -101,8 +140,20 @@ def test_alert_badge_on_background_consent_tab():
|
|
|
101
140
|
slots = _mk_slots(("alpha", _FakeSink()), ("beta", _FakeSink(consent=True)), active_idx=1)
|
|
102
141
|
frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
|
|
103
142
|
styles = {f[1]: f[0] for f in frags}
|
|
104
|
-
assert "○2
|
|
105
|
-
assert styles["○2
|
|
143
|
+
assert " ○ 2·beta ⚠ " in styles
|
|
144
|
+
assert styles[" ○ 2·beta ⚠ "] == TAB_STYLE_ALERT
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_alert_style_never_applied_to_the_active_tab_itself():
|
|
148
|
+
# tab.alert exists ONLY to flag a BACKGROUND tab -- an ACTIVE tab that
|
|
149
|
+
# happens to have a ⚠ (own pending consent) must never get tab.alert,
|
|
150
|
+
# since only the active chip owns a background and alert has none (it
|
|
151
|
+
# would silently lose its highlight).
|
|
152
|
+
slots = _mk_slots(("alpha", _FakeSink(consent=True)), ("beta", _FakeSink()), active_idx=1)
|
|
153
|
+
frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
|
|
154
|
+
styles = {f[1]: f[0] for f in frags}
|
|
155
|
+
assert styles[" ● 1·alpha ⚠ "] == TAB_STYLE_ACTIVE
|
|
156
|
+
assert TAB_STYLE_ALERT not in styles.values()
|
|
106
157
|
|
|
107
158
|
|
|
108
159
|
def test_active_tab_with_consent_pending_stays_active_styled_not_alert():
|
|
@@ -112,8 +163,8 @@ def test_active_tab_with_consent_pending_stays_active_styled_not_alert():
|
|
|
112
163
|
slots = _mk_slots(("alpha", _FakeSink(consent=True)), active_idx=1)
|
|
113
164
|
frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
|
|
114
165
|
styles = {f[1]: f[0] for f in frags}
|
|
115
|
-
assert "●1
|
|
116
|
-
assert styles["●1
|
|
166
|
+
assert " ● 1·alpha ⚠ " in styles
|
|
167
|
+
assert styles[" ● 1·alpha ⚠ "] == TAB_STYLE_ACTIVE
|
|
117
168
|
|
|
118
169
|
|
|
119
170
|
# ── Home has no close ───────────────────────────────────────────────────────
|
|
@@ -121,10 +172,10 @@ def test_active_tab_with_consent_pending_stays_active_styled_not_alert():
|
|
|
121
172
|
def test_home_never_gets_a_close_fragment():
|
|
122
173
|
slots = _mk_slots(("alpha", _FakeSink()), active_idx=1)
|
|
123
174
|
frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
|
|
124
|
-
assert not any(f[1].strip() == "✕"
|
|
175
|
+
assert not any(f[1].strip() == "✕" for f in frags[:1])
|
|
125
176
|
# Only ONE fragment renders before the first separator (Home's body) —
|
|
126
177
|
# no trailing close piece tacked onto it.
|
|
127
|
-
assert frags[0][1] == "◆ Home"
|
|
178
|
+
assert frags[0][1] == " ◆ Home "
|
|
128
179
|
assert frags[1][1] == " │ " # separator, not a close glyph
|
|
129
180
|
|
|
130
181
|
|
|
@@ -165,7 +216,10 @@ def test_session_close_fragment_fires_on_close_with_its_index():
|
|
|
165
216
|
frags = tab_fragments(slots, on_switch=_noop, on_close=hits.append)
|
|
166
217
|
close_alpha = frags[3] # [home, sep, alpha, CLOSE-alpha, sep, beta, close-beta]
|
|
167
218
|
close_beta = frags[6]
|
|
168
|
-
|
|
219
|
+
# alpha (idx 1) IS the active tab -- its ✕ gets the bg-contiguous style;
|
|
220
|
+
# beta (idx 2, background) keeps the plain dim one.
|
|
221
|
+
assert close_alpha[1] == " ✕ " and close_alpha[0] == TAB_STYLE_CLOSE_ACTIVE
|
|
222
|
+
assert close_beta[1] == " ✕ " and close_beta[0] == TAB_STYLE_CLOSE
|
|
169
223
|
assert _up(close_alpha[2]) is None and hits == [1]
|
|
170
224
|
assert _up(close_beta[2]) is None and hits == [1, 2]
|
|
171
225
|
|
|
@@ -196,12 +250,14 @@ def test_fit_middle_truncates_and_never_shrinks_below_floor():
|
|
|
196
250
|
|
|
197
251
|
|
|
198
252
|
def test_narrow_width_truncates_labels_to_the_floor_and_row_fits():
|
|
199
|
-
# width=
|
|
200
|
-
# the 8-char floor
|
|
201
|
-
# the
|
|
253
|
+
# width=52 is chosen so the computed per-label budget lands exactly on
|
|
254
|
+
# the 8-char floor (chip padding raises the fixed per-tab overhead from
|
|
255
|
+
# the pre-0.3.24 shape, so the exact width that lands on the floor
|
|
256
|
+
# shifted too): both long labels truncate to precisely 8 chars and the
|
|
257
|
+
# whole row still fits inside `width` (no floor-forced overflow).
|
|
202
258
|
label1, label2 = "workspace-one-long-name", "workspace-two-long-name"
|
|
203
259
|
slots = _mk_slots((label1, _FakeSink()), (label2, _FakeSink()), active_idx=1)
|
|
204
|
-
width =
|
|
260
|
+
width = 52
|
|
205
261
|
frags = tab_fragments(slots, on_switch=_noop, on_close=_noop, width=width)
|
|
206
262
|
text = _text(frags)
|
|
207
263
|
assert len(text) <= width
|
|
@@ -225,7 +281,7 @@ def test_single_slot_home_alone_still_renders_the_bar():
|
|
|
225
281
|
slots = _mk_slots(active_idx=0)
|
|
226
282
|
frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
|
|
227
283
|
assert frags
|
|
228
|
-
assert _text(frags) == "◆ Home"
|
|
284
|
+
assert _text(frags) == " ◆ Home "
|
|
229
285
|
|
|
230
286
|
|
|
231
287
|
def test_empty_slot_manager_renders_nothing():
|
|
@@ -3319,12 +3319,13 @@ def test_dock_mounts_sticky_todo_panel_above_queue_panel():
|
|
|
3319
3319
|
# a mid-turn tab switch. No tab-bar UI yet (Task 4) -- these tests switch
|
|
3320
3320
|
# tabs directly via `slots.active_idx`, exactly as a future keybinding will.
|
|
3321
3321
|
|
|
3322
|
-
def
|
|
3323
|
-
#
|
|
3324
|
-
#
|
|
3325
|
-
#
|
|
3326
|
-
#
|
|
3327
|
-
|
|
3322
|
+
def test_switching_tabs_preserves_the_draft_and_restores_it_on_return():
|
|
3323
|
+
# 0.3.24 (per-tab drafts, product decision -- supersedes FIX7b's "drafts
|
|
3324
|
+
# dropped on switch"): a draft mid-type belongs to the tab you typed it
|
|
3325
|
+
# into, browser-tab style -- it must survive a switch AWAY and come back
|
|
3326
|
+
# verbatim (text + cursor) on a switch BACK, while the tab you switched
|
|
3327
|
+
# INTO in between never sees it.
|
|
3328
|
+
from prompt_toolkit.application import create_app_session, get_app
|
|
3328
3329
|
from prompt_toolkit.input import create_pipe_input
|
|
3329
3330
|
from prompt_toolkit.output import DummyOutput
|
|
3330
3331
|
|
|
@@ -3349,14 +3350,27 @@ def test_switching_tabs_drops_the_leaving_tabs_unsent_draft():
|
|
|
3349
3350
|
await asyncio.sleep(0.05)
|
|
3350
3351
|
pipe.send_text("partial draft") # no Enter -- just a draft in progress
|
|
3351
3352
|
await asyncio.sleep(0.05)
|
|
3353
|
+
get_app().current_buffer.cursor_position = 7 # mid-text, not just "at the end"
|
|
3352
3354
|
|
|
3353
3355
|
pipe.send_text("\x1b1") # Alt+1 -- switch to B
|
|
3354
3356
|
await _until(lambda: slots.active_idx == 1)
|
|
3357
|
+
assert get_app().current_buffer.text == "" # B never sees A's draft
|
|
3355
3358
|
|
|
3356
|
-
pipe.send_text("\r") # Enter on
|
|
3359
|
+
pipe.send_text("\r") # Enter on B's (genuinely empty) buffer
|
|
3357
3360
|
await asyncio.sleep(0.1)
|
|
3358
|
-
assert ran == [] #
|
|
3361
|
+
assert ran == [] # nothing resubmitted on B
|
|
3359
3362
|
|
|
3363
|
+
pipe.send_text("\x1b0") # Alt+0 -- switch back to A
|
|
3364
|
+
await _until(lambda: slots.active_idx == 0)
|
|
3365
|
+
assert get_app().current_buffer.text == "partial draft" # restored verbatim
|
|
3366
|
+
assert get_app().current_buffer.cursor_position == 7 # cursor restored too
|
|
3367
|
+
|
|
3368
|
+
# Exit via B -- idx 0 is structurally unclosable in this
|
|
3369
|
+
# 2-slot fixture (SlotManager.close's own Home-guard treats
|
|
3370
|
+
# index 0 as never closable, session or not), same escape
|
|
3371
|
+
# route the pre-0.3.24 version of this test used.
|
|
3372
|
+
pipe.send_text("\x1b1")
|
|
3373
|
+
await _until(lambda: slots.active_idx == 1)
|
|
3360
3374
|
pipe.send_text("\x04")
|
|
3361
3375
|
await _until(lambda: len(slots.slots) == 1)
|
|
3362
3376
|
pipe.send_text("\x04")
|
|
@@ -3366,16 +3380,123 @@ def test_switching_tabs_drops_the_leaving_tabs_unsent_draft():
|
|
|
3366
3380
|
asyncio.run(scenario())
|
|
3367
3381
|
|
|
3368
3382
|
|
|
3369
|
-
def
|
|
3370
|
-
#
|
|
3371
|
-
#
|
|
3372
|
-
#
|
|
3373
|
-
#
|
|
3374
|
-
#
|
|
3375
|
-
|
|
3383
|
+
def test_home_draft_is_isolated_from_a_session_tabs_draft():
|
|
3384
|
+
# Companion to the round-trip test above, specifically for Home: typing
|
|
3385
|
+
# on Home (never submitted -- home_input owns plain text there, no
|
|
3386
|
+
# Enter needed for this test) must not leak into a session tab's buffer,
|
|
3387
|
+
# and Home's own draft must survive a round trip exactly like a session
|
|
3388
|
+
# tab's does.
|
|
3389
|
+
from prompt_toolkit.application import create_app_session, get_app
|
|
3390
|
+
from prompt_toolkit.input import create_pipe_input
|
|
3391
|
+
from prompt_toolkit.output import DummyOutput
|
|
3392
|
+
|
|
3393
|
+
from webbee import tui
|
|
3394
|
+
from webbee.slots import SessionSlot, SlotManager
|
|
3395
|
+
|
|
3396
|
+
async def scenario():
|
|
3397
|
+
home = SessionSlot(kind="home", workspace=".", label="Home",
|
|
3398
|
+
pane=tui.OutputPane(width=80), sink=None, agent=None)
|
|
3399
|
+
session = SessionSlot(kind="session", workspace=".", label="a",
|
|
3400
|
+
pane=tui.OutputPane(width=80), sink=_idle_sink(), agent=None)
|
|
3401
|
+
slots = SlotManager()
|
|
3402
|
+
slots.add(home)
|
|
3403
|
+
slots.add(session)
|
|
3404
|
+
slots.active_idx = 0
|
|
3405
|
+
|
|
3406
|
+
async def on_line(text, slot=None): ...
|
|
3407
|
+
|
|
3408
|
+
with create_pipe_input() as pipe:
|
|
3409
|
+
with create_app_session(input=pipe, output=DummyOutput()):
|
|
3410
|
+
task = asyncio.create_task(tui.run_session(
|
|
3411
|
+
slots=slots, on_line=on_line, on_cycle=lambda: None,
|
|
3412
|
+
home_input=lambda text: None))
|
|
3413
|
+
await asyncio.sleep(0.05)
|
|
3414
|
+
pipe.send_text("home draft")
|
|
3415
|
+
await asyncio.sleep(0.05)
|
|
3416
|
+
|
|
3417
|
+
pipe.send_text("\x1b1") # Alt+1 -- switch to the session tab
|
|
3418
|
+
await _until(lambda: slots.active_idx == 1)
|
|
3419
|
+
assert get_app().current_buffer.text == "" # session tab never sees Home's draft
|
|
3420
|
+
|
|
3421
|
+
pipe.send_text("session draft")
|
|
3422
|
+
await asyncio.sleep(0.05)
|
|
3423
|
+
|
|
3424
|
+
pipe.send_text("\x1b0") # Alt+0 -- back to Home
|
|
3425
|
+
await _until(lambda: slots.active_idx == 0)
|
|
3426
|
+
assert get_app().current_buffer.text == "home draft" # Home's OWN draft restored
|
|
3427
|
+
|
|
3428
|
+
pipe.send_text("\x1b1") # back to the session tab
|
|
3429
|
+
await _until(lambda: slots.active_idx == 1)
|
|
3430
|
+
assert get_app().current_buffer.text == "session draft" # its own draft, untouched
|
|
3431
|
+
|
|
3432
|
+
pipe.send_text("\x1b0")
|
|
3433
|
+
await _until(lambda: slots.active_idx == 0)
|
|
3434
|
+
pipe.send_text("\x04")
|
|
3435
|
+
ok = await asyncio.wait_for(task, 5)
|
|
3436
|
+
assert ok is True
|
|
3437
|
+
|
|
3438
|
+
asyncio.run(scenario())
|
|
3439
|
+
|
|
3440
|
+
|
|
3441
|
+
def test_submitting_a_line_clears_that_slots_draft_so_it_never_resurrects():
|
|
3442
|
+
# 0.3.24: a genuine submit must retire the stashed draft too -- otherwise
|
|
3443
|
+
# switching away and back after sending a message would restore text
|
|
3444
|
+
# that was already sent, silently resurrecting it in the input box.
|
|
3445
|
+
from prompt_toolkit.application import create_app_session, get_app
|
|
3446
|
+
from prompt_toolkit.input import create_pipe_input
|
|
3447
|
+
from prompt_toolkit.output import DummyOutput
|
|
3448
|
+
|
|
3449
|
+
from webbee import tui
|
|
3450
|
+
from webbee.slots import SessionSlot
|
|
3451
|
+
|
|
3452
|
+
async def scenario():
|
|
3453
|
+
pane_a, pane_b = tui.OutputPane(width=80), tui.OutputPane(width=80)
|
|
3454
|
+
ran = []
|
|
3455
|
+
|
|
3456
|
+
async def on_line(text, slot=None):
|
|
3457
|
+
ran.append(text)
|
|
3458
|
+
|
|
3459
|
+
slots = mk_slots(pane=pane_a, sink=_idle_sink())
|
|
3460
|
+
slots.add(SessionSlot(kind="session", workspace=".", label="b",
|
|
3461
|
+
pane=pane_b, sink=_idle_sink(), agent=None))
|
|
3462
|
+
|
|
3463
|
+
with create_pipe_input() as pipe:
|
|
3464
|
+
with create_app_session(input=pipe, output=DummyOutput()):
|
|
3465
|
+
task = asyncio.create_task(tui.run_session(
|
|
3466
|
+
slots=slots, on_line=on_line, on_cycle=lambda: None))
|
|
3467
|
+
await asyncio.sleep(0.05)
|
|
3468
|
+
pipe.send_text("go\r") # type + submit, idle slot -> starts a turn
|
|
3469
|
+
await _until(lambda: ran == ["go"])
|
|
3470
|
+
|
|
3471
|
+
pipe.send_text("\x1b1") # switch away
|
|
3472
|
+
await _until(lambda: slots.active_idx == 1)
|
|
3473
|
+
pipe.send_text("\x1b0") # ...and back
|
|
3474
|
+
await _until(lambda: slots.active_idx == 0)
|
|
3475
|
+
assert get_app().current_buffer.text == "" # NOT resurrected
|
|
3476
|
+
|
|
3477
|
+
pipe.send_text("\x1b1")
|
|
3478
|
+
await _until(lambda: slots.active_idx == 1)
|
|
3479
|
+
pipe.send_text("\x04")
|
|
3480
|
+
await _until(lambda: len(slots.slots) == 1)
|
|
3481
|
+
pipe.send_text("\x04")
|
|
3482
|
+
ok = await asyncio.wait_for(task, 5)
|
|
3483
|
+
assert ok is True
|
|
3484
|
+
|
|
3485
|
+
asyncio.run(scenario())
|
|
3486
|
+
|
|
3487
|
+
|
|
3488
|
+
def test_switch_preserves_the_leaving_slots_pulled_carry_so_resubmit_unchanged_still_dedups():
|
|
3489
|
+
# 0.3.24 (product decision, supersedes FIX7b's "pulled cleared on the
|
|
3490
|
+
# way out"): pulled now travels WITH the draft, on its own slot -- a
|
|
3491
|
+
# pulled-but-not-yet-resubmitted queue item survives a switch away and
|
|
3492
|
+
# back, and resubmitting it UNCHANGED after the round trip still reuses
|
|
3493
|
+
# its ORIGINAL steer_iid, so the kernel's dedup ring can still catch a
|
|
3494
|
+
# landed twin exactly as if the user had never glanced at another tab.
|
|
3495
|
+
# `_rewrap_pulled`'s one-shot consume still retires it -- on the Enter
|
|
3496
|
+
# that actually resubmits, not on the switch.
|
|
3376
3497
|
from collections import deque
|
|
3377
3498
|
|
|
3378
|
-
from prompt_toolkit.application import create_app_session
|
|
3499
|
+
from prompt_toolkit.application import create_app_session, get_app
|
|
3379
3500
|
from prompt_toolkit.input import create_pipe_input
|
|
3380
3501
|
from prompt_toolkit.output import DummyOutput
|
|
3381
3502
|
|
|
@@ -3407,14 +3528,17 @@ def test_switch_clears_the_leaving_slots_pulled_carry_so_a_stale_iid_cannot_leak
|
|
|
3407
3528
|
|
|
3408
3529
|
pipe.send_text("\x1b1") # Alt+1 -- switch away WITHOUT resubmitting
|
|
3409
3530
|
await _until(lambda: slots.active_idx == 1)
|
|
3410
|
-
assert slot_a.pulled == {"text": "", "iid": ""} #
|
|
3531
|
+
assert slot_a.pulled == {"text": "queued text", "iid": "iid-1"} # SURVIVES the switch
|
|
3532
|
+
assert slot_a.draft == "queued text" # draft travels with it
|
|
3411
3533
|
|
|
3412
3534
|
pipe.send_text("\x1b0") # Alt+0 -- switch back to A
|
|
3413
3535
|
await _until(lambda: slots.active_idx == 0)
|
|
3536
|
+
assert get_app().current_buffer.text == "queued text" # restored, unedited
|
|
3414
3537
|
|
|
3415
|
-
pipe.send_text("
|
|
3538
|
+
pipe.send_text("\r") # resubmit UNCHANGED -- no retyping
|
|
3416
3539
|
await _until(lambda: ran == ["queued text"])
|
|
3417
|
-
assert getattr(ran[0], "iid", "") == ""
|
|
3540
|
+
assert getattr(ran[0], "iid", "") == "iid-1" # ORIGINAL iid preserved -- dedup intact
|
|
3541
|
+
assert slot_a.pulled == {"text": "", "iid": ""} # one-shot: consumed by THIS Enter, not the switch
|
|
3418
3542
|
|
|
3419
3543
|
pipe.send_text("\x1b1") # switch to B -- A (idx 0) is unclosable here
|
|
3420
3544
|
await _until(lambda: slots.active_idx == 1)
|
|
@@ -4016,7 +4140,7 @@ def test_tab_bar_window_is_root_hsplit_first_child():
|
|
|
4016
4140
|
assert not isinstance(bar, ConditionalContainer) # ALWAYS visible
|
|
4017
4141
|
text = "".join(f[1] for f in bar.content.text())
|
|
4018
4142
|
assert "◆ Home" in text # Home always first
|
|
4019
|
-
assert "●1
|
|
4143
|
+
assert "● 1·proj" in text # active session, marked ●N
|
|
4020
4144
|
pipe.send_text("\x04")
|
|
4021
4145
|
ok = await asyncio.wait_for(task, 5)
|
|
4022
4146
|
assert ok is True
|
|
@@ -4495,7 +4619,7 @@ def test_tab_bar_close_click_closes_the_clicked_background_tab_not_the_active_on
|
|
|
4495
4619
|
frags = bar.content.text()
|
|
4496
4620
|
# frags: [home, sep, a-body, a-CLOSE, sep, b-body, b-close]
|
|
4497
4621
|
close_a = frags[3]
|
|
4498
|
-
assert close_a[1] == " ✕"
|
|
4622
|
+
assert close_a[1] == " ✕ "
|
|
4499
4623
|
ev = MouseEvent(position=Point(0, 0), event_type=MouseEventType.MOUSE_UP,
|
|
4500
4624
|
button=MouseButton.LEFT, modifiers=frozenset())
|
|
4501
4625
|
close_a[2](ev) # click ✕ on the BACKGROUND tab a
|
|
@@ -4546,7 +4670,7 @@ def test_tab_bar_close_click_closes_the_active_tab_when_that_is_the_one_clicked(
|
|
|
4546
4670
|
bar = get_app().layout.container.children[0]
|
|
4547
4671
|
frags = bar.content.text()
|
|
4548
4672
|
close_b = frags[6]
|
|
4549
|
-
assert close_b[1] == " ✕"
|
|
4673
|
+
assert close_b[1] == " ✕ "
|
|
4550
4674
|
ev = MouseEvent(position=Point(0, 0), event_type=MouseEventType.MOUSE_UP,
|
|
4551
4675
|
button=MouseButton.LEFT, modifiers=frozenset())
|
|
4552
4676
|
close_b[2](ev) # click ✕ on the ACTIVE tab b
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.23"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|