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.
Files changed (152) hide show
  1. {agitrack-0.2.2 → agitrack-0.2.4}/PKG-INFO +3 -3
  2. {agitrack-0.2.2 → agitrack-0.2.4}/README.md +2 -2
  3. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/commits/message.py +16 -10
  4. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/config/state.py +19 -0
  5. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/git/repo.py +19 -0
  6. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/metrics/collect.py +42 -18
  7. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/metrics/server.py +5 -1
  8. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/metrics/web.py +110 -5
  9. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proc.py +11 -1
  10. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/commit_engine.py +23 -3
  11. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/modal.py +20 -3
  12. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/runner.py +96 -19
  13. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack.egg-info/PKG-INFO +3 -3
  14. {agitrack-0.2.2 → agitrack-0.2.4}/pyproject.toml +1 -1
  15. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_commit_engine.py +20 -0
  16. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_commit_message.py +14 -0
  17. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_dashboard.py +69 -1
  18. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_proc.py +7 -5
  19. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_proxy.py +53 -9
  20. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_proxy_modal.py +53 -3
  21. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_state.py +25 -0
  22. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_summary_async.py +80 -0
  23. {agitrack-0.2.2 → agitrack-0.2.4}/LICENSE +0 -0
  24. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/__init__.py +0 -0
  25. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/__main__.py +0 -0
  26. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/backends/__init__.py +0 -0
  27. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/backends/base.py +0 -0
  28. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/backends/claude.py +0 -0
  29. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/backends/opencode.py +0 -0
  30. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/backends/proxy_agents.py +0 -0
  31. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/backends/setup.py +0 -0
  32. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/cli.py +0 -0
  33. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/commits/__init__.py +0 -0
  34. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/commits/actions.py +0 -0
  35. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/commits/manual.py +0 -0
  36. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/config/__init__.py +0 -0
  37. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/config/migrate.py +0 -0
  38. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/config/settings.py +0 -0
  39. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/env.py +0 -0
  40. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/events.py +0 -0
  41. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/git/__init__.py +0 -0
  42. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/git/hooks.py +0 -0
  43. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/git/lock.py +0 -0
  44. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/git/worktree.py +0 -0
  45. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/metrics/__init__.py +0 -0
  46. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/metrics/daemon.py +0 -0
  47. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/metrics/github.py +0 -0
  48. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/metrics/render.py +0 -0
  49. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/__init__.py +0 -0
  50. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/background.py +0 -0
  51. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/host_prompt.py +0 -0
  52. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/integration.py +0 -0
  53. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/platform/__init__.py +0 -0
  54. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/platform/_conpty.py +0 -0
  55. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/platform/_winconsole.py +0 -0
  56. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/platform/base.py +0 -0
  57. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/platform/nt.py +0 -0
  58. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/platform/posix.py +0 -0
  59. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/process.py +0 -0
  60. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/pty_backend.py +0 -0
  61. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/renderer.py +0 -0
  62. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/sandbox.py +0 -0
  63. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/session.py +0 -0
  64. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/session_names.py +0 -0
  65. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/proxy/terminal.py +0 -0
  66. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/recovery.py +0 -0
  67. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/sessions/__init__.py +0 -0
  68. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/sessions/identity.py +0 -0
  69. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/sessions/redact.py +0 -0
  70. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/sessions/share_cap.py +0 -0
  71. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/sessions/store.py +0 -0
  72. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/shell/__init__.py +0 -0
  73. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/shell/bridge.py +0 -0
  74. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/shell/runner.py +0 -0
  75. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/shell/ui.py +0 -0
  76. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/summaries/__init__.py +0 -0
  77. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/summaries/model_select.py +0 -0
  78. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/summaries/prompts.py +0 -0
  79. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/summaries/summarizer.py +0 -0
  80. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/system_tools.py +0 -0
  81. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/transcripts/__init__.py +0 -0
  82. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/transcripts/claude.py +0 -0
  83. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/transcripts/opencode.py +0 -0
  84. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/transcripts/types.py +0 -0
  85. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/update/__init__.py +0 -0
  86. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/update/marker.py +0 -0
  87. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack/update/updater.py +0 -0
  88. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack.egg-info/SOURCES.txt +0 -0
  89. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack.egg-info/dependency_links.txt +0 -0
  90. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack.egg-info/entry_points.txt +0 -0
  91. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack.egg-info/requires.txt +0 -0
  92. {agitrack-0.2.2 → agitrack-0.2.4}/agitrack.egg-info/top_level.txt +0 -0
  93. {agitrack-0.2.2 → agitrack-0.2.4}/setup.cfg +0 -0
  94. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_backend_commits.py +0 -0
  95. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_backend_process.py +0 -0
  96. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_backend_setup.py +0 -0
  97. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_backends_and_config.py +0 -0
  98. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_backends_setup.py +0 -0
  99. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_background.py +0 -0
  100. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_bridge.py +0 -0
  101. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_claude_session.py +0 -0
  102. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_cli.py +0 -0
  103. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_commit_graph_resilience.py +0 -0
  104. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_commit_message_summary.py +0 -0
  105. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_commits_actions.py +0 -0
  106. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_config_application.py +0 -0
  107. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_config_migrate.py +0 -0
  108. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_copy_to_base.py +0 -0
  109. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_dashboard_daemon.py +0 -0
  110. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_demo_script.py +0 -0
  111. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_events.py +0 -0
  112. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_flows.py +0 -0
  113. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_git_commit_encoding.py +0 -0
  114. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_git_hooks.py +0 -0
  115. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_git_notes.py +0 -0
  116. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_integration_service.py +0 -0
  117. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_lock.py +0 -0
  118. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_main_module.py +0 -0
  119. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_manual_commits.py +0 -0
  120. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_mode_switching.py +0 -0
  121. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_model_select.py +0 -0
  122. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_opencode_commands.py +0 -0
  123. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_opencode_parse.py +0 -0
  124. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_opencode_session.py +0 -0
  125. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_pre_compaction.py +0 -0
  126. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_proxy_dashboard.py +0 -0
  127. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_proxy_session.py +0 -0
  128. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_pty_backend.py +0 -0
  129. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_pty_render.py +0 -0
  130. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_recovery.py +0 -0
  131. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_recovery_report.py +0 -0
  132. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_rename_compat.py +0 -0
  133. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_sandbox.py +0 -0
  134. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_session_names.py +0 -0
  135. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_session_sharing.py +0 -0
  136. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_settings.py +0 -0
  137. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_shell.py +0 -0
  138. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_shutdown_request.py +0 -0
  139. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_summarizer.py +0 -0
  140. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_summarizer_config.py +0 -0
  141. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_summary_scratch.py +0 -0
  142. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_system_tools.py +0 -0
  143. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_terminal_compat.py +0 -0
  144. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_ui.py +0 -0
  145. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_update.py +0 -0
  146. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_update_marker.py +0 -0
  147. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_updater.py +0 -0
  148. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_version.py +0 -0
  149. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_version_sync.py +0 -0
  150. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_windows_conpty.py +0 -0
  151. {agitrack-0.2.2 → agitrack-0.2.4}/tests/test_windows_platform.py +0 -0
  152. {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.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 jump to the commit on GitHub.
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** with a link to the commit on GitHub, and 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).
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 jump to the commit on GitHub.
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** with a link to the commit on GitHub, and 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).
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
- role = item.get("role", "").strip().lower()
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
- role = item.get("role", "").strip().lower()
320
- # Nest the message's own headings under the "## User"/"## Agent" heading so
321
- # a message's "# Title" can't outrank the role it belongs to in the log.
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
- stats = _drop_inherited_metadata(repo, ref, stats)
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 _drop_inherited_metadata(repo: GitRepo, ref: str, stats: list[CommitStat]) -> list[CommitStat]:
640
- """Drop commits that merely inherit a parent's aGiTrack metadata block.
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. We keep the original (the parent)
652
- and discard the inheriting merge. Restricting to merges is what separates this
653
- from a genuine run of repeated turns, which is linear each such commit has a
654
- single parent, so an identical neighbouring block is a real (repeated) turn,
655
- not a copy. Cover commits and integration merges are themselves merge-shaped
656
- but never share a parent's block, so they are untouched."""
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 placetokens, 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 stats
663
+ return
660
664
  parents = _parents(repo, ref)
661
- duplicates = {
662
- stat.sha
663
- for stat in stats
664
- if stat.metadata_block
665
- and len(parents.get(stat.sha, ())) > 1
666
- and any(block_by_sha.get(parent) == stat.metadata_block for parent in parents[stat.sha])
667
- }
668
- return [stat for stat in stats if stat.sha not in duplicates]
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>${partsHtml(c.parts)}`;
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)||"&nbsp;")+'</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. On POSIX there is no console coupling, so only the stdin detach (harmless) applies.
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
- self.state.append_trace("user", followup)
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) or _prompt_covered_by(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)