extract-tracker 0.2.2__tar.gz → 0.3.0__tar.gz

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 (58) hide show
  1. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/PKG-INFO +1 -1
  2. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/manual/mcp.md +24 -0
  3. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/pyproject.toml +1 -1
  4. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/__main__.py +38 -13
  5. extract_tracker-0.3.0/python/src/extract/cli_query.py +223 -0
  6. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/mcp.py +165 -87
  7. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract_tracker.egg-info/PKG-INFO +1 -1
  8. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract_tracker.egg-info/SOURCES.txt +1 -0
  9. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/Cargo.lock +1 -1
  10. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/Cargo.toml +1 -1
  11. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/app.rs +2 -0
  12. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/db.rs +25 -0
  13. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/layout.rs +8 -1
  14. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/popup.rs +121 -13
  15. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/summary.rs +9 -1
  16. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/MANIFEST.in +0 -0
  17. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/README.md +0 -0
  18. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/manual/config.md +0 -0
  19. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/manual/packaging.md +0 -0
  20. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/manual/sync.md +0 -0
  21. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/manual/tui.md +0 -0
  22. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/manual/usage.md +0 -0
  23. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/__init__.py +0 -0
  24. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/experiment.py +0 -0
  25. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/init.py +0 -0
  26. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/metrics.py +0 -0
  27. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/release_versioning.py +0 -0
  28. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/run.py +0 -0
  29. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/store.py +0 -0
  30. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract/sync.py +0 -0
  31. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract_tracker.egg-info/dependency_links.txt +0 -0
  32. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract_tracker.egg-info/entry_points.txt +0 -0
  33. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract_tracker.egg-info/requires.txt +0 -0
  34. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/python/src/extract_tracker.egg-info/top_level.txt +0 -0
  35. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/artifact.rs +0 -0
  36. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/config.rs +0 -0
  37. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/event.rs +0 -0
  38. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/keys.rs +0 -0
  39. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/main.rs +0 -0
  40. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/model.rs +0 -0
  41. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/chart.rs +0 -0
  42. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/compare.rs +0 -0
  43. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/dashboard.rs +0 -0
  44. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/detail.rs +0 -0
  45. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/diff.rs +0 -0
  46. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/heatmap.rs +0 -0
  47. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/help.rs +0 -0
  48. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/lineage.rs +0 -0
  49. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/mod.rs +0 -0
  50. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/registry.rs +0 -0
  51. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/search.rs +0 -0
  52. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/selection.rs +0 -0
  53. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/statusbar.rs +0 -0
  54. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/theme.rs +0 -0
  55. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/todo.rs +0 -0
  56. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/rust/src/ui/tree.rs +0 -0
  57. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/setup.cfg +0 -0
  58. {extract_tracker-0.2.2 → extract_tracker-0.3.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: extract-tracker
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Local-first experiment tracking for deep learning
5
5
  Author: Phil Oh
6
6
  Keywords: experiment-tracking,machine-learning,deep-learning,sqlite,tui,mcp
@@ -47,3 +47,27 @@ All tools are read-only.
47
47
  | `list_models` | Registered models with metadata |
48
48
 
49
49
  Full schemas, response shapes, and error catalog live in [`DOC.md`](../DOC.md#mcp-server).
50
+
51
+ ## Agent CLI surface
52
+
53
+ The same read-only operations are also exposed as JSON CLI commands for agents or hosts that cannot attach the MCP server:
54
+
55
+ ```bash
56
+ extract experiments list --store .extract --format json
57
+ extract runs list --limit 20 --store .extract --format json
58
+ extract runs get RUN_ID --store .extract --format json
59
+ extract runs compare RUN_A RUN_B --store .extract --format json
60
+ extract search --query resnet --tag production-candidate --store .extract --format json
61
+ extract todos list --scope-type run --scope-id RUN_ID --store .extract --format json
62
+ extract lineage get run RUN_ID --direction both --store .extract --format json
63
+ extract models list --store .extract --format json
64
+ ```
65
+
66
+ An installable Pi package in `pi/extract-pi-tools/` wraps these commands as native Pi tools such as `extract_list_runs`, `extract_get_run`, and `extract_compare_runs`:
67
+
68
+ ```bash
69
+ pi install ./pi/extract-pi-tools # user-scoped
70
+ pi install -l ./pi/extract-pi-tools # project-scoped
71
+ ```
72
+
73
+ Reload or restart Pi after installation. These native tools are context-safe summaries, not raw dumps: list tools cap rows, `extract_get_run` omits full config unless specific `configKeys` are requested, and large raw JSON is written to a temp file path. For full configs, curves, or bulk aggregation, rerun the returned `meta.command` inside `ctx_execute` and print only the derived answer.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "extract-tracker"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Local-first experiment tracking for deep learning"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -29,7 +29,9 @@ def _find_tui_binary() -> str | None:
29
29
  return None
30
30
 
31
31
 
32
- def _print_merge_stats(verb: str, src: object, dst: object, stats: dict[str, int]) -> None:
32
+ def _print_merge_stats(
33
+ verb: str, src: object, dst: object, stats: dict[str, int]
34
+ ) -> None:
33
35
  print(f"{verb} {src} → {dst}")
34
36
  exps = stats.get("experiments", 0)
35
37
  runs = stats.get("runs", 0)
@@ -45,29 +47,39 @@ def _print_merge_stats(verb: str, src: object, dst: object, stats: dict[str, int
45
47
 
46
48
 
47
49
  def main(argv: list[str] | None = None) -> None:
48
- parser = argparse.ArgumentParser(prog="extract", description="Extract experiment tracker")
50
+ parser = argparse.ArgumentParser(
51
+ prog="extract", description="Extract experiment tracker"
52
+ )
49
53
  sub = parser.add_subparsers(dest="command")
50
54
 
55
+ from extract.cli_query import add_query_parsers, run_query_command
56
+
57
+ add_query_parsers(sub)
58
+
51
59
  # --- tui ---
52
60
  tui_parser = sub.add_parser("tui", help="Launch the TUI explorer")
53
- tui_parser.add_argument("--store", default=".extract", help="Path to .extract/ directory")
61
+ tui_parser.add_argument(
62
+ "--store", default=".extract", help="Path to .extract/ directory"
63
+ )
54
64
 
55
65
  # --- init ---
56
66
  init_parser = sub.add_parser(
57
67
  "init", help="Bootstrap a .extract/ store with a hierarchy"
58
68
  )
59
69
  init_parser.add_argument(
60
- "path", nargs="?", default=".extract",
61
- help="Path to create the store at (default: .extract)"
70
+ "path",
71
+ nargs="?",
72
+ default=".extract",
73
+ help="Path to create the store at (default: .extract)",
62
74
  )
63
75
  init_parser.add_argument(
64
- "--hierarchy", default=None,
76
+ "--hierarchy",
77
+ default=None,
65
78
  help="Skip the interactive picker; use this hierarchy "
66
- "(e.g. 'benchmark > model > variant')"
79
+ "(e.g. 'benchmark > model > variant')",
67
80
  )
68
81
  init_parser.add_argument(
69
- "--no-gitignore", action="store_true",
70
- help="Do not add .extract/ to .gitignore"
82
+ "--no-gitignore", action="store_true", help="Do not add .extract/ to .gitignore"
71
83
  )
72
84
 
73
85
  # --- sync ---
@@ -84,14 +96,24 @@ def main(argv: list[str] | None = None) -> None:
84
96
 
85
97
  export_p = sync_sub.add_parser("export", help="Archive .extract/ to tar.gz")
86
98
  export_p.add_argument("output", help="Output archive path, e.g. experiments.tar.gz")
87
- export_p.add_argument("--root", default=".extract", help="Local .extract/ directory")
99
+ export_p.add_argument(
100
+ "--root", default=".extract", help="Local .extract/ directory"
101
+ )
88
102
 
89
- import_p = sync_sub.add_parser("import", help="Import tar.gz archive into .extract/")
103
+ import_p = sync_sub.add_parser(
104
+ "import", help="Import tar.gz archive into .extract/"
105
+ )
90
106
  import_p.add_argument("archive", help="Archive path to import")
91
- import_p.add_argument("--root", default=".extract", help="Target .extract/ directory")
107
+ import_p.add_argument(
108
+ "--root", default=".extract", help="Target .extract/ directory"
109
+ )
92
110
 
93
111
  args = parser.parse_args(argv)
94
112
 
113
+ query_exit = run_query_command(args)
114
+ if query_exit is not None:
115
+ sys.exit(query_exit)
116
+
95
117
  if args.command == "tui":
96
118
  binary = _find_tui_binary()
97
119
  if binary is None:
@@ -105,7 +127,10 @@ def main(argv: list[str] | None = None) -> None:
105
127
  os.execvp(binary, [binary, "--store", args.store])
106
128
 
107
129
  elif args.command == "init":
108
- from extract import init # lazy import — pulls in rich/questionary only when needed
130
+ from extract import (
131
+ init,
132
+ ) # lazy import — pulls in rich/questionary only when needed
133
+
109
134
  sys.exit(init.run(args))
110
135
 
111
136
  elif args.command == "sync":
@@ -0,0 +1,223 @@
1
+ """JSON CLI adapter for Extract read-only query tools.
2
+
3
+ This module reuses the existing MCP tool implementations so the command-line
4
+ surface and MCP surface share one result contract while the query core is
5
+ factored out in a later cleanup.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from extract.store import Store
17
+
18
+
19
+ def _add_common(parser: argparse.ArgumentParser) -> None:
20
+ parser.add_argument(
21
+ "--store", default=".extract", help="Path to .extract/ directory"
22
+ )
23
+ parser.add_argument(
24
+ "--format",
25
+ choices=("json",),
26
+ default="json",
27
+ help="Output format (default: json)",
28
+ )
29
+
30
+
31
+ def add_query_parsers(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
32
+ """Register read-only query subcommands on the top-level CLI parser."""
33
+ experiments = sub.add_parser("experiments", help="Read experiments from a store")
34
+ exp_sub = experiments.add_subparsers(dest="query_action")
35
+ exp_list = exp_sub.add_parser("list", help="List experiments")
36
+ _add_common(exp_list)
37
+ exp_list.add_argument("--prefix", default="", help="Experiment path prefix")
38
+ exp_list.add_argument("--limit", type=int, default=50, help="Max rows (max 500)")
39
+ exp_list.add_argument("--include-archived", action="store_true")
40
+ exp_list.set_defaults(_extract_query="list_experiments")
41
+
42
+ runs = sub.add_parser("runs", help="Read runs from a store")
43
+ runs_sub = runs.add_subparsers(dest="query_action")
44
+ runs_list = runs_sub.add_parser("list", help="List runs")
45
+ _add_common(runs_list)
46
+ runs_list.add_argument(
47
+ "--experiment-id", default=None, help="Scope to one experiment id"
48
+ )
49
+ runs_list.add_argument("--limit", type=int, default=50, help="Max rows (max 500)")
50
+ runs_list.add_argument("--include-archived", action="store_true")
51
+ runs_list.set_defaults(_extract_query="list_runs")
52
+
53
+ runs_get = runs_sub.add_parser("get", help="Get full run detail")
54
+ _add_common(runs_get)
55
+ runs_get.add_argument("run_id", help="Run ULID")
56
+ runs_get.set_defaults(_extract_query="get_run")
57
+
58
+ runs_compare = runs_sub.add_parser("compare", help="Compare 2-10 runs")
59
+ _add_common(runs_compare)
60
+ runs_compare.add_argument("run_ids", nargs="+", help="Run ULIDs")
61
+ runs_compare.add_argument("--include-curves", action="store_true")
62
+ runs_compare.set_defaults(_extract_query="compare_runs")
63
+
64
+ search = sub.add_parser("search", help="Search runs")
65
+ _add_common(search)
66
+ search.add_argument("--query", default="", help="Case-insensitive text search")
67
+ search.add_argument("--tag", default=None, help="Require tag")
68
+ search.add_argument("--status", default=None, help="Require status")
69
+ search.add_argument(
70
+ "--experiment-prefix", default=None, help="Experiment path prefix"
71
+ )
72
+ search.add_argument(
73
+ "--started-after", default=None, help="ISO timestamp lower bound"
74
+ )
75
+ search.add_argument(
76
+ "--started-before", default=None, help="ISO timestamp upper bound"
77
+ )
78
+ search.add_argument("--limit", type=int, default=50, help="Max rows (max 500)")
79
+ search.add_argument("--include-archived", action="store_true")
80
+ search.set_defaults(_extract_query="search")
81
+
82
+ todos = sub.add_parser("todos", help="Read TODOs from a store")
83
+ todos_sub = todos.add_subparsers(dest="query_action")
84
+ todos_list = todos_sub.add_parser("list", help="List TODOs")
85
+ _add_common(todos_list)
86
+ todos_list.add_argument(
87
+ "--scope-type",
88
+ default="global",
89
+ choices=("global", "experiment", "run"),
90
+ help="TODO scope",
91
+ )
92
+ todos_list.add_argument(
93
+ "--scope-id", default=None, help="Required for experiment/run scope"
94
+ )
95
+ todos_list.add_argument("--include-done", action="store_true")
96
+ todos_list.add_argument("--limit", type=int, default=50, help="Max rows (max 500)")
97
+ todos_list.set_defaults(_extract_query="list_todos")
98
+
99
+ lineage = sub.add_parser("lineage", help="Read lineage graph from a store")
100
+ lineage_sub = lineage.add_subparsers(dest="query_action")
101
+ lineage_get = lineage_sub.add_parser("get", help="Walk lineage DAG")
102
+ _add_common(lineage_get)
103
+ lineage_get.add_argument("node_type", choices=("experiment", "run", "model"))
104
+ lineage_get.add_argument("node_id")
105
+ lineage_get.add_argument(
106
+ "--direction",
107
+ default="both",
108
+ choices=("ancestors", "descendants", "both"),
109
+ )
110
+ lineage_get.add_argument("--depth", type=int, default=2)
111
+ lineage_get.set_defaults(_extract_query="get_lineage")
112
+
113
+ models = sub.add_parser("models", help="Read model registry from a store")
114
+ models_sub = models.add_subparsers(dest="query_action")
115
+ models_list = models_sub.add_parser("list", help="List registered models")
116
+ _add_common(models_list)
117
+ models_list.add_argument("--name-prefix", default="", help="Model name prefix")
118
+ models_list.add_argument("--limit", type=int, default=50, help="Max rows (max 500)")
119
+ models_list.set_defaults(_extract_query="list_models")
120
+
121
+
122
+ def _json_error(code: str, message: str) -> str:
123
+ return json.dumps(
124
+ {"error": {"code": code, "message": message}}, indent=2, sort_keys=True
125
+ )
126
+
127
+
128
+ def _build_filters(args: argparse.Namespace) -> dict[str, str]:
129
+ filters: dict[str, str] = {}
130
+ for attr, key in (
131
+ ("tag", "tag"),
132
+ ("status", "status"),
133
+ ("experiment_prefix", "experiment_prefix"),
134
+ ("started_after", "started_after"),
135
+ ("started_before", "started_before"),
136
+ ):
137
+ value = getattr(args, attr, None)
138
+ if value is not None:
139
+ filters[key] = value
140
+ return filters
141
+
142
+
143
+ def _call_query(args: argparse.Namespace) -> Any:
144
+ import extract.mcp as mcp_mod
145
+
146
+ action = args._extract_query
147
+ if action == "list_experiments":
148
+ return mcp_mod.list_experiments(
149
+ prefix=args.prefix,
150
+ limit=args.limit,
151
+ include_archived=args.include_archived,
152
+ )
153
+ if action == "list_runs":
154
+ return mcp_mod.list_runs(
155
+ experiment_id=args.experiment_id,
156
+ limit=args.limit,
157
+ include_archived=args.include_archived,
158
+ )
159
+ if action == "get_run":
160
+ return mcp_mod.get_run(args.run_id)
161
+ if action == "compare_runs":
162
+ return mcp_mod.compare_runs(args.run_ids, include_curves=args.include_curves)
163
+ if action == "search":
164
+ return mcp_mod.search(
165
+ query=args.query,
166
+ filters=_build_filters(args),
167
+ limit=args.limit,
168
+ include_archived=args.include_archived,
169
+ )
170
+ if action == "list_todos":
171
+ return mcp_mod.list_todos(
172
+ scope_type=args.scope_type,
173
+ scope_id=args.scope_id,
174
+ include_done=args.include_done,
175
+ limit=args.limit,
176
+ )
177
+ if action == "get_lineage":
178
+ return mcp_mod.get_lineage(
179
+ node_type=args.node_type,
180
+ node_id=args.node_id,
181
+ direction=args.direction,
182
+ depth=args.depth,
183
+ )
184
+ if action == "list_models":
185
+ return mcp_mod.list_models(name_prefix=args.name_prefix, limit=args.limit)
186
+ raise RuntimeError(f"unknown query action: {action}")
187
+
188
+
189
+ def run_query_command(args: argparse.Namespace) -> int | None:
190
+ """Run a query command if args selects one; else return None."""
191
+ if not hasattr(args, "_extract_query"):
192
+ return None
193
+
194
+ store_path = Path(args.store)
195
+ if not store_path.exists():
196
+ print(
197
+ _json_error(
198
+ "store_not_found",
199
+ f"store not found: {store_path} — run training with extract-tracker first, or pass --store",
200
+ ),
201
+ file=sys.stdout,
202
+ )
203
+ return 2
204
+
205
+ import extract.mcp as mcp_mod
206
+
207
+ store: Store | None = None
208
+ try:
209
+ store = Store(store_path)
210
+ mcp_mod._store = store
211
+ result = _call_query(args)
212
+ print(json.dumps(result, indent=2, sort_keys=True))
213
+ return 0
214
+ except ValueError as exc:
215
+ print(_json_error("invalid_request", str(exc)), file=sys.stdout)
216
+ return 1
217
+ except Exception as exc: # pragma: no cover - defensive CLI boundary
218
+ print(_json_error("internal_error", str(exc)), file=sys.stdout)
219
+ return 70
220
+ finally:
221
+ mcp_mod._store = None
222
+ if store is not None:
223
+ store.close()
@@ -12,9 +12,9 @@ import json
12
12
  import sys
13
13
  from pathlib import Path
14
14
 
15
- from mcp.server.fastmcp import FastMCP
16
-
15
+ import tomli
17
16
  from extract.store import Store
17
+ from mcp.server.fastmcp import FastMCP
18
18
 
19
19
  # Module-level state. Set by main() at startup; monkey-patched by tests.
20
20
  _store: Store | None = None
@@ -31,8 +31,17 @@ def _tool(fn):
31
31
  # ----------------------------------------------------------------------
32
32
 
33
33
  _MIN_METRIC_PATTERNS = (
34
- "loss", "error", "perplexity", "mse", "mae", "rmse",
35
- "nll", "cer", "wer", "fid", "divergence",
34
+ "loss",
35
+ "error",
36
+ "perplexity",
37
+ "mse",
38
+ "mae",
39
+ "rmse",
40
+ "nll",
41
+ "cer",
42
+ "wer",
43
+ "fid",
44
+ "divergence",
36
45
  )
37
46
 
38
47
 
@@ -70,8 +79,18 @@ def _flatten_config(config: dict, prefix: str = "") -> dict:
70
79
  return result
71
80
 
72
81
 
73
- def _metric_direction(name: str) -> str:
74
- """Return 'min' if metric name matches a minimize pattern, else 'max'."""
82
+ def _metric_direction(
83
+ name: str,
84
+ overrides: tuple[set[str], set[str]] | None = None,
85
+ ) -> str:
86
+ """Return 'min' or 'max' using config overrides, then heuristics."""
87
+ if overrides is not None:
88
+ minimize, maximize = overrides
89
+ if name in minimize:
90
+ return "min"
91
+ if name in maximize:
92
+ return "max"
93
+
75
94
  lowered = name.lower()
76
95
  for pat in _MIN_METRIC_PATTERNS:
77
96
  if pat in lowered:
@@ -79,6 +98,35 @@ def _metric_direction(name: str) -> str:
79
98
  return "max"
80
99
 
81
100
 
101
+ def _metric_direction_overrides(store: Store) -> tuple[set[str], set[str]]:
102
+ """Read [metrics] minimize/maximize overrides from config.toml."""
103
+ config_path = store.root / "config.toml"
104
+ if not config_path.exists():
105
+ return set(), set()
106
+
107
+ with config_path.open("rb") as f:
108
+ data = tomli.load(f)
109
+
110
+ metrics = data.get("metrics", {})
111
+ if not isinstance(metrics, dict):
112
+ return set(), set()
113
+
114
+ minimize = metrics.get("minimize", [])
115
+ maximize = metrics.get("maximize", [])
116
+ return (
117
+ (
118
+ {m for m in minimize if isinstance(m, str)}
119
+ if isinstance(minimize, list)
120
+ else set()
121
+ ),
122
+ (
123
+ {m for m in maximize if isinstance(m, str)}
124
+ if isinstance(maximize, list)
125
+ else set()
126
+ ),
127
+ )
128
+
129
+
82
130
  def _config_diffs(runs_configs: list[tuple[str, dict]]) -> dict:
83
131
  """Return {flat_key: {run_id: value}} for keys that differ across runs.
84
132
 
@@ -96,8 +144,9 @@ def _config_diffs(runs_configs: list[tuple[str, dict]]) -> dict:
96
144
  result: dict = {}
97
145
  for key in all_keys:
98
146
  values = [(rid, flat.get(key, _MISSING)) for rid, flat in flattened]
99
- distinct = {id(v) if v is _MISSING else (type(v).__name__, repr(v))
100
- for _, v in values}
147
+ distinct = {
148
+ id(v) if v is _MISSING else (type(v).__name__, repr(v)) for _, v in values
149
+ }
101
150
  if len(distinct) > 1:
102
151
  result[key] = {rid: v for rid, v in values if v is not _MISSING}
103
152
  return result
@@ -193,14 +242,16 @@ def list_experiments(
193
242
  f"SELECT COUNT(*) FROM runs WHERE experiment_id = ?{run_filter}",
194
243
  (row["id"],),
195
244
  ).fetchone()
196
- items.append({
197
- "id": row["id"],
198
- "path": row["path"],
199
- "name": row["name"],
200
- "node_type": row["node_type"],
201
- "parent_id": row["parent_id"],
202
- "n_runs": n_runs_row[0],
203
- })
245
+ items.append(
246
+ {
247
+ "id": row["id"],
248
+ "path": row["path"],
249
+ "name": row["name"],
250
+ "node_type": row["node_type"],
251
+ "parent_id": row["parent_id"],
252
+ "n_runs": n_runs_row[0],
253
+ }
254
+ )
204
255
 
205
256
  return _listing(items, total=len(items), limit=limit, limit_clamped=clamped)
206
257
 
@@ -257,23 +308,25 @@ def list_runs(
257
308
  for row in rows:
258
309
  config_dict = json.loads(row["config"]) if row["config"] else {}
259
310
  top_keys = list(config_dict.keys())
260
- items.append({
261
- "id": row["id"],
262
- "label": _label(row["experiment_path"], row["name"], row["id"]),
263
- "experiment_id": row["experiment_id"],
264
- "experiment_path": row["experiment_path"],
265
- "name": row["name"],
266
- "status": row["status"],
267
- "started_at": row["started_at"],
268
- "ended_at": row["ended_at"],
269
- "tags": json.loads(row["tags"]) if row["tags"] else [],
270
- "git_sha": row["git_sha"],
271
- "hostname": row["hostname"],
272
- "config_summary": {
273
- "n_keys": len(top_keys),
274
- "top_level_keys": top_keys,
275
- },
276
- })
311
+ items.append(
312
+ {
313
+ "id": row["id"],
314
+ "label": _label(row["experiment_path"], row["name"], row["id"]),
315
+ "experiment_id": row["experiment_id"],
316
+ "experiment_path": row["experiment_path"],
317
+ "name": row["name"],
318
+ "status": row["status"],
319
+ "started_at": row["started_at"],
320
+ "ended_at": row["ended_at"],
321
+ "tags": json.loads(row["tags"]) if row["tags"] else [],
322
+ "git_sha": row["git_sha"],
323
+ "hostname": row["hostname"],
324
+ "config_summary": {
325
+ "n_keys": len(top_keys),
326
+ "top_level_keys": top_keys,
327
+ },
328
+ }
329
+ )
277
330
 
278
331
  return _listing(items, total=len(items), limit=limit, limit_clamped=clamped)
279
332
 
@@ -308,16 +361,18 @@ def list_models(name_prefix: str = "", limit: int = 50) -> dict:
308
361
 
309
362
  items: list[dict] = []
310
363
  for row in rows:
311
- items.append({
312
- "id": row["id"],
313
- "name": row["name"],
314
- "version": row["version"],
315
- "run_id": row["run_id"],
316
- "framework": row["framework"],
317
- "artifact_path": row["artifact_path"],
318
- "metadata": json.loads(row["metadata"]) if row["metadata"] else None,
319
- "created_at": row["created_at"],
320
- })
364
+ items.append(
365
+ {
366
+ "id": row["id"],
367
+ "name": row["name"],
368
+ "version": row["version"],
369
+ "run_id": row["run_id"],
370
+ "framework": row["framework"],
371
+ "artifact_path": row["artifact_path"],
372
+ "metadata": json.loads(row["metadata"]) if row["metadata"] else None,
373
+ "created_at": row["created_at"],
374
+ }
375
+ )
321
376
 
322
377
  return _listing(items, total=len(items), limit=limit, limit_clamped=clamped)
323
378
 
@@ -429,14 +484,16 @@ def get_run(run_id: str) -> dict:
429
484
  ).fetchall()
430
485
  artifacts: list[dict] = []
431
486
  for a in art_rows:
432
- artifacts.append({
433
- "name": a["name"],
434
- "kind": a["kind"],
435
- "step": a["step"],
436
- "rel_path": a["rel_path"],
437
- "shape": json.loads(a["shape"]) if a["shape"] else None,
438
- "dtype": a["dtype"],
439
- })
487
+ artifacts.append(
488
+ {
489
+ "name": a["name"],
490
+ "kind": a["kind"],
491
+ "step": a["step"],
492
+ "rel_path": a["rel_path"],
493
+ "shape": json.loads(a["shape"]) if a["shape"] else None,
494
+ "dtype": a["dtype"],
495
+ }
496
+ )
440
497
 
441
498
  todo_rows = _store._conn.execute(
442
499
  "SELECT id, content, priority, done, created_at, completed_at "
@@ -470,7 +527,11 @@ def get_run(run_id: str) -> dict:
470
527
 
471
528
  _VALID_STATUS = {"running", "completed", "failed", "archived"}
472
529
  _VALID_FILTERS = {
473
- "tag", "status", "experiment_prefix", "started_after", "started_before",
530
+ "tag",
531
+ "status",
532
+ "experiment_prefix",
533
+ "started_after",
534
+ "started_before",
474
535
  }
475
536
 
476
537
 
@@ -568,23 +629,25 @@ def search(
568
629
  for row in rows:
569
630
  config_dict = json.loads(row["config"]) if row["config"] else {}
570
631
  top_keys = list(config_dict.keys())
571
- items.append({
572
- "id": row["id"],
573
- "label": _label(row["experiment_path"], row["name"], row["id"]),
574
- "experiment_id": row["experiment_id"],
575
- "experiment_path": row["experiment_path"],
576
- "name": row["name"],
577
- "status": row["status"],
578
- "started_at": row["started_at"],
579
- "ended_at": row["ended_at"],
580
- "tags": json.loads(row["tags"]) if row["tags"] else [],
581
- "git_sha": row["git_sha"],
582
- "hostname": row["hostname"],
583
- "config_summary": {
584
- "n_keys": len(top_keys),
585
- "top_level_keys": top_keys,
586
- },
587
- })
632
+ items.append(
633
+ {
634
+ "id": row["id"],
635
+ "label": _label(row["experiment_path"], row["name"], row["id"]),
636
+ "experiment_id": row["experiment_id"],
637
+ "experiment_path": row["experiment_path"],
638
+ "name": row["name"],
639
+ "status": row["status"],
640
+ "started_at": row["started_at"],
641
+ "ended_at": row["ended_at"],
642
+ "tags": json.loads(row["tags"]) if row["tags"] else [],
643
+ "git_sha": row["git_sha"],
644
+ "hostname": row["hostname"],
645
+ "config_summary": {
646
+ "n_keys": len(top_keys),
647
+ "top_level_keys": top_keys,
648
+ },
649
+ }
650
+ )
588
651
 
589
652
  return _listing(items, total=len(items), limit=limit, limit_clamped=clamped)
590
653
 
@@ -617,7 +680,9 @@ def compare_runs(run_ids: list[str], include_curves: bool = False) -> dict:
617
680
  }
618
681
  """
619
682
  if len(run_ids) < 2:
620
- raise ValueError(f"compare_runs requires at least 2 run_ids (got {len(run_ids)})")
683
+ raise ValueError(
684
+ f"compare_runs requires at least 2 run_ids (got {len(run_ids)})"
685
+ )
621
686
  if len(run_ids) > 10:
622
687
  raise ValueError(
623
688
  f"compare_runs supports at most 10 runs per call (got {len(run_ids)})"
@@ -627,7 +692,9 @@ def compare_runs(run_ids: list[str], include_curves: bool = False) -> dict:
627
692
  runs_out: list[dict] = []
628
693
  configs: list[tuple[str, dict]] = []
629
694
  metric_values: dict[str, dict[str, float]] = {} # name -> {run_id: final_val}
630
- curves_out: dict[str, dict[str, list[list]]] = {} # name -> {run_id: [[step, value], ...]}
695
+ curves_out: dict[str, dict[str, list[list]]] = (
696
+ {}
697
+ ) # name -> {run_id: [[step, value], ...]}
631
698
 
632
699
  with _store.lock:
633
700
  for rid in run_ids:
@@ -640,12 +707,14 @@ def compare_runs(run_ids: list[str], include_curves: bool = False) -> dict:
640
707
  if row is None:
641
708
  raise ValueError(f"Run not found: {rid!r}")
642
709
 
643
- runs_out.append({
644
- "id": row["id"],
645
- "label": _label(row["experiment_path"], row["name"], row["id"]),
646
- "experiment_path": row["experiment_path"],
647
- "status": row["status"],
648
- })
710
+ runs_out.append(
711
+ {
712
+ "id": row["id"],
713
+ "label": _label(row["experiment_path"], row["name"], row["id"]),
714
+ "experiment_path": row["experiment_path"],
715
+ "status": row["status"],
716
+ }
717
+ )
649
718
  cfg = json.loads(row["config"]) if row["config"] else {}
650
719
  configs.append((rid, cfg))
651
720
 
@@ -671,13 +740,15 @@ def compare_runs(run_ids: list[str], include_curves: bool = False) -> dict:
671
740
  )
672
741
 
673
742
  # Build the metrics dict.
743
+ direction_overrides = _metric_direction_overrides(_store)
674
744
  metrics_out: dict[str, dict] = {}
675
745
  for name, vals in metric_values.items():
676
- direction = _metric_direction(name)
677
- reverse = (direction == "max")
678
- ranking = [rid for rid, _ in sorted(
679
- vals.items(), key=lambda kv: kv[1], reverse=reverse
680
- )]
746
+ direction = _metric_direction(name, direction_overrides)
747
+ reverse = direction == "max"
748
+ ranking = [
749
+ rid
750
+ for rid, _ in sorted(vals.items(), key=lambda kv: kv[1], reverse=reverse)
751
+ ]
681
752
  metrics_out[name] = {
682
753
  "direction": direction,
683
754
  "values": vals,
@@ -767,7 +838,9 @@ def get_lineage(
767
838
  with _store.lock:
768
839
  root_label = _lookup_node_label(_store._conn, node_type, node_id)
769
840
  if root_label is None:
770
- pretty = {"run": "Run", "experiment": "Experiment", "model": "Model"}[node_type]
841
+ pretty = {"run": "Run", "experiment": "Experiment", "model": "Model"}[
842
+ node_type
843
+ ]
771
844
  raise ValueError(f"{pretty} not found: {node_id!r}")
772
845
 
773
846
  visited: set[tuple[str, str]] = {(node_type, node_id)}
@@ -776,7 +849,7 @@ def get_lineage(
776
849
 
777
850
  for _hop in range(depth):
778
851
  next_frontier: list[tuple[str, str]] = []
779
- for (nt, nid) in frontier:
852
+ for nt, nid in frontier:
780
853
  # Descendants: I am the parent.
781
854
  if direction in ("descendants", "both"):
782
855
  rows = _store._conn.execute(
@@ -811,14 +884,19 @@ def get_lineage(
811
884
  seen_edges: set[tuple] = set()
812
885
  unique_edges: list[dict] = []
813
886
  for e in edges_out:
814
- key = (e["parent_type"], e["parent_id"],
815
- e["child_type"], e["child_id"], e["relation"])
887
+ key = (
888
+ e["parent_type"],
889
+ e["parent_id"],
890
+ e["child_type"],
891
+ e["child_id"],
892
+ e["relation"],
893
+ )
816
894
  if key not in seen_edges:
817
895
  seen_edges.add(key)
818
896
  unique_edges.append(e)
819
897
 
820
898
  nodes_out: list[dict] = []
821
- for (nt, nid) in visited:
899
+ for nt, nid in visited:
822
900
  if (nt, nid) == (node_type, node_id):
823
901
  continue # root is emitted separately
824
902
  label = _lookup_node_label(_store._conn, nt, nid)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: extract-tracker
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Local-first experiment tracking for deep learning
5
5
  Author: Phil Oh
6
6
  Keywords: experiment-tracking,machine-learning,deep-learning,sqlite,tui,mcp
@@ -10,6 +10,7 @@ manual/tui.md
10
10
  manual/usage.md
11
11
  python/src/extract/__init__.py
12
12
  python/src/extract/__main__.py
13
+ python/src/extract/cli_query.py
13
14
  python/src/extract/experiment.py
14
15
  python/src/extract/init.py
15
16
  python/src/extract/mcp.py
@@ -592,7 +592,7 @@ dependencies = [
592
592
 
593
593
  [[package]]
594
594
  name = "extract-tui"
595
- version = "0.2.2"
595
+ version = "0.3.0"
596
596
  dependencies = [
597
597
  "chrono",
598
598
  "clap",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "extract-tui"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  edition = "2024"
5
5
 
6
6
  [dependencies]
@@ -306,6 +306,7 @@ pub struct RunBrowserState {
306
306
  pub filtered: Vec<usize>,
307
307
  pub cursor: usize,
308
308
  pub search_query: Option<String>,
309
+ pub rename_buffer: Option<String>,
309
310
  pub scroll_offset: usize,
310
311
  }
311
312
 
@@ -318,6 +319,7 @@ impl RunBrowserState {
318
319
  filtered,
319
320
  cursor: 0,
320
321
  search_query: None,
322
+ rename_buffer: None,
321
323
  scroll_offset: 0,
322
324
  }
323
325
  }
@@ -854,6 +854,19 @@ impl Db {
854
854
  Ok(())
855
855
  }
856
856
 
857
+ /// Rename a run. Empty string clears the name. Opens a writable connection.
858
+ pub fn rename_run(db_path: &Path, id: &str, name: &str) -> Result<()> {
859
+ let conn = Connection::open(db_path)?;
860
+ conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
861
+ let trimmed = name.trim();
862
+ let value: Option<&str> = if trimmed.is_empty() { None } else { Some(trimmed) };
863
+ conn.execute(
864
+ "UPDATE runs SET name = ? WHERE id = ?",
865
+ params![value, id],
866
+ )?;
867
+ Ok(())
868
+ }
869
+
857
870
  /// Set status on a single run or experiment. Opens a writable connection.
858
871
  pub fn set_status(db_path: &Path, table: &str, id: &str, status: &str) -> Result<()> {
859
872
  assert!(table == "runs" || table == "experiments");
@@ -1314,6 +1327,18 @@ mod tests {
1314
1327
  assert_eq!(run.status, "failed");
1315
1328
  }
1316
1329
 
1330
+ #[test]
1331
+ fn test_rename_run() {
1332
+ let tdb = test_db_with_path();
1333
+ Db::rename_run(&tdb.path, "r1", "renamed").unwrap();
1334
+ let run = tdb.db.get_run("r1").unwrap().unwrap();
1335
+ assert_eq!(run.name.as_deref(), Some("renamed"));
1336
+
1337
+ Db::rename_run(&tdb.path, "r1", " ").unwrap();
1338
+ let run = tdb.db.get_run("r1").unwrap().unwrap();
1339
+ assert!(run.name.is_none());
1340
+ }
1341
+
1317
1342
  #[test]
1318
1343
  fn test_archive_experiment() {
1319
1344
  let tdb = test_db_with_path();
@@ -82,7 +82,14 @@ impl AppLayout {
82
82
  // focused panel's input handler see every keystroke unmodified.
83
83
  let in_text_input = state.tag_picker.is_some()
84
84
  || state.note_input.is_some()
85
- || state.todo_input.is_some();
85
+ || state.todo_input.is_some()
86
+ || state
87
+ .run_picker
88
+ .as_ref()
89
+ .is_some_and(|picker| picker.search_query.is_some())
90
+ || state.run_browser.as_ref().is_some_and(|browser| {
91
+ browser.search_query.is_some() || browser.rename_buffer.is_some()
92
+ });
86
93
 
87
94
  if !in_text_input {
88
95
  // Global keys: gg/G, ?, work in all views
@@ -1,4 +1,5 @@
1
- use crossterm::event::{KeyCode, KeyEvent};
1
+ use chrono::{DateTime, Local};
2
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2
3
  use ratatui::layout::Rect;
3
4
  use ratatui::style::{Modifier, Style};
4
5
  use ratatui::symbols::border;
@@ -216,6 +217,70 @@ impl PopupRenderer {
216
217
  };
217
218
 
218
219
  let is_searching = browser.search_query.is_some();
220
+ let is_renaming = browser.rename_buffer.is_some();
221
+
222
+ // Rename mode
223
+ if is_renaming {
224
+ match key.code {
225
+ KeyCode::Esc => {
226
+ browser.rename_buffer = None;
227
+ return false;
228
+ }
229
+ KeyCode::Enter => {
230
+ if let Some(&run_idx) = browser.filtered.get(browser.cursor) {
231
+ if let Some(run) = browser.runs.get(run_idx) {
232
+ let run_id = run.id.clone();
233
+ let new_name = browser.rename_buffer.take().unwrap_or_default();
234
+ let db_path = state.store_root.join("extract.db");
235
+ match crate::db::Db::rename_run(&db_path, &run_id, &new_name) {
236
+ Ok(()) => {
237
+ let trimmed = new_name.trim();
238
+ let value = if trimmed.is_empty() {
239
+ None
240
+ } else {
241
+ Some(trimmed.to_string())
242
+ };
243
+ if let Some(run) = browser.runs.get_mut(run_idx) {
244
+ run.name = value.clone();
245
+ }
246
+ if let Some(state_idx) = state.runs.iter().position(|r| r.id == run_id) {
247
+ state.runs[state_idx].name = value;
248
+ }
249
+ let _ = state.refresh_selection_summary();
250
+ state.notify(crate::app::NotifyLevel::Success, "Run renamed");
251
+ }
252
+ Err(err) => {
253
+ state.notify(
254
+ crate::app::NotifyLevel::Error,
255
+ format!("Rename failed: {err}"),
256
+ );
257
+ }
258
+ }
259
+ } else {
260
+ browser.rename_buffer = None;
261
+ }
262
+ } else {
263
+ browser.rename_buffer = None;
264
+ }
265
+ return false;
266
+ }
267
+ KeyCode::Backspace => {
268
+ if let Some(ref mut name) = browser.rename_buffer {
269
+ name.pop();
270
+ }
271
+ return false;
272
+ }
273
+ KeyCode::Char(c) => {
274
+ if accepts_text_modifiers(key) {
275
+ if let Some(ref mut name) = browser.rename_buffer {
276
+ name.push(c);
277
+ }
278
+ }
279
+ return false;
280
+ }
281
+ _ => return false,
282
+ }
283
+ }
219
284
 
220
285
  // Search mode
221
286
  if is_searching {
@@ -290,6 +355,15 @@ impl PopupRenderer {
290
355
  return false;
291
356
  }
292
357
 
358
+ if is_rename_key(key) {
359
+ if let Some(&run_idx) = browser.filtered.get(browser.cursor) {
360
+ if let Some(run) = browser.runs.get(run_idx) {
361
+ browser.rename_buffer = Some(run.name.clone().unwrap_or_default());
362
+ }
363
+ }
364
+ return false;
365
+ }
366
+
293
367
  if keys::matches(key, keys::SELECT) {
294
368
  if let Some(&filtered_idx) = browser.filtered.get(browser.cursor) {
295
369
  if let Some(run) = browser.runs.get(filtered_idx) {
@@ -499,6 +573,7 @@ impl PopupRenderer {
499
573
  browser: &mut RunBrowserState,
500
574
  ) {
501
575
  let is_searching = browser.search_query.is_some();
576
+ let is_renaming = browser.rename_buffer.is_some();
502
577
  let width = POPUP_WIDTH.min(area.width.saturating_sub(4));
503
578
  let height = POPUP_HEIGHT.min(area.height.saturating_sub(4));
504
579
  let popup_area = centered_rect(width, height, area);
@@ -508,12 +583,16 @@ impl PopupRenderer {
508
583
  let title = format!(" {} — runs ", browser.experiment_name);
509
584
  let footer_spans = if is_searching {
510
585
  search_footer_spans(&self.theme)
586
+ } else if is_renaming {
587
+ rename_footer_spans(&self.theme)
511
588
  } else {
512
589
  vec![
513
590
  Span::styled("j/k", Style::default().fg(self.theme.accent)),
514
591
  Span::styled(" nav ", Style::default().fg(self.theme.accent_dim)),
515
592
  Span::styled("Enter", Style::default().fg(self.theme.accent)),
516
593
  Span::styled(" select ", Style::default().fg(self.theme.accent_dim)),
594
+ Span::styled("R", Style::default().fg(self.theme.accent)),
595
+ Span::styled(" rename ", Style::default().fg(self.theme.accent_dim)),
517
596
  Span::styled("/", Style::default().fg(self.theme.accent)),
518
597
  Span::styled(" search ", Style::default().fg(self.theme.accent_dim)),
519
598
  Span::styled("x", Style::default().fg(self.theme.accent)),
@@ -570,11 +649,19 @@ impl PopupRenderer {
570
649
  Style::default()
571
650
  };
572
651
 
573
- let label = run
574
- .name
575
- .as_deref()
576
- .map(|n| format!("{} ", n))
577
- .unwrap_or_default();
652
+ let label = if is_cursor {
653
+ browser
654
+ .rename_buffer
655
+ .as_ref()
656
+ .map(|name| format!("{name}_ "))
657
+ .or_else(|| run.name.as_deref().map(|n| format!("{} ", n)))
658
+ .unwrap_or_default()
659
+ } else {
660
+ run.name
661
+ .as_deref()
662
+ .map(|n| format!("{} ", n))
663
+ .unwrap_or_default()
664
+ };
578
665
  let date = format_date(run);
579
666
  let config_summary = differing_config_summary(run, &diff_keys);
580
667
 
@@ -624,14 +711,24 @@ fn compute_scroll(cursor: usize, current_offset: usize, list_height: usize) -> u
624
711
  }
625
712
  }
626
713
 
627
- /// Format a run's date for display (first 19 chars of ended_at or started_at).
714
+ /// Format a run timestamp for display in the user's local timezone.
628
715
  fn format_date(run: &Run) -> String {
629
- run.ended_at
630
- .as_deref()
631
- .unwrap_or(&run.started_at)
632
- .chars()
633
- .take(19)
634
- .collect()
716
+ let raw = run.ended_at.as_deref().unwrap_or(&run.started_at);
717
+ format_local_timestamp(raw).unwrap_or_else(|| raw.chars().take(19).collect())
718
+ }
719
+
720
+ fn format_local_timestamp(raw: &str) -> Option<String> {
721
+ DateTime::parse_from_rfc3339(raw)
722
+ .ok()
723
+ .map(|dt| dt.with_timezone(&Local).format("%Y-%m-%d %H:%M:%S").to_string())
724
+ }
725
+
726
+ fn is_rename_key(key: &KeyEvent) -> bool {
727
+ matches!(key.code, KeyCode::Char('R')) && accepts_text_modifiers(key)
728
+ }
729
+
730
+ fn accepts_text_modifiers(key: &KeyEvent) -> bool {
731
+ key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT
635
732
  }
636
733
 
637
734
  /// Build search input line with blinking cursor.
@@ -655,6 +752,17 @@ fn search_footer_spans(theme: &Theme) -> Vec<Span<'static>> {
655
752
  ]
656
753
  }
657
754
 
755
+ fn rename_footer_spans(theme: &Theme) -> Vec<Span<'static>> {
756
+ vec![
757
+ Span::styled("Type", Style::default().fg(theme.accent)),
758
+ Span::styled(" name ", Style::default().fg(theme.accent_dim)),
759
+ Span::styled("Enter", Style::default().fg(theme.accent)),
760
+ Span::styled(" save ", Style::default().fg(theme.accent_dim)),
761
+ Span::styled("Esc", Style::default().fg(theme.accent)),
762
+ Span::styled(" cancel", Style::default().fg(theme.accent_dim)),
763
+ ]
764
+ }
765
+
658
766
  /// Compare configs across all runs and return only the keys whose values differ.
659
767
  fn differing_config_keys(runs: &[Run]) -> Vec<String> {
660
768
  use std::collections::HashMap;
@@ -1,3 +1,4 @@
1
+ use chrono::{DateTime, Local};
1
2
  use ratatui::buffer::Buffer;
2
3
  use ratatui::layout::Rect;
3
4
  use ratatui::style::{Color, Modifier, Style};
@@ -219,7 +220,7 @@ impl SummaryRenderer {
219
220
  }
220
221
 
221
222
  let run = &data.runs[i];
222
- let date = run.started_at.get(..10).unwrap_or(&run.started_at);
223
+ let date = format_run_time(run);
223
224
  let label = run.name.clone().unwrap_or_else(|| {
224
225
  let id = &run.id;
225
226
  if id.len() > 8 { id[id.len() - 8..].to_string() } else { id.clone() }
@@ -632,6 +633,13 @@ impl SummaryRenderer {
632
633
  }
633
634
  }
634
635
 
636
+ fn format_run_time(run: &Run) -> String {
637
+ DateTime::parse_from_rfc3339(&run.started_at)
638
+ .ok()
639
+ .map(|dt| dt.with_timezone(&Local).format("%Y-%m-%d %H:%M:%S").to_string())
640
+ .unwrap_or_else(|| run.started_at.chars().take(19).collect())
641
+ }
642
+
635
643
  /// Catmull-Rom spline interpolation.
636
644
  /// Generates `num_points` evenly spaced points along the spline that passes
637
645
  /// through all input points. Requires at least 3 input points.