collins 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. collins/THIRD_PARTY_LICENSES.md +87 -0
  2. collins/__init__.py +12 -0
  3. collins/__main__.py +6 -0
  4. collins/activity.py +541 -0
  5. collins/animatedimage.py +156 -0
  6. collins/app.py +2381 -0
  7. collins/apppicker.py +112 -0
  8. collins/attachpanel.py +753 -0
  9. collins/attachrecords.py +731 -0
  10. collins/avatars.py +105 -0
  11. collins/bgstatus.py +299 -0
  12. collins/bodyimages.py +154 -0
  13. collins/buildinfo.py +118 -0
  14. collins/caffeine.py +209 -0
  15. collins/chatbubbles.py +78 -0
  16. collins/chats.py +204 -0
  17. collins/chatsession.py +272 -0
  18. collins/chatsessionview.py +400 -0
  19. collins/claudemodels.py +613 -0
  20. collins/clisetup.py +198 -0
  21. collins/cliwelcome.py +255 -0
  22. collins/com.episode6.Collins.desktop +17 -0
  23. collins/com.episode6.Collins.metainfo.xml +167 -0
  24. collins/composer.py +616 -0
  25. collins/composerkeys.py +176 -0
  26. collins/copylabel.py +149 -0
  27. collins/desktopentry.py +178 -0
  28. collins/dialogs.py +987 -0
  29. collins/docktree.py +201 -0
  30. collins/dockzones.py +99 -0
  31. collins/dropimages.py +220 -0
  32. collins/editor.py +1390 -0
  33. collins/editorfiles.py +782 -0
  34. collins/editorwindow.py +151 -0
  35. collins/fileclipboard.py +153 -0
  36. collins/filetree.py +443 -0
  37. collins/filetypes.py +245 -0
  38. collins/flash.py +44 -0
  39. collins/footerapps.py +92 -0
  40. collins/formatting.py +422 -0
  41. collins/fuzzy.py +84 -0
  42. collins/ghsetup.py +64 -0
  43. collins/ghwelcome.py +372 -0
  44. collins/gitinfo.py +319 -0
  45. collins/i18n.py +52 -0
  46. collins/icongen.py +249 -0
  47. collins/icons/com.episode6.Collins-panel.svg +48 -0
  48. collins/icons/com.episode6.Collins.svg +23 -0
  49. collins/icons/hicolor/scalable/actions/agent-claude-symbolic.svg +14 -0
  50. collins/icons/hicolor/scalable/actions/alert-fill-symbolic.svg +32 -0
  51. collins/icons/hicolor/scalable/actions/alert-symbolic.svg +30 -0
  52. collins/icons/hicolor/scalable/actions/archive-symbolic.svg +11 -0
  53. collins/icons/hicolor/scalable/actions/caffeine-cup-empty-symbolic.svg +11 -0
  54. collins/icons/hicolor/scalable/actions/caffeine-cup-full-symbolic.svg +15 -0
  55. collins/icons/hicolor/scalable/actions/chat-bubble-symbolic.svg +14 -0
  56. collins/icons/hicolor/scalable/actions/check-circle-fill-symbolic.svg +31 -0
  57. collins/icons/hicolor/scalable/actions/circle-fill-symbolic.svg +7 -0
  58. collins/icons/hicolor/scalable/actions/dock-bottom-symbolic.svg +11 -0
  59. collins/icons/hicolor/scalable/actions/dock-right-symbolic.svg +13 -0
  60. collins/icons/hicolor/scalable/actions/ft-book-symbolic.svg +30 -0
  61. collins/icons/hicolor/scalable/actions/ft-code-symbolic.svg +30 -0
  62. collins/icons/hicolor/scalable/actions/ft-container-symbolic.svg +30 -0
  63. collins/icons/hicolor/scalable/actions/ft-database-symbolic.svg +30 -0
  64. collins/icons/hicolor/scalable/actions/ft-diff-ignored-symbolic.svg +30 -0
  65. collins/icons/hicolor/scalable/actions/ft-file-binary-symbolic.svg +30 -0
  66. collins/icons/hicolor/scalable/actions/ft-file-code-symbolic.svg +30 -0
  67. collins/icons/hicolor/scalable/actions/ft-file-symbolic.svg +30 -0
  68. collins/icons/hicolor/scalable/actions/ft-file-zip-symbolic.svg +30 -0
  69. collins/icons/hicolor/scalable/actions/ft-gear-symbolic.svg +30 -0
  70. collins/icons/hicolor/scalable/actions/ft-image-symbolic.svg +30 -0
  71. collins/icons/hicolor/scalable/actions/ft-law-symbolic.svg +30 -0
  72. collins/icons/hicolor/scalable/actions/ft-lock-symbolic.svg +30 -0
  73. collins/icons/hicolor/scalable/actions/ft-markdown-symbolic.svg +30 -0
  74. collins/icons/hicolor/scalable/actions/ft-package-symbolic.svg +30 -0
  75. collins/icons/hicolor/scalable/actions/ft-table-symbolic.svg +30 -0
  76. collins/icons/hicolor/scalable/actions/ft-terminal-symbolic.svg +30 -0
  77. collins/icons/hicolor/scalable/actions/ft-video-symbolic.svg +30 -0
  78. collins/icons/hicolor/scalable/actions/git-merge-symbolic.svg +30 -0
  79. collins/icons/hicolor/scalable/actions/git-pull-request-closed-symbolic.svg +30 -0
  80. collins/icons/hicolor/scalable/actions/git-pull-request-draft-symbolic.svg +30 -0
  81. collins/icons/hicolor/scalable/actions/git-pull-request-symbolic.svg +30 -0
  82. collins/icons/hicolor/scalable/actions/github-symbolic.svg +31 -0
  83. collins/icons/hicolor/scalable/actions/tab-close-symbolic.svg +12 -0
  84. collins/icons/hicolor/scalable/actions/unarchive-symbolic.svg +11 -0
  85. collins/icons/hicolor/scalable/actions/undock-bottom-symbolic.svg +11 -0
  86. collins/icons/hicolor/scalable/actions/undock-right-symbolic.svg +11 -0
  87. collins/icons/hicolor/scalable/actions/x-circle-fill-symbolic.svg +31 -0
  88. collins/keybindings.py +388 -0
  89. collins/keybindingsdialog.py +277 -0
  90. collins/keymap.py +124 -0
  91. collins/licenses.py +75 -0
  92. collins/lightbox.py +772 -0
  93. collins/linkpatterns.py +394 -0
  94. collins/locale/de/LC_MESSAGES/collins.mo +0 -0
  95. collins/locale/es/LC_MESSAGES/collins.mo +0 -0
  96. collins/locale/fr/LC_MESSAGES/collins.mo +0 -0
  97. collins/locale/hu/LC_MESSAGES/collins.mo +0 -0
  98. collins/mcp_shim.py +275 -0
  99. collins/mcpserver.py +253 -0
  100. collins/mcptools.py +803 -0
  101. collins/modelmenu.py +133 -0
  102. collins/models.py +77 -0
  103. collins/openwith.py +290 -0
  104. collins/panedsizer.py +254 -0
  105. collins/paneldnd.py +431 -0
  106. collins/paneldock.py +1871 -0
  107. collins/panelhistory.py +164 -0
  108. collins/panelkeys.py +50 -0
  109. collins/panellayout.py +240 -0
  110. collins/panelsizing.py +144 -0
  111. collins/panelstrip.py +646 -0
  112. collins/pictures.py +249 -0
  113. collins/pkgrepos.py +175 -0
  114. collins/practions.py +764 -0
  115. collins/prattach.py +133 -0
  116. collins/prblobs.py +213 -0
  117. collins/prdetail.py +785 -0
  118. collins/prefs.py +1564 -0
  119. collins/prefssearch.py +19 -0
  120. collins/prfileimages.py +218 -0
  121. collins/prmenu.py +855 -0
  122. collins/proctree.py +179 -0
  123. collins/projecticons.py +122 -0
  124. collins/providers.py +923 -0
  125. collins/prstatus.py +1724 -0
  126. collins/prstore.py +149 -0
  127. collins/prview.py +2552 -0
  128. collins/quickopen.py +229 -0
  129. collins/remotearchive.py +210 -0
  130. collins/remoteimages.py +268 -0
  131. collins/replaymodel.py +120 -0
  132. collins/replayview.py +157 -0
  133. collins/scrolling.py +59 -0
  134. collins/sessions.py +763 -0
  135. collins/shellinput.py +39 -0
  136. collins/sidebar.py +2764 -0
  137. collins/state.py +896 -0
  138. collins/statusicon.py +1006 -0
  139. collins/store.py +907 -0
  140. collins/svgtexture.py +123 -0
  141. collins/switcher.py +137 -0
  142. collins/tabguard.py +123 -0
  143. collins/taborder.py +40 -0
  144. collins/terminal.py +5345 -0
  145. collins/themes.py +237 -0
  146. collins/titles.py +441 -0
  147. collins/tooltipmute.py +164 -0
  148. collins/transcript.py +353 -0
  149. collins/transcriptlinks.py +178 -0
  150. collins/traymodel.py +301 -0
  151. collins/trust.py +149 -0
  152. collins/usage.py +234 -0
  153. collins/usagepanel.py +408 -0
  154. collins/vtehtml.py +87 -0
  155. collins/window.py +5636 -0
  156. collins-0.1.1.dist-info/METADATA +234 -0
  157. collins-0.1.1.dist-info/RECORD +162 -0
  158. collins-0.1.1.dist-info/WHEEL +5 -0
  159. collins-0.1.1.dist-info/entry_points.txt +2 -0
  160. collins-0.1.1.dist-info/licenses/LICENSE +674 -0
  161. collins-0.1.1.dist-info/licenses/NOTICE +27 -0
  162. collins-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,87 @@
