genetinav 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.
Files changed (49) hide show
  1. genetinav/__init__.py +1 -0
  2. genetinav/api_client.py +124 -0
  3. genetinav/cache.py +71 -0
  4. genetinav/chunk_cache.py +148 -0
  5. genetinav/cli.py +239 -0
  6. genetinav/command_parser.py +32 -0
  7. genetinav/command_router.py +33 -0
  8. genetinav/db.py +77 -0
  9. genetinav/favorites.py +79 -0
  10. genetinav/gene_service.py +265 -0
  11. genetinav/history.py +94 -0
  12. genetinav/models.py +80 -0
  13. genetinav/navigation_history.py +61 -0
  14. genetinav/sequence.py +63 -0
  15. genetinav/settings.py +69 -0
  16. genetinav/textual_app.py +168 -0
  17. genetinav/themes.py +206 -0
  18. genetinav/ui_textual/__init__.py +1 -0
  19. genetinav/ui_textual/about_modal.py +339 -0
  20. genetinav/ui_textual/base_screen.py +16 -0
  21. genetinav/ui_textual/command_palette.py +137 -0
  22. genetinav/ui_textual/favorites_modal.py +79 -0
  23. genetinav/ui_textual/help_modal.py +191 -0
  24. genetinav/ui_textual/history_modal.py +69 -0
  25. genetinav/ui_textual/home_screen.py +255 -0
  26. genetinav/ui_textual/loading_screen.py +36 -0
  27. genetinav/ui_textual/menu_modal.py +210 -0
  28. genetinav/ui_textual/result_screen.py +152 -0
  29. genetinav/ui_textual/sequence_viewer.tcss +142 -0
  30. genetinav/ui_textual/sequence_viewer_controller.py +156 -0
  31. genetinav/ui_textual/sequence_viewer_screen.py +765 -0
  32. genetinav/ui_textual/settings_modal.py +177 -0
  33. genetinav/ui_textual/theme.py +387 -0
  34. genetinav/ui_textual/widgets/__init__.py +85 -0
  35. genetinav/ui_textual/widgets/gc_track_widget.py +75 -0
  36. genetinav/ui_textual/widgets/legend_widget.py +58 -0
  37. genetinav/ui_textual/widgets/minimap_widget.py +74 -0
  38. genetinav/ui_textual/widgets/ruler_widget.py +110 -0
  39. genetinav/ui_textual/widgets/sequence_track_widget.py +121 -0
  40. genetinav/ui_textual/widgets/stats_footer_widget.py +117 -0
  41. genetinav/utils/__init__.py +1 -0
  42. genetinav/utils/errors.py +36 -0
  43. genetinav/utils/export.py +17 -0
  44. genetinav/utils/validation.py +23 -0
  45. genetinav-0.1.0.dist-info/METADATA +311 -0
  46. genetinav-0.1.0.dist-info/RECORD +49 -0
  47. genetinav-0.1.0.dist-info/WHEEL +4 -0
  48. genetinav-0.1.0.dist-info/entry_points.txt +2 -0
  49. genetinav-0.1.0.dist-info/licenses/LICENSE +65 -0
