webbee 0.3.36__tar.gz → 0.3.37__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.36 → webbee-0.3.37}/CHANGELOG.md +33 -0
- {webbee-0.3.36 → webbee-0.3.37}/PKG-INFO +1 -1
- {webbee-0.3.36 → webbee-0.3.37}/pyproject.toml +1 -1
- webbee-0.3.37/src/webbee/__init__.py +1 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/active_sessions.py +79 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/commands.py +19 -1
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/queue_panel.py +50 -3
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/repl.py +227 -1
- webbee-0.3.37/src/webbee/tab_store.py +123 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/tui.py +113 -8
- webbee-0.3.37/tests/test_queue_manage_0337.py +138 -0
- webbee-0.3.37/tests/test_tab_restore_0337.py +248 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_tui.py +2 -1
- webbee-0.3.37/tests/test_version_badge_corner.py +71 -0
- webbee-0.3.36/src/webbee/__init__.py +0 -1
- {webbee-0.3.36 → webbee-0.3.37}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/.gitignore +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/LICENSE +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/README.md +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/docs/superpowers/plans/2026-07-21-interactive-home-dashboard.md +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/install.sh +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/account.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/boot.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/cli.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/clipboard_read.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/config.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/consent.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/details.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/events.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/frames.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/home.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/home_view.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/http.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/instance_lock.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/mode_store.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/newtab_mode.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/reflow.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/remote.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/render.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/repo.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/selection.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/session.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/sessions.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/sizing.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/slots.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/steer.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/stream.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/tabs.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/thread.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/todo_panel.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/tokens.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/tools.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/update.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/urlopen.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/wallet.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/src/webbee/worktrees.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/__init__.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/conftest.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_account.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_active_sessions.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_audit_2026_07_25_findings.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_cli.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_clipboard.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_clipboard_read.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_coding_context.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_commands.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_config.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_details.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_events.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_home.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_home_view.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_instance_lock.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_query.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_service.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_store.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_marathon.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_mode_store.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_newtab_mode.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_packaging.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_reflow.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_render.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_repl.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_repo.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_session.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_sessions.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_sizing.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_slots.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_steer.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_stream.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_tabs.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_terminal_ux_0336.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_thread.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_todo_panel.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_tokens.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_tools.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_update.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_urlopen.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_version.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_wallet.py +0 -0
- {webbee-0.3.36 → webbee-0.3.37}/tests/test_worktrees.py +0 -0
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.37
|
|
4
|
+
|
|
5
|
+
Your tabs come back, the queue does what you tell it, and the version sits
|
|
6
|
+
where you asked for it.
|
|
7
|
+
|
|
8
|
+
- **Closing the terminal no longer throws your work away.** Three tabs of live
|
|
9
|
+
work used to become one empty tab on the next start. Your layout is
|
|
10
|
+
remembered per repo and restored on boot — and it is reconciled against what
|
|
11
|
+
is *actually* still running, so a tab that really ended comes back as a
|
|
12
|
+
labelled tab rather than a dead one pretending to be alive. An accidental
|
|
13
|
+
exit, Ctrl-D and a crash all remember equally. Autopilot is deliberately
|
|
14
|
+
never remembered: a mode that acts on its own has to be turned on each time.
|
|
15
|
+
- **A queue you can actually manage.** Every queued line now has its own **✕**,
|
|
16
|
+
so you can remove *any* item — previously the only way out was ↑, which
|
|
17
|
+
hijacked your input and could only ever reach the newest row, leaving item 1
|
|
18
|
+
of 3 stuck. `/queue drop N` and `/queue edit N` do the same from the keyboard
|
|
19
|
+
when your terminal reports no mouse. Editing an item *removes* it from the
|
|
20
|
+
queue as it moves into your input, so a line can never both sit in the queue
|
|
21
|
+
and still fire.
|
|
22
|
+
- **Version badge, bottom-right of the window.** Pinned to the corner of the
|
|
23
|
+
whole window rather than floating inside the toolbar's content. On a narrow
|
|
24
|
+
terminal the badge is dropped instead of truncating real information.
|
|
25
|
+
- **A live-session indicator that keeps telling you the truth.** The old boot
|
|
26
|
+
note fired once and scrolled away. The toolbar now shows "● live",
|
|
27
|
+
"● live · needs approval" or "○ N elsewhere", refreshed every 30 seconds from
|
|
28
|
+
what the server actually reports — and it stays empty when nothing is
|
|
29
|
+
running, so an idle dock gains no permanent noise.
|
|
30
|
+
- **✕, Ctrl+W and `/close` all really do close a tab** — they always shared one
|
|
31
|
+
teardown path; what was missing was saying so, and the busy-close note now
|
|
32
|
+
names all three ways out.
|
|
33
|
+
- Fixed two latent crashes that would only have fired on a click (a queue panel
|
|
34
|
+
and a row renderer were each used without being imported).
|
|
35
|
+
|
|
3
36
|
## 0.3.36
|
|
4
37
|
|
|
5
38
|
Write on more than one line, and see what a session actually costs.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.37"
|
|
@@ -39,6 +39,85 @@ async def fetch_active_sessions(cfg, token_provider, *, client=None) -> list[dic
|
|
|
39
39
|
return []
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def live_session_ids(sessions: list[dict]) -> set:
|
|
43
|
+
"""PURE. The set of session ids the gateway currently reports as RUNNING.
|
|
44
|
+
One helper so the tab-restore reconciliation and the status indicator read
|
|
45
|
+
liveness from the SAME shape (0.3.37)."""
|
|
46
|
+
return {str(s.get("session_id") or "") for s in (sessions or [])
|
|
47
|
+
if str(s.get("session_id") or "")}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def session_indicator(sessions: list[dict], repo_key: str) -> str:
|
|
51
|
+
"""PURE. The PERSISTENT live-session indicator for the toolbar (0.3.37).
|
|
52
|
+
|
|
53
|
+
Before this, a running Temporal workflow was mentioned exactly once, in a
|
|
54
|
+
boot note that scrolled away -- so after an accidental exit and reopen the
|
|
55
|
+
terminal silently re-attached with nothing on screen ever confirming that
|
|
56
|
+
it had seen a running session, or that one existed at all.
|
|
57
|
+
|
|
58
|
+
Returns a SHORT string for the status line, or "" when there is nothing
|
|
59
|
+
to say (no running sessions at all -- silence is correct then, the dock
|
|
60
|
+
must not grow permanent noise):
|
|
61
|
+
* this repo has a live session -> "● live"
|
|
62
|
+
* ...and it is parked on an approval -> "● live · needs approval"
|
|
63
|
+
* only OTHER repos have live ones -> "○ 2 elsewhere"
|
|
64
|
+
Honest by construction: it reports what the gateway's Temporal listing
|
|
65
|
+
actually says, never a guess.
|
|
66
|
+
"""
|
|
67
|
+
suffix = f"-r{repo_key}"
|
|
68
|
+
own, own_parked, others = False, False, 0
|
|
69
|
+
for s in (sessions or []):
|
|
70
|
+
sid = str(s.get("session_id") or "")
|
|
71
|
+
if not sid:
|
|
72
|
+
continue
|
|
73
|
+
if sid.endswith(suffix):
|
|
74
|
+
own = True
|
|
75
|
+
if s.get("pending_consent"):
|
|
76
|
+
own_parked = True
|
|
77
|
+
else:
|
|
78
|
+
others += 1
|
|
79
|
+
if own:
|
|
80
|
+
return "● live · needs approval" if own_parked else "● live"
|
|
81
|
+
if others:
|
|
82
|
+
return f"○ {others} elsewhere"
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def plan_tab_restore(saved: list, sessions: list[dict]) -> list:
|
|
87
|
+
"""PURE. Reconcile the REMEMBERED tab layout (tab_store.load_tabs) against
|
|
88
|
+
the sessions Temporal reports as Running, and decide per tab what reopening
|
|
89
|
+
it means (0.3.37).
|
|
90
|
+
|
|
91
|
+
Each returned dict is the saved record plus:
|
|
92
|
+
* `attach`: True -> its workflow is STILL RUNNING; the tab must re-attach
|
|
93
|
+
and pick the live run up where it left off.
|
|
94
|
+
`attach`: False -> nothing is running under that id any more; restore
|
|
95
|
+
the tab's state (label/mode/draft) only.
|
|
96
|
+
* `pending_consent`: carried through from the live listing so a restored
|
|
97
|
+
tab can immediately show it is parked on an approval.
|
|
98
|
+
|
|
99
|
+
A saved tab with no session_id is legal (a tab you never sent anything in)
|
|
100
|
+
and simply restores as state-only. Never raises; unknown/extra keys on the
|
|
101
|
+
saved record are ignored rather than trusted.
|
|
102
|
+
"""
|
|
103
|
+
live = {}
|
|
104
|
+
for s in (sessions or []):
|
|
105
|
+
sid = str(s.get("session_id") or "")
|
|
106
|
+
if sid:
|
|
107
|
+
live[sid] = s
|
|
108
|
+
out = []
|
|
109
|
+
for rec in (saved or []):
|
|
110
|
+
if not isinstance(rec, dict):
|
|
111
|
+
continue
|
|
112
|
+
sid = str(rec.get("session_id") or "")
|
|
113
|
+
s = live.get(sid)
|
|
114
|
+
item = dict(rec)
|
|
115
|
+
item["attach"] = bool(sid and s is not None)
|
|
116
|
+
item["pending_consent"] = bool((s or {}).get("pending_consent"))
|
|
117
|
+
out.append(item)
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
42
121
|
def boot_reattach_notice(sessions: list[dict], repo_key: str) -> list[str]:
|
|
43
122
|
"""Decide what, if anything, to tell the user about their OTHER running
|
|
44
123
|
sessions once THIS terminal's own boot replay has finished. `sessions`
|
|
@@ -111,6 +111,23 @@ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
|
|
|
111
111
|
# same way /status reads session state); the repl clears the actual
|
|
112
112
|
# deque on action="queue_clear" — the message here already carries
|
|
113
113
|
# the honest drop count.
|
|
114
|
+
# 0.3.37: drop/edit ONE queued item by its 1-based /queue number --
|
|
115
|
+
# the KEYBOARD twin of the panel's per-row ✕ and click-to-edit, so
|
|
116
|
+
# managing a 3-deep queue works on terminals with no mouse reporting
|
|
117
|
+
# (tmux without mouse on, restrictive SSH clients) too. The repl owns
|
|
118
|
+
# the actual deque mutation; this layer only validates + routes.
|
|
119
|
+
if args and args[0].lower() in ("drop", "remove", "rm", "edit"):
|
|
120
|
+
verb = "queue_drop" if args[0].lower() != "edit" else "queue_edit"
|
|
121
|
+
if len(args) < 2:
|
|
122
|
+
return SlashResult(handled=True, action="queue",
|
|
123
|
+
message=f"Usage: /queue {args[0].lower()} <number> "
|
|
124
|
+
f"(see /queue for the numbers).")
|
|
125
|
+
raw = args[1]
|
|
126
|
+
if not raw.isdigit() or int(raw) < 1 or int(raw) > len(ctx.queued):
|
|
127
|
+
return SlashResult(handled=True, action="queue",
|
|
128
|
+
message=(f"No queued item #{raw}. "
|
|
129
|
+
f"Queue holds {len(ctx.queued)}."))
|
|
130
|
+
return SlashResult(handled=True, action=verb, arg=str(int(raw)))
|
|
114
131
|
if args and args[0].lower() == "clear":
|
|
115
132
|
n = len(ctx.queued)
|
|
116
133
|
return SlashResult(handled=True, action="queue_clear",
|
|
@@ -122,7 +139,8 @@ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
|
|
|
122
139
|
lines = "\n".join(f" {i}. {t}" for i, t in enumerate(ctx.queued, 1))
|
|
123
140
|
return SlashResult(handled=True, action="queue",
|
|
124
141
|
message=(f"Queued ({len(ctx.queued)}) — runs in order, "
|
|
125
|
-
f"one per finished turn:\n{lines}"
|
|
142
|
+
f"one per finished turn:\n{lines}\n"
|
|
143
|
+
f" /queue drop <n> · /queue edit <n> · /queue clear"))
|
|
126
144
|
if cmd == "/steps":
|
|
127
145
|
if args:
|
|
128
146
|
return SlashResult(handled=True, action="step_detail", arg=args[0])
|
|
@@ -68,6 +68,43 @@ def pull_item(pending, buf, index: int):
|
|
|
68
68
|
return item
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def drop_item(pending, index: int):
|
|
72
|
+
"""The ONE queue-REMOVE implementation (0.3.37) — serves the per-row ✕ in
|
|
73
|
+
the panel. Deletes pending[index] WITHOUT touching the input buffer, so
|
|
74
|
+
removing item #1 of 3 no longer forces you to pull it into the input just
|
|
75
|
+
to get rid of it (before this, the only exit from the queue was
|
|
76
|
+
`pull_item`, which is edit-the-line, hijacks the buffer and — bound to ↑ —
|
|
77
|
+
only ever reached the NEWEST row).
|
|
78
|
+
|
|
79
|
+
Guards mirror `pull_item`: a stale index (the queue drained between render
|
|
80
|
+
and click) is ignored rather than raising or eating the wrong row. Returns
|
|
81
|
+
the removed item, or None when nothing was removed.
|
|
82
|
+
"""
|
|
83
|
+
if not (0 <= index < len(pending)):
|
|
84
|
+
return None
|
|
85
|
+
item = pending[index]
|
|
86
|
+
del pending[index]
|
|
87
|
+
return item
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _drop_handler(drop, index: int, forward=None):
|
|
91
|
+
"""One row's ✕ mouse handler: MOUSE_UP removes THAT queued item via
|
|
92
|
+
`drop(index)`. Same event discipline as `_item_handler` (MOUSE_UP only,
|
|
93
|
+
`forward` gets first refusal so a drag released here is not mistaken for
|
|
94
|
+
a click) — the difference is purely WHICH action fires, so a ✕ click can
|
|
95
|
+
never be confused with the row-body click that pulls for editing.
|
|
96
|
+
"""
|
|
97
|
+
def _h(mouse_event):
|
|
98
|
+
from prompt_toolkit.mouse_events import MouseEventType
|
|
99
|
+
if forward is not None and forward(mouse_event):
|
|
100
|
+
return None
|
|
101
|
+
if mouse_event.event_type == MouseEventType.MOUSE_UP:
|
|
102
|
+
drop(index)
|
|
103
|
+
return None
|
|
104
|
+
return NotImplemented
|
|
105
|
+
return _h
|
|
106
|
+
|
|
107
|
+
|
|
71
108
|
def _item_handler(pull, index: int, forward=None):
|
|
72
109
|
"""One row's mouse handler: MOUSE_UP (a click, not a drag/press) pulls
|
|
73
110
|
THAT queued item into the input via `pull(index)`; every other event
|
|
@@ -109,7 +146,7 @@ def _toggle_handler(toggle, forward=None):
|
|
|
109
146
|
|
|
110
147
|
def queue_fragments(pending, pull=None, width: int = 0, remote=None,
|
|
111
148
|
collapsed=False, toggle=None, max_items=QP_MAX_ITEMS,
|
|
112
|
-
forward=None):
|
|
149
|
+
forward=None, drop=None):
|
|
113
150
|
"""PURE builder: the panel as prompt_toolkit formatted text, re-invoked
|
|
114
151
|
every redraw (same live mechanics as the toolbar) so every queue
|
|
115
152
|
add/edit/drain shows at once. Layout, top→bottom = drain order (FIFO —
|
|
@@ -159,7 +196,9 @@ def queue_fragments(pending, pull=None, width: int = 0, remote=None,
|
|
|
159
196
|
if collapsed:
|
|
160
197
|
return frags
|
|
161
198
|
if n:
|
|
162
|
-
|
|
199
|
+
hint = (" · ↑ edit last · click to edit · ✕ remove" if drop is not None
|
|
200
|
+
else " · ↑ edit last · click to edit")
|
|
201
|
+
frags.append(("class:qp.item", hint))
|
|
163
202
|
rstart = max(0, len(rem) - max_items)
|
|
164
203
|
if rstart:
|
|
165
204
|
frags.append(("class:qp.remote", f"\n … +{rstart} more"))
|
|
@@ -177,9 +216,17 @@ def queue_fragments(pending, pull=None, width: int = 0, remote=None,
|
|
|
177
216
|
frags.append(("class:qp.item", f"\n … +{start} more"))
|
|
178
217
|
for i in range(start, n):
|
|
179
218
|
style = "class:qp.last" if i == n - 1 else "class:qp.item"
|
|
180
|
-
|
|
219
|
+
# 0.3.37: reserve the ✕ column so the row text truncates BEFORE the
|
|
220
|
+
# button rather than shoving it off-screen on a narrow terminal.
|
|
221
|
+
avail = (width - 4 - (2 if drop is not None else 0)) if width > 0 else 0
|
|
222
|
+
row = "\n " + one_line(items[i], avail)
|
|
181
223
|
if pull is None:
|
|
182
224
|
frags.append((style, row))
|
|
183
225
|
else:
|
|
184
226
|
frags.append((style, row, _item_handler(pull, i, forward)))
|
|
227
|
+
if drop is not None:
|
|
228
|
+
# Its OWN fragment + OWN handler: clicking ✕ removes the item,
|
|
229
|
+
# clicking the row body still pulls it for editing. Two adjacent
|
|
230
|
+
# targets, zero ambiguity.
|
|
231
|
+
frags.append(("class:qp.drop", " ✕", _drop_handler(drop, i, forward)))
|
|
185
232
|
return frags
|
|
@@ -174,7 +174,7 @@ def _slot_ctx(slot: SessionSlot, *, logged_in: bool, slots=None) -> CommandConte
|
|
|
174
174
|
# number the Home toolbar and dashboard show) instead of Home's own zeros.
|
|
175
175
|
_HOME_GATED_ACTIONS = frozenset({
|
|
176
176
|
"steps", "step_detail", "checkpoints", "rollback", "notify", "mode",
|
|
177
|
-
"queue", "queue_clear", "rename",
|
|
177
|
+
"queue", "queue_clear", "queue_drop", "queue_edit", "rename",
|
|
178
178
|
})
|
|
179
179
|
|
|
180
180
|
_HOME_GATE_NOTE = "open a session tab first — Ctrl+T or type a task"
|
|
@@ -437,6 +437,116 @@ def _resolve_agent_factory(agent_factory, bundle: dict):
|
|
|
437
437
|
return lambda c, tp, ws, m: AgentSession(c, tp, ws, m, intel=bundle["intel"], shadow=bundle["shadow"])
|
|
438
438
|
|
|
439
439
|
|
|
440
|
+
async def restore_tabs(workspace: str, slots, make_slot, *, sessions_fn=None,
|
|
441
|
+
note=None, spawn_poller=None, limit: int = 0) -> int:
|
|
442
|
+
"""Reopen the tabs remembered from the last run (0.3.37).
|
|
443
|
+
|
|
444
|
+
Closing the terminal used to be silently destructive: three tabs of live
|
|
445
|
+
work became one empty tab, and the still-Running Temporal workflows behind
|
|
446
|
+
them were reachable only by remembering what you had been doing. Now the
|
|
447
|
+
layout is remembered (`tab_store`), reconciled against what Temporal
|
|
448
|
+
actually still has Running (`active_sessions.plan_tab_restore`) and rebuilt
|
|
449
|
+
through the SAME `_make_session_slot` factory `/new` uses -- so a restored
|
|
450
|
+
tab is a completely ordinary tab, not a special second kind.
|
|
451
|
+
|
|
452
|
+
The FIRST session tab already exists when this runs (the boot builds it),
|
|
453
|
+
so the first remembered record is skipped: it is that tab. Each further
|
|
454
|
+
record becomes a real tab, labelled as it was, and told honestly whether it
|
|
455
|
+
re-attached to a live run or restored state only.
|
|
456
|
+
|
|
457
|
+
Best-effort by construction: a missing layout file, a gateway that cannot
|
|
458
|
+
list sessions, or a single tab that fails to build all degrade to "fewer
|
|
459
|
+
tabs restored", never to a failed boot. Returns how many tabs it opened.
|
|
460
|
+
"""
|
|
461
|
+
from webbee.active_sessions import plan_tab_restore
|
|
462
|
+
from webbee.tab_store import load_tabs
|
|
463
|
+
opened = 0
|
|
464
|
+
try:
|
|
465
|
+
saved = load_tabs(workspace)
|
|
466
|
+
except Exception:
|
|
467
|
+
return 0
|
|
468
|
+
if len(saved) <= 1:
|
|
469
|
+
return 0 # nothing beyond the tab the boot already made
|
|
470
|
+
try:
|
|
471
|
+
sessions = await sessions_fn() if sessions_fn is not None else []
|
|
472
|
+
except Exception:
|
|
473
|
+
sessions = [] # no listing -> every tab restores state-only
|
|
474
|
+
plan = plan_tab_restore(saved, sessions)
|
|
475
|
+
live_n = 0
|
|
476
|
+
for rec in plan[1:]:
|
|
477
|
+
if limit and opened >= limit:
|
|
478
|
+
break
|
|
479
|
+
try:
|
|
480
|
+
slot = await make_slot(rec)
|
|
481
|
+
except Exception:
|
|
482
|
+
continue # one bad tab must not cost the whole layout
|
|
483
|
+
if slot is None:
|
|
484
|
+
continue
|
|
485
|
+
label = str(rec.get("label") or "")
|
|
486
|
+
if label:
|
|
487
|
+
slot.label = label
|
|
488
|
+
slot.label_pinned = True
|
|
489
|
+
draft = str(rec.get("draft") or "")
|
|
490
|
+
if draft:
|
|
491
|
+
slot.draft = draft
|
|
492
|
+
slot.draft_cursor = len(draft)
|
|
493
|
+
slots.add(slot)
|
|
494
|
+
if spawn_poller is not None:
|
|
495
|
+
try:
|
|
496
|
+
spawn_poller(slot)
|
|
497
|
+
except Exception:
|
|
498
|
+
pass
|
|
499
|
+
opened += 1
|
|
500
|
+
if rec.get("attach"):
|
|
501
|
+
live_n += 1
|
|
502
|
+
if note is not None and opened:
|
|
503
|
+
try:
|
|
504
|
+
if live_n:
|
|
505
|
+
note(f"restored {opened} tab(s) — {live_n} still running server-side")
|
|
506
|
+
else:
|
|
507
|
+
note(f"restored {opened} tab(s) — their earlier runs have finished")
|
|
508
|
+
except Exception:
|
|
509
|
+
pass
|
|
510
|
+
return opened
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def snapshot_tabs(slots) -> list:
|
|
514
|
+
"""Build the persistable tab layout from the LIVE slots (0.3.37).
|
|
515
|
+
|
|
516
|
+
SESSION tabs only -- Home is a fixture the dock always builds itself, so
|
|
517
|
+
persisting it would duplicate it on restore. The re-attach handle is the
|
|
518
|
+
agent's own `session_id` (the gateway id it streamed under); a tab that
|
|
519
|
+
never ran anything has none and restores as state-only. Reads defensively
|
|
520
|
+
(getattr with defaults) so a half-built slot during shutdown can never
|
|
521
|
+
turn saving the layout into a crash on exit.
|
|
522
|
+
"""
|
|
523
|
+
from webbee.tab_store import tab_record
|
|
524
|
+
out = []
|
|
525
|
+
for slot in list(getattr(slots, "slots", []) or []):
|
|
526
|
+
if getattr(slot, "kind", "") == "home":
|
|
527
|
+
continue
|
|
528
|
+
agent = getattr(slot, "agent", None)
|
|
529
|
+
out.append(tab_record(
|
|
530
|
+
session_id=str(getattr(agent, "session_id", "") or ""),
|
|
531
|
+
label=str(getattr(slot, "label", "") or ""),
|
|
532
|
+
mode=str(getattr(slot, "mode", "default") or "default"),
|
|
533
|
+
workspace=str(getattr(slot, "workspace", "") or ""),
|
|
534
|
+
draft=str(getattr(slot, "draft", "") or ""),
|
|
535
|
+
))
|
|
536
|
+
return out
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _save_tab_layout(workspace: str, slots) -> None:
|
|
540
|
+
"""Persist the tab layout, best-effort (0.3.37). Called on the way out of
|
|
541
|
+
the dock: losing the memory is a far smaller problem than failing to exit
|
|
542
|
+
cleanly, so EVERY error is swallowed -- same posture as mode_store."""
|
|
543
|
+
try:
|
|
544
|
+
from webbee.tab_store import save_tabs
|
|
545
|
+
save_tabs(workspace, snapshot_tabs(slots))
|
|
546
|
+
except Exception:
|
|
547
|
+
pass
|
|
548
|
+
|
|
549
|
+
|
|
440
550
|
async def _note_reattach(cfg, token_provider, workspace, sink) -> None:
|
|
441
551
|
"""Boot reattach notice (T6.3, coding-remote flow perfection): best-
|
|
442
552
|
effort, entirely swallowed on any failure -- same division of labor as
|
|
@@ -461,6 +571,44 @@ async def _note_reattach(cfg, token_provider, workspace, sink) -> None:
|
|
|
461
571
|
pass
|
|
462
572
|
|
|
463
573
|
|
|
574
|
+
async def _live_session_watch(cfg, token_provider, workspace, live: dict,
|
|
575
|
+
invalidate=None, interval: float = 30.0,
|
|
576
|
+
sessions_fn=None, sleep=None) -> None:
|
|
577
|
+
"""Keep the toolbar's PERSISTENT live-session indicator honest (0.3.37).
|
|
578
|
+
|
|
579
|
+
The boot note fires once and scrolls away; this refreshes the indicator on
|
|
580
|
+
a slow cadence (default 30s -- it answers "is a workflow running", which
|
|
581
|
+
does not need per-second polling) so an accidental exit + reopen, or a
|
|
582
|
+
session that ends while you watch, is always reflected.
|
|
583
|
+
|
|
584
|
+
Writes ONLY into `live["text"]`, which `tui`'s toolbar reads every redraw:
|
|
585
|
+
no new UI ownership, no second source of truth. Fail-soft like every other
|
|
586
|
+
background poller here -- ANY error just leaves the previous value and
|
|
587
|
+
tries again next tick, and a gateway without the route means the indicator
|
|
588
|
+
stays empty (today's behaviour). `sessions_fn`/`sleep` are test seams.
|
|
589
|
+
"""
|
|
590
|
+
from webbee.active_sessions import fetch_active_sessions, session_indicator
|
|
591
|
+
from webbee.repo import compute_repo_key, find_repo_root
|
|
592
|
+
_sleep = sleep or asyncio.sleep
|
|
593
|
+
fetch = sessions_fn or (lambda: fetch_active_sessions(cfg, token_provider))
|
|
594
|
+
try:
|
|
595
|
+
repo_key = await asyncio.to_thread(
|
|
596
|
+
lambda: compute_repo_key(find_repo_root(workspace)))
|
|
597
|
+
except Exception:
|
|
598
|
+
repo_key = ""
|
|
599
|
+
while True:
|
|
600
|
+
try:
|
|
601
|
+
sessions = await fetch()
|
|
602
|
+
text = session_indicator(sessions, repo_key)
|
|
603
|
+
if text != live.get("text"):
|
|
604
|
+
live["text"] = text
|
|
605
|
+
if invalidate is not None:
|
|
606
|
+
invalidate()
|
|
607
|
+
except Exception:
|
|
608
|
+
pass
|
|
609
|
+
await _sleep(interval)
|
|
610
|
+
|
|
611
|
+
|
|
464
612
|
async def _finish_slot(cfg, token_provider, workspace, mode, *, resources: WorkspaceResources,
|
|
465
613
|
agent_factory, intel_factory, shadow_factory, pane, sink, first: bool,
|
|
466
614
|
account, slot_id: str = "", label: "str | None" = None) -> "tuple[SessionSlot, int]":
|
|
@@ -1080,6 +1228,33 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1080
1228
|
# count follows on the sink's redraw below. (Home never
|
|
1081
1229
|
# reaches here -- "queue_clear" is in _HOME_GATED_ACTIONS.)
|
|
1082
1230
|
slot.pending.clear()
|
|
1231
|
+
if res.action in ("queue_drop", "queue_edit"):
|
|
1232
|
+
# 0.3.37 keyboard queue management. dispatch already validated
|
|
1233
|
+
# the number against the SAME snapshot, but re-check here: the
|
|
1234
|
+
# running turn can drain an item between the command and this
|
|
1235
|
+
# line, and a stale index must never delete the wrong message.
|
|
1236
|
+
idx = int(res.arg) - 1
|
|
1237
|
+
if not (0 <= idx < len(slot.pending)):
|
|
1238
|
+
_say(slot, "that queued item just ran — nothing removed.")
|
|
1239
|
+
return "continue"
|
|
1240
|
+
if res.action == "queue_drop":
|
|
1241
|
+
from webbee.queue_panel import drop_item as _drop_item
|
|
1242
|
+
item = _drop_item(slot.pending, idx)
|
|
1243
|
+
_say(slot, f"removed from queue: {item}")
|
|
1244
|
+
else:
|
|
1245
|
+
# edit = move it OUT of the queue and INTO the input, so it
|
|
1246
|
+
# can never both sit in the queue AND still fire (the exact
|
|
1247
|
+
# bug ↑ had). Reuses the dock's own `_pull_at` -- the SAME
|
|
1248
|
+
# code the panel's click-to-edit runs -- so the live buffer
|
|
1249
|
+
# is filled correctly and `pulled` carries the steer_iid
|
|
1250
|
+
# for dedup. No dock (headless/tests) = honest no-op note.
|
|
1251
|
+
pull_fn = ui_hooks.get("pull_queued")
|
|
1252
|
+
if pull_fn is None:
|
|
1253
|
+
_say(slot, "editing a queued item needs the interactive dock.")
|
|
1254
|
+
return "continue"
|
|
1255
|
+
pull_fn(idx)
|
|
1256
|
+
_say(slot, "queued item moved into the input — edit it and press Enter.")
|
|
1257
|
+
return "continue"
|
|
1083
1258
|
if res.action == "clear":
|
|
1084
1259
|
# FIX4: works fully on Home too -- clears Home's OWN pane
|
|
1085
1260
|
# (there are no session counters to reset there, so only the
|
|
@@ -1261,6 +1436,19 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1261
1436
|
if agent is not None:
|
|
1262
1437
|
await agent.stop()
|
|
1263
1438
|
|
|
1439
|
+
def _invalidate_dock() -> None:
|
|
1440
|
+
"""Ask the dock to redraw (0.3.37 live-session indicator). Fail-soft:
|
|
1441
|
+
before the Application exists -- or in the headless fallback loop that
|
|
1442
|
+
has no dock at all -- there is simply nothing to invalidate, and a
|
|
1443
|
+
background poller must never die over a missing UI."""
|
|
1444
|
+
try:
|
|
1445
|
+
from prompt_toolkit.application.current import get_app_or_none
|
|
1446
|
+
app = get_app_or_none()
|
|
1447
|
+
if app is not None:
|
|
1448
|
+
app.invalidate()
|
|
1449
|
+
except Exception:
|
|
1450
|
+
pass
|
|
1451
|
+
|
|
1264
1452
|
def _cancel_background() -> None:
|
|
1265
1453
|
# Task 7: the actual walk moved to the module-level, directly-tested
|
|
1266
1454
|
# `_cancel_all_background` (every slot's bg_tasks, .done()-guarded --
|
|
@@ -1464,6 +1652,36 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1464
1652
|
# is very likely already done (or well under way) by the
|
|
1465
1653
|
# time anyone actually switches to it.
|
|
1466
1654
|
home_slot.bg_tasks.append(asyncio.ensure_future(home.fill_home(home_slot, **home_fill_kwargs)))
|
|
1655
|
+
# 0.3.37: keep the toolbar's live-session indicator fresh. Parked
|
|
1656
|
+
# in Home's bg_tasks so the EXISTING teardown
|
|
1657
|
+
# (_cancel_all_background) cancels it -- no new lifecycle to get
|
|
1658
|
+
# wrong, and Home outlives every session tab.
|
|
1659
|
+
# 0.3.37: reopen the tabs remembered from the last run, through
|
|
1660
|
+
# the SAME factory /new uses. Awaited (not fire-and-forget) so
|
|
1661
|
+
# the tab bar is already complete on the first frame instead of
|
|
1662
|
+
# visibly popping in; every failure mode degrades to fewer tabs.
|
|
1663
|
+
try:
|
|
1664
|
+
await restore_tabs(
|
|
1665
|
+
workspace, slots,
|
|
1666
|
+
lambda rec: _make_session_slot(
|
|
1667
|
+
cfg, token_provider,
|
|
1668
|
+
str(rec.get("workspace") or workspace),
|
|
1669
|
+
str(rec.get("mode") or mode), resources=resources,
|
|
1670
|
+
shared_client=shared_client, agent_factory=agent_factory,
|
|
1671
|
+
intel_factory=intel_factory, shadow_factory=shadow_factory,
|
|
1672
|
+
first=False, account=account),
|
|
1673
|
+
sessions_fn=lambda: __import__(
|
|
1674
|
+
"webbee.active_sessions", fromlist=["x"]
|
|
1675
|
+
).fetch_active_sessions(cfg, token_provider),
|
|
1676
|
+
note=session_slot.sink.note if session_slot.sink else None,
|
|
1677
|
+
spawn_poller=_spawn_slot_poller)
|
|
1678
|
+
except Exception:
|
|
1679
|
+
pass
|
|
1680
|
+
|
|
1681
|
+
_live_state: dict = {"text": ""}
|
|
1682
|
+
home_slot.bg_tasks.append(asyncio.ensure_future(
|
|
1683
|
+
_live_session_watch(cfg, token_provider, workspace, _live_state,
|
|
1684
|
+
invalidate=_invalidate_dock)))
|
|
1467
1685
|
|
|
1468
1686
|
async def _on_line(text: str, slot: SessionSlot) -> None:
|
|
1469
1687
|
if await _handle(text, slot) == "exit":
|
|
@@ -1519,8 +1737,15 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1519
1737
|
on_switch=lambda idx: _schedule_home_refill(slots, idx, home_fill_kwargs),
|
|
1520
1738
|
on_new=lambda: _open_new_tab(),
|
|
1521
1739
|
on_paste=_on_paste,
|
|
1740
|
+
live=_live_state,
|
|
1522
1741
|
)
|
|
1523
1742
|
finally:
|
|
1743
|
+
# 0.3.37: remember the tab layout BEFORE teardown, while the
|
|
1744
|
+
# slots (and their agents' session ids) are still intact.
|
|
1745
|
+
# In `finally` so an accidental exit, Ctrl-D and a crash all
|
|
1746
|
+
# remember equally -- the whole point is that closing the
|
|
1747
|
+
# terminal stops being destructive.
|
|
1748
|
+
_save_tab_layout(workspace, slots)
|
|
1524
1749
|
_cancel_background()
|
|
1525
1750
|
# Part C: release the per-repo instance lock (if this
|
|
1526
1751
|
# process actually acquired one -- None on a secondary
|
|
@@ -1593,6 +1818,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1593
1818
|
if await _handle(line, slots.active()) == "exit":
|
|
1594
1819
|
return
|
|
1595
1820
|
finally:
|
|
1821
|
+
_save_tab_layout(workspace, slots) # 0.3.37, see the dock path above
|
|
1596
1822
|
_cancel_background()
|
|
1597
1823
|
if shared_client is not None:
|
|
1598
1824
|
try:
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Tab persistence per-repo (0.3.37): remembers WHICH tabs were open so
|
|
2
|
+
closing the terminal is no longer destructive -- reopen it and your 3 tabs
|
|
3
|
+
come back, each one re-attaching to its own still-Running Temporal workflow
|
|
4
|
+
when there is one, or restoring just its label/mode/draft when there is not.
|
|
5
|
+
|
|
6
|
+
Deliberately modelled on `mode_store` (same house pattern, same failure
|
|
7
|
+
posture) with one difference: a tab is a RECORD, not a single string, so the
|
|
8
|
+
file is JSONL -- one JSON object per line, newest write replaces the file.
|
|
9
|
+
JSONL (not one big JSON doc) so a single corrupt line costs ONE tab instead
|
|
10
|
+
of the whole layout.
|
|
11
|
+
|
|
12
|
+
Fail-soft in BOTH directions, by design:
|
|
13
|
+
* `load_tabs` -- missing file, unreadable dir, corrupt/garbage lines all
|
|
14
|
+
degrade to [] (or skip just the bad line). A bad cache is exactly as safe
|
|
15
|
+
as no cache: you get today's behaviour, a single fresh tab.
|
|
16
|
+
* `save_tabs` -- a write failure (read-only home, disk full) is silently
|
|
17
|
+
dropped. Losing the memory is far smaller than crashing the terminal over
|
|
18
|
+
a nice-to-have.
|
|
19
|
+
|
|
20
|
+
WHAT IS PERSISTED, and what deliberately is NOT:
|
|
21
|
+
* persisted: `session_id` (the re-attach handle -- the whole point),
|
|
22
|
+
`label`, `mode`, `workspace`, `draft`.
|
|
23
|
+
* NEVER persisted: the transcript (it lives server-side and replays on
|
|
24
|
+
re-attach -- duplicating it here would risk showing a stale copy), and
|
|
25
|
+
`autopilot` as a mode. Autopilot auto-approves every tool call, so it is
|
|
26
|
+
downgraded to 'default' on write for EXACTLY the reason mode_store
|
|
27
|
+
downgrades it: resuming it silently from a stale file is unsafe.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
|
|
34
|
+
from webbee.repo import compute_repo_key, find_repo_root
|
|
35
|
+
|
|
36
|
+
_CACHE_DIR = os.path.expanduser("~/.cache/webbee") # test seam: monkeypatch this name
|
|
37
|
+
|
|
38
|
+
MAX_TABS = 12 # a sane ceiling: a corrupt/huge file can never spawn tabs forever
|
|
39
|
+
|
|
40
|
+
_KEY_CACHE: dict[str, str] = {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _repo_key_for(workspace: str) -> str:
|
|
44
|
+
"""Memoised exactly like mode_store's twin: `compute_repo_key` shells out
|
|
45
|
+
to git, and this is reached from UI paths where a stall would be felt."""
|
|
46
|
+
key = _KEY_CACHE.get(workspace)
|
|
47
|
+
if key is None:
|
|
48
|
+
key = compute_repo_key(find_repo_root(workspace))
|
|
49
|
+
_KEY_CACHE[workspace] = key
|
|
50
|
+
return key
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _path_for(workspace: str) -> str:
|
|
54
|
+
return os.path.join(_CACHE_DIR, f"tabs-{_repo_key_for(workspace)}.jsonl")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def tab_record(session_id: str = "", label: str = "", mode: str = "default",
|
|
58
|
+
workspace: str = "", draft: str = "") -> dict:
|
|
59
|
+
"""PURE. Build ONE normalised tab record. Central so the writer and the
|
|
60
|
+
tests agree on the shape, and so the autopilot downgrade can never be
|
|
61
|
+
forgotten at a call site."""
|
|
62
|
+
return {
|
|
63
|
+
"session_id": str(session_id or ""),
|
|
64
|
+
"label": str(label or ""),
|
|
65
|
+
"mode": "default" if str(mode or "") == "autopilot" else (str(mode or "") or "default"),
|
|
66
|
+
"workspace": str(workspace or ""),
|
|
67
|
+
"draft": str(draft or ""),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_tabs(workspace: str) -> list:
|
|
72
|
+
"""The remembered tabs for `workspace`'s repo, oldest-first, or [] on no
|
|
73
|
+
file / ANY error. Individual corrupt lines are SKIPPED, not fatal: one bad
|
|
74
|
+
line must never cost you the other two tabs. Capped at MAX_TABS."""
|
|
75
|
+
out: list = []
|
|
76
|
+
try:
|
|
77
|
+
with open(_path_for(workspace), "r", encoding="utf-8") as f:
|
|
78
|
+
for line in f:
|
|
79
|
+
line = line.strip()
|
|
80
|
+
if not line:
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
rec = json.loads(line)
|
|
84
|
+
except Exception:
|
|
85
|
+
continue # one unreadable tab, not a broken layout
|
|
86
|
+
if isinstance(rec, dict):
|
|
87
|
+
out.append(tab_record(**{k: rec.get(k, "") for k in
|
|
88
|
+
("session_id", "label", "mode",
|
|
89
|
+
"workspace", "draft")}))
|
|
90
|
+
if len(out) >= MAX_TABS:
|
|
91
|
+
break
|
|
92
|
+
except Exception:
|
|
93
|
+
return []
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def save_tabs(workspace: str, tabs: list) -> None:
|
|
98
|
+
"""Remember `tabs` (a list of dicts/records, oldest-first) for this repo.
|
|
99
|
+
Written whole (replace, not append) so the file always mirrors the CURRENT
|
|
100
|
+
layout -- a closed tab genuinely disappears. Never raises."""
|
|
101
|
+
try:
|
|
102
|
+
os.makedirs(_CACHE_DIR, exist_ok=True)
|
|
103
|
+
lines = []
|
|
104
|
+
for t in list(tabs)[:MAX_TABS]:
|
|
105
|
+
if not isinstance(t, dict):
|
|
106
|
+
continue
|
|
107
|
+
lines.append(json.dumps(tab_record(**{k: t.get(k, "") for k in
|
|
108
|
+
("session_id", "label", "mode",
|
|
109
|
+
"workspace", "draft")}),
|
|
110
|
+
ensure_ascii=False))
|
|
111
|
+
with open(_path_for(workspace), "w", encoding="utf-8") as f:
|
|
112
|
+
f.write("\n".join(lines) + ("\n" if lines else ""))
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def clear_tabs(workspace: str) -> None:
|
|
118
|
+
"""Forget this repo's remembered layout (a clean `/exit` of the LAST tab,
|
|
119
|
+
or a user who wants a fresh dock). Never raises."""
|
|
120
|
+
try:
|
|
121
|
+
os.remove(_path_for(workspace))
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|