context-graph-viewer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ """Read-only terminal viewer for a Context Graph (map #374).
2
+
3
+ Run it with ``memgraph-context-graph ui``. It reads the hooks' config file, polls Memgraph, and never writes.
4
+ """
5
+
6
+ from .app import ViewerApp
7
+ from .cli import main
8
+
9
+ __all__ = ["ViewerApp", "main"]
@@ -0,0 +1,146 @@
1
+ """The viewer app: one reader per space, a scope shared by every view, and a polling tick."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from datetime import datetime, timezone
7
+ from typing import TYPE_CHECKING, ClassVar
8
+
9
+ from textual.app import App
10
+ from textual.binding import Binding
11
+
12
+ from . import fmt, queries
13
+ from .base import PolledScreen
14
+ from .dashboard import DashboardScreen
15
+ from .source import Identity, Reader, describe
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Callable
19
+
20
+ POLL_SECONDS = 3.0
21
+
22
+ CSS = """
23
+ #left { width: 2fr; }
24
+ #sessions, #entities, #nb { height: 1fr; }
25
+ #side { width: 1fr; }
26
+ #banner { padding: 0 1; border: round $warning; height: auto; }
27
+ #filter { height: 3; }
28
+ .pane { border: round $primary-darken-2; padding: 0 1; height: 1fr; }
29
+ .pane:focus-within, .pane:focus { border: round $accent; }
30
+ #growth-pane { height: auto; }
31
+ #growth { height: 3; }
32
+ #timeline { height: 3fr; border: round $primary-darken-2; }
33
+ #timeline:focus { border: round $accent; }
34
+ #produced-pane { height: 1fr; max-height: 12; }
35
+ #main { width: 3fr; }
36
+ #details-pane { width: 2fr; }
37
+ #entities { width: 3fr; border: round $primary-darken-2; }
38
+ #entities:focus, #sessions:focus { border: round $accent; }
39
+ #sessions { border: round $primary-darken-2; }
40
+ #prov-pane { width: 2fr; }
41
+ #nbmain { width: 3fr; }
42
+ #nb { border: round $primary-darken-2; }
43
+ #nb:focus { border: round $accent; }
44
+ #evidence-pane { width: 2fr; }
45
+ #view-status { height: 1; padding: 0 1; background: $panel; }
46
+ ProjectPicker { align: center middle; }
47
+ #picker { width: 80%; max-width: 100; height: 70%; border: round $accent; background: $surface; padding: 0 1; }
48
+ #picker .title { text-style: bold; padding: 0 0 1 0; }
49
+ """
50
+
51
+
52
+ class ViewerApp(App[None]):
53
+ """Read-only Context Graph viewer. Opens on the dashboard and polls the active view."""
54
+
55
+ TITLE = "Context Graph"
56
+ CSS = CSS
57
+ BINDINGS: ClassVar = [
58
+ Binding("a", "widen_scope", "scope"),
59
+ Binding("w", "next_space", "space", show=False),
60
+ Binding("question_mark", "help", "keys"),
61
+ Binding("q", "quit", "quit"),
62
+ ]
63
+
64
+ def __init__(
65
+ self,
66
+ identity: Identity,
67
+ *,
68
+ poll_seconds: float = POLL_SECONDS,
69
+ clock: Callable[[], datetime] | None = None,
70
+ reader_factory: Callable[..., Reader] = Reader,
71
+ ) -> None:
72
+ """Args:
73
+ identity: Who is looking, and which spaces they may read (personal first).
74
+ poll_seconds: How often the active view reloads; 0 turns polling off (tests drive it by hand).
75
+ clock: The current time, for "3m ago" text; tests pin it.
76
+ reader_factory: Builds the reader for a space.
77
+ """
78
+ super().__init__()
79
+ self.identity = identity
80
+ self.scope = queries.Scope(user_id=identity.user_id)
81
+ self.poll_seconds = poll_seconds
82
+ self._clock = clock or (lambda: datetime.now(timezone.utc))
83
+ self._reader_factory = reader_factory
84
+ self.space_index = 0
85
+ self.reader = reader_factory(identity.spaces[0])
86
+
87
+ def now(self) -> datetime:
88
+ """The current time, from the injected clock."""
89
+ return self._clock()
90
+
91
+ def on_mount(self) -> None:
92
+ """Open the dashboard and start polling."""
93
+ self.push_screen(DashboardScreen())
94
+ if self.poll_seconds > 0:
95
+ self.set_interval(self.poll_seconds, self.poll_active)
96
+
97
+ def on_unmount(self) -> None:
98
+ """Close the connection."""
99
+ self.reader.close()
100
+
101
+ def poll_active(self) -> None:
102
+ """Reload the view on top; views underneath catch up when they're shown again."""
103
+ if isinstance(self.screen, PolledScreen):
104
+ self.screen.action_poll()
105
+
106
+ def context_line(self) -> str:
107
+ """Where, as whom, and what scope: shown under every view's title."""
108
+ space = self.identity.spaces[self.space_index]
109
+ user = self.identity.user_id or "no user configured"
110
+ parts = [f"{describe(space)}", f"{space.name} space", f"user {user}", f"scope {self.scope.mode}"]
111
+ if self.scope.project:
112
+ parts.append(f"project {fmt.project_name(self.scope.project)}")
113
+ return " · ".join(parts)
114
+
115
+ def _rescoped(self) -> None:
116
+ for screen in self.screen_stack:
117
+ if isinstance(screen, PolledScreen):
118
+ screen.refresh_titles()
119
+ self.poll_active()
120
+
121
+ def action_widen_scope(self) -> None:
122
+ """Cycle the scope: your sessions, plus unattributed ones, everyone's."""
123
+ self.scope = self.scope.widened()
124
+ self._rescoped()
125
+
126
+ def set_project(self, project: str | None) -> None:
127
+ """Narrow every view to one working directory, or None for all."""
128
+ self.scope = replace(self.scope, project=project)
129
+ self._rescoped()
130
+
131
+ def action_next_space(self) -> None:
132
+ """Enterprise: switch to the next space this login may read; Community has only one."""
133
+ if len(self.identity.spaces) < 2:
134
+ self.notify("Only one space: shared spaces need the Enterprise edition.", timeout=3)
135
+ return
136
+ self.reader.close()
137
+ self.space_index = (self.space_index + 1) % len(self.identity.spaces)
138
+ self.reader = self._reader_factory(self.identity.spaces[self.space_index])
139
+ self._rescoped()
140
+
141
+ def action_help(self) -> None:
142
+ """Toggle the key help panel."""
143
+ if self.screen.query("HelpPanel"):
144
+ self.action_hide_help_panel()
145
+ else:
146
+ self.action_show_help_panel()
@@ -0,0 +1,210 @@
1
+ """What every view shares: polling with its own error state, pane navigation, and in-place table updates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast
6
+
7
+ from rich.text import Text
8
+ from textual.binding import Binding
9
+ from textual.containers import VerticalScroll
10
+ from textual.screen import Screen
11
+ from textual.widgets import DataTable, Footer, Header, Static
12
+ from textual.worker import get_current_worker
13
+
14
+ from . import fmt
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Iterable, Sequence
18
+ from datetime import datetime
19
+
20
+ from textual.app import ComposeResult
21
+ from textual.widget import Widget
22
+
23
+ from .app import ViewerApp
24
+ from .source import Reader
25
+
26
+ T = TypeVar("T")
27
+
28
+
29
+ class Pane(VerticalScroll):
30
+ """A titled, focusable, scrollable pane that ``f`` can fill the screen with."""
31
+
32
+ ALLOW_MAXIMIZE = True
33
+
34
+
35
+ class ViewStatus(Static):
36
+ """One line under a view: live, or the error from this view's last poll and how old its data is."""
37
+
38
+ def show(self, *, error: str | None, last_ok: datetime | None, now: datetime, note: str = "") -> None:
39
+ """Redraw for the view's latest poll outcome."""
40
+ line = Text()
41
+ if error:
42
+ line.append("⚠ ", style="bold red").append(fmt.clip(error, 160), style="red")
43
+ line.append(f" · data from {fmt.ago(last_ok, now)}" if last_ok else " · no data yet", style="dim")
44
+ elif last_ok is None:
45
+ line.append("… loading", style="dim")
46
+ else:
47
+ line.append("● ", style="green").append(f"live · updated {fmt.ago(last_ok, now)}", style="dim")
48
+ if note:
49
+ line.append(f" · {note}", style="dim")
50
+ self.update(line)
51
+
52
+
53
+ class PolledScreen(Screen[None], Generic[T]):
54
+ """A view that loads its data off the UI thread on every poll and keeps its own error state.
55
+
56
+ Subclasses implement :meth:`fetch` (runs in a worker thread, may raise) and :meth:`show` (runs on the UI
57
+ thread with the fetched data). A failed poll leaves the last good data on screen and says so on this view's
58
+ status line; another view's successful poll can't hide it.
59
+ """
60
+
61
+ #: Selectors of the panes number keys jump between, in order.
62
+ PANES: ClassVar[Sequence[str]] = ()
63
+ BINDINGS: ClassVar = [
64
+ *(Binding(str(n), f"pane({n})", f"pane {n}", show=False) for n in range(1, 10)),
65
+ Binding("f", "fullscreen", "fullscreen"),
66
+ Binding("r", "poll", "refresh", show=False),
67
+ ]
68
+
69
+ def __init__(self) -> None:
70
+ super().__init__()
71
+ self.data: T | None = None
72
+ self.error: str | None = None
73
+ self.last_ok: datetime | None = None
74
+
75
+ @property
76
+ def viewer(self) -> ViewerApp:
77
+ """The app, typed."""
78
+ return cast("ViewerApp", self.app)
79
+
80
+ def compose(self) -> ComposeResult:
81
+ """Header, the view's own body, its status line, and the key footer."""
82
+ yield Header()
83
+ yield from self.compose_body()
84
+ yield ViewStatus(id="view-status")
85
+ yield Footer()
86
+
87
+ def compose_body(self) -> Iterable[Widget]:
88
+ """The view's widgets between the header and the status line."""
89
+ return ()
90
+
91
+ def fetch(self, reader: Reader) -> T:
92
+ """Load this view's data. Runs in a worker thread."""
93
+ raise NotImplementedError
94
+
95
+ def show(self, data: T) -> None:
96
+ """Draw *data*. Runs on the UI thread."""
97
+ raise NotImplementedError
98
+
99
+ def status_note(self) -> str:
100
+ """Extra context for the status line (e.g. counts)."""
101
+ return ""
102
+
103
+ def on_mount(self) -> None:
104
+ """Load immediately rather than waiting for the first poll tick."""
105
+ self.refresh_titles()
106
+ self.draw_status()
107
+ self.action_poll()
108
+
109
+ def on_screen_resume(self) -> None:
110
+ """Coming back along the breadcrumb: catch up at once."""
111
+ self.refresh_titles()
112
+ if self.is_mounted:
113
+ self.action_poll()
114
+
115
+ def refresh_titles(self) -> None:
116
+ """Set the header from :meth:`title_text` and the viewer's context line."""
117
+ self.title = self.title_text()
118
+ self.sub_title = self.viewer.context_line()
119
+
120
+ def title_text(self) -> str:
121
+ """This view's breadcrumb title."""
122
+ return "Context Graph"
123
+
124
+ def action_poll(self) -> None:
125
+ """Start a background load; a newer poll replaces one still running."""
126
+ reader = self.viewer.reader
127
+ self.run_worker(lambda: self._load(reader), thread=True, exclusive=True, group="poll")
128
+
129
+ def _load(self, reader: Reader) -> None:
130
+ worker = get_current_worker()
131
+ try:
132
+ data = self.fetch(reader)
133
+ except Exception as exc: # A poll must never reach Textual's handler, which would end the app (#382).
134
+ if not worker.is_cancelled:
135
+ self.app.call_from_thread(self._failed, f"{type(exc).__name__}: {exc}")
136
+ return
137
+ if not worker.is_cancelled:
138
+ self.app.call_from_thread(self._loaded, data)
139
+
140
+ def _loaded(self, data: T) -> None:
141
+ self.data, self.error, self.last_ok = data, None, self.viewer.now()
142
+ self.show(data)
143
+ self.refresh_titles()
144
+ self.draw_status()
145
+
146
+ def _failed(self, error: str) -> None:
147
+ self.error = error
148
+ self.draw_status()
149
+
150
+ def draw_status(self) -> None:
151
+ """Redraw this view's status line."""
152
+ for status in self.query(ViewStatus):
153
+ status.show(error=self.error, last_ok=self.last_ok, now=self.viewer.now(), note=self.status_note())
154
+
155
+ def action_pane(self, number: int) -> None:
156
+ """Focus the *number*-th pane."""
157
+ if 0 < number <= len(self.PANES):
158
+ for widget in self.query(self.PANES[number - 1]):
159
+ widget.focus()
160
+ return
161
+
162
+ def action_fullscreen(self) -> None:
163
+ """Toggle the focused pane filling the screen."""
164
+ if self.maximized is not None:
165
+ self.minimize()
166
+ elif self.focused is not None:
167
+ self.maximize(self.focused)
168
+
169
+
170
+ def sync_table(table: DataTable, columns: Sequence[tuple[str, str]], rows: Sequence[tuple[str, list[Any]]]) -> None:
171
+ """Make *table* show *rows* without losing the reader's place.
172
+
173
+ Cells change in place when the same rows arrive in the same order, so the cursor and scroll stay put. When
174
+ rows are added, removed or reordered the table is rebuilt, and the cursor follows the row it was on.
175
+
176
+ Args:
177
+ columns: ``(key, label)`` per column.
178
+ rows: ``(row_key, cells)`` in display order; row keys must be unique.
179
+ """
180
+ column_keys = [key for key, _ in columns]
181
+ if [column.key.value for column in table.columns.values()] != column_keys:
182
+ table.clear(columns=True)
183
+ for key, label in columns:
184
+ table.add_column(label, key=key)
185
+ current = [row.value for row in table.rows]
186
+ wanted = [key for key, _ in rows]
187
+ if current == wanted:
188
+ for key, cells in rows:
189
+ for column, cell in zip(column_keys, cells, strict=True):
190
+ if table.get_cell(key, column) != cell:
191
+ table.update_cell(key, column, cell)
192
+ return
193
+ selected = cursor_key(table)
194
+ scroll_y = table.scroll_y
195
+ table.clear()
196
+ for key, cells in rows:
197
+ table.add_row(*cells, key=key)
198
+ if selected in wanted:
199
+ table.move_cursor(row=wanted.index(selected), animate=False, scroll=False)
200
+ table.scroll_to(y=scroll_y, animate=False)
201
+
202
+
203
+ def cursor_key(table: DataTable) -> str | None:
204
+ """The key of the row under *table*'s cursor, or None."""
205
+ if not table.row_count:
206
+ return None
207
+ try:
208
+ return table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
209
+ except Exception: # The cursor sits outside the rows while the table is empty or shrinking.
210
+ return None
@@ -0,0 +1,47 @@
1
+ """``memgraph-context-graph ui`` / ``context-graph-viewer``: open the viewer on the configured graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from importlib import metadata
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Sequence
12
+
13
+
14
+ def main(argv: Sequence[str] | None = None) -> int:
15
+ """Parse arguments, resolve identity from the config file, and run the viewer until the user quits.
16
+
17
+ Returns:
18
+ 0 on a normal exit, 2 when the config names an edition that can't be resolved.
19
+ """
20
+ parser = argparse.ArgumentParser(
21
+ prog="memgraph-context-graph ui",
22
+ description=(
23
+ "Read-only terminal viewer for your Context Graph: capture health, pipeline state, sessions and the "
24
+ "knowledge graph. Reads the same config file as the hooks (CONTEXT_GRAPH_CONFIG or "
25
+ "~/.config/context-graph/config.toml)."
26
+ ),
27
+ )
28
+ parser.add_argument("--poll", type=float, default=3.0, help="Seconds between refreshes (default 3).")
29
+ parser.add_argument("--version", action="version", version=metadata.version("context-graph-viewer"))
30
+ args = parser.parse_args(argv)
31
+
32
+ from memgraph_context_graph.spaces import SpaceError
33
+
34
+ from .app import ViewerApp
35
+ from .source import resolve_identity
36
+
37
+ try:
38
+ identity = resolve_identity()
39
+ except SpaceError as exc:
40
+ print(f"memgraph-context-graph ui: {exc}", file=sys.stderr)
41
+ return 2
42
+ ViewerApp(identity, poll_seconds=max(args.poll, 0.5)).run()
43
+ return 0
44
+
45
+
46
+ if __name__ == "__main__":
47
+ raise SystemExit(main())