genetinav/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,124 @@
1
+ import httpx
2
+ from typing import Optional, Dict
3
+
4
+ from genetinav.utils.errors import (
5
+ GeneNotFoundError,
6
+ NetworkUnavailableError,
7
+ ApiRateLimitError,
8
+ SequenceFetchError,
9
+ )
10
+
11
+ class EnsemblClient:
12
+ def __init__(self, base_url: str = "https://rest.ensembl.org", timeout: float = 10.0):
13
+ self.base_url = base_url.rstrip("/")
14
+ self.timeout = timeout
15
+ self._client: Optional[httpx.Client] = None
16
+
17
+ @property
18
+ def client(self) -> httpx.Client:
19
+ if self._client is None:
20
+ self._client = httpx.Client(timeout=self.timeout)
21
+ return self._client
22
+
23
+ def close(self) -> None:
24
+ if self._client is not None:
25
+ self._client.close()
26
+ self._client = None
27
+
28
+ def __enter__(self):
29
+ return self
30
+
31
+ def __exit__(self, exc_type, exc_val, exc_tb):
32
+ self.close()
33
+
34
+ def _handle_exceptions(self, response: httpx.Response, symbol_or_region: str):
35
+ if response.status_code == 404:
36
+ raise GeneNotFoundError(f"No gene found for '{symbol_or_region}'.")
37
+ elif response.status_code == 400:
38
+ raise GeneNotFoundError(f"Bad request or out of bounds for '{symbol_or_region}'.")
39
+ elif response.status_code == 429:
40
+ raise ApiRateLimitError("API rate limit exceeded.")
41
+ elif not response.is_success:
42
+ raise SequenceFetchError(f"API request failed with status code {response.status_code}.")
43
+
44
+ def lookup_gene(self, symbol: str, species: str = "human") -> dict:
45
+ url = f"{self.base_url}/lookup/symbol/{species}/{symbol}?content-type=application/json"
46
+
47
+ try:
48
+ response = self.client.get(url)
49
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
50
+ raise NetworkUnavailableError(f"Network error: {e}") from e
51
+
52
+ self._handle_exceptions(response, symbol)
53
+
54
+ return response.json()
55
+
56
+ def fetch_sequence(self, region: str, species: str = "human") -> str:
57
+ url = f"{self.base_url}/sequence/region/{species}/{region}?content-type=text/plain"
58
+
59
+ try:
60
+ response = self.client.get(url)
61
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
62
+ raise NetworkUnavailableError(f"Network error: {e}") from e
63
+
64
+ self._handle_exceptions(response, region)
65
+
66
+ # Strip all whitespace/newlines from the sequence
67
+ return "".join(response.text.split())
68
+
69
+ def lookup_gene_by_id(self, gene_id: str) -> dict:
70
+ url = f"{self.base_url}/lookup/id/{gene_id}?expand=1&content-type=application/json"
71
+
72
+ try:
73
+ response = self.client.get(url)
74
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
75
+ raise NetworkUnavailableError(f"Network error: {e}") from e
76
+
77
+ self._handle_exceptions(response, gene_id)
78
+
79
+ return response.json()
80
+
81
+ def overlap_genes(self, chromosome: str, start: int, end: int, species: str = "human") -> list[dict]:
82
+ """Return a list of genes overlapping the given genomic range.
83
+
84
+ Queries ``GET /overlap/region/{species}/{chromosome}:{start}-{end}?feature=gene``
85
+ and returns the raw JSON list (each element has at least ``external_name``,
86
+ ``start``, ``end``, ``strand``, and ``id`` fields).
87
+
88
+ Returns an empty list on 404 / out-of-bounds rather than raising.
89
+ """
90
+ url = (
91
+ f"{self.base_url}/overlap/region/{species}/{chromosome}:{start}-{end}"
92
+ f"?feature=gene&content-type=application/json"
93
+ )
94
+ try:
95
+ response = self.client.get(url)
96
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
97
+ raise NetworkUnavailableError(f"Network error: {e}") from e
98
+
99
+ if response.status_code == 404 or response.status_code == 400:
100
+ return []
101
+ elif response.status_code == 429:
102
+ raise ApiRateLimitError("API rate limit exceeded.")
103
+ elif not response.is_success:
104
+ raise SequenceFetchError(f"API request failed with status code {response.status_code}.")
105
+
106
+ data = response.json()
107
+ # The endpoint returns either a list or an error dict
108
+ if isinstance(data, list):
109
+ return data
110
+ return []
111
+
112
+ def fetch_transcript_sequence(self, transcript_id: str, is_cds: bool = False) -> str:
113
+ seq_type = "cds" if is_cds else "cdna"
114
+ url = f"{self.base_url}/sequence/id/{transcript_id}?type={seq_type}&content-type=text/plain"
115
+
116
+ try:
117
+ response = self.client.get(url)
118
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
119
+ raise NetworkUnavailableError(f"Network error: {e}") from e
120
+
121
+ self._handle_exceptions(response, transcript_id)
122
+
123
+ # Strip all whitespace/newlines from the sequence
124
+ return "".join(response.text.split())
genetinav/cache.py ADDED
@@ -0,0 +1,71 @@
1
+ import datetime
2
+ import sqlite3
3
+
4
+ from genetinav.db import get_connection, initialize_schema
5
+ from genetinav.utils.errors import CacheError
6
+
7
+ class CacheManager:
8
+ def __init__(self, conn: sqlite3.Connection):
9
+ self.conn = conn
10
+ initialize_schema(self.conn)
11
+
12
+ def set(self, key: str, response_data: str, ttl_seconds: int | None = None) -> None:
13
+ try:
14
+ now = datetime.datetime.now(datetime.timezone.utc)
15
+ created_at = now.isoformat()
16
+
17
+ if ttl_seconds is not None:
18
+ expires_at = (now + datetime.timedelta(seconds=ttl_seconds)).isoformat()
19
+ else:
20
+ expires_at = None
21
+
22
+ self.conn.execute(
23
+ """
24
+ INSERT INTO cache (key, response_data, created_at, expires_at)
25
+ VALUES (?, ?, ?, ?)
26
+ ON CONFLICT(key) DO UPDATE SET
27
+ response_data=excluded.response_data,
28
+ created_at=excluded.created_at,
29
+ expires_at=excluded.expires_at
30
+ """,
31
+ (key, response_data, created_at, expires_at)
32
+ )
33
+ self.conn.commit()
34
+ except sqlite3.Error as e:
35
+ raise CacheError(f"Cache read/write failed: {e}") from e
36
+
37
+ def get(self, key: str) -> str | None:
38
+ try:
39
+ now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
40
+
41
+ cursor = self.conn.execute(
42
+ "SELECT response_data, expires_at FROM cache WHERE key = ?",
43
+ (key,)
44
+ )
45
+ row = cursor.fetchone()
46
+
47
+ if row is None:
48
+ return None
49
+
50
+ expires_at = row["expires_at"]
51
+ if expires_at is not None and expires_at <= now_iso:
52
+ self.delete(key)
53
+ return None
54
+
55
+ return row["response_data"]
56
+ except sqlite3.Error as e:
57
+ raise CacheError(f"Cache read/write failed: {e}") from e
58
+
59
+ def clear(self) -> None:
60
+ try:
61
+ self.conn.execute("DELETE FROM cache")
62
+ self.conn.commit()
63
+ except sqlite3.Error as e:
64
+ raise CacheError(f"Cache read/write failed: {e}") from e
65
+
66
+ def delete(self, key: str) -> None:
67
+ try:
68
+ self.conn.execute("DELETE FROM cache WHERE key = ?", (key,))
69
+ self.conn.commit()
70
+ except sqlite3.Error as e:
71
+ raise CacheError(f"Cache read/write failed: {e}") from e
@@ -0,0 +1,148 @@
1
+ import threading
2
+ from collections import OrderedDict
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ from typing import Optional
5
+
6
+ from genetinav.api_client import EnsemblClient
7
+ from genetinav.utils.errors import GeneNotFoundError, NetworkUnavailableError, SequenceFetchError
8
+
9
+
10
+ class ChunkCache:
11
+ def __init__(self, api_client: EnsemblClient, chunk_size: int = 5000, max_chunks: int = 100):
12
+ self.api_client = api_client
13
+ self.chunk_size = chunk_size
14
+ self.max_chunks = max_chunks
15
+
16
+ self._cache: OrderedDict[tuple[str, str, int], str] = OrderedDict()
17
+ self._lock = threading.Lock()
18
+ self._executor = ThreadPoolExecutor(max_workers=3)
19
+ self._fetching: set[tuple[str, str, int]] = set()
20
+ self._errors: dict[tuple[str, str], Exception] = {}
21
+
22
+ def get_chunk_start(self, coord: int) -> int:
23
+ """Ensembl coordinates are 1-indexed. We align to chunk_size boundaries.
24
+ Example for chunk_size=5000:
25
+ coord=1 -> 1
26
+ coord=5000 -> 1
27
+ coord=5001 -> 5001
28
+ """
29
+ return ((coord - 1) // self.chunk_size) * self.chunk_size + 1
30
+
31
+ def get_sequence_slice(self, species: str, chromosome: str, start_coord: int, end_coord: int) -> Optional[str]:
32
+ """
33
+ Returns the sequence string if fully cached, else None.
34
+ start_coord and end_coord are absolute 1-indexed coordinates.
35
+ end_coord is inclusive.
36
+ """
37
+ req_chunks = []
38
+ c_start = self.get_chunk_start(start_coord)
39
+ while c_start <= end_coord:
40
+ req_chunks.append(c_start)
41
+ c_start += self.chunk_size
42
+
43
+ with self._lock:
44
+ for c in req_chunks:
45
+ key = (species, chromosome, c)
46
+ if key not in self._cache:
47
+ return None
48
+
49
+ pieces = []
50
+ for c in req_chunks:
51
+ key = (species, chromosome, c)
52
+ self._cache.move_to_end(key)
53
+ pieces.append(self._cache[key])
54
+
55
+ full_str = "".join(pieces)
56
+
57
+ # Calculate offsets into the joined string
58
+ first_chunk_start = req_chunks[0]
59
+ offset_start = start_coord - first_chunk_start
60
+ length = end_coord - start_coord + 1
61
+
62
+ return full_str[offset_start : offset_start + length]
63
+
64
+ def fetch_blocking(self, species: str, chromosome: str, start_coord: int, end_coord: int) -> str:
65
+ """Synchronously fetches missing chunks for the requested range, returns the slice."""
66
+ req_chunks = []
67
+ c_start = self.get_chunk_start(start_coord)
68
+ while c_start <= end_coord:
69
+ req_chunks.append(c_start)
70
+ c_start += self.chunk_size
71
+
72
+ for c in req_chunks:
73
+ key = (species, chromosome, c)
74
+ with self._lock:
75
+ if key in self._cache:
76
+ continue
77
+ self._fetch_chunk(species, chromosome, c)
78
+
79
+ # Clear any recent errors for this species/chrom since we succeeded
80
+ with self._lock:
81
+ self._errors.pop((species, chromosome), None)
82
+
83
+ res = self.get_sequence_slice(species, chromosome, start_coord, end_coord)
84
+ if res is None:
85
+ # Fallback if something went extremely wrong, shouldn't happen.
86
+ return "N" * (end_coord - start_coord + 1)
87
+ return res
88
+
89
+ def prefetch(self, species: str, chromosome: str, start_coord: int, end_coord: int) -> None:
90
+ """Kicks off background fetch for the view and its neighbors."""
91
+ current_c = self.get_chunk_start(start_coord)
92
+
93
+ chunks_to_fetch = [
94
+ current_c - self.chunk_size,
95
+ current_c,
96
+ current_c + self.chunk_size,
97
+ current_c + 2 * self.chunk_size,
98
+ current_c + 3 * self.chunk_size
99
+ ]
100
+
101
+ for c in chunks_to_fetch:
102
+ if c < 1:
103
+ continue
104
+ key = (species, chromosome, c)
105
+ with self._lock:
106
+ if key in self._cache or key in self._fetching:
107
+ continue
108
+ self._fetching.add(key)
109
+
110
+ self._executor.submit(self._fetch_chunk_bg, species, chromosome, c)
111
+
112
+ def _fetch_chunk_bg(self, species: str, chromosome: str, chunk_start: int) -> None:
113
+ try:
114
+ self._fetch_chunk(species, chromosome, chunk_start)
115
+ except Exception as e:
116
+ # Store error so it can be surfaced non-fatally
117
+ with self._lock:
118
+ self._errors[(species, chromosome)] = e
119
+ finally:
120
+ with self._lock:
121
+ self._fetching.discard((species, chromosome, chunk_start))
122
+
123
+ def _fetch_chunk(self, species: str, chromosome: str, chunk_start: int) -> None:
124
+ chunk_end = chunk_start + self.chunk_size - 1
125
+ region = f"{chromosome}:{chunk_start}..{chunk_end}"
126
+ try:
127
+ seq = self.api_client.fetch_sequence(region, species=species)
128
+ except GeneNotFoundError:
129
+ # Region is completely out of bounds or missing. Pad entirely with Ns.
130
+ seq = ""
131
+
132
+ expected_len = self.chunk_size
133
+ if len(seq) < expected_len:
134
+ seq += "N" * (expected_len - len(seq))
135
+
136
+ with self._lock:
137
+ key = (species, chromosome, chunk_start)
138
+ self._cache[key] = seq
139
+ if len(self._cache) > self.max_chunks:
140
+ self._cache.popitem(last=False)
141
+
142
+ def get_latest_error(self, species: str, chromosome: str) -> Optional[Exception]:
143
+ with self._lock:
144
+ return self._errors.get((species, chromosome))
145
+
146
+ def get_cached_bytes(self) -> int:
147
+ with self._lock:
148
+ return len(self._cache) * self.chunk_size
genetinav/cli.py ADDED
@@ -0,0 +1,239 @@
1
+ """Command-line interface for GenetiNav.
2
+
3
+ Quick-command flags (spec Section 25):
4
+ genetinav # launch interactive TUI
5
+ genetinav <GENE> # quick gene lookup, then exit
6
+ genetinav search <GENE> # launch TUI pre-filled with GENE
7
+ genetinav settings # open settings screen then exit
8
+ genetinav --no-animation # disable animations for this session
9
+ genetinav --theme <NAME> # override active palette for this session
10
+ genetinav --species <NAME> # override default species for this session
11
+ genetinav --clear-cache # clear local gene cache then exit
12
+ genetinav --clear-history # clear search history then exit
13
+ genetinav --version # print version and exit
14
+ """
15
+
16
+ import typer
17
+ from typing import Optional
18
+
19
+ from genetinav import __version__
20
+ from genetinav.cache import CacheManager
21
+ from genetinav.history import HistoryManager
22
+ from genetinav.db import get_connection, initialize_schema
23
+
24
+
25
+ class DefaultGroup(typer.core.TyperGroup):
26
+ """Allow a bare positional argument to be handled as the 'default' sub-command."""
27
+
28
+ def resolve_command(self, ctx, args):
29
+ if args and args[0] not in self.commands and not args[0].startswith("-"):
30
+ args.insert(0, "default")
31
+ return super().resolve_command(ctx, args)
32
+
33
+
34
+ app = typer.Typer(cls=DefaultGroup)
35
+
36
+
37
+ def version_callback(value: bool):
38
+ if value:
39
+ typer.echo(f"genetinav v{__version__}")
40
+ raise typer.Exit()
41
+
42
+
43
+ def _build_overrides(
44
+ no_animation: bool,
45
+ theme: Optional[str],
46
+ species: Optional[str],
47
+ ) -> dict:
48
+ """Return a partial settings dict reflecting CLI flag overrides."""
49
+ overrides: dict = {}
50
+ if no_animation:
51
+ overrides["performance_mode"] = True
52
+ overrides["animations_enabled"] = False
53
+ overrides["splash_animation_enabled"] = False
54
+ if theme:
55
+ overrides["theme"] = theme
56
+ if species:
57
+ overrides["default_species"] = species
58
+ return overrides
59
+
60
+
61
+
62
+
63
+
64
+ def _validate_cli_overrides(theme: Optional[str], species: Optional[str]) -> None:
65
+ """Validate --theme and --species values at the CLI boundary.
66
+
67
+ Prints a user-friendly error and raises ``typer.Exit(code=1)`` when an
68
+ invalid value is supplied, so the error surfaces before GenetinavApp is
69
+ ever constructed or the database is touched.
70
+ """
71
+ if theme is not None:
72
+ from genetinav.themes import list_ui_theme_names
73
+ valid_themes = list_ui_theme_names()
74
+ if theme not in valid_themes:
75
+ typer.echo(
76
+ f"Error: '{theme}' is not a valid theme. "
77
+ f"Valid themes: {', '.join(valid_themes)}"
78
+ )
79
+ raise typer.Exit(code=1)
80
+
81
+ if species is not None:
82
+ from genetinav.utils.validation import DEFAULT_ALLOWED_SPECIES
83
+ valid_species = DEFAULT_ALLOWED_SPECIES
84
+ normalized = species.strip().lower()
85
+ if normalized not in valid_species:
86
+ typer.echo(
87
+ f"Error: '{species}' is not a valid species. "
88
+ f"Valid species: {', '.join(valid_species)}"
89
+ )
90
+ raise typer.Exit(code=1)
91
+
92
+
93
+ def _run_gene_cmd(
94
+ gene: str,
95
+ no_animation: bool,
96
+ theme: Optional[str],
97
+ species: Optional[str],
98
+ echo_message: str,
99
+ ) -> None:
100
+ """Shared implementation for default_cmd and search_cmd.
101
+
102
+ Validates overrides, emits the command-specific banner, constructs the
103
+ app, and runs the quick-lookup flow. Each caller supplies its own
104
+ *echo_message* ("Quick lookup: {gene}" vs "Searching for: {gene}").
105
+ """
106
+ _validate_cli_overrides(theme, species)
107
+ typer.echo(echo_message)
108
+ overrides = _build_overrides(no_animation, theme, species)
109
+ from genetinav.settings import load_settings
110
+ from genetinav.textual_app import GenetinavTUI
111
+
112
+ settings = load_settings()
113
+ settings.update(overrides)
114
+ tui = GenetinavTUI(settings=settings, initial_query=gene)
115
+ tui.run()
116
+ typer.echo("Session terminated gracefully. Goodbye!")
117
+
118
+
119
+ @app.callback(invoke_without_command=True)
120
+ def cli(
121
+ ctx: typer.Context,
122
+ version: Optional[bool] = typer.Option(
123
+ None,
124
+ "--version",
125
+ callback=version_callback,
126
+ is_eager=True,
127
+ help="Show the version and exit.",
128
+ ),
129
+ no_animation: bool = typer.Option(False, "--no-animation", help="Disable animations for this session"),
130
+ theme: Optional[str] = typer.Option(None, "--theme", help="Override active colour palette"),
131
+ species: Optional[str] = typer.Option(None, "--species", help="Override default species"),
132
+ clear_cache: bool = typer.Option(False, "--clear-cache", help="Clear the local gene cache then exit"),
133
+ clear_history: bool = typer.Option(False, "--clear-history", help="Clear the search history then exit"),
134
+ ) -> None:
135
+ """GenetiNav — navigational genomics toolkit."""
136
+
137
+ # --clear-cache / --clear-history are handled first so they can be combined
138
+ # with each other (and still print their confirmation messages) before any
139
+ # interactive UI is launched.
140
+ if clear_cache:
141
+ conn = get_connection()
142
+ initialize_schema(conn)
143
+ cache_mgr = CacheManager(conn)
144
+ cache_mgr.clear()
145
+ conn.close()
146
+ typer.echo("Cache cleared")
147
+
148
+ if clear_history:
149
+ conn = get_connection()
150
+ initialize_schema(conn)
151
+ history_mgr = HistoryManager(conn)
152
+ history_mgr.clear()
153
+ conn.close()
154
+ typer.echo("History cleared")
155
+
156
+ # If a data-clearing flag was used without a subcommand, exit cleanly.
157
+ if clear_cache or clear_history:
158
+ if ctx.invoked_subcommand is None:
159
+ return
160
+
161
+ # Launch the interactive TUI when no subcommand was given.
162
+ # Informational flag summaries are printed *after* the session ends so
163
+ # they are not immediately scrolled away by the splash screen.
164
+ if ctx.invoked_subcommand is None:
165
+ overrides = _build_overrides(no_animation, theme, species)
166
+ from genetinav.settings import load_settings
167
+ from genetinav.textual_app import GenetinavTUI
168
+ settings = load_settings()
169
+ settings.update(overrides)
170
+ tui = GenetinavTUI(settings=settings)
171
+ tui.run()
172
+ typer.echo("Session terminated gracefully. Goodbye!")
173
+
174
+ # Print flag confirmations after the TUI exits so they remain visible.
175
+ if no_animation:
176
+ typer.echo("Performance mode enabled")
177
+ if theme:
178
+ typer.echo(f"Theme set to: {theme}")
179
+ if species:
180
+ typer.echo(f"Species set to: {species}")
181
+
182
+
183
+ @app.command("default", hidden=True)
184
+ def default_cmd(
185
+ gene: str,
186
+ no_animation: bool = typer.Option(False, "--no-animation"),
187
+ theme: Optional[str] = typer.Option(None, "--theme"),
188
+ species: Optional[str] = typer.Option(None, "--species"),
189
+ ) -> None:
190
+ """Quick lookup: launch TUI pre-filled with GENE."""
191
+ _run_gene_cmd(
192
+ gene=gene,
193
+ no_animation=no_animation,
194
+ theme=theme,
195
+ species=species,
196
+ echo_message=f"Quick lookup: {gene.upper()}",
197
+ )
198
+
199
+
200
+ @app.command("search")
201
+ def search_cmd(
202
+ gene: str,
203
+ no_animation: bool = typer.Option(False, "--no-animation"),
204
+ theme: Optional[str] = typer.Option(None, "--theme"),
205
+ species: Optional[str] = typer.Option(None, "--species"),
206
+ ) -> None:
207
+ """Search for a GENE in the interactive TUI."""
208
+ _run_gene_cmd(
209
+ gene=gene,
210
+ no_animation=no_animation,
211
+ theme=theme,
212
+ species=species,
213
+ echo_message=f"Searching for: {gene.upper()}",
214
+ )
215
+
216
+
217
+ @app.command("settings")
218
+ def settings_cmd(
219
+ no_animation: bool = typer.Option(False, "--no-animation"),
220
+ theme: Optional[str] = typer.Option(None, "--theme"),
221
+ ) -> None:
222
+ """Open the settings screen."""
223
+ overrides = _build_overrides(no_animation, theme, None)
224
+ from genetinav.settings import load_settings
225
+ from genetinav.textual_app import GenetinavTUI
226
+
227
+ settings = load_settings()
228
+ settings.update(overrides)
229
+ tui = GenetinavTUI(settings=settings, initial_screen="settings")
230
+ tui.run()
231
+ typer.echo("Session terminated gracefully. Goodbye!")
232
+
233
+
234
+ def main() -> None:
235
+ app()
236
+
237
+
238
+ if __name__ == "__main__":
239
+ main()
@@ -0,0 +1,32 @@
1
+ def parse_command(input_str: str, valid_commands: list[str] | None = None) -> tuple[str | None, str, str | None]:
2
+ """Parse a slash command or return as a raw query.
3
+
4
+ Returns:
5
+ (command_name, arguments, error_message)
6
+ """
7
+ s = input_str.strip()
8
+ if not s:
9
+ return None, "", None
10
+
11
+ if s.startswith("/"):
12
+ parts = s[1:].split(" ", 1)
13
+ raw_cmd = parts[0].strip().lower()
14
+ args = parts[1].strip() if len(parts) > 1 else ""
15
+
16
+ if valid_commands is not None and raw_cmd:
17
+ if raw_cmd in valid_commands:
18
+ return raw_cmd, args, None
19
+
20
+ prefix_matches = [cmd for cmd in valid_commands if cmd.startswith(raw_cmd)]
21
+
22
+ if len(prefix_matches) == 1:
23
+ return prefix_matches[0], args, None
24
+ elif len(prefix_matches) > 1:
25
+ pick_list = " ".join(f"{i+1}) /{cmd}" for i, cmd in enumerate(prefix_matches))
26
+ return None, "", f"Ambiguous command. Did you mean: {pick_list}"
27
+ else:
28
+ return None, "", f"Unknown command: /{raw_cmd}"
29
+
30
+ return raw_cmd, args, None
31
+
32
+ return None, s, None
@@ -0,0 +1,33 @@
1
+ """Command routing and registry for GenetiNav."""
2
+ from typing import Callable, Dict, Tuple
3
+
4
+ # Registry entry: (handler, description, keybinding_shortcut)
5
+ CommandRegistry = Dict[str, Tuple[Callable[[], None], str, str]]
6
+
7
+
8
+ class CommandRouter:
9
+ """Dispatches commands to their appropriate handlers.
10
+
11
+ The *live* argument accepts any object with ``stop()`` and ``start()``
12
+ methods — used by the Textual app with a ``DummyLive`` stub that is a
13
+ no-op, since Textual manages its own render loop.
14
+ """
15
+
16
+ def __init__(self, registry: CommandRegistry, live) -> None:
17
+ self._registry = registry
18
+ self._live = live
19
+
20
+ @property
21
+ def registry(self) -> CommandRegistry:
22
+ """Access the command registry."""
23
+ return self._registry
24
+
25
+ def dispatch(self, command: str) -> None:
26
+ handler_info = self._registry.get(command)
27
+ if handler_info:
28
+ handler = handler_info[0]
29
+ self._live.stop()
30
+ try:
31
+ handler()
32
+ finally:
33
+ self._live.start()