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/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` diff --git a/TODO.md b/TODO.md index c5f0c45..ccdcb9d 100644 --- a/TODO.md +++ b/TODO.md @@ -2,12 +2,18 @@ ## Active +### Migration to agent-orchestrator +- [ ] **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. - [ ] **Certificate pinning** -- TLS encryption done (v3.0). Pin cert hash in RemoteManager for v3.1. - [ ] **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 +26,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 diff --git a/agent-orchestrator b/agent-orchestrator new file mode 160000 index 0000000..55ba8d0 --- /dev/null +++ b/agent-orchestrator @@ -0,0 +1 @@ +Subproject commit 55ba8d0969b4c9e34e47fe621ea4812528441365 diff --git a/bterminal.py b/bterminal.py index 3888a3d..91110b4 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']}; +}} """ @@ -680,9 +710,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 +757,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 +792,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 +821,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 +848,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.""" @@ -909,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.""" @@ -1072,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) @@ -1301,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) @@ -1329,8 +1764,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") @@ -1658,8 +2091,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 +2261,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,10 +2281,1118 @@ 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() +# ─── 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 ────────────────────────────────────────────────────────── + + +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_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 + 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() + + 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 ───────────────────────────────────────────────────────────── @@ -1883,9 +3425,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() @@ -1896,6 +3460,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) 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")