1
+ # Third-party license notices
2
+
3
+ Collins is free software licensed under the GNU General Public License v3.0 or later
4
+ ([LICENSE](https://github.com/episode6/collins/blob/main/LICENSE)). It is a fork of, and
5
+ is built with, the third-party components below. The app ships this document and shows it
6
+ on the **Legal** page of its About dialog.
7
+
8
+ ## Upstream project
9
+
10
+ Collins is a fork of **agent-session-manager** — © Máté Molnár —
11
+ [r4nd3l/agent-session-manager](https://github.com/r4nd3l/agent-session-manager) — used
12
+ under the **GNU General Public License v3.0**. Files inherited from it carry an in-file
13
+ modification notice, and comment-less files that changed are listed in `NOTICE` at the
14
+ repo root. The interface translations under `po/` also originate there.
15
+
16
+ ## GNOME platform libraries
17
+
18
+ Collins bundles none of these — they are loaded at runtime from your system packages, and
19
+ you are free to replace them with your own builds:
20
+
21
+ - **GTK 4** — © The GTK Team — LGPL-2.1-or-later — [gtk.org](https://www.gtk.org)
22
+ - **libadwaita** — © GNOME contributors — LGPL-2.1-or-later —
23
+ [GNOME/libadwaita](https://gitlab.gnome.org/GNOME/libadwaita)
24
+ - **VTE** (`vte-2.91-gtk4`, the embedded terminal widget) — © the VTE authors —
25
+ LGPL-3.0-or-later — [GNOME/vte](https://gitlab.gnome.org/GNOME/vte)
26
+ - **GLib / GObject / GIO**, **Pango** and **gdk-pixbuf** — © the GNOME Project —
27
+ LGPL-2.1-or-later — [gtk.org](https://www.gtk.org)
28
+ - **cairo** — © the cairo authors — LGPL-2.1 or MPL-1.1 —
29
+ [cairographics.org](https://www.cairographics.org)
30
+ - **PyGObject**, the Python bindings for all of the above — © PyGObject contributors —
31
+ LGPL-2.1-or-later — [pygobject.gnome.org](https://pygobject.gnome.org)
32
+ - **Python** and its standard library — © the Python Software Foundation — PSF License
33
+ Agreement — [python.org](https://www.python.org)
34
+
35
+ ## Icons
36
+
37
+ Most icons are named icons resolved at runtime from the system icon theme, typically the
38
+ **Adwaita icon theme** — © the GNOME Project — CC-BY-SA-3.0 or LGPL-3.0 —
39
+ [GNOME/adwaita-icon-theme](https://gitlab.gnome.org/GNOME/adwaita-icon-theme). The
40
+ symbolic icons bundled under `data/icons` are either original artwork for this fork or
41
+ derived from agent-session-manager (GPL-3.0), except the ones used unmodified from
42
+ **Octicons** — © GitHub, Inc. — MIT License —
43
+ [primer/octicons](https://github.com/primer/octicons): `alert-symbolic`,
44
+ `alert-fill-symbolic`, `check-circle-fill-symbolic`, `x-circle-fill-symbolic`,
45
+ `git-merge-symbolic`, `git-pull-request-symbolic`,
46
+ `git-pull-request-draft-symbolic`, `git-pull-request-closed-symbolic` and
47
+ `github-symbolic`, so that a pull request's state and status marks — merged, open,
48
+ draft or closed, with the check, warning or error riding its corner — read the same
49
+ here as on the site they came from; and the file tree's
50
+ file-type set, `ft-*-symbolic` (each SVG names its source Octicon in its header
51
+ comment). The Collins app icon is original artwork.
52
+
53
+ ## Terminal color schemes
54
+
55
+ The built-in terminal palettes reproduce color values from the schemes below. No code is
56
+ taken from any of them — only the published colors.
57
+
58
+ - **Solarized** — © Ethan Schoonover — MIT License —
59
+ [altercation/solarized](https://github.com/altercation/solarized)
60
+ - **Dracula** — © Dracula Theme — MIT License — [draculatheme.com](https://draculatheme.com)
61
+ - **Gruvbox** — © Pavel Pertsev — MIT License —
62
+ [morhetz/gruvbox](https://github.com/morhetz/gruvbox)
63
+ - **Nord** — © Sven Greb and the Nord contributors — MIT License —
64
+ [nordtheme.com](https://www.nordtheme.com)
65
+ - **Catppuccin** — © Catppuccin — MIT License — [catppuccin.com](https://catppuccin.com)
66
+ - **Tokyo Night** — © enkia — MIT License —
67
+ [enkia/tokyo-night-vscode-theme](https://github.com/enkia/tokyo-night-vscode-theme)
68
+ - **One Dark** — © GitHub, Inc., from Atom's One Dark syntax theme — MIT License —
69
+ [atom/atom](https://github.com/atom/atom)
70
+ - **Tango** — the Tango Desktop Project palette, published for unrestricted use —
71
+ [Tango Desktop Project](https://en.wikipedia.org/wiki/Tango_Desktop_Project)
72
+ - **Monokai** — the palette popularized by Wimer Hazenberg's Monokai theme; its color
73
+ values are widely reproduced and no separate license is claimed over them.
74
+
75
+ ## Claude Code
76
+
77
+ Collins drives the **Claude Code** CLI, which it neither bundles nor redistributes: it
78
+ runs whatever `claude` it finds on your `PATH`, under Anthropic's own terms
79
+ ([claude.com/claude-code](https://claude.com/claude-code)). Claude, Claude Code and
80
+ Anthropic are trademarks of Anthropic PBC. Collins is an unofficial community tool, not
81
+ affiliated with or endorsed by Anthropic.
82
+
83
+ ## Documentation site
84
+
85
+ The docs under `docs/` are built with **VitePress** — © Yuxi (Evan) You and VitePress
86
+ contributors — MIT License — [vitepress.dev](https://vitepress.dev). It is a
87
+ development-time dependency only, and is not part of the installed application.
collins/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ # Modified from the original agent-session-manager
2
+ # (https://github.com/r4nd3l/agent-session-manager, GPL-3.0) in the ghackett
3
+ # fork. Last modified: 2026-08-18. Full change history: git log for this file.
4
+ """Collins — native GTK4 GUI to manage and resume AI coding agent sessions."""
5
+
6
+ __version__ = "0.1.1"
7
+
8
+ # The two app ids a user's real instances run under. The debug id doubles as
9
+ # the debug build's icon name, and as a prefix: any COLLINS_APP_ID derived
10
+ # from it (com.episode6.Collins.Debug.*) counts as a debug instance too.
11
+ APP_ID = "com.episode6.Collins"
12
+ DEBUG_APP_ID = "com.episode6.Collins.Debug"
collins/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ # Modified from the original agent-session-manager
2
+ # (https://github.com/r4nd3l/agent-session-manager, GPL-3.0) in the ghackett
3
+ # fork. Last modified: 2026-07-26. Full change history: git log for this file.
4
+ from .app import main
5
+
6
+ raise SystemExit(main())
collins/activity.py ADDED
@@ -0,0 +1,541 @@
1
+ # Original to the ghackett fork of agent-session-manager
2
+ # (https://github.com/r4nd3l/agent-session-manager, GPL-3.0): this file has no
3
+ # upstream version, so it carries no modification notice. Licensed GPL-3.0
4
+ # with the rest of the project.
5
+
6
+ """Which sessions are working *right now*, so the sidebar can say so.
7
+
8
+ A session's status says where it is running — in a tab, or detached — but not
9
+ whether the agent is doing anything. That is what the barber pole on a row's
10
+ guide line answers, and it needs a signal that says "output is flowing" and,
11
+ harder, "output stopped".
12
+
13
+ One source is the agent's own word — Claude Code announces its busy state
14
+ through the terminal as ConEmu-style OSC 9;4 progress sequences, which VTE
15
+ parses into a *termprop* on the tab's terminal (see `ProgressWatch`). Every
16
+ other source is inferred, and stays wired as the fallback for a CLI (or a
17
+ configuration) that doesn't speak progress:
18
+
19
+ - a **tab**'s terminal emits ``contents-changed`` on every redraw, which the
20
+ window feeds to `ActivityTracker.mark`. It is noisy by design — a spinner
21
+ frame counts — but silence is what matters, and a prompt sitting idle is
22
+ silent. `EchoGate` throws out the redraws the *app* caused, which are
23
+ otherwise indistinguishable from a working agent.
24
+ - an open tab whose terminal has gone quiet may still have a **background
25
+ process** running below the agent — a dev server, a long build — that
26
+ produces no terminal output of its own. The window polls each open tab's
27
+ process tree (`proctree.has_live_descendant`) and marks the session for as
28
+ long as one is found.
29
+ - a tab's screen also shows the agent's own **thinking spinner** while it
30
+ works, and `SpinnerWatch` reads it — not by matching its glyphs, but by
31
+ noticing first-column motion between samples of the visible screen. It is
32
+ the one tab source that needs no gate: it can start a pole `EchoGate`
33
+ would have to wave through first, such as a freshly attached background
34
+ agent mid-turn that nothing was ever typed at. (On a tab whose CLI was
35
+ *spawned* rather than attached, no such agent can exist, and the window
36
+ holds the ungated sources through the startup paint — see
37
+ `EchoGate.armed`.)
38
+
39
+ A tab attached to a **background agent** has none of the above worth having:
40
+ the agent is the daemon's child, spawned with a scrubbed environment, so it
41
+ never sees the terminal declarations that make the CLI emit progress at all
42
+ (see `terminal._agent_tab_environment`) — no termprop, ever. Its turn starts
43
+ without a keystroke in the tab, which also leaves `EchoGate` unarmed and every
44
+ relayed redraw discounted. That leaves the spinner it happens to be painting.
45
+ `BackgroundBusyWatch` gives it back the agent's own word from the other
46
+ direction: the agent list the app already polls reports a per-job busy status,
47
+ and the window feeds it in for background agents with a tab open.
48
+
49
+ All of them funnel into one tracker: "busy" means output seen within the
50
+ source's idle window — `IDLE_S` for a terminal, which redraws continuously
51
+ while its agent works, and `PROCESS_IDLE_S` for a process-tree sighting.
52
+ Detached (`/bg`) sessions have no source at all: their only signal would be
53
+ transcript growth, and the pole that once rode it is gone — a detached row
54
+ shows its still yellow guide line whatever the agent is doing.
55
+
56
+ Nothing here touches GTK — the timer is injected — so the whole thing is
57
+ testable without a display.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import time
63
+ from collections.abc import Callable, Iterable
64
+
65
+ # How long output has to stop before a session reads as idle. Long enough to
66
+ # ride out the pauses inside a turn — an agent thinking between tool calls
67
+ # prints nothing for a beat — and short enough that a finished turn stops the
68
+ # pole while the user is still looking at the row. This is the window for a
69
+ # tab's terminal, which repaints continuously (spinner frames count) while
70
+ # its agent works.
71
+ IDLE_S = 2.0
72
+
73
+ # How often the sweep looks for sessions that went quiet. Only the moment a
74
+ # session stops being busy is this coarse; starting is immediate, on the first
75
+ # byte of output.
76
+ SWEEP_MS = 250
77
+
78
+ # How often an open tab's process tree is checked for something the agent left
79
+ # running below it — a background job (a dev server, a long build) that keeps
80
+ # no output flowing to the terminal, and so would otherwise read as idle the
81
+ # moment its last line scrolled by. Coarser than the sweep because it costs a
82
+ # handful of /proc reads per open tab.
83
+ PROCESS_POLL_MS = 2000
84
+
85
+ # The window a live descendant keeps a session's pole up for. Wider than
86
+ # PROCESS_POLL_MS so ordinary scheduling jitter never closes the gap between
87
+ # two sightings of the same still-running process.
88
+ PROCESS_IDLE_S = 5.0
89
+
90
+ # How often a tab's screen is read for spinner motion, at most. Sampling hangs
91
+ # off ``contents-changed``, so an idle terminal is never read at all; while the
92
+ # agent works its spinner repaints roughly every 100ms, and this decides which
93
+ # of those repaints get compared. Deliberately not a clean multiple of that
94
+ # frame clock: sampling exactly one glyph-cycle apart would see the same frame
95
+ # every time and read a live spinner as still.
96
+ SPINNER_SAMPLE_S = 0.35
97
+
98
+ # Two first-column changes at most this far apart read as animation; farther
99
+ # apart they are unrelated one-off repaints — a prompt redrawn now, a scroll a
100
+ # minute later — and starting a pole on those would undo what EchoGate is for.
101
+ # Wide enough (four samples) to ride out a coincidence sample: two frames of a
102
+ # live spinner that happened to show the same glyph.
103
+ SPINNER_STREAK_S = 4 * SPINNER_SAMPLE_S
104
+
105
+ # The idle window for a progress-termprop mark. The termprop is edge-triggered
106
+ # — one busy hint as a turn starts, one clear as it ends — not a heartbeat, so
107
+ # the window has to be able to ride out a long turn on its own. It is a safety
108
+ # net rather than the signal: the clear is what normally ends the pole, and
109
+ # this only catches a CLI killed too abruptly to send one (SIGKILL emits no
110
+ # final OSC). In practice redraw marks keep arriving alongside and refresh the
111
+ # deadline far more often than this anyway.
112
+ PROGRESS_IDLE_S = 60.0
113
+
114
+ # How long after a termprop finish a redraw may not *start* a new pole. The
115
+ # CLI announces a turn's end before its screen goes still — the prompt box
116
+ # repaints, the working indicator fades out — and those trailing redraws pass
117
+ # an armed EchoGate, which would blip the pole back up for IDLE_S right after
118
+ # the honest instant-down. A genuinely new turn is never held back: the Enter
119
+ # pre-mark and the next busy hint both bypass this window.
120
+ PROGRESS_QUIET_S = IDLE_S
121
+
122
+ # How often the agent list is asked which background agents are working. It
123
+ # shells out to the CLI (~0.4s of node startup, off the main thread), so the
124
+ # window only runs it while a background agent actually has a tab open — the
125
+ # only place the answer can show — and this is the resulting worst-case delay
126
+ # before such a session's pole comes up.
127
+ BACKGROUND_POLL_MS = 3000
128
+
129
+ # How long one "busy" reading keeps a background session's pole up. Wider than
130
+ # the poll interval plus the CLI call it waits on, so ordinary jitter never
131
+ # opens a gap between two readings of the same still-working agent. It is only
132
+ # the backstop: the reading that says the agent went idle ends the pole at
133
+ # once, the way a progress termprop's clear does.
134
+ BACKGROUND_IDLE_S = 10.0
135
+
136
+ # How long a redraw goes on counting as the answer to something the app sent.
137
+ # Measured on a live agent TUI, its reply lands 10-30ms after the bytes leave
138
+ # the terminal; a quarter second is slack for a loaded machine. Nothing real is
139
+ # lost by being generous: a turn that starts on the keystroke keeps producing
140
+ # output long after the window closes.
141
+ ECHO_S = 0.25
142
+
143
+
144
+ class ActivityTracker:
145
+ """The set of session ids whose agent is producing output right now.
146
+
147
+ `mark` is cheap and expected to be called at redraw frequency: it only
148
+ stamps a clock. The transition *out* of busy is found by a sweep that runs
149
+ only while something is busy, so an idle app schedules nothing at all.
150
+
151
+ *on_change* is called with (session_id, busy) on each transition, never for
152
+ a repeat mark.
153
+
154
+ *on_finished* is called (after on_change) only when a session's idle window
155
+ runs out — the agent's output genuinely stopped coming. An explicit clear()
156
+ is teardown, not a finish: its tab closed or its detach ended, and neither
157
+ should read as "a run just completed".
158
+ """
159
+
160
+ def __init__(
161
+ self,
162
+ on_change: Callable[[str, bool], None],
163
+ *,
164
+ on_finished: Callable[[str], None] | None = None,
165
+ idle_s: float = IDLE_S,
166
+ sweep_ms: int = SWEEP_MS,
167
+ clock: Callable[[], float] = time.monotonic,
168
+ add_timeout: Callable[[int, Callable[[], bool]], int] | None = None,
169
+ remove_timeout: Callable[[int], None] | None = None,
170
+ ) -> None:
171
+ self._on_change = on_change
172
+ self._on_finished = on_finished
173
+ self._idle_s = idle_s
174
+ self._sweep_ms = sweep_ms
175
+ self._clock = clock
176
+ self._add_timeout = add_timeout or _glib_add_timeout
177
+ self._remove_timeout = remove_timeout or _glib_remove_timeout
178
+ self._deadlines: dict[str, float] = {} # busy session id -> when it reads idle
179
+ self._sweep: int | None = None
180
+
181
+ def mark(self, session_id: str, *, idle_s: float | None = None) -> None:
182
+ """Record that *session_id* just produced output.
183
+
184
+ *idle_s* is how long this mark keeps the session busy, defaulting to
185
+ the tracker's window. A sparse source — a transcript that grows only
186
+ when a turn lands — passes a wider one than a terminal that redraws
187
+ continuously. The latest mark decides: a session whose signal changes
188
+ (its tab reopened, say) is on the new window from its next mark.
189
+ """
190
+ if not session_id:
191
+ return
192
+ fresh = session_id not in self._deadlines
193
+ window = self._idle_s if idle_s is None else idle_s
194
+ self._deadlines[session_id] = self._clock() + window
195
+ if fresh:
196
+ self._start_sweep()
197
+ self._on_change(session_id, True)
198
+
199
+ def clear(self, session_id: str) -> None:
200
+ """Drop *session_id* now, without waiting out the idle window — its tab
201
+ closed, or it stopped running detached, so there is nothing left to be
202
+ busy."""
203
+ if self._deadlines.pop(session_id, None) is None:
204
+ return
205
+ self._stop_sweep_if_idle()
206
+ self._on_change(session_id, False)
207
+
208
+ def finish(self, session_id: str) -> None:
209
+ """Drop *session_id* now *as a completed run*: the agent itself said
210
+ the turn is over (a progress termprop clear), so this is the sweep's
211
+ timeout finish without the wait — on_change, then on_finished — where
212
+ clear() is teardown and reports no finish at all. A session that
213
+ isn't busy has no run to complete: no-op, so the CLI's repeated
214
+ shutdown clears can't flag anything twice."""
215
+ if self._deadlines.pop(session_id, None) is None:
216
+ return
217
+ self._stop_sweep_if_idle()
218
+ self._on_change(session_id, False)
219
+ if self._on_finished is not None:
220
+ self._on_finished(session_id)
221
+
222
+ def is_busy(self, session_id: str) -> bool:
223
+ return session_id in self._deadlines
224
+
225
+ def busy(self) -> set[str]:
226
+ return set(self._deadlines)
227
+
228
+ def stop(self) -> None:
229
+ """Release the sweep timer (window teardown). Leaves no callbacks
230
+ pointing at a window that is going away."""
231
+ self._deadlines.clear()
232
+ self._stop_sweep_if_idle()
233
+
234
+ # -- the sweep ----------------------------------------------------------
235
+
236
+ def _start_sweep(self) -> None:
237
+ if self._sweep is None:
238
+ self._sweep = self._add_timeout(self._sweep_ms, self._on_sweep)
239
+
240
+ def _stop_sweep_if_idle(self) -> None:
241
+ if self._sweep is not None and not self._deadlines:
242
+ self._remove_timeout(self._sweep)
243
+ self._sweep = None
244
+
245
+ def _on_sweep(self) -> bool:
246
+ now = self._clock()
247
+ for session_id in [sid for sid, deadline in self._deadlines.items() if deadline <= now]:
248
+ del self._deadlines[session_id]
249
+ self._on_change(session_id, False)
250
+ if self._on_finished is not None:
251
+ self._on_finished(session_id)
252
+ if not self._deadlines:
253
+ self._sweep = None
254
+ return False # nothing left to time out; mark() starts it again
255
+ return True
256
+
257
+
258
+ class EchoGate:
259
+ """One terminal's "did the agent do this, or did we?" filter.
260
+
261
+ ``contents-changed`` says the visible terminal changed, not that the child
262
+ produced anything of its own. Three of the ways it fires have nothing to do
263
+ with the agent working, and all three are answers to the app:
264
+
265
+ - the user types. The agent renders every keystroke itself (it holds the
266
+ terminal in raw mode), so a keypress comes back as real child output.
267
+ - a tab is switched to. VTE reports the focus change to the child, which
268
+ redraws — on both terminals, the one being left and the one arrived at.
269
+ - the terminal is reflowed: a window resize, a panel divider drag, a font
270
+ zoom, or simply the first time a tab is shown at its real size. VTE
271
+ repaints with nothing at all having arrived from the child.
272
+
273
+ The first two are announced by VTE's ``commit`` — anything the app sends
274
+ the child, keystrokes and focus and mouse reports alike — so the window
275
+ reports them with `poked`. The third shows up as a different grid size than
276
+ the last redraw came at, which `counts` notices on its own.
277
+
278
+ The gate also starts *held*: a terminal nothing has ever been sent to has
279
+ no turn to be working on, yet its agent CLI paints a whole welcome screen
280
+ at spawn — a burst of real child output that would otherwise start a pole
281
+ on a tab the user only just opened. The first submit arms the gate for the
282
+ life of the tab: a carriage return in a commit (a typed Enter, an injected
283
+ prompt's send, a question card's answer — every way a turn starts goes
284
+ through the pty as one), or the window's own report of a bare Enter via
285
+ `arm`, which doesn't rely on what VTE encodes the key as.
286
+
287
+ Discounting only decides whether a pole may *start*: the window still marks
288
+ a session it already believes is working, so typing at an agent mid-turn
289
+ can never stall its pole.
290
+ """
291
+
292
+ def __init__(
293
+ self,
294
+ *,
295
+ quiet_s: float = ECHO_S,
296
+ clock: Callable[[], float] = time.monotonic,
297
+ ) -> None:
298
+ self._quiet_s = quiet_s
299
+ self._clock = clock
300
+ self._armed = False # something has been submitted; poles may start
301
+ self._poked_at: float | None = None
302
+ self._grid: tuple[int, int] | None = None # (columns, rows) at the last redraw
303
+
304
+ def arm(self) -> None:
305
+ """A turn was just asked for; redraws may mean work from here on."""
306
+ self._armed = True
307
+
308
+ @property
309
+ def armed(self) -> bool:
310
+ """Whether anything has ever been submitted to this terminal.
311
+
312
+ The window reads this to extend the startup hold beyond redraws: on a
313
+ tab whose CLI was spawned fresh — where no agent can already be
314
+ mid-turn — even the ungated pole starters (spinner motion, the CLI's
315
+ own progress hint) wait for the first submit, because a spawning CLI
316
+ animates its welcome paint and blips its progress hint with no turn
317
+ anywhere in sight.
318
+ """
319
+ return self._armed
320
+
321
+ def poked(self, text: str = "") -> None:
322
+ """The app just sent this terminal's child *text*.
323
+
324
+ A carriage return in it is a submit — never part of a spawn-time
325
+ initial command (those end in a newline) or a focus report — so it
326
+ arms the gate as a side effect.
327
+ """
328
+ self._poked_at = self._clock()
329
+ if "\r" in text:
330
+ self._armed = True
331
+
332
+ def counts(self, grid: tuple[int, int]) -> bool:
333
+ """Whether the redraw arriving now — at *grid* columns and rows — is
334
+ the agent working rather than the terminal answering us."""
335
+ reflowed = self._grid is not None and grid != self._grid
336
+ self._grid = grid
337
+ if not self._armed or reflowed:
338
+ return False
339
+ if self._poked_at is None:
340
+ return True
341
+ return self._clock() - self._poked_at >= self._quiet_s
342
+
343
+
344
+ class SpinnerWatch:
345
+ """One terminal's "is something animating at a line start?" detector.
346
+
347
+ While the agent works, its CLI draws a status line whose *text* is a
348
+ moving target — the verb is random and user-configurable, the trailing
349
+ hint configurable too — but whose animated indicator is always the first
350
+ character of its line. So nothing here matches glyphs: the window samples
351
+ the first character of every visible screen row, and a row that keeps
352
+ changing between recent samples is an animation, which is an agent
353
+ working. That covers the spinner sitting still on an otherwise quiet
354
+ screen and, just as deliberately, output scrolling through — flowing
355
+ output is exactly what the pole shows.
356
+
357
+ The first column is what makes the signal echo-proof without a gate:
358
+ keystrokes echo after the prompt marker, mid-line; a focus repaint
359
+ redraws the same text; and a reflow arrives at a different grid, which
360
+ resets the baseline instead of comparing across it.
361
+
362
+ One change alone is not animation — a submitted prompt repaints the
363
+ screen once, and so does a spawn-time welcome paint. `sample` only
364
+ reports motion when the *previous* change was recent (`SPINNER_STREAK_S`),
365
+ which a live spinner refreshes every sample and a one-off repaint never
366
+ does. The cost is one extra sample (~`SPINNER_SAMPLE_S`) of latency on a
367
+ pole this watch starts; poles the gate starts are as immediate as ever.
368
+
369
+ `due` is the throttle, split from `sample` so the caller can skip the
370
+ screen read entirely between samples — reading text out of VTE is the
371
+ expensive half of the job.
372
+ """
373
+
374
+ def __init__(
375
+ self,
376
+ *,
377
+ sample_s: float = SPINNER_SAMPLE_S,
378
+ streak_s: float = SPINNER_STREAK_S,
379
+ clock: Callable[[], float] = time.monotonic,
380
+ ) -> None:
381
+ self._sample_s = sample_s
382
+ self._streak_s = streak_s
383
+ self._clock = clock
384
+ self._sampled_at: float | None = None
385
+ self._changed_at: float | None = None # when a sample last saw a change
386
+ self._column: tuple[str, ...] | None = None # first chars at the last sample
387
+ self._grid: tuple[int, int] | None = None # (columns, rows) it was read at
388
+
389
+ def due(self) -> bool:
390
+ """Whether enough time has passed that a fresh sample is worth taking."""
391
+ return self._sampled_at is None or self._clock() - self._sampled_at >= self._sample_s
392
+
393
+ def sample(self, first_column: Iterable[str], grid: tuple[int, int]) -> bool:
394
+ """Record the screen's first column as read now, at *grid* columns and
395
+ rows, and say whether it shows animation — this sample changed, and on
396
+ the heels of another change."""
397
+ column = tuple(first_column)
398
+ now = self._clock()
399
+ self._sampled_at = now
400
+ reflowed = grid != self._grid
401
+ previous, self._column, self._grid = self._column, column, grid
402
+ if reflowed: # rewrapped text moves every line; nothing comparable
403
+ self._changed_at = None
404
+ return False
405
+ if previous is None or column == previous:
406
+ return False
407
+ streak = self._changed_at is not None and now - self._changed_at <= self._streak_s
408
+ self._changed_at = now
409
+ return streak
410
+
411
+
412
+ # Vte.ProgressHint values, spelled out so this module stays GTK-free. VTE maps
413
+ # ConEmu's OSC 9;4 states onto these one-to-one; every state but INACTIVE means
414
+ # the agent calls itself mid-turn. A cleared property (the CLI sent state 0)
415
+ # reads back from VTE as *no value*, not as INACTIVE — callers pass that as
416
+ # None, and reading() treats the two identically.
417
+ PROGRESS_HINT_INACTIVE = 0
418
+ _BUSY_HINTS = frozenset({1, 2, 3, 4}) # ACTIVE, ERROR, INDETERMINATE, PAUSED
419
+
420
+
421
+ class ProgressWatch:
422
+ """One terminal's progress-termprop interpreter: the agent's own word.
423
+
424
+ Claude Code announces its busy state through the terminal as ConEmu-style
425
+ OSC 9;4 progress sequences — a busy hint as a turn starts, a clear as it
426
+ ends — which VTE parses into the ``vte.progress.hint`` termprop the window
427
+ forwards here. Unlike every other tab source this is not inference, so it
428
+ gets the one power no inferred source can be trusted with: ending the pole
429
+ the instant the agent says the turn is over, instead of waiting out an
430
+ idle window.
431
+
432
+ `reading` maps each hint change to the pole action it asks for: ``"mark"``
433
+ for a busy hint (with the wide `PROGRESS_IDLE_S` window — the termprop is
434
+ edge-triggered, and only its own clear normally ends the pole), ``"finish"``
435
+ for a clear, or None. The finish is gated on this tab having spoken a busy
436
+ hint before — a tab whose CLI never emits progress keeps its inferred
437
+ poles untouched, and one whose CLI stops emitting mid-session (a version
438
+ downgrade, say) falls back to inference rather than fighting it.
439
+
440
+ `quiet` is the finish's shadow: for a beat after the agent calls a turn
441
+ over, its trailing repaints — the prompt box returning, the working
442
+ indicator fading — must not read as a new turn starting, or the honest
443
+ instant-down would blip right back up for `IDLE_S`. Only redraw-inferred
444
+ pole *starts* defer to it; a real new turn arrives by other roads (the
445
+ Enter pre-mark, the next busy hint) and is never held back.
446
+ """
447
+
448
+ def __init__(
449
+ self,
450
+ *,
451
+ quiet_s: float = PROGRESS_QUIET_S,
452
+ clock: Callable[[], float] = time.monotonic,
453
+ ) -> None:
454
+ self._quiet_s = quiet_s
455
+ self._clock = clock
456
+ self._spoken = False # a busy hint has been seen; clears mean something
457
+ self._finished_at: float | None = None
458
+
459
+ def reading(self, hint: int | None) -> str | None:
460
+ """The pole action a hint change asks for: "mark", "finish", or None.
461
+
462
+ *hint* is the termprop's new value — None for cleared, which is how
463
+ VTE reports the CLI's "remove progress" state.
464
+ """
465
+ if hint in _BUSY_HINTS:
466
+ self._spoken = True
467
+ return "mark"
468
+ if not self._spoken:
469
+ return None
470
+ self.turn_ended()
471
+ return "finish"
472
+
473
+ def quiet(self) -> bool:
474
+ """Whether a turn just ended here, so a redraw arriving now is its
475
+ trailing repaint rather than evidence of a new one."""
476
+ return self._finished_at is not None and self._clock() - self._finished_at < self._quiet_s
477
+
478
+ def turn_ended(self) -> None:
479
+ """Another source called this tab's turn over — the agent list saw its
480
+ background agent go idle (see `BackgroundBusyWatch`), which a tab
481
+ attached to one gets instead of a termprop clear. Opens the same quiet
482
+ window, so the CLI's trailing repaints can't blip the pole back up
483
+ after the honest instant-down.
484
+
485
+ `_spoken` is deliberately untouched: this tab still hasn't announced
486
+ any progress of its own, and a clear it never speaks must not start
487
+ finishing runs on its word.
488
+ """
489
+ self._finished_at = self._clock()
490
+
491
+
492
+ class BackgroundBusyWatch:
493
+ """The agent list's own word on which background agents are working.
494
+
495
+ `claude agents --json` carries a per-job ``status`` — busy while the agent
496
+ works, idle the moment it stops (measured steady across a 110s turn on
497
+ 2.1.226, flipping only at the end) — and for a session running as a
498
+ background agent it is the only authoritative signal there is. The progress
499
+ OSC that every spawned tab announces its turns with never reaches one: the
500
+ daemon spawns background agents with the terminal declarations stripped, so
501
+ the CLI's emission gate is shut whatever the tab it is attached in does.
502
+
503
+ `reading` turns one poll into the pole actions it asks for: mark every
504
+ session reported busy, and finish the ones that were reported busy before
505
+ and aren't now — the agent itself saying the turn is over, which is what
506
+ lets this end a pole outright instead of waiting out an idle window.
507
+
508
+ Only what this watch marked can be finished by it. A pole some other
509
+ source raised is not its to end, and an agent it never saw working has no
510
+ run to complete — the same rule `ProgressWatch` applies to a tab that
511
+ never spoke a busy hint.
512
+ """
513
+
514
+ def __init__(self) -> None:
515
+ self._marked: set[str] = set() # reported busy at the last reading
516
+
517
+ def reading(self, busy_ids: Iterable[str]) -> tuple[set[str], set[str]]:
518
+ """One poll's answer, as (to mark, to finish).
519
+
520
+ *busy_ids* is every session the agent list reports as a working
521
+ background agent. A session that drops out of the list entirely — its
522
+ job ended, its tab closed and the window stopped watching it — reads
523
+ the same as one that went idle: whatever it was doing, it isn't
524
+ doing it here any more.
525
+ """
526
+ busy = set(busy_ids)
527
+ finished = self._marked - busy
528
+ self._marked = busy
529
+ return busy, finished
530
+
531
+
532
+ def _glib_add_timeout(interval_ms: int, callback: Callable[[], bool]) -> int:
533
+ from gi.repository import GLib
534
+
535
+ return GLib.timeout_add(interval_ms, callback)
536
+
537
+
538
+ def _glib_remove_timeout(source: int) -> None:
539
+ from gi.repository import GLib
540
+
541
+ GLib.source_remove(source)