agitrack 0.2.2__tar.gz → 0.2.4__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.
- {agitrack-0.2.2 → agitrack-0.2.4}/PKG-INFO +3 -3
- {agitrack-0.2.2 → agitrack-0.2.4}/README.md +2 -2
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/commits/message.py +16 -10
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/config/state.py +19 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/git/repo.py +19 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/metrics/collect.py +42 -18
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/metrics/server.py +5 -1
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/metrics/web.py +110 -5
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proc.py +11 -1
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/commit_engine.py +23 -3
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/modal.py +20 -3
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/runner.py +96 -19
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack.egg-info/PKG-INFO +3 -3
- {agitrack-0.2.2 → agitrack-0.2.4}/pyproject.toml +1 -1
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_commit_engine.py +20 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_commit_message.py +14 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_dashboard.py +69 -1
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_proc.py +7 -5
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_proxy.py +53 -9
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_proxy_modal.py +53 -3
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_state.py +25 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_summary_async.py +80 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/LICENSE +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/__init__.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/__main__.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/backends/__init__.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/backends/base.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/backends/claude.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/backends/opencode.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/backends/proxy_agents.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/backends/setup.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/cli.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/commits/__init__.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/commits/actions.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/commits/manual.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/config/__init__.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/config/migrate.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/config/settings.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/env.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/events.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/git/__init__.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/git/hooks.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/git/lock.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/git/worktree.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/metrics/__init__.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/metrics/daemon.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/metrics/github.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/metrics/render.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/__init__.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/background.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/host_prompt.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/integration.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/platform/__init__.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/platform/_conpty.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/platform/_winconsole.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/platform/base.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/platform/nt.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/platform/posix.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/process.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/pty_backend.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/renderer.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/sandbox.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/session.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/session_names.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/terminal.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/recovery.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/sessions/__init__.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/sessions/identity.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/sessions/redact.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/sessions/share_cap.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/sessions/store.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/shell/__init__.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/shell/bridge.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/shell/runner.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/shell/ui.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/summaries/__init__.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/summaries/model_select.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/summaries/prompts.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/summaries/summarizer.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/system_tools.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/transcripts/__init__.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/transcripts/claude.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/transcripts/opencode.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/transcripts/types.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/update/__init__.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/update/marker.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/update/updater.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack.egg-info/SOURCES.txt +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack.egg-info/dependency_links.txt +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack.egg-info/entry_points.txt +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack.egg-info/requires.txt +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/agitrack.egg-info/top_level.txt +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/setup.cfg +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_backend_commits.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_backend_process.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_backend_setup.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_backends_and_config.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_backends_setup.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_background.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_bridge.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_claude_session.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_cli.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_commit_graph_resilience.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_commit_message_summary.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_commits_actions.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_config_application.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_config_migrate.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_copy_to_base.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_dashboard_daemon.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_demo_script.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_events.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_flows.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_git_commit_encoding.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_git_hooks.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_git_notes.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_integration_service.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_lock.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_main_module.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_manual_commits.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_mode_switching.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_model_select.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_opencode_commands.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_opencode_parse.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_opencode_session.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_pre_compaction.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_proxy_dashboard.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_proxy_session.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_pty_backend.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_pty_render.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_recovery.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_recovery_report.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_rename_compat.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_sandbox.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_session_names.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_session_sharing.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_settings.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_shell.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_shutdown_request.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_summarizer.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_summarizer_config.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_summary_scratch.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_system_tools.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_terminal_compat.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_ui.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_update.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_update_marker.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_updater.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_version.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_version_sync.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_windows_conpty.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_windows_platform.py +0 -0
- {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_worktree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agitrack
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Interactive coding-agent CLI that commits AI changes to git with traceable metadata.
|
|
5
5
|
Author: aGiTrack contributors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -283,7 +283,7 @@ agitrack -d text # one-shot plain-text report instead (pipe it, paste
|
|
|
283
283
|
|
|
284
284
|
- **aGiTrack-tracked AI vs non-tracked lines** — what the agents wrote (tracked by aGiTrack) versus everything else; it never claims a human wrote what the model did.
|
|
285
285
|
- **Filter live** — narrow the whole dashboard to one committer (merged to their GitHub ID), a backend, a model, or a time range.
|
|
286
|
-
- **Tokens, efficiency, and loop detection**, plus a commit log you can click to read the full message and
|
|
286
|
+
- **Tokens, efficiency, and loop detection**, plus a commit log you can click to read the full message and show each commit's **file diff right in the page** (served from your local clone — no GitHub needed).
|
|
287
287
|
|
|
288
288
|
See [Repository dashboard](#repository-dashboard) below for the full breakdown.
|
|
289
289
|
|
|
@@ -428,7 +428,7 @@ The [Dashboard](#dashboard) section above covers how to run it (`-d`, and `-d te
|
|
|
428
428
|
- **Breakdowns** by backend, by model (a cover commit's bucket includes the lines of the backend-made commits it covers), and by committer. Committer identities are merged to **GitHub IDs** via the `gh` CLI when available (every commit GitHub knows is keyed by its real login); without `gh` it falls back to merging by email and no-reply login. Each committer's lines are split into the aGiTrack-tracked AI they drove versus non-tracked.
|
|
429
429
|
- **Possible loops**: runs of three or more consecutive turns with near-identical prompts (or the same prompt repeated within one turn's trace), with the output tokens they consumed — a sign the conversation is going in circles.
|
|
430
430
|
|
|
431
|
-
The web page (styled like the [project page](https://github.com/core-aix/agitrack/tree/main/docs)) lets you **filter live** — narrow the whole dashboard to one committer or view the entire team, slice by backend or model, or restrict to a **time range** (presets or a custom from/to). The server recomputes the metrics for each filter, and the **commit log is paginated** (fetched a page at a time), so the browser never holds the whole history — memory stays bounded no matter how big the repo is. Each log line shows per-line token metrics; clicking a line opens the full commit message **rendered as Markdown**
|
|
431
|
+
The web page (styled like the [project page](https://github.com/core-aix/agitrack/tree/main/docs)) lets you **filter live** — narrow the whole dashboard to one committer or view the entire team, slice by backend or model, or restrict to a **time range** (presets or a custom from/to). The server recomputes the metrics for each filter, and the **commit log is paginated** (fetched a page at a time), so the browser never holds the whole history — memory stays bounded no matter how big the repo is. Each log line shows per-line token metrics; clicking a line opens the full commit message **rendered as Markdown** and a **"show file diff"** button that renders that commit's diff inline — computed from your **local clone**, so the whole dashboard works with no GitHub (a "view on GitHub" link is offered only when a remote is configured). Merge/cover commits show their first-parent diff (the AI work they account for). A squash expands into its original commits (each itself expandable). Agent commits also record when the AI-driven conversation started and ended (`agent_started_at` / `agent_ended_at` in the metadata block).
|
|
432
432
|
|
|
433
433
|
The dashboard is read-only in either form (served or `-d text`): it never commits, never prompts, and skips the privacy acknowledgment.
|
|
434
434
|
|
|
@@ -266,7 +266,7 @@ agitrack -d text # one-shot plain-text report instead (pipe it, paste
|
|
|
266
266
|
|
|
267
267
|
- **aGiTrack-tracked AI vs non-tracked lines** — what the agents wrote (tracked by aGiTrack) versus everything else; it never claims a human wrote what the model did.
|
|
268
268
|
- **Filter live** — narrow the whole dashboard to one committer (merged to their GitHub ID), a backend, a model, or a time range.
|
|
269
|
-
- **Tokens, efficiency, and loop detection**, plus a commit log you can click to read the full message and
|
|
269
|
+
- **Tokens, efficiency, and loop detection**, plus a commit log you can click to read the full message and show each commit's **file diff right in the page** (served from your local clone — no GitHub needed).
|
|
270
270
|
|
|
271
271
|
See [Repository dashboard](#repository-dashboard) below for the full breakdown.
|
|
272
272
|
|
|
@@ -411,7 +411,7 @@ The [Dashboard](#dashboard) section above covers how to run it (`-d`, and `-d te
|
|
|
411
411
|
- **Breakdowns** by backend, by model (a cover commit's bucket includes the lines of the backend-made commits it covers), and by committer. Committer identities are merged to **GitHub IDs** via the `gh` CLI when available (every commit GitHub knows is keyed by its real login); without `gh` it falls back to merging by email and no-reply login. Each committer's lines are split into the aGiTrack-tracked AI they drove versus non-tracked.
|
|
412
412
|
- **Possible loops**: runs of three or more consecutive turns with near-identical prompts (or the same prompt repeated within one turn's trace), with the output tokens they consumed — a sign the conversation is going in circles.
|
|
413
413
|
|
|
414
|
-
The web page (styled like the [project page](https://github.com/core-aix/agitrack/tree/main/docs)) lets you **filter live** — narrow the whole dashboard to one committer or view the entire team, slice by backend or model, or restrict to a **time range** (presets or a custom from/to). The server recomputes the metrics for each filter, and the **commit log is paginated** (fetched a page at a time), so the browser never holds the whole history — memory stays bounded no matter how big the repo is. Each log line shows per-line token metrics; clicking a line opens the full commit message **rendered as Markdown**
|
|
414
|
+
The web page (styled like the [project page](https://github.com/core-aix/agitrack/tree/main/docs)) lets you **filter live** — narrow the whole dashboard to one committer or view the entire team, slice by backend or model, or restrict to a **time range** (presets or a custom from/to). The server recomputes the metrics for each filter, and the **commit log is paginated** (fetched a page at a time), so the browser never holds the whole history — memory stays bounded no matter how big the repo is. Each log line shows per-line token metrics; clicking a line opens the full commit message **rendered as Markdown** and a **"show file diff"** button that renders that commit's diff inline — computed from your **local clone**, so the whole dashboard works with no GitHub (a "view on GitHub" link is offered only when a remote is configured). Merge/cover commits show their first-parent diff (the AI work they account for). A squash expands into its original commits (each itself expandable). Agent commits also record when the AI-driven conversation started and ended (`agent_started_at` / `agent_ended_at` in the metadata block).
|
|
415
415
|
|
|
416
416
|
The dashboard is read-only in either form (served or `-d text`): it never commits, never prompts, and skips the privacy acknowledgment.
|
|
417
417
|
|
|
@@ -276,6 +276,18 @@ def _insert_before_version_line(lines: list[str], extra: list[str]) -> list[str]
|
|
|
276
276
|
return lines + list(extra)
|
|
277
277
|
|
|
278
278
|
|
|
279
|
+
def _trace_role_lines(item: dict) -> list[str]:
|
|
280
|
+
"""The rendered lines for one trace turn — its "## User"/"## Agent" role heading plus the
|
|
281
|
+
masked, heading-nested body — or an EMPTY list when the entry has no textual content. A
|
|
282
|
+
blank/whitespace entry (e.g. a garbled or empty proxy capture of a follow-up typed while the
|
|
283
|
+
agent was busy) must never produce a bare "## User" heading with nothing under it."""
|
|
284
|
+
content = _nest_headings_under_role(_mask_secrets(item.get("content", "")))
|
|
285
|
+
if not content.strip():
|
|
286
|
+
return []
|
|
287
|
+
label = "User" if item.get("role", "").strip().lower() == "user" else "Agent"
|
|
288
|
+
return [f"## {label}", "", *_body_lines(content), ""]
|
|
289
|
+
|
|
290
|
+
|
|
279
291
|
def render_interaction_trace(trace: list[dict], trace_turn_limit: int) -> str:
|
|
280
292
|
"""The interaction-trace body exactly as it is appended to an aGiTrack commit:
|
|
281
293
|
role headings plus masked, heading-nested content (same as the commit's
|
|
@@ -284,10 +296,7 @@ def render_interaction_trace(trace: list[dict], trace_turn_limit: int) -> str:
|
|
|
284
296
|
nothing else — no diff, no out-of-band context."""
|
|
285
297
|
lines: list[str] = []
|
|
286
298
|
for item in _limit_trace_turns(trace, trace_turn_limit):
|
|
287
|
-
|
|
288
|
-
content = _nest_headings_under_role(_mask_secrets(item.get("content", "")))
|
|
289
|
-
label = "User" if role == "user" else "Agent"
|
|
290
|
-
lines.extend([f"## {label}", "", *_body_lines(content), ""])
|
|
299
|
+
lines.extend(_trace_role_lines(item))
|
|
291
300
|
return "\n".join(lines).strip()
|
|
292
301
|
|
|
293
302
|
|
|
@@ -316,12 +325,9 @@ def _trace_and_metadata_lines(
|
|
|
316
325
|
# itself shows when the context — and the token counts riding on it — changed.
|
|
317
326
|
lines.extend(_session_event_note_lines(compactions=compactions, origin_event=origin_event))
|
|
318
327
|
for item in _limit_trace_turns(trace, trace_turn_limit):
|
|
319
|
-
|
|
320
|
-
#
|
|
321
|
-
|
|
322
|
-
content = _nest_headings_under_role(_mask_secrets(item.get("content", "")))
|
|
323
|
-
label = "User" if role == "user" else "Agent"
|
|
324
|
-
lines.extend([f"## {label}", "", *_body_lines(content), ""])
|
|
328
|
+
# Nest each message's own headings under its "## User"/"## Agent" role heading (so a
|
|
329
|
+
# message's "# Title" can't outrank the role) and skip empty entries (see _trace_role_lines).
|
|
330
|
+
lines.extend(_trace_role_lines(item))
|
|
325
331
|
|
|
326
332
|
lines.extend(
|
|
327
333
|
[
|
|
@@ -10,6 +10,10 @@ from typing import Any
|
|
|
10
10
|
|
|
11
11
|
from agitrack.proc import console_isolation_kwargs
|
|
12
12
|
|
|
13
|
+
# The git-resolved .git/info/exclude path per repo root — invariant for a run, so resolve it once
|
|
14
|
+
# (see AgitrackState._exclude_path) instead of spawning `git rev-parse` on every save/ensure.
|
|
15
|
+
_EXCLUDE_PATH_CACHE: dict[Path, Path] = {}
|
|
16
|
+
|
|
13
17
|
|
|
14
18
|
class AgitrackState:
|
|
15
19
|
def __init__(self, repo: Path, *, default_backend: str | None = None) -> None:
|
|
@@ -144,6 +148,21 @@ class AgitrackState:
|
|
|
144
148
|
handle.write(".agitrack/\n")
|
|
145
149
|
|
|
146
150
|
def _exclude_path(self) -> Path | None:
|
|
151
|
+
# The info/exclude location is invariant for a repo, but ensure_local_ignore() /
|
|
152
|
+
# save() ask for it constantly (every state save, every daemon poll). Resolving it via
|
|
153
|
+
# git each time spawns a `git rev-parse` subprocess — dozens during the startup burst
|
|
154
|
+
# (a chunk of the slow start + the terminal "git" title flicker). Cache it per repo so
|
|
155
|
+
# the git call happens once; the (cheap) file read/append in _ensure_repo_local_ignore
|
|
156
|
+
# still runs each time, so an externally emptied exclude file is still re-populated.
|
|
157
|
+
cached = _EXCLUDE_PATH_CACHE.get(self.repo)
|
|
158
|
+
if cached is not None:
|
|
159
|
+
return cached
|
|
160
|
+
resolved = self._resolve_exclude_path()
|
|
161
|
+
if resolved is not None:
|
|
162
|
+
_EXCLUDE_PATH_CACHE[self.repo] = resolved
|
|
163
|
+
return resolved
|
|
164
|
+
|
|
165
|
+
def _resolve_exclude_path(self) -> Path | None:
|
|
147
166
|
# Resolve the info/exclude path via git so it works inside a worktree,
|
|
148
167
|
# where ``.git`` is a file pointing at the shared git dir rather than a
|
|
149
168
|
# directory. Fall back to the conventional location when git is not
|
|
@@ -292,6 +292,25 @@ class GitRepo:
|
|
|
292
292
|
def diff_range(self, base: str, head: str) -> str:
|
|
293
293
|
return self._run(["git", "diff", f"{base}..{head}"], check=False).stdout
|
|
294
294
|
|
|
295
|
+
def show_commit(self, ref: str, *, max_bytes: int = 400_000) -> tuple[str, bool]:
|
|
296
|
+
"""The file diffs a commit introduced — a diffstat followed by the unified patch —
|
|
297
|
+
for the dashboard's local diff view (so the dashboard is fully usable off GitHub).
|
|
298
|
+
|
|
299
|
+
``--first-parent`` makes a merge or cover commit show its change against the mainline
|
|
300
|
+
parent (the AI work a cover commit accounts for) instead of the near-empty combined
|
|
301
|
+
diff; it's a no-op for ordinary single-parent commits, and a root commit shows as
|
|
302
|
+
all-additions. All local: it reads blobs already in the clone, never the network.
|
|
303
|
+
|
|
304
|
+
Returns ``(patch, truncated)`` — the patch is capped at ``max_bytes`` so one enormous
|
|
305
|
+
commit can't produce an unbounded response; ``truncated`` is True when it was cut."""
|
|
306
|
+
out = self._run(
|
|
307
|
+
["git", "show", "--no-color", "--first-parent", "--format=", "--stat", "--patch", ref],
|
|
308
|
+
check=False,
|
|
309
|
+
).stdout
|
|
310
|
+
if len(out) > max_bytes:
|
|
311
|
+
return out[:max_bytes], True
|
|
312
|
+
return out, False
|
|
313
|
+
|
|
295
314
|
# --- branches / worktrees / merges (used by concurrent-session support) ---
|
|
296
315
|
|
|
297
316
|
def current_branch(self) -> str:
|
|
@@ -557,7 +557,7 @@ def collect_commit_stats(repo: GitRepo, ref: str = "HEAD") -> list[CommitStat]:
|
|
|
557
557
|
committed_at, _, body = rest.partition(_FIELD_SEP)
|
|
558
558
|
stats.append(_parse_commit(sha.strip(), author, email, committed_at.strip(), body))
|
|
559
559
|
|
|
560
|
-
|
|
560
|
+
_neutralize_inherited_metadata(repo, ref, stats)
|
|
561
561
|
|
|
562
562
|
_apply_numstat(repo, ref, {stat.sha: stat for stat in stats})
|
|
563
563
|
|
|
@@ -636,8 +636,8 @@ def _dedupe_squash_constituents(stats: list[CommitStat]) -> None:
|
|
|
636
636
|
stat.tokens = _sum_tokens(kept)
|
|
637
637
|
|
|
638
638
|
|
|
639
|
-
def
|
|
640
|
-
"""
|
|
639
|
+
def _neutralize_inherited_metadata(repo: GitRepo, ref: str, stats: list[CommitStat]) -> None:
|
|
640
|
+
"""Strip the aGiTrack metadata from commits that merely inherit a parent's block.
|
|
641
641
|
|
|
642
642
|
When a session branch is integrated with a real merge commit (e.g. a GitHub
|
|
643
643
|
PR merged with "Create a merge commit"), GitHub copies the PR title and body
|
|
@@ -648,24 +648,48 @@ def _drop_inherited_metadata(repo: GitRepo, ref: str, stats: list[CommitStat]) -
|
|
|
648
648
|
|
|
649
649
|
The fingerprint is a *merge* commit (>1 parent) that shares an identical
|
|
650
650
|
metadata block with one of its parents: that parent is the cover commit it
|
|
651
|
-
merges, and the block was copied wholesale.
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
651
|
+
merges, and the block was copied wholesale. Rather than drop the merge (which
|
|
652
|
+
would make the dashboard show fewer commits than GitHub does), we keep it in
|
|
653
|
+
the log but wipe the inherited metadata in place — tokens, covered_commits,
|
|
654
|
+
trace, backend/model — and reclassify it as the plain merge it really is, so
|
|
655
|
+
the turn's figures are still counted only once (on the cover parent).
|
|
656
|
+
Restricting to merges is what separates this from a genuine run of repeated
|
|
657
|
+
turns, which is linear — each such commit has a single parent, so an identical
|
|
658
|
+
neighbouring block is a real (repeated) turn, not a copy. Cover commits and
|
|
659
|
+
integration merges are themselves merge-shaped but never share a parent's
|
|
660
|
+
block, so they are untouched."""
|
|
657
661
|
block_by_sha = {stat.sha: stat.metadata_block for stat in stats if stat.metadata_block}
|
|
658
662
|
if not block_by_sha:
|
|
659
|
-
return
|
|
663
|
+
return
|
|
660
664
|
parents = _parents(repo, ref)
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
665
|
+
for stat in stats:
|
|
666
|
+
inherited = (
|
|
667
|
+
stat.metadata_block
|
|
668
|
+
and len(parents.get(stat.sha, ())) > 1
|
|
669
|
+
and any(block_by_sha.get(parent) == stat.metadata_block for parent in parents[stat.sha])
|
|
670
|
+
)
|
|
671
|
+
if not inherited:
|
|
672
|
+
continue
|
|
673
|
+
# Keep the commit (so the count matches GitHub) but scrub every field that
|
|
674
|
+
# the copied block populated, so nothing about this turn is counted twice.
|
|
675
|
+
stat.kind = _neutralized_merge_kind(stat.subject)
|
|
676
|
+
stat.tokens = {}
|
|
677
|
+
stat.covered_commits = []
|
|
678
|
+
stat.constituents = []
|
|
679
|
+
stat.started_at = stat.ended_at = ""
|
|
680
|
+
stat.backend = stat.model = None
|
|
681
|
+
stat.prompt = ""
|
|
682
|
+
stat.user_prompts = []
|
|
683
|
+
stat.metadata_block = ""
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _neutralized_merge_kind(subject: str) -> str:
|
|
687
|
+
"""The kind a merge should carry once its inherited aGiTrack metadata is stripped:
|
|
688
|
+
aGiTrack's own integration plumbing if it names a session branch, else untracked —
|
|
689
|
+
mirroring the metadata-less classification in :func:`_parse_commit`."""
|
|
690
|
+
if subject.startswith("Merge ") and _AGITRACK_BRANCH_RE.search(subject):
|
|
691
|
+
return "agitrack-ops"
|
|
692
|
+
return "untracked"
|
|
669
693
|
|
|
670
694
|
|
|
671
695
|
def _parents(repo: GitRepo, ref: str) -> dict[str, list[str]]:
|
|
@@ -20,7 +20,7 @@ from typing import Any
|
|
|
20
20
|
from agitrack.git import GitRepo
|
|
21
21
|
from agitrack.metrics.collect import Dashboard, build_dashboard
|
|
22
22
|
from agitrack.metrics.github import cached_logins
|
|
23
|
-
from agitrack.metrics.web import aggregates_payload, log_page, shared_sessions_for, shell_html
|
|
23
|
+
from agitrack.metrics.web import aggregates_payload, commit_diff, log_page, shared_sessions_for, shell_html
|
|
24
24
|
|
|
25
25
|
DEFAULT_HOST = "127.0.0.1"
|
|
26
26
|
DEFAULT_PORT = 8765
|
|
@@ -83,6 +83,10 @@ class _DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
83
83
|
sort=_str(query, "sort"),
|
|
84
84
|
)
|
|
85
85
|
self._respond("application/json", self._json(page))
|
|
86
|
+
elif parsed.path == "/diff":
|
|
87
|
+
# This commit's file diffs, straight from the local clone — so the dashboard
|
|
88
|
+
# shows changes without GitHub. The sha is validated as a hex id in commit_diff.
|
|
89
|
+
self._respond("application/json", self._json(commit_diff(self.repo, _str(query, "sha"))))
|
|
86
90
|
else:
|
|
87
91
|
self.send_error(404, "not found")
|
|
88
92
|
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
|
@@ -25,6 +25,7 @@ and ad-hoc use; the live page does not embed it.
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
27
|
import json
|
|
28
|
+
import re
|
|
28
29
|
from datetime import datetime, timedelta, timezone
|
|
29
30
|
|
|
30
31
|
from agitrack.commits import METADATA_HEADER
|
|
@@ -473,6 +474,7 @@ def _log_entry(dash: Dashboard, stat: CommitStat, covers: dict[str, CommitStat])
|
|
|
473
474
|
eff_backend, eff_model = _effective(stat, covers)
|
|
474
475
|
return {
|
|
475
476
|
"short": stat.short,
|
|
477
|
+
"sha": stat.sha, # full sha so the live page can fetch this commit's diff from /diff
|
|
476
478
|
"author": dash.label_of(stat),
|
|
477
479
|
"committers": dash.committers_of(stat),
|
|
478
480
|
"subject": stat.subject,
|
|
@@ -529,6 +531,24 @@ def log_page(
|
|
|
529
531
|
}
|
|
530
532
|
|
|
531
533
|
|
|
534
|
+
# A commit id must be a bare hex object name before it is handed to git, so a crafted
|
|
535
|
+
# ?sha= value can never become a git option (e.g. --upload-pack) or reach the shell.
|
|
536
|
+
_SHA_RE = re.compile(r"^[0-9a-fA-F]{4,64}$")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def commit_diff(repo: GitRepo, sha: str) -> dict:
|
|
540
|
+
"""The file diffs a single commit introduced (a diffstat + unified patch), for the
|
|
541
|
+
dashboard's local diff view — computed entirely from the local clone, so the dashboard
|
|
542
|
+
needs no GitHub. ``sha`` is validated as a hex object id before touching git."""
|
|
543
|
+
if not _SHA_RE.match(sha or ""):
|
|
544
|
+
return {"sha": sha, "diff": "", "error": "invalid commit id"}
|
|
545
|
+
try:
|
|
546
|
+
patch, truncated = repo.show_commit(sha)
|
|
547
|
+
except Exception:
|
|
548
|
+
return {"sha": sha, "diff": "", "error": "could not read this commit's diff"}
|
|
549
|
+
return {"sha": sha, "diff": patch, "truncated": truncated}
|
|
550
|
+
|
|
551
|
+
|
|
532
552
|
def initial_payload(dash: Dashboard, *, shared_sessions: list[dict] | None = None) -> dict:
|
|
533
553
|
"""What the page embeds for an instant first paint: unfiltered aggregates,
|
|
534
554
|
the first log page, repo metadata, the page size, and any shared sessions."""
|
|
@@ -557,7 +577,7 @@ _TEMPLATE = r"""<!DOCTYPE html>
|
|
|
557
577
|
<meta charset="utf-8">
|
|
558
578
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
559
579
|
<title>aGiTrack - Dashboard · __REPO_NAME__</title>
|
|
560
|
-
<!-- Inline SVG favicon (the aGiTrack wordmark mark) — the server only serves /, /data, /log, so it can't host a file. -->
|
|
580
|
+
<!-- Inline SVG favicon (the aGiTrack wordmark mark) — the server only serves /, /data, /log, /diff, so it can't host a file. -->
|
|
561
581
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2064%2064'%3E%3Crect%20width='64'%20height='64'%20rx='13'%20fill='%23070b09'/%3E%3Ctext%20x='32'%20y='45'%20text-anchor='middle'%20font-family='ui-monospace,monospace'%20font-weight='700'%20font-size='42'%20letter-spacing='-1'%3E%3Ctspan%20fill='%23ffb454'%3Ea%3C/tspan%3E%3Ctspan%20fill='%233dffa0'%3EG%3C/tspan%3E%3C/text%3E%3C/svg%3E">
|
|
562
582
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
563
583
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
@@ -680,8 +700,10 @@ h2.section::before{content:"# ";color:var(--amber)}
|
|
|
680
700
|
/* No text-transform: the "aGiTrack" brand must never render all-caps. */
|
|
681
701
|
.card .label{font-size:11.5px;color:var(--amber);letter-spacing:.5px}
|
|
682
702
|
.card .value{font-family:var(--display);font-size:42px;line-height:1.05;color:var(--phosphor);margin-top:6px;
|
|
683
|
-
text-shadow:0 0 14px rgba(61,255,160,.3)}
|
|
703
|
+
text-shadow:0 0 14px rgba(61,255,160,.3);white-space:nowrap}
|
|
684
704
|
.card .value.amber{color:var(--amber);text-shadow:0 0 14px rgba(255,180,84,.3)}
|
|
705
|
+
/* scientific-notation exponent: noticeably smaller than the mantissa (e.g. 1.01×10⁸) */
|
|
706
|
+
.card .value sup{font-size:.5em;vertical-align:super;line-height:0;margin-left:1px}
|
|
685
707
|
.card .note{font-size:12px;color:var(--fg-dim);margin-top:4px}
|
|
686
708
|
|
|
687
709
|
/* ---- time-series chart ---- */
|
|
@@ -795,6 +817,21 @@ h2.section::before{content:"# ";color:var(--amber)}
|
|
|
795
817
|
.entry .detail .dmeta{color:var(--ops);font-size:12px;margin-bottom:6px}
|
|
796
818
|
.entry .detail .dmsg{font-size:12.5px;color:var(--fg-dim);background:var(--ink);border:1px solid var(--line);
|
|
797
819
|
padding:4px 12px;max-height:440px;overflow:auto;word-break:break-word}
|
|
820
|
+
/* local (off-GitHub) per-commit file diff — the button flips this one box to the diff, so in diff
|
|
821
|
+
mode the box drops its own frame and the .diffbox (with its own border/scroll) becomes the pane */
|
|
822
|
+
.entry .detail .dmsg.diff{padding:0;border:none;background:none;max-height:none;overflow:visible}
|
|
823
|
+
.entry .detail .diffbtn{margin-right:10px;font-family:inherit;font-size:11.5px;color:var(--phosphor);
|
|
824
|
+
background:transparent;border:1px solid var(--phosphor-dim);padding:1px 8px;cursor:pointer;letter-spacing:.3px}
|
|
825
|
+
.entry .detail .diffbtn:hover{background:var(--phosphor);color:var(--ink)}
|
|
826
|
+
.diffbox{border:1px solid var(--line);background:var(--ink);max-height:460px;overflow:auto;
|
|
827
|
+
font-size:12px;line-height:1.5;white-space:pre}
|
|
828
|
+
.diffbox .dl{display:block;padding:0 10px}
|
|
829
|
+
.diffbox .dfile{color:var(--amber);background:rgba(255,180,84,.06)}
|
|
830
|
+
.diffbox .dhunk{color:var(--ops);background:rgba(103,184,214,.09)}
|
|
831
|
+
.diffbox .dmeta2{color:var(--fg-dim)}
|
|
832
|
+
.diffbox .dadd{color:var(--phosphor);background:rgba(61,255,160,.08)}
|
|
833
|
+
.diffbox .ddel{color:var(--red);background:rgba(255,107,107,.08)}
|
|
834
|
+
.dmsg .diffempty{color:var(--fg-dim);font-size:12px;font-style:italic;padding:8px 12px}
|
|
798
835
|
/* rendered Markdown inside the expanded message */
|
|
799
836
|
.dmsg.md p{margin:7px 0}
|
|
800
837
|
.dmsg.md .md-h{font-family:var(--mono);color:var(--amber);margin:11px 0 5px;font-size:13px;font-weight:600}
|
|
@@ -1090,7 +1127,21 @@ function subBarRow(name, value, max, numHtml, min){
|
|
|
1090
1127
|
}
|
|
1091
1128
|
function card(label, value, note, amber){
|
|
1092
1129
|
return `<div class="card"><div class="label">${esc(label)}</div>`+
|
|
1093
|
-
`<div class="value ${amber?"amber":""}">${value}</div><div class="note">${esc(note||"")}</div></div>`;
|
|
1130
|
+
`<div class="value ${amber?"amber":""}">${bigValue(value)}</div><div class="note">${esc(note||"")}</div></div>`;
|
|
1131
|
+
}
|
|
1132
|
+
// Keep the big display font, but when a plain integer is too long to fit the card (e.g. 100M+
|
|
1133
|
+
// output tokens) show it in scientific notation instead — nicer than shrinking the type. Percents,
|
|
1134
|
+
// ratios, "—", and already-short numbers are shown verbatim.
|
|
1135
|
+
function bigValue(value){
|
|
1136
|
+
const m = String(value).match(/^([+\-]?)([\d,]+)$/); // a signed, comma-grouped integer only
|
|
1137
|
+
if(!m || m[2].length <= 9) return value; // ≤ 9 chars incl. commas still fits at 42px
|
|
1138
|
+
return m[1] + sci(Number(m[2].replace(/,/g,"")));
|
|
1139
|
+
}
|
|
1140
|
+
function sci(n){
|
|
1141
|
+
if(!isFinite(n) || n===0) return "0";
|
|
1142
|
+
const exp = Math.floor(Math.log10(n));
|
|
1143
|
+
const mant = (n/Math.pow(10,exp)).toFixed(2).replace(/\.?0+$/,""); // 1.01, 1.2, 5, …
|
|
1144
|
+
return mant + "×10<sup>" + exp + "</sup>"; // e.g. 1.01×10⁸ (exp in a <sup>)
|
|
1094
1145
|
}
|
|
1095
1146
|
function tokenBrief(t){
|
|
1096
1147
|
if(!t) return "";
|
|
@@ -1423,19 +1474,71 @@ function toggleDetail(i){
|
|
|
1423
1474
|
const c = LOG_ENTRIES[i], detail = $("detail-"+i);
|
|
1424
1475
|
if(!c || !detail) return;
|
|
1425
1476
|
if(detail.hidden){
|
|
1477
|
+
// Local file diff (served from the clone by /diff) is the primary, GitHub-free action;
|
|
1478
|
+
// the GitHub link is kept as an optional extra when a remote is configured. A squash has
|
|
1479
|
+
// no single diff worth showing here (its parts expand separately), so skip the button then.
|
|
1480
|
+
const diffBtn = (LIVE && c.sha && !(c.parts&&c.parts.length))
|
|
1481
|
+
? `<button class="diffbtn" data-diff="${i}">show file diff</button>` : "";
|
|
1426
1482
|
const link = c.url ? `<a href="${esc(c.url)}" target="_blank" rel="noopener">view on GitHub ↗</a>` : "";
|
|
1427
1483
|
const span = (c.started||c.ended)
|
|
1428
1484
|
? `<div class="dmeta">AI conversation: ${esc(c.started||"?")} → ${esc(c.ended||"?")}</div>` : "";
|
|
1429
1485
|
const who = (c.committers&&c.committers.length)
|
|
1430
1486
|
? `<div class="dmeta">committer${c.committers.length>1?"s":""}: ${c.committers.map(esc).join(", ")}</div>` : "";
|
|
1431
|
-
detail.innerHTML = `<div class="dhead">${esc(c.short)} ${link}</div>${who}${span}`+
|
|
1432
|
-
`<div class="dmsg md">${md(c.message||"(no message recorded)")}</div
|
|
1487
|
+
detail.innerHTML = `<div class="dhead">${esc(c.short)} ${diffBtn}${link}</div>${who}${span}`+
|
|
1488
|
+
`<div class="dmsg md" id="dbody-${i}" data-mode="msg">${md(c.message||"(no message recorded)")}</div>`+
|
|
1489
|
+
partsHtml(c.parts);
|
|
1433
1490
|
detail.hidden = false;
|
|
1434
1491
|
} else {
|
|
1435
1492
|
detail.hidden = true;
|
|
1436
1493
|
}
|
|
1437
1494
|
}
|
|
1438
1495
|
|
|
1496
|
+
// One box that flips between the commit message and this commit's file diff — the button toggles
|
|
1497
|
+
// which is shown, so you never juggle two panes. The diff (from the local clone via /diff, no
|
|
1498
|
+
// GitHub) is fetched once and cached by sha, so flipping back and forth is instant.
|
|
1499
|
+
const _diffCache = {};
|
|
1500
|
+
async function toggleDiff(i){
|
|
1501
|
+
const c = LOG_ENTRIES[i], body = $("dbody-"+i);
|
|
1502
|
+
const btn = document.querySelector('.diffbtn[data-diff="'+i+'"]');
|
|
1503
|
+
if(!c || !body) return;
|
|
1504
|
+
if(body.dataset.mode === "diff"){ // diff → back to the commit message
|
|
1505
|
+
body.dataset.mode = "msg"; body.className = "dmsg md";
|
|
1506
|
+
body.innerHTML = md(c.message||"(no message recorded)");
|
|
1507
|
+
if(btn) btn.textContent = "show file diff";
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
// commit message → file diff. Flip the mode + button label now for instant feedback, but KEEP the
|
|
1511
|
+
// message visible until the diff is ready, then swap the box in a single paint — so there's no
|
|
1512
|
+
// "loading…" placeholder flashing on the first (uncached) switch.
|
|
1513
|
+
body.dataset.mode = "diff";
|
|
1514
|
+
if(btn) btn.textContent = "show commit message";
|
|
1515
|
+
const apply = html => { if(body.dataset.mode === "diff"){ body.className = "dmsg diff"; body.innerHTML = html; } };
|
|
1516
|
+
if(_diffCache[c.sha] !== undefined){ apply(_diffCache[c.sha]); return; }
|
|
1517
|
+
try{
|
|
1518
|
+
const r = await fetch("diff?sha="+encodeURIComponent(c.sha), {cache:"no-store"});
|
|
1519
|
+
const d = r.ok ? await r.json() : {error:"server error"};
|
|
1520
|
+
let html;
|
|
1521
|
+
if(d.error) html = '<div class="diffempty">'+esc(d.error)+'</div>';
|
|
1522
|
+
else if(!d.diff || !d.diff.trim()) html = '<div class="diffempty">no file changes in this commit</div>';
|
|
1523
|
+
else html = renderDiff(d.diff) + (d.truncated?'<div class="diffempty">…diff truncated (very large commit)</div>':"");
|
|
1524
|
+
_diffCache[c.sha] = html;
|
|
1525
|
+
apply(html); // apply() no-ops if the user flipped back to the message mid-fetch
|
|
1526
|
+
}catch(e){ apply('<div class="diffempty">couldn\'t load the diff (server unreachable)</div>'); }
|
|
1527
|
+
}
|
|
1528
|
+
// Color a unified diff (diffstat + patch) line-by-line: file headers, hunk headers, +adds, −dels.
|
|
1529
|
+
function renderDiff(text){
|
|
1530
|
+
const rows = (text||"").replace(/\r\n/g,"\n").replace(/\n+$/,"").split("\n").map(raw => {
|
|
1531
|
+
let cls = "dl";
|
|
1532
|
+
if(/^(diff --git |index |new file|deleted file|similarity |rename |old mode|new mode)/.test(raw)) cls="dl dfile";
|
|
1533
|
+
else if(raw.startsWith("@@")) cls="dl dhunk";
|
|
1534
|
+
else if(raw.startsWith("+++")||raw.startsWith("---")) cls="dl dmeta2";
|
|
1535
|
+
else if(raw.startsWith("+")) cls="dl dadd";
|
|
1536
|
+
else if(raw.startsWith("-")) cls="dl ddel";
|
|
1537
|
+
return '<span class="'+cls+'">'+(esc(raw)||" ")+'</span>';
|
|
1538
|
+
});
|
|
1539
|
+
return '<pre class="diffbox">'+rows.join("")+'</pre>';
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1439
1542
|
function fillSelect(id, values, allLabel, keep){
|
|
1440
1543
|
const sel = $(id);
|
|
1441
1544
|
sel.innerHTML = `<option value="">${allLabel}</option>` +
|
|
@@ -1571,6 +1674,8 @@ async function init(){
|
|
|
1571
1674
|
// inside the opened detail (links, the squash <details> tree, the pager) are
|
|
1572
1675
|
// left alone.
|
|
1573
1676
|
$("commitlog").addEventListener("click", e => {
|
|
1677
|
+
const dbtn = e.target.closest(".diffbtn");
|
|
1678
|
+
if(dbtn){ e.preventDefault(); toggleDiff(+dbtn.dataset.diff); return; }
|
|
1574
1679
|
if(e.target.closest("a") || e.target.closest(".detail") || e.target.closest(".pager")) return;
|
|
1575
1680
|
const entry = e.target.closest(".entry");
|
|
1576
1681
|
if(entry) toggleDetail(+entry.dataset.i);
|
|
@@ -131,13 +131,23 @@ def console_isolation_kwargs(*, detach_stdin: bool = True) -> dict:
|
|
|
131
131
|
|
|
132
132
|
Pass ``detach_stdin=False`` for a call that already feeds the child via ``input=`` (the two
|
|
133
133
|
are mutually exclusive in ``subprocess``); the ``input=`` pipe already keeps it off the
|
|
134
|
-
console.
|
|
134
|
+
console.
|
|
135
|
+
|
|
136
|
+
On POSIX, run the child in its OWN session (``start_new_session`` → ``setsid``). aGiTrack fires
|
|
137
|
+
many short git subprocesses (per-turn commits, status/branch polls); left in aGiTrack's process
|
|
138
|
+
group they sit in the controlling terminal's FOREGROUND group, so the terminal keeps retitling
|
|
139
|
+
its tab to "git" and back — a visible flicker during the startup burst. A new session takes the
|
|
140
|
+
child out of the foreground group so the title stays put. It's safe here because these children
|
|
141
|
+
are output-captured with stdin detached (``DEVNULL`` or an ``input=`` pipe): they never need the
|
|
142
|
+
controlling terminal — no pager, no credential prompt on the tty.
|
|
135
143
|
"""
|
|
136
144
|
kwargs: dict = {}
|
|
137
145
|
if detach_stdin:
|
|
138
146
|
kwargs["stdin"] = subprocess.DEVNULL
|
|
139
147
|
if _IS_WINDOWS:
|
|
140
148
|
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
149
|
+
else:
|
|
150
|
+
kwargs["start_new_session"] = True
|
|
141
151
|
return kwargs
|
|
142
152
|
|
|
143
153
|
|
|
@@ -153,6 +153,21 @@ def _prompt_covered_by(pending: str, prompt: str) -> bool:
|
|
|
153
153
|
return len(pt & tt) / len(pt) >= 0.9
|
|
154
154
|
|
|
155
155
|
|
|
156
|
+
def _garbled_short_duplicate(pending: str, prompt: str) -> bool:
|
|
157
|
+
"""True when a SHORT *pending* leftover looks like a garbled capture of *prompt* already in the
|
|
158
|
+
trace. The proxy reconstructs the raw typed bytes, so a follow-up typed while the agent was busy
|
|
159
|
+
can be captured with a dropped leading/trailing character (e.g. "ontinue" for "Continue"). Such a
|
|
160
|
+
one-word near-copy is too short for the word-overlap / containment checks above (which need ≥3
|
|
161
|
+
words), so it would leak in as a spurious extra ## User heading. For short prompts (≤2 words),
|
|
162
|
+
treat them as the same when one's normalized text is a substring of the other's AND covers most
|
|
163
|
+
of it (≥0.7) — which catches a dropped edge character without matching unrelated short words."""
|
|
164
|
+
a, b = _norm(pending).lower(), _norm(prompt).lower()
|
|
165
|
+
if not a or not b or len(_TOKEN_RE.findall(a)) > 2 or len(_TOKEN_RE.findall(b)) > 2:
|
|
166
|
+
return False
|
|
167
|
+
short, long = (a, b) if len(a) <= len(b) else (b, a)
|
|
168
|
+
return len(short) >= 3 and short in long and len(short) / len(long) >= 0.7
|
|
169
|
+
|
|
170
|
+
|
|
156
171
|
class CommitEngine:
|
|
157
172
|
"""Stateless agent-commit engine bound to a ``(repo, state)`` pair.
|
|
158
173
|
|
|
@@ -270,7 +285,8 @@ class CommitEngine:
|
|
|
270
285
|
# Each message the user queued mid-turn gets its OWN ## User heading (it was sent
|
|
271
286
|
# after the agent had already said something), not merged into the base prompt.
|
|
272
287
|
for followup in turn.queued_followups:
|
|
273
|
-
|
|
288
|
+
if followup.strip():
|
|
289
|
+
self.state.append_trace("user", followup)
|
|
274
290
|
for message in self._agent_messages_for(turn):
|
|
275
291
|
self.state.append_trace("agent", message)
|
|
276
292
|
self.state.add_token_usage(turn.tokens)
|
|
@@ -297,6 +313,8 @@ class CommitEngine:
|
|
|
297
313
|
# A mid-turn queued message gets its own ## User heading (sent after the agent
|
|
298
314
|
# already responded), rather than being merged into the base prompt.
|
|
299
315
|
for followup in turn.queued_followups:
|
|
316
|
+
if not followup.strip():
|
|
317
|
+
continue
|
|
300
318
|
subject_prompts.append(followup)
|
|
301
319
|
entries.append(("user", followup))
|
|
302
320
|
for message in self._agent_messages_for(turn):
|
|
@@ -320,7 +338,9 @@ class CommitEngine:
|
|
|
320
338
|
# (_same_prompt) OR because the turn aggregated it as a queued follow-up so the
|
|
321
339
|
# base is CONTAINED in the (longer) combined turn prompt (_prompt_covered_by).
|
|
322
340
|
if any(
|
|
323
|
-
_same_prompt(pending_user, prompt)
|
|
341
|
+
_same_prompt(pending_user, prompt)
|
|
342
|
+
or _prompt_covered_by(pending_user, prompt)
|
|
343
|
+
or _garbled_short_duplicate(pending_user, prompt)
|
|
324
344
|
for prompt in turn_prompts
|
|
325
345
|
):
|
|
326
346
|
continue
|
|
@@ -766,7 +786,7 @@ class CommitEngine:
|
|
|
766
786
|
slash command). Slash commands (``/compact``, ``/model``, …) are backend
|
|
767
787
|
directives, not prompts, and are kept out of the trace — see
|
|
768
788
|
:func:`_is_slash_command`."""
|
|
769
|
-
if prompt_text and not _is_slash_command(prompt_text):
|
|
789
|
+
if prompt_text and prompt_text.strip() and not _is_slash_command(prompt_text):
|
|
770
790
|
self.state.append_trace("user", prompt_text)
|
|
771
791
|
|
|
772
792
|
def await_followup(self, prompt_text: str, awaited: list[str]) -> list[str]:
|
|
@@ -120,20 +120,27 @@ class PromptModal:
|
|
|
120
120
|
if data == b"\x1b":
|
|
121
121
|
return ("cancel", None)
|
|
122
122
|
|
|
123
|
+
# Only redraw when something actually changed. Mouse-motion reports (and other dropped
|
|
124
|
+
# sequences) arrive as escape sequences here; without this a moving mouse would return
|
|
125
|
+
# "redraw" for every report and repaint the popup dozens of times a second (title flash +
|
|
126
|
+
# slowdown). Such no-op input returns "noop" so the caller keeps reading without repainting.
|
|
127
|
+
changed = False
|
|
123
128
|
for byte in data:
|
|
124
129
|
char = bytes([byte])
|
|
125
130
|
|
|
126
131
|
# Inside an escape sequence: accumulate until complete. PgUp/PgDn scroll the
|
|
127
|
-
# detail list; any other complete sequence (arrows, etc.) is dropped.
|
|
132
|
+
# detail list; any other complete sequence (arrows, mouse reports, etc.) is dropped.
|
|
128
133
|
if self._escape_buffer is not None:
|
|
129
134
|
self._escape_buffer.extend(char)
|
|
130
135
|
sequence = bytes(self._escape_buffer)
|
|
131
136
|
if sequence == b"\x1b[5~": # PageUp — scroll the detail list up
|
|
132
137
|
self.detail_scroll = max(0, self.detail_scroll - max(1, self._detail_window() - 1))
|
|
133
138
|
self._escape_buffer = None
|
|
139
|
+
changed = True
|
|
134
140
|
elif sequence == b"\x1b[6~": # PageDown — scroll the detail list down
|
|
135
141
|
self.detail_scroll += max(1, self._detail_window() - 1)
|
|
136
142
|
self._escape_buffer = None
|
|
143
|
+
changed = True
|
|
137
144
|
elif _escape_sequence_complete(sequence):
|
|
138
145
|
self._escape_buffer = None
|
|
139
146
|
continue
|
|
@@ -150,10 +157,12 @@ class PromptModal:
|
|
|
150
157
|
|
|
151
158
|
if char in {b"\x7f", b"\b"}:
|
|
152
159
|
self.value = self.value[:-1]
|
|
160
|
+
changed = True
|
|
153
161
|
elif byte >= 32:
|
|
154
162
|
self.value += char.decode(errors="ignore")
|
|
163
|
+
changed = True
|
|
155
164
|
|
|
156
|
-
return ("redraw", None)
|
|
165
|
+
return ("redraw", None) if changed else ("noop", None)
|
|
157
166
|
|
|
158
167
|
|
|
159
168
|
class SelectModal:
|
|
@@ -281,6 +290,10 @@ class SelectModal:
|
|
|
281
290
|
if data == b"\x1b":
|
|
282
291
|
return ("cancel", None)
|
|
283
292
|
|
|
293
|
+
# See PromptModal.feed: return "redraw" only on a real change so a moving mouse (whose
|
|
294
|
+
# motion reports arrive here as dropped escape sequences) doesn't repaint the menu on
|
|
295
|
+
# every report — which flashed the title and slowed everything down.
|
|
296
|
+
changed = False
|
|
284
297
|
for byte in data:
|
|
285
298
|
char = bytes([byte])
|
|
286
299
|
|
|
@@ -290,15 +303,19 @@ class SelectModal:
|
|
|
290
303
|
if sequence == b"\x1b[A":
|
|
291
304
|
self._advance(-1)
|
|
292
305
|
self._escape_buffer = None
|
|
306
|
+
changed = True
|
|
293
307
|
elif sequence == b"\x1b[B":
|
|
294
308
|
self._advance(1)
|
|
295
309
|
self._escape_buffer = None
|
|
310
|
+
changed = True
|
|
296
311
|
elif sequence == b"\x1b[5~": # PageUp — scroll the detail list up
|
|
297
312
|
self.detail_scroll = max(0, self.detail_scroll - max(1, self._detail_window() - 1))
|
|
298
313
|
self._escape_buffer = None
|
|
314
|
+
changed = True
|
|
299
315
|
elif sequence == b"\x1b[6~": # PageDown — scroll the detail list down
|
|
300
316
|
self.detail_scroll += max(1, self._detail_window() - 1)
|
|
301
317
|
self._escape_buffer = None
|
|
318
|
+
changed = True
|
|
302
319
|
elif _escape_sequence_complete(sequence):
|
|
303
320
|
self._escape_buffer = None
|
|
304
321
|
continue
|
|
@@ -313,4 +330,4 @@ class SelectModal:
|
|
|
313
330
|
if char in {b"\r", b"\n"}:
|
|
314
331
|
return ("done", self.options[self.selected])
|
|
315
332
|
|
|
316
|
-
return ("redraw", None)
|
|
333
|
+
return ("redraw", None) if changed else ("noop", None)
|