From 6938e8c3a98c1ff4bd07882ddd13cfd194cea67c Mon Sep 17 00:00:00 2001 From: Hibryda Date: Thu, 12 Mar 2026 07:34:40 +0100 Subject: [PATCH 01/10] chore: add nested Claude session E2E TODO + trim completed list --- TODO.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index c5f0c45..455cee3 100644 --- a/TODO.md +++ b/TODO.md @@ -8,6 +8,7 @@ - [ ] **Agent Teams real-world testing** -- Subagent delegation prompt fix done + env var injection. Needs real multi-agent session to verify Manager spawns child agents. - [ ] **Plugin sandbox migration** -- `new Function()` has inherent escape vectors (prototype walking, arguments.callee.constructor). Consider Web Worker isolation for v3.2. - [ ] **Soak test** -- Run 4-hour soak with 6+ agents across 3+ projects. Monitor memory, SQLite WAL size, xterm.js instances. +- [ ] **E2E agent tests hang in nested Claude sessions** -- B4/B5 phase-b tests timeout when run from within a Claude Code session (sidecar spawns Claude CLI which hangs in git repo context). Works in CI. Investigate process isolation or session nesting guard. ## Completed @@ -20,7 +21,3 @@ - [x] **Reviewer agent role** -- Tier 1 specialist with role='reviewer'. Reviewer workflow in agent-prompts.ts (8-step process). #review-queue/#review-log auto-channels. reviewQueueDepth in attention scoring (10pts/task, cap 50). 388 vitest + 76 cargo. | Done: 2026-03-12 - [x] **Auto-wake Manager** -- wake-scheduler.svelte.ts + wake-scorer.ts (24 tests). 3 strategies: persistent/on-demand/smart. 6 signals. Settings UI. 381 vitest + 72 cargo. | Done: 2026-03-12 - [x] **Dashboard metrics panel** -- MetricsPanel.svelte: live health + task board summary + SVG sparkline history. 25 tests. 357 vitest + 72 cargo. | Done: 2026-03-12 -- [x] **Brand Dexter's new types (SOLID Phase 3b)** -- GroupId + AgentId branded types. Applied to ~40 sites. 332 vitest + 72 cargo. | Done: 2026-03-11 -- [x] **Regression tests + sidecar env security** -- 49 new tests. Added ANTHROPIC_* to Rust env strip. 327 vitest + 72 cargo. | Done: 2026-03-11 -- [x] **Integrate dexter_changes + fix 5 critical bugs** -- Fixed: btmsg.rs column index, btmsg-bridge camelCase, GroupAgentsPanel stopPropagation, ArchitectureTab PlantUML, TestingTab Tauri 2.x. | Done: 2026-03-11 -- [x] **SOLID Phase 3 — Primitive obsession** -- Branded types SessionId/ProjectId. Applied to ~130 sites. 293 vitest + 49 cargo. | Done: 2026-03-11 From af670871edefb413c2590297001617ed40d4610e Mon Sep 17 00:00:00 2001 From: DexterFromLab Date: Fri, 6 Mar 2026 11:04:42 +0100 Subject: [PATCH 02/10] Replace ctx auto-setup with step-by-step wizard - Remove silent setup_ctx and "Edit ctx entries" button from ClaudeCodeDialog - Add CtxSetupWizard: 3-step guided flow (project registration, first entry, confirm) - Show ctx status label in session dialog (registered vs new project) - Launch wizard automatically on save when project_dir is set and ctx not initialized - Add ctx cleanup prompt when deleting a Claude session - Extract helper functions: _detect_project_description, _is_ctx_project_registered Co-Authored-By: Claude Opus 4.6 --- bterminal.py | 470 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 411 insertions(+), 59 deletions(-) diff --git a/bterminal.py b/bterminal.py index 3888a3d..7762030 100755 --- a/bterminal.py +++ b/bterminal.py @@ -680,9 +680,8 @@ class ClaudeCodeDialog(Gtk.Dialog): dir_box.pack_start(btn_browse, False, False, 0) grid.attach(dir_box, 1, 3, 1, 1) - self.btn_edit_ctx = Gtk.Button(label="Edit ctx entries\u2026") - self.btn_edit_ctx.connect("clicked", self._on_edit_ctx) - grid.attach(self.btn_edit_ctx, 1, 4, 1, 1) + self.lbl_ctx_status = Gtk.Label(xalign=0) + grid.attach(self.lbl_ctx_status, 1, 4, 1, 1) # Separator box.pack_start(Gtk.Separator(), False, False, 2) @@ -728,6 +727,7 @@ class ClaudeCodeDialog(Gtk.Dialog): self.textview.get_buffer().set_text(prompt) self.show_all() + self._update_ctx_status() def get_data(self): buf = self.textview.get_buffer() @@ -762,16 +762,6 @@ class ClaudeCodeDialog(Gtk.Dialog): dlg.run() dlg.destroy() - def _on_edit_ctx(self, button): - project_dir = self.entry_project_dir.get_text().strip() - if not project_dir: - self._show_error("Set project directory first.") - return - ctx_project = os.path.basename(project_dir.rstrip("/")) - dlg = CtxEditDialog(self, ctx_project, project_dir) - dlg.run() - dlg.destroy() - def _on_browse_dir(self, button): dlg = Gtk.FileChooserDialog( title="Select project directory", @@ -801,56 +791,25 @@ class ClaudeCodeDialog(Gtk.Dialog): f"Przed zakończeniem sesji: ctx summary {basename} \"\"" ) buf.set_text(prompt) + self._update_ctx_status() dlg.destroy() - @staticmethod - def _detect_description(project_dir): - for name in ["README.md", "README.rst", "README.txt", "README"]: - readme_path = os.path.join(project_dir, name) - if os.path.isfile(readme_path): - try: - with open(readme_path, "r") as f: - for line in f: - line = line.strip().lstrip("#").strip() - if line: - return line[:100] - except (IOError, UnicodeDecodeError): - pass - return os.path.basename(project_dir.rstrip("/")) - - def setup_ctx(self): - """Auto-initialize ctx project and generate CLAUDE.md if project_dir is set.""" + def _update_ctx_status(self): project_dir = self.entry_project_dir.get_text().strip() if not project_dir: + self.lbl_ctx_status.set_text("") return - ctx_project = os.path.basename(project_dir.rstrip("/")) - ctx_desc = self._detect_description(project_dir) - try: - subprocess.run( - ["ctx", "init", ctx_project, ctx_desc, project_dir], - check=True, capture_output=True, text=True, + name = os.path.basename(project_dir.rstrip("/")) + if _is_ctx_project_registered(name): + self.lbl_ctx_status.set_markup( + '\u2713 Ctx project "' + + GLib.markup_escape_text(name) + + '" is registered' ) - except (subprocess.CalledProcessError, FileNotFoundError): - return - claude_md = os.path.join(project_dir, "CLAUDE.md") - if not os.path.exists(claude_md): - claude_content = ( - f"# {ctx_project}\n\n" - f"On session start, load context:\n" - f"```bash\n" - f"ctx get {ctx_project}\n" - f"```\n\n" - f"Context manager: `ctx --help`\n\n" - f"During work:\n" - f"- Save important discoveries: `ctx set {ctx_project} `\n" - f"- Append to existing: `ctx append {ctx_project} `\n" - f"- Before ending session: `ctx summary {ctx_project} \"\"`\n" + else: + self.lbl_ctx_status.set_markup( + "\u2139 New project \u2014 ctx wizard will guide you after save" ) - try: - with open(claude_md, "w") as f: - f.write(claude_content) - except IOError: - pass # ─── CtxEditDialog ──────────────────────────────────────────────────────────── @@ -859,6 +818,377 @@ class ClaudeCodeDialog(Gtk.Dialog): CTX_DB = os.path.join(os.path.expanduser("~"), ".claude-context", "context.db") +def _detect_project_description(project_dir): + """Detect project description from README or directory name.""" + for name in ["README.md", "README.rst", "README.txt", "README"]: + readme_path = os.path.join(project_dir, name) + if os.path.isfile(readme_path): + try: + with open(readme_path, "r") as f: + for line in f: + line = line.strip().lstrip("#").strip() + if line: + return line[:100] + except (IOError, UnicodeDecodeError): + pass + return os.path.basename(project_dir.rstrip("/")) + + +def _is_ctx_project_registered(project_name): + """Check if a ctx project is already registered in the database.""" + if not os.path.exists(CTX_DB): + return False + import sqlite3 + try: + db = sqlite3.connect(CTX_DB) + row = db.execute( + "SELECT 1 FROM sessions WHERE name = ?", (project_name,) + ).fetchone() + db.close() + return row is not None + except sqlite3.Error: + return False + + +def _is_ctx_available(): + """Check if ctx command is available.""" + try: + subprocess.run(["ctx", "--version"], capture_output=True, timeout=5) + return True + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def _run_ctx_wizard_if_needed(parent, data): + """Launch ctx wizard if project_dir is set but ctx not registered. Returns updated data.""" + project_dir = data.get("project_dir", "") + if not project_dir or not _is_ctx_available(): + return data + project_name = os.path.basename(project_dir.rstrip("/")) + if _is_ctx_project_registered(project_name): + return data + wizard = CtxSetupWizard(parent, project_dir) + if wizard.run_wizard(): + if not data["prompt"] or data["prompt"].startswith("Wczytaj kontekst"): + data["prompt"] = wizard.result_prompt + return data + + +_WIZARD_BACK = 1 +_WIZARD_NEXT = 2 + + +class CtxSetupWizard(Gtk.Dialog): + """Step-by-step wizard for initial ctx project setup.""" + + def __init__(self, parent, project_dir): + super().__init__( + title="Ctx — New Project Setup", + transient_for=parent, + modal=True, + destroy_with_parent=True, + ) + self.set_default_size(540, -1) + self.set_resizable(False) + self.project_dir = project_dir + self.project_name = os.path.basename(project_dir.rstrip("/")) + self.success = False + self.result_prompt = "" + self._current_page = 0 + + box = self.get_content_area() + box.set_border_width(16) + box.set_spacing(12) + + # Page header + self.lbl_header = Gtk.Label(xalign=0) + box.pack_start(self.lbl_header, False, False, 0) + box.pack_start(Gtk.Separator(), False, False, 0) + + # Stack for pages + self.stack = Gtk.Stack() + self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT) + box.pack_start(self.stack, True, True, 0) + + # Status bar (for errors) + self.lbl_status = Gtk.Label(xalign=0, wrap=True, max_width_chars=60) + box.pack_start(self.lbl_status, False, False, 0) + + self._build_page_project() + self._build_page_entry() + self._build_page_confirm() + + # Navigation buttons + self.btn_cancel = self.add_button("Cancel", Gtk.ResponseType.CANCEL) + self.btn_back = self.add_button("\u2190 Back", _WIZARD_BACK) + self.btn_next = self.add_button("Next \u2192", _WIZARD_NEXT) + self.btn_finish = self.add_button("\u2713 Create", Gtk.ResponseType.OK) + + self._show_page(0) + self.show_all() + + def _build_page_project(self): + page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + + info = Gtk.Label(wrap=True, xalign=0, max_width_chars=58) + info.set_markup( + "Register the project in the ctx database.\n" + "The project name is used in all ctx commands " + "(e.g. ctx get MyProject).\n" + "Description helps Claude understand the project purpose." + ) + page.pack_start(info, False, False, 0) + + warn = Gtk.Label(wrap=True, xalign=0, max_width_chars=58) + warn.set_markup( + '\u26a0 Case matters! "MyProject" \u2260 ' + '"myproject". The name must match exactly in all commands.' + ) + page.pack_start(warn, False, False, 4) + + grid = Gtk.Grid(column_spacing=12, row_spacing=8) + + grid.attach(Gtk.Label(label="Directory:", halign=Gtk.Align.END), 0, 0, 1, 1) + lbl_dir = Gtk.Label( + label=self.project_dir, halign=Gtk.Align.START, + selectable=True, ellipsize=Pango.EllipsizeMode.MIDDLE, + ) + grid.attach(lbl_dir, 1, 0, 1, 1) + + grid.attach(Gtk.Label(label="Project name:", halign=Gtk.Align.END), 0, 1, 1, 1) + self.w_name = Gtk.Entry(hexpand=True) + self.w_name.set_text(self.project_name) + grid.attach(self.w_name, 1, 1, 1, 1) + + grid.attach(Gtk.Label(label="Description:", halign=Gtk.Align.END), 0, 2, 1, 1) + self.w_desc = Gtk.Entry(hexpand=True) + self.w_desc.set_text(_detect_project_description(self.project_dir)) + grid.attach(self.w_desc, 1, 2, 1, 1) + + page.pack_start(grid, False, False, 0) + self.stack.add_named(page, "project") + + def _build_page_entry(self): + page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + + info = Gtk.Label(wrap=True, xalign=0, max_width_chars=58) + info.set_markup( + "Add the first context entry. Claude reads these at the start " + "of each session to understand the project.\n\n" + "Examples:\n" + ' Key: repo Value: GitHub: .../MyRepo, branch: main\n' + ' Key: stack Value: Python 3.12, Flask, PostgreSQL' + ) + page.pack_start(info, False, False, 0) + + grid = Gtk.Grid(column_spacing=12, row_spacing=8) + + grid.attach(Gtk.Label(label="Key:", halign=Gtk.Align.END), 0, 0, 1, 1) + self.w_key = Gtk.Entry(hexpand=True) + self.w_key.set_placeholder_text("e.g. repo, stack, architecture") + grid.attach(self.w_key, 1, 0, 1, 1) + + grid.attach( + Gtk.Label(label="Value:", halign=Gtk.Align.END, valign=Gtk.Align.START), + 0, 1, 1, 1, + ) + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled.set_min_content_height(90) + self.w_value = Gtk.TextView(wrap_mode=Gtk.WrapMode.WORD_CHAR) + scrolled.add(self.w_value) + grid.attach(scrolled, 1, 1, 1, 1) + + page.pack_start(grid, True, True, 0) + self.stack.add_named(page, "entry") + + def _build_page_confirm(self): + page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + + info = Gtk.Label(wrap=True, xalign=0, max_width_chars=58) + info.set_text("Review and confirm. The following actions will be performed:") + page.pack_start(info, False, False, 0) + + self.lbl_summary = Gtk.Label(wrap=True, xalign=0, max_width_chars=58) + page.pack_start(self.lbl_summary, False, False, 0) + + page.pack_start(Gtk.Separator(), False, False, 4) + self.stack.add_named(page, "confirm") + + def _show_page(self, idx): + self._current_page = idx + pages = ["project", "entry", "confirm"] + self.stack.set_visible_child_name(pages[idx]) + self.lbl_status.set_text("") + + headers = [ + "Step 1 of 3: Project registration", + "Step 2 of 3: First context entry", + "Step 3 of 3: Confirm and create", + ] + self.lbl_header.set_markup(f"{headers[idx]}") + + if idx == 2: + self._update_summary() + + def _update_buttons(self): + idx = self._current_page + self.btn_back.set_visible(idx > 0) + self.btn_next.set_visible(idx < 2) + self.btn_finish.set_visible(idx == 2) + + def _update_summary(self): + name = self.w_name.get_text().strip() + desc = self.w_desc.get_text().strip() + key = self.w_key.get_text().strip() + buf = self.w_value.get_buffer() + s, e = buf.get_bounds() + value = buf.get_text(s, e, False).strip() + val_preview = value[:150] + ("\u2026" if len(value) > 150 else "") + + self.lbl_summary.set_markup( + f"1. ctx init \u2014 register project " + f"{GLib.markup_escape_text(name)}\n" + f" {GLib.markup_escape_text(desc)}\n\n" + f"2. ctx set \u2014 add entry " + f"{GLib.markup_escape_text(key)}\n" + f" {GLib.markup_escape_text(val_preview)}\n\n" + f"3. Create CLAUDE.md in project directory\n" + f" (will be skipped if file already exists)" + ) + + def _validate_page(self, idx): + if idx == 0: + name = self.w_name.get_text().strip() + desc = self.w_desc.get_text().strip() + if not name: + self.lbl_status.set_markup( + 'Project name is required.' + ) + self.w_name.grab_focus() + return False + if not desc: + self.lbl_status.set_markup( + 'Description is required.' + ) + self.w_desc.grab_focus() + return False + elif idx == 1: + key = self.w_key.get_text().strip() + buf = self.w_value.get_buffer() + s, e = buf.get_bounds() + value = buf.get_text(s, e, False).strip() + if not key: + self.lbl_status.set_markup( + 'Key is required. ' + 'E.g. "repo", "stack", "notes".' + ) + self.w_key.grab_focus() + return False + if not value: + self.lbl_status.set_markup( + 'Value is required. ' + "Describe something about the project." + ) + self.w_value.grab_focus() + return False + return True + + def _execute(self): + """Run ctx init, ctx set, and create CLAUDE.md.""" + name = self.w_name.get_text().strip() + desc = self.w_desc.get_text().strip() + key = self.w_key.get_text().strip() + buf = self.w_value.get_buffer() + s, e = buf.get_bounds() + value = buf.get_text(s, e, False).strip() + + # 1. ctx init + try: + r = subprocess.run( + ["ctx", "init", name, desc, self.project_dir], + capture_output=True, text=True, + ) + if r.returncode != 0: + self.lbl_status.set_markup( + f'ctx init failed: ' + f"{GLib.markup_escape_text(r.stderr.strip())}" + ) + return False + except FileNotFoundError: + self.lbl_status.set_markup( + 'ctx command not found.' + ) + return False + + # 2. ctx set + try: + r = subprocess.run( + ["ctx", "set", name, key, value], + capture_output=True, text=True, + ) + if r.returncode != 0: + self.lbl_status.set_markup( + f'ctx set failed: ' + f"{GLib.markup_escape_text(r.stderr.strip())}" + ) + return False + except FileNotFoundError: + return False + + # 3. CLAUDE.md + claude_md = os.path.join(self.project_dir, "CLAUDE.md") + if not os.path.exists(claude_md): + try: + with open(claude_md, "w") as f: + f.write( + f"# {name}\n\n" + f"On session start, load context:\n" + f"```bash\n" + f"ctx get {name}\n" + f"```\n\n" + f"Context manager: `ctx --help`\n\n" + f"During work:\n" + f"- Save important discoveries: `ctx set {name} `\n" + f"- Append to existing: `ctx append {name} `\n" + f'- Before ending session: `ctx summary {name} ""`\n' + ) + except IOError as e: + self.lbl_status.set_markup( + f'CLAUDE.md: {GLib.markup_escape_text(str(e))}' + ) + return False + + self.project_name = name + self.result_prompt = ( + f"Wczytaj kontekst projektu poleceniem: ctx get {name}\n" + f"Wykonaj t\u0119 komend\u0119 i zapoznaj si\u0119 z kontekstem zanim zaczniesz prac\u0119.\n" + f"Kontekst zarz\u0105dzasz przez: ctx --help\n" + f"Wa\u017cne odkrycia zapisuj: ctx set {name} \n" + f'Przed zako\u0144czeniem sesji: ctx summary {name} ""' + ) + self.success = True + return True + + def run_wizard(self): + """Run the wizard. Returns True if completed successfully.""" + while True: + self._update_buttons() + resp = self.run() + if resp == _WIZARD_NEXT: + if self._validate_page(self._current_page): + self._show_page(self._current_page + 1) + elif resp == _WIZARD_BACK: + self._show_page(self._current_page - 1) + elif resp == Gtk.ResponseType.OK: + if self._execute(): + self.destroy() + return True + else: + self.destroy() + return False + + class _CtxEntryDialog(Gtk.Dialog): """Small dialog for adding/editing a ctx key-value entry.""" @@ -1658,8 +1988,9 @@ class SessionSidebar(Gtk.Box): if resp != Gtk.ResponseType.OK: break if dlg.validate(): - dlg.setup_ctx() - self.app.claude_manager.add(dlg.get_data()) + data = dlg.get_data() + data = _run_ctx_wizard_if_needed(dlg, data) + self.app.claude_manager.add(data) self.refresh() break dlg.destroy() @@ -1827,8 +2158,8 @@ class SessionSidebar(Gtk.Box): if resp != Gtk.ResponseType.OK: break if dlg.validate(): - dlg.setup_ctx() data = dlg.get_data() + data = _run_ctx_wizard_if_needed(dlg, data) self.app.claude_manager.update(claude_id, data) self.refresh() break @@ -1847,6 +2178,27 @@ class SessionSidebar(Gtk.Box): ) if dlg.run() == Gtk.ResponseType.YES: self.app.claude_manager.delete(claude_id) + # Ask about ctx cleanup + project_dir = config.get("project_dir", "") + if project_dir: + ctx_name = os.path.basename(project_dir.rstrip("/")) + if _is_ctx_available() and _is_ctx_project_registered(ctx_name): + ctx_dlg = Gtk.MessageDialog( + transient_for=self.app, + modal=True, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text=f"Also delete ctx project \"{ctx_name}\"?", + ) + ctx_dlg.format_secondary_text( + "This will remove all context entries for this project from the ctx database." + ) + if ctx_dlg.run() == Gtk.ResponseType.YES: + subprocess.run( + ["ctx", "delete", ctx_name], + capture_output=True, text=True, + ) + ctx_dlg.destroy() self.refresh() dlg.destroy() From f9ec78ce1e6806ecd4736292ff2397bdd52ce269 Mon Sep 17 00:00:00 2001 From: DexterFromLab Date: Fri, 6 Mar 2026 11:20:23 +0100 Subject: [PATCH 03/10] Remove shared context from ctx get output to avoid misleading project info Shared entries (server, webhooks, workflow) were shown for every project, causing Claude to misattribute them. Now ctx get shows only project-specific data. Use --shared flag to include shared context when needed. Co-Authored-By: Claude Opus 4.6 --- ctx | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/ctx b/ctx index 315964b..a089e76 100755 --- a/ctx +++ b/ctx @@ -103,19 +103,18 @@ def cmd_init(args): def cmd_get(args): - """Get full context for a project (shared + project-specific + recent summaries).""" + """Get full context for a project (project-specific + recent summaries). + Use --shared flag to also include shared context.""" if len(args) < 1: - print("Usage: ctx get ") + print("Usage: ctx get [--shared]") sys.exit(1) project = args[0] + show_shared = "--shared" in args db = get_db() # Session info session = db.execute("SELECT * FROM sessions WHERE name = ?", (project,)).fetchone() - # Shared context - shared = db.execute("SELECT key, value FROM shared ORDER BY key").fetchall() - # Project context contexts = db.execute( "SELECT key, value FROM contexts WHERE project = ? ORDER BY key", (project,) @@ -137,11 +136,13 @@ def cmd_get(args): print(f"PROJECT: {project} (not registered, use: ctx init)") print("=" * 60) - if shared: - print("\n--- Shared Context ---") - for row in shared: - print(f"\n[{row['key']}]") - print(row["value"]) + if show_shared: + shared = db.execute("SELECT key, value FROM shared ORDER BY key").fetchall() + if shared: + print("\n--- Shared Context ---") + for row in shared: + print(f"\n[{row['key']}]") + print(row["value"]) if contexts: print(f"\n--- {project} Context ---") @@ -155,8 +156,8 @@ def cmd_get(args): print(f"\n[{row['created_at']}]") print(row["summary"]) - if not shared and not contexts and not summaries: - print("\nNo context stored yet. Use 'ctx set' or 'ctx shared set' to add.") + if not contexts and not summaries: + print("\nNo context stored yet. Use 'ctx set' to add project context.") db.close() @@ -444,7 +445,7 @@ def print_help(): print("ctx — Cross-session context manager for Claude Code\n") print("Commands:") print(" init [dir] Register a new project") - print(" get Load full context (shared + project)") + print(" get [--shared] Load project context (optionally with shared)") print(" set Set project context entry") print(" append Append to existing entry") print(" shared get|set|delete Manage shared context") From a7077c798762adc183ca8f98ec0ecb53df13e472 Mon Sep 17 00:00:00 2001 From: DexterFromLab Date: Fri, 6 Mar 2026 11:56:41 +0100 Subject: [PATCH 04/10] Add Ctx Manager panel as new sidebar tab for browsing and editing project contexts Adds a StackSwitcher to the sidebar with Sessions and Ctx tabs. The Ctx panel provides a tree view of all ctx projects/entries with a detail preview pane, CRUD operations (add/edit/delete projects and entries), right-click context menus, and auto-refresh on tab switch. Co-Authored-By: Claude Opus 4.6 --- bterminal.py | 631 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 627 insertions(+), 4 deletions(-) diff --git a/bterminal.py b/bterminal.py index 7762030..2502bc9 100755 --- a/bterminal.py +++ b/bterminal.py @@ -161,6 +161,36 @@ treeview:hover {{ background: {CATPPUCCIN['surface2']}; color: {CATPPUCCIN['red']}; }} +stackswitcher {{ + background: {CATPPUCCIN['crust']}; + border-bottom: 1px solid {CATPPUCCIN['surface0']}; +}} +stackswitcher button {{ + background: {CATPPUCCIN['crust']}; + color: {CATPPUCCIN['subtext0']}; + border: none; + border-radius: 0; + padding: 6px 16px; + border-bottom: 2px solid transparent; + font-weight: bold; + font-size: 12px; +}} +stackswitcher button:checked {{ + background: {CATPPUCCIN['mantle']}; + color: {CATPPUCCIN['blue']}; + border-bottom: 2px solid {CATPPUCCIN['blue']}; +}} +stackswitcher button:hover {{ + background: {CATPPUCCIN['surface0']}; +}} +textview.ctx-detail {{ + font-family: monospace; + font-size: 10pt; +}} +textview.ctx-detail text {{ + background-color: {CATPPUCCIN['crust']}; + color: {CATPPUCCIN['subtext1']}; +}} """ @@ -1239,6 +1269,78 @@ class _CtxEntryDialog(Gtk.Dialog): return key, value +class _CtxProjectDialog(Gtk.Dialog): + """Dialog for adding/editing a ctx project.""" + + def __init__(self, parent, title="New Project", name="", description="", work_dir=""): + super().__init__( + title=title, + transient_for=parent, + modal=True, + destroy_with_parent=True, + ) + self.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OK, Gtk.ResponseType.OK, + ) + self.set_default_size(450, -1) + self.set_default_response(Gtk.ResponseType.OK) + + box = self.get_content_area() + box.set_border_width(12) + box.set_spacing(8) + + grid = Gtk.Grid(column_spacing=12, row_spacing=8) + box.pack_start(grid, True, True, 0) + + grid.attach(Gtk.Label(label="Name:", halign=Gtk.Align.END), 0, 0, 1, 1) + self.entry_name = Gtk.Entry(hexpand=True) + self.entry_name.set_text(name) + grid.attach(self.entry_name, 1, 0, 1, 1) + + grid.attach(Gtk.Label(label="Description:", halign=Gtk.Align.END), 0, 1, 1, 1) + self.entry_desc = Gtk.Entry(hexpand=True) + self.entry_desc.set_text(description) + grid.attach(self.entry_desc, 1, 1, 1, 1) + + grid.attach(Gtk.Label(label="Directory:", halign=Gtk.Align.END), 0, 2, 1, 1) + dir_box = Gtk.Box(spacing=4) + self.entry_dir = Gtk.Entry(hexpand=True) + self.entry_dir.set_text(work_dir) + self.entry_dir.set_placeholder_text("(optional) path to project directory") + dir_box.pack_start(self.entry_dir, True, True, 0) + btn_browse = Gtk.Button(label="Browse\u2026") + btn_browse.connect("clicked", self._on_browse) + dir_box.pack_start(btn_browse, False, False, 0) + grid.attach(dir_box, 1, 2, 1, 1) + + self.show_all() + + def _on_browse(self, button): + dlg = Gtk.FileChooserDialog( + title="Select directory", + parent=self, + action=Gtk.FileChooserAction.SELECT_FOLDER, + ) + dlg.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OPEN, Gtk.ResponseType.OK, + ) + if dlg.run() == Gtk.ResponseType.OK: + path = dlg.get_filename() + self.entry_dir.set_text(path) + if not self.entry_name.get_text().strip(): + self.entry_name.set_text(os.path.basename(path.rstrip("/"))) + dlg.destroy() + + def get_data(self): + return ( + self.entry_name.get_text().strip(), + self.entry_desc.get_text().strip(), + self.entry_dir.get_text().strip(), + ) + + class CtxEditDialog(Gtk.Dialog): """Dialog to view and edit ctx project entries.""" @@ -1659,8 +1761,6 @@ class SessionSidebar(Gtk.Box): def __init__(self, app): super().__init__(orientation=Gtk.Orientation.VERTICAL) self.app = app - self.get_style_context().add_class("sidebar") - self.set_size_request(250, -1) # Header header = Gtk.Label(label=f" {APP_NAME} Sessions") @@ -2203,6 +2303,497 @@ class SessionSidebar(Gtk.Box): dlg.destroy() +# ─── CtxManagerPanel ────────────────────────────────────────────────────────── + + +class CtxManagerPanel(Gtk.Box): + """Panel for browsing and managing ctx project contexts.""" + + def __init__(self, app): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.app = app + + # Paned: tree on top, detail on bottom + paned = Gtk.VPaned() + self.pack_start(paned, True, True, 0) + + # ── Tree ── + # Columns: icon, display_name, project, key, color, weight + self.store = Gtk.TreeStore(str, str, str, str, str, int) + self.tree = Gtk.TreeView(model=self.store) + self.tree.set_headers_visible(False) + self.tree.set_activate_on_single_click(False) + + col = Gtk.TreeViewColumn() + cell_icon = Gtk.CellRendererText() + col.pack_start(cell_icon, False) + col.add_attribute(cell_icon, "text", 0) + + cell_name = Gtk.CellRendererText() + cell_name.set_property("ellipsize", Pango.EllipsizeMode.END) + col.pack_start(cell_name, True) + col.add_attribute(cell_name, "text", 1) + col.add_attribute(cell_name, "foreground", 4) + col.add_attribute(cell_name, "weight", 5) + + self.tree.append_column(col) + + tree_scroll = Gtk.ScrolledWindow() + tree_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + tree_scroll.add(self.tree) + paned.pack1(tree_scroll, resize=True, shrink=False) + + # ── Detail pane ── + detail_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + self.detail_header = Gtk.Label(xalign=0) + self.detail_header.set_margin_start(8) + self.detail_header.set_margin_top(4) + detail_box.pack_start(self.detail_header, False, False, 0) + + detail_scroll = Gtk.ScrolledWindow() + detail_scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + detail_scroll.set_min_content_height(80) + self.detail_view = Gtk.TextView() + self.detail_view.set_editable(False) + self.detail_view.set_cursor_visible(False) + self.detail_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + self.detail_view.set_left_margin(8) + self.detail_view.set_right_margin(8) + self.detail_view.set_top_margin(4) + self.detail_view.set_bottom_margin(4) + self.detail_view.get_style_context().add_class("ctx-detail") + detail_scroll.add(self.detail_view) + detail_box.pack_start(detail_scroll, True, True, 0) + + paned.pack2(detail_box, resize=False, shrink=False) + paned.set_position(300) + + # ── Buttons ── + btn_box = Gtk.Box(spacing=4) + btn_box.set_border_width(6) + + btn_add = Gtk.MenuButton(label="Add \u25be") + btn_add.get_style_context().add_class("sidebar-btn") + add_menu = Gtk.Menu() + item_proj = Gtk.MenuItem(label="New Project") + item_proj.connect("activate", lambda _: self._on_add_project()) + add_menu.append(item_proj) + item_entry = Gtk.MenuItem(label="New Entry") + item_entry.connect("activate", lambda _: self._on_add_entry()) + add_menu.append(item_entry) + add_menu.show_all() + btn_add.set_popup(add_menu) + + btn_edit = Gtk.Button(label="Edit") + btn_edit.get_style_context().add_class("sidebar-btn") + btn_edit.connect("clicked", lambda _: self._on_edit()) + + btn_del = Gtk.Button(label="Delete") + btn_del.get_style_context().add_class("sidebar-btn") + btn_del.connect("clicked", lambda _: self._on_delete()) + + btn_refresh = Gtk.Button(label="\u21bb") + btn_refresh.get_style_context().add_class("sidebar-btn") + btn_refresh.set_tooltip_text("Refresh") + btn_refresh.connect("clicked", lambda _: self.refresh()) + + btn_box.pack_start(btn_add, True, True, 0) + btn_box.pack_start(btn_edit, True, True, 0) + btn_box.pack_start(btn_del, True, True, 0) + btn_box.pack_start(btn_refresh, False, False, 0) + self.pack_start(btn_box, False, False, 0) + + # Signals + self.tree.connect("row-activated", self._on_row_activated) + self.tree.connect("button-press-event", self._on_button_press) + self.tree.get_selection().connect("changed", self._on_selection_changed) + + self.refresh() + + def refresh(self): + """Reload all data from the ctx database.""" + self.store.clear() + self.detail_header.set_text("") + self.detail_view.get_buffer().set_text("") + if not os.path.exists(CTX_DB): + return + + import sqlite3 + db = sqlite3.connect(CTX_DB) + db.row_factory = sqlite3.Row + + projects = db.execute( + "SELECT name, description, work_dir FROM sessions ORDER BY name" + ).fetchall() + + for proj in projects: + proj_iter = self.store.append(None, [ + "\U0001f4c1", + proj["name"], + proj["name"], + "", + CATPPUCCIN["blue"], + Pango.Weight.BOLD, + ]) + entries = db.execute( + "SELECT key FROM contexts WHERE project = ? ORDER BY key", + (proj["name"],), + ).fetchall() + for entry in entries: + self.store.append(proj_iter, [ + " ", + entry["key"], + proj["name"], + entry["key"], + CATPPUCCIN["text"], + Pango.Weight.NORMAL, + ]) + + # Shared entries + shared = db.execute("SELECT key FROM shared ORDER BY key").fetchall() + if shared: + shared_iter = self.store.append(None, [ + "\U0001f517", + "Shared", + "__shared__", + "", + CATPPUCCIN["peach"], + Pango.Weight.BOLD, + ]) + for entry in shared: + self.store.append(shared_iter, [ + " ", + entry["key"], + "__shared__", + entry["key"], + CATPPUCCIN["text"], + Pango.Weight.NORMAL, + ]) + + db.close() + self.tree.expand_all() + + def _get_selected_info(self): + """Returns (project_name, entry_key) of selected row.""" + sel = self.tree.get_selection() + model, it = sel.get_selected() + if it is None: + return None, None + return model.get_value(it, 2), model.get_value(it, 3) + + def _on_selection_changed(self, selection): + model, it = selection.get_selected() + if it is None: + self.detail_header.set_text("") + self.detail_view.get_buffer().set_text("") + return + project = model.get_value(it, 2) + key = model.get_value(it, 3) + if key: + self._show_entry_detail(project, key) + else: + self._show_project_detail(project) + + def _show_project_detail(self, project): + import sqlite3 + if not os.path.exists(CTX_DB): + return + db = sqlite3.connect(CTX_DB) + db.row_factory = sqlite3.Row + + if project == "__shared__": + self.detail_header.set_markup("\U0001f517 Shared") + self.detail_view.get_buffer().set_text( + "Shared context entries available to all projects." + ) + db.close() + return + + proj = db.execute( + "SELECT description, work_dir FROM sessions WHERE name = ?", + (project,), + ).fetchone() + if not proj: + db.close() + return + + self.detail_header.set_markup( + f"\U0001f4c1 {GLib.markup_escape_text(project)}" + ) + lines = [] + if proj["description"]: + lines.append(proj["description"]) + if proj["work_dir"]: + lines.append(f"Dir: {proj['work_dir']}") + + count = db.execute( + "SELECT COUNT(*) FROM contexts WHERE project = ?", (project,) + ).fetchone()[0] + lines.append(f"Entries: {count}") + + # Last summary + summary = db.execute( + "SELECT summary, created_at FROM summaries " + "WHERE project = ? ORDER BY created_at DESC LIMIT 1", + (project,), + ).fetchone() + if summary: + lines.append( + f"\n\u2500\u2500 Last summary ({summary['created_at'][:10]}) \u2500\u2500" + ) + lines.append(summary["summary"]) + + # Associated Claude session prompt + for cs in self.app.claude_manager.all(): + cs_dir = cs.get("project_dir", "").rstrip("/") + if cs_dir and os.path.basename(cs_dir) == project: + prompt = cs.get("prompt", "") + if prompt: + lines.append("\n\u2500\u2500 Introductory prompt \u2500\u2500") + lines.append(prompt) + break + + self.detail_view.get_buffer().set_text("\n".join(lines)) + db.close() + + def _show_entry_detail(self, project, key): + import sqlite3 + if not os.path.exists(CTX_DB): + return + db = sqlite3.connect(CTX_DB) + if project == "__shared__": + row = db.execute( + "SELECT value FROM shared WHERE key = ?", (key,) + ).fetchone() + else: + row = db.execute( + "SELECT value FROM contexts WHERE project = ? AND key = ?", + (project, key), + ).fetchone() + if row: + self.detail_header.set_markup( + f"{GLib.markup_escape_text(key)}" + ) + self.detail_view.get_buffer().set_text(row[0]) + db.close() + + def _on_row_activated(self, tree, path, column): + self._on_edit() + + def _on_button_press(self, widget, event): + if event.button != 3: + return False + path_info = self.tree.get_path_at_pos(int(event.x), int(event.y)) + if not path_info: + return True + path = path_info[0] + self.tree.get_selection().select_path(path) + it = self.store.get_iter(path) + project = self.store.get_value(it, 2) + key = self.store.get_value(it, 3) + + menu = Gtk.Menu() + if not key: + # Project row + item_add = Gtk.MenuItem(label="Add Entry") + item_add.connect("activate", lambda _: self._on_add_entry()) + menu.append(item_add) + + item_edit = Gtk.MenuItem(label="Edit Project") + item_edit.connect("activate", lambda _: self._on_edit()) + menu.append(item_edit) + + menu.append(Gtk.SeparatorMenuItem()) + + item_del = Gtk.MenuItem(label="Delete Project") + item_del.connect("activate", lambda _: self._on_delete()) + menu.append(item_del) + else: + # Entry row + item_edit = Gtk.MenuItem(label="Edit Entry") + item_edit.connect("activate", lambda _: self._on_edit()) + menu.append(item_edit) + + item_del = Gtk.MenuItem(label="Delete Entry") + item_del.connect("activate", lambda _: self._on_delete()) + menu.append(item_del) + + menu.show_all() + menu.popup_at_pointer(event) + return True + + def _on_add_project(self): + dlg = _CtxProjectDialog(self.app, "New Project") + if dlg.run() == Gtk.ResponseType.OK: + name, desc, work_dir = dlg.get_data() + if name and desc: + args = ["ctx", "init", name, desc] + if work_dir: + args.append(work_dir) + subprocess.run(args, capture_output=True, text=True) + self.refresh() + dlg.destroy() + + def _on_add_entry(self): + project, _ = self._get_selected_info() + if not project or project == "__shared__": + return + dlg = _CtxEntryDialog(self.app, f"Add entry to {project}") + if dlg.run() == Gtk.ResponseType.OK: + key, value = dlg.get_data() + if key: + subprocess.run( + ["ctx", "set", project, key, value], + capture_output=True, text=True, + ) + self.refresh() + dlg.destroy() + + def _on_edit(self): + project, key = self._get_selected_info() + if not project: + return + if project == "__shared__": + if key: + self._edit_shared_entry(key) + return + if key: + self._edit_entry(project, key) + else: + self._edit_project(project) + + def _edit_project(self, project): + import sqlite3 + if not os.path.exists(CTX_DB): + return + db = sqlite3.connect(CTX_DB) + row = db.execute( + "SELECT description, work_dir FROM sessions WHERE name = ?", + (project,), + ).fetchone() + db.close() + if not row: + return + dlg = _CtxProjectDialog( + self.app, "Edit Project", project, row[0] or "", row[1] or "" + ) + dlg.entry_name.set_sensitive(False) + if dlg.run() == Gtk.ResponseType.OK: + _, desc, work_dir = dlg.get_data() + if desc: + args = ["ctx", "init", project, desc] + if work_dir: + args.append(work_dir) + subprocess.run(args, capture_output=True, text=True) + self.refresh() + dlg.destroy() + + def _edit_entry(self, project, key): + import sqlite3 + if not os.path.exists(CTX_DB): + return + db = sqlite3.connect(CTX_DB) + row = db.execute( + "SELECT value FROM contexts WHERE project = ? AND key = ?", + (project, key), + ).fetchone() + db.close() + if not row: + return + dlg = _CtxEntryDialog(self.app, f"Edit: {key}", key, row[0]) + if dlg.run() == Gtk.ResponseType.OK: + new_key, value = dlg.get_data() + if new_key: + if new_key != key: + subprocess.run( + ["ctx", "delete", project, key], + capture_output=True, text=True, + ) + subprocess.run( + ["ctx", "set", project, new_key, value], + capture_output=True, text=True, + ) + self.refresh() + dlg.destroy() + + def _edit_shared_entry(self, key): + import sqlite3 + if not os.path.exists(CTX_DB): + return + db = sqlite3.connect(CTX_DB) + row = db.execute( + "SELECT value FROM shared WHERE key = ?", (key,) + ).fetchone() + db.close() + if not row: + return + dlg = _CtxEntryDialog(self.app, f"Edit shared: {key}", key, row[0]) + if dlg.run() == Gtk.ResponseType.OK: + new_key, value = dlg.get_data() + if new_key: + db = sqlite3.connect(CTX_DB) + if new_key != key: + db.execute("DELETE FROM shared WHERE key = ?", (key,)) + db.execute( + "INSERT OR REPLACE INTO shared (key, value, updated_at) " + "VALUES (?, ?, datetime('now'))", + (new_key, value), + ) + db.commit() + db.close() + self.refresh() + dlg.destroy() + + def _on_delete(self): + project, key = self._get_selected_info() + if not project: + return + if key: + self._delete_entry(project, key) + elif project != "__shared__": + self._delete_project(project) + + def _delete_entry(self, project, key): + dlg = Gtk.MessageDialog( + transient_for=self.app, + modal=True, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text=f'Delete entry "{key}" from {project}?', + ) + if dlg.run() == Gtk.ResponseType.YES: + if project == "__shared__": + import sqlite3 + db = sqlite3.connect(CTX_DB) + db.execute("DELETE FROM shared WHERE key = ?", (key,)) + db.commit() + db.close() + else: + subprocess.run( + ["ctx", "delete", project, key], + capture_output=True, text=True, + ) + self.refresh() + dlg.destroy() + + def _delete_project(self, project): + dlg = Gtk.MessageDialog( + transient_for=self.app, + modal=True, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text=f'Delete project "{project}" and all its entries?', + ) + if dlg.run() == Gtk.ResponseType.YES: + subprocess.run( + ["ctx", "delete", project], + capture_output=True, text=True, + ) + self.refresh() + dlg.destroy() + + # ─── BTerminalApp ───────────────────────────────────────────────────────────── @@ -2235,9 +2826,31 @@ class BTerminalApp(Gtk.Window): paned = Gtk.HPaned() self.add(paned) - # Sidebar + # Sidebar container with stack switcher + sidebar_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + sidebar_box.get_style_context().add_class("sidebar") + sidebar_box.set_size_request(250, -1) + + self.sidebar_stack = Gtk.Stack() + self.sidebar_stack.set_transition_type( + Gtk.StackTransitionType.SLIDE_LEFT_RIGHT + ) + self.sidebar = SessionSidebar(self) - paned.pack1(self.sidebar, resize=False, shrink=False) + self.sidebar_stack.add_titled(self.sidebar, "sessions", "Sessions") + + self.ctx_panel = CtxManagerPanel(self) + self.sidebar_stack.add_titled(self.ctx_panel, "ctx", "Ctx") + + switcher = Gtk.StackSwitcher() + switcher.set_stack(self.sidebar_stack) + switcher.set_halign(Gtk.Align.FILL) + switcher.set_homogeneous(True) + + sidebar_box.pack_start(switcher, False, False, 0) + sidebar_box.pack_start(self.sidebar_stack, True, True, 0) + + paned.pack1(sidebar_box, resize=False, shrink=False) # Notebook (tabs) self.notebook = Gtk.Notebook() @@ -2248,6 +2861,16 @@ class BTerminalApp(Gtk.Window): paned.set_position(250) + # Auto-refresh ctx panel when switching to it + self.sidebar_stack.connect( + "notify::visible-child", + lambda s, _: ( + self.ctx_panel.refresh() + if s.get_visible_child() is self.ctx_panel + else None + ), + ) + # Keyboard shortcuts self.connect("key-press-event", self._on_key_press) self.connect("delete-event", self._on_delete_event) From 31fed163d0475c493b91ff69a2139375753fc258 Mon Sep 17 00:00:00 2001 From: DexterFromLab Date: Sat, 7 Mar 2026 09:07:02 +0100 Subject: [PATCH 05/10] Add selective import/export for Ctx Manager with checkbox tree UI Export dialog lets users pick specific projects, entries, summaries, and shared context to save as JSON. Import dialog previews file contents with checkboxes and supports overwrite/skip conflict mode. Co-Authored-By: Claude Opus 4.6 --- bterminal.py | 596 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 596 insertions(+) diff --git a/bterminal.py b/bterminal.py index 2502bc9..6f4caba 100755 --- a/bterminal.py +++ b/bterminal.py @@ -2303,6 +2303,482 @@ class SessionSidebar(Gtk.Box): dlg.destroy() +# ─── Ctx Import / Export ────────────────────────────────────────────────────── + + +class _CtxExportDialog(Gtk.Dialog): + """Dialog for selectively exporting ctx data to a JSON file.""" + + def __init__(self, parent): + super().__init__( + title="Export Context", + transient_for=parent, + modal=True, + destroy_with_parent=True, + ) + self.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + "Export", Gtk.ResponseType.OK, + ) + self.set_default_size(500, 450) + self.set_default_response(Gtk.ResponseType.OK) + + box = self.get_content_area() + box.set_border_width(12) + box.set_spacing(8) + + # Select all / Deselect all + sel_box = Gtk.Box(spacing=8) + btn_all = Gtk.Button(label="Select All") + btn_all.connect("clicked", lambda _: self._set_all(True)) + btn_none = Gtk.Button(label="Deselect All") + btn_none.connect("clicked", lambda _: self._set_all(False)) + sel_box.pack_start(btn_all, False, False, 0) + sel_box.pack_start(btn_none, False, False, 0) + box.pack_start(sel_box, False, False, 0) + + # Tree with checkboxes: toggle, icon, name, data_type, data_key + self.store = Gtk.TreeStore(bool, str, str, str, str) + self.tree = Gtk.TreeView(model=self.store) + self.tree.set_headers_visible(False) + + col = Gtk.TreeViewColumn() + cell_toggle = Gtk.CellRendererToggle() + cell_toggle.connect("toggled", self._on_toggled) + col.pack_start(cell_toggle, False) + col.add_attribute(cell_toggle, "active", 0) + + cell_icon = Gtk.CellRendererText() + col.pack_start(cell_icon, False) + col.add_attribute(cell_icon, "text", 1) + + cell_name = Gtk.CellRendererText() + cell_name.set_property("ellipsize", Pango.EllipsizeMode.END) + col.pack_start(cell_name, True) + col.add_attribute(cell_name, "text", 2) + + self.tree.append_column(col) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.add(self.tree) + box.pack_start(scroll, True, True, 0) + + self._load_data() + self.show_all() + + def _load_data(self): + import sqlite3 + if not os.path.exists(CTX_DB): + return + db = sqlite3.connect(CTX_DB) + db.row_factory = sqlite3.Row + + projects = db.execute( + "SELECT name FROM sessions ORDER BY name" + ).fetchall() + for proj in projects: + pname = proj["name"] + proj_iter = self.store.append(None, [ + True, "\U0001f4c1", pname, "project", pname, + ]) + entries = db.execute( + "SELECT key FROM contexts WHERE project = ? ORDER BY key", + (pname,), + ).fetchall() + for entry in entries: + self.store.append(proj_iter, [ + True, " ", entry["key"], "entry", entry["key"], + ]) + scount = db.execute( + "SELECT COUNT(*) as c FROM summaries WHERE project = ?", + (pname,), + ).fetchone()["c"] + if scount: + self.store.append(proj_iter, [ + True, "\U0001f4cb", f"Summaries ({scount})", "summaries", pname, + ]) + + shared = db.execute("SELECT key FROM shared ORDER BY key").fetchall() + if shared: + shared_iter = self.store.append(None, [ + True, "\U0001f517", "Shared", "shared", "", + ]) + for entry in shared: + self.store.append(shared_iter, [ + True, " ", entry["key"], "shared_entry", entry["key"], + ]) + + db.close() + self.tree.expand_all() + + def _on_toggled(self, renderer, path): + it = self.store.get_iter(path) + new_val = not self.store.get_value(it, 0) + self.store.set_value(it, 0, new_val) + # Propagate to children + child = self.store.iter_children(it) + while child: + self.store.set_value(child, 0, new_val) + child = self.store.iter_next(child) + # Update parent based on children + parent = self.store.iter_parent(it) + if parent: + any_checked = False + child = self.store.iter_children(parent) + while child: + if self.store.get_value(child, 0): + any_checked = True + break + child = self.store.iter_next(child) + self.store.set_value(parent, 0, any_checked) + + def _set_all(self, val): + def _walk(it): + while it: + self.store.set_value(it, 0, val) + child = self.store.iter_children(it) + if child: + _walk(child) + it = self.store.iter_next(it) + root = self.store.get_iter_first() + if root: + _walk(root) + + def get_export_data(self): + """Collect checked items and return export dict.""" + import sqlite3 + if not os.path.exists(CTX_DB): + return None + db = sqlite3.connect(CTX_DB) + db.row_factory = sqlite3.Row + data = {"sessions": [], "contexts": [], "shared": [], "summaries": []} + + root = self.store.get_iter_first() + while root: + dtype = self.store.get_value(root, 3) + dkey = self.store.get_value(root, 4) + + if dtype == "project": + proj_name = dkey + child = self.store.iter_children(root) + checked_entries = [] + include_summaries = False + while child: + if self.store.get_value(child, 0): + ctype = self.store.get_value(child, 3) + ckey = self.store.get_value(child, 4) + if ctype == "entry": + checked_entries.append(ckey) + elif ctype == "summaries": + include_summaries = True + child = self.store.iter_next(child) + + if checked_entries or include_summaries or self.store.get_value(root, 0): + row = db.execute( + "SELECT * FROM sessions WHERE name = ?", (proj_name,) + ).fetchone() + if row: + data["sessions"].append(dict(row)) + + for ekey in checked_entries: + row = db.execute( + "SELECT project, key, value, updated_at FROM contexts " + "WHERE project = ? AND key = ?", + (proj_name, ekey), + ).fetchone() + if row: + data["contexts"].append(dict(row)) + + if include_summaries: + rows = db.execute( + "SELECT project, summary, created_at FROM summaries " + "WHERE project = ?", + (proj_name,), + ).fetchall() + data["summaries"].extend(dict(r) for r in rows) + + elif dtype == "shared": + child = self.store.iter_children(root) + while child: + if self.store.get_value(child, 0): + skey = self.store.get_value(child, 4) + row = db.execute( + "SELECT * FROM shared WHERE key = ?", (skey,) + ).fetchone() + if row: + data["shared"].append(dict(row)) + child = self.store.iter_next(child) + + root = self.store.iter_next(root) + db.close() + + data = {k: v for k, v in data.items() if v} + if not data: + return None + data["_export_version"] = 1 + return data + + +class _CtxImportDialog(Gtk.Dialog): + """Dialog for importing ctx data from a JSON file.""" + + def __init__(self, parent): + super().__init__( + title="Import Context", + transient_for=parent, + modal=True, + destroy_with_parent=True, + ) + self.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + "Import", Gtk.ResponseType.OK, + ) + self.set_default_size(500, 450) + self.set_default_response(Gtk.ResponseType.OK) + self.set_response_sensitive(Gtk.ResponseType.OK, False) + + box = self.get_content_area() + box.set_border_width(12) + box.set_spacing(8) + + # File chooser + file_box = Gtk.Box(spacing=8) + file_box.pack_start(Gtk.Label(label="File:"), False, False, 0) + self.file_entry = Gtk.Entry(hexpand=True) + self.file_entry.set_placeholder_text("Select JSON file\u2026") + self.file_entry.set_editable(False) + file_box.pack_start(self.file_entry, True, True, 0) + btn_browse = Gtk.Button(label="Browse\u2026") + btn_browse.connect("clicked", self._on_browse) + file_box.pack_start(btn_browse, False, False, 0) + box.pack_start(file_box, False, False, 0) + + # Select all / Deselect all + sel_box = Gtk.Box(spacing=8) + btn_all = Gtk.Button(label="Select All") + btn_all.connect("clicked", lambda _: self._set_all(True)) + btn_none = Gtk.Button(label="Deselect All") + btn_none.connect("clicked", lambda _: self._set_all(False)) + sel_box.pack_start(btn_all, False, False, 0) + sel_box.pack_start(btn_none, False, False, 0) + box.pack_start(sel_box, False, False, 0) + + # Preview tree: toggle, icon, name, data_type, data_key + self.store = Gtk.TreeStore(bool, str, str, str, str) + self.tree = Gtk.TreeView(model=self.store) + self.tree.set_headers_visible(False) + + col = Gtk.TreeViewColumn() + cell_toggle = Gtk.CellRendererToggle() + cell_toggle.connect("toggled", self._on_toggled) + col.pack_start(cell_toggle, False) + col.add_attribute(cell_toggle, "active", 0) + + cell_icon = Gtk.CellRendererText() + col.pack_start(cell_icon, False) + col.add_attribute(cell_icon, "text", 1) + + cell_name = Gtk.CellRendererText() + cell_name.set_property("ellipsize", Pango.EllipsizeMode.END) + col.pack_start(cell_name, True) + col.add_attribute(cell_name, "text", 2) + + self.tree.append_column(col) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.add(self.tree) + box.pack_start(scroll, True, True, 0) + + # Overwrite option + self.chk_overwrite = Gtk.CheckButton(label="Overwrite existing entries") + box.pack_start(self.chk_overwrite, False, False, 0) + + self.import_data = None + self.show_all() + + def _on_browse(self, button): + dlg = Gtk.FileChooserDialog( + title="Select context file", + parent=self, + action=Gtk.FileChooserAction.OPEN, + ) + dlg.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OPEN, Gtk.ResponseType.OK, + ) + filt = Gtk.FileFilter() + filt.set_name("JSON files") + filt.add_pattern("*.json") + dlg.add_filter(filt) + filt_all = Gtk.FileFilter() + filt_all.set_name("All files") + filt_all.add_pattern("*") + dlg.add_filter(filt_all) + if dlg.run() == Gtk.ResponseType.OK: + path = dlg.get_filename() + self.file_entry.set_text(path) + self._load_preview(path) + dlg.destroy() + + def _load_preview(self, path): + self.store.clear() + self.import_data = None + self.set_response_sensitive(Gtk.ResponseType.OK, False) + try: + with open(path, "r") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + dlg = Gtk.MessageDialog( + transient_for=self, + modal=True, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text=f"Failed to load file: {e}", + ) + dlg.run() + dlg.destroy() + return + + self.import_data = data + + # Group by project + sessions = {s["name"]: s for s in data.get("sessions", [])} + contexts_by_proj = {} + for ctx in data.get("contexts", []): + contexts_by_proj.setdefault(ctx["project"], []).append(ctx) + summaries_by_proj = {} + for s in data.get("summaries", []): + summaries_by_proj.setdefault(s["project"], []).append(s) + + all_projects = sorted( + set(sessions) | set(contexts_by_proj) | set(summaries_by_proj) + ) + for proj_name in all_projects: + proj_iter = self.store.append(None, [ + True, "\U0001f4c1", proj_name, "project", proj_name, + ]) + for ctx in contexts_by_proj.get(proj_name, []): + self.store.append(proj_iter, [ + True, " ", ctx["key"], "entry", ctx["key"], + ]) + scount = len(summaries_by_proj.get(proj_name, [])) + if scount: + self.store.append(proj_iter, [ + True, "\U0001f4cb", f"Summaries ({scount})", "summaries", proj_name, + ]) + + shared = data.get("shared", []) + if shared: + shared_iter = self.store.append(None, [ + True, "\U0001f517", "Shared", "shared", "", + ]) + for entry in shared: + self.store.append(shared_iter, [ + True, " ", entry["key"], "shared_entry", entry["key"], + ]) + + self.tree.expand_all() + self.set_response_sensitive(Gtk.ResponseType.OK, True) + + def _on_toggled(self, renderer, path): + it = self.store.get_iter(path) + new_val = not self.store.get_value(it, 0) + self.store.set_value(it, 0, new_val) + child = self.store.iter_children(it) + while child: + self.store.set_value(child, 0, new_val) + child = self.store.iter_next(child) + parent = self.store.iter_parent(it) + if parent: + any_checked = False + child = self.store.iter_children(parent) + while child: + if self.store.get_value(child, 0): + any_checked = True + break + child = self.store.iter_next(child) + self.store.set_value(parent, 0, any_checked) + + def _set_all(self, val): + def _walk(it): + while it: + self.store.set_value(it, 0, val) + child = self.store.iter_children(it) + if child: + _walk(child) + it = self.store.iter_next(it) + root = self.store.get_iter_first() + if root: + _walk(root) + + def get_selected_data(self): + """Return (filtered_data_dict, overwrite_bool) or (None, False).""" + if not self.import_data: + return None, False + + data = self.import_data + overwrite = self.chk_overwrite.get_active() + sessions_map = {s["name"]: s for s in data.get("sessions", [])} + contexts_by_proj = {} + for ctx in data.get("contexts", []): + contexts_by_proj.setdefault(ctx["project"], []).append(ctx) + summaries_by_proj = {} + for s in data.get("summaries", []): + summaries_by_proj.setdefault(s["project"], []).append(s) + shared_map = {s["key"]: s for s in data.get("shared", [])} + + result = {"sessions": [], "contexts": [], "shared": [], "summaries": []} + + root = self.store.get_iter_first() + while root: + dtype = self.store.get_value(root, 3) + dkey = self.store.get_value(root, 4) + + if dtype == "project": + proj_name = dkey + child = self.store.iter_children(root) + checked_entries = [] + include_summaries = False + while child: + if self.store.get_value(child, 0): + ctype = self.store.get_value(child, 3) + ckey = self.store.get_value(child, 4) + if ctype == "entry": + checked_entries.append(ckey) + elif ctype == "summaries": + include_summaries = True + child = self.store.iter_next(child) + + if checked_entries or include_summaries: + if proj_name in sessions_map: + result["sessions"].append(sessions_map[proj_name]) + for ekey in checked_entries: + for ctx in contexts_by_proj.get(proj_name, []): + if ctx["key"] == ekey: + result["contexts"].append(ctx) + break + if include_summaries: + result["summaries"].extend( + summaries_by_proj.get(proj_name, []) + ) + + elif dtype == "shared": + child = self.store.iter_children(root) + while child: + if self.store.get_value(child, 0): + skey = self.store.get_value(child, 4) + if skey in shared_map: + result["shared"].append(shared_map[skey]) + child = self.store.iter_next(child) + + root = self.store.iter_next(root) + + result = {k: v for k, v in result.items() if v} + return (result if result else None), overwrite + + # ─── CtxManagerPanel ────────────────────────────────────────────────────────── @@ -2398,10 +2874,24 @@ class CtxManagerPanel(Gtk.Box): btn_refresh.set_tooltip_text("Refresh") btn_refresh.connect("clicked", lambda _: self.refresh()) + btn_more = Gtk.MenuButton(label="\u22ee") + btn_more.get_style_context().add_class("sidebar-btn") + btn_more.set_tooltip_text("More actions") + more_menu = Gtk.Menu() + item_export = Gtk.MenuItem(label="Export\u2026") + item_export.connect("activate", lambda _: self._on_export()) + more_menu.append(item_export) + item_import = Gtk.MenuItem(label="Import\u2026") + item_import.connect("activate", lambda _: self._on_import()) + more_menu.append(item_import) + more_menu.show_all() + btn_more.set_popup(more_menu) + btn_box.pack_start(btn_add, True, True, 0) btn_box.pack_start(btn_edit, True, True, 0) btn_box.pack_start(btn_del, True, True, 0) btn_box.pack_start(btn_refresh, False, False, 0) + btn_box.pack_start(btn_more, False, False, 0) self.pack_start(btn_box, False, False, 0) # Signals @@ -2793,6 +3283,112 @@ class CtxManagerPanel(Gtk.Box): self.refresh() dlg.destroy() + def _on_export(self): + dlg = _CtxExportDialog(self.app) + if dlg.run() == Gtk.ResponseType.OK: + data = dlg.get_export_data() + if data: + save_dlg = Gtk.FileChooserDialog( + title="Save export file", + parent=self.app, + action=Gtk.FileChooserAction.SAVE, + ) + save_dlg.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_SAVE, Gtk.ResponseType.OK, + ) + save_dlg.set_do_overwrite_confirmation(True) + save_dlg.set_current_name("ctx_export.json") + filt = Gtk.FileFilter() + filt.set_name("JSON files") + filt.add_pattern("*.json") + save_dlg.add_filter(filt) + if save_dlg.run() == Gtk.ResponseType.OK: + path = save_dlg.get_filename() + try: + with open(path, "w") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + except OSError as e: + err = Gtk.MessageDialog( + transient_for=self.app, + modal=True, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text=f"Failed to save: {e}", + ) + err.run() + err.destroy() + save_dlg.destroy() + dlg.destroy() + + def _on_import(self): + dlg = _CtxImportDialog(self.app) + if dlg.run() == Gtk.ResponseType.OK: + data, overwrite = dlg.get_selected_data() + if data: + self._do_import(data, overwrite) + self.refresh() + dlg.destroy() + + def _do_import(self, data, overwrite): + import sqlite3 + # Ensure database and tables exist + subprocess.run(["ctx", "list"], capture_output=True, text=True) + if not os.path.exists(CTX_DB): + return + + db = sqlite3.connect(CTX_DB) + mode = "REPLACE" if overwrite else "IGNORE" + + for session in data.get("sessions", []): + db.execute( + f"INSERT OR {mode} INTO sessions (name, description, work_dir, created_at) " + "VALUES (?, ?, ?, ?)", + ( + session["name"], + session.get("description", ""), + session.get("work_dir", ""), + session.get("created_at", ""), + ), + ) + + for ctx in data.get("contexts", []): + db.execute( + f"INSERT OR {mode} INTO contexts (project, key, value, updated_at) " + "VALUES (?, ?, ?, ?)", + ( + ctx["project"], + ctx["key"], + ctx["value"], + ctx.get("updated_at", ""), + ), + ) + + for shared in data.get("shared", []): + db.execute( + f"INSERT OR {mode} INTO shared (key, value, updated_at) " + "VALUES (?, ?, ?)", + ( + shared["key"], + shared["value"], + shared.get("updated_at", ""), + ), + ) + + for summary in data.get("summaries", []): + db.execute( + "INSERT INTO summaries (project, summary, created_at) " + "VALUES (?, ?, ?)", + ( + summary["project"], + summary["summary"], + summary.get("created_at", ""), + ), + ) + + db.commit() + db.close() + # ─── BTerminalApp ───────────────────────────────────────────────────────────── From 58159438615b166cdfcaa1e3fb2dfa0e7a8037d4 Mon Sep 17 00:00:00 2001 From: DexterFromLab Date: Sat, 7 Mar 2026 09:10:10 +0100 Subject: [PATCH 06/10] Update README with full feature coverage Add Ctx Manager panel, setup wizard, import/export, session colors, ctx CLI flags (--shared, append, export), and FTS/WAL details. Co-Authored-By: Claude Opus 4.6 --- README.md | 53 +++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 01ad604..591075f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # BTerminal -Terminal with session panel (MobaXterm-style), built with GTK 3 + VTE. Catppuccin Mocha theme. +GTK 3 terminal with SSH & Claude Code session management, macros, and a cross-session context database. Catppuccin Mocha theme. > **v2 complete, v3 all phases complete.** v2: Multi-session Claude agent dashboard using Tauri 2.x + Svelte 5. v3: Multi-project mission control dashboard (All Phases 1-10 complete + sidebar redesign) -- project groups with per-project Claude sessions, session continuity (persist/restore agent messages), team agents panel, terminal tabs, **VSCode-style left sidebar** (vertical icon rail + expandable drawer panel + always-visible workspace), command palette with group switching. Features: **project groups** (up to 5 projects per group, horizontal layout, adaptive viewport count), **per-project Claude sessions** with session continuity, **team agents panel** (compact subagent cards), **terminal tabs** (shell/SSH/agent per project), agent panes with structured output, tree visualization with subtree cost and session resume, **subagent/agent-teams support**, **multi-machine support** (bterminal-relay WebSocket server + RemoteManager), **Claude profile/account switching** (switcher-claude integration), **skill discovery and autocomplete** (type `/` in agent prompt), SSH session management, ctx context database viewer, SQLite session persistence with layout restore, live markdown file viewer with Shiki syntax highlighting, 17 themes in 3 groups (4 Catppuccin + 7 Editor + 6 Deep Dark: Tokyo Night, Gruvbox Dark, Ayu Dark, Poimandres, Vesper, Midnight), **global font controls** (separate UI font [sans-serif] + terminal font [monospace] with live preview), .deb + AppImage packaging, GitHub Actions CI, 138 vitest + 36 cargo tests. Branch `v2-mission-control`. See [docs/v3-task_plan.md](docs/v3-task_plan.md) for v3 architecture. @@ -8,14 +8,21 @@ Terminal with session panel (MobaXterm-style), built with GTK 3 + VTE. Catppucci ## Features -- **SSH sessions** — saved configs (host, port, user, key, folder, color), CRUD with side panel -- **Claude Code sessions** — saved Claude Code configs with sudo, resume, skip-permissions and initial prompt -- **SSH macros** — multi-step macros (text, key, delay) assigned to sessions, run from sidebar -- **Tabs** — multiple terminals in tabs, Ctrl+T new, Ctrl+Shift+W close, Ctrl+PageUp/Down switch -- **Sudo askpass** — Claude Code with sudo: password entered once, temporary askpass helper, auto-cleanup -- **Folder grouping** — SSH and Claude Code sessions can be grouped in folders on the sidebar -- **ctx — Context manager** — SQLite-based cross-session context database for Claude Code projects -- **Catppuccin Mocha** — full theme: terminal, sidebar, tabs, session colors +- **SSH sessions** — saved configs (host, port, user, key, folder, color), one-click connect from sidebar +- **Claude Code sessions** — saved configs with sudo askpass, resume, skip-permissions and initial prompt +- **SSH macros** — multi-step automation (text, key press, delay) bound to sessions, runnable from sidebar +- **Tabs** — multiple terminals in tabs with reordering, auto-close and shell respawn +- **Folder grouping** — organize both SSH and Claude Code sessions in collapsible sidebar folders +- **Session colors** — 10 Catppuccin accent colors for quick visual identification +- **Sudo askpass** — temporary helper for Claude Code sudo mode: password entered once, auto-cleanup on exit +- **Catppuccin Mocha** — full theme across terminal, sidebar, tabs, dialogs and scrollbars + +### Context Manager + +- **ctx CLI** — SQLite-based tool for persistent context across Claude Code sessions +- **Ctx Manager panel** — sidebar tab for browsing, editing and managing all project contexts +- **Ctx Setup Wizard** — step-by-step project setup with auto-detection from README and CLAUDE.md generation +- **Import / Export** — selective import and export of projects, entries, summaries and shared context via JSON with checkbox tree UI ## Installation @@ -30,7 +37,7 @@ The installer will: 2. Copy files to `~/.local/share/bterminal/` 3. Create symlinks: `bterminal` and `ctx` in `~/.local/bin/` 4. Initialize context database at `~/.claude-context/context.db` -5. Add desktop entry to application menu +5. Add desktop entry and icon to application menu ### v2 Installation (Tauri — build from source) @@ -60,23 +67,37 @@ bterminal ## Context Manager (ctx) -`ctx` is a SQLite-based tool for managing persistent context across Claude Code sessions. +`ctx` is a SQLite-based tool for managing persistent context across Claude Code sessions. It uses FTS5 full-text search and WAL journal mode. ```bash ctx init myproject "Project description" /path/to/project -ctx get myproject # Load full context (shared + project) +ctx get myproject # Load project context +ctx get myproject --shared # Include shared context ctx set myproject key "value" # Save a context entry -ctx shared set preferences "value" # Save shared context (available in all projects) +ctx append myproject key "more" # Append to existing entry +ctx shared set preferences "value" # Save shared context (all projects) ctx summary myproject "What was done" # Save session summary ctx search "query" # Full-text search across everything ctx list # List all projects ctx history myproject # Show session history +ctx export # Export all data as JSON +ctx delete myproject [key] # Delete project or entry ctx --help # All commands ``` +### Ctx Manager Panel + +The sidebar **Ctx** tab provides a GUI for the context database: + +- Browse all projects and their entries in a tree view +- View entry values and project details in the detail pane +- Add, edit and delete projects and entries +- **Export** — select specific projects, entries, summaries and shared context to save as JSON +- **Import** — load a JSON file, preview contents with checkboxes, optionally overwrite existing entries + ### Integration with Claude Code -Add a `CLAUDE.md` to your project root: +Add a `CLAUDE.md` to your project root (the Ctx Setup Wizard can generate this automatically): ```markdown On session start, load context: @@ -94,8 +115,8 @@ Config files in `~/.config/bterminal/`: | File | Description | |------|-------------| -| `sessions.json` | Saved SSH sessions + macros | -| `claude_sessions.json` | Saved Claude Code configs | +| `sessions.json` | SSH sessions and macros | +| `claude_sessions.json` | Claude Code session configs | Context database: `~/.claude-context/context.db` From 09463810c44170036f24089e55af72fa7b147d76 Mon Sep 17 00:00:00 2001 From: DexterFromLab Date: Mon, 9 Mar 2026 15:26:33 +0100 Subject: [PATCH 07/10] Fix terminal scroll behavior and Claude Code tab titles Disable auto-scroll on output so users can read scrollback without being jumped to bottom. Keep Claude Code tab names from config instead of overwriting with generic VTE title. Co-Authored-By: Claude Opus 4.6 --- bterminal.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bterminal.py b/bterminal.py index 6f4caba..91110b4 100755 --- a/bterminal.py +++ b/bterminal.py @@ -1504,7 +1504,7 @@ class TerminalTab(Gtk.Box): self.terminal = Vte.Terminal() self.terminal.set_font(Pango.FontDescription(FONT)) self.terminal.set_scrollback_lines(SCROLLBACK_LINES) - self.terminal.set_scroll_on_output(True) + self.terminal.set_scroll_on_output(False) self.terminal.set_scroll_on_keystroke(True) self.terminal.set_audible_bell(False) @@ -1733,6 +1733,9 @@ class TerminalTab(Gtk.Box): if self.session: # SSH tab: keep session name, show VTE title in window title only self.app.update_tab_title(self, self.session.get("name", "SSH")) + elif self.claude_config: + # Claude Code tab: keep config name instead of generic VTE title + self.app.update_tab_title(self, self.claude_config.get("name", "Claude Code")) else: self.app.update_tab_title(self, title) From 92d8ee2c0393acb54674dab72814c6b2c80a0e05 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Thu, 12 Mar 2026 18:23:40 +0100 Subject: [PATCH 08/10] chore: add agent-orchestrator submodule for migration analysis --- .gitmodules | 3 +++ agent-orchestrator | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 agent-orchestrator diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..ba2822f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "agent-orchestrator"] + path = agent-orchestrator + url = git@github.com:DexterFromLab/agent-orchestrator.git diff --git a/agent-orchestrator b/agent-orchestrator new file mode 160000 index 0000000..4fee567 --- /dev/null +++ b/agent-orchestrator @@ -0,0 +1 @@ +Subproject commit 4fee567dd9b59b86108079331ee472503d056626 From 719496853e4709a30c34506002dc6dd1e91ad1e9 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Thu, 12 Mar 2026 18:23:40 +0100 Subject: [PATCH 09/10] docs: add migration plan TODOs and update active tasks --- TODO.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/TODO.md b/TODO.md index 455cee3..844014d 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,10 @@ ## Active +### Migration to agent-orchestrator +- [ ] **Migrate to agent-orchestrator repo** -- Tribunal plan (78% confidence): cherry-pick 7 BTerminal-unique commits onto hib_changes, rebase onto dexter_changes (55ba8d0). Pre-flight: topology verification, content dedup, CLAUDE.md Commit Zero. Decide: do v1 bterminal.py features belong in agent-orchestrator? Full report: .tribunal/tribunal-report.md +- [ ] **Integrate dexter_changes features** -- 13 commits: Aider provider, splash screen, provider/model unification, Tier 2 btmsg access, auto-wake on btmsg. After cherry-pick + rebase onto hib_changes. + ### v3.1 Remaining - [ ] **Multi-machine real-world testing** -- TLS added to relay. Needs real 2-machine test. Multi-machine UI not surfaced in v3, code exists in bridges/stores only. - [ ] **Certificate pinning** -- TLS encryption done (v3.0). Pin cert hash in RemoteManager for v3.1. From 5d0d5c5f074c1252473b4fc49c1cb67f9070650e Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 13 Mar 2026 02:40:42 +0100 Subject: [PATCH 10/10] chore: rebase hib_changes onto dexter_changes and update migration TODOs --- TODO.md | 5 +++-- agent-orchestrator | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 844014d..ccdcb9d 100644 --- a/TODO.md +++ b/TODO.md @@ -3,8 +3,9 @@ ## Active ### Migration to agent-orchestrator -- [ ] **Migrate to agent-orchestrator repo** -- Tribunal plan (78% confidence): cherry-pick 7 BTerminal-unique commits onto hib_changes, rebase onto dexter_changes (55ba8d0). Pre-flight: topology verification, content dedup, CLAUDE.md Commit Zero. Decide: do v1 bterminal.py features belong in agent-orchestrator? Full report: .tribunal/tribunal-report.md -- [ ] **Integrate dexter_changes features** -- 13 commits: Aider provider, splash screen, provider/model unification, Tier 2 btmsg access, auto-wake on btmsg. After cherry-pick + rebase onto hib_changes. +- [ ] **Review Dexter's 13 feature commits** -- hib_changes rebased onto dexter_changes (55ba8d0). Need to review: Aider provider, splash screen, provider/model unification, Tier 2 btmsg access, auto-wake on btmsg. Then push hib_changes and start working from agent-orchestrator repo. +- [ ] **CLAUDE.md Commit Zero** -- Update agent-orchestrator's CLAUDE.md to reflect rebrand + new features. Update docs/ accordingly. +- [ ] **Switch primary development to agent-orchestrator** -- After review + CLAUDE.md update, develop on hib_changes in agent-orchestrator. BTerminal repo stays for v1 production only. ### v3.1 Remaining - [ ] **Multi-machine real-world testing** -- TLS added to relay. Needs real 2-machine test. Multi-machine UI not surfaced in v3, code exists in bridges/stores only. diff --git a/agent-orchestrator b/agent-orchestrator index 4fee567..55ba8d0 160000 --- a/agent-orchestrator +++ b/agent-orchestrator @@ -1 +1 @@ -Subproject commit 4fee567dd9b59b86108079331ee472503d056626 +Subproject commit 55ba8d0969b4c9e34e47fe621ea4812528441365