diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index ba2822f..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "agent-orchestrator"] - path = agent-orchestrator - url = git@github.com:DexterFromLab/agent-orchestrator.git diff --git a/README.md b/README.md index 591075f..01ad604 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # BTerminal -GTK 3 terminal with SSH & Claude Code session management, macros, and a cross-session context database. Catppuccin Mocha theme. +Terminal with session panel (MobaXterm-style), built with GTK 3 + VTE. 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,21 +8,14 @@ GTK 3 terminal with SSH & Claude Code session management, macros, and a cross-se ## Features -- **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 +- **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 ## Installation @@ -37,7 +30,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 and icon to application menu +5. Add desktop entry to application menu ### v2 Installation (Tauri — build from source) @@ -67,37 +60,23 @@ bterminal ## Context Manager (ctx) -`ctx` is a SQLite-based tool for managing persistent context across Claude Code sessions. It uses FTS5 full-text search and WAL journal mode. +`ctx` is a SQLite-based tool for managing persistent context across Claude Code sessions. ```bash ctx init myproject "Project description" /path/to/project -ctx get myproject # Load project context -ctx get myproject --shared # Include shared context +ctx get myproject # Load full context (shared + project) ctx set myproject key "value" # Save a context entry -ctx append myproject key "more" # Append to existing entry -ctx shared set preferences "value" # Save shared context (all projects) +ctx shared set preferences "value" # Save shared context (available in 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 (the Ctx Setup Wizard can generate this automatically): +Add a `CLAUDE.md` to your project root: ```markdown On session start, load context: @@ -115,8 +94,8 @@ Config files in `~/.config/bterminal/`: | File | Description | |------|-------------| -| `sessions.json` | SSH sessions and macros | -| `claude_sessions.json` | Claude Code session configs | +| `sessions.json` | Saved SSH sessions + macros | +| `claude_sessions.json` | Saved Claude Code configs | Context database: `~/.claude-context/context.db` diff --git a/TODO.md b/TODO.md index ccdcb9d..c5f0c45 100644 --- a/TODO.md +++ b/TODO.md @@ -2,18 +2,12 @@ ## 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 @@ -26,3 +20,7 @@ - [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 deleted file mode 160000 index 55ba8d0..0000000 --- a/agent-orchestrator +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 55ba8d0969b4c9e34e47fe621ea4812528441365 diff --git a/bterminal.py b/bterminal.py index 91110b4..3888a3d 100755 --- a/bterminal.py +++ b/bterminal.py @@ -161,36 +161,6 @@ 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']}; -}} """ @@ -710,8 +680,9 @@ class ClaudeCodeDialog(Gtk.Dialog): dir_box.pack_start(btn_browse, False, False, 0) grid.attach(dir_box, 1, 3, 1, 1) - self.lbl_ctx_status = Gtk.Label(xalign=0) - grid.attach(self.lbl_ctx_status, 1, 4, 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) # Separator box.pack_start(Gtk.Separator(), False, False, 2) @@ -757,7 +728,6 @@ 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() @@ -792,6 +762,16 @@ 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", @@ -821,25 +801,56 @@ class ClaudeCodeDialog(Gtk.Dialog): f"Przed zakończeniem sesji: ctx summary {basename} \"\"" ) buf.set_text(prompt) - self._update_ctx_status() dlg.destroy() - def _update_ctx_status(self): + @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.""" project_dir = self.entry_project_dir.get_text().strip() if not project_dir: - self.lbl_ctx_status.set_text("") return - 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' + 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, ) - else: - self.lbl_ctx_status.set_markup( - "\u2139 New project \u2014 ctx wizard will guide you after save" + 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" ) + try: + with open(claude_md, "w") as f: + f.write(claude_content) + except IOError: + pass # ─── CtxEditDialog ──────────────────────────────────────────────────────────── @@ -848,377 +859,6 @@ 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.""" @@ -1269,78 +909,6 @@ 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.""" @@ -1504,7 +1072,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(False) + self.terminal.set_scroll_on_output(True) self.terminal.set_scroll_on_keystroke(True) self.terminal.set_audible_bell(False) @@ -1733,9 +1301,6 @@ 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) @@ -1764,6 +1329,8 @@ 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") @@ -2091,9 +1658,8 @@ class SessionSidebar(Gtk.Box): if resp != Gtk.ResponseType.OK: break if dlg.validate(): - data = dlg.get_data() - data = _run_ctx_wizard_if_needed(dlg, data) - self.app.claude_manager.add(data) + dlg.setup_ctx() + self.app.claude_manager.add(dlg.get_data()) self.refresh() break dlg.destroy() @@ -2261,8 +1827,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 @@ -2281,1118 +1847,10 @@ 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 ───────────────────────────────────────────────────────────── @@ -3425,31 +1883,9 @@ class BTerminalApp(Gtk.Window): paned = Gtk.HPaned() self.add(paned) - # 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 - ) - + # Sidebar self.sidebar = SessionSidebar(self) - 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) + paned.pack1(self.sidebar, resize=False, shrink=False) # Notebook (tabs) self.notebook = Gtk.Notebook() @@ -3460,16 +1896,6 @@ 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 a089e76..315964b 100755 --- a/ctx +++ b/ctx @@ -103,18 +103,19 @@ def cmd_init(args): def cmd_get(args): - """Get full context for a project (project-specific + recent summaries). - Use --shared flag to also include shared context.""" + """Get full context for a project (shared + project-specific + recent summaries).""" if len(args) < 1: - print("Usage: ctx get [--shared]") + print("Usage: ctx get ") 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,) @@ -136,13 +137,11 @@ def cmd_get(args): print(f"PROJECT: {project} (not registered, use: ctx init)") print("=" * 60) - 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 shared: + print("\n--- Shared Context ---") + for row in shared: + print(f"\n[{row['key']}]") + print(row["value"]) if contexts: print(f"\n--- {project} Context ---") @@ -156,8 +155,8 @@ def cmd_get(args): print(f"\n[{row['created_at']}]") print(row["summary"]) - if not contexts and not summaries: - print("\nNo context stored yet. Use 'ctx set' to add project context.") + if not shared and not contexts and not summaries: + print("\nNo context stored yet. Use 'ctx set' or 'ctx shared set' to add.") db.close() @@ -445,7 +444,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 [--shared] Load project context (optionally with shared)") + print(" get Load full context (shared + project)") print(" set Set project context entry") print(" append Append to existing entry") print(" shared get|set|delete Manage shared context")