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.
- collins/THIRD_PARTY_LICENSES.md +87 -0
- collins/__init__.py +12 -0
- collins/__main__.py +6 -0
- collins/activity.py +541 -0
- collins/animatedimage.py +156 -0
- collins/app.py +2381 -0
- collins/apppicker.py +112 -0
- collins/attachpanel.py +753 -0
- collins/attachrecords.py +731 -0
- collins/avatars.py +105 -0
- collins/bgstatus.py +299 -0
- collins/bodyimages.py +154 -0
- collins/buildinfo.py +118 -0
- collins/caffeine.py +209 -0
- collins/chatbubbles.py +78 -0
- collins/chats.py +204 -0
- collins/chatsession.py +272 -0
- collins/chatsessionview.py +400 -0
- collins/claudemodels.py +613 -0
- collins/clisetup.py +198 -0
- collins/cliwelcome.py +255 -0
- collins/com.episode6.Collins.desktop +17 -0
- collins/com.episode6.Collins.metainfo.xml +167 -0
- collins/composer.py +616 -0
- collins/composerkeys.py +176 -0
- collins/copylabel.py +149 -0
- collins/desktopentry.py +178 -0
- collins/dialogs.py +987 -0
- collins/docktree.py +201 -0
- collins/dockzones.py +99 -0
- collins/dropimages.py +220 -0
- collins/editor.py +1390 -0
- collins/editorfiles.py +782 -0
- collins/editorwindow.py +151 -0
- collins/fileclipboard.py +153 -0
- collins/filetree.py +443 -0
- collins/filetypes.py +245 -0
- collins/flash.py +44 -0
- collins/footerapps.py +92 -0
- collins/formatting.py +422 -0
- collins/fuzzy.py +84 -0
- collins/ghsetup.py +64 -0
- collins/ghwelcome.py +372 -0
- collins/gitinfo.py +319 -0
- collins/i18n.py +52 -0
- collins/icongen.py +249 -0
- collins/icons/com.episode6.Collins-panel.svg +48 -0
- collins/icons/com.episode6.Collins.svg +23 -0
- collins/icons/hicolor/scalable/actions/agent-claude-symbolic.svg +14 -0
- collins/icons/hicolor/scalable/actions/alert-fill-symbolic.svg +32 -0
- collins/icons/hicolor/scalable/actions/alert-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/archive-symbolic.svg +11 -0
- collins/icons/hicolor/scalable/actions/caffeine-cup-empty-symbolic.svg +11 -0
- collins/icons/hicolor/scalable/actions/caffeine-cup-full-symbolic.svg +15 -0
- collins/icons/hicolor/scalable/actions/chat-bubble-symbolic.svg +14 -0
- collins/icons/hicolor/scalable/actions/check-circle-fill-symbolic.svg +31 -0
- collins/icons/hicolor/scalable/actions/circle-fill-symbolic.svg +7 -0
- collins/icons/hicolor/scalable/actions/dock-bottom-symbolic.svg +11 -0
- collins/icons/hicolor/scalable/actions/dock-right-symbolic.svg +13 -0
- collins/icons/hicolor/scalable/actions/ft-book-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-code-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-container-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-database-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-diff-ignored-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-file-binary-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-file-code-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-file-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-file-zip-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-gear-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-image-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-law-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-lock-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-markdown-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-package-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-table-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-terminal-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/ft-video-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/git-merge-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/git-pull-request-closed-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/git-pull-request-draft-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/git-pull-request-symbolic.svg +30 -0
- collins/icons/hicolor/scalable/actions/github-symbolic.svg +31 -0
- collins/icons/hicolor/scalable/actions/tab-close-symbolic.svg +12 -0
- collins/icons/hicolor/scalable/actions/unarchive-symbolic.svg +11 -0
- collins/icons/hicolor/scalable/actions/undock-bottom-symbolic.svg +11 -0
- collins/icons/hicolor/scalable/actions/undock-right-symbolic.svg +11 -0
- collins/icons/hicolor/scalable/actions/x-circle-fill-symbolic.svg +31 -0
- collins/keybindings.py +388 -0
- collins/keybindingsdialog.py +277 -0
- collins/keymap.py +124 -0
- collins/licenses.py +75 -0
- collins/lightbox.py +772 -0
- collins/linkpatterns.py +394 -0
- collins/locale/de/LC_MESSAGES/collins.mo +0 -0
- collins/locale/es/LC_MESSAGES/collins.mo +0 -0
- collins/locale/fr/LC_MESSAGES/collins.mo +0 -0
- collins/locale/hu/LC_MESSAGES/collins.mo +0 -0
- collins/mcp_shim.py +275 -0
- collins/mcpserver.py +253 -0
- collins/mcptools.py +803 -0
- collins/modelmenu.py +133 -0
- collins/models.py +77 -0
- collins/openwith.py +290 -0
- collins/panedsizer.py +254 -0
- collins/paneldnd.py +431 -0
- collins/paneldock.py +1871 -0
- collins/panelhistory.py +164 -0
- collins/panelkeys.py +50 -0
- collins/panellayout.py +240 -0
- collins/panelsizing.py +144 -0
- collins/panelstrip.py +646 -0
- collins/pictures.py +249 -0
- collins/pkgrepos.py +175 -0
- collins/practions.py +764 -0
- collins/prattach.py +133 -0
- collins/prblobs.py +213 -0
- collins/prdetail.py +785 -0
- collins/prefs.py +1564 -0
- collins/prefssearch.py +19 -0
- collins/prfileimages.py +218 -0
- collins/prmenu.py +855 -0
- collins/proctree.py +179 -0
- collins/projecticons.py +122 -0
- collins/providers.py +923 -0
- collins/prstatus.py +1724 -0
- collins/prstore.py +149 -0
- collins/prview.py +2552 -0
- collins/quickopen.py +229 -0
- collins/remotearchive.py +210 -0
- collins/remoteimages.py +268 -0
- collins/replaymodel.py +120 -0
- collins/replayview.py +157 -0
- collins/scrolling.py +59 -0
- collins/sessions.py +763 -0
- collins/shellinput.py +39 -0
- collins/sidebar.py +2764 -0
- collins/state.py +896 -0
- collins/statusicon.py +1006 -0
- collins/store.py +907 -0
- collins/svgtexture.py +123 -0
- collins/switcher.py +137 -0
- collins/tabguard.py +123 -0
- collins/taborder.py +40 -0
- collins/terminal.py +5345 -0
- collins/themes.py +237 -0
- collins/titles.py +441 -0
- collins/tooltipmute.py +164 -0
- collins/transcript.py +353 -0
- collins/transcriptlinks.py +178 -0
- collins/traymodel.py +301 -0
- collins/trust.py +149 -0
- collins/usage.py +234 -0
- collins/usagepanel.py +408 -0
- collins/vtehtml.py +87 -0
- collins/window.py +5636 -0
- collins-0.1.1.dist-info/METADATA +234 -0
- collins-0.1.1.dist-info/RECORD +162 -0
- collins-0.1.1.dist-info/WHEEL +5 -0
- collins-0.1.1.dist-info/entry_points.txt +2 -0
- collins-0.1.1.dist-info/licenses/LICENSE +674 -0
- collins-0.1.1.dist-info/licenses/NOTICE +27 -0
- 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
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)
|