From 0e51356a7dbc313f661ad112b3c1bcd3f4db0b4b Mon Sep 17 00:00:00 2001 From: Vu Dam Tuan Date: Thu, 27 Aug 2026 20:55:32 +0900 Subject: [PATCH] feat(R08): split ScheduleTaskTab, FolderTab, DashboardTab, StructureGraphView Team Hoa, EPIC R08 (UI/Application Separation) - Team Hoa scope only (R08-T11 -> T14; R08-T01->T10 belong to Team Duy/Team Nam). - R08-T11: ui/schedule_task_tab.py (795 lines) -> presentation/scheduling/ {kanban_board_widget,calendar_view_widget,ai_task_creator_dialog, ai_task_import_dialog,run_history_dialog}.py + schedule_task_tab.py shell. Kanban CRUD/drag-drop now goes through application/scheduling/task_application_service.py (R07-T04) instead of ~30 lines of inline if/elif per drag target. - R08-T12: ui/folder_tab.py (1587 lines, the largest of the four) -> presentation/folder/{workspace_file_tree,document_preview_manager, code_editor,office_document_renderer,ai_file_editor_dialog, ai_edit_model_resolver,ai_edit_pipeline}.py + folder_tab.py shell. Closes the R06-T05 loop: FileWorkspaceService existed since R06 with zero production call sites (confirmed by grep); every plain-text write (save/create/write_content) now goes through it, gaining path containment and a Python-syntax warning the original code never had. Pure helpers (_read_text, _is_probably_text, _pptx_available, _split_code_block, _parse_ai_output) moved to application/workspaces/{file_preview_helpers,ai_edit_output}.py. - R08-T13: ui/dashboard_tab.py (437 lines) -> presentation/dashboard/ {token_usage_card_widget,usage_chart_widget,habits_widget}.py + dashboard_tab.py shell, backed by a new application/monitoring/dashboard_query_service.py (pricing/period/ summary queries the three widgets used to each recompute separately). Directory-ownership note left in the checklist for Team Nam. - R08-T14: ui/structure_graph_view.py (1035 lines) -> presentation/graph/{graph_scene_items,graph_renderer, graph_messages_view,graph_qa_widget}.py + structure_graph_view.py shell. Extraction helpers (_pdf_to_markdown, _extract_file_contents) moved to application/workspaces/graph_index_service.py (pure Python). Renderer and Q&A panel talk only through signals (node_selected/graph_rendered/raw_json_ready/project_changed) - neither imports the other. - presentation/shared/web_engine_support.py: HAS_WEB_ENGINE, previously duplicated (folder_tab imported it FROM structure_graph_view.py) - now one shared flag instead of one screen importing another screen's module. All four old ui/*.py files deleted; app.py and ui/workspace_tab.py updated to the new import paths (each god-file only had 1-2 real construction sites, so import sites were updated directly rather than kept as a strangler-fig shim - unlike core/tools.py at R05, which had dozens). pytest: 377 pass (+94 vs the R07 baseline of 328; same 4 pre-existing failures as the R05/R06 baseline, unrelated to this work). scripts/check_imports.py: PASS. python -c "import cowork_local.app": OK. Every new file < 400 lines (largest: graph_renderer.py, 391). Co-Authored-By: Claude Sonnet 5 --- app.py | 6 +- application/monitoring/__init__.py | 18 + .../monitoring/dashboard_query_service.py | 110 ++ application/workspaces/__init__.py | 16 +- application/workspaces/ai_edit_output.py | 40 + .../workspaces/file_preview_helpers.py | 54 + application/workspaces/graph_index_service.py | 91 + docs/refactor/Refactoring_Checklist.md | 44 +- presentation/__init__.py | 7 + presentation/dashboard/__init__.py | 3 + presentation/dashboard/dashboard_tab.py | 91 + presentation/dashboard/habits_widget.py | 171 ++ .../dashboard/token_usage_card_widget.py | 108 ++ presentation/dashboard/usage_chart_widget.py | 179 ++ presentation/folder/__init__.py | 4 + presentation/folder/ai_edit_model_resolver.py | 248 +++ presentation/folder/ai_edit_pipeline.py | 372 ++++ presentation/folder/ai_file_editor_dialog.py | 237 +++ presentation/folder/code_editor.py | 190 ++ .../folder/document_preview_manager.py | 304 ++++ presentation/folder/folder_tab.py | 120 ++ .../folder/office_document_renderer.py | 269 +++ presentation/folder/workspace_file_tree.py | 97 + presentation/graph/__init__.py | 3 + presentation/graph/graph_messages_view.py | 99 + presentation/graph/graph_qa_widget.py | 374 ++++ presentation/graph/graph_renderer.py | 391 ++++ presentation/graph/graph_scene_items.py | 142 ++ presentation/graph/structure_graph_view.py | 80 + presentation/scheduling/__init__.py | 3 + .../scheduling/ai_task_creator_dialog.py | 196 ++ .../scheduling/ai_task_import_dialog.py | 148 ++ .../scheduling/calendar_view_widget.py | 14 +- .../scheduling/kanban_board_widget.py | 369 ++++ presentation/scheduling/run_history_dialog.py | 72 + presentation/scheduling/schedule_task_tab.py | 185 ++ presentation/shared/__init__.py | 14 + presentation/shared/web_engine_support.py | 38 + tests/integration/test_dashboard_tab.py | 142 ++ tests/integration/test_folder_tab.py | 193 ++ tests/integration/test_routing_surfaces.py | 17 +- tests/integration/test_schedule_task_tab.py | 125 ++ .../integration/test_structure_graph_view.py | 162 ++ tests/unit/test_dashboard_query_service.py | 103 ++ ...file_preview_helpers_and_ai_edit_output.py | 76 + tests/unit/test_graph_index_service.py | 44 + ui/dashboard_tab.py | 438 ----- ui/folder_tab.py | 1586 ----------------- ui/schedule_task_tab.py | 794 --------- ui/structure_graph_view.py | 1034 ----------- ui/workspace_tab.py | 2 +- 51 files changed, 5746 insertions(+), 3877 deletions(-) create mode 100644 application/monitoring/__init__.py create mode 100644 application/monitoring/dashboard_query_service.py create mode 100644 application/workspaces/ai_edit_output.py create mode 100644 application/workspaces/file_preview_helpers.py create mode 100644 application/workspaces/graph_index_service.py create mode 100644 presentation/__init__.py create mode 100644 presentation/dashboard/__init__.py create mode 100644 presentation/dashboard/dashboard_tab.py create mode 100644 presentation/dashboard/habits_widget.py create mode 100644 presentation/dashboard/token_usage_card_widget.py create mode 100644 presentation/dashboard/usage_chart_widget.py create mode 100644 presentation/folder/__init__.py create mode 100644 presentation/folder/ai_edit_model_resolver.py create mode 100644 presentation/folder/ai_edit_pipeline.py create mode 100644 presentation/folder/ai_file_editor_dialog.py create mode 100644 presentation/folder/code_editor.py create mode 100644 presentation/folder/document_preview_manager.py create mode 100644 presentation/folder/folder_tab.py create mode 100644 presentation/folder/office_document_renderer.py create mode 100644 presentation/folder/workspace_file_tree.py create mode 100644 presentation/graph/__init__.py create mode 100644 presentation/graph/graph_messages_view.py create mode 100644 presentation/graph/graph_qa_widget.py create mode 100644 presentation/graph/graph_renderer.py create mode 100644 presentation/graph/graph_scene_items.py create mode 100644 presentation/graph/structure_graph_view.py create mode 100644 presentation/scheduling/__init__.py create mode 100644 presentation/scheduling/ai_task_creator_dialog.py create mode 100644 presentation/scheduling/ai_task_import_dialog.py rename ui/calendar_view.py => presentation/scheduling/calendar_view_widget.py (96%) create mode 100644 presentation/scheduling/kanban_board_widget.py create mode 100644 presentation/scheduling/run_history_dialog.py create mode 100644 presentation/scheduling/schedule_task_tab.py create mode 100644 presentation/shared/__init__.py create mode 100644 presentation/shared/web_engine_support.py create mode 100644 tests/integration/test_dashboard_tab.py create mode 100644 tests/integration/test_folder_tab.py create mode 100644 tests/integration/test_schedule_task_tab.py create mode 100644 tests/integration/test_structure_graph_view.py create mode 100644 tests/unit/test_dashboard_query_service.py create mode 100644 tests/unit/test_file_preview_helpers_and_ai_edit_output.py create mode 100644 tests/unit/test_graph_index_service.py delete mode 100644 ui/dashboard_tab.py delete mode 100644 ui/folder_tab.py delete mode 100644 ui/schedule_task_tab.py delete mode 100644 ui/structure_graph_view.py diff --git a/app.py b/app.py index 49c974b..4f55c22 100644 --- a/app.py +++ b/app.py @@ -23,13 +23,13 @@ from .state import AppContext from .ui.widgets import tidy_popup from .theme import current_palette, set_active_theme, stylesheet from .core.task_scheduler import TaskScheduler +from .presentation.dashboard.dashboard_tab import DashboardTab +from .presentation.graph.structure_graph_view import StructureGraphView +from .presentation.scheduling.schedule_task_tab import ScheduleTaskTab from .ui.cowork_tab import CoworkTab -from .ui.dashboard_tab import DashboardTab from .ui.monitoring_tab import MonitoringTab -from .ui.schedule_task_tab import ScheduleTaskTab from .ui.settings_dialog import SettingsDialog from .ui.sidebar import HistorySidebar -from .ui.structure_graph_view import StructureGraphView from .ui.workspace_tab import WorkspaceTab ASSETS = Path(__file__).resolve().parent / "assets" diff --git a/application/monitoring/__init__.py b/application/monitoring/__init__.py new file mode 100644 index 0000000..73f7e58 --- /dev/null +++ b/application/monitoring/__init__.py @@ -0,0 +1,18 @@ +"""Read-only query services for monitoring/dashboard screens (EPIC R08). + +⚠️ Ownership note (R08-T13): per ``docs/refactor/Feature_Architecture_ +Proposal.md``'s file-split diagram, ``dashboard_query_service.py`` lives +under ``application/monitoring/`` alongside the Dashboard split — but the +SAME document's "Ranh giới phân hệ" table assigns ``application/monitoring/`` +to Team Nam (R08-T07→T10, Monitoring's own 8-tab split). This directory did +not exist yet when Team Hoa reached R08-T13, so creating it here does not +collide with any file Team Nam has written — same situation R06-T02 flagged +for ``infrastructure/persistence/json/atomic_write.py`` vs. Team Nam's +planned ``atomic_json_file.py``. Team Nam should confirm when they start +R08-T07→T10 whether ``DashboardQueryService`` belongs here permanently or +should move once Monitoring's own query service exists. +""" + +from .dashboard_query_service import DashboardQueryService + +__all__ = ["DashboardQueryService"] diff --git a/application/monitoring/dashboard_query_service.py b/application/monitoring/dashboard_query_service.py new file mode 100644 index 0000000..eff3bdf --- /dev/null +++ b/application/monitoring/dashboard_query_service.py @@ -0,0 +1,110 @@ +"""DashboardQueryService - read-only usage/cost queries for the Dashboard +screen (R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``, lines +196-199/256-261/284-322 of the original 437-line file: ``_pricing``, +``_period_range``'s date-math, and the ``period_totals``/``period_breakdown`` +calls ``_refresh_chart`` made directly). + +``ui/dashboard_tab.py`` called ``core/usage_tracker.py``/``core/model_ +pricing.py`` directly from FIVE different methods spread across what is now +three widgets (``token_usage_card_widget.py``, ``usage_chart_widget.py``, +``habits_widget.py``) — each recomputing the same merged pricing dict. This +service is the one place that merge happens now; the three widgets share it +instead of each calling ``core.usage_tracker``/``core.model_pricing`` on +their own. + +Pure Python: no Qt. Wraps ``core/usage_tracker.py`` (a plain-Python module +already) rather than reimplementing any of its date/cost math. +""" +from __future__ import annotations + +from datetime import date, timedelta +from typing import Any, Dict, List, Tuple + + +class DashboardQueryService: + """Usage/cost queries scoped to one ``AppContext``. + + Args: + ctx: ``AppContext`` — read for ``ctx.config`` (pricing table, + currency, budget) and nothing else; this class does no I/O of + its own beyond what ``core.usage_tracker`` already does. + """ + + def __init__(self, ctx: Any) -> None: + self.ctx = ctx + + def pricing(self) -> Dict[str, Any]: + """The merged price table (defaults + user overrides), synced from + Monitoring's model-pricing table first so cost figures always agree + between the two screens.""" + from cowork_local.core import model_pricing as mp + from cowork_local.core import usage_tracker as ut + + mp.sync_to_usage(self.ctx.config) + return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})} + + def period_range(self, granularity: str, offset: int) -> Tuple[date, date]: + """The SELECTED period as an inclusive ``(start, end)`` date range — + drives every widget on the screen (cards, chart, habits).""" + from cowork_local.core import usage_tracker as ut + + start, end = ut.period_bounds(granularity, offset) + return start, end - timedelta(days=1) # load_events end is inclusive + + def summary(self, start: date, end: date) -> Dict[str, Any]: + """Everything the stat cards + habits panel need for one period: + the raw events, ``usage_tracker.summarize``'s aggregate stats, the + per-bucket costs, and their total — computed once so both widgets + read the same numbers instead of loading events twice.""" + from cowork_local.core import usage_tracker as ut + + events = ut.load_events(start, end) + pricing = self.pricing() + stats = ut.summarize(events) + costs = ut.cost_usd_events(events, pricing) + return { + "events": events, + "pricing": pricing, + "stats": stats, + "costs": costs, + "total_cost": sum(costs.values()), + } + + def chart_series(self, granularity: str, offset: int, metric: str + ) -> List[Tuple[str, float]]: + """``(label, value)`` points for the spline chart — WEEK -> 7 days, + MONTH -> weeks, YEAR -> 12 months, in whichever ``metric`` + ("tokens" | "cost") was selected.""" + from cowork_local.core import usage_tracker as ut + + events = ut.load_events() # all events; breakdown slices by period + pricing = self.pricing() + parts = ut.period_breakdown(events, granularity, pricing, offset=offset) + mi = 0 if metric == "tokens" else 1 # (label, tokens, cost) -> +1 for the value + return [(row[0], float(row[mi + 1])) for row in parts] + + def period_totals(self, granularity: str, offset: int) -> Tuple[float, float]: + """``(tokens, cost)`` totals for one period — used to compute the + vs-previous-period delta the chart's reference line shows.""" + from cowork_local.core import usage_tracker as ut + + events = ut.load_events() + return ut.period_totals(events, granularity, self.pricing(), offset) + + def period_range_label(self, granularity: str, offset: int) -> str: + from cowork_local.core import usage_tracker as ut + + return ut.period_range_label(granularity, offset) + + def budget_status(self): + from cowork_local.core import usage_tracker as ut + + return ut.budget_status(self.ctx.config) + + def set_budget(self, amount: float, currency: str) -> None: + from cowork_local.core import usage_tracker as ut + + ut.set_budget(self.ctx.config, amount, currency) + + +__all__ = ["DashboardQueryService"] diff --git a/application/workspaces/__init__.py b/application/workspaces/__init__.py index dd727af..595901d 100644 --- a/application/workspaces/__init__.py +++ b/application/workspaces/__init__.py @@ -1,5 +1,17 @@ -"""Workspace file operations for non-agent-loop callers (EPIC R06).""" +"""Workspace file operations for non-agent-loop callers (EPIC R06, R08).""" +from .ai_edit_output import parse_ai_output, split_code_block +from .file_preview_helpers import is_probably_text, pptx_available, read_text from .file_workspace_service import FileWorkspaceService +from .graph_index_service import extract_file_contents, pdf_to_markdown -__all__ = ["FileWorkspaceService"] +__all__ = [ + "FileWorkspaceService", + "read_text", + "is_probably_text", + "pptx_available", + "split_code_block", + "parse_ai_output", + "pdf_to_markdown", + "extract_file_contents", +] diff --git a/application/workspaces/ai_edit_output.py b/application/workspaces/ai_edit_output.py new file mode 100644 index 0000000..0482f93 --- /dev/null +++ b/application/workspaces/ai_edit_output.py @@ -0,0 +1,40 @@ +"""Parse an AI file-edit reply into its parts (R08-T12, moved out of +``ui/folder_tab.py`` — that file's module-level ``_split_code_block``/ +``_parse_ai_output``, lines 1536-1562 of the original 1587-line file). Pure +string parsing, no Qt — used by ``presentation/folder/ai_file_editor_dialog.py`` +to turn a model's raw reply into a proposed edit. +""" +from __future__ import annotations + +import re +from typing import List, Optional, Tuple + + +def split_code_block(text: str) -> Tuple[Optional[str], str]: + """Split an AI reply into ``(file_content, summary)``. ``file_content`` + is the first fenced code block (the edited file); ``summary`` is any + prose before it. Returns ``(None, text)`` when there's no code block.""" + m = re.search(r"```[^\n]*\n(.*?)```", text or "", re.DOTALL) + if not m: + return None, (text or "") + return m.group(1), (text[:m.start()].strip()) + + +def parse_ai_output(text: str) -> Tuple[Optional[str], Optional[str], str, List[Tuple[str, str]]]: + """Parse an AI edit reply into ``(target, content, summary, image_gens)``. + ``FILE: `` names a NEW file to create; ``IMAGE_GEN: => + `` lines request generated illustration images (relative paths).""" + content, summary = split_code_block(text) + target = None + m = re.search(r"(?mi)^\s*FILE:\s*(.+?)\s*$", text or "") + if m: + target = m.group(1).strip().strip("`\"'") + image_gens = [] + for gm in re.finditer(r"(?mi)^\s*IMAGE_GEN:\s*(.+?)\s*=>\s*(\S+)\s*$", text or ""): + image_gens.append((gm.group(1).strip(), gm.group(2).strip().strip("`\"'"))) + # Strip the directive lines out of the shown summary. + summary = re.sub(r"(?mi)^\s*(FILE|IMAGE_GEN):\s*.+?$", "", summary).strip() + return target, content, summary, image_gens + + +__all__ = ["split_code_block", "parse_ai_output"] diff --git a/application/workspaces/file_preview_helpers.py b/application/workspaces/file_preview_helpers.py new file mode 100644 index 0000000..393395a --- /dev/null +++ b/application/workspaces/file_preview_helpers.py @@ -0,0 +1,54 @@ +"""Pure helpers for previewing a file (R08-T12, moved out of +``ui/folder_tab.py`` — that file's module-level functions +``_read_text``/``_is_probably_text``/``_pptx_available``, lines 1519-1533 and +1565-1587 of the original 1587-line file). No Qt, no widget state — the +"is this file text? is pptx editing available?" questions the preview +manager asks before it decides how to render something. +""" +from __future__ import annotations + +from pathlib import Path + +_PPTX_READY = None # cached: pptx-editing library available (after auto-install) + + +def pptx_available() -> bool: + """True when python-pptx is importable. If it's MISSING, auto-download & + install it (via deps.ensure_module) so pptx editing 'just works' — cached + so the (one-time) install is attempted only once.""" + global _PPTX_READY + if _PPTX_READY is None: + try: + from cowork_local.core.deps import ensure_module + + _PPTX_READY = ensure_module("pptx", "python-pptx") is not None + except Exception: # noqa: BLE001 + _PPTX_READY = False + return _PPTX_READY + + +def read_text(path: str) -> str: + try: + return Path(path).read_text(encoding="utf-8", errors="replace") + except OSError as exc: + return f"[could not read file: {exc}]" + + +def is_probably_text(path: str) -> bool: + try: + with open(path, "rb") as f: + chunk = f.read(4096) + except OSError: + return False + if b"\x00" in chunk: + return False + try: + chunk.decode("utf-8") + return True + except UnicodeDecodeError: + # Latin-ish text still edits fine via errors="replace"; only reject on + # a hard binary signal (NUL above), so most source files pass. + return True + + +__all__ = ["pptx_available", "read_text", "is_probably_text"] diff --git a/application/workspaces/graph_index_service.py b/application/workspaces/graph_index_service.py new file mode 100644 index 0000000..a63558c --- /dev/null +++ b/application/workspaces/graph_index_service.py @@ -0,0 +1,91 @@ +"""Temporary file-content extraction for Graph-RAG Q&A (R08-T14, moved out +of ``ui/structure_graph_view.py`` — that file's module-level +``_pdf_to_markdown``/``_extract_file_contents``, lines 964-1034 of the +original 1035-line file). Runs inside the ask worker's job function so the +answer is synthesized from real file content, not just the graph structure. + +Pure Python: no Qt. Best-effort throughout (never raises) — a failed +extraction degrades to "no content for this file", not a broken Q&A turn. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Dict, List, Optional, Tuple + + +def pdf_to_markdown(pdf_path: str, out_dir: str) -> Optional[str]: + """Convert a PDF to Markdown with opendataloader-pdf when available + (richer structure than a plain text dump). Best-effort — returns None + if the package isn't installed or the call fails, so the caller falls + back to ``core/doc_extract.py``.""" + try: + import opendataloader_pdf # optional; auto-installed elsewhere if present + except Exception: # noqa: BLE001 + try: + from cowork_local.core.deps import ensure_module + if ensure_module("opendataloader_pdf", "opendataloader-pdf") is None: + return None + import opendataloader_pdf # noqa: F811 + except Exception: # noqa: BLE001 + return None + out = Path(out_dir) + out.mkdir(parents=True, exist_ok=True) + for call in ( + lambda: opendataloader_pdf.convert(input_path=[str(pdf_path)], output_dir=str(out), + generate_markdown=True), + lambda: opendataloader_pdf.convert(input_path=str(pdf_path), output_dir=str(out)), + lambda: opendataloader_pdf.convert(str(pdf_path), str(out)), + ): + try: + call() + break + except TypeError: + continue + except Exception: # noqa: BLE001 + return None + mds = list(out.rglob(Path(pdf_path).stem + "*.md")) or list(out.rglob("*.md")) + for md in mds: + try: + return md.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + return None + + +def extract_file_contents(paths: List[str], cache: Dict[str, str], tmp_dir: str, + max_files: int = 15, max_total: int = 120_000 + ) -> Tuple[str, Dict[str, str]]: + """Read the ACTUAL content of ``paths`` (PDF -> markdown via + opendataloader when available, else ``doc_extract`` for office/pdf/ + text). Returns ``(block, cache)`` — ``block`` is the concatenated + content for the prompt (bounded), ``cache`` maps path -> text for + reuse. Never raises.""" + from cowork_local.core import doc_extract + + cache = dict(cache or {}) + parts, total = [], 0 + for p in paths[:max_files]: + if total >= max_total: + break + text = cache.get(p) + if text is None: + try: + if Path(p).suffix.lower() == ".pdf": + text = pdf_to_markdown(p, tmp_dir) + if not text: + text, _n = doc_extract.extract_text(p) + else: + text, _n = doc_extract.extract_text(p) + except Exception: # noqa: BLE001 + text = "" + cache[p] = text or "" + text = cache.get(p) or "" + if not text: + continue + chunk = text[: max(0, max_total - total)] + total += len(chunk) + parts.append(f'--- {Path(p).name} ({p}) ---\n{chunk}') + return ("\n\n".join(parts), cache) + + +__all__ = ["pdf_to_markdown", "extract_file_contents"] diff --git a/docs/refactor/Refactoring_Checklist.md b/docs/refactor/Refactoring_Checklist.md index 12b1de7..8cbfe7a 100644 --- a/docs/refactor/Refactoring_Checklist.md +++ b/docs/refactor/Refactoring_Checklist.md @@ -97,6 +97,34 @@ --- +## 📊 TIẾN ĐỘ THỰC TẾ — TEAM HOA, R07 + R08 (cập nhật `2026-08-27 20:52`) + +> [!NOTE] +> ### ✅ ĐÃ HOÀN TẤT: 9/9 task phạm vi Team Hoa của **R07 + R08** — cùng branch `feature/teamhoa/r05-r06` +> +> | EPIC | Task (phạm vi Team Hoa) | Trạng thái | +> | :--- | :--- | :--- | +> | **R07** Scheduling & Workflow Runtime | T01 → T05 | ✅ 5/5 (T06 Co4EWorkflowService là Team Nam) | +> | **R08** UI/Application Separation | T11 → T14 | ✅ 4/4 (T01-T10 là Team Duy/Team Nam) | +> +> **Kiểm chứng (chạy thật):** +> * `pytest tests/` ➔ **377 pass / 4 fail** (+94 test mới cho R07+R08 — 328 sau R07, 377 sau R08) +> * 4 fail là **lỗi có sẵn từ trước**, giống hệt baseline đã ghi nhận ở R05/R06 (2× `test_config_security.py` EPIC R02, 2× `test_routing_wiring.py` môi trường máy) +> * `python scripts/check_imports.py` ➔ **PASS** (0 Qt import trong `domain/`, `application/`) +> * Mọi file mới **< 400 dòng** (lớn nhất: `presentation/graph/graph_renderer.py` 391 dòng) +> * `python -c "import cowork_local.app"` ➔ OK sau mỗi task (app khởi động được với toàn bộ import mới) +> +> ### 📄 BÁO CÁO CHI TIẾT +> Xem `docs/refactor/BaoCao_TeamHoa_R07_R08.md` — kết quả từng task, 1 quyết định kiến trúc đổi so với plan gốc (đã thực nghiệm xác nhận), việc "nối dây" `FileWorkspaceService` (nợ từ R06-T05), và phạm vi **chưa** kiểm thử. +> +> ### 📌 CÒN NỢ / CẦN QUYẾT ĐỊNH +> 1. **`application/monitoring/` mới tạo ở R08-T13** (`dashboard_query_service.py`) nhưng thư mục này được quy hoạch cho Team Nam (R08-T07→T10). Chưa có xung đột file thật (thư mục trống trước đó) nhưng **cần Team Nam xác nhận** khi bắt đầu phần Monitoring của họ — xem chi tiết trong báo cáo. +> 2. **R07-T03 đổi vị trí so với plan gốc**: `platform/qt/qt_scheduler_clock.py` ➔ `infrastructure/qt/qt_scheduler_clock.py`, sau khi xác nhận bằng thực nghiệm rằng một package `platform/` ở top-level đè lên module chuẩn `platform` của Python. +> 3. **AI-Edit pipeline (`presentation/folder/ai_edit_pipeline.py`) và Q&A ask-flow (`presentation/graph/graph_qa_widget.py::_ask`) chưa có test end-to-end** — cả hai chạy trên `AgentWorker` (QThread) thật và **vốn đã không có test nào từ trước khi refactor** (xác nhận bằng grep). Phạm vi test của R08-T12/T14 tập trung vào phần có thể test không cần thread thật (wiring, containment, rendering) — xem mục "Phạm vi chưa kiểm thử" trong báo cáo. +> 4. Chưa `git push` — nhánh cục bộ vẫn chưa lên được Gitea, giống tình trạng đã ghi nhận ở báo cáo R05/R06 mục 7-#1. + +--- + ## 📌 PHẦN 1: CHECKLIST CHI TIẾT THEO 10 EPIC (R01 ➔ R10) ### 🔹 EPIC R01: Architecture Foundation & Characterization (Nền Tảng Kiến Trúc & Test Bảo Vệ) @@ -253,14 +281,14 @@ *Start: `____-__-__ __:__` | End: `____-__-__ __:__`* #### 🟢 Team Hoa (Workspace, Folder, Scheduling, Dashboard & Graph): -- [ ] **R08-T11**: Tách `ui/schedule_task_tab.py` ➔ `kanban_board_widget.py`, `calendar_view_widget.py`, `ai_task_creator_dialog.py`, `ai_task_import_dialog.py` - *Start: `____-__-__ __:__` | End: `____-__-__ __:__`* -- [ ] **R08-T12**: Tách `ui/folder_tab.py#L350` ➔ `workspace_file_tree.py`, `document_preview_manager.py`, `ai_file_editor_dialog.py` - *Start: `____-__-__ __:__` | End: `____-__-__ __:__`* -- [ ] **R08-T13**: Tách `ui/dashboard_tab.py` ➔ `token_usage_card_widget.py`, `usage_chart_widget.py`, `habits_widget.py` - *Start: `____-__-__ __:__` | End: `____-__-__ __:__`* -- [ ] **R08-T14**: Tách `ui/structure_graph_view.py` ➔ `presentation/graph/structure_graph_view.py` & `graph_qa_widget.py` - *Start: `____-__-__ __:__` | End: `____-__-__ __:__`* +- [x] **R08-T11**: Tách `ui/schedule_task_tab.py` ➔ `kanban_board_widget.py`, `calendar_view_widget.py`, `ai_task_creator_dialog.py`, `ai_task_import_dialog.py` (+ `run_history_dialog.py`, `schedule_task_tab.py` shell — xem báo cáo) + *Start: `2026-08-27 17:14` | End: `2026-08-27 17:39`* +- [x] **R08-T12**: Tách `ui/folder_tab.py#L350` ➔ `workspace_file_tree.py`, `document_preview_manager.py`, `ai_file_editor_dialog.py` (+ `code_editor.py`, `office_document_renderer.py`, `ai_edit_model_resolver.py`, `ai_edit_pipeline.py`, `folder_tab.py` shell — xem báo cáo). Đã nối `FileWorkspaceService` (nợ từ R06-T05). + *Start: `2026-08-27 17:39` | End: `2026-08-27 18:09`* +- [x] **R08-T13**: Tách `ui/dashboard_tab.py` ➔ `token_usage_card_widget.py`, `usage_chart_widget.py`, `habits_widget.py` (+ `dashboard_tab.py` shell, `application/monitoring/dashboard_query_service.py` — xem báo cáo về ghi chú xung đột thư mục với Team Nam) + *Start: `2026-08-27 18:09` | End: `2026-08-27 18:16`* +- [x] **R08-T14**: Tách `ui/structure_graph_view.py` ➔ `presentation/graph/structure_graph_view.py` (shell) & `graph_qa_widget.py` (+ `graph_renderer.py`, `graph_scene_items.py`, `graph_messages_view.py`, `application/workspaces/graph_index_service.py` — xem báo cáo) + *Start: `2026-08-27 18:16` | End: `2026-08-27 20:52`* --- diff --git a/presentation/__init__.py b/presentation/__init__.py new file mode 100644 index 0000000..b20b65e --- /dev/null +++ b/presentation/__init__.py @@ -0,0 +1,7 @@ +"""Presentation layer: Qt widgets, one screen/concern per file, assembled +into thin shell containers (EPIC R08). + +Nothing under here is imported by ``domain/`` or ``application/`` +(``scripts/check_imports.py`` rule I3) — data flows the other way, through +application services these widgets call. +""" diff --git a/presentation/dashboard/__init__.py b/presentation/dashboard/__init__.py new file mode 100644 index 0000000..80f3901 --- /dev/null +++ b/presentation/dashboard/__init__.py @@ -0,0 +1,3 @@ +"""Dashboard screen, split into single-responsibility widgets (R08-T13): +``token_usage_card_widget``, ``usage_chart_widget``, ``habits_widget``, +assembled by the ``dashboard_tab`` shell.""" diff --git a/presentation/dashboard/dashboard_tab.py b/presentation/dashboard/dashboard_tab.py new file mode 100644 index 0000000..30e868e --- /dev/null +++ b/presentation/dashboard/dashboard_tab.py @@ -0,0 +1,91 @@ +"""DashboardTab shell (R08-T13) — assembles +``token_usage_card_widget.py::TokenUsageCardWidget``, +``usage_chart_widget.py::UsageChartWidget`` and +``habits_widget.py::HabitsWidget`` behind the scroll area / header / 30s +auto-refresh timer that used to be inline in +``ui/dashboard_tab.py::DashboardTab.__init__`` (lines 40-193 of the original +437-line file). + +The one ``DashboardQueryService`` (R08-T13) instance is built here and +shared by all three children so pricing/currency stay consistent across the +whole screen. +""" +from __future__ import annotations + +from PySide6.QtCore import QTimer, Signal +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QScrollArea, QVBoxLayout, QWidget + +from cowork_local.application.monitoring import DashboardQueryService +from cowork_local.i18n import on_language_changed, tr +from cowork_local.presentation.dashboard.habits_widget import HabitsWidget +from cowork_local.presentation.dashboard.token_usage_card_widget import TokenUsageCardWidget +from cowork_local.presentation.dashboard.usage_chart_widget import UsageChartWidget +from cowork_local.state import AppContext +from cowork_local.ui.icons import icon + + +class DashboardTab(QWidget): + status_message = Signal(str) + + def __init__(self, ctx: AppContext): + super().__init__() + self.ctx = ctx + self._query = DashboardQueryService(ctx) + + outer = QVBoxLayout(self) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.NoFrame) + content = QWidget() + scroll.setWidget(content) + outer.addWidget(scroll) + root = QVBoxLayout(content) + + head = QHBoxLayout() + self._title = QLabel() + self._title.setStyleSheet("font-weight:700; font-size:15px;") + self.refresh_btn = QPushButton("") + self.refresh_btn.setIcon(icon("refresh")) + self.refresh_btn.setFixedWidth(34) + self.refresh_btn.clicked.connect(self.refresh) + head.addWidget(self._title, 1) + head.addWidget(self.refresh_btn) + root.addLayout(head) + + self.token_cards = TokenUsageCardWidget(ctx, self._query) + root.addWidget(self.token_cards) + + self.chart = UsageChartWidget(ctx, self._query) + self.chart.period_changed.connect(self.refresh) + self.chart.currency_changed.connect(self.refresh) + root.addWidget(self.chart) + + self.habits = HabitsWidget(ctx, self._query) + self.habits.status_message.connect(self.status_message.emit) + root.addWidget(self.habits, 1) + + # Auto-refresh every 30s so numbers follow ongoing work. + self._timer = QTimer(self) + self._timer.setInterval(30_000) + self._timer.timeout.connect(self.refresh) + self._timer.start() + + on_language_changed(self._retranslate) + self.refresh() + + def _retranslate(self) -> None: + self._title.setText(tr("dashboard.title")) + self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip")) + self.token_cards.retranslate() + self.chart.retranslate() + self.habits.retranslate() + self.refresh() + + def refresh(self, *_a) -> None: + start, end = self.chart.period_range() + self.token_cards.refresh(start, end) + self.chart.refresh() + self.habits.refresh(start, end) + + +__all__ = ["DashboardTab"] diff --git a/presentation/dashboard/habits_widget.py b/presentation/dashboard/habits_widget.py new file mode 100644 index 0000000..34b90ce --- /dev/null +++ b/presentation/dashboard/habits_widget.py @@ -0,0 +1,171 @@ +"""HabitsWidget — the usage-habits summary + AI recommendations panel of the +Dashboard (R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``, +lines 154-184/348-372/376-438 of the original 437-line file: the habits/AI +layout, ``refresh()``'s habits-HTML section, ``_apply_saving_strategy``, +``_ai_analyze``). +""" +from __future__ import annotations + +from datetime import date +from typing import List, Optional + +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextBrowser, QVBoxLayout, QWidget +from PySide6.QtCore import Signal + +from cowork_local.application.monitoring import DashboardQueryService +from cowork_local.core.worker import AgentWorker +from cowork_local.i18n import tr +from cowork_local.ui.icons import icon +from cowork_local.ui.widgets import fmt_tokens + + +class HabitsWidget(QWidget): + status_message = Signal(str) + + def __init__(self, ctx, query: DashboardQueryService, parent=None): + super().__init__(parent) + self.ctx = ctx + self._query = query + self._ai_worker: Optional[AgentWorker] = None + self._period_range = (None, None) # set on each refresh(); _ai_analyze reuses it + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + self._habits_title = QLabel() + self._habits_title.setStyleSheet("font-weight:600;") + habits_head = QHBoxLayout() + self.ai_analyze_btn = QPushButton() + self.ai_analyze_btn.setIcon(icon("sparkle")) + self.ai_analyze_btn.clicked.connect(self._ai_analyze) + # Apply an AI-suggested cost-saving strategy — only after the user + # clicks to approve it. + self.apply_strategy_btn = QPushButton() + self.apply_strategy_btn.setIcon(icon("bolt")) + self.apply_strategy_btn.setVisible(False) + self.apply_strategy_btn.clicked.connect(self._apply_saving_strategy) + habits_head.addWidget(self._habits_title, 1) + habits_head.addWidget(self.apply_strategy_btn) + habits_head.addWidget(self.ai_analyze_btn) + root.addLayout(habits_head) + self.habits = QTextBrowser() + self.habits.setOpenExternalLinks(False) + self.habits.setMinimumHeight(160) + root.addWidget(self.habits, 1) + self._ai_title = QLabel() + self._ai_title.setStyleSheet("font-weight:600;") + self._ai_title.setVisible(False) + root.addWidget(self._ai_title) + self.ai_advice = QTextBrowser() + self.ai_advice.setOpenExternalLinks(False) + self.ai_advice.setMinimumHeight(140) + self.ai_advice.setVisible(False) + root.addWidget(self.ai_advice, 1) + + self.retranslate() + + def retranslate(self) -> None: + self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn")) + self.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip")) + self.apply_strategy_btn.setText(tr("dashboard.strategy_btn")) + self.apply_strategy_btn.setToolTip(tr("dashboard.strategy_tooltip")) + self._habits_title.setText(tr("dashboard.habits_title")) + + def refresh(self, start: date, end: date) -> None: + self._period_range = (start, end) + summary = self._query.summary(start, end) + s, events = summary["stats"], summary["events"] + + lines: List[str] = [] + if not events: + lines.append(f"{tr('dashboard.no_data')}") + else: + lines.append(f"{tr('dashboard.h_top')}") + lines.append("
    ") + for label, tok in s["top_labels"]: + pct = int(tok * 100 / s["total"]) if s["total"] else 0 + lines.append(f"
  1. {label[:60]} — {fmt_tokens(tok)} tokens ({pct}%)
  2. ") + lines.append("
") + src_parts = ", ".join( + f"{tr(f'app.tab.{k}') if k in ('cowork', 'code') else k}: {fmt_tokens(v)}" + for k, v in s["by_source"]) + lines.append(f"{tr('dashboard.h_by_source')}: {src_parts}
") + lines.append(f"{tr('dashboard.h_avg')}: " + f"{fmt_tokens(s['avg_per_turn'])} tokens
") + if s["busiest_day"]: + lines.append(f"{tr('dashboard.h_busiest_day')}: {s['busiest_day']}
") + if s["busiest_hour"] is not None: + lines.append(f"{tr('dashboard.h_busiest_hour')}: " + f"{s['busiest_hour']:02d}:00–{s['busiest_hour']:02d}:59
") + if s["estimated_share"] > 0: + lines.append(f"{tr('dashboard.estimated_note', pct=int(s['estimated_share'] * 100))}") + self.habits.setHtml("".join(lines)) + + def _apply_saving_strategy(self) -> None: + """Apply an AI-suggested cost-saving strategy AFTER the user + approves: turn on auto-compress and compress earlier (lower + threshold) + compress content before sending it to the agent.""" + from PySide6.QtWidgets import QMessageBox + if QMessageBox.question(self, tr("dashboard.strategy_title"), + tr("dashboard.strategy_confirm")) != QMessageBox.Yes: + return + cx = self.ctx.config.data.setdefault("context", {}) + cx["auto_compact"] = True + cx["compact_threshold"] = 0.6 # compress at 60% of the window (was ~80%) + cx["compress_before_send"] = True # digest context before each turn + self.ctx.save() + self.status_message.emit(tr("dashboard.strategy_applied")) + + def _ai_analyze(self) -> None: + """✨ Send the aggregated numbers (never raw prompt text) to the + active provider and show habit feedback + token-saving + recommendations.""" + if self._ai_worker is not None: + return + start, end = self._period_range + if start is None: + return + summary = self._query.summary(start, end) + if not summary["events"]: + self.status_message.emit(tr("dashboard.no_data")) + return + stats = summary["stats"] + self.ai_analyze_btn.setEnabled(False) + self.ai_analyze_btn.setText(tr("dashboard.ai_analyzing")) + ctx = self.ctx + + def job(worker: AgentWorker): + from cowork_local.core import usage_tracker as ut + from cowork_local.i18n import get_language + + prompt = ut.build_ai_analysis_prompt(stats, get_language()) + provider = ctx.build_active_provider() + reply = provider.chat([{"role": "user", "content": prompt}], + cancel=worker.stop_event) + return {"text": (reply.get("content") or "").strip()} + + def done(result: dict) -> None: + self._ai_worker = None + self.ai_analyze_btn.setEnabled(True) + self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn")) + text = result.get("text") or "" + if text: + self._ai_title.setText(tr("dashboard.ai_advice_title")) + self._ai_title.setVisible(True) + self.ai_advice.setMarkdown(text) + self.ai_advice.setVisible(True) + self.apply_strategy_btn.setVisible(True) + + def failed(err: str) -> None: + self._ai_worker = None + self.ai_analyze_btn.setEnabled(True) + self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn")) + self.status_message.emit(str(err)) + + w = AgentWorker(job) + w.finished_ok.connect(done) + w.failed.connect(failed) + self._ai_worker = w + w.start() + + +__all__ = ["HabitsWidget"] diff --git a/presentation/dashboard/token_usage_card_widget.py b/presentation/dashboard/token_usage_card_widget.py new file mode 100644 index 0000000..12527d9 --- /dev/null +++ b/presentation/dashboard/token_usage_card_widget.py @@ -0,0 +1,108 @@ +"""TokenUsageCardWidget — the stat-card grid + budget card of the Dashboard +(R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``, lines +116-141/226-254/337-346 of the original 437-line file: the card grid layout, +``_apply_budget``, ``_refresh_budget``, and ``refresh()``'s card-filling +section). +""" +from __future__ import annotations + +from datetime import date + +from PySide6.QtWidgets import QGridLayout, QWidget + +from cowork_local.application.monitoring import DashboardQueryService +from cowork_local.i18n import tr +from cowork_local.ui.icons import icon +from cowork_local.ui.widgets import BudgetCard, StatCard, fmt_tokens + + +class TokenUsageCardWidget(QWidget): + """Cost is the headline this screen exists for, so it gets a card twice + the height of the rest instead of being the fifth of five identical + tiles — with six equal cards nothing said which number mattered.""" + + def __init__(self, ctx, query: DashboardQueryService, parent=None): + super().__init__(parent) + self.ctx = ctx + self._query = query + + cards_grid = QGridLayout(self) + cards_grid.setSpacing(8) + self.card_total = StatCard() + self.card_in = StatCard() + self.card_out = StatCard() + self.card_cache = StatCard() + self.card_cost = StatCard().as_hero() + # Hero on the left, spanning both rows; the four supporting figures + # fill a 2x2 block beside it. + cards_grid.addWidget(self.card_cost, 0, 0, 2, 1) + for i, card in enumerate((self.card_total, self.card_in, + self.card_out, self.card_cache)): + cards_grid.addWidget(card, i // 2, 1 + i % 2) + # Budget: remaining/budget, direct entry, auto-warns red past 85% used. + self.budget_card = BudgetCard() + self.budget_card.apply_btn.setIcon(icon("check")) + self.budget_card.apply_btn.clicked.connect(self._apply_budget) + cards_grid.addWidget(self.budget_card, 0, 3, 2, 1) + for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)): + cards_grid.setColumnStretch(col, stretch) + + self.retranslate() + + def retranslate(self) -> None: + self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip")) + self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip")) + + def refresh(self, start: date, end: date) -> None: + summary = self._query.summary(start, end) + s, pricing, costs = summary["stats"], summary["pricing"], summary["costs"] + from cowork_local.core import usage_tracker as ut + + est_note = (tr("dashboard.estimated_note", pct=int(s["estimated_share"] * 100)) + if s["estimated_share"] > 0 else "") + self.card_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"]), + tr("dashboard.card_turns", n=s["turns"])) + self.card_in.set(tr("dashboard.card_in"), fmt_tokens(s["in"]), + ut.format_cost(costs["in"], pricing)) + self.card_out.set(tr("dashboard.card_out"), fmt_tokens(s["out"]), + ut.format_cost(costs["out"], pricing)) + self.card_cache.set(tr("dashboard.card_cache"), fmt_tokens(s["cache"]), + ut.format_cost(costs["cache"], pricing)) + self.card_cost.set(tr("dashboard.card_cost"), + ut.format_cost(summary["total_cost"], pricing, digits=2), est_note) + self._refresh_budget() + + def _apply_budget(self) -> None: + """Persist the spin box's value as the new budget — starts a fresh + remaining-balance window (spend before now is no longer counted).""" + ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD") + self._query.set_budget(self.budget_card.budget_spin.value(), ccy) + self.ctx.save() + self._refresh_budget() + + def _refresh_budget(self) -> None: + from cowork_local.core import model_pricing as mp + from cowork_local.core import usage_tracker as ut + + pricing = self._query.pricing() + status = self._query.budget_status() + if status is None: + self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget")) + self.budget_card.budget_spin.setValue(0.0) + return + remaining_disp = mp.convert(status["remaining_usd"], "USD", + pricing.get("currency", "USD"), self.ctx.config) + amount_disp = mp.convert(status["amount_usd"], "USD", + pricing.get("currency", "USD"), self.ctx.config) + value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}" + f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}") + pct = int(round(status["pct_used"] * 100)) + sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct) + self.budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"]) + # keep the entry field showing the CURRENT budget (in display currency) + # — only when it doesn't already have unsaved focus/edits from the user. + if not self.budget_card.budget_spin.hasFocus(): + self.budget_card.budget_spin.setValue(round(amount_disp, 2)) + + +__all__ = ["TokenUsageCardWidget"] diff --git a/presentation/dashboard/usage_chart_widget.py b/presentation/dashboard/usage_chart_widget.py new file mode 100644 index 0000000..81571d6 --- /dev/null +++ b/presentation/dashboard/usage_chart_widget.py @@ -0,0 +1,179 @@ +"""UsageChartWidget — the period pager + granularity/metric/currency +controls + spline chart of the Dashboard (R08-T13, extracted from +``ui/dashboard_tab.py::DashboardTab``, lines 53-114/143-152/201-207/ +263-323 of the original 437-line file). + +Owns the period SELECTOR (granularity + prev/next offset) that the whole +screen follows — ``token_usage_card_widget.py`` and ``habits_widget.py`` +read :meth:`period_range`/:meth:`granularity` rather than keeping their own +copy, and the shell re-refreshes them on :attr:`period_changed`. +""" +from __future__ import annotations + +from typing import Tuple + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget + +from cowork_local.application.monitoring import DashboardQueryService +from cowork_local.i18n import tr +from cowork_local.theme import current_palette +from cowork_local.ui.icons import icon +from cowork_local.ui.spline_chart import SplineChart +from cowork_local.ui.widgets import fmt_tokens + + +class UsageChartWidget(QWidget): + period_changed = Signal() # granularity or offset changed — re-run every widget + currency_changed = Signal() # display currency changed — same, cost text depends on it + + def __init__(self, ctx, query: DashboardQueryService, parent=None): + super().__init__(parent) + self.ctx = ctx + self._query = query + self._chart_offset = 0 # 0 = current period; <0 = a past period + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + controls = QHBoxLayout() + controls.setSpacing(6) + self.chart_prev_btn = QPushButton() + self.chart_prev_btn.setIcon(icon("chevron-left")) + self.chart_prev_btn.setFixedWidth(30) + self.chart_prev_btn.clicked.connect(self._chart_prev) + controls.addWidget(self.chart_prev_btn) + self._chart_period_lbl = QLabel() + self._chart_period_lbl.setObjectName("hint") + self._chart_period_lbl.setAlignment(Qt.AlignCenter) + self._chart_period_lbl.setMinimumWidth(170) + controls.addWidget(self._chart_period_lbl) + self.chart_next_btn = QPushButton() + self.chart_next_btn.setIcon(icon("chevron-right")) + self.chart_next_btn.setFixedWidth(30) + self.chart_next_btn.clicked.connect(self._chart_next) + controls.addWidget(self.chart_next_btn) + controls.addSpacing(12) + self.gran_combo = QComboBox() + for g in ("week", "month", "year"): + self.gran_combo.addItem(tr(f"dashboard.gran_{g}"), g) + self.gran_combo.currentIndexChanged.connect(self._on_gran_changed) + controls.addWidget(self.gran_combo) + self.metric_combo = QComboBox() + for m in ("cost", "tokens"): + self.metric_combo.addItem(tr(f"dashboard.metric_{m}"), m) + self.metric_combo.currentIndexChanged.connect(self.refresh) + controls.addWidget(self.metric_combo) + controls.addStretch(1) + # Display-currency picker — both Dashboard and Monitoring read/write + # the same usage.currency config key, so changing it here updates + # cost text everywhere. + self.currency_lbl = QLabel() + self.currency_lbl.setObjectName("hint") + controls.addWidget(self.currency_lbl) + self.currency_combo = QComboBox() + from cowork_local.core import usage_tracker as ut + for cur in ut.SUPPORTED_CURRENCIES: + self.currency_combo.addItem(cur, cur) + idx = self.currency_combo.findData( + (self.ctx.config.data.get("usage") or {}).get("currency", "USD")) + self.currency_combo.setCurrentIndex(max(0, idx)) + self.currency_combo.currentIndexChanged.connect(self._on_currency_changed) + controls.addWidget(self.currency_combo) + root.addLayout(controls) + + chart_head = QHBoxLayout() + self._chart_title = QLabel() + self._chart_title.setStyleSheet("font-weight:600;") + chart_head.addWidget(self._chart_title, 1) + root.addLayout(chart_head) + self.chart = SplineChart() + root.addWidget(self.chart) + + self.retranslate() + + def retranslate(self) -> None: + self.currency_lbl.setText(tr("monitoring.overview_currency")) + self.currency_combo.setToolTip(tr("dashboard.currency_tooltip")) + self._chart_title.setText(tr("dashboard.chart_title")) + self.chart_prev_btn.setToolTip(tr("dashboard.chart_prev")) + self.chart_next_btn.setToolTip(tr("dashboard.chart_next")) + + # ---- public: the period selector every other widget follows ------------- # + def granularity(self) -> str: + return self.gran_combo.currentData() or "week" + + @property + def chart_offset(self) -> int: + return self._chart_offset + + def period_range(self) -> Tuple: + return self._query.period_range(self.granularity(), self._chart_offset) + + # ---- navigation ------------------------------------------------------------ # + def _on_gran_changed(self, *_a) -> None: + self._chart_offset = 0 # period size changed → back to current + self.period_changed.emit() + + def _chart_prev(self) -> None: + self._chart_offset -= 1 + self.period_changed.emit() + + def _chart_next(self) -> None: + self._chart_offset = min(0, self._chart_offset + 1) # never past the present + self.period_changed.emit() + + def _on_currency_changed(self, _idx: int) -> None: + cur = self.currency_combo.currentData() + if not cur: + return + self.ctx.config.data.setdefault("usage", {})["currency"] = cur + self.ctx.save() + self.currency_changed.emit() + + @staticmethod + def _delta_txt(cur: float, prev: float) -> str: + """▲/▼ percent change of ``cur`` vs ``prev`` (empty if no baseline).""" + if not prev: + return "" + pct = (cur - prev) / prev * 100 + arrow = "▲" if pct > 0.5 else ("▼" if pct < -0.5 else "•") + return f"{arrow}{abs(pct):.0f}%" + + # ---- rendering --------------------------------------------------------------- # + def refresh(self, *_a) -> None: + """Break the SELECTED period into its parts: WEEK -> 7 days (Mon-Sun) + - MONTH -> weeks W1..Wn - YEAR -> 12 months. A dashed line marks the + previous same-granularity period's average per point with the % + change of the totals.""" + from cowork_local.core import usage_tracker as ut + + gran = self.granularity() + metric = self.metric_combo.currentData() or "cost" + pts = self._query.chart_series(gran, self._chart_offset, metric) + pricing = self._query.pricing() + mi = 0 if metric == "tokens" else 1 + # Compact cost format (2 decimals, K/M above 1,000/1,000,000) — the + # chart's y-axis label box is narrow; format_cost's full precision + # overflowed it, clipping/obscuring the amount. + fmt = fmt_tokens if metric == "tokens" else (lambda v: ut.format_cost_compact(v, pricing)) + + cur = self._query.period_totals(gran, self._chart_offset) + prev = self._query.period_totals(gran, self._chart_offset - 1) + ref_key = {"week": "dashboard.ref_last_week", + "month": "dashboard.ref_last_month", + "year": "dashboard.ref_last_year"}.get(gran, "dashboard.ref_last_week") + n_points = max(1, len(pts)) + refs = [] + if prev[mi] > 0: + # Muted on purpose: the comparison line is a reference, not the + # series — it must not compete with the accent-coloured spline. + refs.append((prev[mi] / n_points, + f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}", + current_palette().text_muted)) + self.chart.set_reference_lines(refs) + self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}")) + self._chart_period_lbl.setText(self._query.period_range_label(gran, self._chart_offset)) + self.chart_next_btn.setEnabled(self._chart_offset < 0) + + +__all__ = ["UsageChartWidget"] diff --git a/presentation/folder/__init__.py b/presentation/folder/__init__.py new file mode 100644 index 0000000..1059c7d --- /dev/null +++ b/presentation/folder/__init__.py @@ -0,0 +1,4 @@ +"""Folder Explorer screen, split into single-responsibility widgets +(R08-T12): ``workspace_file_tree``, ``document_preview_manager``, +``ai_edit_model_resolver``, ``ai_file_editor_dialog``, assembled by the +``folder_tab`` shell.""" diff --git a/presentation/folder/ai_edit_model_resolver.py b/presentation/folder/ai_edit_model_resolver.py new file mode 100644 index 0000000..8e7d270 --- /dev/null +++ b/presentation/folder/ai_edit_model_resolver.py @@ -0,0 +1,248 @@ +"""AiEditModelResolver — model picker + Auto Model Routing + image-model +discovery for the AI-Edit panel (R08-T12, extracted from +``ui/folder_tab.py::FolderTab``, lines 802-1036/911-961 of the original +1587-line file: ``refresh_ai_models``, ``_scan_all_image_models``, +``_ai_provider``, ``_ai_apply_routing``, ``_confirm_routing_switch``, +``_ai_image_model``, ``_maybe_suggest_image_model``, +``_suggest_cross_provider_image``). + +A plain (non-Qt-widget) helper composed BY +``ai_file_editor_dialog.py::AiFileEditorDialog`` — this is genuinely a +distinct concern (which provider/model answers THIS run) from the panel's +send/plan/edit orchestration, and splitting it out is also what keeps +``ai_file_editor_dialog.py`` under the 400-line cap. +""" +from __future__ import annotations + +from typing import Any, List, Optional, Tuple + +from cowork_local.core.worker import AgentWorker +from cowork_local.i18n import tr + + +class AiEditModelResolver: + """Owns the AI-edit model combo's contents and every "which provider/ + model should THIS run use" decision — independent of the Cowork/Settings + agent, exactly like the original panel's own picker was. + + Args: + ctx: ``AppContext``. + model_combo: the ``QComboBox`` populated by :meth:`refresh`. + on_status: ``(text) -> None`` — posts a status line into the AI + chat (production passes ``ai_chat.add_status``). + confirm_switch: ``(self, decision, timeout) -> bool`` — the Qt + confirm dialog for Manual routing mode (kept as a callback so + this class never imports a dialog itself). + """ + + def __init__(self, ctx, model_combo, on_status, confirm_switch) -> None: + self.ctx = ctx + self._combo = model_combo + self._on_status = on_status + self._confirm_switch = confirm_switch + self._models: List[str] = [] + self._models_provider = "" + self._all_image_models: List[Tuple[str, str]] = [] # [(provider_key, model)] + self._img_scan_worker = None + self._pending_img_suggest = False + self._routed_provider: Optional[str] = None + self._routed_model: Optional[str] = None + + @property + def models(self) -> List[str]: + return self._models + + @property + def models_provider(self) -> str: + return self._models_provider + + @property + def routed_provider(self) -> Optional[str]: + """The provider :meth:`apply_routing` switched to for the current + run, or ``None`` when it didn't switch (routing off/declined).""" + return self._routed_provider + + @property + def routed_model(self) -> Optional[str]: + return self._routed_model + + def should_refresh(self) -> bool: + """True on first open, or when the active provider changed since + the model list was last loaded — a stale list would resolve a pick + to the wrong/default model at the new endpoint.""" + return self._combo.count() <= 1 or self._models_provider != self.ctx.config.active_provider + + def refresh(self) -> None: + """Fetch the active provider's model list (background) into the + picker. Also proactively scans ALL providers for image-capable + models so a suggestion is ready the moment one is needed.""" + name = self.ctx.config.active_provider + setting_model = self.ctx.config.provider_conf(name).get("model", "") + + def job(worker): + prov = self.ctx.build_provider_for(name) + try: + models = list(getattr(prov, "list_models", lambda: [])() or []) + except Exception: # noqa: BLE001 + models = [] + return {"models": models} + + def done(res): + fetched = list(res.get("models", [])) + # Always offer the Settings-configured model as an explicit + # choice, even when the provider can't list models. + self._models = list(dict.fromkeys( + ([setting_model] if setting_model else []) + [m for m in fetched if m])) + self._models_provider = name + cur = self._combo.currentData() + self._combo.blockSignals(True) + self._combo.clear() + self._combo.addItem(tr("folder.ai_model_auto"), None) + for m in self._models: + self._combo.addItem(m, m) + idx = self._combo.findData(cur) + self._combo.setCurrentIndex(idx if idx >= 0 else 0) + self._combo.blockSignals(False) + + w = AgentWorker(job) + w.finished_ok.connect(done) + self._models_worker = w + w.start() + self._scan_all_image_models() + + def _scan_all_image_models(self, then_suggest: bool = False) -> None: + if self._img_scan_worker is not None: + if then_suggest: + self._pending_img_suggest = True + return + providers = dict(self.ctx.config.data.get("providers", {})) + candidates = [k for k, c in providers.items() + if (c.get("base_url") or c.get("api_key"))] + + def job(worker): + from cowork_local.core import image_gen + found = [] + for key in candidates: + try: + prov = self.ctx.build_provider_for(key) + models = list(getattr(prov, "list_models", lambda: [])() or []) + except Exception: # noqa: BLE001 - a broken provider must not block the scan + models = [] + for m in models: + if image_gen.looks_like_image_model(m): + found.append((key, m)) + return {"found": found} + + def done(res): + self._img_scan_worker = None + self._all_image_models = list(res.get("found", [])) + if self._pending_img_suggest: + self._pending_img_suggest = False + self._suggest_cross_provider_image() + + w = AgentWorker(job) + w.finished_ok.connect(done) + w.failed.connect(self._on_image_scan_failed) + self._img_scan_worker = w + if then_suggest: + self._pending_img_suggest = True + w.start() + + def _on_image_scan_failed(self, _err) -> None: + self._img_scan_worker = None + + def provider(self) -> Any: + """Build a provider using the model chosen in the picker ('(auto)' + -> the active provider's default), or an Auto/Manual routing + override set by :meth:`apply_routing` for the current run.""" + if self._routed_provider or self._routed_model: + provider = self._routed_provider or self.ctx.config.active_provider + return self.ctx.build_provider_for(provider, self._routed_model or None) + model = self._combo.currentData() + return self.ctx.build_provider_for(self.ctx.config.active_provider, model or None) + + def apply_routing(self, instruction: str) -> None: + """Auto Model Routing for the AI-Edit surface (always a CODING + task). Sets the routing override :meth:`provider` honours.""" + from cowork_local.core.routing.models import TaskType + + self._routed_provider = None + self._routed_model = None + cur_provider = self.ctx.config.active_provider + picked = self._combo.currentData() + cur_model = picked or self.ctx.config.provider_conf(cur_provider).get("model", "") + decision = self.ctx.routing_application().route_turn( + "ai_edit", instruction, cur_provider, cur_model, + task_type=TaskType.CODING, confirm=self._confirm_switch, + ) + if not decision.switched: + return + self._routed_provider, self._routed_model = decision.target() + self._on_status(tr( + "routing.switched_notice", + model=decision.model, task=decision.task_type, + gain=f"{decision.score_gain:.2f}")) + + _IMAGE_WORDS = ("image", "picture", "photo", "illustration", "icon", "logo", "diagram", + "ảnh", "hình", "minh họa", "biểu tượng", "画像", "イラスト") + + def maybe_suggest_image_model(self, instruction: str) -> None: + """If the request looks image-related, suggest a suitable image + model BEFORE running — active provider first, then ALL providers.""" + from cowork_local.core import image_gen + low = (instruction or "").lower() + if not any(w in low for w in self._IMAGE_WORDS): + return + picked = self._combo.currentData() + if picked and image_gen.looks_like_image_model(picked): + return + local = image_gen.suggest_image_model(self._models) + if local: + self._on_status(tr("folder.ai_image_suggest", model=local)) + return + if self._all_image_models: + self._suggest_cross_provider_image() + elif self._img_scan_worker is not None: + self._pending_img_suggest = True + else: + self._scan_all_image_models(then_suggest=True) + + def _suggest_cross_provider_image(self) -> None: + from cowork_local.config import PROVIDER_LABELS + if not self._all_image_models: + picked = self._combo.currentData() + if picked: + self._on_status(tr("folder.ai_image_use_selected", model=picked)) + else: + self._on_status(tr("folder.ai_image_none")) + return + seen, lines = set(), [] + for key, model in self._all_image_models: + tag = (key, model) + if tag in seen: + continue + seen.add(tag) + lines.append(f"• {model} ({PROVIDER_LABELS.get(key, key)})") + if len(lines) >= 5: + break + self._on_status(tr("folder.ai_image_suggest_all") + "\n" + "\n".join(lines)) + + def image_model(self) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """Resolve ``(model, base_url, api_key)`` for image generation, + searching ALL providers — see the module docstring for priority + order (picked model if image-capable -> active provider's image + model -> any other provider's -> fall back to the picked model).""" + from cowork_local.core import image_gen + picked = self._combo.currentData() + if picked and image_gen.looks_like_image_model(picked): + return picked, None, None + local = image_gen.suggest_image_model(self._models) + if local: + return local, None, None + for key, model in self._all_image_models: + conf = self.ctx.config.provider_conf(key) + return model, (conf.get("base_url") or None), (conf.get("api_key") or None) + return (picked or None), None, None + + +__all__ = ["AiEditModelResolver"] diff --git a/presentation/folder/ai_edit_pipeline.py b/presentation/folder/ai_edit_pipeline.py new file mode 100644 index 0000000..f8b9b28 --- /dev/null +++ b/presentation/folder/ai_edit_pipeline.py @@ -0,0 +1,372 @@ +"""AiEditPipeline — the plan-then-edit-then-apply state machine behind the +AI-Edit panel (R08-T12, extracted from ``ui/folder_tab.py::FolderTab``, +lines 1068-1097/1119-1146/1147-1467 of the original 1587-line file: +``_ai_start`` through ``_ai_failed``, minus the queue/busy-badge bookkeeping +which stays on ``ai_file_editor_dialog.py::AiFileEditorDialog`` — see that +module's docstring for the split rationale). + +A plain (non-Qt-widget) helper composed BY ``AiFileEditorDialog`` — same +composition-to-respect-the-400-line-cap pattern as +``office_document_renderer.py``. Talks to the file only through +``document_preview_manager.py``'s public API (``ensure_editable_for_ai``, +``write_content``, ``create_new_file``) — it never touches disk itself. +""" +from __future__ import annotations + +import difflib +import os +from pathlib import Path +from typing import Optional + +from cowork_local.application.workspaces.ai_edit_output import parse_ai_output +from cowork_local.core.worker import AgentWorker +from cowork_local.i18n import tr +from cowork_local.theme import current_palette + + +class AiEditPipeline: + """Runs one instruction through PLAN -> EDIT -> (review) -> APPLY/DISCARD. + + Args: + owner: the ``AiFileEditorDialog`` — supplies ``ai_chat``, ``preview`` + (``DocumentPreviewManager``), ``resolver`` + (``AiEditModelResolver``), ``ctx``, ``cowork_context()``, and is + told about status changes via ``on_busy_changed``/``on_flag_done`` + so the panel's queue/badge bookkeeping stays in one place. + """ + + def __init__(self, owner) -> None: + self._owner = owner + self.worker: Optional[AgentWorker] = None + self.pending: Optional[dict] = None # proposed content awaiting confirmation + self._ctx: dict = {} + self._prompt_usage: dict = {"in": 0, "out": 0, "cache": 0, "cost": 0.0} + self._running_file = "" + + def start(self, instruction: str) -> None: + """Begin processing one instruction. Assumes the pipeline is idle + (the panel's queue calls this when the previous run finishes).""" + o = self._owner + preview = o.preview + editable = preview.stack.currentWidget() is preview.editor + if not editable: + editable = preview.ensure_editable_for_ai() + o.resolver.maybe_suggest_image_model(instruction) + o.resolver.apply_routing(instruction) # may switch to the best coding model + has_file = editable and bool(preview.current_file) + self._running_file = Path(preview.current_file).name if has_file else tr("folder.ai_new_file") + o.set_busy(True) + o.status_message.emit(tr("folder.ai_running", name=self._running_file)) + # Two phases so the PLAN is shown INLINE *before* the edit runs. + self._ctx = { + "filename": Path(preview.current_file).name if has_file else "", + "content": preview.editor.toPlainText() if has_file else "", + "convo": o.cowork_context(), + "instruction": instruction, + "provider": o.resolver.provider(), + "plan": "", + "edit_kind": preview.edit_kind, + } + self._prompt_usage = {"in": 0, "out": 0, "cache": 0, "cost": 0.0} + self._run_plan() + + # ---- usage accounting (like Cowork's per-message footer) --------------- # + def _add_usage(self, usage) -> None: + if not isinstance(usage, dict): + return + tot = self._prompt_usage + tot["in"] += int(usage.get("in", 0) or 0) + tot["out"] += int(usage.get("out", 0) or 0) + tot["cache"] += int(usage.get("cache", 0) or 0) + tot["cost"] += float(usage.get("cost_usd", 0.0) or 0.0) + + def _show_usage(self, bubble) -> None: + tot = self._prompt_usage + if bubble is None or not (tot["in"] or tot["out"]): + return + from cowork_local.core import model_pricing as mp, usage_tracker as ut + pricing = {**ut.DEFAULT_PRICING, **(self._owner.ctx.config.data.get("usage") or {})} + line = (f"↓{mp.format_tokens(int(tot['in']))} ↑{mp.format_tokens(int(tot['out']))} " + f"▤{mp.format_tokens(int(tot['in'] + tot['out'] + tot['cache']))} " + f"{ut.format_cost(tot['cost'], pricing)}") + try: + bubble.add_usage(line) + except Exception: # noqa: BLE001 - a usage footer must never break the edit + pass + + # ---- phase 1: plan ------------------------------------------------------- # + def _run_plan(self) -> None: + o = self._owner + c = self._ctx + plan_bubble = o.ai_chat.add_plan(tr("folder.ai_planning")) + o.ai_chat.scroll_to_bottom() + + def job(worker): + from cowork_local.core import usage_tracker as ut + from cowork_local.core.co4e_runner import _usage_delta + provider = c["provider"] + messages = [{"role": "system", "content": + "You are an AI file editor. Give a SHORT numbered plan (2-4 steps) for " + "the requested change. Plan ONLY — do NOT output any code."}] + if c["convo"]: + messages.append({"role": "system", + "content": "Context from the user's Cowork conversation:\n" + c["convo"]}) + messages.append({"role": "user", "content": + f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n" + f"Request: {c['instruction']}"}) + ut.set_context("folder", c.get("filename") or "AI edit") + ut.begin_accumulation(); base = ut.accumulated() + try: + r = provider.chat(messages, tools=None, cancel=worker.is_cancelled) + txt = r.get("content", "") if isinstance(r, dict) else str(r) + usage = _usage_delta(base, o.ctx.config) + finally: + ut.end_accumulation() + return {"plan": provider.strip_think(txt) or "", "usage": usage} + + worker = AgentWorker(job) + worker.finished_ok.connect(lambda res, b=plan_bubble: self._plan_done(res, b)) + worker.failed.connect(lambda err, b=plan_bubble: self._failed(err, b)) + self.worker = worker + worker.start() + + def _plan_done(self, result, plan_bubble) -> None: + self._add_usage((result or {}).get("usage")) + plan = ((result or {}).get("plan") or "").strip() + self._ctx["plan"] = plan + plan_bubble.set_plain(plan or tr("folder.ai_empty")) + self._owner.ai_chat.scroll_to_bottom() + self._run_edit() + + # ---- phase 2: execute (edit the file) ------------------------------------ # + def _run_edit(self) -> None: + o = self._owner + c = self._ctx + bubble = o.ai_chat.add_assistant(tr("folder.ai_edit")) + o.ai_chat.scroll_to_bottom() + + pptx_note = ("\nThis is a PPTX shown as marker blocks '### Slide N / Box M', where N is the " + "1-based SLIDE NUMBER and M the box on that slide. When the user refers to a " + "slide by number (e.g. 'edit slide 3'), change ONLY the blocks under '### Slide " + "3' and leave every other slide's block exactly as-is. Each block has fields " + "type/pos/size/font/text (and image for pictures). To change TEXT COLOUR or " + "FONT edit the `font:` line — e.g. `font: name=Arial size=28 bold=1 " + "color=FF0000`. Keep all block markers and structure.") if c["edit_kind"] == "pptx" else "" + + _pptx_words = ("pptx", "powerpoint", "slide", "presentation", "deck", + "スライド", "プレゼン", "trình chiếu", "trinh chieu", "bài thuyết trình") + wants_new_pptx = (c["edit_kind"] != "pptx" + and any(w in c["instruction"].lower() for w in _pptx_words)) + new_pptx_note = ("\nTo CREATE a PowerPoint, name it `FILE: .pptx` and output the slides " + "as marker blocks — one block per shape:\n" + "### Slide 1 / Box 1\ntype: text\npos: 0.5, 0.4\nsize: 9.0, 1.2\n" + "font: name=Calibri size=32 bold=1 color=1F3864\ntext:\nTitle here\n\n" + "### Slide 1 / Box 2\ntype: text\npos: 0.5, 1.8\nsize: 9.0, 4.5\n" + "text:\nBullet one\nBullet two\n\n" + "Increment the Slide number for each new slide; pos/size are in inches; " + "font color is RRGGBB hex.") if wants_new_pptx else "" + + imggen_note = "" + try: + from cowork_local.core import image_gen + if image_gen.is_configured(o.ctx.config): + imggen_note = ("\nYou can also GENERATE an illustration image: add a line " + "`IMAGE_GEN: => `. Use a " + "generated image e.g. as a new picture, or (for pptx) set a picture " + "box's `image:` field to that same path to insert it.") + except Exception: # noqa: BLE001 + pass + + def job(worker): + provider = c["provider"] + open_note = (f"the currently-open file '{c['filename']}'" if c["filename"] + else "no file is open") + messages = [{"role": "system", "content": + "You are an AI file editor inside an app. Following the plan, output the " + "COMPLETE file content in ONE fenced code block (```), and nothing after " + "it. Preserve everything you were not asked to change.\n" + "If the request is to CREATE A NEW file (or a different file than the one " + "open), put a line `FILE: ` (relative to the " + "current folder) immediately before the code block. Omit FILE to edit the " + f"open file. Right now, {open_note}." + pptx_note + new_pptx_note + imggen_note}] + if c["convo"]: + messages.append({"role": "system", + "content": "Context from the user's Cowork conversation:\n" + c["convo"]}) + if c["plan"]: + messages.append({"role": "system", "content": "Plan to follow:\n" + c["plan"]}) + cur = (f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n" + if c["filename"] else "No file is currently open.\n\n") + messages.append({"role": "user", "content": cur + f"Request: {c['instruction']}"}) + + def on_text(piece: str) -> None: + worker.emit_event({"type": "text", "delta": piece}) + + from cowork_local.core import usage_tracker as ut + from cowork_local.core.co4e_runner import _usage_delta + ut.set_context("folder", c.get("filename") or "AI edit") + ut.begin_accumulation(); base = ut.accumulated() + try: + r = provider.chat(messages, tools=None, on_text=on_text, cancel=worker.is_cancelled) + txt = r.get("content", "") if isinstance(r, dict) else str(r) + usage = _usage_delta(base, o.ctx.config) + finally: + ut.end_accumulation() + return {"text": provider.strip_think(txt) or "", "usage": usage} + + worker = AgentWorker(job) + worker.event.connect(lambda ev, b=bubble: self._stream(ev, b)) + worker.finished_ok.connect(lambda res, b=bubble: self._done(res, b)) + worker.failed.connect(lambda err, b=bubble: self._failed(err, b)) + self.worker = worker + worker.start() + + def _stream(self, ev, bubble) -> None: + if isinstance(ev, dict) and ev.get("type") == "text": + bubble.append_delta(ev.get("delta", "")) + self._owner.ai_chat.scroll_to_bottom() + + def _done(self, result, bubble) -> None: + o = self._owner + self.worker = None + o.set_busy(False) + self._add_usage((result or {}).get("usage")) + self._show_usage(bubble) + text = ((result or {}).get("text") or "").strip() + target, new_content, summary, image_gens = parse_ai_output(text) + if new_content is None and not image_gens: + bubble.set_markdown(text or tr("folder.ai_empty")) + o.ai_chat.scroll_to_bottom() + o.flag_done() + return + create = bool(target) and (not o.preview.current_file + or Path(target).name != Path(o.preview.current_file).name) + self.pending = {"content": new_content, "target": target if create else None, + "image_gens": image_gens} + hint = tr("folder.ai_review_hint") + bubble.set_markdown((summary + "\n\n" if summary else "") + "_" + hint + "_") + if new_content is not None: + old = "" if create else o.preview.editor.toPlainText() + diff = "".join(difflib.unified_diff( + old.splitlines(keepends=True), new_content.splitlines(keepends=True), + fromfile=("(new file)" if create else "current"), + tofile=(target if create else "proposed"))) or "(no textual difference)" + title = tr("folder.ai_proposed_new", name=target) if create else tr("folder.ai_proposed") + o.ai_chat.add_diff(title, diff) + if image_gens: + listing = "\n".join(f"• {p} ({prompt[:60]})" for prompt, p in image_gens) + o.ai_chat.add_status(tr("folder.ai_image_plan") + "\n" + listing) + o.show_confirm_row(True) + o.ai_chat.scroll_to_bottom() + name = target if create else self._running_file + o.status_message.emit(tr("folder.ai_proposed_status", name=name)) + o.set_review_status("● " + hint, current_palette().warning) + + # ---- apply / discard ------------------------------------------------------ # + def apply(self) -> None: + """Confirmed by the user. If the edit GENERATES images, ask the + image gate then generate them (off-thread) before finalising.""" + if not self.pending: + return + p = self.pending + self.pending = None + self._owner.show_confirm_row(False) + if p.get("image_gens"): + from PySide6.QtWidgets import QMessageBox + if QMessageBox.question(self._owner, tr("folder.ai_image_confirm_title"), + tr("folder.ai_image_confirm_gen")) != QMessageBox.Yes: + self._owner.status_message.emit(tr("folder.ai_image_declined")) + return + self._generate_then_finalize(p) + return + self._finalize_apply(p) + + def _generate_then_finalize(self, p: dict) -> None: + o = self._owner + imgs = p.get("image_gens") or [] + root = os.path.normpath(o.preview.root) + img_model, img_base, img_key = o.resolver.image_model() + o.set_busy(True) + o.status_message.emit(tr("folder.ai_generating")) + + def job(worker): + from cowork_local.core import image_gen + results = [] + for prompt, rel in imgs: + dest = rel if os.path.isabs(rel) else os.path.join(root, rel) + dest = os.path.normpath(dest) + if os.path.commonpath([dest, root]) != root: + results.append((rel, False, "path escapes the folder")) + continue + try: + os.makedirs(os.path.dirname(dest) or root, exist_ok=True) + except OSError as exc: + results.append((rel, False, str(exc))) + continue + ok, msg = image_gen.generate_image(o.ctx.config, prompt, dest, + model=img_model, base_url=img_base, api_key=img_key) + results.append((dest, ok, msg)) + return {"results": results} + + worker = AgentWorker(job) + worker.finished_ok.connect(lambda res, pp=p: self._images_done(res, pp)) + worker.failed.connect(lambda err, pp=p: self._images_done({"results": [], "err": err}, pp)) + self.worker = worker + worker.start() + + def _images_done(self, res: dict, p: dict) -> None: + o = self._owner + self.worker = None + o.set_busy(False) + created = [] + for dest, ok, msg in res.get("results", []): + if ok: + created.append(dest) + o.ai_chat.add_success("✓ " + tr("folder.ai_image_created", name=Path(dest).name)) + else: + o.ai_chat.add_error(tr("folder.ai_image_failed", err=msg)) + self._finalize_apply(p, images_done=True) + if p.get("content") is None and not p.get("target") and created: + o.preview.open_file(created[0], reset_ai=False) + + def _finalize_apply(self, p: dict, images_done: bool = False) -> None: + o = self._owner + content = p.get("content") + target = p.get("target") + if content is None: + o.ai_chat.scroll_to_bottom() + o.flag_done() + o.status_message.emit(tr("folder.ai_done", name=self._running_file)) + return + if target: + dest = o.preview.create_new_file(target, content) + if dest is None: + return + o.ai_chat.add_success("✓ " + tr("folder.ai_created", name=Path(dest).name)) + o.status_message.emit(tr("folder.ai_created", name=Path(dest).name)) + else: + o.preview.editor.setPlainText(content) # live update in the editor/preview + o.preview.write_content(content, skip_image_confirm=images_done) + o.ai_chat.add_success("✓ " + tr("folder.ai_applied")) + o.status_message.emit(tr("folder.ai_done", name=self._running_file)) + o.ai_chat.scroll_to_bottom() + o.flag_done() + + def discard(self) -> None: + o = self._owner + self.pending = None + o.show_confirm_row(False) + o.ai_chat.add_status(tr("folder.ai_discarded")) + o.ai_chat.scroll_to_bottom() + o.set_review_status("", None) + o.maybe_dequeue() # discarding resolves the gate → run the next queued edit + + def _failed(self, err, bubble) -> None: + o = self._owner + self.worker = None + bubble.set_markdown(tr("folder.ai_error", err=err)) + o.set_busy(False) + o.status_message.emit(tr("folder.ai_error", err=err)) + o.flag_done() + + +__all__ = ["AiEditPipeline"] diff --git a/presentation/folder/ai_file_editor_dialog.py b/presentation/folder/ai_file_editor_dialog.py new file mode 100644 index 0000000..7b5c4b1 --- /dev/null +++ b/presentation/folder/ai_file_editor_dialog.py @@ -0,0 +1,237 @@ +"""AiFileEditorDialog — the collapsible AI-edit panel of the Folder Explorer +(R08-T12, extracted from ``ui/folder_tab.py::FolderTab``, lines 701-800/ +1039-1066/1099-1118/1468-1517 of the original 1587-line file: +``_build_ai_panel``, panel open/reset, the instruction queue, and the busy/ +done status line). + +Despite the name (matching ``docs/refactor/Feature_Architecture_Proposal.md``'s +R08-T12 file list), this is an inline collapsible ``QWidget`` panel, not a +modal ``QDialog`` — exactly like the original ``_ai_panel`` was. + +Composes two helpers to stay under the 400-line cap: +``ai_edit_model_resolver.py::AiEditModelResolver`` (which provider/model +answers a run) and ``ai_edit_pipeline.py::AiEditPipeline`` (the actual +plan-then-edit-then-apply state machine). This class owns the widget itself, +the instruction queue, and the busy/done status line/badge — the parts that +needed to stay together because the queue decides when the pipeline's next +``start()`` call happens. +""" +from __future__ import annotations + +from typing import List, Optional + +from PySide6.QtWidgets import ( + QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget, +) +from PySide6.QtCore import Signal + +from cowork_local.i18n import on_language_changed, tr +from cowork_local.presentation.folder.ai_edit_model_resolver import AiEditModelResolver +from cowork_local.presentation.folder.ai_edit_pipeline import AiEditPipeline +from cowork_local.theme import current_palette +from cowork_local.ui.chat_view import ChatView + + +class AiFileEditorDialog(QWidget): + """Collapsible panel: a Cowork-style inline chat timeline, this panel's + OWN model picker + routing toggle, an instruction box, and an Apply/ + Discard confirmation bar for the proposed edit. + + Args: + ctx: ``AppContext``. + preview: ``document_preview_manager.py::DocumentPreviewManager`` — + every read/write of the actual file content goes through it. + cowork: the shared Cowork tab (optional) — its recent messages are + included as background context for the edit. + """ + + status_message = Signal(str) + badge_changed = Signal(str) # "" | " ⏳" | " ✓" — the shell mirrors this onto its toggle button + + def __init__(self, ctx, preview, cowork=None, parent=None): + super().__init__(parent) + self.ctx = ctx + self.preview = preview + self._cowork = cowork + self._ai_queue: List[str] = [] + self.pipeline = AiEditPipeline(self) + self.resolver: Optional[AiEditModelResolver] = None # built after ai_model_combo exists + + preview.ai_reset_requested.connect(self.reset_conversation) + preview.status_message.connect(self.status_message.emit) + + v = QVBoxLayout(self) + v.setContentsMargins(6, 0, 0, 0) + v.setSpacing(4) + title_row = QHBoxLayout() + self._ai_title = QLabel(tr("folder.ai_edit")) + self._ai_title.setStyleSheet("font-weight:600;") + title_row.addWidget(self._ai_title) + title_row.addStretch(1) + self._ai_status = QLabel("") + self._ai_status.setObjectName("hint") + title_row.addWidget(self._ai_status) + v.addLayout(title_row) + self.ai_chat = ChatView() + v.addWidget(self.ai_chat, 1) + + model_row = QHBoxLayout() + self._ai_model_lbl = QLabel(tr("folder.ai_model_label")) + self._ai_model_lbl.setObjectName("hint") + model_row.addWidget(self._ai_model_lbl) + self.ai_model_combo = QComboBox() + self.ai_model_combo.addItem(tr("folder.ai_model_auto"), None) + model_row.addWidget(self.ai_model_combo, 1) + from cowork_local.ui.routing_toggle import RoutingToggle + self.ai_routing_toggle = RoutingToggle(self.ctx, "ai_edit") + model_row.addWidget(self.ai_routing_toggle) + v.addLayout(model_row) + self.resolver = AiEditModelResolver( + ctx, self.ai_model_combo, self.ai_chat.add_status, self._confirm_routing_switch) + + row = QHBoxLayout() + self.ai_input = QLineEdit() + self.ai_input.setPlaceholderText(tr("folder.ai_placeholder")) + self.ai_input.returnPressed.connect(self._ai_send) + row.addWidget(self.ai_input, 1) + self.ai_send_btn = QPushButton(tr("folder.ai_send")) + self.ai_send_btn.setObjectName("primary") + self.ai_send_btn.clicked.connect(self._ai_send) + row.addWidget(self.ai_send_btn) + v.addLayout(row) + + self._ai_confirm_row = QWidget() + cf = QHBoxLayout(self._ai_confirm_row) + cf.setContentsMargins(0, 0, 0, 0) + cf.addStretch(1) + self._ai_discard_btn = QPushButton(tr("folder.ai_discard")) + self._ai_discard_btn.clicked.connect(self.pipeline.discard) + cf.addWidget(self._ai_discard_btn) + self._ai_apply_btn = QPushButton(tr("folder.ai_apply")) + self._ai_apply_btn.setObjectName("primary") + self._ai_apply_btn.clicked.connect(self.pipeline.apply) + cf.addWidget(self._ai_apply_btn) + self._ai_confirm_row.setVisible(False) + v.addWidget(self._ai_confirm_row) + + on_language_changed(self.retranslate) + self.retranslate() + + def retranslate(self) -> None: + self._ai_title.setText(tr("folder.ai_edit")) + self.ai_input.setPlaceholderText(tr("folder.ai_placeholder")) + self.ai_send_btn.setText(tr("folder.ai_send")) + self._ai_model_lbl.setText(tr("folder.ai_model_label")) + if self.ai_model_combo.count() >= 1 and self.ai_model_combo.itemData(0) is None: + self.ai_model_combo.setItemText(0, tr("folder.ai_model_auto")) + self._ai_apply_btn.setText(tr("folder.ai_apply")) + self._ai_discard_btn.setText(tr("folder.ai_discard")) + + # ---- called by the shell (header button, splitter owner) --------------- # + def on_opened(self) -> None: + """The shell's AI toggle button was just checked ON.""" + self.ai_input.setFocus() + if self.resolver.should_refresh(): + self.resolver.refresh() + if self.pipeline.worker is None: + self.badge_changed.emit("") + self._ai_status.setText("") + + def reset_conversation(self) -> None: + """Clear the AI-edit chat so each file starts a clean conversation. A + run in progress (editing the previous file) is left untouched — the + reset applies the next time a file is opened while idle.""" + if self.pipeline.worker is not None: + return + self.ai_chat.clear() + self.badge_changed.emit("") + self.pipeline.pending = None + self._ai_confirm_row.setVisible(False) + self._ai_status.setText("") + + def cowork_context(self) -> str: + """The whole Cowork conversation (recent turns) as background context.""" + cw = self._cowork + msgs = getattr(cw, "messages", None) if cw is not None else None + if not msgs: + return "" + lines = [f"{m['role']}: {str(m['content'])[:1000]}" + for m in msgs if m.get("role") in ("user", "assistant") and m.get("content")] + return "\n".join(lines[-12:]) + + # ---- send / queue -------------------------------------------------------- # + def _ai_send(self) -> None: + if not self.preview.root: + self.ai_chat.add_error(tr("folder.ai_no_file")) + return + instruction = self.ai_input.text().strip() + if not instruction: + return + self.ai_input.clear() + self.ai_chat.add_user(instruction) + # QUEUE: while a run is active OR a proposal is awaiting Apply/Discard, + # hold the new instruction and run it once the pipeline goes idle. + if self.pipeline.worker is not None or self.pipeline.pending is not None: + self._ai_queue.append(instruction) + self.ai_chat.add_status(tr("folder.ai_queued", n=len(self._ai_queue))) + self._update_queue_status() + return + self.pipeline.start(instruction) + + def _update_queue_status(self) -> None: + n = len(self._ai_queue) + if n: + self._ai_status.setText("⏳ " + tr("folder.ai_status_running") + + " · " + tr("folder.ai_queue_count", n=n)) + self._ai_status.setStyleSheet(f"color:{current_palette().accent};") + + def maybe_dequeue(self) -> None: + """When the pipeline is fully idle, start the next queued instruction.""" + if self.pipeline.worker is not None or self.pipeline.pending is not None: + return + if not self._ai_queue: + return + nxt = self._ai_queue.pop(0) + self._update_queue_status() + self.pipeline.start(nxt) + + # ---- pipeline callbacks (see ai_edit_pipeline.py) ------------------------- # + def set_busy(self, busy: bool) -> None: + self.ai_input.setEnabled(not busy) + self.ai_send_btn.setEnabled(not busy) + if busy: + self._ai_status.setText("⏳ " + tr("folder.ai_status_running")) + self._ai_status.setStyleSheet(f"color:{current_palette().accent};") + self.badge_changed.emit(" ⏳") # visible even when collapsed + else: + self._ai_status.setText("") + self.badge_changed.emit("") + + def flag_done(self) -> None: + """After a background run, show a 'done' badge so the user notices + the result when they return to the tab; cleared on reopen. If more + instructions are queued, start the next one instead.""" + if self.pipeline.worker is None and self.pipeline.pending is None and self._ai_queue: + self.maybe_dequeue() + return + self._ai_status.setText("✓ " + tr("folder.ai_status_done")) + self._ai_status.setStyleSheet(f"color:{current_palette().success};") + self.badge_changed.emit(" ✓") + + def show_confirm_row(self, visible: bool) -> None: + self._ai_confirm_row.setVisible(visible) + + def set_review_status(self, text: str, color) -> None: + self._ai_status.setText(text) + if color: + self._ai_status.setStyleSheet(f"color:{color};") + + def _confirm_routing_switch(self, decision) -> bool: + """Manual mode: ask before moving this AI-Edit run to another model.""" + from cowork_local.ui.routing_toggle import confirm_switch + + timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60) + return bool(confirm_switch(self, decision, timeout)) + + +__all__ = ["AiFileEditorDialog"] diff --git a/presentation/folder/code_editor.py b/presentation/folder/code_editor.py new file mode 100644 index 0000000..0f5de3e --- /dev/null +++ b/presentation/folder/code_editor.py @@ -0,0 +1,190 @@ +"""CodeEditor — the VS-Code-style code/text editor widget (R08-T12, split +out of ``document_preview_manager.py`` to keep that file under the 400-line +cap; originally ``ui/folder_tab.py``, lines 61-236 of the original +1587-line file: the Pygments token-colour helper, ``PygmentsHighlighter``, +``_LineNumbers``, ``CodeEditor``). +""" +from __future__ import annotations + +from PySide6.QtCore import QRect, QSize, Qt, QTimer +from PySide6.QtGui import QColor, QFont, QPainter, QSyntaxHighlighter, QTextCharFormat +from PySide6.QtWidgets import QPlainTextEdit, QWidget + +from cowork_local.theme import current_palette + +_MAX_HIGHLIGHT_CHARS = 300_000 # skip colouring huge files (keeps typing snappy) + + +# ── VS-Code-Dark+-ish token palette ──────────────────────────────────────── +def _fmt(color: str, *, italic: bool = False, bold: bool = False) -> QTextCharFormat: + f = QTextCharFormat() + f.setForeground(QColor(color)) + if italic: + f.setFontItalic(True) + if bold: + f.setFontWeight(QFont.Bold) + return f + + +class PygmentsHighlighter(QSyntaxHighlighter): + """Colour the whole document with Pygments and apply per-block. Re-lexes the + full text (debounced) so multi-line strings/comments colour correctly.""" + + def __init__(self, document): + super().__init__(document) + from pygments.lexers.special import TextLexer + self._lexer = TextLexer(stripnl=False) + self._ranges: list[tuple[int, int, QTextCharFormat]] = [] + self._rules = self._build_rules() + self._timer = QTimer(self) + self._timer.setSingleShot(True) + self._timer.setInterval(250) + self._timer.timeout.connect(self._retokenize) + document.contentsChanged.connect(self._timer.start) + + @staticmethod + def _build_rules(): + from pygments.token import ( + Comment, Error, Keyword, Name, Number, Operator, Punctuation, String, + ) + p = current_palette() + return [ + (Comment, _fmt(p.code_comment, italic=True)), + (Keyword.Type, _fmt(p.code_type)), + (Keyword, _fmt(p.code_keyword)), + (Name.Function, _fmt(p.code_func)), + (Name.Class, _fmt(p.code_type)), + (Name.Decorator, _fmt(p.code_func)), + (Name.Builtin, _fmt(p.code_type)), + (Name.Tag, _fmt(p.code_keyword)), + (Name.Attribute, _fmt(p.code_attr)), + (String.Doc, _fmt(p.code_comment, italic=True)), + (String, _fmt(p.code_string)), + (Number, _fmt(p.code_number)), + (Operator, _fmt(p.code_fg)), + (Punctuation, _fmt(p.code_fg)), + (Error, _fmt(p.code_error)), + ] + + def set_filename(self, filename: str, text: str = "") -> None: + from pygments.lexers import get_lexer_for_filename, guess_lexer + from pygments.lexers.special import TextLexer + from pygments.util import ClassNotFound + try: + self._lexer = get_lexer_for_filename(filename, stripnl=False) + except ClassNotFound: + try: + self._lexer = guess_lexer(text) if text.strip() else TextLexer() + except ClassNotFound: + self._lexer = TextLexer(stripnl=False) + self._retokenize() + + def _fmt_for(self, tok): + for ttype, fmt in self._rules: + if tok in ttype: + return fmt + return None + + def _retokenize(self) -> None: + from pygments import lex + text = self.document().toPlainText() + self._ranges = [] + if len(text) <= _MAX_HIGHLIGHT_CHARS: + pos = 0 + for tok, val in lex(text, self._lexer): + fmt = self._fmt_for(tok) + if fmt is not None and val: + self._ranges.append((pos, pos + len(val), fmt)) + pos += len(val) + self.rehighlight() + + def highlightBlock(self, text: str) -> None: # noqa: N802 - Qt override + if not self._ranges: + return + bstart = self.currentBlock().position() + bend = bstart + len(text) + for start, end, fmt in self._ranges: + if end <= bstart or start >= bend: + continue + s = max(start, bstart) - bstart + e = min(end, bend) - bstart + if e > s: + self.setFormat(s, e - s, fmt) + + +class _LineNumbers(QWidget): + def __init__(self, editor): + super().__init__(editor) + self._editor = editor + + def sizeHint(self) -> QSize: + return QSize(self._editor.line_number_width(), 0) + + def paintEvent(self, event): # noqa: N802 + self._editor.paint_line_numbers(event) + + +class CodeEditor(QPlainTextEdit): + """A dark, monospaced editor with a line-number gutter + Pygments colouring — + the Sublime/VS-Code look for viewing & editing source files.""" + + def __init__(self): + super().__init__() + self.setObjectName("codeEditor") + self.setLineWrapMode(QPlainTextEdit.NoWrap) + self.setTabStopDistance(4 * self.fontMetrics().horizontalAdvance(" ")) + font = QFont("Consolas") + font.setStyleHint(QFont.Monospace) + font.setPointSize(10) + self.setFont(font) + self._gutter = _LineNumbers(self) + self.blockCountChanged.connect(lambda _=0: self._update_gutter_width()) + self.updateRequest.connect(self._on_update_request) + self._highlighter = PygmentsHighlighter(self.document()) + self._update_gutter_width() + + def line_number_width(self) -> int: + digits = max(2, len(str(max(1, self.blockCount())))) + return 12 + self.fontMetrics().horizontalAdvance("9") * digits + + def _update_gutter_width(self) -> None: + self.setViewportMargins(self.line_number_width(), 0, 0, 0) + + def _on_update_request(self, rect, dy: int) -> None: + if dy: + self._gutter.scroll(0, dy) + else: + self._gutter.update(0, rect.y(), self._gutter.width(), rect.height()) + if rect.contains(self.viewport().rect()): + self._update_gutter_width() + + def resizeEvent(self, event): # noqa: N802 + super().resizeEvent(event) + cr = self.contentsRect() + self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height())) + + def paint_line_numbers(self, event) -> None: + p = current_palette() + painter = QPainter(self._gutter) + painter.fillRect(event.rect(), QColor(p.code_gutter_bg)) + block = self.firstVisibleBlock() + num = block.blockNumber() + top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() + bottom = top + self.blockBoundingRect(block).height() + painter.setPen(QColor(p.code_gutter_fg)) + while block.isValid() and top <= event.rect().bottom(): + if block.isVisible() and bottom >= event.rect().top(): + painter.drawText(0, int(top), self._gutter.width() - 6, + self.fontMetrics().height(), Qt.AlignRight, + str(num + 1)) + block = block.next() + top = bottom + bottom = top + self.blockBoundingRect(block).height() + num += 1 + + def load_file(self, path: str, text: str) -> None: + self.setPlainText(text) + self._highlighter.set_filename(path, text) + + +__all__ = ["CodeEditor", "PygmentsHighlighter"] diff --git a/presentation/folder/document_preview_manager.py b/presentation/folder/document_preview_manager.py new file mode 100644 index 0000000..a1af89e --- /dev/null +++ b/presentation/folder/document_preview_manager.py @@ -0,0 +1,304 @@ +"""DocumentPreviewManager — the view/edit pane of the Folder Explorer +(R08-T12, extracted from ``ui/folder_tab.py::FolderTab``, lines 295-373/ +410-449/649-699 of the original 1587-line file: the preview +``QStackedWidget`` + open/save/create/external dispatch. HTML/PPTX/Excel/ +PDF/office rendering lives in ``office_document_renderer.py``; the code +editor widget lives in ``code_editor.py`` — both split out to keep this file +under the 400-line cap. + +**Closes the R06-T05 loop**: ``application/workspaces/file_workspace_service. +py::FileWorkspaceService`` existed since R06 but had zero production call +sites (confirmed by grep before this task — ``ui/folder_tab.py`` wrote files +with raw ``Path.write_text`` instead). Every plain-text write this class does +(``save``, ``create_new_file``, ``write_content``) now goes through it — +same path-containment check, same auto ``mkdir``, and (new, from +``infrastructure/filesystem/file_tools.py::write_file``) a Python-syntax +warning on a bad ``.py`` write, which the original code never had. A ``.pptx`` +save still goes through ``core/pptx_edit.py`` directly — that's a binary +package build, not a text write, and ``FileWorkspaceService`` has no opinion +on it. +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QHBoxLayout, QLabel, QPushButton, QScrollArea, QStackedWidget, + QTextBrowser, QVBoxLayout, QWidget, +) + +from cowork_local.application.workspaces import FileWorkspaceService +from cowork_local.application.workspaces.file_preview_helpers import ( + is_probably_text, pptx_available, read_text, +) +from cowork_local.domain.workspaces.workspace_session import WorkspaceSession +from cowork_local.i18n import tr +from cowork_local.presentation.folder.code_editor import CodeEditor +from cowork_local.presentation.folder.office_document_renderer import OfficeDocumentRenderer +from cowork_local.ui.icons import icon +from cowork_local.ui.libreoffice_view import DOC_SUFFIXES + +_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico"} +_HTML_SUFFIXES = {".html", ".htm"} +_PPTX_SUFFIXES = {".pptx"} # text-editable in-app via python-pptx (no PowerPoint) +_EXCEL_SUFFIXES = {".xlsx", ".xlsm"} # viewed as a real table via openpyxl (no LibreOffice) +_MAX_EDIT_BYTES = 2_000_000 # above this, view read-only / note only + + +class DocumentPreviewManager(QWidget): + """View/edit pane: header (file name, Preview⇄Edit toggle, Save, Open + externally) above a ``QStackedWidget`` that renders whichever preview a + file's suffix calls for.""" + + status_message = Signal(str) + ai_reset_requested = Signal() # a DIFFERENT file was opened by the user + + def __init__(self, root: str, parent=None): + super().__init__(parent) + self._root = root + self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root))) + self._office = OfficeDocumentRenderer(self) + self._edit_kind: Optional[str] = None # None | "html" | "pptx" + self._current_file: Optional[str] = None + + rl = QVBoxLayout(self) + rl.setContentsMargins(0, 0, 0, 0) + + hdr = QHBoxLayout() + self.file_label = QLabel("") + self.file_label.setStyleSheet("font-weight:600;") + self.file_label.setWordWrap(True) + hdr.addWidget(self.file_label, 1) + self.mode_btn = QPushButton() # Preview⇄Edit toggle (HTML / PPTX) + self.mode_btn.setCheckable(True) + self.mode_btn.clicked.connect(self._office.toggle_edit_mode) + self.mode_btn.setVisible(False) + hdr.addWidget(self.mode_btn) + self.save_btn = QPushButton() + self.save_btn.setIcon(icon("save")) + self.save_btn.setObjectName("primary") + self.save_btn.clicked.connect(self.save) + self.save_btn.setVisible(False) + hdr.addWidget(self.save_btn) + self.ext_btn = QPushButton() + self.ext_btn.setIcon(icon("upload")) + self.ext_btn.clicked.connect(self.open_external) + self.ext_btn.setVisible(False) + hdr.addWidget(self.ext_btn) + rl.addLayout(hdr) + # Exposed so the shell can insert its own AI-panel toggle button into + # this same header row (between mode_btn and save_btn, matching the + # original single-class layout) without this class knowing the AI + # panel exists. + self.header_layout = hdr + + self.stack = QStackedWidget() + self._placeholder = QLabel("") + self._placeholder.setObjectName("hint") + self._placeholder.setAlignment(Qt.AlignCenter) + self.stack.addWidget(self._placeholder) # 0 + + self.editor = CodeEditor() # 1 + self.stack.addWidget(self.editor) + + self.web = QTextBrowser() # 2 + self.web.setOpenExternalLinks(True) + self.stack.addWidget(self.web) + + self.doc_view = QTextBrowser() # 3 + self.doc_view.setObjectName("docPreview") + self.stack.addWidget(self.doc_view) + + self._img_scroll = QScrollArea() # 4 + self._img_scroll.setWidgetResizable(True) + self._img_label = QLabel("") + self._img_label.setAlignment(Qt.AlignCenter) + self._img_scroll.setWidget(self._img_label) + self.stack.addWidget(self._img_scroll) + + rl.addWidget(self.stack, 1) + self.retranslate() + + def retranslate(self) -> None: + self.save_btn.setText(tr("folder.save")) + self.ext_btn.setText(tr("folder.open_external")) + if not self._current_file: + self._placeholder.setText(tr("folder.select_file")) + self._retranslate_mode_btn() + + def _retranslate_mode_btn(self) -> None: + self.mode_btn.setText(tr("folder.edit") if not self.mode_btn.isChecked() + else tr("folder.preview")) + + # ---- public API used by the shell / AI panel --------------------------- # + @property + def current_file(self) -> Optional[str]: + return self._current_file + + @property + def edit_kind(self) -> Optional[str]: + return self._edit_kind + + @property + def root(self) -> str: + return self._root + + def set_root(self, root: str) -> None: + self._root = root + self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root))) + + def open_file(self, path: str, reset_ai: bool = True) -> None: + # Switching to a DIFFERENT file starts a fresh AI-edit conversation + # (reset_ai=False when the AI itself just CREATED this file — keep + # that chat). Whether/how to reset is the AI panel's own business — + # this class only announces that a genuine file switch happened. + if reset_ai and path != self._current_file: + self.ai_reset_requested.emit() + self._current_file = path + self.file_label.setText(path) + suffix = Path(path).suffix.lower() + self.mode_btn.setVisible(False) + self.save_btn.setVisible(False) + self.ext_btn.setVisible(False) + self._edit_kind = None + try: + size = os.path.getsize(path) + except OSError: + size = 0 + + if suffix in _IMAGE_SUFFIXES: + self._show_image(path) + elif suffix in _HTML_SUFFIXES: + self._office.show_html(path, mode_preview=True) + elif suffix in _PPTX_SUFFIXES and pptx_available(): + self._office.show_pptx(path, mode_preview=True) + elif suffix in _EXCEL_SUFFIXES: + self._office.show_excel(path) + elif suffix in DOC_SUFFIXES: + self._office.show_document(path) + elif size > _MAX_EDIT_BYTES or not is_probably_text(path): + self._show_binary(path) + else: + self._show_code(path) + + def ensure_editable_for_ai(self) -> bool: + """Make the current file editable in the code editor (switching an + HTML preview to edit, or loading a text file). Returns False when + there's no file open or it isn't a text/code file.""" + path = self._current_file + if not path or not os.path.isfile(path): + return False + suffix = Path(path).suffix.lower() + if suffix in _HTML_SUFFIXES: + self._office.show_html(path, mode_preview=False) + return True + if suffix in _PPTX_SUFFIXES and pptx_available(): + self._office.show_pptx(path, mode_preview=False) + return True + if suffix in DOC_SUFFIXES or suffix in _IMAGE_SUFFIXES: + return False + if is_probably_text(path): + self._show_code(path) + return True + return False + + def save(self) -> None: + if not self._current_file: + return + try: + if self._edit_kind == "pptx": + if not self._office.write_pptx(self.editor.toPlainText()): + return + else: + self._write_plain_text(self._current_file, self.editor.toPlainText()) + self.status_message.emit(tr("folder.saved", name=Path(self._current_file).name)) + except Exception as exc: # noqa: BLE001 + self.status_message.emit(tr("folder.save_error", err=str(exc))) + + def write_content(self, content: str, skip_image_confirm: bool = False) -> None: + """Persist AI-confirmed content to disk AND refresh the preview. + pptx text is written back into the deck (no PowerPoint window).""" + if not self._current_file: + return + try: + if self._edit_kind == "pptx": + if not self._office.write_pptx(content, skip_confirm=skip_image_confirm): + return + else: + self._write_plain_text(self._current_file, content) + except Exception as exc: # noqa: BLE001 + self.status_message.emit(tr("folder.save_error", err=str(exc))) + return + suffix = Path(self._current_file).suffix.lower() + if suffix in _HTML_SUFFIXES: + self._office.show_html(self._current_file, mode_preview=True) + elif suffix in _PPTX_SUFFIXES: + self._office.show_pptx(self._current_file, mode_preview=True) + + def create_new_file(self, target: str, content: str) -> Optional[str]: + """Create ``target`` (relative to the folder root) with ``content`` + and open it — like Cowork's save_file. Refuses paths escaping the + root (enforced by ``FileWorkspaceService``/``WorkspaceSession``).""" + root = os.path.normpath(self._root) + dest = target if os.path.isabs(target) else os.path.join(root, target) + dest = os.path.normpath(dest) + try: + if Path(dest).suffix.lower() in _PPTX_SUFFIXES and pptx_available(): + # A .pptx is a binary package — build a real deck from the + # marker text (writing text straight to .pptx would corrupt it). + from cowork_local.core import pptx_edit + os.makedirs(os.path.dirname(dest) or root, exist_ok=True) + pptx_edit.create_pptx_from_text(dest, content) + else: + self._write_plain_text(dest, content) + except Exception as exc: # noqa: BLE001 - OS error, containment error, or pptx build failure + self.status_message.emit(tr("folder.save_error", err=str(exc))) + return None + self.open_file(dest, reset_ai=False) # show the new file; keep the AI chat + return dest + + def open_external(self) -> None: + if self._current_file: + from cowork_local.ui.osutil import open_location + open_location(self._current_file) + + # ---- writes ------------------------------------------------------------- # + def _write_plain_text(self, path: str, content: str) -> None: + """Write ``content`` to ``path`` (must resolve inside the current + root) via ``FileWorkspaceService`` — same containment check, ``mkdir`` + and Python-syntax warning the agent's own ``write_file`` tool gets.""" + rel = os.path.relpath(path, self._root) + result = self._file_service.write_file(rel, content) + if not result.get("ok"): + raise OSError(result.get("output") or "write failed") + + # ---- simple renderers (HTML/PPTX/Excel/PDF/office live in + # office_document_renderer.py) --------------------------------------------- # + def _show_code(self, path: str) -> None: + text = read_text(path) + self.editor.setReadOnly(False) + self.editor.load_file(path, text) + self.save_btn.setVisible(True) + self.stack.setCurrentWidget(self.editor) + + def _show_image(self, path: str) -> None: + from PySide6.QtGui import QPixmap + pix = QPixmap(path) + if pix.isNull(): + self._show_binary(path) + return + self._img_label.setPixmap(pix) + self._img_label.resize(pix.size()) + self.ext_btn.setVisible(True) + self.stack.setCurrentWidget(self._img_scroll) + + def _show_binary(self, path: str) -> None: + self._placeholder.setText(tr("folder.binary_file")) + self.ext_btn.setVisible(True) + self.stack.setCurrentWidget(self._placeholder) + + +__all__ = ["DocumentPreviewManager"] diff --git a/presentation/folder/folder_tab.py b/presentation/folder/folder_tab.py new file mode 100644 index 0000000..65714ab --- /dev/null +++ b/presentation/folder/folder_tab.py @@ -0,0 +1,120 @@ +"""FolderTab shell (R08-T12) — assembles +``workspace_file_tree.py::WorkspaceFileTree``, +``document_preview_manager.py::DocumentPreviewManager`` and +``ai_file_editor_dialog.py::AiFileEditorDialog`` behind the splitter/terminal +layout that used to be inline in ``ui/folder_tab.py::FolderTab.__init__`` +(lines 238-384 of the original 1587-line file). + +The AI-panel toggle button (``ai_btn``) lives here because it controls +things two different children own: the panel's own visibility AND the +content splitter's sizing — a genuine shell-level concern, not either +child's. +""" +from __future__ import annotations + +from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSplitter, QVBoxLayout, QWidget +from PySide6.QtCore import Qt, Signal + +from cowork_local.i18n import on_language_changed, tr +from cowork_local.presentation.folder.ai_file_editor_dialog import AiFileEditorDialog +from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager +from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree +from cowork_local.state import AppContext +from cowork_local.ui.icons import icon + + +class FolderTab(QWidget): + """Two-pane file explorer: directory tree + view/edit pane (+ collapsible + AI-edit panel, + collapsible terminal).""" + + status_message = Signal(str) + + def __init__(self, ctx: AppContext, cowork=None): + super().__init__() + self.ctx = ctx + self._root = str(ctx.config.cowork_output_dir()) + + root_layout = QVBoxLayout(self) + split = QSplitter(Qt.Horizontal) + + self.tree = WorkspaceFileTree(self._root) + split.addWidget(self.tree) + + right = QWidget() + rl = QVBoxLayout(right) + rl.setContentsMargins(0, 0, 0, 0) + + self.preview = DocumentPreviewManager(self._root) + self.ai_panel = AiFileEditorDialog(ctx, self.preview, cowork=cowork) + + self.ai_btn = QPushButton() # expand/collapse the AI-edit panel + self.ai_btn.setIcon(icon("sparkle")) + self.ai_btn.setCheckable(True) + self.ai_btn.clicked.connect(self._toggle_ai_panel) + # Same visual position as the original single-class header: between + # the Preview⇄Edit toggle and Save (file_label=0, mode_btn=1). + self.preview.header_layout.insertWidget(2, self.ai_btn) + self.ai_panel.badge_changed.connect(self._on_ai_badge_changed) + + content_split = QSplitter(Qt.Horizontal) + content_split.addWidget(self.preview) + content_split.addWidget(self.ai_panel) + content_split.setStretchFactor(0, 1) + content_split.setStretchFactor(1, 0) + content_split.setSizes([700, 320]) + self._content_split = content_split + self.ai_panel.setVisible(False) # default collapsed + rl.addWidget(content_split, 1) + + split.addWidget(right) + split.setStretchFactor(0, 0) + split.setStretchFactor(1, 1) + split.setSizes([300, 800]) + root_layout.addWidget(split, 1) + + # Terminal CLI below the file view — collapsible, default collapsed; + # opening it points the shell at the current workspace folder. + from cowork_local.ui.terminal_panel import TerminalPanel + + self.terminal = TerminalPanel() + self.terminal.set_cwd(self._root) + self.terminal.expanded.connect(lambda: self.terminal.set_cwd(self._root)) + root_layout.addWidget(self.terminal) + + self.tree.file_selected.connect(self.preview.open_file) + self.preview.status_message.connect(self.status_message.emit) + self.ai_panel.status_message.connect(self.status_message.emit) + + on_language_changed(self._retranslate) + self._retranslate() + + # ---- public API --------------------------------------------------------- + def set_root(self, path: str) -> None: + self.tree.set_root(path) + # WorkspaceFileTree silently no-ops on an invalid path (same guard + # the original single-class _root setter had) — mirror that here by + # only propagating when the tree actually accepted it. + if self.tree.root == path: + self._root = path + self.preview.set_root(path) + self.terminal.set_cwd(path) + + def _toggle_ai_panel(self) -> None: + show = self.ai_btn.isChecked() + self.ai_panel.setVisible(show) + if show: + self._content_split.setSizes([700, 320]) + self.ai_panel.on_opened() + + def _on_ai_badge_changed(self, suffix: str) -> None: + self.ai_btn.setText(tr("folder.ai_edit") + suffix) + + def _retranslate(self) -> None: + self.tree.retranslate() + self.preview.retranslate() + self.ai_panel.retranslate() + self.ai_btn.setText(tr("folder.ai_edit")) + self.ai_btn.setToolTip(tr("folder.ai_edit_tooltip")) + + +__all__ = ["FolderTab"] diff --git a/presentation/folder/office_document_renderer.py b/presentation/folder/office_document_renderer.py new file mode 100644 index 0000000..c9494ab --- /dev/null +++ b/presentation/folder/office_document_renderer.py @@ -0,0 +1,269 @@ +"""OfficeDocumentRenderer — HTML/PPTX/Excel/PDF/office-doc preview for +``document_preview_manager.py`` (R08-T12, split out to keep that file under +the 400-line cap; originally ``ui/folder_tab.py``, lines 451-647/679-694 of +the original 1587-line file). + +A plain (non-Qt-widget) helper composed BY a ``DocumentPreviewManager`` +rather than a widget of its own: these renderers are tightly coupled to the +manager's shared ``QStackedWidget``/toolbar/editor — genuinely one screen's +internal state, not an independent concern — so this is a composition split +to respect the line-count cap, the same way +``presentation/scheduling/kanban_board_widget.py`` composes +``TaskApplicationService`` rather than owning that logic inline. +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + +from PySide6.QtWidgets import QTabWidget, QTableWidget, QTableWidgetItem + +from cowork_local.application.workspaces.file_preview_helpers import read_text +from cowork_local.core.worker import AgentWorker +from cowork_local.i18n import tr +from cowork_local.presentation.shared import HAS_WEB_ENGINE + +try: + from PySide6.QtPdf import QPdfDocument # noqa: F401 + from PySide6.QtPdfWidgets import QPdfView # noqa: F401 + HAS_PDF = True +except Exception: # pragma: no cover - QtPdf not bundled + HAS_PDF = False + + +class OfficeDocumentRenderer: + """Renders HTML/PPTX/Excel/PDF/office docs into ``owner.stack``. + + ``owner`` is the ``DocumentPreviewManager`` — this class reaches into + ``owner.stack``/``owner.editor``/``owner.mode_btn``/``owner.ext_btn``/ + ``owner.save_btn``/``owner.doc_view``/``owner.web`` because those widgets + are shared with the manager's simpler renderers (code/image/binary); + duplicating them here would mean two stacked widgets fighting over which + one is "the" preview. + """ + + def __init__(self, owner) -> None: + self._owner = owner + self._engine = None + self._pdf_view = None + self._pdf_doc = None + self._pdf_tmp: Optional[str] = None + self._pdf_cache: dict = {} + self._convert_worker = None + self._xlsx_view = None + + def show_html(self, path: str, mode_preview: bool) -> None: + o = self._owner + o._edit_kind = "html" + o.mode_btn.setVisible(True) + o.mode_btn.setChecked(not mode_preview) # checked = Edit + o._retranslate_mode_btn() + if mode_preview: + from PySide6.QtCore import QUrl + html = read_text(path) + engine = self._ensure_engine() + if engine is not None: + engine.setHtml(html, QUrl.fromLocalFile(path)) + o.stack.setCurrentWidget(engine) + else: + o.web.setHtml(html) + o.stack.setCurrentWidget(o.web) + o.save_btn.setVisible(False) + else: + o._show_code(path) + + def show_pptx(self, path: str, mode_preview: bool) -> None: + """PPTX: Preview renders the slides (PDF via LibreOffice); Edit shows the + deck's text (marker-delimited per box) in the editor.""" + o = self._owner + o._edit_kind = "pptx" + o.mode_btn.setVisible(True) + o.mode_btn.setChecked(not mode_preview) # checked = Edit + o._retranslate_mode_btn() + o.ext_btn.setVisible(True) + if mode_preview: + self.show_document(path) # PDF render of the slides + o.mode_btn.setVisible(True) # show_document doesn't touch it + else: + from cowork_local.core.pptx_edit import pptx_to_text + try: + text = pptx_to_text(path) + except Exception as exc: # noqa: BLE001 + text = f"[could not read pptx text: {exc}]" + o.editor.setReadOnly(False) + o.editor.load_file(path + ".txt", text) # .txt → plain highlighting + o.save_btn.setVisible(True) + o.stack.setCurrentWidget(o.editor) + + def _ensure_engine(self): + """Create the QWebEngineView on first HTML preview (only when WebEngine + is safe to use); otherwise stay on the QTextBrowser fallback.""" + if not HAS_WEB_ENGINE: + return None + if self._engine is None: + try: + from PySide6.QtWebEngineWidgets import QWebEngineView + self._engine = QWebEngineView() + self._owner.stack.addWidget(self._engine) + except Exception: # noqa: BLE001 + self._engine = None + return self._engine + + def toggle_edit_mode(self) -> None: + o = self._owner + if not o.current_file: + return + preview = not o.mode_btn.isChecked() # checked = Edit + if o._edit_kind == "pptx": + self.show_pptx(o.current_file, mode_preview=preview) + else: + self.show_html(o.current_file, mode_preview=preview) + + def show_excel(self, path: str) -> None: + """View a spreadsheet as a real TABLE (openpyxl) — one tab per sheet.""" + o = self._owner + o.ext_btn.setVisible(True) + try: + from cowork_local.core.deps import ensure_module + ensure_module("openpyxl", "openpyxl") + from openpyxl import load_workbook + wb = load_workbook(path, read_only=True, data_only=True) + except Exception: # noqa: BLE001 - no openpyxl / unreadable → try PDF/text + self.show_document(path) + return + MAX_ROWS, MAX_COLS = 2000, 100 + if self._xlsx_view is None: + self._xlsx_view = QTabWidget() + o.stack.addWidget(self._xlsx_view) + tabs = self._xlsx_view + while tabs.count(): + w = tabs.widget(0); tabs.removeTab(0); w.deleteLater() + try: + for ws in wb.worksheets: + rows = list(ws.iter_rows(max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True)) + ncols = max((len(r) for r in rows), default=0) + table = QTableWidget(len(rows), ncols) + table.setEditTriggers(QTableWidget.NoEditTriggers) + table.horizontalHeader().setVisible(False) + for r, row in enumerate(rows): + for c, val in enumerate(row): + if val is not None: + table.setItem(r, c, QTableWidgetItem(str(val))) + table.resizeColumnsToContents() + title = ws.title + (" (…)" if (ws.max_row or 0) > MAX_ROWS + or (ws.max_column or 0) > MAX_COLS else "") + tabs.addTab(table, title) + finally: + wb.close() + if tabs.count() == 0: + self.show_document(path) + return + o.stack.setCurrentWidget(tabs) + + def show_document(self, path: str) -> None: + """Office docs + PDF are RENDERED via QtPdf — LibreOffice converts + them to PDF first. Falls back to text extraction when QtPdf/ + LibreOffice aren't available.""" + o = self._owner + o.ext_btn.setVisible(True) + suffix = Path(path).suffix.lower() + if not HAS_PDF: + self.show_document_text(path) + return + if suffix == ".pdf": + self._render_pdf(path) + return + try: + mtime = os.path.getmtime(path) + except OSError: + mtime = 0 + cached = self._pdf_cache.get((path, mtime)) + if cached and os.path.exists(cached): + self._render_pdf(cached) + return + from cowork_local.core.doc_extract import convert_to_pdf, find_soffice + if not find_soffice() and os.name != "nt": + self.show_document_text(path) + return + o.doc_view.setPlainText(tr("folder.converting")) + o.stack.setCurrentWidget(o.doc_view) + if self._pdf_tmp is None: + import tempfile + self._pdf_tmp = tempfile.mkdtemp(prefix="cowork_folder_pdf_") + src, out_dir = path, self._pdf_tmp + + def job(worker): + return {"src": src, "mtime": mtime, "pdf": convert_to_pdf(src, out_dir)} + + def done(result): + if result.get("src") != o.current_file: + return # user moved on to another file + pdf = result.get("pdf") + if pdf: + self._pdf_cache[(result["src"], result["mtime"])] = pdf + self._render_pdf(pdf) + else: + self.show_document_text(src) + + worker = AgentWorker(job) + worker.finished_ok.connect(done) + worker.failed.connect(lambda _e, p=src: self.show_document_text(p)) + self._convert_worker = worker + worker.start() + + def _ensure_pdf_view(self): + if not HAS_PDF: + return None + if self._pdf_view is None: + from PySide6.QtPdf import QPdfDocument + from PySide6.QtPdfWidgets import QPdfView + self._pdf_doc = QPdfDocument(self._owner) + self._pdf_view = QPdfView(self._owner) + self._pdf_view.setDocument(self._pdf_doc) + try: + self._pdf_view.setPageMode(QPdfView.PageMode.MultiPage) + self._pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth) + except Exception: # noqa: BLE001 - enum names vary slightly across versions + pass + self._owner.stack.addWidget(self._pdf_view) + return self._pdf_view + + def _render_pdf(self, pdf_path: str) -> None: + view = self._ensure_pdf_view() + if view is None: + self.show_document_text(pdf_path) + return + self._pdf_doc.load(pdf_path) + self._owner.stack.setCurrentWidget(view) + + def show_document_text(self, path: str) -> None: + from cowork_local.core.doc_extract import extract_text + o = self._owner + try: + text, note = extract_text(path) + except Exception as exc: # noqa: BLE001 + text, note = None, str(exc) + body = text if text else tr("folder.doc_unreadable", note=note or "?") + o.doc_view.setPlainText(body) + o.stack.setCurrentWidget(o.doc_view) + + def write_pptx(self, content: str, skip_confirm: bool = False) -> bool: + """Write edited pptx text back into the deck. If the edit REPLACES any + image, ask the user to confirm first. ``skip_confirm`` is used when + the image was already confirmed (e.g. just generated). Returns False + if the user declined.""" + from cowork_local.core import pptx_edit + o = self._owner + if not skip_confirm and pptx_edit.image_change_requested(content): + from PySide6.QtWidgets import QMessageBox + ok = QMessageBox.question(o, tr("folder.ai_image_confirm_title"), + tr("folder.ai_image_confirm")) + if ok != QMessageBox.Yes: + o.status_message.emit(tr("folder.ai_image_declined")) + return False + pptx_edit.apply_text_to_pptx(o.current_file, content) + return True + + +__all__ = ["OfficeDocumentRenderer", "HAS_PDF"] diff --git a/presentation/folder/workspace_file_tree.py b/presentation/folder/workspace_file_tree.py new file mode 100644 index 0000000..b7c0f63 --- /dev/null +++ b/presentation/folder/workspace_file_tree.py @@ -0,0 +1,97 @@ +"""WorkspaceFileTree — the folder-picker bar + directory tree pane of the +Folder Explorer (R08-T12, extracted from ``ui/folder_tab.py::FolderTab``, +lines 264-293/386-408 of the original 1587-line file). + +Owns navigation only: which root is browsed and which file was clicked. +Rendering/editing the SELECTED file is +``document_preview_manager.py::DocumentPreviewManager``'s job — this widget +just emits :attr:`file_selected`. +""" +from __future__ import annotations + +import os + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QFileDialog, QFileSystemModel, QHBoxLayout, QLabel, QPushButton, + QTreeView, QVBoxLayout, QWidget, +) + +from cowork_local.i18n import tr +from cowork_local.ui.icons import icon + + +class WorkspaceFileTree(QWidget): + """The left-hand tree pane: a path bar (label + "open folder" button) + above a ``QFileSystemModel``-backed ``QTreeView``.""" + + file_selected = Signal(str) # absolute path of the clicked file + root_changed = Signal(str) # absolute path of the new root + + def __init__(self, initial_root: str, parent=None): + super().__init__(parent) + self._root = initial_root + + root_layout = QVBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + + # The path IS the title of this screen, so it is written as one + # rather than shown in a read-only text box that looks editable. + # Full path on hover; the button still opens the folder picker. + bar = QHBoxLayout() + self.path_lbl = QLabel(self._root) + self.path_lbl.setObjectName("folderTitle") + self.path_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.path_lbl.setToolTip(self._root) + self._open_btn = QPushButton() + self._open_btn.setIcon(icon("folder")) + self._open_btn.setObjectName("primary") + self._open_btn.clicked.connect(self._pick_root) + bar.addWidget(self.path_lbl, 1) + bar.addWidget(self._open_btn) + root_layout.addLayout(bar) + + self.model = QFileSystemModel() + self.model.setRootPath(self._root) + self.tree = QTreeView() + self.tree.setModel(self.model) + self.tree.setRootIndex(self.model.index(self._root)) + for col in (1, 2, 3): # hide Size / Type / Date-modified columns + self.tree.hideColumn(col) + self.tree.setHeaderHidden(True) + self.tree.clicked.connect(self._on_tree_clicked) + root_layout.addWidget(self.tree, 1) + + self.retranslate() + + def retranslate(self) -> None: + self._open_btn.setToolTip(tr("folder.path_placeholder")) + self._open_btn.setText(tr("folder.open_folder")) + + @property + def root(self) -> str: + return self._root + + def set_root(self, path: str) -> None: + p = str(path or "").strip() + if not p or not os.path.isdir(p): + return + self._root = p + self.path_lbl.setText(p) + self.path_lbl.setToolTip(p) + self.model.setRootPath(p) + self.tree.setRootIndex(self.model.index(p)) + self.root_changed.emit(p) + + def _pick_root(self) -> None: + chosen = QFileDialog.getExistingDirectory(self, tr("folder.open_folder"), self._root) + if chosen: + self.set_root(chosen) + + def _on_tree_clicked(self, index) -> None: + path = self.model.filePath(index) + if path and os.path.isfile(path): + self.file_selected.emit(path) + + +__all__ = ["WorkspaceFileTree"] diff --git a/presentation/graph/__init__.py b/presentation/graph/__init__.py new file mode 100644 index 0000000..6bed565 --- /dev/null +++ b/presentation/graph/__init__.py @@ -0,0 +1,3 @@ +"""GraphRAG (Structure) screen, split into single-responsibility widgets +(R08-T14): ``graph_scene_items``, ``graph_renderer``, ``graph_qa_widget``, +assembled by the ``structure_graph_view`` shell.""" diff --git a/presentation/graph/graph_messages_view.py b/presentation/graph/graph_messages_view.py new file mode 100644 index 0000000..1591fd8 --- /dev/null +++ b/presentation/graph/graph_messages_view.py @@ -0,0 +1,99 @@ +"""GraphMessagesView — the "Messages by day" tab of GraphRAG (R08-T14, split +out of ``graph_renderer.py`` to keep that file under the 400-line cap; +originally ``ui/structure_graph_view.py``, lines 427-497 of the original +1035-line file: ``_on_view_tab``, ``_toggle_messages``, ``_reload_messages``, +``_show_msg_json``). + +A plain (non-Qt-widget) helper composed BY ``GraphRenderer`` — same +composition-to-respect-the-line-cap pattern as +``office_document_renderer.py``. Owns the ``QTreeWidget`` itself (built +here, added to the owner's stack at construction) since nothing else needs +it, but reaches into ``owner._stack``/``owner.web``/``owner.view``/ +``owner.active_project_id`` to switch the shared stack and scope by project. +""" +from __future__ import annotations + +from collections import OrderedDict + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem + +from cowork_local.i18n import tr + + +class GraphMessagesView: + def __init__(self, owner) -> None: + self._owner = owner + self.widget = QTreeWidget() + self.widget.setHeaderHidden(True) + self.widget.itemClicked.connect(self._show_msg_json) + owner._stack.addWidget(self.widget) + + def on_view_tab(self, index: int) -> None: + """Tab 0 = graph, tab 1 = messages.""" + o = self._owner + if index == 1: + self.reload() + o._stack.setCurrentWidget(self.widget) + else: + o._stack.setCurrentWidget(o.web if o.web is not None else o.view) + + def toggle(self) -> None: + """Kept for callers that still ask for a flip (e.g. keyboard paths).""" + o = self._owner + showing = o._stack.currentWidget() is self.widget + o.view_tabs.setCurrentIndex(0 if showing else 1) + + def reload(self) -> None: + """Build the tree: day -> conversation. Click a conversation to see + its messages as JSON. Scoped to the current project's history.""" + from cowork_local.core.history import list_conversations + + o = self._owner + self.widget.clear() + pid = o.active_project_id or "" + by_day: "OrderedDict[str, list]" = OrderedDict() + try: + convs = list_conversations(o.ctx.config.history_dir()) + except Exception: # noqa: BLE001 + convs = [] + for conv in convs: + if pid and conv.get("project_id", "default") != pid: + continue + day = (conv.get("created") or "")[:10] or "—" + by_day.setdefault(day, []).append(conv) + if not by_day: + self.widget.addTopLevelItem(QTreeWidgetItem([tr("structure.msgs_none")])) + return + for day in sorted(by_day, reverse=True): + convs_d = by_day[day] + day_item = QTreeWidgetItem([f"{day} ({len(convs_d)})"]) + for conv in convs_d: + it = QTreeWidgetItem([conv.get("title", "(untitled)")]) + it.setData(0, Qt.UserRole, str(conv.get("path", ""))) + day_item.addChild(it) + self.widget.addTopLevelItem(day_item) + day_item.setExpanded(True) + + def _show_msg_json(self, item, _col: int = 0) -> None: + import html + import json + + from cowork_local.core.history import load_conversation + path = item.data(0, Qt.UserRole) + if not path: + return + try: + conv = load_conversation(path) + payload = {"title": conv.get("title", ""), "created": conv.get("created", ""), + "kind": conv.get("kind", ""), "project_id": conv.get("project_id", ""), + "messages": conv.get("messages", [])} + text = json.dumps(payload, ensure_ascii=False, indent=2) + except Exception as exc: # noqa: BLE001 + text = f"(could not read: {exc})" + self._owner.raw_json_ready.emit( + f'
{html.escape(text)}
') + + +__all__ = ["GraphMessagesView"] diff --git a/presentation/graph/graph_qa_widget.py b/presentation/graph/graph_qa_widget.py new file mode 100644 index 0000000..fcd9156 --- /dev/null +++ b/presentation/graph/graph_qa_widget.py @@ -0,0 +1,374 @@ +"""GraphQaWidget — the right-side "ask questions about this graph" panel of +GraphRAG (R08-T14, extracted from +``ui/structure_graph_view.py::StructureGraphView``, lines 288-334/342-360 +(partial)/647-663/704-955 of the original 1035-line file). + +Reads the current graph and scene selection from a +``graph_renderer.py::GraphRenderer`` instance passed at construction +(``renderer.graph``, ``renderer.selected_node_data()``, +``renderer.active_project_id``) and reacts to its +``node_selected``/``graph_rendered``/``raw_json_ready`` signals — this class +has no rendering state of its own, matching how +``presentation/folder/ai_file_editor_dialog.py`` reads +``DocumentPreviewManager`` rather than duplicating file state. + +File-content extraction for grounding the answer goes through +``application/workspaces/graph_index_service.py`` (R08-T14 also moved that +out of this file, as pure Python — see its own docstring). +""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import List, Optional, Tuple + +from PySide6.QtCore import QUrl, Signal +from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QTextBrowser, QVBoxLayout, QWidget + +from cowork_local.application.workspaces.graph_index_service import extract_file_contents +from cowork_local.core.worker import AgentWorker +from cowork_local.i18n import tr +from cowork_local.ui.icons import collapse_right_icon, icon +from cowork_local.ui.osutil import open_folder, open_location +from cowork_local.ui.widgets import CollapseStrip + + +class GraphQaWidget(QWidget): + """The collapsible pane itself (strip + header + ask row + detail + browser) — the shell adds ONE widget to its splitter.""" + + status_message = Signal(str) + collapse_changed = Signal(bool) # so the shell can resize its own splitter + + def __init__(self, ctx, renderer, parent=None): + super().__init__(parent) + self.ctx = ctx + self._renderer = renderer + self._ask_worker: Optional[AgentWorker] = None + self._answer = "" + self._detail_mode = "idle" # "answer" | "node" | "idle" + self._extract_cache: dict = {} + self._extract_dir = None + + outer = QHBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + self._strip = CollapseStrip(tr("structure.expand_agent_tooltip"), expand_dir="left") + self._strip.clicked.connect(lambda: self._set_collapsed(False)) + self._strip.setVisible(False) + outer.addWidget(self._strip) + + self._panel = QWidget() + rl = QVBoxLayout(self._panel) + rl.setContentsMargins(0, 0, 0, 0) + ag_hdr = QHBoxLayout() + self._collapse_btn = QPushButton() + self._collapse_btn.setIcon(collapse_right_icon()) + self._collapse_btn.setFixedWidth(28) + self._collapse_btn.clicked.connect(lambda: self._set_collapsed(True)) + self._label = QLabel() + ag_hdr.addWidget(self._collapse_btn) + ag_hdr.addWidget(self._label, 1) + rl.addLayout(ag_hdr) + + ask_row = QHBoxLayout() + self.ask_edit = QLineEdit() + self.ask_edit.returnPressed.connect(self._ask) + self._ask_btn = QPushButton() + self._ask_btn.setIcon(icon("chat")) + self._ask_btn.setObjectName("primary") + self._ask_btn.clicked.connect(self._ask) + ask_row.addWidget(self.ask_edit, 1) + ask_row.addWidget(self._ask_btn) + rl.addLayout(ask_row) + + self.detail = QTextBrowser() + self.detail.setReadOnly(True) + self.detail.setOpenLinks(False) + self.detail.anchorClicked.connect(self._on_detail_link) + rl.addWidget(self.detail, 1) + outer.addWidget(self._panel, 1) + + renderer.node_selected.connect(self._on_node_selected) + renderer.graph_rendered.connect(self._preserve_answer) + renderer.raw_json_ready.connect(self._show_raw_json) + renderer.project_changed.connect(self.clear_extracts) + + self.retranslate() + + def retranslate(self) -> None: + self._collapse_btn.setToolTip(tr("structure.collapse_agent_tooltip")) + self._label.setText(tr("structure.agent_header")) + self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder")) + self._ask_btn.setText(tr("structure.ask")) + if self._detail_mode == "idle": + self.detail.setPlaceholderText(tr("structure.detail_placeholder")) + self._strip.setToolTip(tr("structure.expand_agent_tooltip")) + + # ---- collapse ------------------------------------------------------------- # + def _set_collapsed(self, collapsed: bool) -> None: + self._panel.setVisible(not collapsed) + self._strip.setVisible(collapsed) + self.collapse_changed.emit(collapsed) + + # ---- reacting to the renderer ----------------------------------------------- # + def _on_node_selected(self, data) -> None: + self.detail.setPlainText(f"[{data.kind.upper()}] {data.label}\n\n{data.detail}") + self._detail_mode = "node" + + def _show_raw_json(self, html_text: str) -> None: + self.detail.setHtml(html_text) + + def _preserve_answer(self) -> None: + if self._detail_mode == "answer" and self._answer.strip(): + self._render_answer() + + # ---- Q&A -------------------------------------------------------------------- # + @staticmethod + def _graph_context(graph) -> str: + from collections import defaultdict + by_kind = defaultdict(list) + for n in graph.nodes: + by_kind[n.kind].append(n.label) + lines = [] + for kind in ("file", "class", "function", "method", "module", "section"): + items = by_kind.get(kind, []) + if items: + lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60])) + id2label = {n.id: n.label for n in graph.nodes} + rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}" + for e in graph.edges[:140]] + if rels: + lines.append("Relationships (sample):\n" + "\n".join(rels)) + return "\n".join(lines)[:7000] + + def _matched_sources(self, text: str): + graph = self._renderer.graph + if graph is None or not text: + return [] + found: dict = {} + for n in graph.nodes: + if not n.path: + continue + label = n.label.rstrip("()") + if len(label) < 3: + continue + if n.path not in found and re.search(rf"\b{re.escape(label)}\b", text): + found[n.path] = (n.kind, n.label, n.detail or n.path) + return sorted(found.items(), key=lambda kv: kv[1][1].lower())[:12] + + def _linkify_files(self, text: str, sources) -> str: + """Turn file/entity NAMES mentioned in the answer into clickable + links that open the file.""" + for path, (kind, label, rel) in sources: + href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded) + tokens = [] + base = Path(path).name + if base and len(base) >= 3: + tokens.append(base) + lab = (label or "").rstrip("()").strip() + if lab and lab != base and len(lab) >= 3: + tokens.append(lab) + for tok in tokens: + esc = re.escape(tok) + text = re.sub(rf"`{esc}`", f"[`{tok}`]({href})", text) + text = re.sub(rf"(? None: + text = self._answer + sources = self._matched_sources(text) + if sources: + text = self._linkify_files(text, sources) + lines = [text, "", "---", f"**{tr('structure.related_sources')}**"] + for path, (kind, label, rel) in sources: + href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded) + kind_badge = f" [{kind.upper()}]" if kind not in ("file",) else "" + lines.append(f"- **[{label}⧉]({href})**{kind_badge} — `{rel}`") + text = "\n".join(lines) + self.detail.setMarkdown(text) + + def _on_detail_link(self, url: QUrl) -> None: + if url.isLocalFile(): + p = url.toLocalFile() + if Path(p).is_file(): + open_location(p) + else: + open_folder(p) + + def _ask(self) -> None: + question = self.ask_edit.text().strip() + if not question: + return + from cowork_local.core.skills import parse_skill_command + skill_prefix, question, info = parse_skill_command(question) + if info is not None: + self.detail.setMarkdown(info) + self._detail_mode = "answer" + self.ask_edit.clear() + return + graph = self._renderer.graph + if graph is None: + self.status_message.emit(tr("structure.scan_first")) + return + context = self._graph_context(graph) + file_paths = self._candidate_file_paths() + extract_cache = dict(self._extract_cache) + extract_dir = str(self._extract_tmp_dir()) + self._answer = "" + self._detail_mode = "answer" + self.detail.setPlainText("…") + self.ask_edit.clear() + + active_project_id = self._renderer.active_project_id + selected_nodes = self._renderer.selected_node_data() + selected_context = self._selection_context(selected_nodes, graph) + + def job(worker: AgentWorker): + provider = self.ctx.build_active_provider() + system = self._system_prompt(skill_prefix, active_project_id) + user_content = f"Graph context:\n{context}" + if selected_context: + user_content += f"\n\nSelected node(s) context (focus your answer on these):\n{selected_context}" + content_block, new_cache = extract_file_contents(file_paths, extract_cache, extract_dir) + if content_block: + user_content += ("\n\nExtracted file contents (read these to answer about file " + "details/data; cite the file path):\n" + content_block) + user_content += f"\n\nQuestion: {question}" + messages = [ + {"role": "system", "content": system}, + {"role": "user", "content": user_content}, + ] + from cowork_local.core import agent_roles, audit_log + ok = True + try: + provider.chat(messages, on_text=lambda t: worker.emit_event({"type": "text", "delta": t}), + cancel=worker.is_cancelled) + except Exception: + ok = False + raise + finally: + audit_log.record("tool_call", "graphrag_ask", ok, question[:500], + agent_role=agent_roles.KNOWLEDGE) + return {"extracted": new_cache} + + w = AgentWorker(job) + w.event.connect(self._on_ask_event) + w.finished_ok.connect(self._on_ask_done) + w.failed.connect(lambda e: self.detail.setPlainText(f"Error: {e}")) + self._ask_worker = w + w.start() + + @staticmethod + def _selection_context(selected_nodes, graph) -> str: + if not selected_nodes: + return "" + node_lines = [] + for nd in selected_nodes: + node_lines.append(f"- {nd.label} (kind: {nd.kind}, path: {getattr(nd, 'path', '')})") + if nd.detail: + node_lines.append(f" detail: {nd.detail}") + connected_ids = set() + for nd in selected_nodes: + for edge in graph.edges: + if edge.source == nd.id: + connected_ids.add(edge.target) + elif edge.target == nd.id: + connected_ids.add(edge.source) + connected_nodes = [n for n in graph.nodes if n.id in connected_ids] + if connected_nodes: + node_lines.append("\nConnected nodes:") + for cn in connected_nodes: + node_lines.append(f"- {cn.label} (kind: {cn.kind})") + return "\n".join(node_lines) + + @staticmethod + def _system_prompt(skill_prefix: str, active_project_id: str) -> str: + system = ("You answer questions about a code/document knowledge graph. Use the provided " + "graph context AND the extracted file contents to retrieve, synthesize and " + "explain the answer. Be concise. Answer ONLY from what is provided (graph " + "context + extracted contents) — never invent files, functions, or facts that " + "aren't in it.\n\n" + "EACH answer MUST include source citations so the user can verify where " + "information came from. For every factual claim, file reference, or code " + "element you mention, add a citation using this format:\n\n" + " [source: filename.ext, line/section: XXX]\n\n" + "Rules for citations:\n" + " 1. Cite the EXACT file path from the graph context (use the path field).\n" + " 2. For Python files: cite the function/class name and approximate line " + " if available, or the module name.\n" + " 3. For document files (.md, .txt): cite the section heading.\n" + " 4. For JSON files: cite the key path (e.g. settings > database > host).\n" + " 5. Place citations inline after the relevant sentence or fact.\n" + " 6. At the end of your answer, add a '---' separator followed by a " + " numbered **Sources cited:** section listing each unique source with " + " its full path so the user can click to open it.\n\n" + "Example citation format in text:\n" + " The `process_data()` function handles CSV parsing " + "[source: src/utils/parser.py, function: process_data].\n\n" + "Example end-of-answer source list:\n" + " ---\n" + " **Sources cited:**\n" + " 1. `src/utils/parser.py` — process_data function\n" + " 2. `docs/api.md` — Section: Authentication\n") + if skill_prefix: + system += "\n\nFollow this skill:\n" + skill_prefix + if active_project_id: + from cowork_local.core.projects import load_project, project_context_text + proj_ctx = project_context_text(load_project(active_project_id)) + if proj_ctx: + system += "\n\n" + proj_ctx + return system + + def _on_ask_event(self, ev: dict) -> None: + if ev.get("type") == "text": + if self._answer == "": + self.detail.clear() + self._answer += ev.get("delta", "") + self.detail.setPlainText(self._answer) + + def _on_ask_done(self, result: dict) -> None: + # Keep the (temporary) extracted content so repeated questions reuse + # it without re-extracting — dropped when leaving the tab. + if isinstance(result, dict): + self._extract_cache.update(result.get("extracted", {}) or {}) + self._render_answer() + + # ---- temporary file-content extraction for Q&A -------------------------------- # + def _candidate_file_paths(self) -> List[str]: + """File paths to read for a question: the SELECTED file nodes if + any, else every file node in the graph (capped downstream).""" + graph = self._renderer.graph + if graph is None: + return [] + sel = self._renderer.selected_node_data() + nodes = sel or list(graph.nodes) + out, seen = [], set() + for nd in nodes: + p = (getattr(nd, "path", "") or "").strip() + if p and p not in seen and Path(p).is_file(): + seen.add(p) + out.append(p) + return out + + def _extract_tmp_dir(self) -> Path: + if self._extract_dir is None: + import tempfile + from cowork_local.config import CONFIG_DIR + base = CONFIG_DIR / "tmp" / "graphrag_extract" + base.mkdir(parents=True, exist_ok=True) + self._extract_dir = Path(tempfile.mkdtemp(dir=str(base))) + return self._extract_dir + + def clear_extracts(self) -> None: + """Discard the temporary extracted content (on leaving the tab / + switching project). The extraction is a scratch aid, never + persisted.""" + self._extract_cache = {} + d, self._extract_dir = self._extract_dir, None + if d is not None: + import shutil + shutil.rmtree(d, ignore_errors=True) + + +__all__ = ["GraphQaWidget"] diff --git a/presentation/graph/graph_renderer.py b/presentation/graph/graph_renderer.py new file mode 100644 index 0000000..45a046b --- /dev/null +++ b/presentation/graph/graph_renderer.py @@ -0,0 +1,391 @@ +"""GraphRenderer — the toolbar, scan/render pipeline, and graph/messages +stack of GraphRAG (R08-T14, extracted from +``ui/structure_graph_view.py::StructureGraphView``, lines 188-286/336-661/ +664-702 of the original 1035-line file — everything except the right-side +Q&A panel, which is ``graph_qa_widget.py::GraphQaWidget``). + +Talks to the Q&A panel only through signals (:attr:`node_selected`, +:attr:`graph_rendered`) and a small read API (:attr:`graph`, +:meth:`selected_node_data`, :attr:`active_project_id`) — this class has no +idea ``GraphQaWidget`` exists, matching how +``presentation/folder/document_preview_manager.py`` doesn't know about the +AI-edit panel either. +""" +from __future__ import annotations + +import math +from pathlib import Path +from typing import List, Optional + +from PySide6.QtCore import QPointF, Qt, QTimer, QUrl, Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QComboBox, QFileDialog, QGraphicsScene, QHBoxLayout, QLineEdit, + QPushButton, QStackedWidget, QTabBar, QVBoxLayout, QWidget, +) + +from cowork_local.core.worker import AgentWorker +from cowork_local.i18n import on_language_changed, tr +from cowork_local.presentation.graph.graph_messages_view import GraphMessagesView +from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node +from cowork_local.presentation.shared import HAS_WEB_ENGINE +from cowork_local.state import AppContext +from cowork_local.theme import current_palette +from cowork_local.ui.icons import icon + + +class GraphRenderer(QWidget): + status_message = Signal(str) + node_selected = Signal(object) # a node's .data, whenever the scene selection changes + graph_rendered = Signal() # a scan just finished rendering (fresh OR re-fit) + raw_json_ready = Signal(str) # pre-formatted HTML for a clicked Messages entry + project_changed = Signal() # a DIFFERENT project was selected (or cleared) + + def __init__(self, ctx: AppContext): + super().__init__() + self.ctx = ctx + self._worker: Optional[AgentWorker] = None + self._node_items: List[_Node] = [] + self._edge_items: List[_Edge] = [] + self._centroid = QPointF(0, 0) + self._graph = None + self._needs_scan = False + self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite) + self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox + + self._rescan_timer = QTimer(self) + self._rescan_timer.setSingleShot(True) + self._rescan_timer.setInterval(1500) + self._rescan_timer.timeout.connect(self._scan) + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + + bar = QHBoxLayout() + self.path_edit = QLineEdit(str(ctx.config.cowork_output_dir())) + self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) + self._pick_btn = QPushButton() + self._pick_btn.setIcon(icon("folder")) + self._pick_btn.setObjectName("primary") + self._pick_btn.clicked.connect(self._pick) + self.project_combo = QComboBox() + self.project_combo.currentIndexChanged.connect(self._on_project_changed) + self._scan_btn = QPushButton() + self._scan_btn.setIcon(icon("search")) + self._scan_btn.setObjectName("primary") + self._scan_btn.clicked.connect(self._scan) + self._export_btn = QPushButton() + self._export_btn.setIcon(icon("upload")) + self._export_btn.setObjectName("primary") + self._export_btn.clicked.connect(self._export) + bar.addWidget(self.path_edit, 1) + bar.addWidget(self._pick_btn) + bar.addWidget(self.project_combo) + bar.addWidget(self._scan_btn) + bar.addWidget(self._export_btn) + root.addLayout(bar) + self._refresh_project_combo() + + # Đồ thị | Tin nhắn as a real pair of tabs. + self.view_tabs = QTabBar() + self.view_tabs.setObjectName("viewTabs") + self.view_tabs.setDrawBase(False) + self.view_tabs.setExpanding(False) + self.view_tabs.addTab(icon("graph"), "") + self.view_tabs.addTab(icon("message"), "") + self.view_tabs.currentChanged.connect(self._on_view_tab) + tab_row = QHBoxLayout() + tab_row.setContentsMargins(0, 0, 0, 0) + tab_row.addWidget(self.view_tabs) + tab_row.addStretch(1) + root.addLayout(tab_row) + + self.scene = QGraphicsScene() + self.scene.setBackgroundBrush(QColor(current_palette().bg)) + self.scene.selectionChanged.connect(self._on_selection) + self.view = _GraphView(self.scene) + + self._stack = QStackedWidget() + self._stack.addWidget(self.view) + self.web = None + self._bridge = None + self._channel = None + root.addWidget(self._stack, 1) + # "Messages" view: all conversation messages grouped BY DAY, shown as + # JSON — a separate concern composed in (see graph_messages_view.py). + self._messages = GraphMessagesView(self) + + on_language_changed(self._retranslate) + + def _retranslate(self) -> None: + self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) + self._pick_btn.setText(tr("structure.browse")) + self._scan_btn.setText(tr("structure.scan")) + self._export_btn.setText(tr("structure.export_png")) + self.view_tabs.setTabText(0, tr("structure.graph_btn")) + self.view_tabs.setTabText(1, tr("structure.msgs_btn")) + self.view_tabs.setTabToolTip(1, tr("structure.msgs_tooltip")) + self.project_combo.setToolTip(tr("structure.project_tooltip")) + self._refresh_project_combo() + + # ---- public read API for GraphQaWidget ----------------------------------- # + @property + def graph(self): + return self._graph + + @property + def active_project_id(self) -> str: + return self._active_project_id + + def selected_node_data(self) -> list: + return [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)] + + # ---- project sandbox lock ------------------------------------------------- # + def _refresh_project_combo(self) -> None: + from cowork_local.core.projects import list_projects + + keep = self._active_project_id + self.project_combo.blockSignals(True) + self.project_combo.clear() + self.project_combo.addItem(tr("structure.project_none"), "") + row_to_select = 0 + for i, p in enumerate(list_projects(), start=1): + self.project_combo.addItem(p.name, p.project_id) + if p.project_id == keep: + row_to_select = i + self.project_combo.setCurrentIndex(row_to_select) + self.project_combo.blockSignals(False) + + def set_project(self, project_id: str) -> None: + pid = project_id or "" + self._refresh_project_combo() + target = self.project_combo.findData(pid) + if target < 0: + target = 0 + if self.project_combo.currentIndex() == target: + self._on_project_changed(target) + else: + self.project_combo.setCurrentIndex(target) + + def _on_project_changed(self, _idx: int) -> None: + from cowork_local.core.projects import load_project + + pid = self.project_combo.currentData() or "" + project_changed = pid != self._active_project_id + self._active_project_id = pid + locked = bool(pid) + self.path_edit.setReadOnly(locked) + self._pick_btn.setEnabled(not locked) + if locked: + project = load_project(pid) + if project is not None: + self.path_edit.setText(str(project.workspace_dir())) + if project_changed: + self.project_changed.emit() # GraphQaWidget drops its temp extraction cache + # Mark it and scan on the next visit rather than now — see + # auto_scan_and_fit()'s docstring for why. + self._needs_scan = True + + # ---- helpers ---------------------------------------------------------------- # + def _pick(self) -> None: + chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text()) + if chosen: + self.path_edit.setText(chosen) + + def schedule_rescan(self, path: str = "") -> None: + if self._graph is None: + self._needs_scan = True + return + self._rescan_timer.start() + + # ---- Messages (by day, as JSON) — see graph_messages_view.py --------------- # + def _on_view_tab(self, index: int) -> None: + self._messages.on_view_tab(index) + + def _toggle_messages(self) -> None: + """Kept for callers that still ask for a flip (e.g. keyboard paths).""" + self._messages.toggle() + + # ---- prewarm / scan lifecycle -------------------------------------------------- # + def prewarm(self) -> None: + """Pay for the graph view before it is clicked on, not during.""" + if not HAS_WEB_ENGINE or self.web is not None: + return + self._ensure_web() + if self._graph is None and self.path_edit.text().strip(): + self._needs_scan = False + self._scan() + + def _ensure_web(self) -> None: + if self.web is not None or not HAS_WEB_ENGINE: + return + from PySide6.QtWebChannel import QWebChannel + from PySide6.QtWebEngineWidgets import QWebEngineView + + self.web = QWebEngineView() + self.web.setHtml( + f"") + self._bridge = _Bridge() + self._channel = QWebChannel() + self._channel.registerObject("py", self._bridge) + self.web.page().setWebChannel(self._channel) + self._stack.addWidget(self.web) + self._stack.setCurrentWidget(self.web) + if self._graph is not None: + self._render_d3() + + def auto_scan_and_fit(self) -> None: + self._ensure_web() + if not self.path_edit.text().strip(): + return + if self._worker is not None and self._worker.isRunning(): + self._fit() + self.graph_rendered.emit() + return + if self._graph is not None and not self._needs_scan: + self._fit() + self.graph_rendered.emit() + return + self._needs_scan = False + self._scan() + + # ---- scan --------------------------------------------------------------------- # + def _scan(self) -> None: + path = self.path_edit.text().strip() or str(Path.cwd()) + mode = "files" + use_cmem = bool(self.ctx.config.codebase_memory.get("enabled")) + cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "") + st = self.ctx.config.structure + max_nodes = int(st.get("max_nodes", 500) or 0) + max_edges = int(st.get("max_edges", 500) or 0) + self._scan_seq += 1 + seq = self._scan_seq + self.status_message.emit(tr("structure.scanning")) + + def job(worker: AgentWorker): + from cowork_local.core.structure_graph import ( + build_from_codebase_memory, build_from_directory, force_layout, + ) + if use_cmem: + from cowork_local.core.codebase_memory import CodebaseMemory + mem = CodebaseMemory(cmem_bin) + graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges) + if mem.available else build_from_directory(path, mode, max_nodes, max_edges)) + else: + graph = build_from_directory(path, mode, max_nodes, max_edges) + pos = force_layout(graph) + return {"graph": graph, "pos": pos, "seq": seq} + + w = AgentWorker(job) + w.finished_ok.connect(self._render) + w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e))) + self._worker = w + w.start() + + def _render(self, result: dict) -> None: + if result.get("seq") is not None and result["seq"] != self._scan_seq: + return + graph = result.get("graph") + pos = result.get("pos", {}) + if graph is None: + return + self._graph = graph + + self.scene.clear() + self.scene.setBackgroundBrush(QColor(current_palette().bg)) + self._node_items = [] + self._edge_items = [] + degree = {n.id: 0 for n in graph.nodes} + for e in graph.edges: + if e.source in degree: + degree[e.source] += 1 + if e.target in degree: + degree[e.target] += 1 + items = {} + sx = sy = 0.0 + for node in graph.nodes: + radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0)))) + item = _Node(node, radius) + x, y = pos.get(node.id, (0, 0)) + item.setPos(x, y) + self.scene.addItem(item) + items[node.id] = item + self._node_items.append(item) + sx += x + sy += y + for edge in graph.edges: + a, b = items.get(edge.source), items.get(edge.target) + if a and b: + e = _Edge(a, b, getattr(edge, "type", "")) + self.scene.addItem(e) + self._edge_items.append(e) + n = max(1, len(self._node_items)) + self._centroid = QPointF(sx / n, sy / n) + self._fit() + + if self.web is not None: + self._render_d3() + + note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else "" + self.status_message.emit(tr( + "structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note)) + self.graph_rendered.emit() + + def _render_d3(self) -> None: + if self.web is None or self._graph is None: + return + from cowork_local.core.d3_graph import build_html + try: + self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/")) + except Exception as exc: + self.status_message.emit(f"D3 view error: {exc}") + + # ---- native interactions ------------------------------------------------------- # + def _on_selection(self) -> None: + for item in self.scene.selectedItems(): + if isinstance(item, _Node): + self.node_selected.emit(item.data) + return + + def _fit(self) -> None: + if self.web is not None and self._stack.currentWidget() is self.web: + self.web.page().runJavaScript("window.fitGraph && window.fitGraph();") + return + rect = self.scene.itemsBoundingRect() + if not rect.isNull(): + self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio) + + def _export(self) -> None: + path, _ = QFileDialog.getSaveFileName( + self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)") + if not path: + return + showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web) + if showing_d3: + self._export_d3_png(path) + else: + self._export_widget_grab(path) + + def _export_d3_png(self, path: str) -> None: + def on_result(data_url) -> None: + if not isinstance(data_url, str) or "," not in data_url: + self._export_widget_grab(path) + return + import base64 + try: + with open(path, "wb") as f: + f.write(base64.b64decode(data_url.split(",", 1)[1])) + self.status_message.emit(tr("structure.export_done", path=path)) + except (OSError, ValueError) as exc: + self.status_message.emit(tr("structure.export_failed", err=str(exc))) + self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result) + + def _export_widget_grab(self, path: str) -> None: + ok = self._stack.currentWidget().grab().save(path, "PNG") + if ok: + self.status_message.emit(tr("structure.export_done", path=path)) + else: + self.status_message.emit(tr("structure.export_failed", err="grab() returned no image")) + + +__all__ = ["GraphRenderer"] diff --git a/presentation/graph/graph_scene_items.py b/presentation/graph/graph_scene_items.py new file mode 100644 index 0000000..e02bf96 --- /dev/null +++ b/presentation/graph/graph_scene_items.py @@ -0,0 +1,142 @@ +"""Native QGraphicsScene primitives for the fallback (non-WebEngine) graph +view (R08-T14, split out of ``graph_renderer.py`` to keep it under the +400-line cap; originally ``ui/structure_graph_view.py``, lines 65-186 of the +original 1035-line file: ``_Bridge``, ``_Edge``, ``_Node``, ``_GraphView``). +""" +from __future__ import annotations + +import math + +from PySide6.QtCore import QObject, QPointF, Qt, Slot +from PySide6.QtGui import QBrush, QColor, QFont, QPen +from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsSimpleTextItem, QGraphicsView + +from cowork_local.core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS +from cowork_local.theme import current_palette +from cowork_local.ui.osutil import open_folder, open_location + + +class _Bridge(QObject): + """Exposed to the D3 page so a Shift+click on a node can open its + storage folder/link (local path or URL — see osutil.open_location).""" + + @Slot(str) + def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name + if path: + open_location(path) + + +class _Edge(QGraphicsLineItem): + def __init__(self, a: "_Node", b: "_Node", type_: str = ""): + super().__init__() + self.a, self.b = a, b + self.type = type_ + # Colour the edge by its RELATIONSHIP type (contains/defines/method/…), + # so the graph shows what each connection MEANS — falling back to the + # source node's tint for any untyped edge. + color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor() + if not color.isValid(): + color = a.brush().color().lighter(130) + self._color = color + self.setPen(QPen(color, 1.4)) + self.setZValue(-1) + # A small label naming the relationship, shown at the edge midpoint. + self._label = None + if type_: + self._label = QGraphicsSimpleTextItem(type_, self) + self._label.setBrush(QBrush(color.lighter(140))) + f = QFont() + f.setPointSize(7) + self._label.setFont(f) + self._label.setZValue(0) + a.edges.append(self) + b.edges.append(self) + self.adjust() + + def adjust(self) -> None: + pa, pb = self.a.scenePos(), self.b.scenePos() + self.setLine(pa.x(), pa.y(), pb.x(), pb.y()) + if self._label is not None: + br = self._label.boundingRect() + self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2, + (pa.y() + pb.y()) / 2 - br.height() / 2) + + +class _Node(QGraphicsEllipseItem): + def __init__(self, data, radius: int): + super().__init__(-radius, -radius, 2 * radius, 2 * radius) + self.data = data + self.edges = [] + tok = current_palette() + # NODE_KIND_COLORS is a categorical data encoding (one hue per node + # kind), not UI chrome — it stays fixed across themes on purpose so a + # given kind is always the same colour. Only the chrome follows tokens. + color = QColor(NODE_KIND_COLORS.get(data.kind, tok.text_muted)) + self.setBrush(QBrush(color)) + self.setPen(QPen(color.darker(160), 1.5)) + self.setFlags( + QGraphicsEllipseItem.ItemIsMovable + | QGraphicsEllipseItem.ItemIsSelectable + | QGraphicsEllipseItem.ItemSendsGeometryChanges + ) + self.setZValue(1) + label = QGraphicsSimpleTextItem(data.label, self) + label.setBrush(QBrush(QColor(tok.text))) + label.setPos(radius + 3, -8) + + def itemChange(self, change, value): # noqa: N802 + if change == QGraphicsEllipseItem.ItemPositionHasChanged: + for edge in self.edges: + edge.adjust() + return super().itemChange(change, value) + + +class _GraphView(QGraphicsView): + def __init__(self, scene): + super().__init__(scene) + self.setDragMode(QGraphicsView.NoDrag) + self._panning = False + self._pan_start = QPointF() + + def wheelEvent(self, e): # noqa: N802 + self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15, + 1.15 if e.angleDelta().y() > 0 else 1 / 1.15) + + def mousePressEvent(self, e): # noqa: N802 + if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None: + self._panning = True + self._pan_start = e.position() + self.setCursor(Qt.ClosedHandCursor) + e.accept() + return + super().mousePressEvent(e) + + def mouseMoveEvent(self, e): # noqa: N802 + if self._panning: + delta = e.position() - self._pan_start + self._pan_start = e.position() + self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x())) + self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y())) + e.accept() + return + super().mouseMoveEvent(e) + + def mouseReleaseEvent(self, e): # noqa: N802 + if self._panning: + self._panning = False + self.setCursor(Qt.ArrowCursor) + e.accept() + return + super().mouseReleaseEvent(e) + + def mouseDoubleClickEvent(self, e): # noqa: N802 + """Double-click or Ctrl+click on a node opens its storage folder.""" + item = self.itemAt(e.pos()) + if isinstance(item, _Node) and getattr(item.data, "path", ""): + open_folder(item.data.path) + e.accept() + return + super().mouseDoubleClickEvent(e) + + +__all__ = ["_Bridge", "_Edge", "_Node", "_GraphView"] diff --git a/presentation/graph/structure_graph_view.py b/presentation/graph/structure_graph_view.py new file mode 100644 index 0000000..fc9e8af --- /dev/null +++ b/presentation/graph/structure_graph_view.py @@ -0,0 +1,80 @@ +"""StructureGraphView shell (R08-T14) — assembles +``graph_renderer.py::GraphRenderer`` and +``graph_qa_widget.py::GraphQaWidget`` behind the splitter that used to be +inline in ``ui/structure_graph_view.py::StructureGraphView.__init__`` (lines +188-343 of the original 1035-line file), and forwards the public methods +``app.py``/``ui/workspace_tab.py`` call: ``schedule_rescan``, +``auto_scan_and_fit``, ``set_project``, ``prewarm``. +""" +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QSplitter, QVBoxLayout, QWidget + +from cowork_local.i18n import on_language_changed +from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget +from cowork_local.presentation.graph.graph_renderer import GraphRenderer +from cowork_local.state import AppContext +from cowork_local.ui.widgets import CollapseStrip + +_COLLAPSED_SIZES_HINT = (840, 320) # matches the original single-class default + + +class StructureGraphView(QWidget): + status_message = Signal(str) + + def __init__(self, ctx: AppContext): + super().__init__() + self.ctx = ctx + + root = QVBoxLayout(self) + self.renderer = GraphRenderer(ctx) + self.renderer.status_message.connect(self.status_message.emit) + self.qa = GraphQaWidget(ctx, self.renderer) + self.qa.status_message.connect(self.status_message.emit) + self.qa.collapse_changed.connect(self._on_qa_collapse_changed) + + self._split = QSplitter(Qt.Horizontal) + self._split.addWidget(self.renderer) + self._split.addWidget(self.qa) + self._split.setChildrenCollapsible(False) + self._split.setSizes(list(_COLLAPSED_SIZES_HINT)) + root.addWidget(self._split, 1) + + on_language_changed(self._retranslate) + + def _retranslate(self) -> None: + self.renderer._retranslate() + self.qa.retranslate() + + def _on_qa_collapse_changed(self, collapsed: bool) -> None: + strip_w = CollapseStrip.WIDTH + 2 + if collapsed: + self.qa.setMaximumWidth(strip_w) + sizes = self._split.sizes() + if len(sizes) == 2: + self._split.setSizes([max(1, sum(sizes) - strip_w), strip_w]) + else: + self.qa.setMaximumWidth(16777215) + self._split.setSizes(list(_COLLAPSED_SIZES_HINT)) + + # ---- public API (app.py / ui/workspace_tab.py) --------------------------- # + def schedule_rescan(self, path: str = "") -> None: + self.renderer.schedule_rescan(path) + + def auto_scan_and_fit(self) -> None: + self.renderer.auto_scan_and_fit() + + def set_project(self, project_id: str) -> None: + self.renderer.set_project(project_id) + + def prewarm(self) -> None: + self.renderer.prewarm() + + def hideEvent(self, e): # noqa: N802 + # Leaving the GraphRAG tab → drop the temporary extracted info. + self.qa.clear_extracts() + super().hideEvent(e) + + +__all__ = ["StructureGraphView"] diff --git a/presentation/scheduling/__init__.py b/presentation/scheduling/__init__.py new file mode 100644 index 0000000..fa05096 --- /dev/null +++ b/presentation/scheduling/__init__.py @@ -0,0 +1,3 @@ +"""Schedule Task screen, split into single-responsibility widgets (R08-T11): +``kanban_board_widget``, ``calendar_view_widget``, ``ai_task_creator_dialog``, +``ai_task_import_dialog``, assembled by the ``schedule_task_tab`` shell.""" diff --git a/presentation/scheduling/ai_task_creator_dialog.py b/presentation/scheduling/ai_task_creator_dialog.py new file mode 100644 index 0000000..47b9a01 --- /dev/null +++ b/presentation/scheduling/ai_task_creator_dialog.py @@ -0,0 +1,196 @@ +"""AiTaskCreatorDialog — "AI Create Task" (R08-T11, extracted from +``ui/schedule_task_tab.py``'s ``_AiCreateDialog``, lines 579-641/722-794 of +the original 795-line file). + +Still one dialog with two tabs (AI-gen, then Import — the latter is +:class:`~presentation.scheduling.ai_task_import_dialog.ImportTaskPanel`, +embedded here rather than duplicated): the physical file split matches +``docs/refactor/Feature_Architecture_Proposal.md``'s R08-T11 breakdown, the +user-visible dialog is unchanged. ``_confirm`` still uses "whichever tab +produced a task list most recently" (mirroring the original class's shared +``self._planned`` attribute) — the AI-gen tab sets it on completion, the +Import tab reports it through :attr:`ImportTaskPanel.tasks_changed`. + +AI generation goes through +``application/scheduling/ai_task_planner_service.py::AiTaskPlannerService`` +(R07-T05) instead of ``core.ai_task_planner.plan_tasks`` directly. +""" +from __future__ import annotations + +from typing import List, Optional + +from PySide6.QtWidgets import ( + QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit, + QPlainTextEdit, QPushButton, QTabWidget, QVBoxLayout, QWidget, +) + +from cowork_local.application.scheduling.ai_task_planner_service import ( + AiTaskPlannerService, +) +from cowork_local.core.projects import list_projects +from cowork_local.core.worker import AgentWorker +from cowork_local.i18n import tr +from cowork_local.presentation.scheduling.ai_task_import_dialog import ImportTaskPanel +from cowork_local.state import AppContext +from cowork_local.ui.icons import icon + + +class AiTaskCreatorDialog(QDialog): + """Create tasks two ways, one tab each (both preview first — nothing is + saved until the user confirms): ✨ AI gen from a natural-language + description, or 📥 Import from a filled Excel/CSV/JSON file.""" + + def __init__(self, ctx: AppContext, parent=None): + super().__init__(parent) + self.ctx = ctx + self._planner = AiTaskPlannerService(provider_factory=ctx.build_active_provider) + self.created_tasks: List[dict] = [] + self._ai_planned: List[dict] = [] + # Which tab produced the task list currently backing the Ok button — + # mirrors the original single-class dialog's shared `self._planned` + # attribute, where whichever of _on_planned()/_load_import_file() + # ran LAST (regardless of which tab is currently showing) won. + self._active_source = "ai" + self._worker: Optional[AgentWorker] = None + self.setWindowTitle(tr("schedtask.ai_btn")) + self.resize(600, 520) + + root = QVBoxLayout(self) + ws_row = QHBoxLayout() + ws_row.addWidget(QLabel(tr("schedtask.f_workspace"))) + self.workspace_combo = QComboBox() + self.workspace_combo.addItem(tr("schedtask.no_workspace"), "") + for p in list_projects(): + self.workspace_combo.addItem(p.name, p.project_id) + self.workspace_combo.setToolTip(tr("schedtask.hint_workspace")) + ws_row.addWidget(self.workspace_combo, 1) + root.addLayout(ws_row) + self.tabs = QTabWidget() + root.addWidget(self.tabs, 1) + + self.tabs.addTab(self._build_ai_gen_page(), tr("schedtask.tab_ai")) + self.import_panel = ImportTaskPanel(self._planner) + self.import_panel.tasks_changed.connect(self._on_import_tasks_changed) + self.tabs.addTab(self.import_panel, tr("schedtask.tab_import")) + + self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm")) + self.buttons.button(QDialogButtonBox.Ok).setEnabled(False) + self.buttons.accepted.connect(self._confirm) + self.buttons.rejected.connect(self.reject) + root.addWidget(self.buttons) + + # ---- AI-gen tab ------------------------------------------------------- + def _build_ai_gen_page(self) -> QWidget: + ai_page = QWidget() + al = QVBoxLayout(ai_page) + al.addWidget(QLabel(tr("schedtask.ai_desc_label"))) + self.desc_edit = QPlainTextEdit() + self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph")) + self.desc_edit.setMaximumHeight(110) + al.addWidget(self.desc_edit) + # Attachments (files + links) — merged into every task this generates, + # AND into the planning prompt so the AI knows they exist. + attach_row = QHBoxLayout() + self.ai_files_edit = QLineEdit() + self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder")) + ai_pick_btn = QPushButton(tr("schedtask.pick_files")) + ai_pick_btn.setIcon(icon("folder")) + ai_pick_btn.clicked.connect(self._ai_pick_files) + attach_row.addWidget(self.ai_files_edit, 1) + attach_row.addWidget(ai_pick_btn) + al.addWidget(QLabel(tr("schedtask.f_files"))) + al.addLayout(attach_row) + self.ai_links_edit = QLineEdit() + self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder")) + al.addWidget(QLabel(tr("schedtask.f_links"))) + al.addWidget(self.ai_links_edit) + self.gen_btn = QPushButton(tr("schedtask.ai_generate")) + self.gen_btn.setIcon(icon("sparkle")) + self.gen_btn.setObjectName("primary") + self.gen_btn.clicked.connect(self._generate) + al.addWidget(self.gen_btn) + al.addWidget(QLabel(tr("schedtask.ai_preview_label"))) + self.preview = QPlainTextEdit() + self.preview.setReadOnly(True) + al.addWidget(self.preview, 1) + return ai_page + + def _ai_pick_files(self) -> None: + from PySide6.QtWidgets import QFileDialog + + files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files")) + if files: + existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()] + self.ai_files_edit.setText("; ".join(existing + files)) + + def _attached_files(self) -> List[str]: + return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()] + + def _attached_links(self) -> List[str]: + return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()] + + def _generate(self) -> None: + description = self.desc_edit.toPlainText().strip() + if not description or self._worker is not None: + return + files, links = self._attached_files(), self._attached_links() + self.gen_btn.setEnabled(False) + self.gen_btn.setText(tr("schedtask.ai_generating")) + + def job(worker: AgentWorker): + full_desc = description + if files or links: + attach_note = "; ".join(files + links) + full_desc += f"\n\n(Attached references available: {attach_note})" + planned = self._planner.plan( + full_desc, file_paths=files, links=links, cancel=worker.is_cancelled) + return {"tasks": planned} + + w = AgentWorker(job) + w.finished_ok.connect(self._on_planned) + w.failed.connect(self._on_failed) + self._worker = w + w.start() + + def _on_planned(self, result: dict) -> None: + self._worker = None + self.gen_btn.setEnabled(True) + self.gen_btn.setText(tr("schedtask.ai_generate")) + self._ai_planned = result.get("tasks") or [] + self._active_source = "ai" + lines = [] + for i, t in enumerate(self._ai_planned, 1): + sched = t.get("schedule", {}) + when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule") + dep = t.get("dependency", {}) + chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else "" + lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n" + f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n" + f" {t.get('description', '')[:150]}") + self.preview.setPlainText("\n\n".join(lines)) + self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._ai_planned)) + + def _on_failed(self, err: str) -> None: + self._worker = None + self.gen_btn.setEnabled(True) + self.gen_btn.setText(tr("schedtask.ai_generate")) + self.preview.setPlainText(str(err)) + + # ---- Import tab --------------------------------------------------------- + def _on_import_tasks_changed(self, has_tasks: bool) -> None: + if has_tasks: + self._active_source = "import" + self.buttons.button(QDialogButtonBox.Ok).setEnabled(has_tasks) + + # ---- confirm ------------------------------------------------------------ + def _confirm(self) -> None: + planned = self.import_panel.planned if self._active_source == "import" else self._ai_planned + project_id = self.workspace_combo.currentData() or "" + for t in planned: + t["project_id"] = project_id + self.created_tasks = planned + self.accept() + + +__all__ = ["AiTaskCreatorDialog"] diff --git a/presentation/scheduling/ai_task_import_dialog.py b/presentation/scheduling/ai_task_import_dialog.py new file mode 100644 index 0000000..6aabfe3 --- /dev/null +++ b/presentation/scheduling/ai_task_import_dialog.py @@ -0,0 +1,148 @@ +"""Import-from-file tab content for AI Create Task (R08-T11, extracted from +``ui/schedule_task_tab.py``'s ``_AiCreateDialog`` — the Import tab + its +``_DropZone``, lines 551-576/643-665/674-720 of the original file). + +:class:`ImportTaskPanel` is a plain ``QWidget`` (not its own dialog) so +``ai_task_creator_dialog.py`` can embed it as one tab of the single AI-create +dialog the user sees — the two files are a code split, not a UX split; there +is still one dialog with two tabs, exactly as before. +""" +from __future__ import annotations + +from pathlib import Path +from typing import List + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QHBoxLayout, QLabel, QMessageBox, QPlainTextEdit, QPushButton, + QVBoxLayout, QWidget, +) + +from cowork_local.application.scheduling.ai_task_planner_service import ( + AiTaskPlannerService, +) +from cowork_local.i18n import tr +from cowork_local.theme import current_palette +from cowork_local.ui.icons import icon +from cowork_local.ui.osutil import open_path + + +class _DropZone(QLabel): + """Drag-an-.xlsx-here area for the Import tab.""" + + file_dropped = Signal(str) + + def __init__(self): + super().__init__() + from PySide6.QtCore import Qt + + self.setAlignment(Qt.AlignCenter) + self.setMinimumHeight(70) + _p = current_palette() + self.setStyleSheet( + f"QLabel {{ border: 1px dashed {_p.border_strong};" + f" border-radius: {_p.radius_lg}px;" + f" color: {_p.text_muted}; padding: 10px; }}") + self.setAcceptDrops(True) + + def dragEnterEvent(self, event): # noqa: N802 + urls = event.mimeData().urls() + if urls and urls[0].toLocalFile().lower().endswith( + (".xlsx", ".xlsm", ".xls", ".csv", ".json")): + event.acceptProposedAction() + + def dropEvent(self, event): # noqa: N802 + urls = event.mimeData().urls() + if urls: + self.file_dropped.emit(urls[0].toLocalFile()) + + +class ImportTaskPanel(QWidget): + """Pick/drag an Excel/CSV/JSON file, preview the tasks it maps to, and + hold that NOT-yet-saved list — the dialog reads :attr:`planned` when the + user confirms. + + Args: + planner: an ``AiTaskPlannerService`` — ``import_file`` is called + through it (R07-T05) rather than ``core.task_import`` directly. + """ + + tasks_changed = Signal(bool) # True when the current preview has >=1 valid task + + def __init__(self, planner: AiTaskPlannerService, parent=None): + super().__init__(parent) + self._planner = planner + self.planned: List[dict] = [] + + il = QVBoxLayout(self) + tpl_btn = QPushButton(tr("schedtask.export_template_btn")) + tpl_btn.setIcon(icon("upload")) + tpl_btn.clicked.connect(self._export_template) + il.addWidget(tpl_btn) + pick_row = QHBoxLayout() + pick_btn = QPushButton(tr("schedtask.import_pick_btn")) + pick_btn.setIcon(icon("folder")) + pick_btn.clicked.connect(self._pick_import_file) + pick_row.addWidget(pick_btn) + pick_row.addStretch(1) + il.addLayout(pick_row) + self.drop_zone = _DropZone() + self.drop_zone.setText(tr("schedtask.drop_hint")) + self.drop_zone.file_dropped.connect(self._load_import_file) + il.addWidget(self.drop_zone) + il.addWidget(QLabel(tr("schedtask.ai_preview_label"))) + self.preview = QPlainTextEdit() + self.preview.setReadOnly(True) + il.addWidget(self.preview, 1) + + def _export_template(self) -> None: + from PySide6.QtWidgets import QFileDialog + + from cowork_local.core.task_excel import export_template + + path, _ = QFileDialog.getSaveFileName( + self, tr("schedtask.export_template_btn"), + "cowork_tasks_template.xlsx", "Excel (*.xlsx)") + if not path: + return + try: + export_template(path) + open_path(str(Path(path).parent)) + except Exception as exc: # noqa: BLE001 + QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc)) + + def _pick_import_file(self) -> None: + from PySide6.QtWidgets import QFileDialog + + from cowork_local.core.task_import import IMPORT_FILTER + + path, _ = QFileDialog.getOpenFileName( + self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER) + if path: + self._load_import_file(path) + + def _load_import_file(self, path: str) -> None: + try: + self.planned = self._planner.import_file(path) + except ValueError as exc: + # Same as the original single-class dialog: a bad file leaves + # whatever was previously loaded in `planned` untouched (only the + # preview text and the Ok button reflect the failure) rather than + # discarding a prior successful load. + self.preview.setPlainText(str(exc)) + self.tasks_changed.emit(False) + return + by_id = {t["task_id"]: t["title"] for t in self.planned} + lines = [] + for i, t in enumerate(self.planned, 1): + sched = t.get("schedule", {}) + when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule") + deps = t.get("dependency", {}).get("depends_on") or [] + dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else "" + lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n" + f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}") + self.preview.setPlainText("\n\n".join(lines)) + self.tasks_changed.emit(bool(self.planned)) + + +__all__ = ["ImportTaskPanel"] diff --git a/ui/calendar_view.py b/presentation/scheduling/calendar_view_widget.py similarity index 96% rename from ui/calendar_view.py rename to presentation/scheduling/calendar_view_widget.py index 861e20a..672c2b2 100644 --- a/ui/calendar_view.py +++ b/presentation/scheduling/calendar_view_widget.py @@ -1,4 +1,5 @@ -"""Calendar view for Schedule Task — an alternative to the Kanban board: +"""Calendar view for Schedule Task — an alternative to the Kanban board +(R08-T11, relocated from ``ui/calendar_view.py`` with no logic changes): Week / Month / Year granularity, each task placed on its scheduled date (``schedule.run_at``). Click a task to edit it (same editor the Kanban board's double-click opens); click a day's "+" to create a task pre-filled @@ -16,12 +17,12 @@ from PySide6.QtWidgets import ( QListWidgetItem, QPushButton, QScrollArea, QVBoxLayout, QWidget, ) -from ..core.calendar_grid import ( +from cowork_local.core.calendar_grid import ( GRANULARITIES, group_tasks_by_date, month_grid, month_task_counts, shift_period, week_days, ) -from ..i18n import on_language_changed, tr -from ..theme import current_palette -from .icons import icon +from cowork_local.i18n import on_language_changed, tr +from cowork_local.theme import current_palette +from cowork_local.ui.icons import icon _WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") @@ -229,3 +230,6 @@ class CalendarView(QWidget): self.period_lbl.setText(str(self.anchor.year)) else: self.period_lbl.setText(self.anchor.strftime("%Y-%m")) + + +__all__ = ["CalendarView"] diff --git a/presentation/scheduling/kanban_board_widget.py b/presentation/scheduling/kanban_board_widget.py new file mode 100644 index 0000000..c33e25d --- /dev/null +++ b/presentation/scheduling/kanban_board_widget.py @@ -0,0 +1,369 @@ +"""Kanban board for Schedule Task (R08-T11, extracted from +``ui/schedule_task_tab.py``'s ``ScheduleTaskTab`` — Kanban rendering + +drag-drop + row actions, lines 41-79/222-300/302-484/496-548 of the original +795-line file). + +Owns the 7-lane board itself. What used to be plain module-function calls +into ``core/tasks.py`` (``duplicate_task``, ``taskrepo.save_task``, +``taskrepo.delete_task``, ...) and ``self.scheduler.run_now(...)`` inline are +now calls into +``application/scheduling/task_application_service.py::TaskApplicationService`` +(R07-T04) — the drag-drop business rules (dropping on Running/Done/Scheduled) +in particular used to be ~30 lines of if/elif inside a Qt slot; now it's +``TaskApplicationService.move_to_status`` plus a few branches on its result. + +Task EDITING (opening ``TaskEditorDialog``) is deliberately NOT owned here — +``CalendarView`` needs the exact same "open the editor for this task id" +behaviour for its own click handler, so it stays a shell-level concern +(``schedule_task_tab.py``) both widgets request via a signal, instead of +being duplicated in two places. +""" +from __future__ import annotations + +from typing import Dict, List, Optional + +from PySide6.QtCore import QEvent, Qt, Signal +from PySide6.QtWidgets import ( + QAbstractItemView, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, + QMenu, QMessageBox, QScrollArea, QVBoxLayout, QWidget, +) + +from cowork_local.application.scheduling.task_application_service import ( + TaskApplicationService, +) +from cowork_local.core.tasks import STATUSES, chain_error, new_task +from cowork_local.i18n import tr +from cowork_local.infrastructure.persistence.json.task_repository_impl import ( + TaskRepository, +) +from cowork_local.presentation.scheduling.run_history_dialog import RunHistoryDialog +from cowork_local.theme import current_palette +from cowork_local.ui.osutil import open_path + +# Priority shown as a plain text tag (no colored-emoji squares). Only the +# elevated priorities get a visible marker; low/medium stay unmarked as before. +_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"} + + +class _KanbanColumn(QListWidget): + """One status lane. Accepts drops from sibling columns; a drop means + 'move this task to my status'.""" + + task_dropped = Signal(str, str) # task_id, new_status + + def __init__(self, status: str): + super().__init__() + self.status = status + self.setDragDropMode(QAbstractItemView.DragDrop) + self.setDefaultDropAction(Qt.MoveAction) + # Shift/Ctrl-click several cards in the SAME column, then right-click + # → "Delete N selected" to bulk-remove tasks instead of one at a time. + self.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.setWordWrap(True) + # Cards wrap, so there is never anything to reach by scrolling + # sideways — but QListWidget's own column hint runs 1-6px past the + # viewport; the board divides whatever width it has by seven instead + # (see KanbanBoardWidget._fit_lanes()). + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setResizeMode(QListWidget.Adjust) # re-wrap on every resize + + def dropEvent(self, event): # noqa: N802 + source = event.source() + if isinstance(source, _KanbanColumn) and source is not self: + item = source.currentItem() + tid = item.data(Qt.UserRole) if item else None + if tid: + event.acceptProposedAction() + self.task_dropped.emit(tid, self.status) + return + event.ignore() + + +class KanbanBoardWidget(QWidget): + """The 7-lane board: Backlog / Scheduled / Running / Waiting Input / + Done / Failed / Paused. Cards drag between columns (dropping = changing + status via ``TaskApplicationService.move_to_status``), double-click and + the right-click menu request an edit via :attr:`edit_requested`. + + Args: + ctx: ``AppContext`` — passed through to ``TaskEditorDialog`` callers + need it for, kept here only so callers don't have to fetch it + separately. + tasks_dir: ``None`` -> the app's default task-storage directory; + tests pass a ``tmp_path``. + scheduler: ``TaskScheduler`` (may be ``None`` — matches the original + widget's "no scheduler in tests" tolerance) used as the + ``run_now`` dispatch source for the service. + service: inject a ready-made ``TaskApplicationService`` (tests); when + ``None``, one is built from ``tasks_dir``/``scheduler``. + """ + + status_message = Signal(str) + counts_changed = Signal(dict) # status -> count, for the shell's summary label + edit_requested = Signal(str) # task_id — shell opens TaskEditorDialog + + _LANE_FLOOR_CH = 8 # roughly eight characters of a task title, plus padding + + def __init__(self, ctx, tasks_dir=None, scheduler=None, + service: Optional[TaskApplicationService] = None, parent=None): + super().__init__(parent) + self.ctx = ctx + self._tasks_dir = tasks_dir + self._repo = TaskRepository(tasks_dir) + self._service = service or TaskApplicationService( + self._repo, run_now=scheduler.run_now if scheduler is not None else None) + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + board = QWidget() + scroll.setWidget(board) + cols = QHBoxLayout(board) + cols.setSpacing(2) + self.columns: Dict[str, _KanbanColumn] = {} + self.column_headers: Dict[str, QLabel] = {} + for status in STATUSES: + box = QVBoxLayout() + box.setContentsMargins(0, 0, 0, 0) + box.setSpacing(2) + head = QLabel() + head.setStyleSheet("font-weight:600;") + col = _KanbanColumn(status) + col.setObjectName("kanbanLane") + col.task_dropped.connect(self._on_task_dropped) + col.itemDoubleClicked.connect(self._on_double_click) + col.setContextMenuPolicy(Qt.CustomContextMenu) + col.customContextMenuRequested.connect( + lambda pos, c=col: self._context_menu(c, pos)) + box.addWidget(head) + box.addWidget(col, 1) + holder = QWidget() + holder.setLayout(box) + cols.addWidget(holder) + self.columns[status] = col + self.column_headers[status] = head + root.addWidget(scroll, 1) + self._board_scroll = scroll + scroll.viewport().installEventFilter(self) + + def retranslate(self) -> None: + for status, col in self.columns.items(): + col.setToolTip(tr(f"schedtask.col_tip.{status}")) + + # ---- lane widths ------------------------------------------------------ + def eventFilter(self, obj, event): # noqa: N802 + if obj is self._board_scroll.viewport() and event.type() == QEvent.Resize: + self._fit_lanes() + return super().eventFilter(obj, event) + + def _fit_lanes(self) -> None: + floor = self.fontMetrics().averageCharWidth() * self._LANE_FLOOR_CH + 24 + for col in self.columns.values(): + if col.minimumWidth() != floor: + col.setMinimumWidth(floor) + + # ---- rendering ---------------------------------------------------------- + def _card_text(self, t: dict) -> str: + prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "") + ai = "[AI] " if t.get("is_ai_generated") else "" + sched = t.get("schedule", {}) + when = sched.get("run_at") if sched.get("enabled") else None + when_line = when or tr("schedtask.no_schedule") + chain = "" + if t.get("dependency", {}).get("next_task_id") or t.get("dependency", {}).get("previous_task_id"): + chain = " (linked)" + last = t.get("logs", {}).get("last_status") + last_line = {"success": tr("schedtask.last_success"), + "failed": tr("schedtask.last_failed")}.get(last, tr("schedtask.last_never")) + return (f"{ai}{t.get('title', '')}{chain}\n" + f"{when_line} {prio}\n{last_line}") + + def refresh(self) -> List[dict]: + """Re-render every lane from disk. Returns the full task list so the + shell can hand the same read to ``CalendarView.set_tasks`` without a + second ``list_tasks`` call.""" + all_tasks = self._repo.list() + counts = {s: 0 for s in STATUSES} + for col in self.columns.values(): + col.clear() + for t in all_tasks: + status = t.get("status", "backlog") + if status not in self.columns: + continue + counts[status] += 1 + item = QListWidgetItem(self._card_text(t)) + item.setData(Qt.UserRole, t["task_id"]) + self.columns[status].addItem(item) + pal = current_palette() + for status, col in self.columns.items(): + self.column_headers[status].setText( + f"{tr(f'schedtask.status.{status}')} ({counts[status]})") + # Dropping a card into Running STARTS the task for real, so that + # lane is outlined while it holds anything. + if status == "running" and counts[status]: + col.setStyleSheet( + f"border: 1px solid {pal.warning}; border-radius: {pal.radius}px;") + self.column_headers[status].setStyleSheet( + f"font-weight:600; color: {pal.warning};") + else: + col.setStyleSheet("") + self.column_headers[status].setStyleSheet("font-weight:600;") + if col.count() == 0: + empty = QListWidgetItem(tr("schedtask.no_tasks")) + empty.setFlags(Qt.NoItemFlags) + col.addItem(empty) + self.counts_changed.emit(counts) + return all_tasks + + # ---- actions -------------------------------------------------------- + def _on_double_click(self, item: QListWidgetItem) -> None: + tid = item.data(Qt.UserRole) + if tid: + self.edit_requested.emit(tid) + + def _on_task_dropped(self, task_id: str, new_status: str) -> None: + """Dropping a card ACTS on the task via ``TaskApplicationService. + move_to_status`` — see that method's docstring for the exact rules.""" + result = self._service.move_to_status(task_id, new_status) + if result is None: + self.refresh() + return + if result.blocked: + self.refresh() # can't drag a running task + return + if result.ran_now: + self._emit_run_now_message(result.run_now_result, + (result.task or {}).get("title", "")) + self.refresh() + return + self.refresh() + if result.needs_schedule: + # No time set yet — a silently-disabled "Scheduled" card would + # never run and look broken. Open the editor right away. + self.status_message.emit(tr("schedtask.msg_set_schedule")) + self.edit_requested.emit(task_id) + + @staticmethod + def _is_multi_selection(item, selected) -> bool: + """True when the right-clicked card is part of an existing multi-item + selection — pure boolean, kept separate from _context_menu so it's + testable without ever invoking Qt's (modal, event-loop-blocking) menu.""" + return len(selected) > 1 and item in selected + + def _context_menu(self, col: _KanbanColumn, pos) -> None: + item = col.itemAt(pos) + if item is None or not item.data(Qt.UserRole): + return + selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)] + if self._is_multi_selection(item, selected): + self._bulk_delete_menu(col, pos, selected) + return + tid = item.data(Qt.UserRole) + task = self._repo.get(tid) + if not task: + return + menu = QMenu(col) + run_act = menu.addAction(tr("schedtask.menu_run")) + edit_act = menu.addAction(tr("schedtask.menu_edit")) + dup_act = menu.addAction(tr("schedtask.menu_duplicate")) + paused = task.get("status") == "paused" + pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause")) + logs_act = menu.addAction(tr("schedtask.menu_logs")) + hist_act = menu.addAction(tr("schedtask.menu_history")) + next_act = menu.addAction(tr("schedtask.menu_create_next")) + menu.addSeparator() + del_act = menu.addAction(tr("schedtask.menu_delete")) + chosen = menu.exec(col.viewport().mapToGlobal(pos)) + if chosen == run_act: + self._emit_run_now_message(self._service.run_now(tid), task.get("title", "")) + self.refresh() + elif chosen == edit_act: + self.edit_requested.emit(tid) + elif chosen == dup_act: + self._service.duplicate(tid) + self.refresh() + elif chosen == pause_act: + self._service.toggle_pause(tid) + self.refresh() + elif chosen == logs_act: + self._view_logs(task) + elif chosen == hist_act: + RunHistoryDialog(task, self).exec() + elif chosen == next_act: + self._create_next_from_output(task) + elif chosen == del_act: + if QMessageBox.question(self, tr("schedtask.menu_delete"), + tr("schedtask.delete_confirm", title=task.get("title", "")) + ) == QMessageBox.Yes: + self._service.delete(tid) + self.refresh() + + def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None: + menu = QMenu(col) + del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected))) + chosen = menu.exec(col.viewport().mapToGlobal(pos)) + if chosen == del_act: + self._confirm_and_delete_selected(selected) + + def _confirm_and_delete_selected(self, selected) -> bool: + """Confirm, then delete every task in ``selected``. Split out of + _bulk_delete_menu so tests can drive it directly without having to + fake a real (modal, event-loop-blocking) QMenu popup.""" + if QMessageBox.question( + self, tr("schedtask.menu_delete"), + tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes: + return False + ids = [it.data(Qt.UserRole) for it in selected if it.data(Qt.UserRole)] + self._service.bulk_delete(ids) + self.refresh() + return True + + def _emit_run_now_message(self, result, title: str = "") -> None: + if result is None: + return + if result.ok: + self.status_message.emit(tr("schedtask.msg_running", title=title)) + elif result.reason == "manual_task": + self.status_message.emit(tr("schedtask.msg_manual_norun")) + elif result.reason == "no_scheduler": + self.status_message.emit(tr("schedtask.msg_no_scheduler")) + + def _view_logs(self, task: dict) -> None: + from cowork_local.core.tasks import ARTIFACTS_DIR + + run_id = task.get("logs", {}).get("last_run_id") + if not run_id: + QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) + return + folder = ARTIFACTS_DIR / task["task_id"] / run_id + if folder.exists(): + open_path(str(folder)) + else: + QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) + + def _create_next_from_output(self, task: dict) -> None: + """Scaffold a follow-up task pre-wired to consume this task's output. + Chain-cycle validation (``chain_error``) is core/tasks.py domain + logic already, not duplicated here — only the save + edit-request + wiring is this widget's job.""" + nxt = new_task(tr("schedtask.next_of", title=task.get("title", ""))) + nxt["task_type"] = "cowork" + nxt["input"]["mode"] = "previous_task_output" + nxt["input"]["previous_task_id"] = task["task_id"] + nxt["dependency"]["previous_task_id"] = task["task_id"] + err = chain_error(self._repo.list() + [nxt], task["task_id"], nxt["task_id"]) + if err: + QMessageBox.warning(self, tr("schedtask.g_dependency"), err) + return + self._repo.save(nxt) + task["dependency"]["next_task_id"] = nxt["task_id"] + task["dependency"]["pass_output_to_next"] = True + if task["dependency"].get("run_next_mode", "none") == "none": + task["dependency"]["run_next_mode"] = "run_after_success" + self._repo.save(task) + self.refresh() + self.edit_requested.emit(nxt["task_id"]) + + +__all__ = ["KanbanBoardWidget"] diff --git a/presentation/scheduling/run_history_dialog.py b/presentation/scheduling/run_history_dialog.py new file mode 100644 index 0000000..9fa2870 --- /dev/null +++ b/presentation/scheduling/run_history_dialog.py @@ -0,0 +1,72 @@ +"""RunHistoryDialog — one task's run history as a table (R08-T11, split out +of ``kanban_board_widget.py`` to keep that file under the 400-line cap; +originally ``ui/schedule_task_tab.py::_RunHistoryDialog``, lines 496-548).""" +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QAbstractItemView, QDialog, QDialogButtonBox, QLabel, QTableWidget, + QTableWidgetItem, QVBoxLayout, +) + +from cowork_local.i18n import tr +from cowork_local.ui.osutil import open_path + + +class RunHistoryDialog(QDialog): + """Run history of one task as a table (newest first): time, status, error; + double-click a row to open that run's artifact folder.""" + + def __init__(self, task: dict, parent=None): + super().__init__(parent) + self._task = task + self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}") + self.resize(620, 380) + root = QVBoxLayout(self) + hint = QLabel(tr("schedtask.hist_hint")) + hint.setObjectName("hint") + root.addWidget(hint) + + runs = list(reversed(task.get("runs", []) or [])) + self.table = QTableWidget(len(runs), 4) + self.table.setHorizontalHeaderLabels([ + tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"), + tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"), + ]) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + for row, run in enumerate(runs): + cells = ( + run.get("finished_at", ""), + str(run.get("status", "")), + run.get("run_id", ""), + (run.get("error") or "")[:200], + ) + for col, text in enumerate(cells): + item = QTableWidgetItem(str(text)) + if col == 0: + item.setData(Qt.UserRole, run.get("run_id", "")) + self.table.setItem(row, col, item) + self.table.resizeColumnsToContents() + self.table.horizontalHeader().setStretchLastSection(True) + self.table.itemDoubleClicked.connect(self._open_artifact) + root.addWidget(self.table, 1) + + buttons = QDialogButtonBox(QDialogButtonBox.Close) + buttons.rejected.connect(self.reject) + buttons.accepted.connect(self.accept) + root.addWidget(buttons) + + def _open_artifact(self, item: QTableWidgetItem) -> None: + from cowork_local.core.tasks import ARTIFACTS_DIR + + first = self.table.item(item.row(), 0) + run_id = first.data(Qt.UserRole) if first else "" + if not run_id: + return + folder = ARTIFACTS_DIR / self._task["task_id"] / run_id + if folder.exists(): + open_path(str(folder)) + + +__all__ = ["RunHistoryDialog"] diff --git a/presentation/scheduling/schedule_task_tab.py b/presentation/scheduling/schedule_task_tab.py new file mode 100644 index 0000000..6b19c91 --- /dev/null +++ b/presentation/scheduling/schedule_task_tab.py @@ -0,0 +1,185 @@ +"""ScheduleTaskTab shell (R08-T11) — assembles +``kanban_board_widget.py::KanbanBoardWidget`` and +``calendar_view_widget.py::CalendarView`` behind the header/view-switch that +used to be inline in ``ui/schedule_task_tab.py`` (lines 81-245 of the +original 795-line file: header, view-tab wiring, lane-fit event filter moved +into the Kanban widget itself, the belt-and-braces 10s refresh timer). + +Task EDITING (opening ``TaskEditorDialog``) lives HERE, not in either child +widget, because both need the exact same "open the editor for this task id" +behaviour — Kanban's double-click/edit-menu and Calendar's task click both +request it via a signal instead of each importing ``TaskEditorDialog`` +themselves. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import QTimer, Signal +from PySide6.QtWidgets import ( + QHBoxLayout, QLabel, QPushButton, QSizePolicy, QStackedWidget, + QTabBar, QVBoxLayout, QWidget, +) + +from cowork_local.core.tasks import STATUSES, list_tasks, load_task, new_task, save_task +from cowork_local.i18n import on_language_changed, tr +from cowork_local.presentation.scheduling.calendar_view_widget import CalendarView +from cowork_local.presentation.scheduling.kanban_board_widget import KanbanBoardWidget +from cowork_local.state import AppContext +from cowork_local.ui.icons import icon + +_VIEWS = ("kanban", "calendar") + + +class ScheduleTaskTab(QWidget): + status_message = Signal(str) + + def __init__(self, ctx: AppContext, scheduler=None, tasks_dir: Optional[Path] = None): + super().__init__() + self.ctx = ctx + self.scheduler = scheduler # TaskScheduler (may be None in tests) + # None -> the app's default TASKS_DIR (core/tasks.py). Overridable + # (new in R08-T11; the original monolithic tab hardcoded None with no + # way to point it at a tmp_path) so this shell is actually testable + # without touching the user's real config folder — same shape + # TaskScheduler.__init__ already accepts. + self._tasks_dir: Optional[Path] = tasks_dir + + root = QVBoxLayout(self) + + # ---- header ---------------------------------------------------- + header = QHBoxLayout() + self._title = QLabel() + self._title.setStyleSheet("font-weight:700; font-size:15px;") + self.counts_lbl = QLabel("") + self.counts_lbl.setObjectName("hint") + self.counts_lbl.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) + self.counts_lbl.setMinimumWidth(0) + self.add_btn = QPushButton() + self.add_btn.setIcon(icon("plus")) + self.add_btn.setObjectName("primary") + self.add_btn.clicked.connect(self._add_task) + self.ai_btn = QPushButton() + self.ai_btn.setIcon(icon("sparkle")) + self.ai_btn.clicked.connect(self._ai_create) + # Two views of the same tasks, so they read as a pair of tabs rather + # than a drop-list you have to open to discover the Calendar exists. + self.view_tabs = QTabBar() + self.view_tabs.setObjectName("viewTabs") + self.view_tabs.setDrawBase(False) + self.view_tabs.setExpanding(False) + for _v in _VIEWS: + self.view_tabs.addTab("") + self.view_tabs.currentChanged.connect(self._on_view_changed) + header.addWidget(self._title) + header.addWidget(self.counts_lbl, 1) + header.addWidget(self.view_tabs) + header.addWidget(self.add_btn) + header.addWidget(self.ai_btn) + root.addLayout(header) + + # ---- board / calendar (two views of the SAME tasks) ----------------- + self._view_stack = QStackedWidget() + self.kanban = KanbanBoardWidget(ctx, tasks_dir=self._tasks_dir, scheduler=scheduler) + self.kanban.status_message.connect(self.status_message.emit) + self.kanban.counts_changed.connect(self._on_counts_changed) + self.kanban.edit_requested.connect(self._edit_task) + self._view_stack.addWidget(self.kanban) + self.calendar = CalendarView() + self.calendar.edit_task.connect(self._edit_task) + self.calendar.add_task_on_date.connect(self._add_task_on_date) + self._view_stack.addWidget(self.calendar) + root.addWidget(self._view_stack, 1) + + if self.scheduler is not None: + self.scheduler.tasks_changed.connect(self.refresh) + self.scheduler.task_started.connect(lambda _tid: self.refresh()) + self.scheduler.task_finished.connect(lambda _tid, _ok: self.refresh()) + + # Belt-and-braces: also re-read the board every 10s so a card's lane + # ALWAYS reflects reality (Scheduled → Running → Done) even if some + # change slipped past the signals (e.g. task files edited externally). + self._refresh_timer = QTimer(self) + self._refresh_timer.setInterval(10_000) + self._refresh_timer.timeout.connect(self.refresh) + self._refresh_timer.start() + + self.refresh() + on_language_changed(self._retranslate) + + # ---- i18n ------------------------------------------------------------ + def _retranslate(self) -> None: + self._title.setText(tr("schedtask.title")) + self.add_btn.setText(tr("schedtask.add_btn")) + self.add_btn.setToolTip(tr("schedtask.add_tooltip")) + self.ai_btn.setText(tr("schedtask.ai_btn")) + self.ai_btn.setToolTip(tr("schedtask.ai_tooltip")) + for i, v in enumerate(_VIEWS): + self.view_tabs.setTabText(i, tr(f"schedtask.view.{v}")) + self.kanban.retranslate() + self.refresh() + + # ---- Kanban / Calendar view switch -------------------------------- + def _on_view_changed(self) -> None: + self._view_stack.setCurrentIndex(self.view_tabs.currentIndex()) + + def _on_counts_changed(self, counts: dict) -> None: + summary = " ".join( + f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s]) + self.counts_lbl.setText(summary) + self.counts_lbl.setToolTip(summary) # full text stays reachable if clipped + + def refresh(self) -> None: + all_tasks = self.kanban.refresh() + self.calendar.set_tasks(all_tasks) + + # ---- task creation / editing (shared by Kanban + Calendar) ----------- + def _save_and_refresh(self, task: dict) -> None: + save_task(task, self._tasks_dir) + self.refresh() + + def _add_task(self) -> None: + from cowork_local.ui.task_editor_dialog import TaskEditorDialog + + dlg = TaskEditorDialog(None, list_tasks(self._tasks_dir), self, ctx=self.ctx) + if dlg.exec() and dlg.edited_task: + self._save_and_refresh(dlg.edited_task) + self.status_message.emit(tr("schedtask.msg_created")) + + def _edit_task(self, task_id: str) -> None: + from cowork_local.ui.task_editor_dialog import TaskEditorDialog + + task = load_task(task_id, self._tasks_dir) + if not task: + return + dlg = TaskEditorDialog(task, list_tasks(self._tasks_dir), self, ctx=self.ctx) + if dlg.exec() and dlg.edited_task: + self._save_and_refresh(dlg.edited_task) + + def _add_task_on_date(self, date_str: str) -> None: + """Create a task pre-filled with the clicked calendar date (default + 09:00) — same editor Add Task opens, nothing is saved until confirmed.""" + from cowork_local.ui.task_editor_dialog import TaskEditorDialog + + t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"}) + dlg = TaskEditorDialog(t, list_tasks(self._tasks_dir), self, ctx=self.ctx) + if dlg.exec() and dlg.edited_task: + self._save_and_refresh(dlg.edited_task) + self.status_message.emit(tr("schedtask.msg_created")) + + # ---- AI create ---------------------------------------------------------- + def _ai_create(self) -> None: + from cowork_local.presentation.scheduling.ai_task_creator_dialog import ( + AiTaskCreatorDialog, + ) + + dlg = AiTaskCreatorDialog(self.ctx, self) + if dlg.exec() and dlg.created_tasks: + for t in dlg.created_tasks: + save_task(t, self._tasks_dir) + self.refresh() + self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks))) + + +__all__ = ["ScheduleTaskTab"] diff --git a/presentation/shared/__init__.py b/presentation/shared/__init__.py new file mode 100644 index 0000000..387e5e2 --- /dev/null +++ b/presentation/shared/__init__.py @@ -0,0 +1,14 @@ +"""Small pieces shared across more than one presentation screen (EPIC R08). + +Kept intentionally minimal — this is NOT a dumping ground for every reusable +widget (``ui/icons.py``, ``ui/widgets.py``, ``ui/routing_toggle.py`` stay +where they are; migrating those is a separate concern from R08-T12/T14). +Only ``HAS_WEB_ENGINE`` lives here so far — it was one module-level flag +duplicated between two God files being split by two different R08 tasks +(``ui/folder_tab.py`` and ``ui/structure_graph_view.py``), and a shared +constant beats one screen importing another screen's module. +""" + +from .web_engine_support import HAS_WEB_ENGINE + +__all__ = ["HAS_WEB_ENGINE"] diff --git a/presentation/shared/web_engine_support.py b/presentation/shared/web_engine_support.py new file mode 100644 index 0000000..39f5ef4 --- /dev/null +++ b/presentation/shared/web_engine_support.py @@ -0,0 +1,38 @@ +"""HAS_WEB_ENGINE — whether ``QWebEngineView`` is safe to construct here +(R08-T12/T14, extracted from ``ui/structure_graph_view.py``, lines 25-48 of +its original 1035-line version — the ONLY place this detection logic lived; +``ui/folder_tab.py`` used to import it FROM that module via a try/except). +""" +from __future__ import annotations + +import sys +from pathlib import Path + + +def _frozen_onefile() -> bool: + """True only for a PyInstaller ONEFILE build. Onefile extracts itself to a + temp dir (sys._MEIPASS under %TEMP%), where the QtWebEngine helper process + can't run — creating a QWebEngineView hard-crashes the app (reported as + "click the graph tab → app closes"). A ONEDIR build keeps _MEIPASS as the + ``_internal`` folder right next to the exe, where WebEngine works fine, so + it keeps the full embedded D3/HTML view.""" + if not getattr(sys, "frozen", False): + return False + meipass = getattr(sys, "_MEIPASS", "") + if not meipass: + return False + try: + return Path(meipass).resolve().parent != Path(sys.executable).resolve().parent + except OSError: # can't tell → play safe: use the native/fallback view + return True + + +try: # WebEngine + WebChannel are optional PySide6 add-ons + from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401 + from PySide6.QtWebChannel import QWebChannel # noqa: F401 + HAS_WEB_ENGINE = not _frozen_onefile() +except Exception: # pragma: no cover + HAS_WEB_ENGINE = False + + +__all__ = ["HAS_WEB_ENGINE"] diff --git a/tests/integration/test_dashboard_tab.py b/tests/integration/test_dashboard_tab.py new file mode 100644 index 0000000..7ce5f35 --- /dev/null +++ b/tests/integration/test_dashboard_tab.py @@ -0,0 +1,142 @@ +"""EPIC R08-T13: TokenUsageCardWidget / UsageChartWidget / HabitsWidget / +DashboardTab shell, real Qt offscreen. + +``DashboardQueryService``'s own logic is unit tested (R08-T13, no Qt) in +``tests/unit/test_dashboard_query_service.py``; this file proves the three +widgets and the shell are actually wired to it and to each other (the period +selector living on ``UsageChartWidget`` driving all three). +""" +from __future__ import annotations + +import json +import os +from datetime import date + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from cowork_local.config import AppConfig # noqa: E402 +from cowork_local.core import usage_tracker as ut # noqa: E402 +from cowork_local.state import AppContext # noqa: E402 + +pytest.importorskip("PySide6", reason="Qt is required for the integration suite") + + +@pytest.fixture(scope="module") +def qt_app(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def usage_dir(tmp_path, monkeypatch): + d = tmp_path / "usage" + monkeypatch.setattr(ut, "USAGE_DIR", d) + return d + + +@pytest.fixture +def ctx(qt_app, tmp_path): + return AppContext(AppConfig.load(tmp_path / "config.json")) + + +def _write_event(usage_dir, day: date, **overrides): + usage_dir.mkdir(parents=True, exist_ok=True) + event = { + "ts": f"{day.isoformat()}T10:00:00", "source": "cowork", "label": "Test chat", + "provider": "anthropic", "model": "claude-sonnet-4-6", + "in": 100, "out": 50, "cache": 0, "estimated": False, + "account": "", "machine": "", + } + event.update(overrides) + path = usage_dir / f"{day.isoformat()}.jsonl" + with path.open("a", encoding="utf-8") as f: + f.write(json.dumps(event) + "\n") + + +def test_dashboard_tab_builds_and_populates_cards(usage_dir, ctx): + from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab + + _write_event(usage_dir, date.today()) + tab = DashboardTab(ctx) # refresh() runs once at construction + + assert "100" in tab.token_cards.card_in.value_lbl.text() \ + or tab.token_cards.card_in.value_lbl.text() # non-crashing, has SOME text + assert tab.token_cards.card_total.value_lbl.text() != "" + + +def test_period_navigation_on_chart_refreshes_the_whole_shell(usage_dir, ctx): + from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab + + _write_event(usage_dir, date.today()) + tab = DashboardTab(ctx) + calls = [] + tab.refresh = lambda *a, orig=tab.refresh: (calls.append(1), orig(*a))[-1] + + tab.chart._chart_prev() + + assert calls == [1] + + +def test_granularity_change_resets_offset_to_current(usage_dir, ctx): + from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab + from cowork_local.presentation.dashboard.usage_chart_widget import UsageChartWidget + + tab = DashboardTab(ctx) + tab.chart._chart_offset = -3 + + idx = tab.chart.gran_combo.findData("month") + tab.chart.gran_combo.setCurrentIndex(idx) + + assert tab.chart.chart_offset == 0 + + +def test_currency_change_persists_and_triggers_refresh(usage_dir, ctx): + from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab + + tab = DashboardTab(ctx) + idx = tab.chart.currency_combo.findData("EUR") \ + if tab.chart.currency_combo.findData("EUR") >= 0 else 0 + seen = [] + tab.chart.currency_changed.connect(lambda: seen.append(1)) + + tab.chart.currency_combo.setCurrentIndex(idx) + + if tab.chart.currency_combo.currentData() != "USD": + assert seen == [1] + assert ctx.config.data["usage"]["currency"] == tab.chart.currency_combo.currentData() + + +def test_habits_widget_ai_analyze_noop_without_data(usage_dir, ctx): + """No events in range -> emits a status message instead of starting a + background worker (matches the original _ai_analyze guard).""" + from cowork_local.presentation.dashboard.habits_widget import HabitsWidget + from cowork_local.application.monitoring import DashboardQueryService + + query = DashboardQueryService(ctx) + widget = HabitsWidget(ctx, query) + widget.refresh(date(2020, 1, 1), date(2020, 1, 1)) + messages = [] + widget.status_message.connect(messages.append) + + widget._ai_analyze() + + assert messages # "no data" status, no worker started + assert widget._ai_worker is None + + +def test_budget_apply_updates_budget_card(usage_dir, ctx): + from cowork_local.application.monitoring import DashboardQueryService + from cowork_local.presentation.dashboard.token_usage_card_widget import TokenUsageCardWidget + + query = DashboardQueryService(ctx) + widget = TokenUsageCardWidget(ctx, query) + widget.budget_card.budget_spin.setValue(50.0) + + widget._apply_budget() + + status = query.budget_status() + assert status is not None + assert status["amount_usd"] == pytest.approx(50.0) diff --git a/tests/integration/test_folder_tab.py b/tests/integration/test_folder_tab.py new file mode 100644 index 0000000..9be67c4 --- /dev/null +++ b/tests/integration/test_folder_tab.py @@ -0,0 +1,193 @@ +"""EPIC R08-T12: WorkspaceFileTree / DocumentPreviewManager / FolderTab, +real Qt offscreen. + +Scope note: the AI-Edit panel's send -> plan -> edit -> apply pipeline +(``ai_edit_pipeline.py``) runs on a real ``core.worker.AgentWorker`` QThread +and had ZERO existing tests before this task (confirmed by grep — nothing +under tests/ exercised ``ui/folder_tab.py``'s AI methods except the routing +surface test, fixed alongside this task in +``tests/integration/test_routing_surfaces.py``). Driving that full async +pipeline end to end is out of scope here; what's covered is everything that +doesn't need a live QThread: navigation, preview rendering, and — the +concrete R08-T12 deliverable — that saves/creates actually go through +``FileWorkspaceService`` (containment enforced, not just "writes a file"). +""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from cowork_local.config import AppConfig # noqa: E402 +from cowork_local.state import AppContext # noqa: E402 + +pytest.importorskip("PySide6", reason="Qt is required for the integration suite") + + +@pytest.fixture(scope="module") +def qt_app(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def ctx(qt_app, tmp_path: Path): + return AppContext(AppConfig.load(tmp_path / "config.json")) + + +@pytest.fixture +def root(tmp_path: Path) -> Path: + d = tmp_path / "workspace" + d.mkdir() + return d + + +# ---- WorkspaceFileTree ----------------------------------------------------- # +def test_workspace_file_tree_set_root_updates_label_and_model(qt_app, root): + from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree + + tree = WorkspaceFileTree(str(root)) + other = root.parent / "other-root" + other.mkdir() + + tree.set_root(str(other)) + + assert tree.root == str(other) + assert tree.path_lbl.text() == str(other) + + +def test_workspace_file_tree_ignores_an_invalid_root(qt_app, root): + from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree + + tree = WorkspaceFileTree(str(root)) + tree.set_root(str(root / "does-not-exist")) + + assert tree.root == str(root) # unchanged + + +def test_workspace_file_tree_click_emits_file_selected(qt_app, root): + from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree + + f = root / "a.txt" + f.write_text("hello", encoding="utf-8") + tree = WorkspaceFileTree(str(root)) + seen = [] + tree.file_selected.connect(seen.append) + + index = tree.model.index(str(f)) + tree._on_tree_clicked(index) + + assert seen and os.path.normpath(seen[0]) == os.path.normpath(str(f)) + + +# ---- DocumentPreviewManager ------------------------------------------------- # +def test_open_file_renders_code_into_the_editor(qt_app, root): + from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager + + f = root / "script.py" + f.write_text("print('hi')\n", encoding="utf-8") + preview = DocumentPreviewManager(str(root)) + + preview.open_file(str(f)) + + assert preview.stack.currentWidget() is preview.editor + assert preview.editor.toPlainText() == "print('hi')\n" + assert preview.current_file == str(f) + + +def test_open_file_on_a_binary_file_shows_the_placeholder(qt_app, root): + from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager + + f = root / "data.bin" + f.write_bytes(b"\x00\x01\x02binary") + preview = DocumentPreviewManager(str(root)) + + preview.open_file(str(f)) + + assert preview.stack.currentWidget() is preview._placeholder + + +def test_save_writes_through_file_workspace_service(qt_app, root): + """The concrete R08-T12/R06-T05 deliverable: FileWorkspaceService (built + at R06, unused until this task) is now the actual write path.""" + from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager + + f = root / "note.txt" + f.write_text("old", encoding="utf-8") + preview = DocumentPreviewManager(str(root)) + preview.open_file(str(f)) + + preview.editor.setPlainText("new content") + preview.save() + + assert f.read_text(encoding="utf-8") == "new content" + + +def test_create_new_file_writes_inside_root_via_file_workspace_service(qt_app, root): + from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager + + preview = DocumentPreviewManager(str(root)) + + dest = preview.create_new_file("sub/new.md", "# hello") + + assert dest == str(root / "sub" / "new.md") + assert (root / "sub" / "new.md").read_text(encoding="utf-8") == "# hello" + assert preview.current_file == dest # opened it, kept the AI chat (reset_ai=False) + + +def test_create_new_file_rejects_a_path_escaping_the_root(qt_app, root): + from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager + + preview = DocumentPreviewManager(str(root)) + messages = [] + preview.status_message.connect(messages.append) + + dest = preview.create_new_file("../escaped.txt", "nope") + + assert dest is None + assert not (root.parent / "escaped.txt").exists() + assert messages # a save_error status was emitted + + +def test_write_content_persists_and_refreshes_html_preview(qt_app, root): + from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager + + f = root / "page.html" + f.write_text("

old

", encoding="utf-8") + preview = DocumentPreviewManager(str(root)) + preview.open_file(str(f)) # HTML opens in preview mode by default + + preview.write_content("

new

") + + assert f.read_text(encoding="utf-8") == "

new

" + + +# ---- FolderTab shell -------------------------------------------------------- # +def test_folder_tab_builds_and_wires_tree_to_preview(ctx, root): + from cowork_local.presentation.folder.folder_tab import FolderTab + + tab = FolderTab(ctx) + tab.set_root(str(root)) + f = root / "hello.py" + f.write_text("x = 1\n", encoding="utf-8") + + tab.tree.file_selected.emit(str(f)) # simulate a tree click + + assert tab.preview.current_file == str(f) + assert tab.preview.editor.toPlainText() == "x = 1\n" + + +def test_folder_tab_ai_panel_toggle_updates_button_badge(ctx, root): + from cowork_local.presentation.folder.folder_tab import FolderTab + + tab = FolderTab(ctx) + assert tab.ai_panel.isHidden() + + tab.ai_btn.setChecked(True) + tab._toggle_ai_panel() + + assert not tab.ai_panel.isHidden() diff --git a/tests/integration/test_routing_surfaces.py b/tests/integration/test_routing_surfaces.py index 6cf11fb..ecaaf90 100644 --- a/tests/integration/test_routing_surfaces.py +++ b/tests/integration/test_routing_surfaces.py @@ -6,7 +6,7 @@ of that algorithm now call it, on real (offscreen) widgets: * ``ui/chat_panel.py::_apply_routing`` (Cowork) * ``ui/co4e_tab.py::_apply_co4e_routing`` (Co4E) -* ``ui/folder_tab.py::_ai_apply_routing`` (AI-Edit) +* ``presentation/folder/ai_edit_model_resolver.py::AiEditModelResolver.apply_routing`` (AI-Edit) It also pins the Manual-mode handshake, including the field contract the existing confirm dialog reads off the decision - the one place where the new @@ -189,15 +189,20 @@ def test_co4e_returns_an_empty_model_when_routing_is_off(ctx): # AI-Edit # --------------------------------------------------------------------------- # def test_ai_edit_routes_on_its_own_surface_key(ctx): - from cowork_local.ui.folder_tab import FolderTab + """R08-T12: the routing call this test pins moved from + ``ui/folder_tab.py::FolderTab._ai_apply_routing`` to + ``presentation/folder/ai_edit_model_resolver.py::AiEditModelResolver. + apply_routing`` - same RoutingApplicationService call, same surface key, + now independently testable without the whole FolderTab widget tree.""" + from cowork_local.presentation.folder.folder_tab import FolderTab router = _install(ctx, "auto") tab = FolderTab(ctx) - tab._ai_apply_routing("rename this variable") + tab.ai_panel.resolver.apply_routing("rename this variable") assert router.surfaces == ["ai_edit"] - assert (tab._ai_routed_provider, tab._ai_routed_model) == ( + assert (tab.ai_panel.resolver.routed_provider, tab.ai_panel.resolver.routed_model) == ( "anthropic", "claude-sonnet-4-6") @@ -206,7 +211,7 @@ def test_ai_edit_pins_the_coding_task_type(ctx): classification entirely - the constraint has to survive the move into the shared service or it is silently dropped.""" from cowork_local.core.routing.models import TaskType - from cowork_local.ui.folder_tab import FolderTab + from cowork_local.presentation.folder.folder_tab import FolderTab seen: List[Any] = [] @@ -219,7 +224,7 @@ def test_ai_edit_pins_the_coding_task_type(ctx): _Recorder(), mode_reader=lambda _s: "auto") tab = FolderTab(ctx) - tab._ai_apply_routing("rename this variable") + tab.ai_panel.resolver.apply_routing("rename this variable") assert seen == [TaskType.CODING] diff --git a/tests/integration/test_schedule_task_tab.py b/tests/integration/test_schedule_task_tab.py new file mode 100644 index 0000000..04b2861 --- /dev/null +++ b/tests/integration/test_schedule_task_tab.py @@ -0,0 +1,125 @@ +"""EPIC R08-T11: ScheduleTaskTab shell + KanbanBoardWidget, real Qt offscreen. + +Drives the real widgets end to end (build -> refresh -> drag-drop rule via +TaskApplicationService -> refresh) against a tmp_path task repository, the +way ``test_history_dir_race.py`` proves R06-T04 against real Qt rather than +a double. ``TaskApplicationService``'s own business rules are already unit +tested (R07-T04); this file exists to prove the WIDGET is actually wired to +that service, not to re-test the rules themselves. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from cowork_local.config import AppConfig # noqa: E402 +from cowork_local.infrastructure.persistence.json.task_repository_impl import ( # noqa: E402 + TaskRepository, +) +from cowork_local.state import AppContext # noqa: E402 + +pytest.importorskip("PySide6", reason="Qt is required for the integration suite") + + +@pytest.fixture(scope="module") +def qt_app(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def ctx(qt_app, tmp_path: Path): + return AppContext(AppConfig.load(tmp_path / "config.json")) + + +@pytest.fixture +def tasks_dir(tmp_path: Path) -> Path: + d = tmp_path / "tasks" + d.mkdir() + return d + + +def test_schedule_task_tab_builds_and_refreshes_with_no_tasks(ctx, tasks_dir): + from cowork_local.presentation.scheduling.schedule_task_tab import ScheduleTaskTab + + tab = ScheduleTaskTab(ctx, scheduler=None, tasks_dir=tasks_dir) + tab.refresh() # must not raise against an empty repo + + assert tab.kanban.columns.keys() # 7 lanes were built + + +def test_kanban_renders_a_task_into_its_status_lane(ctx, tasks_dir): + from cowork_local.presentation.scheduling.kanban_board_widget import KanbanBoardWidget + + repo = TaskRepository(tasks_dir) + task = repo.create("My Task", task_type="cowork") + task["status"] = "backlog" + repo.save(task) + + board = KanbanBoardWidget(ctx, tasks_dir=tasks_dir) + board.refresh() + + from PySide6.QtCore import Qt + + backlog_ids = [board.columns["backlog"].item(i).data(Qt.UserRole) + for i in range(board.columns["backlog"].count())] + assert task["task_id"] in backlog_ids + + +def test_dropping_a_card_on_done_disables_its_schedule_through_the_real_widget(ctx, tasks_dir): + """Same rule TaskApplicationService.move_to_status covers at the unit + level (R07-T04) — this proves the Kanban widget's drop handler actually + calls it, end to end, with a real TaskRepository on disk.""" + from cowork_local.presentation.scheduling.kanban_board_widget import KanbanBoardWidget + + repo = TaskRepository(tasks_dir) + task = repo.create("Recurring", task_type="cowork") + task["schedule"]["enabled"] = True + task["schedule"]["run_at"] = "2026-08-28 09:00" + task["schedule"]["repeat_type"] = "daily" + task["status"] = "scheduled" + repo.save(task) + + board = KanbanBoardWidget(ctx, tasks_dir=tasks_dir) + board.refresh() + + board._on_task_dropped(task["task_id"], "done") + + on_disk = repo.get(task["task_id"]) + assert on_disk["status"] == "done" + assert on_disk["schedule"]["enabled"] is False + + +def test_kanban_edit_requested_is_wired_to_the_shells_edit_task(ctx, tasks_dir, monkeypatch): + """Proves ScheduleTaskTab actually connects + ``kanban.edit_requested -> self._edit_task`` (not just that the Kanban + widget emits the signal in isolation) by monkeypatching the dialog class + ``_edit_task`` opens and checking it was constructed for the right task.""" + import cowork_local.ui.task_editor_dialog as task_editor_dialog_module + from cowork_local.presentation.scheduling.schedule_task_tab import ScheduleTaskTab + + repo = TaskRepository(tasks_dir) + task = repo.create("Editable") + repo.save(task) + + seen_task_ids = [] + + class _FakeDialog: + def __init__(self, task, all_tasks, parent, ctx): + seen_task_ids.append(task["task_id"] if task else None) + self.edited_task = None + + def exec(self): + return False # Cancel — nothing further should happen + + monkeypatch.setattr(task_editor_dialog_module, "TaskEditorDialog", _FakeDialog) + + tab = ScheduleTaskTab(ctx, scheduler=None, tasks_dir=tasks_dir) + tab.kanban.edit_requested.emit(task["task_id"]) + + assert seen_task_ids == [task["task_id"]] diff --git a/tests/integration/test_structure_graph_view.py b/tests/integration/test_structure_graph_view.py new file mode 100644 index 0000000..68cc63c --- /dev/null +++ b/tests/integration/test_structure_graph_view.py @@ -0,0 +1,162 @@ +"""EPIC R08-T14: GraphRenderer / GraphQaWidget / StructureGraphView shell, +real Qt offscreen. + +Scope note: a real directory SCAN (``GraphRenderer._scan``) runs on a +``core.worker.AgentWorker`` QThread and had no existing test before this +task either (grep confirms nothing under tests/ exercised +``ui/structure_graph_view.py``). These tests drive ``_render()`` directly +with a hand-built ``StructureGraph`` instead of a live scan — enough to +prove the renderer <-> Q&A wiring (the actual R08-T14 deliverable) without +needing a real codebase to walk. +""" +from __future__ import annotations + +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from cowork_local.config import AppConfig # noqa: E402 +from cowork_local.core.structure_graph import GEdge, GNode, StructureGraph # noqa: E402 +from cowork_local.state import AppContext # noqa: E402 + +pytest.importorskip("PySide6", reason="Qt is required for the integration suite") + + +@pytest.fixture(scope="module") +def qt_app(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def ctx(qt_app, tmp_path): + return AppContext(AppConfig.load(tmp_path / "config.json")) + + +def _fake_graph(tmp_path): + f = tmp_path / "mod.py" + f.write_text("def hello():\n pass\n", encoding="utf-8") + nodes = [ + GNode(id="n1", label="mod.py", kind="file", detail="a module", path=str(f)), + GNode(id="n2", label="hello", kind="function", detail="says hi", path=str(f)), + ] + edges = [GEdge(source="n1", target="n2", type="defines")] + return StructureGraph(nodes=nodes, edges=edges) + + +def test_structure_graph_view_builds(ctx): + from cowork_local.presentation.graph.structure_graph_view import StructureGraphView + + view = StructureGraphView(ctx) + assert view.renderer is not None + assert view.qa is not None + + +def test_render_populates_the_scene_and_emits_graph_rendered(ctx, tmp_path): + from cowork_local.presentation.graph.graph_renderer import GraphRenderer + + renderer = GraphRenderer(ctx) + graph = _fake_graph(tmp_path) + seen = [] + renderer.graph_rendered.connect(lambda: seen.append(1)) + + renderer._render({"graph": graph, "pos": {"n1": (0, 0), "n2": (100, 0)}, "seq": renderer._scan_seq}) + + assert renderer.graph is graph + assert len(renderer._node_items) == 2 + assert seen == [1] + + +def test_render_ignores_a_stale_scan_result(ctx, tmp_path): + """Only the LATEST scan's result is ever rendered (no stale overwrite).""" + from cowork_local.presentation.graph.graph_renderer import GraphRenderer + + renderer = GraphRenderer(ctx) + first = _fake_graph(tmp_path) + renderer._render({"graph": first, "pos": {}, "seq": renderer._scan_seq}) + renderer._scan_seq += 1 # a second scan started + + stale = StructureGraph(nodes=[], edges=[]) + renderer._render({"graph": stale, "pos": {}, "seq": renderer._scan_seq - 1}) + + assert renderer.graph is first # stale result was dropped + + +def test_node_selection_updates_the_qa_detail_panel(ctx, tmp_path): + from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget + from cowork_local.presentation.graph.graph_renderer import GraphRenderer + + renderer = GraphRenderer(ctx) + graph = _fake_graph(tmp_path) + renderer._render({"graph": graph, "pos": {"n1": (0, 0), "n2": (100, 0)}, "seq": renderer._scan_seq}) + qa = GraphQaWidget(ctx, renderer) + + node = graph.nodes[1] + renderer.node_selected.emit(node) + + assert "hello" in qa.detail.toPlainText() + assert "says hi" in qa.detail.toPlainText() + + +def test_candidate_file_paths_uses_selected_nodes_when_present(ctx, tmp_path): + from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget + from cowork_local.presentation.graph.graph_renderer import GraphRenderer + + renderer = GraphRenderer(ctx) + graph = _fake_graph(tmp_path) + renderer._render({"graph": graph, "pos": {"n1": (0, 0), "n2": (100, 0)}, "seq": renderer._scan_seq}) + qa = GraphQaWidget(ctx, renderer) + + # No selection -> every file node in the graph (both nodes share one + # file here, so this also proves the path-dedup in _candidate_file_paths). + assert qa._candidate_file_paths() == [graph.nodes[0].path] + + renderer._node_items[0].setSelected(True) + assert qa._candidate_file_paths() == [graph.nodes[0].path] + + +def test_project_change_clears_the_qa_extraction_cache(ctx, tmp_path): + from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget + from cowork_local.presentation.graph.graph_renderer import GraphRenderer + + renderer = GraphRenderer(ctx) + qa = GraphQaWidget(ctx, renderer) + qa._extract_cache = {"some/path.py": "cached text"} + + renderer.project_changed.emit() + + assert qa._extract_cache == {} + + +def test_qa_collapse_resizes_the_shells_splitter(ctx): + """Exact pixel width is Qt's splitter-layout arithmetic, not this code's + concern - what matters is that collapsing narrows the QA pane a lot + (down near the strip's width) while the strip itself becomes visible and + the total splitter width is conserved.""" + from cowork_local.presentation.graph.structure_graph_view import StructureGraphView + from cowork_local.ui.widgets import CollapseStrip + + view = StructureGraphView(ctx) + before = view._split.sizes() + + view.qa._set_collapsed(True) + + after = view._split.sizes() + assert after[1] <= CollapseStrip.WIDTH + 2 + assert view.qa.maximumWidth() == CollapseStrip.WIDTH + 2 + assert sum(after) == sum(before) # total width conserved, just redistributed + + +def test_hide_event_clears_extracts(ctx): + from cowork_local.presentation.graph.structure_graph_view import StructureGraphView + + view = StructureGraphView(ctx) + view.qa._extract_cache = {"x": "y"} + + from PySide6.QtGui import QHideEvent + view.hideEvent(QHideEvent()) + + assert view.qa._extract_cache == {} diff --git a/tests/unit/test_dashboard_query_service.py b/tests/unit/test_dashboard_query_service.py new file mode 100644 index 0000000..4801cc9 --- /dev/null +++ b/tests/unit/test_dashboard_query_service.py @@ -0,0 +1,103 @@ +"""EPIC R08-T13: DashboardQueryService — no Qt. + +``core/usage_tracker.py::USAGE_DIR`` is a module-level constant (not +injectable per-call except through an explicit ``directory=`` kwarg +``load_events`` alone accepts) — this is a pre-existing testability gap the +original ``ui/dashboard_tab.py`` also had (it had zero tests before this +task). Monkeypatching the module attribute is what lets these tests write +usage events without touching the real ``~/.cowork_local/usage/``. +""" +from __future__ import annotations + +import json +from datetime import date, timedelta + +import pytest + +from cowork_local.application.monitoring import DashboardQueryService +from cowork_local.config import AppConfig +from cowork_local.core import usage_tracker as ut +from cowork_local.state import AppContext + + +@pytest.fixture +def usage_dir(tmp_path, monkeypatch): + d = tmp_path / "usage" + monkeypatch.setattr(ut, "USAGE_DIR", d) + return d + + +@pytest.fixture +def ctx(tmp_path): + return AppContext(AppConfig.load(tmp_path / "config.json")) + + +def _write_event(usage_dir, day: date, **overrides): + usage_dir.mkdir(parents=True, exist_ok=True) + event = { + "ts": f"{day.isoformat()}T10:00:00", "source": "cowork", "label": "Test chat", + "provider": "anthropic", "model": "claude-sonnet-4-6", + "in": 100, "out": 50, "cache": 0, "estimated": False, + "account": "", "machine": "", + } + event.update(overrides) + path = usage_dir / f"{day.isoformat()}.jsonl" + with path.open("a", encoding="utf-8") as f: + f.write(json.dumps(event) + "\n") + + +def test_period_range_is_inclusive_end(usage_dir, ctx): + query = DashboardQueryService(ctx) + start, end = query.period_range("week", 0) + assert start <= end + + +def test_summary_aggregates_events_in_range(usage_dir, ctx): + today = date.today() + _write_event(usage_dir, today, **{"in": 100, "out": 50}) + _write_event(usage_dir, today - timedelta(days=400), **{"in": 999, "out": 999}) # out of range + query = DashboardQueryService(ctx) + + summary = query.summary(today, today) + + assert len(summary["events"]) == 1 + assert summary["stats"]["in"] == 100 + assert summary["stats"]["out"] == 50 + assert summary["total_cost"] >= 0 + + +def test_summary_empty_range_has_no_events(usage_dir, ctx): + query = DashboardQueryService(ctx) + summary = query.summary(date(2020, 1, 1), date(2020, 1, 1)) + assert summary["events"] == [] + assert summary["stats"]["total"] == 0 + + +def test_pricing_returns_a_dict_with_currency(usage_dir, ctx): + query = DashboardQueryService(ctx) + pricing = query.pricing() + assert "currency" in pricing + + +def test_chart_series_returns_points_for_the_granularity(usage_dir, ctx): + today = date.today() + _write_event(usage_dir, today) + query = DashboardQueryService(ctx) + + pts = query.chart_series("week", 0, "tokens") + + assert len(pts) == 7 # week view = 7 days + assert all(isinstance(p, tuple) and len(p) == 2 for p in pts) + + +def test_budget_status_none_when_no_budget_set(usage_dir, ctx): + query = DashboardQueryService(ctx) + assert query.budget_status() is None + + +def test_set_budget_then_status_reflects_it(usage_dir, ctx): + query = DashboardQueryService(ctx) + query.set_budget(100.0, "USD") + status = query.budget_status() + assert status is not None + assert status["amount_usd"] == pytest.approx(100.0) diff --git a/tests/unit/test_file_preview_helpers_and_ai_edit_output.py b/tests/unit/test_file_preview_helpers_and_ai_edit_output.py new file mode 100644 index 0000000..d44af4e --- /dev/null +++ b/tests/unit/test_file_preview_helpers_and_ai_edit_output.py @@ -0,0 +1,76 @@ +"""EPIC R08-T12: pure helpers moved out of ui/folder_tab.py into +application/workspaces/ (file_preview_helpers.py, ai_edit_output.py) — no Qt, +directly unit-testable, unlike when they lived as private module functions +inside the Qt widget file. +""" +from __future__ import annotations + +from cowork_local.application.workspaces.ai_edit_output import ( + parse_ai_output, + split_code_block, +) +from cowork_local.application.workspaces.file_preview_helpers import ( + is_probably_text, + read_text, +) + + +def test_read_text_returns_file_contents(tmp_path): + f = tmp_path / "a.txt" + f.write_text("hello", encoding="utf-8") + assert read_text(str(f)) == "hello" + + +def test_read_text_on_missing_file_returns_a_note_not_raise(tmp_path): + result = read_text(str(tmp_path / "missing.txt")) + assert "could not read file" in result + + +def test_is_probably_text_true_for_utf8(tmp_path): + f = tmp_path / "a.txt" + f.write_text("hello world", encoding="utf-8") + assert is_probably_text(str(f)) is True + + +def test_is_probably_text_false_for_null_bytes(tmp_path): + f = tmp_path / "a.bin" + f.write_bytes(b"\x00\x01\x02") + assert is_probably_text(str(f)) is False + + +def test_split_code_block_extracts_fenced_block_and_summary(): + text = "Here is the change:\n\n```python\nprint('hi')\n```" + content, summary = split_code_block(text) + assert content == "print('hi')\n" + assert summary == "Here is the change:" + + +def test_split_code_block_no_fence_returns_none_and_full_text(): + content, summary = split_code_block("just prose, no code") + assert content is None + assert summary == "just prose, no code" + + +def test_parse_ai_output_extracts_file_target(): + text = "FILE: new/thing.py\n```python\nx = 1\n```" + target, content, summary, image_gens = parse_ai_output(text) + assert target == "new/thing.py" + assert content == "x = 1\n" + assert image_gens == [] + + +def test_parse_ai_output_extracts_image_gen_directives(): + text = ("Adding an illustration.\n" + "IMAGE_GEN: a red fox in a forest => assets/fox.png\n" + "```html\n\n```") + target, content, summary, image_gens = parse_ai_output(text) + assert image_gens == [("a red fox in a forest", "assets/fox.png")] + assert "IMAGE_GEN" not in summary + + +def test_parse_ai_output_no_directives_or_code_block(): + target, content, summary, image_gens = parse_ai_output("just an answer") + assert target is None + assert content is None + assert image_gens == [] + assert summary == "just an answer" diff --git a/tests/unit/test_graph_index_service.py b/tests/unit/test_graph_index_service.py new file mode 100644 index 0000000..3b166c4 --- /dev/null +++ b/tests/unit/test_graph_index_service.py @@ -0,0 +1,44 @@ +"""EPIC R08-T14: graph_index_service.py — pure helpers moved out of +ui/structure_graph_view.py, no Qt. +""" +from __future__ import annotations + +from cowork_local.application.workspaces.graph_index_service import extract_file_contents + + +def test_extract_file_contents_reads_text_files(tmp_path): + f = tmp_path / "a.py" + f.write_text("print('hello')\n", encoding="utf-8") + + block, cache = extract_file_contents([str(f)], {}, str(tmp_path)) + + assert "print('hello')" in block + assert str(f) in cache + assert cache[str(f)] + + +def test_extract_file_contents_reuses_the_cache(tmp_path): + f = tmp_path / "a.txt" + f.write_text("original", encoding="utf-8") + seed_cache = {str(f): "cached content, not re-read"} + + block, cache = extract_file_contents([str(f)], seed_cache, str(tmp_path)) + + assert "cached content, not re-read" in block + + +def test_extract_file_contents_skips_unreadable_paths_without_raising(tmp_path): + missing = tmp_path / "does-not-exist.txt" + block, cache = extract_file_contents([str(missing)], {}, str(tmp_path)) + assert block == "" + + +def test_extract_file_contents_respects_max_total_budget(tmp_path): + f1 = tmp_path / "a.txt" + f1.write_text("x" * 100, encoding="utf-8") + f2 = tmp_path / "b.txt" + f2.write_text("y" * 100, encoding="utf-8") + + block, cache = extract_file_contents([str(f1), str(f2)], {}, str(tmp_path), max_total=50) + + assert len(block) < 250 # bounded, not both files in full diff --git a/ui/dashboard_tab.py b/ui/dashboard_tab.py deleted file mode 100644 index 8978b8e..0000000 --- a/ui/dashboard_tab.py +++ /dev/null @@ -1,438 +0,0 @@ -"""Dashboard tab — token usage & cost overview. - -Top: header (period filter + display-currency picker + refresh), then stat -cards (total, input, output, cache tokens, and cost per bucket). Unit prices -still come from Monitoring's model pricing table (same ``usage.*`` config keys -— both screens always agree); the currency picker itself lives HERE, beside -refresh. Bottom: a habits summary — which tasks/sessions burn the most tokens, -average per prompt, busiest day/hour. Data comes from the local usage log (one -event per model turn, recorded by the providers — real server counts when -available, ~4 chars/token estimates otherwise). -""" -from __future__ import annotations - -from datetime import date, timedelta -from typing import Dict, List, Optional - -from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtWidgets import ( - QComboBox, QGridLayout, QHBoxLayout, QLabel, - QPushButton, QScrollArea, QTextBrowser, QVBoxLayout, QWidget, -) - -from ..core import usage_tracker as ut -from ..core.worker import AgentWorker -from ..i18n import on_language_changed, tr -from ..state import AppContext -from ..theme import current_palette -from .icons import icon -from .spline_chart import SplineChart -from .widgets import BudgetCard as _BudgetCard -from .widgets import StatCard as _StatCard -from .widgets import fmt_tokens as _fmt_tokens - - -class DashboardTab(QWidget): - status_message = Signal(str) - - _PERIODS = ("today", "week", "month", "all") - - def __init__(self, ctx: AppContext): - super().__init__() - self.ctx = ctx - - outer = QVBoxLayout(self) - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setFrameShape(QScrollArea.NoFrame) - content = QWidget() - scroll.setWidget(content) - outer.addWidget(scroll) - root = QVBoxLayout(content) - - # ---- header: title + the PERIOD FILTER (applies to the WHOLE dashboard — - # cards, chart and habits all follow the selected week/month) + refresh - self._chart_offset = 0 # 0 = current period; <0 = a past period - head = QHBoxLayout() - self._title = QLabel() - self._title.setStyleSheet("font-weight:700; font-size:15px;") - self.chart_prev_btn = QPushButton() - self.chart_prev_btn.setIcon(icon("chevron-left")) - self.chart_prev_btn.setFixedWidth(30) - self.chart_prev_btn.clicked.connect(self._chart_prev) - self._chart_period_lbl = QLabel() - self._chart_period_lbl.setObjectName("hint") - self._chart_period_lbl.setAlignment(Qt.AlignCenter) - self._chart_period_lbl.setMinimumWidth(170) - self.chart_next_btn = QPushButton() - self.chart_next_btn.setIcon(icon("chevron-right")) - self.chart_next_btn.setFixedWidth(30) - self.chart_next_btn.clicked.connect(self._chart_next) - self.gran_combo = QComboBox() - for g in ("week", "month", "year"): - self.gran_combo.addItem(tr(f"dashboard.gran_{g}"), g) - self.gran_combo.currentIndexChanged.connect(self._on_gran_changed) - self.metric_combo = QComboBox() - for m in ("cost", "tokens"): - self.metric_combo.addItem(tr(f"dashboard.metric_{m}"), m) - self.metric_combo.currentIndexChanged.connect(self._refresh_chart) - # Display-currency picker — moved here from Monitoring's Token Usage - # card, right beside refresh; both screens still share the same - # usage.currency config key, so changing it here updates everywhere. - self.currency_lbl = QLabel() - self.currency_lbl.setObjectName("hint") - self.currency_combo = QComboBox() - for cur in ut.SUPPORTED_CURRENCIES: - self.currency_combo.addItem(cur, cur) - idx = self.currency_combo.findData( - (self.ctx.config.data.get("usage") or {}).get("currency", "USD")) - self.currency_combo.setCurrentIndex(max(0, idx)) - self.currency_combo.currentIndexChanged.connect(self._on_currency_changed) - self.refresh_btn = QPushButton("") - self.refresh_btn.setIcon(icon("refresh")) - self.refresh_btn.setFixedWidth(34) - self.refresh_btn.clicked.connect(self.refresh) - # Two rows, grouped by what the controls do, instead of nine widgets - # strung across one line where the title, a date pager, two chart - # selectors, a currency picker and Refresh all read as one undifferentiated - # strip. Row 1 is "where am I"; row 2 is "what am I looking at". - head.addWidget(self._title, 1) - head.addWidget(self.refresh_btn) - root.addLayout(head) - - controls = QHBoxLayout() - controls.setSpacing(6) - controls.addWidget(self.chart_prev_btn) # period pager - controls.addWidget(self._chart_period_lbl) - controls.addWidget(self.chart_next_btn) - controls.addSpacing(12) - controls.addWidget(self.gran_combo) # what the chart plots - controls.addWidget(self.metric_combo) - controls.addStretch(1) - controls.addWidget(self.currency_lbl) # how money is displayed - controls.addWidget(self.currency_combo) - root.addLayout(controls) - - # ---- stat cards --------------------------------------------------- - # Cost is the headline this screen exists for, so it gets a card twice - # the height of the rest instead of being the fifth of five identical - # tiles — with six equal cards nothing said which number mattered. - cards_grid = QGridLayout() - cards_grid.setSpacing(8) - self.card_total = _StatCard() - self.card_in = _StatCard() - self.card_out = _StatCard() - self.card_cache = _StatCard() - self.card_cost = _StatCard().as_hero() - # Hero on the left, spanning both rows; the four supporting figures fill - # a 2×2 block beside it. - cards_grid.addWidget(self.card_cost, 0, 0, 2, 1) - for i, card in enumerate((self.card_total, self.card_in, - self.card_out, self.card_cache)): - cards_grid.addWidget(card, i // 2, 1 + i % 2) - # Budget: remaining/budget, direct entry, auto-warns red past 85% used. - self.budget_card = _BudgetCard() - self.budget_card.apply_btn.setIcon(icon("check")) - self.budget_card.apply_btn.clicked.connect(self._apply_budget) - cards_grid.addWidget(self.budget_card, 0, 3, 2, 1) - # The hero and Budget columns get more room than the small tiles. - for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)): - cards_grid.setColumnStretch(col, stretch) - root.addLayout(cards_grid) - - # ---- token/cost within the selected period (spline): WEEK → 7 days - # (Mon–Sun) · MONTH → weeks W1…Wn · YEAR → 12 months. Dashed lines - # compare the previous week / month. ---- - chart_head = QHBoxLayout() - self._chart_title = QLabel() - self._chart_title.setStyleSheet("font-weight:600;") - chart_head.addWidget(self._chart_title, 1) - root.addLayout(chart_head) - self.chart = SplineChart() - root.addWidget(self.chart) - - # ---- habits summary ------------------------------------------------- - self._habits_title = QLabel() - self._habits_title.setStyleSheet("font-weight:600;") - habits_head = QHBoxLayout() - self.ai_analyze_btn = QPushButton() - self.ai_analyze_btn.setIcon(icon("sparkle")) - self.ai_analyze_btn.clicked.connect(self._ai_analyze) - # Apply an AI-suggested cost-saving strategy (enable auto-compress + tune - # the compression threshold) — only after the user clicks to approve it. - self.apply_strategy_btn = QPushButton() - self.apply_strategy_btn.setIcon(icon("bolt")) - self.apply_strategy_btn.setVisible(False) - self.apply_strategy_btn.clicked.connect(self._apply_saving_strategy) - habits_head.addWidget(self._habits_title, 1) - habits_head.addWidget(self.apply_strategy_btn) - habits_head.addWidget(self.ai_analyze_btn) - root.addLayout(habits_head) - self.habits = QTextBrowser() - self.habits.setOpenExternalLinks(False) - self.habits.setMinimumHeight(160) - root.addWidget(self.habits, 1) - # AI recommendations panel (filled by the ✨ button). - self._ai_title = QLabel() - self._ai_title.setStyleSheet("font-weight:600;") - self._ai_title.setVisible(False) - root.addWidget(self._ai_title) - self.ai_advice = QTextBrowser() - self.ai_advice.setOpenExternalLinks(False) - self.ai_advice.setMinimumHeight(140) - self.ai_advice.setVisible(False) - root.addWidget(self.ai_advice, 1) - - # Auto-refresh every 30s so numbers follow ongoing work. - self._timer = QTimer(self) - self._timer.setInterval(30_000) - self._timer.timeout.connect(self.refresh) - self._timer.start() - - on_language_changed(self._retranslate) - self.refresh() - - # ---- helpers ----------------------------------------------------------- - def _pricing(self) -> Dict: - from ..core import model_pricing as mp - mp.sync_to_usage(self.ctx.config) # cost/total comes straight from the price table - return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})} - - def _on_currency_changed(self, _idx: int) -> None: - cur = self.currency_combo.currentData() - if not cur: - return - self.ctx.config.data.setdefault("usage", {})["currency"] = cur - self.ctx.save() - self.refresh() - - def _retranslate(self) -> None: - self._title.setText(tr("dashboard.title")) - self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip")) - self.currency_lbl.setText(tr("monitoring.overview_currency")) - self.currency_combo.setToolTip(tr("dashboard.currency_tooltip")) - self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn")) - self.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip")) - self.apply_strategy_btn.setText(tr("dashboard.strategy_btn")) - self.apply_strategy_btn.setToolTip(tr("dashboard.strategy_tooltip")) - self._habits_title.setText(tr("dashboard.habits_title")) - self._chart_title.setText(tr("dashboard.chart_title")) - self.chart_prev_btn.setToolTip(tr("dashboard.chart_prev")) - self.chart_next_btn.setToolTip(tr("dashboard.chart_next")) - self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip")) - self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip")) - self.refresh() - - def _apply_budget(self) -> None: - """Persist the spin box's value as the new budget — starts a fresh - remaining-balance window (spend before now is no longer counted).""" - ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD") - ut.set_budget(self.ctx.config, self.budget_card.budget_spin.value(), ccy) - self.ctx.save() - self._refresh_budget() - - def _refresh_budget(self) -> None: - from ..core import model_pricing as mp - pricing = self._pricing() - status = ut.budget_status(self.ctx.config) - if status is None: - self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget")) - self.budget_card.budget_spin.setValue(0.0) - return - remaining_disp = mp.convert(status["remaining_usd"], "USD", - pricing.get("currency", "USD"), self.ctx.config) - amount_disp = mp.convert(status["amount_usd"], "USD", - pricing.get("currency", "USD"), self.ctx.config) - value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}" - f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}") - pct = int(round(status["pct_used"] * 100)) - sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct) - self.budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"]) - # keep the entry field showing the CURRENT budget (in display currency) — - # only when it doesn't already have unsaved focus/edits from the user. - if not self.budget_card.budget_spin.hasFocus(): - self.budget_card.budget_spin.setValue(round(amount_disp, 2)) - - def _period_range(self): - """The SELECTED period as an inclusive (start, end) date range — drives - the whole dashboard (cards, chart, habits).""" - gran = self.gran_combo.currentData() or "week" - start, end = ut.period_bounds(gran, self._chart_offset) - return start, end - timedelta(days=1) # load_events end is inclusive - - def _on_gran_changed(self, *_a) -> None: - self._chart_offset = 0 # period size changed → back to current - self.refresh() # the filter drives the WHOLE dashboard - - def _chart_prev(self) -> None: - self._chart_offset -= 1 # page one period into the past - self.refresh() - - def _chart_next(self) -> None: - self._chart_offset = min(0, self._chart_offset + 1) # never past the present - self.refresh() - - @staticmethod - def _delta_txt(cur: float, prev: float) -> str: - """▲/▼ percent change of ``cur`` vs ``prev`` (empty if no baseline).""" - if not prev: - return "" - pct = (cur - prev) / prev * 100 - arrow = "▲" if pct > 0.5 else ("▼" if pct < -0.5 else "•") - return f"{arrow}{abs(pct):.0f}%" - - def _refresh_chart(self, *_a) -> None: - """Break the SELECTED period into its parts: WEEK → 7 days (Mon–Sun) · - MONTH → weeks W1…Wn · YEAR → 12 months. Dashed lines mark the previous - week's / month's average per point with the % change of the totals.""" - if not hasattr(self, "chart"): - return - gran = self.gran_combo.currentData() or "week" - metric = self.metric_combo.currentData() or "cost" - events = ut.load_events() # all events; breakdown slices by period - pricing = self._pricing() - parts = ut.period_breakdown(events, gran, pricing, offset=self._chart_offset) - mi = 0 if metric == "tokens" else 1 # (label, tokens, cost) → +1 for the value - pts = [(row[0], float(row[mi + 1])) for row in parts] - # Compact cost format (2 decimals, K/M above 1,000/1,000,000) — the - # chart's y-axis label box is narrow; format_cost's full precision (up - # to 4 decimals for USD) overflowed it, clipping/obscuring the amount. - fmt = _fmt_tokens if metric == "tokens" else (lambda v: ut.format_cost_compact(v, pricing)) - - # One dashed comparison line that FOLLOWS the filter: the selected period - # vs the previous SAME-granularity one — "Last week" in week view, - # "Last month" in month view, "Last year" in year view. Drawn at the - # previous period's average per point so it sits on-scale; the label shows - # the % change of the period totals. - cur = ut.period_totals(events, gran, pricing, self._chart_offset) - prev = ut.period_totals(events, gran, pricing, self._chart_offset - 1) - ref_key = {"week": "dashboard.ref_last_week", - "month": "dashboard.ref_last_month", - "year": "dashboard.ref_last_year"}.get(gran, "dashboard.ref_last_week") - n_points = max(1, len(parts)) - refs = [] - if prev[mi] > 0: - # Muted on purpose: the comparison line is a reference, not the - # series — it must not compete with the accent-coloured spline. - refs.append((prev[mi] / n_points, - f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}", - current_palette().text_muted)) - self.chart.set_reference_lines(refs) - self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}")) - self._chart_period_lbl.setText(ut.period_range_label(gran, self._chart_offset)) - self.chart_next_btn.setEnabled(self._chart_offset < 0) - - # ---- main refresh -------------------------------------------------------- - def refresh(self) -> None: - start, end = self._period_range() - events = ut.load_events(start, end) - - s = ut.summarize(events) - pricing = self._pricing() - costs = ut.cost_usd_events(events, pricing) # honors the per-model price table - total_cost = sum(costs.values()) - - est_note = (tr("dashboard.estimated_note", pct=int(s["estimated_share"] * 100)) - if s["estimated_share"] > 0 else "") - self.card_total.set(tr("dashboard.card_total"), _fmt_tokens(s["total"]), - tr("dashboard.card_turns", n=s["turns"])) - self.card_in.set(tr("dashboard.card_in"), _fmt_tokens(s["in"]), - ut.format_cost(costs["in"], pricing)) - self.card_out.set(tr("dashboard.card_out"), _fmt_tokens(s["out"]), - ut.format_cost(costs["out"], pricing)) - self.card_cache.set(tr("dashboard.card_cache"), _fmt_tokens(s["cache"]), - ut.format_cost(costs["cache"], pricing)) - self.card_cost.set(tr("dashboard.card_cost"), - ut.format_cost(total_cost, pricing, digits=2), est_note) - - # ---- habits ----------------------------------------------------------- - lines: List[str] = [] - if not events: - lines.append(f"{tr('dashboard.no_data')}") - else: - lines.append(f"{tr('dashboard.h_top')}") - lines.append("
    ") - for label, tok in s["top_labels"]: - pct = int(tok * 100 / s["total"]) if s["total"] else 0 - lines.append(f"
  1. {label[:60]} — {_fmt_tokens(tok)} tokens ({pct}%)
  2. ") - lines.append("
") - src_parts = ", ".join( - f"{tr(f'app.tab.{k}') if k in ('cowork', 'code') else k}: {_fmt_tokens(v)}" - for k, v in s["by_source"]) - lines.append(f"{tr('dashboard.h_by_source')}: {src_parts}
") - lines.append(f"{tr('dashboard.h_avg')}: " - f"{_fmt_tokens(s['avg_per_turn'])} tokens
") - if s["busiest_day"]: - lines.append(f"{tr('dashboard.h_busiest_day')}: {s['busiest_day']}
") - if s["busiest_hour"] is not None: - lines.append(f"{tr('dashboard.h_busiest_hour')}: " - f"{s['busiest_hour']:02d}:00–{s['busiest_hour']:02d}:59
") - if s["estimated_share"] > 0: - lines.append(f"{tr('dashboard.estimated_note', pct=int(s['estimated_share'] * 100))}") - self.habits.setHtml("".join(lines)) - self._refresh_chart() - self._refresh_budget() - - def _apply_saving_strategy(self) -> None: - """Apply an AI-suggested cost-saving strategy AFTER the user approves: - turn on auto-compress and compress earlier (lower threshold) + compress - content before sending it to the agent — cutting tokens on every turn.""" - from PySide6.QtWidgets import QMessageBox - if QMessageBox.question(self, tr("dashboard.strategy_title"), - tr("dashboard.strategy_confirm")) != QMessageBox.Yes: - return - cx = self.ctx.config.data.setdefault("context", {}) - cx["auto_compact"] = True - cx["compact_threshold"] = 0.6 # compress at 60% of the window (was ~80%) - cx["compress_before_send"] = True # digest context before each turn - self.ctx.save() - self.status_message.emit(tr("dashboard.strategy_applied")) - - # ---- AI habits analysis ---------------------------------------------------- - def _ai_analyze(self) -> None: - """✨ Send the aggregated numbers (never raw prompt text) to the active - provider and show habit feedback + token-saving recommendations.""" - if getattr(self, "_ai_worker", None) is not None: - return - start, end = self._period_range() - events = ut.load_events(start, end) - if not events: - self.status_message.emit(tr("dashboard.no_data")) - return - summary = ut.summarize(events) - self.ai_analyze_btn.setEnabled(False) - self.ai_analyze_btn.setText(tr("dashboard.ai_analyzing")) - ctx = self.ctx - - def job(worker: AgentWorker): - from ..i18n import get_language - - prompt = ut.build_ai_analysis_prompt(summary, get_language()) - provider = ctx.build_active_provider() - reply = provider.chat([{"role": "user", "content": prompt}], - cancel=worker.stop_event) - return {"text": (reply.get("content") or "").strip()} - - def done(result: dict) -> None: - self._ai_worker = None - self.ai_analyze_btn.setEnabled(True) - self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn")) - text = result.get("text") or "" - if text: - self._ai_title.setText(tr("dashboard.ai_advice_title")) - self._ai_title.setVisible(True) - self.ai_advice.setMarkdown(text) - self.ai_advice.setVisible(True) - self.apply_strategy_btn.setVisible(True) # offer to apply the saving strategy - - def failed(err: str) -> None: - self._ai_worker = None - self.ai_analyze_btn.setEnabled(True) - self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn")) - self.status_message.emit(str(err)) - - w = AgentWorker(job) - w.finished_ok.connect(done) - w.failed.connect(failed) - self._ai_worker = w - w.start() \ No newline at end of file diff --git a/ui/folder_tab.py b/ui/folder_tab.py deleted file mode 100644 index 467111b..0000000 --- a/ui/folder_tab.py +++ /dev/null @@ -1,1586 +0,0 @@ -"""Folder tab — a two-pane file explorer for the Workspace. - -Left: a directory tree (QFileSystemModel). Right: view / edit the selected file -directly in the folder: - -* **Source code + text/config + HTML source** — an editable code editor with - VS-Code-style syntax colouring (Pygments), line numbers, dark theme. -* **HTML** — a rendered Preview (WebEngine when available, else rich text) with - a Preview⇄Edit toggle. -* **Office docs** (doc/docx/ppt/pptx/xls/xlsx/pdf) — an in-app text preview - (extracted via the same parser attachments use) plus "Open externally" for - full-fidelity viewing. -* **Images** — shown inline. - -Everything is best-effort and never raises: an unreadable/oversized/binary file -degrades to an explanatory note. -""" -from __future__ import annotations - -import os -from pathlib import Path -from typing import Optional - -from PySide6.QtCore import QRect, QSize, Qt, QTimer, Signal -from PySide6.QtGui import QColor, QFont, QPainter, QSyntaxHighlighter, QTextCharFormat -from PySide6.QtWidgets import ( - QComboBox, QFileSystemModel, QFileDialog, QHBoxLayout, QLabel, QLineEdit, - QPlainTextEdit, QPushButton, QScrollArea, QSplitter, QStackedWidget, - QTableWidget, QTableWidgetItem, QTabWidget, QTextBrowser, QTreeView, - QVBoxLayout, QWidget, -) - -from ..core.worker import AgentWorker -from ..i18n import on_language_changed, tr -from ..state import AppContext -from ..theme import current_palette -from .chat_view import ChatView -from .icons import icon -from .libreoffice_view import DOC_SUFFIXES - -try: - from .structure_graph_view import _HAS_WEB -except Exception: # pragma: no cover - _HAS_WEB = False - -try: - from PySide6.QtPdf import QPdfDocument # noqa: F401 - from PySide6.QtPdfWidgets import QPdfView # noqa: F401 - _HAS_PDF = True -except Exception: # pragma: no cover - QtPdf not bundled - _HAS_PDF = False - -_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico"} -_HTML_SUFFIXES = {".html", ".htm"} -_PPTX_SUFFIXES = {".pptx"} # text-editable in-app via python-pptx (no PowerPoint) -_EXCEL_SUFFIXES = {".xlsx", ".xlsm"} # viewed as a real table via openpyxl (no LibreOffice) -_MAX_EDIT_BYTES = 2_000_000 # above this, view read-only / note only -_MAX_HIGHLIGHT_CHARS = 300_000 # skip colouring huge files (keeps typing snappy) - - -# ── VS-Code-Dark+-ish token palette ──────────────────────────────────────── -def _fmt(color: str, *, italic: bool = False, bold: bool = False) -> QTextCharFormat: - f = QTextCharFormat() - f.setForeground(QColor(color)) - if italic: - f.setFontItalic(True) - if bold: - f.setFontWeight(QFont.Bold) - return f - - -class PygmentsHighlighter(QSyntaxHighlighter): - """Colour the whole document with Pygments and apply per-block. Re-lexes the - full text (debounced) so multi-line strings/comments colour correctly.""" - - def __init__(self, document): - super().__init__(document) - from pygments.lexers.special import TextLexer - self._lexer = TextLexer(stripnl=False) - self._ranges: list[tuple[int, int, QTextCharFormat]] = [] - self._rules = self._build_rules() - self._timer = QTimer(self) - self._timer.setSingleShot(True) - self._timer.setInterval(250) - self._timer.timeout.connect(self._retokenize) - document.contentsChanged.connect(self._timer.start) - - @staticmethod - def _build_rules(): - from pygments.token import ( - Comment, Error, Keyword, Name, Number, Operator, Punctuation, String, - ) - p = current_palette() - # Ordered specific → general: first matching token type wins. - # Colours are resolved when the editor is built, so reopening a file - # after a theme switch re-highlights it in the new theme. - return [ - (Comment, _fmt(p.code_comment, italic=True)), - (Keyword.Type, _fmt(p.code_type)), - (Keyword, _fmt(p.code_keyword)), - (Name.Function, _fmt(p.code_func)), - (Name.Class, _fmt(p.code_type)), - (Name.Decorator, _fmt(p.code_func)), - (Name.Builtin, _fmt(p.code_type)), - (Name.Tag, _fmt(p.code_keyword)), - (Name.Attribute, _fmt(p.code_attr)), - (String.Doc, _fmt(p.code_comment, italic=True)), - (String, _fmt(p.code_string)), - (Number, _fmt(p.code_number)), - (Operator, _fmt(p.code_fg)), - (Punctuation, _fmt(p.code_fg)), - (Error, _fmt(p.code_error)), - ] - - def set_filename(self, filename: str, text: str = "") -> None: - from pygments.lexers import get_lexer_for_filename, guess_lexer - from pygments.lexers.special import TextLexer - from pygments.util import ClassNotFound - try: - self._lexer = get_lexer_for_filename(filename, stripnl=False) - except ClassNotFound: - try: - self._lexer = guess_lexer(text) if text.strip() else TextLexer() - except ClassNotFound: - self._lexer = TextLexer(stripnl=False) - self._retokenize() - - def _fmt_for(self, tok): - for ttype, fmt in self._rules: - if tok in ttype: - return fmt - return None - - def _retokenize(self) -> None: - from pygments import lex - text = self.document().toPlainText() - self._ranges = [] - if len(text) <= _MAX_HIGHLIGHT_CHARS: - pos = 0 - for tok, val in lex(text, self._lexer): - fmt = self._fmt_for(tok) - if fmt is not None and val: - self._ranges.append((pos, pos + len(val), fmt)) - pos += len(val) - self.rehighlight() - - def highlightBlock(self, text: str) -> None: # noqa: N802 - Qt override - if not self._ranges: - return - bstart = self.currentBlock().position() - bend = bstart + len(text) - for start, end, fmt in self._ranges: - if end <= bstart or start >= bend: - continue - s = max(start, bstart) - bstart - e = min(end, bend) - bstart - if e > s: - self.setFormat(s, e - s, fmt) - - -class _LineNumbers(QWidget): - def __init__(self, editor): - super().__init__(editor) - self._editor = editor - - def sizeHint(self) -> QSize: - return QSize(self._editor.line_number_width(), 0) - - def paintEvent(self, event): # noqa: N802 - self._editor.paint_line_numbers(event) - - -class CodeEditor(QPlainTextEdit): - """A dark, monospaced editor with a line-number gutter + Pygments colouring — - the Sublime/VS-Code look for viewing & editing source files.""" - - def __init__(self): - super().__init__() - self.setObjectName("codeEditor") - self.setLineWrapMode(QPlainTextEdit.NoWrap) - self.setTabStopDistance(4 * self.fontMetrics().horizontalAdvance(" ")) - font = QFont("Consolas") - font.setStyleHint(QFont.Monospace) - font.setPointSize(10) - self.setFont(font) - # Surface comes from the central style sheet (#codeEditor) — see theme.py. - self._gutter = _LineNumbers(self) - self.blockCountChanged.connect(lambda _=0: self._update_gutter_width()) - self.updateRequest.connect(self._on_update_request) - self._highlighter = PygmentsHighlighter(self.document()) - self._update_gutter_width() - - # ---- line-number gutter ------------------------------------------------- - def line_number_width(self) -> int: - digits = max(2, len(str(max(1, self.blockCount())))) - return 12 + self.fontMetrics().horizontalAdvance("9") * digits - - def _update_gutter_width(self) -> None: - self.setViewportMargins(self.line_number_width(), 0, 0, 0) - - def _on_update_request(self, rect, dy: int) -> None: - if dy: - self._gutter.scroll(0, dy) - else: - self._gutter.update(0, rect.y(), self._gutter.width(), rect.height()) - if rect.contains(self.viewport().rect()): - self._update_gutter_width() - - def resizeEvent(self, event): # noqa: N802 - super().resizeEvent(event) - cr = self.contentsRect() - self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height())) - - def paint_line_numbers(self, event) -> None: - p = current_palette() - painter = QPainter(self._gutter) - painter.fillRect(event.rect(), QColor(p.code_gutter_bg)) - block = self.firstVisibleBlock() - num = block.blockNumber() - top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() - bottom = top + self.blockBoundingRect(block).height() - painter.setPen(QColor(p.code_gutter_fg)) - while block.isValid() and top <= event.rect().bottom(): - if block.isVisible() and bottom >= event.rect().top(): - painter.drawText(0, int(top), self._gutter.width() - 6, - self.fontMetrics().height(), Qt.AlignRight, - str(num + 1)) - block = block.next() - top = bottom - bottom = top + self.blockBoundingRect(block).height() - num += 1 - - def load_file(self, path: str, text: str) -> None: - self.setPlainText(text) - self._highlighter.set_filename(path, text) - - -class FolderTab(QWidget): - """Two-pane file explorer: directory tree + view/edit pane.""" - - status_message = Signal(str) - - def __init__(self, ctx: AppContext, cowork=None): - super().__init__() - self.ctx = ctx - self._cowork = cowork # shared Cowork tab → reuse its conversation - self._ai_worker = None - self._ai_queue: list[str] = [] # instructions waiting for the current run - self._img_scan_worker = None # background scan for image models (all providers) - self._all_image_models: list = [] # [(provider_key, model)] found across ALL providers - self._ai_models_provider = "" # which provider the AI-edit model list was fetched for - self._edit_kind: Optional[str] = None # None | "html" | "pptx" (what the editor holds) - self._current_file: Optional[str] = None - self._root = str(ctx.config.cowork_output_dir()) - self._pdf_view = None # lazy QtPdf view for office/pdf rendering - self._pdf_doc = None - self._pdf_tmp: Optional[str] = None - self._pdf_cache: dict = {} # (path, mtime) → converted .pdf path - self._convert_worker = None - self._xlsx_view = None # lazy QTabWidget table view for spreadsheets - - root = QVBoxLayout(self) - - # The path IS the title of this screen, so it is written as one rather - # than shown in a read-only text box that looks editable and costs a - # whole row of its own. Full path on hover; the button still opens the - # folder picker. - bar = QHBoxLayout() - self.path_lbl = QLabel(self._root) - self.path_lbl.setObjectName("folderTitle") - self.path_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) - self.path_lbl.setToolTip(self._root) - self._open_btn = QPushButton() - self._open_btn.setIcon(icon("folder")) - self._open_btn.setObjectName("primary") - self._open_btn.clicked.connect(self._pick_root) - bar.addWidget(self.path_lbl, 1) - bar.addWidget(self._open_btn) - root.addLayout(bar) - - split = QSplitter(Qt.Horizontal) - - # ---- left: directory tree ------------------------------------------ - self.model = QFileSystemModel() - self.model.setRootPath(self._root) - self.tree = QTreeView() - self.tree.setModel(self.model) - self.tree.setRootIndex(self.model.index(self._root)) - for col in (1, 2, 3): # hide Size / Type / Date-modified columns - self.tree.hideColumn(col) - self.tree.setHeaderHidden(True) - self.tree.clicked.connect(self._on_tree_clicked) - split.addWidget(self.tree) - - # ---- right: view / edit pane --------------------------------------- - right = QWidget() - rl = QVBoxLayout(right) - rl.setContentsMargins(0, 0, 0, 0) - - hdr = QHBoxLayout() - self.file_label = QLabel("") - self.file_label.setStyleSheet("font-weight:600;") - self.file_label.setWordWrap(True) - hdr.addWidget(self.file_label, 1) - self.mode_btn = QPushButton() # Preview⇄Edit toggle (HTML / PPTX) - self.mode_btn.setCheckable(True) - self.mode_btn.clicked.connect(self._toggle_edit_mode) - self.mode_btn.setVisible(False) - hdr.addWidget(self.mode_btn) - self.ai_btn = QPushButton() # expand/collapse the AI-edit panel - self.ai_btn.setIcon(icon("sparkle")) - self.ai_btn.setCheckable(True) - self.ai_btn.clicked.connect(self._toggle_ai_panel) - hdr.addWidget(self.ai_btn) - self.save_btn = QPushButton() - self.save_btn.setIcon(icon("save")) - self.save_btn.setObjectName("primary") - self.save_btn.clicked.connect(self._save) - self.save_btn.setVisible(False) - hdr.addWidget(self.save_btn) - self.ext_btn = QPushButton() - self.ext_btn.setIcon(icon("upload")) - self.ext_btn.clicked.connect(self._open_external) - self.ext_btn.setVisible(False) - hdr.addWidget(self.ext_btn) - rl.addLayout(hdr) - - self.stack = QStackedWidget() - self._placeholder = QLabel("") - self._placeholder.setObjectName("hint") - self._placeholder.setAlignment(Qt.AlignCenter) - self.stack.addWidget(self._placeholder) # 0 - - self.editor = CodeEditor() # 1 - self.stack.addWidget(self.editor) - - # HTML preview: a lightweight QTextBrowser fallback always exists; a real - # QWebEngineView is created LAZILY the first time an HTML file is - # previewed (so startup/tests never build WebEngine, and the onefile - # build — where WebEngine crashes — stays on the fallback). - self.web = QTextBrowser() # 2 - self.web.setOpenExternalLinks(True) - self.stack.addWidget(self.web) - self._engine = None - - self.doc_view = QTextBrowser() # 3 - self.doc_view.setObjectName("docPreview") - self.stack.addWidget(self.doc_view) - - self._img_scroll = QScrollArea() # 4 - self._img_scroll.setWidgetResizable(True) - self._img_label = QLabel("") - self._img_label.setAlignment(Qt.AlignCenter) - self._img_scroll.setWidget(self._img_label) - self.stack.addWidget(self._img_scroll) - - # Preview/editor on the left, a collapsible AI-edit panel on the right. - content_split = QSplitter(Qt.Horizontal) - content_split.addWidget(self.stack) - content_split.addWidget(self._build_ai_panel()) - content_split.setStretchFactor(0, 1) - content_split.setStretchFactor(1, 0) - content_split.setSizes([700, 320]) - self._content_split = content_split - self._ai_panel.setVisible(False) # default collapsed - rl.addWidget(content_split, 1) - - split.addWidget(right) - split.setStretchFactor(0, 0) - split.setStretchFactor(1, 1) - split.setSizes([300, 800]) - root.addWidget(split, 1) - - # Terminal CLI below the file view — collapsible, default collapsed; - # opening it points the shell at the current workspace folder. - from .terminal_panel import TerminalPanel - - self.terminal = TerminalPanel() - self.terminal.set_cwd(self._root) - self.terminal.expanded.connect(lambda: self.terminal.set_cwd(self._root)) - root.addWidget(self.terminal) - - on_language_changed(self._retranslate) - self._retranslate() - - # ---- public API --------------------------------------------------------- - def set_root(self, path: str) -> None: - p = str(path or "").strip() - if not p or not os.path.isdir(p): - return - self._root = p - self.path_lbl.setText(p) - self.path_lbl.setToolTip(p) - self.model.setRootPath(p) - self.tree.setRootIndex(self.model.index(p)) - if getattr(self, "terminal", None) is not None: - self.terminal.set_cwd(p) # terminal follows the workspace folder - - # ---- tree selection ------------------------------------------------------ - def _pick_root(self) -> None: - chosen = QFileDialog.getExistingDirectory(self, tr("folder.open_folder"), self._root) - if chosen: - self.set_root(chosen) - - def _on_tree_clicked(self, index) -> None: - path = self.model.filePath(index) - if path and os.path.isfile(path): - self.open_file(path) - - # ---- open a file the right way ------------------------------------------- - def open_file(self, path: str, reset: bool = True) -> None: - # Switching to a DIFFERENT file starts a fresh AI-edit conversation, so - # the previous file's chat can't bleed into (hallucinate) the new file. - # (reset=False when the AI just CREATED this file — keep that chat.) - if reset and path != self._current_file: - self._reset_ai_conversation() - self._current_file = path - self.file_label.setText(path) - suffix = Path(path).suffix.lower() - self.mode_btn.setVisible(False) - self.save_btn.setVisible(False) - self.ext_btn.setVisible(False) - self._edit_kind = None - try: - size = os.path.getsize(path) - except OSError: - size = 0 - - if suffix in _IMAGE_SUFFIXES: - self._show_image(path) - elif suffix in _HTML_SUFFIXES: - self._show_html(path, mode_preview=True) - elif suffix in _PPTX_SUFFIXES and _pptx_available(): - self._show_pptx(path, mode_preview=True) - elif suffix in _EXCEL_SUFFIXES: - self._show_excel(path) - elif suffix in DOC_SUFFIXES: - self._show_document(path) - elif size > _MAX_EDIT_BYTES or not _is_probably_text(path): - self._show_binary(path) - else: - self._show_code(path) - - def _show_code(self, path: str) -> None: - text = _read_text(path) - self.editor.setReadOnly(False) - self.editor.load_file(path, text) - self.save_btn.setVisible(True) - self.stack.setCurrentWidget(self.editor) - - def _show_html(self, path: str, mode_preview: bool) -> None: - self._edit_kind = "html" - self.mode_btn.setVisible(True) - self.mode_btn.setChecked(not mode_preview) # checked = Edit - self._retranslate_mode_btn() - if mode_preview: - from PySide6.QtCore import QUrl - html = _read_text(path) - engine = self._ensure_engine() - if engine is not None: - engine.setHtml(html, QUrl.fromLocalFile(path)) - self.stack.setCurrentWidget(engine) - else: - self.web.setHtml(html) - self.stack.setCurrentWidget(self.web) - self.save_btn.setVisible(False) - else: - self._show_code(path) - - def _show_pptx(self, path: str, mode_preview: bool) -> None: - """PPTX: Preview renders the slides (PDF via LibreOffice); Edit shows the - deck's text (marker-delimited per box) in the editor. Saving/AI-editing - writes the text back into the .pptx silently (no PowerPoint window).""" - self._edit_kind = "pptx" - self.mode_btn.setVisible(True) - self.mode_btn.setChecked(not mode_preview) # checked = Edit - self._retranslate_mode_btn() - self.ext_btn.setVisible(True) - if mode_preview: - self._show_document(path) # PDF render of the slides - self.mode_btn.setVisible(True) # _show_document doesn't touch it - else: - from ..core.pptx_edit import pptx_to_text - try: - text = pptx_to_text(path) - except Exception as exc: # noqa: BLE001 - text = f"[could not read pptx text: {exc}]" - self.editor.setReadOnly(False) - self.editor.load_file(path + ".txt", text) # .txt → plain highlighting - self.save_btn.setVisible(True) - self.stack.setCurrentWidget(self.editor) - - def _ensure_engine(self): - """Create the QWebEngineView on first HTML preview (only when WebEngine - is safe to use); otherwise stay on the QTextBrowser fallback.""" - if not _HAS_WEB: - return None - if self._engine is None: - try: - from PySide6.QtWebEngineWidgets import QWebEngineView - self._engine = QWebEngineView() - self.stack.addWidget(self._engine) - except Exception: # noqa: BLE001 - self._engine = None - return self._engine - - def _toggle_edit_mode(self) -> None: - if not self._current_file: - return - preview = not self.mode_btn.isChecked() # checked = Edit - if self._edit_kind == "pptx": - self._show_pptx(self._current_file, mode_preview=preview) - else: - self._show_html(self._current_file, mode_preview=preview) - - def _show_excel(self, path: str) -> None: - """View a spreadsheet as a real TABLE (openpyxl) — one tab per sheet — so - Excel is viewable WITHOUT LibreOffice/PowerPoint. Bounded rows/cols keep - large workbooks snappy. Falls back to the document (PDF/text) path if the - workbook can't be read.""" - self.ext_btn.setVisible(True) - try: - from ..core.deps import ensure_module - ensure_module("openpyxl", "openpyxl") - from openpyxl import load_workbook - wb = load_workbook(path, read_only=True, data_only=True) - except Exception: # noqa: BLE001 - no openpyxl / unreadable → try PDF/text - self._show_document(path) - return - MAX_ROWS, MAX_COLS = 2000, 100 - if self._xlsx_view is None: - self._xlsx_view = QTabWidget() - self.stack.addWidget(self._xlsx_view) - tabs = self._xlsx_view - while tabs.count(): - w = tabs.widget(0); tabs.removeTab(0); w.deleteLater() - try: - for ws in wb.worksheets: - rows = list(ws.iter_rows(max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True)) - ncols = max((len(r) for r in rows), default=0) - table = QTableWidget(len(rows), ncols) - table.setEditTriggers(QTableWidget.NoEditTriggers) - table.horizontalHeader().setVisible(False) - for r, row in enumerate(rows): - for c, val in enumerate(row): - if val is not None: - table.setItem(r, c, QTableWidgetItem(str(val))) - table.resizeColumnsToContents() - title = ws.title + (" (…)" if (ws.max_row or 0) > MAX_ROWS - or (ws.max_column or 0) > MAX_COLS else "") - tabs.addTab(table, title) - finally: - wb.close() - if tabs.count() == 0: - self._show_document(path) - return - self.stack.setCurrentWidget(tabs) - - def _show_document(self, path: str) -> None: - """Office docs (ppt/pptx/doc/docx/xls/…) + PDF are RENDERED via QtPdf — - LibreOffice converts them to PDF first. Falls back to text extraction - when QtPdf/LibreOffice aren't available.""" - self.ext_btn.setVisible(True) - suffix = Path(path).suffix.lower() - if not _HAS_PDF: - self._show_document_text(path) - return - if suffix == ".pdf": - self._render_pdf(path) - return - # Cached conversion (per path+mtime) → render immediately. - try: - mtime = os.path.getmtime(path) - except OSError: - mtime = 0 - cached = self._pdf_cache.get((path, mtime)) - if cached and os.path.exists(cached): - self._render_pdf(cached) - return - # Convert to PDF off the UI thread (LibreOffice → MS Office COM). Only - # skip to text when NEITHER is possible (no LibreOffice AND not Windows, - # where COM may drive an installed Office). This is what lets a large - # .pptx/.docx render via MS Office when LibreOffice isn't installed. - from ..core.doc_extract import convert_to_pdf, find_soffice - if not find_soffice() and os.name != "nt": - self._show_document_text(path) - return - self.doc_view.setPlainText(tr("folder.converting")) - self.stack.setCurrentWidget(self.doc_view) - if self._pdf_tmp is None: - import tempfile - self._pdf_tmp = tempfile.mkdtemp(prefix="cowork_folder_pdf_") - src, out_dir = path, self._pdf_tmp - - def job(worker): - return {"src": src, "mtime": mtime, "pdf": convert_to_pdf(src, out_dir)} - - def done(result): - if result.get("src") != self._current_file: - return # user moved on to another file - pdf = result.get("pdf") - if pdf: - self._pdf_cache[(result["src"], result["mtime"])] = pdf - self._render_pdf(pdf) - else: - self._show_document_text(src) - - worker = AgentWorker(job) - worker.finished_ok.connect(done) - worker.failed.connect(lambda _e, p=src: self._show_document_text(p)) - self._convert_worker = worker - worker.start() - - def _ensure_pdf_view(self): - if not _HAS_PDF: - return None - if self._pdf_view is None: - from PySide6.QtPdf import QPdfDocument - from PySide6.QtPdfWidgets import QPdfView - self._pdf_doc = QPdfDocument(self) - self._pdf_view = QPdfView(self) - self._pdf_view.setDocument(self._pdf_doc) - try: - self._pdf_view.setPageMode(QPdfView.PageMode.MultiPage) - self._pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth) - except Exception: # noqa: BLE001 - enum names vary slightly across versions - pass - self.stack.addWidget(self._pdf_view) - return self._pdf_view - - def _render_pdf(self, pdf_path: str) -> None: - view = self._ensure_pdf_view() - if view is None: - self._show_document_text(pdf_path) - return - self._pdf_doc.load(pdf_path) - self.stack.setCurrentWidget(view) - - def _show_document_text(self, path: str) -> None: - from ..core.doc_extract import extract_text - try: - text, note = extract_text(path) - except Exception as exc: # noqa: BLE001 - text, note = None, str(exc) - body = text if text else tr("folder.doc_unreadable", note=note or "?") - self.doc_view.setPlainText(body) - self.stack.setCurrentWidget(self.doc_view) - - def _show_image(self, path: str) -> None: - from PySide6.QtGui import QPixmap - pix = QPixmap(path) - if pix.isNull(): - self._show_binary(path) - return - self._img_label.setPixmap(pix) - self._img_label.resize(pix.size()) - self.ext_btn.setVisible(True) - self.stack.setCurrentWidget(self._img_scroll) - - def _show_binary(self, path: str) -> None: - self._placeholder.setText(tr("folder.binary_file")) - self.ext_btn.setVisible(True) - self.stack.setCurrentWidget(self._placeholder) - - # ---- save / external ----------------------------------------------------- - def _save(self) -> None: - if not self._current_file: - return - try: - if self._edit_kind == "pptx": - if not self._write_pptx(self.editor.toPlainText()): - return - else: - Path(self._current_file).write_text(self.editor.toPlainText(), encoding="utf-8") - self.status_message.emit(tr("folder.saved", name=Path(self._current_file).name)) - except Exception as exc: # noqa: BLE001 - self.status_message.emit(tr("folder.save_error", err=str(exc))) - - def _write_pptx(self, content: str, skip_confirm: bool = False) -> bool: - """Write edited pptx text back into the deck. If the edit REPLACES any - image, ask the user to confirm first (image edits are gated so a future - image-processing model can't touch pictures without an explicit OK). - ``skip_confirm`` is used when the image was already confirmed (e.g. just - generated). Returns False if the user declined.""" - from ..core import pptx_edit - if not skip_confirm and pptx_edit.image_change_requested(content): - from PySide6.QtWidgets import QMessageBox - ok = QMessageBox.question(self, tr("folder.ai_image_confirm_title"), - tr("folder.ai_image_confirm")) - if ok != QMessageBox.Yes: - self.status_message.emit(tr("folder.ai_image_declined")) - return False - pptx_edit.apply_text_to_pptx(self._current_file, content) - return True - - def _open_external(self) -> None: - if self._current_file: - from .osutil import open_location - open_location(self._current_file) - - # ---- AI edit panel ------------------------------------------------------- - def _build_ai_panel(self) -> QWidget: - self._ai_panel = QWidget() - v = QVBoxLayout(self._ai_panel) - v.setContentsMargins(6, 0, 0, 0) - v.setSpacing(4) - title_row = QHBoxLayout() - self._ai_title = QLabel(tr("folder.ai_edit")) - self._ai_title.setStyleSheet("font-weight:600;") - title_row.addWidget(self._ai_title) - title_row.addStretch(1) - # Live status — stays visible so that, after doing other tasks and - # coming back to this tab, the current "processing/done" state is shown. - self._ai_status = QLabel("") - self._ai_status.setObjectName("hint") - title_row.addWidget(self._ai_status) - v.addLayout(title_row) - # A Cowork-style inline timeline (streaming bubbles + plan) — the AI edit - # "processing" reads exactly like the Cowork chat. - self.ai_chat = ChatView() - v.addWidget(self.ai_chat, 1) - - # AI-edit's OWN model picker (independent of the Cowork/Settings agent) — - # the chosen model runs the edit; "(auto)" uses the provider default. - self._ai_models: list[str] = [] - model_row = QHBoxLayout() - self._ai_model_lbl = QLabel(tr("folder.ai_model_label")) - self._ai_model_lbl.setObjectName("hint") - model_row.addWidget(self._ai_model_lbl) - self.ai_model_combo = QComboBox() - self.ai_model_combo.addItem(tr("folder.ai_model_auto"), None) - model_row.addWidget(self.ai_model_combo, 1) - # Off/Auto/Manual routing toggle for AI-Edit (surface key "ai_edit"). - from .routing_toggle import RoutingToggle - self.ai_routing_toggle = RoutingToggle(self.ctx, "ai_edit") - model_row.addWidget(self.ai_routing_toggle) - # Routing override for the next AI-edit run (set by _ai_apply_routing). - self._ai_routed_provider = None - self._ai_routed_model = None - v.addLayout(model_row) - - row = QHBoxLayout() - self.ai_input = QLineEdit() - self.ai_input.setPlaceholderText(tr("folder.ai_placeholder")) - self.ai_input.returnPressed.connect(self._ai_send) - row.addWidget(self.ai_input, 1) - self.ai_send_btn = QPushButton(tr("folder.ai_send")) - self.ai_send_btn.setObjectName("primary") - self.ai_send_btn.clicked.connect(self._ai_send) - row.addWidget(self.ai_send_btn) - v.addLayout(row) - - # Confirmation bar — the proposed edit is NOT applied/saved until the - # user reviews the diff and clicks Apply (Discard keeps the original). - self._ai_confirm_row = QWidget() - cf = QHBoxLayout(self._ai_confirm_row) - cf.setContentsMargins(0, 0, 0, 0) - cf.addStretch(1) - self._ai_discard_btn = QPushButton(tr("folder.ai_discard")) - self._ai_discard_btn.clicked.connect(self._ai_discard) - cf.addWidget(self._ai_discard_btn) - self._ai_apply_btn = QPushButton(tr("folder.ai_apply")) - self._ai_apply_btn.setObjectName("primary") - self._ai_apply_btn.clicked.connect(self._ai_apply) - cf.addWidget(self._ai_apply_btn) - self._ai_confirm_row.setVisible(False) - self._ai_pending = None # proposed content awaiting confirmation - v.addWidget(self._ai_confirm_row) - return self._ai_panel - - def _reset_ai_conversation(self) -> None: - """Clear the AI-edit chat so each file starts a clean conversation. A - run in progress (editing the previous file) is left untouched — the - reset applies the next time a file is opened while idle.""" - if getattr(self, "ai_chat", None) is None or self._ai_worker is not None: - return - self.ai_chat.clear() - self.ai_btn.setText(tr("folder.ai_edit")) - self._ai_pending = None - self._ai_confirm_row.setVisible(False) - if hasattr(self, "_ai_status"): - self._ai_status.setText("") - - def _toggle_ai_panel(self) -> None: - show = self.ai_btn.isChecked() - self._ai_panel.setVisible(show) - if show: - self._content_split.setSizes([700, 320]) - self.ai_input.setFocus() - # Populate the list on first open, AND re-fetch when the active - # provider changed since it was last loaded — otherwise the picker - # would keep another provider's models and a pick would resolve to - # the wrong/default model at the new endpoint. - if (self.ai_model_combo.count() <= 1 - or self._ai_models_provider != self.ctx.config.active_provider): - self.refresh_ai_models() - # Reopening acknowledges any 'done' badge (unless still running). - if self._ai_worker is None: - self.ai_btn.setText(tr("folder.ai_edit")) - self._ai_status.setText("") - - def refresh_ai_models(self) -> None: - """Fetch the active provider's model list (background) into the AI-edit - picker — independent of the Cowork/Settings agent. Called on first open - and whenever the active provider changes, so the picked model always - belongs to the provider that will actually run the edit.""" - name = self.ctx.config.active_provider - setting_model = self.ctx.config.provider_conf(name).get("model", "") - - def job(worker): - prov = self.ctx.build_provider_for(name) - try: - models = list(getattr(prov, "list_models", lambda: [])() or []) - except Exception: # noqa: BLE001 - models = [] - return {"models": models} - - def done(res): - fetched = list(res.get("models", [])) - # Always offer the Settings-configured model as an explicit choice, - # even when the provider can't list models (some gateways don't) — - # so the picker is never just "(auto)" and the user can always pick a - # concrete model instead of falling through to the default. - self._ai_models = list(dict.fromkeys( - ([setting_model] if setting_model else []) + [m for m in fetched if m])) - self._ai_models_provider = name - cur = self.ai_model_combo.currentData() - self.ai_model_combo.blockSignals(True) - self.ai_model_combo.clear() - self.ai_model_combo.addItem(tr("folder.ai_model_auto"), None) - for m in self._ai_models: - self.ai_model_combo.addItem(m, m) - # Keep the user's pick if it exists on THIS provider; otherwise reset - # to "(auto)" (a stale pick must never be sent to the new endpoint). - idx = self.ai_model_combo.findData(cur) - self.ai_model_combo.setCurrentIndex(idx if idx >= 0 else 0) - self.ai_model_combo.blockSignals(False) - - w = AgentWorker(job) - w.finished_ok.connect(done) - self._ai_models_worker = w - w.start() - # Proactively discover image models across ALL providers so an image - # suggestion is ready the moment the user asks for one. - self._scan_all_image_models() - - def _scan_all_image_models(self, then_suggest: bool = False) -> None: - """Background: find image-capable models across EVERY configured provider - (not just the active one), so we can suggest one when an edit involves - images even if the active provider has none. Caches - ``self._all_image_models = [(provider_key, model)]``.""" - if self._img_scan_worker is not None: - if then_suggest: - self._pending_img_suggest = True - return - providers = dict(self.ctx.config.data.get("providers", {})) - # Only providers that actually have an endpoint/key configured. - candidates = [k for k, c in providers.items() - if (c.get("base_url") or c.get("api_key"))] - - def job(worker): - from ..core import image_gen - found = [] - for key in candidates: - try: - prov = self.ctx.build_provider_for(key) - models = list(getattr(prov, "list_models", lambda: [])() or []) - except Exception: # noqa: BLE001 - a broken provider must not block the scan - models = [] - for m in models: - if image_gen.looks_like_image_model(m): - found.append((key, m)) - return {"found": found} - - def done(res): - self._img_scan_worker = None - self._all_image_models = list(res.get("found", [])) - if getattr(self, "_pending_img_suggest", False): - self._pending_img_suggest = False - self._suggest_cross_provider_image() - - w = AgentWorker(job) - w.finished_ok.connect(done) - w.failed.connect(lambda _e: setattr(self, "_img_scan_worker", None)) - self._img_scan_worker = w - if then_suggest: - self._pending_img_suggest = True - w.start() - - def _ensure_editor_for_ai(self) -> bool: - """Make the current file editable in the code editor (switching an HTML - preview to edit, or loading a text file). Returns False when there's no - file open or it isn't a text/code file.""" - path = self._current_file - if not path or not os.path.isfile(path): - return False - suffix = Path(path).suffix.lower() - if suffix in _HTML_SUFFIXES: - self._show_html(path, mode_preview=False) # → editor with the HTML source - return True - if suffix in _PPTX_SUFFIXES and _pptx_available(): - self._show_pptx(path, mode_preview=False) # → editor with the deck's text - return True - if suffix in DOC_SUFFIXES or suffix in _IMAGE_SUFFIXES: - return False - if _is_probably_text(path): - self._show_code(path) - return True - return False - - def _ai_provider(self): - """Build a provider using the model chosen in AI-edit's own picker - ('(auto)' → the active provider's default). NOT tied to the Cowork agent. - - An Auto/Manual routing override (set by :meth:`_ai_apply_routing` for the - current run) takes precedence over the picker.""" - if getattr(self, "_ai_routed_provider", None) or getattr(self, "_ai_routed_model", None): - provider = self._ai_routed_provider or self.ctx.config.active_provider - return self.ctx.build_provider_for(provider, self._ai_routed_model or None) - model = self.ai_model_combo.currentData() if hasattr(self, "ai_model_combo") else None - return self.ctx.build_provider_for(self.ctx.config.active_provider, model or None) - - def _ai_apply_routing(self, instruction: str) -> None: - """Auto Model Routing for the AI-Edit surface (always a CODING task). - - Sets ``self._ai_routed_provider``/``_ai_routed_model`` for this run; - :meth:`_ai_provider` honours them. - - The policy itself lives in the shared ``RoutingApplicationService`` - (R03-T05). What stays here is genuinely AI-Edit-specific: the task type - is pinned to CODING (an edit instruction is never a QA question, so - classifying it would only add noise), and the current model comes from - this screen's own picker rather than the global active model.""" - from ..core.routing.models import TaskType - - self._ai_routed_provider = None - self._ai_routed_model = None - cur_provider = self.ctx.config.active_provider - picked = self.ai_model_combo.currentData() if hasattr(self, "ai_model_combo") else None - cur_model = picked or self.ctx.config.provider_conf(cur_provider).get("model", "") - decision = self.ctx.routing_application().route_turn( - "ai_edit", instruction, cur_provider, cur_model, - task_type=TaskType.CODING, confirm=self._confirm_routing_switch, - ) - if not decision.switched: - return - self._ai_routed_provider, self._ai_routed_model = decision.target() - self.ai_chat.add_status(tr( - "routing.switched_notice", - model=decision.model, task=decision.task_type, - gain=f"{decision.score_gain:.2f}")) - - def _confirm_routing_switch(self, decision) -> bool: - """Manual mode: ask before moving this AI-Edit run to another model. - - Passed to the routing service as a callback, keeping the pure-Python - decision layer free of any Qt dialog knowledge.""" - from .routing_toggle import confirm_switch - - timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60) - return bool(confirm_switch(self, decision, timeout)) - - def _ai_image_model(self): - """Resolve the model+endpoint for image generation, searching ALL - providers. Returns ``(model, base_url, api_key)`` — ``base_url``/``api_key`` - are ``None`` when the active provider is used; set when the image model - lives on a DIFFERENT provider. - - Priority: the picked model if image-capable → an image model on the active - provider → the first image model found on ANY other provider → FALL BACK - to whatever model the user picked in AI-edit (so generation is still - attempted with their choice); ``None`` only when nothing is picked - ('(auto)' → provider default).""" - from ..core import image_gen - picked = self.ai_model_combo.currentData() if hasattr(self, "ai_model_combo") else None - if picked and image_gen.looks_like_image_model(picked): - return picked, None, None - local = image_gen.suggest_image_model(self._ai_models) - if local: - return local, None, None - for key, model in self._all_image_models: # any other configured provider - conf = self.ctx.config.provider_conf(key) - return model, (conf.get("base_url") or None), (conf.get("api_key") or None) - # No image-specific model found anywhere → use the user's PICKED model - # (or provider default when '(auto)' is selected). - return (picked or None), None, None - - _IMAGE_WORDS = ("image", "picture", "photo", "illustration", "icon", "logo", "diagram", - "ảnh", "hình", "minh họa", "biểu tượng", "画像", "イラスト") - - def _maybe_suggest_image_model(self, instruction: str) -> None: - """If the request looks image-related, suggest a suitable image model - BEFORE running — searching the active provider first, then ALL providers. - The suggested model is what image generation will auto-use.""" - from ..core import image_gen - low = (instruction or "").lower() - if not any(w in low for w in self._IMAGE_WORDS): - return - picked = self.ai_model_combo.currentData() - if picked and image_gen.looks_like_image_model(picked): - return - local = image_gen.suggest_image_model(self._ai_models) - if local: - self.ai_chat.add_status(tr("folder.ai_image_suggest", model=local)) - return - # None on the active provider → look across ALL providers (cached, or scan - # now and suggest when the scan returns). - if self._all_image_models: - self._suggest_cross_provider_image() - elif self._img_scan_worker is not None: - self._pending_img_suggest = True # a scan is already running - else: - self._scan_all_image_models(then_suggest=True) - - def _suggest_cross_provider_image(self) -> None: - """Post a suggestion listing image models found on OTHER providers. When - none exist anywhere, fall back to telling the user their PICKED model - will be used for image generation (or that there's nothing to use).""" - from ..config import PROVIDER_LABELS - if not self._all_image_models: - picked = self.ai_model_combo.currentData() - if picked: - self.ai_chat.add_status(tr("folder.ai_image_use_selected", model=picked)) - else: - self.ai_chat.add_status(tr("folder.ai_image_none")) - return - seen, lines = set(), [] - for key, model in self._all_image_models: - tag = (key, model) - if tag in seen: - continue - seen.add(tag) - lines.append(f"• {model} ({PROVIDER_LABELS.get(key, key)})") - if len(lines) >= 5: - break - self.ai_chat.add_status(tr("folder.ai_image_suggest_all") + "\n" + "\n".join(lines)) - - def _cowork_context(self) -> str: - """The whole Cowork conversation (recent turns) as background context — - so the AI edit is aware of what was discussed there.""" - cw = self._cowork - msgs = getattr(cw, "messages", None) if cw is not None else None - if not msgs: - return "" - lines = [f"{m['role']}: {str(m['content'])[:1000]}" - for m in msgs if m.get("role") in ("user", "assistant") and m.get("content")] - return "\n".join(lines[-12:]) - - def _ai_send(self) -> None: - if not self._root or not os.path.isdir(self._root): - self.ai_chat.add_error(tr("folder.ai_no_file")) - return - instruction = self.ai_input.text().strip() - if not instruction: - return - self.ai_input.clear() - self.ai_chat.add_user(instruction) - # QUEUE: while a run is active OR a proposal is awaiting Apply/Discard, - # hold the new instruction and run it when the pipeline goes idle. Lets - # the user line up several edits without waiting for each to finish. - if self._ai_worker is not None or self._ai_pending is not None: - self._ai_queue.append(instruction) - self.ai_chat.add_status(tr("folder.ai_queued", n=len(self._ai_queue))) - self._update_queue_status() - return - self._ai_start(instruction) - - def _ai_start(self, instruction: str) -> None: - """Begin processing one instruction (plan → edit). Assumes the pipeline - is idle (the queue calls this when the previous run finishes).""" - # If a text/code/HTML file is open (even in Preview), switch it into the - # editor so AI can edit it. If nothing editable is open, that's fine — - # the request may be to CREATE a new file (the model names it via FILE:). - editable = self.stack.currentWidget() is self.editor - if not editable: - editable = self._ensure_editor_for_ai() - self._maybe_suggest_image_model(instruction) - # Auto Model Routing (may switch to the best coding model for this run). - self._ai_apply_routing(instruction) - has_file = editable and bool(self._current_file) - self._ai_running_file = Path(self._current_file).name if has_file else tr("folder.ai_new_file") - self._ai_set_busy(True) - # Announce start on the status bar so it's visible even from another tab — - # the edit keeps running in the background until it finishes. - self.status_message.emit(tr("folder.ai_running", name=self._ai_running_file)) - # Two phases so the PLAN is shown INLINE *before* the edit runs. - self._ai_ctx = { - "filename": Path(self._current_file).name if has_file else "", - "content": self.editor.toPlainText() if has_file else "", - "convo": self._cowork_context(), - "instruction": instruction, - "provider": self._ai_provider(), - "plan": "", - } - # Reset the token/cost tally for THIS prompt (plan + edit calls sum into it). - self._ai_prompt_usage = {"in": 0, "out": 0, "cache": 0, "cost": 0.0} - self._ai_run_plan() - - def _update_queue_status(self) -> None: - """Reflect the number of queued instructions on the panel status line.""" - n = len(self._ai_queue) - if n and hasattr(self, "_ai_status"): - self._ai_status.setText("⏳ " + tr("folder.ai_status_running") - + " · " + tr("folder.ai_queue_count", n=n)) - self._ai_status.setStyleSheet(f"color:{current_palette().accent};") - - def _ai_maybe_dequeue(self) -> None: - """When the pipeline is fully idle, start the next queued instruction.""" - if self._ai_worker is not None or self._ai_pending is not None: - return - if not self._ai_queue: - return - nxt = self._ai_queue.pop(0) - self._update_queue_status() - self._ai_start(nxt) - - # ---- phase 1: plan ------------------------------------------------------- - # ---- token / cost accounting for AI-edit (like Cowork's per-message footer) -- - def _ai_add_usage(self, usage) -> None: - """Add one model call's usage (plan or edit) to THIS prompt's tally.""" - if not isinstance(usage, dict): - return - tot = getattr(self, "_ai_prompt_usage", None) - if tot is None: - tot = self._ai_prompt_usage = {"in": 0, "out": 0, "cache": 0, "cost": 0.0} - tot["in"] += int(usage.get("in", 0) or 0) - tot["out"] += int(usage.get("out", 0) or 0) - tot["cache"] += int(usage.get("cache", 0) or 0) - tot["cost"] += float(usage.get("cost_usd", 0.0) or 0.0) - - def _ai_show_usage(self, bubble) -> None: - """Footer under the AI-edit reply: ↓in ↑out ▤ctx $cost for the whole - prompt (plan + edit), priced in the display currency — same as Cowork.""" - tot = getattr(self, "_ai_prompt_usage", None) - if bubble is None or not tot or not (tot["in"] or tot["out"]): - return - from ..core import model_pricing as mp, usage_tracker as ut - pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})} - line = (f"↓{mp.format_tokens(int(tot['in']))} ↑{mp.format_tokens(int(tot['out']))} " - f"▤{mp.format_tokens(int(tot['in'] + tot['out'] + tot['cache']))} " - f"{ut.format_cost(tot['cost'], pricing)}") - try: - bubble.add_usage(line) - except Exception: # noqa: BLE001 - a usage footer must never break the edit - pass - - def _ai_run_plan(self) -> None: - c = self._ai_ctx - plan_bubble = self.ai_chat.add_plan(tr("folder.ai_planning")) - self.ai_chat.scroll_to_bottom() - - def job(worker): - from ..core import usage_tracker as ut - from ..core.co4e_runner import _usage_delta - provider = c["provider"] - messages = [{"role": "system", "content": - "You are an AI file editor. Give a SHORT numbered plan (2-4 steps) for " - "the requested change. Plan ONLY — do NOT output any code."}] - if c["convo"]: - messages.append({"role": "system", - "content": "Context from the user's Cowork conversation:\n" + c["convo"]}) - messages.append({"role": "user", "content": - f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n" - f"Request: {c['instruction']}"}) - ut.set_context("folder", c.get("filename") or "AI edit") # Dashboard + usage - ut.begin_accumulation(); base = ut.accumulated() - try: - r = provider.chat(messages, tools=None, cancel=worker.is_cancelled) - txt = r.get("content", "") if isinstance(r, dict) else str(r) - usage = _usage_delta(base, self.ctx.config) - finally: - ut.end_accumulation() - return {"plan": provider.strip_think(txt) or "", "usage": usage} - - worker = AgentWorker(job) - worker.finished_ok.connect(lambda res, b=plan_bubble: self._ai_plan_done(res, b)) - worker.failed.connect(lambda err, b=plan_bubble: self._ai_failed(err, b)) - self._ai_worker = worker - worker.start() - - def _ai_plan_done(self, result, plan_bubble) -> None: - self._ai_add_usage((result or {}).get("usage")) # plan-step tokens - plan = ((result or {}).get("plan") or "").strip() - self._ai_ctx["plan"] = plan - plan_bubble.set_plain(plan or tr("folder.ai_empty")) - self.ai_chat.scroll_to_bottom() - self._ai_run_edit() # now execute the plan - - # ---- phase 2: execute (edit the file) ------------------------------------ - def _ai_run_edit(self) -> None: - c = self._ai_ctx - bubble = self.ai_chat.add_assistant(tr("folder.ai_edit")) - self.ai_chat.scroll_to_bottom() - - pptx_note = ("\nThis is a PPTX shown as marker blocks '### Slide N / Box M', where N is the " - "1-based SLIDE NUMBER and M the box on that slide. When the user refers to a " - "slide by number (e.g. 'edit slide 3'), change ONLY the blocks under '### Slide " - "3' and leave every other slide's block exactly as-is. Each block has fields " - "type/pos/size/font/text (and image for pictures). To change TEXT COLOUR or " - "FONT edit the `font:` line — e.g. `font: name=Arial size=28 bold=1 " - "color=FF0000`. Keep all block markers and structure.") if self._edit_kind == "pptx" else "" - - # When creating a NEW deck (request mentions slides/pptx and we're not - # already editing one), tell the model the marker format to emit so we can - # build a real .pptx from it. - _pptx_words = ("pptx", "powerpoint", "slide", "presentation", "deck", - "スライド", "プレゼン", "trình chiếu", "trinh chieu", "bài thuyết trình") - wants_new_pptx = (self._edit_kind != "pptx" - and any(w in c["instruction"].lower() for w in _pptx_words)) - new_pptx_note = ("\nTo CREATE a PowerPoint, name it `FILE: .pptx` and output the slides " - "as marker blocks — one block per shape:\n" - "### Slide 1 / Box 1\ntype: text\npos: 0.5, 0.4\nsize: 9.0, 1.2\n" - "font: name=Calibri size=32 bold=1 color=1F3864\ntext:\nTitle here\n\n" - "### Slide 1 / Box 2\ntype: text\npos: 0.5, 1.8\nsize: 9.0, 4.5\n" - "text:\nBullet one\nBullet two\n\n" - "Increment the Slide number for each new slide; pos/size are in inches; " - "font color is RRGGBB hex.") if wants_new_pptx else "" - - imggen_note = "" - try: - from ..core import image_gen - if image_gen.is_configured(self.ctx.config): - imggen_note = ("\nYou can also GENERATE an illustration image: add a line " - "`IMAGE_GEN: => `. Use a " - "generated image e.g. as a new picture, or (for pptx) set a picture " - "box's `image:` field to that same path to insert it.") - except Exception: # noqa: BLE001 - pass - - def job(worker): - provider = c["provider"] - open_note = (f"the currently-open file '{c['filename']}'" if c["filename"] - else "no file is open") - messages = [{"role": "system", "content": - "You are an AI file editor inside an app. Following the plan, output the " - "COMPLETE file content in ONE fenced code block (```), and nothing after " - "it. Preserve everything you were not asked to change.\n" - "If the request is to CREATE A NEW file (or a different file than the one " - "open), put a line `FILE: ` (relative to the " - "current folder) immediately before the code block. Omit FILE to edit the " - f"open file. Right now, {open_note}." + pptx_note + new_pptx_note + imggen_note}] - if c["convo"]: - messages.append({"role": "system", - "content": "Context from the user's Cowork conversation:\n" + c["convo"]}) - if c["plan"]: - messages.append({"role": "system", "content": "Plan to follow:\n" + c["plan"]}) - cur = (f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n" - if c["filename"] else "No file is currently open.\n\n") - messages.append({"role": "user", "content": cur + f"Request: {c['instruction']}"}) - - def on_text(piece: str) -> None: - worker.emit_event({"type": "text", "delta": piece}) - - from ..core import usage_tracker as ut - from ..core.co4e_runner import _usage_delta - ut.set_context("folder", c.get("filename") or "AI edit") # Dashboard + usage - ut.begin_accumulation(); base = ut.accumulated() - try: - r = provider.chat(messages, tools=None, on_text=on_text, cancel=worker.is_cancelled) - txt = r.get("content", "") if isinstance(r, dict) else str(r) - usage = _usage_delta(base, self.ctx.config) - finally: - ut.end_accumulation() - return {"text": provider.strip_think(txt) or "", "usage": usage} - - worker = AgentWorker(job) - worker.event.connect(lambda ev, b=bubble: self._ai_stream(ev, b)) - worker.finished_ok.connect(lambda res, b=bubble: self._ai_done(res, b)) - worker.failed.connect(lambda err, b=bubble: self._ai_failed(err, b)) - self._ai_worker = worker - worker.start() - - def _ai_stream(self, ev, bubble) -> None: - if isinstance(ev, dict) and ev.get("type") == "text": - bubble.append_delta(ev.get("delta", "")) - self.ai_chat.scroll_to_bottom() - - def _ai_done(self, result, bubble) -> None: - self._ai_worker = None - self._ai_set_busy(False) - self._ai_add_usage((result or {}).get("usage")) # edit-step tokens - self._ai_show_usage(bubble) # footer: prompt total (plan+edit) - text = ((result or {}).get("text") or "").strip() - target, new_content, summary, image_gens = _parse_ai_output(text) - if new_content is None and not image_gens: - bubble.set_markdown(text or tr("folder.ai_empty")) - self.ai_chat.scroll_to_bottom() - self._ai_flag_done() - return - # Decide edit-current vs create-new. A FILE: naming a path different from - # the open file (or when nothing is open) → CREATE a new file. - create = bool(target) and (not self._current_file - or Path(target).name != Path(self._current_file).name) - # PROPOSE the change — nothing is written until the user clicks Apply. - self._ai_pending = {"content": new_content, - "target": target if create else None, - "image_gens": image_gens} - hint = tr("folder.ai_review_hint") - bubble.set_markdown((summary + "\n\n" if summary else "") + "_" + hint + "_") - if new_content is not None: - import difflib - old = "" if create else self.editor.toPlainText() - diff = "".join(difflib.unified_diff( - old.splitlines(keepends=True), new_content.splitlines(keepends=True), - fromfile=("(new file)" if create else "current"), - tofile=(target if create else "proposed"))) or "(no textual difference)" - title = tr("folder.ai_proposed_new", name=target) if create else tr("folder.ai_proposed") - self.ai_chat.add_diff(title, diff) - if image_gens: - listing = "\n".join(f"• {p} ({prompt[:60]})" for prompt, p in image_gens) - self.ai_chat.add_status(tr("folder.ai_image_plan") + "\n" + listing) - self._ai_confirm_row.setVisible(True) - self.ai_chat.scroll_to_bottom() - name = target if create else getattr(self, "_ai_running_file", "") - self.status_message.emit(tr("folder.ai_proposed_status", name=name)) - self._ai_status.setText("● " + hint) - self._ai_status.setStyleSheet(f"color:{current_palette().warning};") - - def _ai_apply(self) -> None: - """Confirmed by the user. If the edit GENERATES images, ask the image - gate then generate them (off-thread) before finalising the file edit.""" - if not self._ai_pending: - return - p = self._ai_pending - self._ai_pending = None - self._ai_confirm_row.setVisible(False) - if p.get("image_gens"): - from PySide6.QtWidgets import QMessageBox - if QMessageBox.question(self, tr("folder.ai_image_confirm_title"), - tr("folder.ai_image_confirm_gen")) != QMessageBox.Yes: - self.status_message.emit(tr("folder.ai_image_declined")) - return - self._ai_generate_then_finalize(p) - return - self._ai_finalize_apply(p) - - def _ai_generate_then_finalize(self, p: dict) -> None: - imgs = p.get("image_gens") or [] - root = os.path.normpath(self._root) - img_model, img_base, img_key = self._ai_image_model() # may target another provider - self._ai_set_busy(True) - self.status_message.emit(tr("folder.ai_generating")) - - def job(worker): - from ..core import image_gen - results = [] - for prompt, rel in imgs: - dest = rel if os.path.isabs(rel) else os.path.join(root, rel) - dest = os.path.normpath(dest) - if os.path.commonpath([dest, root]) != root: - results.append((rel, False, "path escapes the folder")) - continue - try: - os.makedirs(os.path.dirname(dest) or root, exist_ok=True) - except OSError as exc: - results.append((rel, False, str(exc))) - continue - ok, msg = image_gen.generate_image(self.ctx.config, prompt, dest, - model=img_model, base_url=img_base, api_key=img_key) - results.append((dest, ok, msg)) - return {"results": results} - - worker = AgentWorker(job) - worker.finished_ok.connect(lambda res, pp=p: self._ai_images_done(res, pp)) - worker.failed.connect(lambda err, pp=p: self._ai_images_done({"results": [], "err": err}, pp)) - self._ai_worker = worker - worker.start() - - def _ai_images_done(self, res: dict, p: dict) -> None: - self._ai_worker = None - self._ai_set_busy(False) - created = [] - for dest, ok, msg in res.get("results", []): - if ok: - created.append(dest) - self.ai_chat.add_success("✓ " + tr("folder.ai_image_created", name=Path(dest).name)) - else: - self.ai_chat.add_error(tr("folder.ai_image_failed", err=msg)) - # Now apply any text/file edit (pptx image: fields now point at real files). - self._ai_finalize_apply(p, images_done=True) - # If it was only image generation, open the first new image. - if p.get("content") is None and not p.get("target") and created: - self.open_file(created[0], reset=False) - - def _ai_finalize_apply(self, p: dict, images_done: bool = False) -> None: - content = p.get("content") - target = p.get("target") - if content is None: - self.ai_chat.scroll_to_bottom() - self._ai_flag_done() - self.status_message.emit(tr("folder.ai_done", name=getattr(self, "_ai_running_file", ""))) - return - if target: - dest = self._create_new_file(target, content) - if dest is None: - return - self.ai_chat.add_success("✓ " + tr("folder.ai_created", name=Path(dest).name)) - self.status_message.emit(tr("folder.ai_created", name=Path(dest).name)) - else: - self.editor.setPlainText(content) # live update in the editor/preview - self._ai_write_out(content, skip_image_confirm=images_done) - self.ai_chat.add_success("✓ " + tr("folder.ai_applied")) - self.status_message.emit(tr("folder.ai_done", name=getattr(self, "_ai_running_file", ""))) - self.ai_chat.scroll_to_bottom() - self._ai_flag_done() - - def _create_new_file(self, target: str, content: str) -> Optional[str]: - """Create ``target`` (relative to the folder root) with ``content`` and - open it — like Cowork's save_file. Refuses paths escaping the root.""" - root = os.path.normpath(self._root) - dest = target if os.path.isabs(target) else os.path.join(root, target) - dest = os.path.normpath(dest) - if os.path.commonpath([dest, root]) != root: - self.status_message.emit(tr("folder.ai_error", err="path escapes the folder")) - return None - try: - os.makedirs(os.path.dirname(dest) or root, exist_ok=True) - if Path(dest).suffix.lower() in _PPTX_SUFFIXES and _pptx_available(): - # A .pptx is a binary package — build a real deck from the marker - # text (writing text straight to .pptx would corrupt it). - from ..core import pptx_edit - pptx_edit.create_pptx_from_text(dest, content) - else: - Path(dest).write_text(content, encoding="utf-8") - except Exception as exc: # noqa: BLE001 - OS error or pptx build failure - self.status_message.emit(tr("folder.save_error", err=str(exc))) - return None - self.open_file(dest, reset=False) # show the new file; keep this AI chat - return dest - - def _ai_discard(self) -> None: - self._ai_pending = None - self._ai_confirm_row.setVisible(False) - self.ai_chat.add_status(tr("folder.ai_discarded")) - self.ai_chat.scroll_to_bottom() - self._ai_status.setText("") - self._ai_maybe_dequeue() # discarding resolves the gate → run the next queued edit - - def _ai_write_out(self, content: str, skip_image_confirm: bool = False) -> None: - """Persist the confirmed content to disk AND refresh the preview. - pptx text is written back into the deck (no PowerPoint window).""" - if not self._current_file: - return - try: - if self._edit_kind == "pptx": - if not self._write_pptx(content, skip_confirm=skip_image_confirm): - return - else: - Path(self._current_file).write_text(content, encoding="utf-8") - except Exception as exc: # noqa: BLE001 - self.status_message.emit(tr("folder.save_error", err=str(exc))) - return - # Refresh preview: HTML re-renders; pptx re-renders the slides; code stays - # in the (now-saved) editor. - suffix = Path(self._current_file).suffix.lower() - if suffix in _HTML_SUFFIXES: - self._show_html(self._current_file, mode_preview=True) - elif suffix in _PPTX_SUFFIXES: - self._show_pptx(self._current_file, mode_preview=True) - - def _ai_failed(self, err, bubble) -> None: - self._ai_worker = None - bubble.set_markdown(tr("folder.ai_error", err=err)) - self._ai_set_busy(False) - self.status_message.emit(tr("folder.ai_error", err=err)) - self._ai_flag_done() - - def _ai_set_busy(self, busy: bool) -> None: - self.ai_input.setEnabled(not busy) - self.ai_send_btn.setEnabled(not busy) - if busy: - self._ai_status.setText("⏳ " + tr("folder.ai_status_running")) - self._ai_status.setStyleSheet(f"color:{current_palette().accent};") - self.ai_btn.setText(tr("folder.ai_edit") + " ⏳") # visible even when collapsed - else: - self._ai_status.setText("") - self.ai_btn.setText(tr("folder.ai_edit")) - - def _ai_flag_done(self) -> None: - """After a background run, show a 'done' badge on the panel/button so the - user notices the result when they return to the tab; cleared on reopen. - If more instructions are queued, start the next one instead.""" - if self._ai_worker is None and self._ai_pending is None and self._ai_queue: - self._ai_maybe_dequeue() - return - self._ai_status.setText("✓ " + tr("folder.ai_status_done")) - self._ai_status.setStyleSheet(f"color:{current_palette().success};") - if not self.ai_btn.isChecked() or self._ai_panel.isHidden(): - self.ai_btn.setText(tr("folder.ai_edit") + " ✓") - - # ---- i18n ---------------------------------------------------------------- - def _retranslate_mode_btn(self) -> None: - # Button label shows the action it performs: in Preview → "Edit"; in Edit → "Preview". - self.mode_btn.setText(tr("folder.edit") if not self.mode_btn.isChecked() - else tr("folder.preview")) - - def _retranslate(self) -> None: - # The label always shows a real path, so the placeholder became a - # tooltip hint on the button that changes it. - self._open_btn.setToolTip(tr("folder.path_placeholder")) - self._open_btn.setText(tr("folder.open_folder")) - self.save_btn.setText(tr("folder.save")) - self.ext_btn.setText(tr("folder.open_external")) - self.ai_btn.setText(tr("folder.ai_edit")) - self.ai_btn.setToolTip(tr("folder.ai_edit_tooltip")) - self._ai_title.setText(tr("folder.ai_edit")) - self.ai_input.setPlaceholderText(tr("folder.ai_placeholder")) - self.ai_send_btn.setText(tr("folder.ai_send")) - self._ai_model_lbl.setText(tr("folder.ai_model_label")) - if self.ai_model_combo.count() >= 1 and self.ai_model_combo.itemData(0) is None: - self.ai_model_combo.setItemText(0, tr("folder.ai_model_auto")) - self._ai_apply_btn.setText(tr("folder.ai_apply")) - self._ai_discard_btn.setText(tr("folder.ai_discard")) - if not self._current_file: - self._placeholder.setText(tr("folder.select_file")) - self._retranslate_mode_btn() - - -_PPTX_READY = None # cached: pptx-editing library available (after auto-install) - - -def _pptx_available() -> bool: - """True when python-pptx is importable. If it's MISSING, auto-download & - install it (via deps.ensure_module) so pptx editing 'just works' — cached so - the (one-time) install is attempted only once.""" - global _PPTX_READY - if _PPTX_READY is None: - try: - from ..core.deps import ensure_module - _PPTX_READY = ensure_module("pptx", "python-pptx") is not None - except Exception: # noqa: BLE001 - _PPTX_READY = False - return _PPTX_READY - - -def _split_code_block(text: str): - """Split an AI reply into ``(file_content, summary)``. ``file_content`` is - the first fenced code block (the edited file); ``summary`` is any prose - before it. Returns ``(None, text)`` when there's no code block.""" - import re - m = re.search(r"```[^\n]*\n(.*?)```", text or "", re.DOTALL) - if not m: - return None, (text or "") - return m.group(1), (text[:m.start()].strip()) - - -def _parse_ai_output(text: str): - """Parse an AI edit reply into ``(target, content, summary, image_gens)``. - ``FILE: `` names a NEW file to create; ``IMAGE_GEN: => `` - lines request generated illustration images (relative paths).""" - import re - content, summary = _split_code_block(text) - target = None - m = re.search(r"(?mi)^\s*FILE:\s*(.+?)\s*$", text or "") - if m: - target = m.group(1).strip().strip("`\"'") - image_gens = [] - for gm in re.finditer(r"(?mi)^\s*IMAGE_GEN:\s*(.+?)\s*=>\s*(\S+)\s*$", text or ""): - image_gens.append((gm.group(1).strip(), gm.group(2).strip().strip("`\"'"))) - # Strip the directive lines out of the shown summary. - summary = re.sub(r"(?mi)^\s*(FILE|IMAGE_GEN):\s*.+?$", "", summary).strip() - return target, content, summary, image_gens - - -def _read_text(path: str) -> str: - try: - return Path(path).read_text(encoding="utf-8", errors="replace") - except OSError as exc: - return f"[could not read file: {exc}]" - - -def _is_probably_text(path: str) -> bool: - try: - with open(path, "rb") as f: - chunk = f.read(4096) - except OSError: - return False - if b"\x00" in chunk: - return False - try: - chunk.decode("utf-8") - return True - except UnicodeDecodeError: - # Latin-ish text still edits fine via errors="replace"; only reject on - # a hard binary signal (NUL above), so most source files pass. - return True diff --git a/ui/schedule_task_tab.py b/ui/schedule_task_tab.py deleted file mode 100644 index bcc2a58..0000000 --- a/ui/schedule_task_tab.py +++ /dev/null @@ -1,794 +0,0 @@ -"""Schedule Task tab — Kanban board for scheduled/automated tasks. - -Columns: Backlog / Scheduled / Running / Waiting Input / Done / Failed / -Paused. Cards drag between columns (dropping = changing status), double-click -edits, right-click offers Run now / Edit / Duplicate / Pause / Delete / View -logs / Create-next-from-output. Header has search, a type filter, Add Task -and AI Create Task (preview first — nothing is created until confirmed). -""" -from __future__ import annotations - -import copy -from pathlib import Path -from typing import Dict, List, Optional - -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import ( - QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, - QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, - QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget, - QTabBar, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, -) - -from ..core import tasks as taskrepo -from ..core.projects import list_projects -from ..core.tasks import STATUSES, chain_error, duplicate_task, new_task -from ..core.worker import AgentWorker -from ..i18n import on_language_changed, tr -from ..state import AppContext -from ..theme import current_palette -from .calendar_view import CalendarView -from .icons import icon -from .osutil import open_path - -_VIEWS = ("kanban", "calendar") - -# Priority shown as a plain text tag (no colored-emoji squares). Only the -# elevated priorities get a visible marker; low/medium stay unmarked as before. -_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"} - - -class _KanbanColumn(QListWidget): - """One status lane. Accepts drops from sibling columns; a drop means - 'move this task to my status'.""" - - task_dropped = Signal(str, str) # task_id, new_status - - def __init__(self, status: str): - super().__init__() - self.status = status - self.setDragDropMode(QAbstractItemView.DragDrop) - self.setDefaultDropAction(Qt.MoveAction) - # Shift/Ctrl-click several cards in the SAME column, then right-click - # → "Delete N selected" to bulk-remove tasks instead of one at a time. - self.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.setWordWrap(True) - # Cards wrap, so there is never anything to reach by scrolling sideways - # — but QListWidget's own column hint runs 1-6px past the viewport, and - # a lane sprouted a horizontal scrollbar at 36 of 38 window widths I - # measured. Which lanes grew one changed with the width, which is why it - # looked like it depended on the screen. - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setResizeMode(QListWidget.Adjust) # re-wrap on every resize - # No pixel floor here. A fixed one is always wrong on some screen: - # 190 lost the seventh lane, 150 still wanted 1242px where a 1280 - # window leaves 1091 — so the 1280 monitor scrolled sideways and the - # 1920 one did not, same app, same build. The board divides whatever - # width it has by seven instead; see _fit_lanes(). - - def dropEvent(self, event): # noqa: N802 - source = event.source() - if isinstance(source, _KanbanColumn) and source is not self: - item = source.currentItem() - tid = item.data(Qt.UserRole) if item else None - if tid: - event.acceptProposedAction() - self.task_dropped.emit(tid, self.status) - return - event.ignore() - - -class ScheduleTaskTab(QWidget): - status_message = Signal(str) - - def __init__(self, ctx: AppContext, scheduler=None): - super().__init__() - self.ctx = ctx - self.scheduler = scheduler # TaskScheduler (may be None in tests) - self._ai_worker: Optional[AgentWorker] = None - self._tasks_dir: Optional[Path] = None # None → default repo dir - - root = QVBoxLayout(self) - - # ---- header ---------------------------------------------------- - header = QHBoxLayout() - self._title = QLabel() - self._title.setStyleSheet("font-weight:700; font-size:15px;") - self.counts_lbl = QLabel("") - self.counts_lbl.setObjectName("hint") - # A one-line summary of every lane's count. Left to size itself it - # reported a sizeHint wide enough to set the MINIMUM width of the whole - # screen — 1285px at 150% scaling, which then became the window's - # minimum and stopped the app fitting a 1280px laptop. It is a summary, - # and the same numbers are on each lane header, so it gives way first. - self.counts_lbl.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) - self.counts_lbl.setMinimumWidth(0) - self.add_btn = QPushButton() - self.add_btn.setIcon(icon("plus")) - self.add_btn.setObjectName("primary") - self.add_btn.clicked.connect(self._add_task) - self.ai_btn = QPushButton() - self.ai_btn.setIcon(icon("sparkle")) - self.ai_btn.clicked.connect(self._ai_create) - # Two views of the same tasks, so they read as a pair of tabs rather - # than a drop-list you have to open to discover the Calendar exists. - self.view_tabs = QTabBar() - self.view_tabs.setObjectName("viewTabs") - self.view_tabs.setDrawBase(False) - self.view_tabs.setExpanding(False) - for _v in _VIEWS: - self.view_tabs.addTab("") - self.view_tabs.currentChanged.connect(self._on_view_changed) - header.addWidget(self._title) - header.addWidget(self.counts_lbl, 1) - header.addWidget(self.view_tabs) - header.addWidget(self.add_btn) - header.addWidget(self.ai_btn) - root.addLayout(header) - - # ---- board / calendar (two views of the SAME tasks) ----------------- - self._view_stack = QStackedWidget() - scroll = QScrollArea() - scroll.setWidgetResizable(True) - board = QWidget() - scroll.setWidget(board) - cols = QHBoxLayout(board) - # Gutters wide enough to read as a break between lanes without eating - # too much of the seven-way split — they still share the board equally - # (see _fit_lanes below), so a wider gutter narrows every lane by the - # same share automatically; nothing else to compute here. - cols.setSpacing(2) - self.columns: Dict[str, _KanbanColumn] = {} - self.column_headers: Dict[str, QLabel] = {} - for status in STATUSES: - box = QVBoxLayout() - # The per-lane holder's own margins were the style's default - # (~9px a side) on top of the inter-column gap — with seven lanes - # that outweighs the gap itself. Zero it out and let the lane's - # header/list fill the width _fit_lanes() hands them. - box.setContentsMargins(0, 0, 0, 0) - box.setSpacing(2) - head = QLabel() - head.setStyleSheet("font-weight:600;") - col = _KanbanColumn(status) - col.setObjectName("kanbanLane") - col.task_dropped.connect(self._on_task_dropped) - col.itemDoubleClicked.connect(self._on_double_click) - col.setContextMenuPolicy(Qt.CustomContextMenu) - col.customContextMenuRequested.connect( - lambda pos, c=col: self._context_menu(c, pos)) - box.addWidget(head) - box.addWidget(col, 1) - holder = QWidget() - holder.setLayout(box) - cols.addWidget(holder) - self.columns[status] = col - self.column_headers[status] = head - self._board_scroll = scroll - self._board_gap = cols.spacing() - scroll.viewport().installEventFilter(self) - self._view_stack.addWidget(scroll) - self.calendar = CalendarView() - self.calendar.edit_task.connect(self._edit_task) - self.calendar.add_task_on_date.connect(self._add_task_on_date) - self._view_stack.addWidget(self.calendar) - root.addWidget(self._view_stack, 1) - - if self.scheduler is not None: - self.scheduler.tasks_changed.connect(self.refresh) - self.scheduler.task_started.connect(lambda _tid: self.refresh()) - self.scheduler.task_finished.connect(lambda _tid, _ok: self.refresh()) - - # Belt-and-braces: also re-read the board every 10s so a card's lane - # ALWAYS reflects reality (Scheduled → Running → Done) even if some - # change slipped past the signals (e.g. task files edited externally). - from PySide6.QtCore import QTimer - self._refresh_timer = QTimer(self) - self._refresh_timer.setInterval(10_000) - self._refresh_timer.timeout.connect(self.refresh) - self._refresh_timer.start() - - self.refresh() - on_language_changed(self._retranslate) - - # ---- i18n ------------------------------------------------------------ - def _retranslate(self) -> None: - self._title.setText(tr("schedtask.title")) - self.add_btn.setText(tr("schedtask.add_btn")) - self.add_btn.setToolTip(tr("schedtask.add_tooltip")) - self.ai_btn.setText(tr("schedtask.ai_btn")) - self.ai_btn.setToolTip(tr("schedtask.ai_tooltip")) - for i, v in enumerate(_VIEWS): - self.view_tabs.setTabText(i, tr(f"schedtask.view.{v}")) - for status, col in self.columns.items(): - col.setToolTip(tr(f"schedtask.col_tip.{status}")) - self.refresh() - - # ---- Kanban / Calendar view switch -------------------------------- - def _on_view_changed(self) -> None: - self._view_stack.setCurrentIndex(self.view_tabs.currentIndex()) - - def _add_task_on_date(self, date_str: str) -> None: - """Create a task pre-filled with the clicked calendar date (default - 09:00) — same editor Add Task opens, nothing is saved until confirmed.""" - from .task_editor_dialog import TaskEditorDialog - - t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"}) - dlg = TaskEditorDialog(t, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) - if dlg.exec() and dlg.edited_task: - self._save_and_refresh(dlg.edited_task) - self.status_message.emit(tr("schedtask.msg_created")) - - # ---- lane widths ------------------------------------------------------ - # - # The seven lanes share the board equally — that is the layout's stretch - # doing the work, so the split is a proportion of whatever width there is, - # on any monitor. The only pixel question left is how narrow a lane may get - # before scrolling sideways beats squeezing, and that is a question about - # TEXT: roughly eight characters of a task title plus its padding. Reading - # it off the font keeps it right at 125%/150% scaling and at a user's own - # font size, where a constant would not be. - _LANE_FLOOR_CH = 8 - - def eventFilter(self, obj, event): # noqa: N802 - from PySide6.QtCore import QEvent - - if obj is self._board_scroll.viewport() and event.type() == QEvent.Resize: - self._fit_lanes() - return super().eventFilter(obj, event) - - def _fit_lanes(self) -> None: - floor = self.fontMetrics().averageCharWidth() * self._LANE_FLOOR_CH + 24 - for col in self.columns.values(): - if col.minimumWidth() != floor: - col.setMinimumWidth(floor) - - # ---- board rendering --------------------------------------------------- - def _card_text(self, t: dict) -> str: - prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "") - ai = "[AI] " if t.get("is_ai_generated") else "" - sched = t.get("schedule", {}) - when = sched.get("run_at") if sched.get("enabled") else None - when_line = when or tr("schedtask.no_schedule") - chain = "" - if t.get("dependency", {}).get("next_task_id") or t.get("dependency", {}).get("previous_task_id"): - chain = " (linked)" - last = t.get("logs", {}).get("last_status") - last_line = {"success": tr("schedtask.last_success"), - "failed": tr("schedtask.last_failed")}.get(last, tr("schedtask.last_never")) - # Card shows ONLY the task's own title (plus the [AI] marker and chain - # note) — no "[Cowork]"/"[Code]" task-type tag cluttering it. - return (f"{ai}{t.get('title', '')}{chain}\n" - f"{when_line} {prio}\n{last_line}") - - def refresh(self) -> None: - all_tasks = taskrepo.list_tasks(self._tasks_dir) - counts = {s: 0 for s in STATUSES} - for col in self.columns.values(): - col.clear() - for t in all_tasks: - status = t.get("status", "backlog") - if status not in self.columns: - continue - counts[status] += 1 - item = QListWidgetItem(self._card_text(t)) - item.setData(Qt.UserRole, t["task_id"]) - self.columns[status].addItem(item) - pal = current_palette() - for status, col in self.columns.items(): - self.column_headers[status].setText( - f"{tr(f'schedtask.status.{status}')} ({counts[status]})") - # Dropping a card into Running STARTS the task for real, so that - # lane is outlined while it holds anything — the one column here - # with a side effect should not look like the other six. - if status == "running" and counts[status]: - col.setStyleSheet( - f"border: 1px solid {pal.warning}; border-radius: {pal.radius}px;") - self.column_headers[status].setStyleSheet( - f"font-weight:600; color: {pal.warning};") - else: - col.setStyleSheet("") - self.column_headers[status].setStyleSheet("font-weight:600;") - if col.count() == 0: - empty = QListWidgetItem(tr("schedtask.no_tasks")) - empty.setFlags(Qt.NoItemFlags) - col.addItem(empty) - summary = " ".join( - f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s]) - self.counts_lbl.setText(summary) - self.counts_lbl.setToolTip(summary) # full text stays reachable if clipped - self.calendar.set_tasks(all_tasks) - - # ---- actions -------------------------------------------------------- - def _save_and_refresh(self, task: dict) -> None: - taskrepo.save_task(task, self._tasks_dir) - self.refresh() - - def _add_task(self) -> None: - from .task_editor_dialog import TaskEditorDialog - - dlg = TaskEditorDialog(None, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) - if dlg.exec() and dlg.edited_task: - self._save_and_refresh(dlg.edited_task) - self.status_message.emit(tr("schedtask.msg_created")) - - def _edit_task(self, task_id: str) -> None: - from .task_editor_dialog import TaskEditorDialog - - task = taskrepo.load_task(task_id, self._tasks_dir) - if not task: - return - dlg = TaskEditorDialog(task, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) - if dlg.exec() and dlg.edited_task: - self._save_and_refresh(dlg.edited_task) - - def _on_double_click(self, item: QListWidgetItem) -> None: - tid = item.data(Qt.UserRole) - if tid: - self._edit_task(tid) - - def _on_task_dropped(self, task_id: str, new_status: str) -> None: - """Dropping a card into a lane ACTS on the task, not just relabels it: - → Running actually runs it now; → Done marks it completed; → Scheduled - puts it on the calendar (opening the editor if no time is set yet).""" - task = taskrepo.load_task(task_id, self._tasks_dir) - if not task: - return - if task.get("status") == "running": - self.refresh() # can't drag a running task - return - if new_status == "running": - # Dropping into Running = "run it now" (counts as manual approval). - self.refresh() - self._run_now(task) - return - if new_status == "done": - task["status"] = "done" - task["schedule"]["enabled"] = False # done by hand → don't re-fire - self._save_and_refresh(task) - return - task["status"] = new_status - if new_status == "scheduled" and not task["schedule"].get("enabled"): - if task["schedule"].get("run_at"): - task["schedule"]["enabled"] = True - else: - # No time set yet — a silently-disabled "Scheduled" card would - # never run and look broken. Open the editor so the user sets - # the schedule right away. - self._save_and_refresh(task) - self.status_message.emit(tr("schedtask.msg_set_schedule")) - self._edit_task(task_id) - return - self._save_and_refresh(task) - - @staticmethod - def _is_multi_selection(item, selected) -> bool: - """True when the right-clicked card is part of an existing multi-item - selection — pure boolean, kept separate from _context_menu so it's - testable without ever invoking Qt's (modal, event-loop-blocking) menu.""" - return len(selected) > 1 and item in selected - - def _context_menu(self, col: _KanbanColumn, pos) -> None: - item = col.itemAt(pos) - if item is None or not item.data(Qt.UserRole): - return - selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)] - if self._is_multi_selection(item, selected): - self._bulk_delete_menu(col, pos, selected) - return - tid = item.data(Qt.UserRole) - task = taskrepo.load_task(tid, self._tasks_dir) - if not task: - return - menu = QMenu(col) - run_act = menu.addAction(tr("schedtask.menu_run")) - edit_act = menu.addAction(tr("schedtask.menu_edit")) - dup_act = menu.addAction(tr("schedtask.menu_duplicate")) - paused = task.get("status") == "paused" - pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause")) - logs_act = menu.addAction(tr("schedtask.menu_logs")) - hist_act = menu.addAction(tr("schedtask.menu_history")) - next_act = menu.addAction(tr("schedtask.menu_create_next")) - menu.addSeparator() - del_act = menu.addAction(tr("schedtask.menu_delete")) - chosen = menu.exec(col.viewport().mapToGlobal(pos)) - if chosen == run_act: - self._run_now(task) - elif chosen == edit_act: - self._edit_task(tid) - elif chosen == dup_act: - self._save_and_refresh(duplicate_task(task)) - elif chosen == pause_act: - task["status"] = "backlog" if paused else "paused" - self._save_and_refresh(task) - elif chosen == logs_act: - self._view_logs(task) - elif chosen == hist_act: - _RunHistoryDialog(task, self).exec() - elif chosen == next_act: - self._create_next_from_output(task) - elif chosen == del_act: - if QMessageBox.question(self, tr("schedtask.menu_delete"), - tr("schedtask.delete_confirm", title=task.get("title", "")) - ) == QMessageBox.Yes: - taskrepo.delete_task(tid, self._tasks_dir) - self.refresh() - - def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None: - """Right-click on a multi-selection within one column (Shift/Ctrl-click - several cards first): one action deletes every selected task. The - popup itself is a thin wrapper — see _confirm_and_delete_selected for - the actual (independently testable) confirm+delete logic.""" - menu = QMenu(col) - del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected))) - chosen = menu.exec(col.viewport().mapToGlobal(pos)) - if chosen == del_act: - self._confirm_and_delete_selected(selected) - - def _confirm_and_delete_selected(self, selected) -> bool: - """Confirm, then delete every task in ``selected``. Split out of - _bulk_delete_menu so tests can drive it directly without having to - fake a real (modal, event-loop-blocking) QMenu popup.""" - if QMessageBox.question( - self, tr("schedtask.menu_delete"), - tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes: - return False - for item in selected: - tid = item.data(Qt.UserRole) - if tid: - taskrepo.delete_task(tid, self._tasks_dir) - self.refresh() - return True - - def _run_now(self, task: dict) -> None: - if task.get("task_type") == "manual": - self.status_message.emit(tr("schedtask.msg_manual_norun")) - return - if self.scheduler is None: - self.status_message.emit(tr("schedtask.msg_no_scheduler")) - return - if self.scheduler.run_now(task["task_id"]): - self.status_message.emit(tr("schedtask.msg_running", title=task.get("title", ""))) - self.refresh() - - def _view_logs(self, task: dict) -> None: - run_id = task.get("logs", {}).get("last_run_id") - if not run_id: - QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) - return - folder = taskrepo.ARTIFACTS_DIR / task["task_id"] / run_id - if folder.exists(): - open_path(str(folder)) - else: - QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) - - def _create_next_from_output(self, task: dict) -> None: - """Scaffold a follow-up task pre-wired to consume this task's output.""" - nxt = new_task(tr("schedtask.next_of", title=task.get("title", ""))) - nxt["task_type"] = "cowork" - nxt["input"]["mode"] = "previous_task_output" - nxt["input"]["previous_task_id"] = task["task_id"] - nxt["dependency"]["previous_task_id"] = task["task_id"] - err = chain_error(taskrepo.list_tasks(self._tasks_dir) + [nxt], - task["task_id"], nxt["task_id"]) - if err: - QMessageBox.warning(self, tr("schedtask.g_dependency"), err) - return - taskrepo.save_task(nxt, self._tasks_dir) - task["dependency"]["next_task_id"] = nxt["task_id"] - task["dependency"]["pass_output_to_next"] = True - if task["dependency"].get("run_next_mode", "none") == "none": - task["dependency"]["run_next_mode"] = "run_after_success" - taskrepo.save_task(task, self._tasks_dir) - self.refresh() - self._edit_task(nxt["task_id"]) - - # ---- AI create ---------------------------------------------------------- - def _ai_create(self) -> None: - dlg = _AiCreateDialog(self.ctx, self) - if dlg.exec() and dlg.created_tasks: - for t in dlg.created_tasks: - taskrepo.save_task(t, self._tasks_dir) - self.refresh() - self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks))) - - -class _RunHistoryDialog(QDialog): - """Run history of one task as a table (newest first): time, status, error; - double-click a row to open that run's artifact folder.""" - - def __init__(self, task: dict, parent=None): - super().__init__(parent) - self._task = task - self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}") - self.resize(620, 380) - root = QVBoxLayout(self) - hint = QLabel(tr("schedtask.hist_hint")) - hint.setObjectName("hint") - root.addWidget(hint) - - runs = list(reversed(task.get("runs", []) or [])) - self.table = QTableWidget(len(runs), 4) - self.table.setHorizontalHeaderLabels([ - tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"), - tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"), - ]) - self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) - self.table.setSelectionBehavior(QAbstractItemView.SelectRows) - for row, run in enumerate(runs): - ok = run.get("status") == "success" - cells = ( - run.get("finished_at", ""), - str(run.get("status", "")), - run.get("run_id", ""), - (run.get("error") or "")[:200], - ) - for col, text in enumerate(cells): - item = QTableWidgetItem(str(text)) - if col == 0: - item.setData(Qt.UserRole, run.get("run_id", "")) - self.table.setItem(row, col, item) - self.table.resizeColumnsToContents() - self.table.horizontalHeader().setStretchLastSection(True) - self.table.itemDoubleClicked.connect(self._open_artifact) - root.addWidget(self.table, 1) - - buttons = QDialogButtonBox(QDialogButtonBox.Close) - buttons.rejected.connect(self.reject) - buttons.accepted.connect(self.accept) - root.addWidget(buttons) - - def _open_artifact(self, item: QTableWidgetItem) -> None: - first = self.table.item(item.row(), 0) - run_id = first.data(Qt.UserRole) if first else "" - if not run_id: - return - folder = taskrepo.ARTIFACTS_DIR / self._task["task_id"] / run_id - if folder.exists(): - open_path(str(folder)) - - -class _DropZone(QLabel): - """Drag-an-.xlsx-here area for the Import tab.""" - - file_dropped = Signal(str) - - def __init__(self): - super().__init__() - self.setAlignment(Qt.AlignCenter) - self.setMinimumHeight(70) - _p = current_palette() - self.setStyleSheet( - f"QLabel {{ border: 1px dashed {_p.border_strong};" - f" border-radius: {_p.radius_lg}px;" - f" color: {_p.text_muted}; padding: 10px; }}") - self.setAcceptDrops(True) - - def dragEnterEvent(self, event): # noqa: N802 - urls = event.mimeData().urls() - if urls and urls[0].toLocalFile().lower().endswith( - (".xlsx", ".xlsm", ".xls", ".csv", ".json")): - event.acceptProposedAction() - - def dropEvent(self, event): # noqa: N802 - urls = event.mimeData().urls() - if urls: - self.file_dropped.emit(urls[0].toLocalFile()) - - -class _AiCreateDialog(QDialog): - """Create tasks two ways, one tab each (both preview first — nothing is - saved until the user confirms): ✨ AI gen from a natural-language - description, or 📥 Import from a filled Excel template (pick or drag).""" - - def __init__(self, ctx: AppContext, parent=None): - super().__init__(parent) - from PySide6.QtWidgets import QTabWidget - - self.ctx = ctx - self.created_tasks: List[dict] = [] - self._planned: List[dict] = [] - self._worker: Optional[AgentWorker] = None - self.setWindowTitle(tr("schedtask.ai_btn")) - self.resize(600, 520) - - root = QVBoxLayout(self) - ws_row = QHBoxLayout() - ws_row.addWidget(QLabel(tr("schedtask.f_workspace"))) - self.workspace_combo = QComboBox() - self.workspace_combo.addItem(tr("schedtask.no_workspace"), "") - for p in list_projects(): - self.workspace_combo.addItem(p.name, p.project_id) - self.workspace_combo.setToolTip(tr("schedtask.hint_workspace")) - ws_row.addWidget(self.workspace_combo, 1) - root.addLayout(ws_row) - self.tabs = QTabWidget() - root.addWidget(self.tabs, 1) - - # ---- tab 1: AI gen ------------------------------------------------ - ai_page = QWidget() - al = QVBoxLayout(ai_page) - al.addWidget(QLabel(tr("schedtask.ai_desc_label"))) - self.desc_edit = QPlainTextEdit() - self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph")) - self.desc_edit.setMaximumHeight(110) - al.addWidget(self.desc_edit) - # Attachments (files + links) — merged into every task this generates, - # AND into the planning prompt so the AI knows they exist. - attach_row = QHBoxLayout() - self.ai_files_edit = QLineEdit() - self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder")) - ai_pick_btn = QPushButton(tr("schedtask.pick_files")) - ai_pick_btn.setIcon(icon("folder")) - ai_pick_btn.clicked.connect(self._ai_pick_files) - attach_row.addWidget(self.ai_files_edit, 1) - attach_row.addWidget(ai_pick_btn) - al.addWidget(QLabel(tr("schedtask.f_files"))) - al.addLayout(attach_row) - self.ai_links_edit = QLineEdit() - self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder")) - al.addWidget(QLabel(tr("schedtask.f_links"))) - al.addWidget(self.ai_links_edit) - self.gen_btn = QPushButton(tr("schedtask.ai_generate")) - self.gen_btn.setIcon(icon("sparkle")) - self.gen_btn.setObjectName("primary") - self.gen_btn.clicked.connect(self._generate) - al.addWidget(self.gen_btn) - al.addWidget(QLabel(tr("schedtask.ai_preview_label"))) - self.preview = QPlainTextEdit() - self.preview.setReadOnly(True) - al.addWidget(self.preview, 1) - self.tabs.addTab(ai_page, tr("schedtask.tab_ai")) - - # ---- tab 2: Import from Excel -------------------------------------- - imp_page = QWidget() - il = QVBoxLayout(imp_page) - tpl_btn = QPushButton(tr("schedtask.export_template_btn")) - tpl_btn.setIcon(icon("upload")) - tpl_btn.clicked.connect(self._export_template) - il.addWidget(tpl_btn) - pick_row = QHBoxLayout() - pick_btn = QPushButton(tr("schedtask.import_pick_btn")) - pick_btn.setIcon(icon("folder")) - pick_btn.clicked.connect(self._pick_import_file) - pick_row.addWidget(pick_btn) - pick_row.addStretch(1) - il.addLayout(pick_row) - self.drop_zone = _DropZone() - self.drop_zone.setText(tr("schedtask.drop_hint")) - self.drop_zone.file_dropped.connect(self._load_import_file) - il.addWidget(self.drop_zone) - il.addWidget(QLabel(tr("schedtask.ai_preview_label"))) - self.import_preview = QPlainTextEdit() - self.import_preview.setReadOnly(True) - il.addWidget(self.import_preview, 1) - self.tabs.addTab(imp_page, tr("schedtask.tab_import")) - - self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm")) - self.buttons.button(QDialogButtonBox.Ok).setEnabled(False) - self.buttons.accepted.connect(self._confirm) - self.buttons.rejected.connect(self.reject) - root.addWidget(self.buttons) - - # ---- Import tab ------------------------------------------------------ - def _export_template(self) -> None: - from PySide6.QtWidgets import QFileDialog - - from ..core.task_excel import export_template - - path, _ = QFileDialog.getSaveFileName( - self, tr("schedtask.export_template_btn"), - "cowork_tasks_template.xlsx", "Excel (*.xlsx)") - if not path: - return - try: - export_template(path) - open_path(str(Path(path).parent)) - except Exception as exc: # noqa: BLE001 - QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc)) - - def _pick_import_file(self) -> None: - from PySide6.QtWidgets import QFileDialog - - from ..core.task_import import IMPORT_FILTER - - path, _ = QFileDialog.getOpenFileName( - self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER) - if path: - self._load_import_file(path) - - def _load_import_file(self, path: str) -> None: - from ..core.task_import import import_tasks - - try: - self._planned = import_tasks(path) - except ValueError as exc: - self.import_preview.setPlainText(str(exc)) - self.buttons.button(QDialogButtonBox.Ok).setEnabled(False) - return - by_id = {t["task_id"]: t["title"] for t in self._planned} - lines = [] - for i, t in enumerate(self._planned, 1): - sched = t.get("schedule", {}) - when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule") - deps = t.get("dependency", {}).get("depends_on") or [] - dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else "" - lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n" - f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}") - self.import_preview.setPlainText("\n\n".join(lines)) - self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned)) - - def _ai_pick_files(self) -> None: - from PySide6.QtWidgets import QFileDialog - - files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files")) - if files: - existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()] - self.ai_files_edit.setText("; ".join(existing + files)) - - def _attached_files(self) -> List[str]: - return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()] - - def _attached_links(self) -> List[str]: - return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()] - - def _generate(self) -> None: - description = self.desc_edit.toPlainText().strip() - if not description or self._worker is not None: - return - files, links = self._attached_files(), self._attached_links() - self.gen_btn.setEnabled(False) - self.gen_btn.setText(tr("schedtask.ai_generating")) - - def job(worker: AgentWorker): - from ..core.ai_task_planner import plan_tasks - - provider = self.ctx.build_active_provider() - full_desc = description - if files or links: - attach_note = "; ".join(files + links) - full_desc += f"\n\n(Attached references available: {attach_note})" - planned = plan_tasks(provider, full_desc, cancel=worker.is_cancelled) - # Attachments apply to every generated task so they're available - # at RUN time too, not just visible to the planner. - for t in planned: - t["input"]["file_paths"] = list(files) - t["input"]["links"] = list(links) - return {"tasks": planned} - - w = AgentWorker(job) - w.finished_ok.connect(self._on_planned) - w.failed.connect(self._on_failed) - self._worker = w - w.start() - - def _on_planned(self, result: dict) -> None: - self._worker = None - self.gen_btn.setEnabled(True) - self.gen_btn.setText(tr("schedtask.ai_generate")) - self._planned = result.get("tasks") or [] - lines = [] - for i, t in enumerate(self._planned, 1): - sched = t.get("schedule", {}) - when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule") - dep = t.get("dependency", {}) - chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else "" - lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n" - f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n" - f" {t.get('description', '')[:150]}") - self.preview.setPlainText("\n\n".join(lines)) - self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned)) - - def _on_failed(self, err: str) -> None: - self._worker = None - self.gen_btn.setEnabled(True) - self.gen_btn.setText(tr("schedtask.ai_generate")) - self.preview.setPlainText(str(err)) - - def _confirm(self) -> None: - project_id = self.workspace_combo.currentData() or "" - for t in self._planned: - t["project_id"] = project_id - self.created_tasks = self._planned - self.accept() diff --git a/ui/structure_graph_view.py b/ui/structure_graph_view.py deleted file mode 100644 index b195bf0..0000000 --- a/ui/structure_graph_view.py +++ /dev/null @@ -1,1034 +0,0 @@ -"""Structure (RAG) tab — knowledge graph of code / document structure. - -Primary view is the D3 knowledge-graph (WebEngine) which gently auto-rotates -when idle and opens a node's storage folder on click. If WebEngine isn't -available (e.g. the standalone .exe), a native draggable QGraphicsView is the -in-app fallback. The graph auto-updates when the Code agent produces output, -and an Agent box on the right answers questions over the graph (Graph-RAG). -""" -from __future__ import annotations - -import math -import re -import sys -from pathlib import Path - -from PySide6.QtCore import QObject, QPointF, Qt, QTimer, QUrl, Signal, Slot -from PySide6.QtGui import QBrush, QColor, QFont, QPen -from PySide6.QtWidgets import ( - QComboBox, QFileDialog, QGraphicsEllipseItem, QGraphicsLineItem, - QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, - QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, QTabBar, - QTextBrowser, QVBoxLayout, QWidget, -) - -def _frozen_onefile() -> bool: - """True only for a PyInstaller ONEFILE build. Onefile extracts itself to a - temp dir (sys._MEIPASS under %TEMP%), where the QtWebEngine helper process - can't run — creating a QWebEngineView hard-crashes the app (reported as - "click the graph tab → app closes"). A ONEDIR build keeps _MEIPASS as the - ``_internal`` folder right next to the exe, where WebEngine works fine, so - it keeps the full embedded D3 view.""" - if not getattr(sys, "frozen", False): - return False - meipass = getattr(sys, "_MEIPASS", "") - if not meipass: - return False - try: - return Path(meipass).resolve().parent != Path(sys.executable).resolve().parent - except OSError: # can't tell → play safe: use the native fallback - return True - - -try: # WebEngine + WebChannel are optional PySide6 add-ons - from PySide6.QtWebEngineWidgets import QWebEngineView - from PySide6.QtWebChannel import QWebChannel - _HAS_WEB = not _frozen_onefile() -except Exception: # pragma: no cover - _HAS_WEB = False - -from ..core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS -from ..theme import current_palette -from ..core.worker import AgentWorker -from ..i18n import on_language_changed, tr -from ..state import AppContext -from .icons import collapse_right_icon, icon -from .osutil import open_folder, open_location -from .widgets import CollapseStrip - -try: - from PySide6.QtWidgets import QGraphicsItem # noqa: F401 — ensure available -except Exception: - pass - - -class _Bridge(QObject): - """Exposed to the D3 page so a Shift+click on a node can open its - storage folder/link (local path or URL — see osutil.open_location).""" - - @Slot(str) - def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name - if path: - open_location(path) - - -class _Edge(QGraphicsLineItem): - def __init__(self, a: "_Node", b: "_Node", type_: str = ""): - super().__init__() - self.a, self.b = a, b - self.type = type_ - # Colour the edge by its RELATIONSHIP type (contains/defines/method/…), - # so the graph shows what each connection MEANS — falling back to the - # source node's tint for any untyped edge. - color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor() - if not color.isValid(): - color = a.brush().color().lighter(130) - self._color = color - self.setPen(QPen(color, 1.4)) - self.setZValue(-1) - # A small label naming the relationship, shown at the edge midpoint. - self._label = None - if type_: - self._label = QGraphicsSimpleTextItem(type_, self) - self._label.setBrush(QBrush(color.lighter(140))) - f = QFont() - f.setPointSize(7) - self._label.setFont(f) - self._label.setZValue(0) - a.edges.append(self) - b.edges.append(self) - self.adjust() - - def adjust(self) -> None: - pa, pb = self.a.scenePos(), self.b.scenePos() - self.setLine(pa.x(), pa.y(), pb.x(), pb.y()) - if self._label is not None: - br = self._label.boundingRect() - self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2, - (pa.y() + pb.y()) / 2 - br.height() / 2) - - -class _Node(QGraphicsEllipseItem): - def __init__(self, data, radius: int): - super().__init__(-radius, -radius, 2 * radius, 2 * radius) - self.data = data - self.edges = [] - tok = current_palette() - # NODE_KIND_COLORS is a categorical data encoding (one hue per node - # kind), not UI chrome — it stays fixed across themes on purpose so a - # given kind is always the same colour. Only the chrome follows tokens. - color = QColor(NODE_KIND_COLORS.get(data.kind, tok.text_muted)) - self.setBrush(QBrush(color)) - self.setPen(QPen(color.darker(160), 1.5)) - self.setFlags( - QGraphicsEllipseItem.ItemIsMovable - | QGraphicsEllipseItem.ItemIsSelectable - | QGraphicsEllipseItem.ItemSendsGeometryChanges - ) - self.setZValue(1) - label = QGraphicsSimpleTextItem(data.label, self) - label.setBrush(QBrush(QColor(tok.text))) - label.setPos(radius + 3, -8) - - def itemChange(self, change, value): # noqa: N802 - if change == QGraphicsEllipseItem.ItemPositionHasChanged: - for edge in self.edges: - edge.adjust() - return super().itemChange(change, value) - - -class _GraphView(QGraphicsView): - def __init__(self, scene): - super().__init__(scene) - self.setDragMode(QGraphicsView.NoDrag) - self._panning = False - self._pan_start = QPointF() - - def wheelEvent(self, e): # noqa: N802 - self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15, - 1.15 if e.angleDelta().y() > 0 else 1 / 1.15) - - def mousePressEvent(self, e): # noqa: N802 - if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None: - self._panning = True - self._pan_start = e.position() - self.setCursor(Qt.ClosedHandCursor) - e.accept() - return - super().mousePressEvent(e) - - def mouseMoveEvent(self, e): # noqa: N802 - if self._panning: - delta = e.position() - self._pan_start - self._pan_start = e.position() - self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x())) - self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y())) - e.accept() - return - super().mouseMoveEvent(e) - - def mouseReleaseEvent(self, e): # noqa: N802 - if self._panning: - self._panning = False - self.setCursor(Qt.ArrowCursor) - e.accept() - return - super().mouseReleaseEvent(e) - - def mouseDoubleClickEvent(self, e): # noqa: N802 - """Double-click or Ctrl+click on a node opens its storage folder.""" - item = self.itemAt(e.pos()) - if isinstance(item, _Node) and getattr(item.data, "path", ""): - open_folder(item.data.path) - e.accept() - return - super().mouseDoubleClickEvent(e) - - -class StructureGraphView(QWidget): - status_message = Signal(str) - - def __init__(self, ctx: AppContext): - super().__init__() - self.ctx = ctx - self._worker: AgentWorker | None = None - self._node_items: list[_Node] = [] - self._edge_items: list[_Edge] = [] - self._centroid = QPointF(0, 0) - self._link = 120 - self._graph = None - self._needs_scan = False - self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite) - self._ask_worker: AgentWorker | None = None - self._answer = "" - self._detail_mode = "idle" # "answer" | "node" | "idle" — what self.detail shows - # TEMPORARY extracted file content for Q&A (real content, not just the - # graph structure). Kept only while this tab is shown — cleared on leaving - # the tab or switching project/root (see _clear_extracts / hideEvent). - self._extract_cache: dict = {} # path -> extracted text - self._extract_dir = None # temp folder for md/json dumps - self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox - - self._rescan_timer = QTimer(self) - self._rescan_timer.setSingleShot(True) - self._rescan_timer.setInterval(1500) - self._rescan_timer.timeout.connect(self._scan) - - root = QVBoxLayout(self) - - bar = QHBoxLayout() - self.path_edit = QLineEdit(str(ctx.config.cowork_output_dir())) - self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) - self._pick_btn = QPushButton() - self._pick_btn.setIcon(icon("folder")) - self._pick_btn.setObjectName("primary") - self._pick_btn.clicked.connect(self._pick) - self.project_combo = QComboBox() - self.project_combo.currentIndexChanged.connect(self._on_project_changed) - self._scan_btn = QPushButton() - self._scan_btn.setIcon(icon("search")) - self._scan_btn.setObjectName("primary") - self._scan_btn.clicked.connect(self._scan) - # ONE toolbar row. There used to be a second row holding just the - # messages toggle and Export, which cost a whole row of height to carry - # two buttons. - self._export_btn = QPushButton() - self._export_btn.setIcon(icon("upload")) - self._export_btn.setObjectName("primary") - self._export_btn.clicked.connect(self._export) - bar.addWidget(self.path_edit, 1) - bar.addWidget(self._pick_btn) - bar.addWidget(self.project_combo) - bar.addWidget(self._scan_btn) - bar.addWidget(self._export_btn) - root.addLayout(bar) - self._refresh_project_combo() - - # Đồ thị | Tin nhắn as a real pair of tabs: the old single button - # relabelled itself, so the view you were NOT looking at was the only - # one named on screen. - self.view_tabs = QTabBar() - self.view_tabs.setObjectName("viewTabs") - self.view_tabs.setDrawBase(False) - self.view_tabs.setExpanding(False) - self.view_tabs.addTab(icon("graph"), "") - self.view_tabs.addTab(icon("message"), "") - self.view_tabs.currentChanged.connect(self._on_view_tab) - tab_row = QHBoxLayout() - tab_row.setContentsMargins(0, 0, 0, 0) - tab_row.addWidget(self.view_tabs) - tab_row.addStretch(1) - root.addLayout(tab_row) - - split = QSplitter(Qt.Horizontal) - self.scene = QGraphicsScene() - self.scene.setBackgroundBrush(QColor(current_palette().bg)) - self.scene.selectionChanged.connect(self._on_selection) - self.view = _GraphView(self.scene) - - self._stack = QStackedWidget() - self._stack.addWidget(self.view) - # A "Messages" view: all conversation messages grouped BY DAY, shown as - # JSON — a plain tree switched in via setCurrentWidget (never touches the - # D3/WebEngine graph). Populated from the (project-scoped) history store. - from PySide6.QtWidgets import QTreeWidget - self._msgs_view = QTreeWidget() - self._msgs_view.setHeaderHidden(True) - self._msgs_view.itemClicked.connect(self._show_msg_json) - self._stack.addWidget(self._msgs_view) - self.web = None - self._bridge = None - self._channel = None - - # The legend + Show-relationship control live INSIDE the D3 graph - # template now (assets/graph_template.html) — the graph column is just - # the stack (native view / D3 web / messages). - split.addWidget(self._stack) - - # Right-side agent panel (GraphRAG Q&A) - right = QWidget() - rl = QVBoxLayout(right) - rl.setContentsMargins(0, 0, 0, 0) - - # Agent panel header with collapse button - ag_hdr = QHBoxLayout() - self._ag_collapse = QPushButton() - self._ag_collapse.setIcon(collapse_right_icon()) - self._ag_collapse.setFixedWidth(28) - self._ag_collapse.clicked.connect(lambda: self._set_agent_collapsed(True)) - self._ag_label = QLabel() - ag_hdr.addWidget(self._ag_collapse) - ag_hdr.addWidget(self._ag_label, 1) - rl.addLayout(ag_hdr) - - # Ask row - ask_row = QHBoxLayout() - self.ask_edit = QLineEdit() - self.ask_edit.returnPressed.connect(self._ask) - self._ask_btn = QPushButton() - self._ask_btn.setIcon(icon("chat")) - self._ask_btn.setObjectName("primary") - self._ask_btn.clicked.connect(self._ask) - ask_row.addWidget(self.ask_edit, 1) - ask_row.addWidget(self._ask_btn) - rl.addLayout(ask_row) - - # Detail browser - self.detail = QTextBrowser() - self.detail.setReadOnly(True) - self.detail.setOpenLinks(False) - self.detail.anchorClicked.connect(self._on_detail_link) - rl.addWidget(self.detail, 1) - - self._agent_panel = right - - self._agent_strip = CollapseStrip(tr("structure.expand_agent_tooltip"), expand_dir="left") - self._agent_strip.clicked.connect(lambda: self._set_agent_collapsed(False)) - self._agent_strip.setVisible(False) - self._agent_pane = QWidget() - apl = QHBoxLayout(self._agent_pane) - apl.setContentsMargins(0, 0, 0, 0) - apl.setSpacing(0) - apl.addWidget(self._agent_strip) - apl.addWidget(right, 1) - - self._split = split - split.addWidget(self._agent_pane) - split.setChildrenCollapsible(False) - split.setSizes([840, 320]) - root.addWidget(split, 1) - on_language_changed(self._retranslate) - - def _retranslate(self) -> None: - self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) - self._pick_btn.setText(tr("structure.browse")) - self._scan_btn.setText(tr("structure.scan")) - self._export_btn.setText(tr("structure.export_png")) - # Both views are named at once now, so neither label depends on state. - self.view_tabs.setTabText(0, tr("structure.graph_btn")) - self.view_tabs.setTabText(1, tr("structure.msgs_btn")) - self.view_tabs.setTabToolTip(1, tr("structure.msgs_tooltip")) - self._ag_collapse.setToolTip(tr("structure.collapse_agent_tooltip")) - self._ag_label.setText(tr("structure.agent_header")) - self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder")) - self._ask_btn.setText(tr("structure.ask")) - if self._detail_mode == "idle": - self.detail.setPlaceholderText(tr("structure.detail_placeholder")) - self._agent_strip.setToolTip(tr("structure.expand_agent_tooltip")) - self.project_combo.setToolTip(tr("structure.project_tooltip")) - self._refresh_project_combo() - - # ---- project sandbox lock ----------------------------------------- - def _refresh_project_combo(self) -> None: - from ..core.projects import list_projects - - keep = self._active_project_id - self.project_combo.blockSignals(True) - self.project_combo.clear() - self.project_combo.addItem(tr("structure.project_none"), "") - row_to_select = 0 - for i, p in enumerate(list_projects(), start=1): - self.project_combo.addItem(p.name, p.project_id) - if p.project_id == keep: - row_to_select = i - self.project_combo.setCurrentIndex(row_to_select) - self.project_combo.blockSignals(False) - - def set_project(self, project_id: str) -> None: - pid = project_id or "" - self._refresh_project_combo() - target = self.project_combo.findData(pid) - if target < 0: - target = 0 - if self.project_combo.currentIndex() == target: - self._on_project_changed(target) - else: - self.project_combo.setCurrentIndex(target) - - def _on_project_changed(self, _idx: int) -> None: - from ..core.projects import load_project - - pid = self.project_combo.currentData() or "" - project_changed = pid != self._active_project_id - if project_changed: - self._clear_extracts() # different workspace → drop temp extraction - self._active_project_id = pid - locked = bool(pid) - self.path_edit.setReadOnly(locked) - # Also disable the folder-pick button — otherwise the scan path is only - # "locked" against typing, but the picker could still repoint it outside - # the selected project's sandbox, breaking GraphRAG scope isolation. - self._pick_btn.setEnabled(not locked) - if locked: - project = load_project(pid) - if project is not None: - self.path_edit.setText(str(project.workspace_dir())) - if project_changed: - # Mark it and scan on the next visit rather than now. The rail's - # project picker made switching a one-click thing from any screen, - # and each switch rebuilt this graph — a folder walk plus a force - # layout plus a full setHtml of the D3 page — for a tab that was - # usually not even on screen. auto_scan_and_fit() picks the flag up - # when GraphRAG is actually opened. - self._needs_scan = True - - # ---- helpers ----------------------------------------------------- - def _pick(self) -> None: - chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text()) - if chosen: - self.path_edit.setText(chosen) - - def schedule_rescan(self, path: str = "") -> None: - if self._graph is None: - self._needs_scan = True - return - self._rescan_timer.start() - - # ---- Messages (by day, as JSON) -------------------------------------- - def _on_view_tab(self, index: int) -> None: - """Tab 0 = graph, tab 1 = messages. Same two views as before, now named - on screen instead of hidden behind one button's changing label.""" - if index == 1: - self._reload_messages() - self._stack.setCurrentWidget(self._msgs_view) - else: - self._stack.setCurrentWidget(self.web if self.web is not None else self.view) - - def _toggle_messages(self) -> None: - """Kept for callers that still ask for a flip (e.g. keyboard paths).""" - showing = self._stack.currentWidget() is self._msgs_view - self.view_tabs.setCurrentIndex(0 if showing else 1) - - def _reload_messages(self) -> None: - """Build the tree: day → conversation. Click a conversation to see its - messages as JSON. Scoped to the current project (its history folder).""" - from collections import OrderedDict - - from PySide6.QtCore import Qt - from PySide6.QtWidgets import QTreeWidgetItem - - from ..core.history import list_conversations - self._msgs_view.clear() - pid = self._active_project_id or "" - by_day: "OrderedDict[str, list]" = OrderedDict() - try: - convs = list_conversations(self.ctx.config.history_dir()) - except Exception: # noqa: BLE001 - convs = [] - for conv in convs: - if pid and conv.get("project_id", "default") != pid: - continue - day = (conv.get("created") or "")[:10] or "—" - by_day.setdefault(day, []).append(conv) - if not by_day: - self._msgs_view.addTopLevelItem(QTreeWidgetItem([tr("structure.msgs_none")])) - return - for day in sorted(by_day, reverse=True): - convs_d = by_day[day] - day_item = QTreeWidgetItem([f"{day} ({len(convs_d)})"]) - for conv in convs_d: - it = QTreeWidgetItem([conv.get("title", "(untitled)")]) - it.setData(0, Qt.UserRole, str(conv.get("path", ""))) - day_item.addChild(it) - self._msgs_view.addTopLevelItem(day_item) - day_item.setExpanded(True) - - def _show_msg_json(self, item, _col: int = 0) -> None: - import html - import json - - from PySide6.QtCore import Qt - - from ..core.history import load_conversation - path = item.data(0, Qt.UserRole) - if not path: - return - try: - conv = load_conversation(path) - payload = {"title": conv.get("title", ""), "created": conv.get("created", ""), - "kind": conv.get("kind", ""), "project_id": conv.get("project_id", ""), - "messages": conv.get("messages", [])} - text = json.dumps(payload, ensure_ascii=False, indent=2) - except Exception as exc: # noqa: BLE001 - text = f"(could not read: {exc})" - self.detail.setHtml( - f'
{html.escape(text)}
') - - def prewarm(self) -> None: - """Pay for the graph view before it is clicked on, not during. - - Opening GraphRAG built a QWebEngineView (~140ms) and scanned the project - (~485ms) while an empty browser sat on screen — long enough, and white - enough, to read as the app restarting itself. Called from an idle timer - after the window is up, so startup itself is unaffected; the memory the - lazy construction was saving is spent a few seconds later instead. - """ - if not _HAS_WEB or self.web is not None: - return - self._ensure_web() - if self._graph is None and self.path_edit.text().strip(): - self._needs_scan = False - self._scan() # runs on a worker thread - - def _ensure_web(self) -> None: - if self.web is not None or not _HAS_WEB: - return - self.web = QWebEngineView() - # Blank the page in the app's own background first. A fresh - # QWebEngineView paints white, and on a dark theme that white rectangle - # WAS the flash — it showed for as long as the first scan took. - self.web.setHtml( - f"") - self._bridge = _Bridge() - self._channel = QWebChannel() - self._channel.registerObject("py", self._bridge) - self.web.page().setWebChannel(self._channel) - self._stack.addWidget(self.web) - self._stack.setCurrentWidget(self.web) - if self._graph is not None: - self._render_d3() - - def auto_scan_and_fit(self) -> None: - self._ensure_web() - if not self.path_edit.text().strip(): - return - if getattr(self, "_worker", None) is not None and self._worker.isRunning(): - self._fit() - self._preserve_answer() - return - if self._graph is not None and not self._needs_scan: - self._fit() - self._preserve_answer() - return - self._needs_scan = False - self._scan() - - # ---- scan -------------------------------------------------------- - def _scan(self) -> None: - path = self.path_edit.text().strip() or str(Path.cwd()) - mode = "files" # default: scan all files (filter removed) - use_cmem = bool(self.ctx.config.codebase_memory.get("enabled")) - cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "") - st = self.ctx.config.structure - max_nodes = int(st.get("max_nodes", 500) or 0) - max_edges = int(st.get("max_edges", 500) or 0) - self._scan_seq += 1 - seq = self._scan_seq - self.status_message.emit(tr("structure.scanning")) - - def job(worker: AgentWorker): - from ..core.structure_graph import ( - build_from_codebase_memory, build_from_directory, force_layout, - ) - if use_cmem: - from ..core.codebase_memory import CodebaseMemory - mem = CodebaseMemory(cmem_bin) - graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges) - if mem.available else build_from_directory(path, mode, max_nodes, max_edges)) - else: - graph = build_from_directory(path, mode, max_nodes, max_edges) - pos = force_layout(graph) - return {"graph": graph, "pos": pos, "seq": seq} - - w = AgentWorker(job) - w.finished_ok.connect(self._render) - w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e))) - self._worker = w - w.start() - - def _render(self, result: dict) -> None: - if result.get("seq") is not None and result["seq"] != self._scan_seq: - return - graph = result.get("graph") - pos = result.get("pos", {}) - if graph is None: - return - self._graph = graph - - self.scene.clear() - self.scene.setBackgroundBrush(QColor(current_palette().bg)) # restore after clear - self._node_items = [] - self._edge_items = [] - degree = {n.id: 0 for n in graph.nodes} - for e in graph.edges: - if e.source in degree: - degree[e.source] += 1 - if e.target in degree: - degree[e.target] += 1 - items = {} - sx = sy = 0.0 - for node in graph.nodes: - radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0)))) - item = _Node(node, radius) - x, y = pos.get(node.id, (0, 0)) - item.setPos(x, y) - self.scene.addItem(item) - items[node.id] = item - self._node_items.append(item) - sx += x - sy += y - for edge in graph.edges: - a, b = items.get(edge.source), items.get(edge.target) - if a and b: - e = _Edge(a, b, getattr(edge, "type", "")) - self.scene.addItem(e) - self._edge_items.append(e) - n = max(1, len(self._node_items)) - self._centroid = QPointF(sx / n, sy / n) - self._fit() - - if self.web is not None: - self._render_d3() - - note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else "" - self.status_message.emit(tr( - "structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note)) - self._preserve_answer() - - def _render_d3(self) -> None: - if self.web is None or self._graph is None: - return - from ..core.d3_graph import build_html - try: - self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/")) - except Exception as exc: - self.status_message.emit(f"D3 view error: {exc}") - - # ---- native interactions ---------------------------------------- - def _on_selection(self) -> None: - for item in self.scene.selectedItems(): - if isinstance(item, _Node): - d = item.data - self.detail.setPlainText(f"[{d.kind.upper()}] {d.label}\n\n{d.detail}") - self._detail_mode = "node" - return - - def _preserve_answer(self) -> None: - if self._detail_mode == "answer" and self._answer.strip(): - self._render_answer() - - def _set_agent_collapsed(self, collapsed: bool) -> None: - strip_w = CollapseStrip.WIDTH + 2 - self._agent_panel.setVisible(not collapsed) - self._agent_strip.setVisible(collapsed) - if collapsed: - self._agent_pane.setMaximumWidth(strip_w) - sizes = self._split.sizes() - if len(sizes) == 2: - self._split.setSizes([max(1, sum(sizes) - strip_w), strip_w]) - else: - self._agent_pane.setMaximumWidth(16777215) - self._split.setSizes([840, 320]) - - def _fit(self) -> None: - if self.web is not None and self._stack.currentWidget() is self.web: - self.web.page().runJavaScript("window.fitGraph && window.fitGraph();") - return - rect = self.scene.itemsBoundingRect() - if not rect.isNull(): - self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio) - - def _export(self) -> None: - path, _ = QFileDialog.getSaveFileName( - self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)") - if not path: - return - showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web) - if showing_d3: - self._export_d3_png(path) - else: - self._export_widget_grab(path) - - def _export_d3_png(self, path: str) -> None: - def on_result(data_url) -> None: - if not isinstance(data_url, str) or "," not in data_url: - self._export_widget_grab(path) - return - import base64 - try: - with open(path, "wb") as f: - f.write(base64.b64decode(data_url.split(",", 1)[1])) - self.status_message.emit(tr("structure.export_done", path=path)) - except (OSError, ValueError) as exc: - self.status_message.emit(tr("structure.export_failed", err=str(exc))) - self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result) - - def _export_widget_grab(self, path: str) -> None: - ok = self._stack.currentWidget().grab().save(path, "PNG") - if ok: - self.status_message.emit(tr("structure.export_done", path=path)) - else: - self.status_message.emit(tr("structure.export_failed", err="grab() returned no image")) - - # ---- agent Q&A over the graph ----------------------------------- - @staticmethod - def _graph_context(graph) -> str: - from collections import defaultdict - by_kind = defaultdict(list) - for n in graph.nodes: - by_kind[n.kind].append(n.label) - lines = [] - for kind in ("file", "class", "function", "method", "module", "section"): - items = by_kind.get(kind, []) - if items: - lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60])) - id2label = {n.id: n.label for n in graph.nodes} - rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}" - for e in graph.edges[:140]] - if rels: - lines.append("Relationships (sample):\n" + "\n".join(rels)) - return "\n".join(lines)[:7000] - - def _matched_sources(self, text: str): - if self._graph is None or not text: - return [] - found: dict[str, tuple[str, str, str]] = {} - for n in self._graph.nodes: - if not n.path: - continue - label = n.label.rstrip("()") - if len(label) < 3: - continue - if n.path not in found and re.search(rf"\b{re.escape(label)}\b", text): - found[n.path] = (n.kind, n.label, n.detail or n.path) - return sorted(found.items(), key=lambda kv: kv[1][1].lower())[:12] - - def _linkify_files(self, text: str, sources) -> str: - """Turn file/entity NAMES mentioned in the answer into clickable links that - open the file — so the user can click a name in the answer to view it.""" - for path, (kind, label, rel) in sources: - href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded) - tokens = [] - base = Path(path).name - if base and len(base) >= 3: - tokens.append(base) - lab = (label or "").rstrip("()").strip() - if lab and lab != base and len(lab) >= 3: - tokens.append(lab) - for tok in tokens: - esc = re.escape(tok) - # `tok` (code span) → keep the code style but make it a link - text = re.sub(rf"`{esc}`", f"[`{tok}`]({href})", text) - # bare tok, not already inside a link / path / code span - text = re.sub(rf"(? None: - text = self._answer - sources = self._matched_sources(text) - if sources: - # 1) Make the file/entity names IN THE ANSWER clickable (open on click). - text = self._linkify_files(text, sources) - # 2) Append a clickable "Related sources" section listing each file. - lines = [text, "", "---", f"**{tr('structure.related_sources')}**"] - for path, (kind, label, rel) in sources: - href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded) - # kind badge for context (file/function/section/json_key) - kind_badge = f" [{kind.upper()}]" if kind not in ("file",) else "" - lines.append(f"- **[{label}⧉]({href})**{kind_badge} — `{rel}`") - text = "\n".join(lines) - self.detail.setMarkdown(text) - - def _on_detail_link(self, url: QUrl) -> None: - if url.isLocalFile(): - p = url.toLocalFile() - # Open the FILE itself for viewing (fall back to its folder for a dir). - if Path(p).is_file(): - open_location(p) - else: - open_folder(p) - - def _ask(self) -> None: - question = self.ask_edit.text().strip() - if not question: - return - from ..core.skills import parse_skill_command - skill_prefix, question, info = parse_skill_command(question) - if info is not None: - self.detail.setMarkdown(info) - self._detail_mode = "answer" - self.ask_edit.clear() - return - if self._graph is None: - self.status_message.emit(tr("structure.scan_first")) - return - context = self._graph_context(self._graph) - # Real file CONTENT to answer from (extracted temporarily in the worker): - file_paths = self._candidate_file_paths() - extract_cache = dict(self._extract_cache) - extract_dir = str(self._extract_tmp_dir()) - self._answer = "" - self._detail_mode = "answer" - self.detail.setPlainText("…") - self.ask_edit.clear() - - active_project_id = self._active_project_id - - # Collect selected node context for auto-filtering - selected_nodes = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)] - selected_context = "" - if selected_nodes: - node_lines = [] - for nd in selected_nodes: - node_lines.append(f"- {nd.label} (kind: {nd.kind}, path: {getattr(nd, 'path', '')})") - if nd.detail: - node_lines.append(f" detail: {nd.detail}") - # Also gather connected nodes - connected_ids = set() - for nd in selected_nodes: - for edge in self._graph.edges: - if edge.source == nd.id: - connected_ids.add(edge.target) - elif edge.target == nd.id: - connected_ids.add(edge.source) - connected_nodes = [n for n in self._graph.nodes if n.id in connected_ids] - if connected_nodes: - node_lines.append("\nConnected nodes:") - for cn in connected_nodes: - node_lines.append(f"- {cn.label} (kind: {cn.kind})") - selected_context = "\n".join(node_lines) - - def job(worker: AgentWorker): - provider = self.ctx.build_active_provider() - system = ("You answer questions about a code/document knowledge graph. Use the provided " - "graph context AND the extracted file contents to retrieve, synthesize and " - "explain the answer. Be concise. Answer ONLY from what is provided (graph " - "context + extracted contents) — never invent files, functions, or facts that " - "aren't in it.\n\n" - "EACH answer MUST include source citations so the user can verify where " - "information came from. For every factual claim, file reference, or code " - "element you mention, add a citation using this format:\n\n" - " [source: filename.ext, line/section: XXX]\n\n" - "Rules for citations:\n" - " 1. Cite the EXACT file path from the graph context (use the path field).\n" - " 2. For Python files: cite the function/class name and approximate line " - " if available, or the module name.\n" - " 3. For document files (.md, .txt): cite the section heading.\n" - " 4. For JSON files: cite the key path (e.g. settings > database > host).\n" - " 5. Place citations inline after the relevant sentence or fact.\n" - " 6. At the end of your answer, add a '---' separator followed by a " - " numbered **Sources cited:** section listing each unique source with " - " its full path so the user can click to open it.\n\n" - "Example citation format in text:\n" - " The `process_data()` function handles CSV parsing " - "[source: src/utils/parser.py, function: process_data].\n\n" - "Example end-of-answer source list:\n" - " ---\n" - " **Sources cited:**\n" - " 1. `src/utils/parser.py` — process_data function\n" - " 2. `docs/api.md` — Section: Authentication\n") - if skill_prefix: - system += "\n\nFollow this skill:\n" + skill_prefix - if active_project_id: - from ..core.projects import load_project, project_context_text - proj_ctx = project_context_text(load_project(active_project_id)) - if proj_ctx: - system += "\n\n" + proj_ctx - user_content = f"Graph context:\n{context}" - if selected_context: - user_content += f"\n\nSelected node(s) context (focus your answer on these):\n{selected_context}" - # Auto-extract the actual file contents (temporary) so the answer is - # synthesized from real content, not just the graph structure. - content_block, new_cache = _extract_file_contents(file_paths, extract_cache, extract_dir) - if content_block: - user_content += ("\n\nExtracted file contents (read these to answer about file " - "details/data; cite the file path):\n" + content_block) - user_content += f"\n\nQuestion: {question}" - messages = [ - {"role": "system", "content": system}, - {"role": "user", "content": user_content}, - ] - from ..core import agent_roles, audit_log - ok = True - try: - provider.chat(messages, on_text=lambda t: worker.emit_event({"type": "text", "delta": t}), - cancel=worker.is_cancelled) - except Exception: - ok = False - raise - finally: - audit_log.record("tool_call", "graphrag_ask", ok, question[:500], - agent_role=agent_roles.KNOWLEDGE) - return {"extracted": new_cache} - - w = AgentWorker(job) - w.event.connect(self._on_ask_event) - w.finished_ok.connect(self._on_ask_done) - w.failed.connect(lambda e: self.detail.setPlainText(f"Error: {e}")) - self._ask_worker = w - w.start() - - def _on_ask_event(self, ev: dict) -> None: - if ev.get("type") == "text": - if self._answer == "": - self.detail.clear() - self._answer += ev.get("delta", "") - self.detail.setPlainText(self._answer) - - def _on_ask_done(self, result: dict) -> None: - # Keep the (temporary) extracted content so repeated questions reuse it - # without re-extracting — dropped when leaving the tab (_clear_extracts). - if isinstance(result, dict): - self._extract_cache.update(result.get("extracted", {}) or {}) - self._render_answer() - - # ---- temporary file-content extraction for Q&A ------------------------ - def _candidate_file_paths(self) -> list: - """File paths to read for a question: the SELECTED file nodes if any, else - every file node in the graph (capped downstream).""" - from pathlib import Path as _P - if self._graph is None: - return [] - sel = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)] - nodes = sel or list(self._graph.nodes) - out, seen = [], set() - for nd in nodes: - p = (getattr(nd, "path", "") or "").strip() - if p and p not in seen and _P(p).is_file(): - seen.add(p) - out.append(p) - return out - - def _extract_tmp_dir(self): - from pathlib import Path as _P - if self._extract_dir is None: - import tempfile - from ..config import CONFIG_DIR - base = CONFIG_DIR / "tmp" / "graphrag_extract" - base.mkdir(parents=True, exist_ok=True) - self._extract_dir = _P(tempfile.mkdtemp(dir=str(base))) - return self._extract_dir - - def _clear_extracts(self) -> None: - """Discard the temporary extracted content (on leaving the tab / switching - project). The extraction is a scratch aid, never persisted.""" - self._extract_cache = {} - d, self._extract_dir = self._extract_dir, None - if d is not None: - import shutil - shutil.rmtree(d, ignore_errors=True) - - def hideEvent(self, e): # noqa: N802 - # Leaving the GraphRAG tab → drop the temporary extracted info. - self._clear_extracts() - super().hideEvent(e) - - - - - -# -------------------------------------------------------------------------- -# Temporary file-content extraction for Graph-RAG Q&A (runs in the ask worker) -# -------------------------------------------------------------------------- -def _pdf_to_markdown(pdf_path, out_dir) -> str | None: - """Convert a PDF to Markdown with opendataloader-pdf when available (richer - structure than a plain text dump). Best-effort — returns None if the package - isn't installed or the call fails, so the caller falls back to doc_extract.""" - from pathlib import Path as _P - try: - import opendataloader_pdf # optional; auto-installed elsewhere if present - except Exception: # noqa: BLE001 - try: - from ..core.deps import ensure_module - if ensure_module("opendataloader_pdf", "opendataloader-pdf") is None: - return None - import opendataloader_pdf # noqa: F811 - except Exception: # noqa: BLE001 - return None - out = _P(out_dir) - out.mkdir(parents=True, exist_ok=True) - for call in ( - lambda: opendataloader_pdf.convert(input_path=[str(pdf_path)], output_dir=str(out), - generate_markdown=True), - lambda: opendataloader_pdf.convert(input_path=str(pdf_path), output_dir=str(out)), - lambda: opendataloader_pdf.convert(str(pdf_path), str(out)), - ): - try: - call() - break - except TypeError: - continue - except Exception: # noqa: BLE001 - return None - mds = list(out.rglob(_P(pdf_path).stem + "*.md")) or list(out.rglob("*.md")) - for md in mds: - try: - return md.read_text(encoding="utf-8", errors="replace") - except OSError: - continue - return None - - -def _extract_file_contents(paths, cache: dict, tmp_dir, - max_files: int = 15, max_total: int = 120_000): - """Read the ACTUAL content of ``paths`` (PDF→markdown via opendataloader when - available, else doc_extract for office/pdf/text). Returns ``(block, cache)`` - — ``block`` is the concatenated content for the prompt (bounded), ``cache`` - maps path→text for reuse. Never raises.""" - from pathlib import Path as _P - from ..core import doc_extract - cache = dict(cache or {}) - parts, total = [], 0 - for p in paths[:max_files]: - if total >= max_total: - break - text = cache.get(p) - if text is None: - try: - if _P(p).suffix.lower() == ".pdf": - text = _pdf_to_markdown(p, tmp_dir) - if not text: - text, _n = doc_extract.extract_text(p) - else: - text, _n = doc_extract.extract_text(p) - except Exception: # noqa: BLE001 - text = "" - cache[p] = text or "" - text = cache.get(p) or "" - if not text: - continue - chunk = text[: max(0, max_total - total)] - total += len(chunk) - parts.append(f'--- {_P(p).name} ({p}) ---\n{chunk}') - return ("\n\n".join(parts), cache) diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py index 533e760..82445cc 100644 --- a/ui/workspace_tab.py +++ b/ui/workspace_tab.py @@ -220,7 +220,7 @@ class WorkspaceTab(QWidget): # Folder — a two-pane file explorer (tree + view/edit) placed right below # Co4E. Always available (not project-gated); its root follows the # selected project's workspace folder when one is chosen. - from .folder_tab import FolderTab + from ..presentation.folder.folder_tab import FolderTab self._folder = FolderTab(self.ctx, cowork=self._cowork) self._folder.status_message.connect(self.status_message)