tabby-clipsnag 0.0.0-stage → 1.0.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexey Belov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,124 @@
1
- # Temporary Holding Version
1
+ # tabby-clipsnag
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ English | [Русский](README_ru.md)
4
+
5
+ A [Tabby](https://tabby.sh) terminal plugin. Press a hotkey, let a noisy command run,
6
+ press it again — the plugin strips the ANSI control garbage and drops clean text on your
7
+ clipboard.
8
+
9
+ Think of it as "select all the output of the last command", but driven by the actual byte
10
+ stream (colors, cursor moves, TUI redraws and all) instead of the terminal's scrollback,
11
+ and it works the same whether the output scrolled off screen or not.
12
+
13
+ ## Features
14
+
15
+ - **One toggle hotkey** (default `Ctrl-Shift-X`, `⌘-Shift-X` on macOS) to start and stop.
16
+ - **Passive capture** — installs a `SessionMiddleware` that observes bytes and forwards
17
+ them unchanged; your terminal renders exactly as before while recording.
18
+ - **Correct ANSI stripping** — the whole capture is decoded and stripped once on stop, so
19
+ colour codes, cursor movement, OSC titles, and hyperlinks never leak into the clipboard.
20
+ - **Bounded memory** — capture stops growing at a configurable cap (default **5 MB**),
21
+ keeping the earliest bytes and reporting truncation in the notification.
22
+ - **Auto-stop on idle** — optionally stop by itself after N seconds of silence (default 3 s).
23
+ - **Settings UI** — every option is editable under **Settings → Clipsnag**; nothing to hand-edit.
24
+ - **Leak-safe** — everything is torn down with the tab; closing a recorded tab cancels the
25
+ recording cleanly with no dangling subscriptions.
26
+
27
+ ## Configuration
28
+
29
+ Everything is editable in **Settings → Clipsnag** (the hotkey lives under
30
+ **Settings → Hotkeys → Toggle terminal output snagging**). The same values are stored under
31
+ the `clipsnag` key in `~/.config/tabby/config.yaml` (`%APPDATA%\tabby\config.yaml` on Windows)
32
+ and can be hand-edited there, though the settings UI is the recommended way:
33
+
34
+ ```yaml
35
+ hotkeys:
36
+ clipsnag-toggle: Ctrl-Shift-X
37
+
38
+ clipsnag:
39
+ maxCaptureBytes: 5242880 # 5 MB hard cap; older bytes kept, rest discarded
40
+ autoStopAfterIdleMs: 3000 # stop after 3s of silence; 0 disables auto-stop
41
+ copyToClipboardOnStop: true # write the cleaned text to the OS clipboard
42
+ stopOnTabSwitch: false # stop + copy as soon as you switch away from the tab
43
+ ```
44
+
45
+ ## How it works
46
+
47
+ ```
48
+ hotkey ──▶ ClipsnagService.toggle()
49
+ │
50
+ ├─ start: resolve active terminal tab
51
+ │ └─ TerminalDecorator.startRecording(tab, sink)
52
+ │ └─ CaptureMiddleware pushed into session.middleware
53
+ │ └─ feedFromSession() copies bytes into CaptureBuffer
54
+ │
55
+ └─ stop: CaptureBuffer.finish()
56
+ ├─ Buffer.concat + utf8 decode
57
+ ├─ stripAnsi(...)
58
+ └─ PlatformService.setClipboard({ text })
59
+ ```
60
+
61
+ - `TerminalDecorator` supplies the per-tab lifecycle hook and guarantees cleanup with
62
+ `subscribeUntilDestroyed`.
63
+ - `SessionMiddleware` is the only supported way to see the raw session byte stream.
64
+ - The service owns the single active recording and subscribes to the toggle hotkey once.
65
+
66
+ Because bytes are accumulated (not decoded per chunk), multi-byte UTF-8 characters split
67
+ across PTY chunks survive intact, and ANSI sequences that straddle chunk boundaries are
68
+ stripped correctly.
69
+
70
+ ## Building
71
+
72
+ Requires Node 18+.
73
+
74
+ ```bash
75
+ npm install
76
+ npm run build # -> dist/index.js (+ .d.ts)
77
+ npm run watch # incremental rebuild while developing
78
+ npm run typecheck # tsc --noEmit
79
+ npm run lint # eslint
80
+ ```
81
+
82
+ ## Installing into Tabby for testing
83
+
84
+ Pick whichever is convenient:
85
+
86
+ ### Option A — point Tabby at the folder (fastest for dev)
87
+
88
+ ```bash
89
+ # Linux / macOS
90
+ tabby --plugin-dir=/absolute/path/to/tabby-clipsnag
91
+
92
+ # Windows (PowerShell)
93
+ tabby --plugin-dir=C:\Users\you\github\alexoffspace\tabby-clipsnag
94
+ ```
95
+
96
+ `--plugin-dir` must point at the project root (the folder containing `package.json` and
97
+ `dist/`). Run `npm run build` first.
98
+
99
+ ### Option B — drop it into the plugins directory
100
+
101
+ Copy `package.json` and the whole `dist/` folder into the per-user plugin directory:
102
+
103
+ ```
104
+ Linux: ~/.config/tabby/plugins/node_modules/tabby-clipsnag/
105
+ macOS: ~/Library/Application Support/tabby/plugins/node_modules/tabby-clipsnag/
106
+ Windows: %APPDATA%\tabby\plugins\node_modules\tabby-clipsnag\
107
+ ```
108
+
109
+ Then restart Tabby.
110
+
111
+ ### Verifying it loaded
112
+
113
+ Open **Settings → Plugins** — `tabby-clipsnag` should be listed. Then:
114
+
115
+ 1. Focus a terminal tab and run `ls --color=always`.
116
+ 2. Press `Ctrl-Shift-X` — a toast says *recording …*.
117
+ 3. Press `Ctrl-Shift-X` again — a second toast reports the size; paste anywhere.
118
+
119
+ The first press arms capture, the second stops it. With `autoStopAfterIdleMs > 0` you can
120
+ also just wait: recording ends automatically once output goes quiet.
121
+
122
+ ## License
123
+
124
+ MIT © Alexey Belov — see [LICENSE](LICENSE).
package/README_ru.md ADDED
@@ -0,0 +1,129 @@
1
+ # tabby-clipsnag
2
+
3
+ [English](README.md) | Русский
4
+
5
+ Плагин для терминала [Tabby](https://tabby.sh). Нажмите горячую клавишу, дайте
6
+ «шумной» команде отработать, нажмите её снова — плагин вырежет ANSI-мусор управления и
7
+ положит в буфер обмена чистый текст.
8
+
9
+ Это как «выделить весь вывод последней команды», но работающее по реальному потоку
10
+ байтов (цвета, перемещение курсора, перерисовки TUI и всё остальное), а не по скроллбеку
11
+ терминала. Работает одинаково независимо от того, уехал вывод за пределы экрана или нет.
12
+
13
+ ## Возможности
14
+
15
+ - **Одна горячая клавиша-переключатель** (по умолчанию `Ctrl-Shift-X`, `⌘-Shift-X` на
16
+ macOS) для запуска и остановки.
17
+ - **Пассивный захват** — устанавливает `SessionMiddleware`, который наблюдает байты и
18
+ передаёт их дальше без изменений; при записи терминал отображается точно как раньше.
19
+ - **Корректное удаление ANSI** — весь захват декодируется и очищается один раз при
20
+ остановке, поэтому цветовые коды, движение курсора, OSC-заголовки и гиперссылки никогда
21
+ не попадут в буфер обмена.
22
+ - **Ограниченная память** — захват перестаёт расти при достижимом лимите (по умолчанию
23
+ **5 МБ**), сохраняются самые ранние байты, а факт усечения сообщается в уведомлении.
24
+ - **Авто-остановка по простою** — опционально останавливается сам после N секунд тишины
25
+ (по умолчанию 3 с).
26
+ - **UI настроек** — все параметры редактируются в **Settings → Clipsnag**; править вручную
27
+ ничего не нужно.
28
+ - **Безопасно относительно утечек** — всё уничтожается вместе с вкладкой; закрытие
29
+ записываемой вкладки чисто отменяет запись без «висячих» подписок.
30
+
31
+ ## Настройка
32
+
33
+ Всё редактируется в **Settings → Clipsnag** (а горячая клавиша — в
34
+ **Settings → Hotkeys → Toggle terminal output snagging**). Те же значения хранятся под
35
+ ключом `clipsnag` в `~/.config/tabby/config.yaml` (`%APPDATA%\tabby\config.yaml` на Windows)
36
+ и могут быть отредактированы вручную там же, хотя UI настроек — рекомендуемый способ:
37
+
38
+ ```yaml
39
+ hotkeys:
40
+ clipsnag-toggle: Ctrl-Shift-X
41
+
42
+ clipsnag:
43
+ maxCaptureBytes: 5242880 # жёсткий лимит 5 МБ; старые байты сохраняются, остальное отбрасывается
44
+ autoStopAfterIdleMs: 3000 # остановка после 3с тишины; 0 отключает авто-остановку
45
+ copyToClipboardOnStop: true # записывать очищенный текст в буфер обмена ОС
46
+ stopOnTabSwitch: false # остановка и копирование при переключении на другую вкладку
47
+ ```
48
+
49
+ ## Как это работает
50
+
51
+ ```
52
+ hotkey ──▶ ClipsnagService.toggle()
53
+ │
54
+ ├─ start: resolve active terminal tab
55
+ │ └─ TerminalDecorator.startRecording(tab, sink)
56
+ │ └─ CaptureMiddleware pushed into session.middleware
57
+ │ └─ feedFromSession() copies bytes into CaptureBuffer
58
+ │
59
+ └─ stop: CaptureBuffer.finish()
60
+ ├─ Buffer.concat + utf8 decode
61
+ ├─ stripAnsi(...)
62
+ └─ PlatformService.setClipboard({ text })
63
+ ```
64
+
65
+ - `TerminalDecorator` предоставляет хук жизненного цикла на вкладку и гарантирует очистку
66
+ через `subscribeUntilDestroyed`.
67
+ - `SessionMiddleware` — единственный поддерживаемый способ увидеть сырой поток байтов
68
+ сессии.
69
+ - Сервис владеет единственной активной записью и подписывается на горячую клавишу один раз.
70
+
71
+ Так как байты накапливаются (а не декодируются по чанкам), многобайтовые символы UTF-8,
72
+ разделённые между чанками PTY, остаются целыми, а ANSI-последовательности, пересекающие
73
+ границы чанков, удаляются корректно.
74
+
75
+ ## Сборка
76
+
77
+ Требуется Node 18+.
78
+
79
+ ```bash
80
+ npm install
81
+ npm run build # -> dist/index.js (+ .d.ts)
82
+ npm run watch # инкрементальная пересборка во время разработки
83
+ npm run typecheck # tsc --noEmit
84
+ npm run lint # eslint
85
+ ```
86
+
87
+ ## Установка в Tabby для тестирования
88
+
89
+ Выберите удобный вариант:
90
+
91
+ ### Вариант A — указать Tabby на папку (быстрее всего для разработки)
92
+
93
+ ```bash
94
+ # Linux / macOS
95
+ tabby --plugin-dir=/absolute/path/to/tabby-clipsnag
96
+
97
+ # Windows (PowerShell)
98
+ tabby --plugin-dir=C:\Users\you\github\alexoffspace\tabby-clipsnag
99
+ ```
100
+
101
+ `--plugin-dir` должен указывать на корень проекта (папку с `package.json` и `dist/`).
102
+ Сначала запустите `npm run build`.
103
+
104
+ ### Вариант B — положить в каталог плагинов
105
+
106
+ Скопируйте `package.json` и всю папку `dist/` в пользовательский каталог плагинов:
107
+
108
+ ```
109
+ Linux: ~/.config/tabby/plugins/node_modules/tabby-clipsnag/
110
+ macOS: ~/Library/Application Support/tabby/plugins/node_modules/tabby-clipsnag/
111
+ Windows: %APPDATA%\tabby\plugins\node_modules\tabby-clipsnag\
112
+ ```
113
+
114
+ Затем перезапустите Tabby.
115
+
116
+ ### Проверка загрузки
117
+
118
+ Откройте **Settings → Plugins** — там должен быть указан `tabby-clipsnag`. Затем:
119
+
120
+ 1. Сфокусируйте вкладку терминала и запустите `ls --color=always`.
121
+ 2. Нажмите `Ctrl-Shift-X` — появится уведомление *recording …*.
122
+ 3. Нажмите `Ctrl-Shift-X` снова — второе уведомление сообщит размер; вставьте куда угодно.
123
+
124
+ Первое нажатие взводит захват, второе — останавливает его. При `autoStopAfterIdleMs > 0`
125
+ можно также просто подождать: запись завершится автоматически, когда вывод затихнет.
126
+
127
+ ## Лицензия
128
+
129
+ MIT © Алексей Белов — см. [LICENSE](LICENSE).
@@ -0,0 +1,32 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ /**
4
+ * One in-flight recording for a single terminal tab.
5
+ *
6
+ * Bytes are accumulated raw (never decoded per chunk) so that multi-byte UTF-8
7
+ * characters split across chunk boundaries survive. Decoding and ANSI stripping
8
+ * both happen exactly once, in `finish()`.
9
+ */
10
+ export declare class CaptureBuffer {
11
+ private readonly maxBytes;
12
+ private readonly chunks;
13
+ private capturedBytes;
14
+ private truncated;
15
+ constructor(maxBytes: number);
16
+ /** Bytes retained so far (always <= maxBytes). */
17
+ get byteLength(): number;
18
+ /** True once the cap was hit and later chunks were dropped. */
19
+ get isTruncated(): boolean;
20
+ /**
21
+ * Append a chunk. Chunks past the cap are dropped and the buffer is marked
22
+ * truncated; we never exceed `maxBytes` of retained memory.
23
+ */
24
+ append(data: Buffer): void;
25
+ /**
26
+ * Decode, strip ANSI control sequences, and normalise line endings.
27
+ * Callable once per recording; it does not clear the buffer.
28
+ */
29
+ finish(): string;
30
+ /** Drop all retained data (used when a recording is abandoned). */
31
+ reset(): void;
32
+ }
@@ -0,0 +1,22 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ import { Buffer } from 'buffer';
4
+ import { SessionMiddleware } from 'tabby-terminal';
5
+ /**
6
+ * Passive tap on the session output stream.
7
+ *
8
+ * `feedFromSession` receives bytes travelling FROM the session TO the terminal
9
+ * (i.e. the program's stdout/stderr as rendered). We hand a copy to the sink and
10
+ * then ALWAYS forward the original bytes — capturing must never alter the stream.
11
+ *
12
+ * The middleware is a no-op when `sink` is `null`, so it can safely stay in the
13
+ * stack across record/stop cycles without re-wiring subscriptions.
14
+ */
15
+ export declare class CaptureMiddleware extends SessionMiddleware {
16
+ private sink;
17
+ /** Begin forwarding copies of session output to `sink`. */
18
+ start(sink: (data: Buffer) => void): void;
19
+ /** Stop forwarding (the terminal keeps receiving data normally). */
20
+ stop(): void;
21
+ feedFromSession(data: Buffer): void;
22
+ }
@@ -0,0 +1,56 @@
1
+ import { NgZone } from '@angular/core';
2
+ import { AppService, ConfigService, HotkeysService, NotificationsService, PlatformService } from 'tabby-core';
3
+ import { ClipsnagDecorator } from './decorator';
4
+ /**
5
+ * Coordinates snag recording across tabs.
6
+ *
7
+ * At most one recording is active at a time. The service:
8
+ * - listens for the toggle hotkey,
9
+ * - starts/stops recording on the active terminal tab,
10
+ * - auto-stops after a configurable period of output silence,
11
+ * - strips ANSI and writes cleaned text to the OS clipboard,
12
+ * - reports state changes through Tabby notifications.
13
+ *
14
+ * Leak safety: every per-recording RxJS subscription is collected into a single
15
+ * `Subscription` torn down in `stopRecording()`/`abortRecording()`; the toggle
16
+ * hotkey subscription lives for the app lifetime, which is correct for a
17
+ * singleton service. If the recorded tab is destroyed or its session ends,
18
+ * recording is finalized without touching the dead tab.
19
+ */
20
+ export declare class ClipsnagService {
21
+ private readonly config;
22
+ private readonly hotkeys;
23
+ private readonly notifications;
24
+ private readonly platform;
25
+ private readonly app;
26
+ private readonly zone;
27
+ private readonly decorator;
28
+ private active;
29
+ constructor(config: ConfigService, hotkeys: HotkeysService, notifications: NotificationsService, platform: PlatformService, app: AppService, zone: NgZone, decorator: ClipsnagDecorator);
30
+ /** App-lifetime subscription to the toggle hotkey. */
31
+ private hotkeySubscription;
32
+ /** Wire up the toggle hotkey. Called once from the plugin module constructor. */
33
+ init(): void;
34
+ /** True while a capture is in progress. */
35
+ get isRecording(): boolean;
36
+ /** Release app-lifetime resources. Called on plugin/app teardown. */
37
+ dispose(): void;
38
+ /** Toggle recording on the currently active terminal tab. */
39
+ toggle(): Promise<void>;
40
+ /** Begin capturing the active terminal tab's output. */
41
+ startRecording(): void;
42
+ /**
43
+ * Stop recording, strip ANSI, and copy to the clipboard.
44
+ * @param automatic true when triggered by the idle timer rather than the hotkey
45
+ */
46
+ stopRecording(automatic?: boolean): Promise<void>;
47
+ /** Finalize a recording because its terminal went away; never touches the tab. */
48
+ private abortRecording;
49
+ /**
50
+ * The active tab may be a plain terminal or a `SplitTabComponent` wrapping
51
+ * several. In the split case we record the focused pane.
52
+ */
53
+ private resolveActiveTerminal;
54
+ /** Read + validate plugin settings, falling back to defaults per-field. */
55
+ private settings;
56
+ }
@@ -0,0 +1,39 @@
1
+ import { ConfigProvider } from 'tabby-core';
2
+ /**
3
+ * Settings shape exposed under `config.store.clipsnag`.
4
+ * Declared explicitly (rather than left as `any`) so the service reads typed values.
5
+ */
6
+ export interface ClipsnagConfig {
7
+ /** Hard cap on captured bytes before truncation kicks in. */
8
+ maxCaptureBytes: number;
9
+ /**
10
+ * Automatically stop recording after this many milliseconds of silence.
11
+ * `0` disables auto-stop (recording then ends only on a second hotkey press).
12
+ */
13
+ autoStopAfterIdleMs: number;
14
+ /** Copy the cleaned capture to the system clipboard when recording stops. */
15
+ copyToClipboardOnStop: boolean;
16
+ /** Stop and copy the capture as soon as the user switches away from the tab. */
17
+ stopOnTabSwitch: boolean;
18
+ }
19
+ /**
20
+ * Single source of truth for default settings. Used both by the config provider
21
+ * (to seed `config.store`) and by the service (as a per-field validation fallback
22
+ * against malformed user config).
23
+ */
24
+ export declare const DEFAULT_CLIPSNAG_CONFIG: ClipsnagConfig;
25
+ export declare class ClipsnagConfigProvider extends ConfigProvider {
26
+ defaults: {
27
+ hotkeys: {
28
+ 'clipsnag-toggle': string[];
29
+ };
30
+ clipsnag: ClipsnagConfig;
31
+ };
32
+ platformDefaults: {
33
+ macOS: {
34
+ hotkeys: {
35
+ 'clipsnag-toggle': string[];
36
+ };
37
+ };
38
+ };
39
+ }
@@ -0,0 +1,43 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ import { TerminalDecorator, BaseTerminalTabComponent, SessionMiddlewareStack } from 'tabby-terminal';
4
+ import { CaptureMiddleware } from './capture.middleware';
5
+ /**
6
+ * Per-tab recorder handle.
7
+ *
8
+ * Owns the CaptureMiddleware instance that is installed into the tab's current
9
+ * session stack. `dispose()` is idempotent so it is safe to call from both the
10
+ * "user stopped recording" and "tab/session vanished" paths.
11
+ */
12
+ export declare class TabRecorder {
13
+ private readonly middleware;
14
+ private readonly stack;
15
+ private disposed;
16
+ constructor(middleware: CaptureMiddleware, stack: SessionMiddlewareStack, onData: (data: Buffer) => void);
17
+ /**
18
+ * Remove the tap from the session stack. We never call `close()` on the
19
+ * middleware: it holds Subjects and calling close() would complete them,
20
+ * making the instance unusable if it were ever reused.
21
+ */
22
+ dispose(): void;
23
+ }
24
+ /**
25
+ * Attaches/detaches per-tab recording support.
26
+ *
27
+ * The decorator does two things:
28
+ * 1. Tracks the tab's *current* session so `startRecording` can find a stack to
29
+ * install into, updating it whenever the session is replaced (reconnectable
30
+ * sessions emit `sessionChanged$` more than once).
31
+ * 2. Guarantees cleanup with the tab's lifecycle via `subscribeUntilDestroyed`,
32
+ * so no map entry or subscription outlives the tab.
33
+ */
34
+ export declare class ClipsnagDecorator extends TerminalDecorator {
35
+ private readonly sessionByTab;
36
+ attach(terminal: BaseTerminalTabComponent<never>): void;
37
+ detach(terminal: BaseTerminalTabComponent<never>): void;
38
+ /**
39
+ * Start forwarding this tab's output into `onData`.
40
+ * Returns `null` when the tab has no live session to tap.
41
+ */
42
+ startRecording(terminal: BaseTerminalTabComponent<never>, onData: (data: Buffer) => void): TabRecorder | null;
43
+ }
@@ -0,0 +1,6 @@
1
+ import { HotkeyProvider, HotkeyDescription } from 'tabby-core';
2
+ /** Single toggle hotkey: first press arms capture, the next press stops and copies. */
3
+ export declare const CLIPSNAG_TOGGLE_HOTKEY = "clipsnag-toggle";
4
+ export declare class ClipsnagHotkeyProvider extends HotkeyProvider {
5
+ provide(): Promise<HotkeyDescription[]>;
6
+ }
@@ -0,0 +1,30 @@
1
+ import { ClipsnagService } from './clipsnag.service';
2
+ export { ClipsnagService } from './clipsnag.service';
3
+ export { ClipsnagDecorator } from './decorator';
4
+ export { ClipsnagConfigProvider, ClipsnagConfig, DEFAULT_CLIPSNAG_CONFIG } from './config';
5
+ export { ClipsnagHotkeyProvider, CLIPSNAG_TOGGLE_HOTKEY } from './hotkeys';
6
+ export { ClipsnagSettingsTabComponent, ClipsnagSettingsTabProvider } from './settings';
7
+ /**
8
+ * Plugin module.
9
+ *
10
+ * Registration points:
11
+ * - `HotkeyProvider` (multi) declares the toggle hotkey id so it appears in
12
+ * Settings → Hotkeys.
13
+ * - `ConfigProvider` (multi) seeds `config.store.hotkeys['clipsnag-toggle']`
14
+ * and `config.store.clipsnag`.
15
+ * - `TerminalDecorator` (multi) attaches the per-tab recording machinery.
16
+ * - `SettingsTabProvider` (multi) adds the "Clipsnag" section to Settings.
17
+ *
18
+ * The settings component must be declared here (not just provided) because Tabby
19
+ * instantiates it through `ComponentFactoryResolver`, which resolves directives
20
+ * and pipes from the declaring module's scope. `TabbyCoreModule` is imported so
21
+ * the component can use core building blocks such as `<toggle>`.
22
+ *
23
+ * The module constructor runs once at app start; that is where we subscribe the
24
+ * service to the hotkey stream. `ClipsnagDecorator` is injected by identity so the
25
+ * service talks to the exact instance Tabby registered.
26
+ */
27
+ export default class ClipsnagModule {
28
+ private readonly service;
29
+ constructor(service: ClipsnagService);
30
+ }
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ (()=>{"use strict";const t={n:e=>{const s=e&&e.__esModule?()=>e.default:()=>e;return t.d(s,{a:s}),s},d:(e,s)=>{for(var i in s)t.o(s,i)&&!t.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:s[i]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r:t=>{Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}};let e={};t.r(e),t.d(e,{CLIPSNAG_TOGGLE_HOTKEY:()=>d,ClipsnagConfigProvider:()=>l,ClipsnagDecorator:()=>f,ClipsnagHotkeyProvider:()=>p,ClipsnagService:()=>$,ClipsnagSettingsTabComponent:()=>O,ClipsnagSettingsTabProvider:()=>x,DEFAULT_CLIPSNAG_CONFIG:()=>u,default:()=>j});const s=require("@angular/core"),i=require("@angular/common"),n=require("@angular/forms"),o=require("tabby-core");var r=t.n(o);const a=require("tabby-terminal"),c=require("tabby-settings"),u={maxCaptureBytes:5242880,autoStopAfterIdleMs:3e3,copyToClipboardOnStop:!0,stopOnTabSwitch:!1};class l extends o.ConfigProvider{constructor(){super(...arguments),this.defaults={hotkeys:{"clipsnag-toggle":["Ctrl-Shift-X"]},clipsnag:u},this.platformDefaults={[o.Platform.macOS]:{hotkeys:{"clipsnag-toggle":["⌘-Shift-X"]}}}}}const d="clipsnag-toggle";class p extends o.HotkeyProvider{async provide(){return[{id:d,name:"Toggle terminal output snagging"}]}}class h extends a.SessionMiddleware{constructor(){super(...arguments),this.sink=null}start(t){this.sink=t}stop(){this.sink=null}feedFromSession(t){const e=this.sink;e&&e(t),super.feedFromSession(t)}}class g{constructor(t,e,s){this.middleware=t,this.stack=e,this.disposed=!1,t.start(s),e.push(t)}dispose(){this.disposed||(this.disposed=!0,this.middleware.stop(),this.stack.remove(this.middleware))}}class f extends a.TerminalDecorator{constructor(){super(...arguments),this.sessionByTab=new Map}attach(t){t.session&&this.sessionByTab.set(t,t.session),t.subscribeUntilDestroyed(t.sessionChanged$,e=>{e?this.sessionByTab.set(t,e):this.sessionByTab.delete(t)}),t.subscribeUntilDestroyed(t.destroyed$,()=>{this.sessionByTab.delete(t)})}detach(t){super.detach(t),this.sessionByTab.delete(t)}startRecording(t,e){const s=this.sessionByTab.get(t)??t.session;return s?new g(new h,s.middleware,e):null}}const b=require("rxjs"),y=require("rxjs/operators"),m=String.raw`(?:\u001B\u005C|\u009C)`,v=String.raw`\u0018\u001A\u001B\u0080-\u009F`,S=String.raw`(?:\u001B\]|\u009D)[^\u0007${v}]*(?:\u0007|${m})`,C=String.raw`(?:\u001B[PX^_k]|[\u0090\u0098\u009E\u009F])[^${v}]*${m}`,T=`${String.raw`\u001B\](?:P[\dA-Fa-f]{7}|R)(?:\u0007|${m})?`}|${S}|${C}|${String.raw`(?:\u001B\[|\u009B)[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E]`}|${String.raw`\u001B(?:[\u0020-\u002F]+[\u0030-\u007E]|[\dA-PR-TZcf-oq-uy=><~|}])`}`,w=function({onlyFirst:t=!1}={}){return new RegExp(T,t?void 0:"g")}();class B{constructor(t){this.maxBytes=t,this.chunks=[],this.capturedBytes=0,this.truncated=!1}get byteLength(){return this.capturedBytes}get isTruncated(){return this.truncated}append(t){if(0===t.length)return;const e=this.maxBytes-this.capturedBytes;if(e<=0)this.truncated=!0;else{if(t.length>e)return this.chunks.push(t.subarray(0,e)),this.capturedBytes+=e,void(this.truncated=!0);this.chunks.push(t),this.capturedBytes+=t.length}}finish(){return function(t){if("string"!=typeof t)throw new TypeError(`Expected a \`string\`, got \`${typeof t}\``);return t.includes("")||t.includes("›")?t.replace(w,""):t}(Buffer.concat(this.chunks,this.capturedBytes).toString("utf8")).replace(/\r\n/g,"\n").replace(/\r/g,"\n")}reset(){this.chunks.length=0,this.capturedBytes=0,this.truncated=!1}}let $=class{constructor(t,e,s,i,n,o,r){this.config=t,this.hotkeys=e,this.notifications=s,this.platform=i,this.app=n,this.zone=o,this.decorator=r,this.active=null,this.hotkeySubscription=null}init(){this.hotkeySubscription||(this.hotkeySubscription=this.hotkeys.hotkey$.subscribe(t=>{t===d&&this.zone.run(()=>{this.toggle()})}))}get isRecording(){return null!==this.active}dispose(){this.abortRecording("plugin shut down"),this.hotkeySubscription?.unsubscribe(),this.hotkeySubscription=null}async toggle(){this.active?await this.stopRecording():this.startRecording()}startRecording(){this.active&&this.abortRecording("a new recording started");const t=this.resolveActiveTerminal();if(!t)return void this.notifications.notice("Clipsnag: no active terminal to record");const e=this.settings(),s=e.maxCaptureBytes,i=new B(s),n=new b.Subject,r=new b.Subscription,a=e.autoStopAfterIdleMs;let c=null;const u=()=>{c?.unsubscribe();const t=(0,b.timer)(a).pipe((0,y.takeUntil)(n)).subscribe(()=>{this.zone.run(()=>{this.stopRecording(!0)})});c=t,r.add(t)},l=this.decorator.startRecording(t,t=>{i.append(t),null!==c&&u()});l?(a>0&&u(),r.add(t.destroyed$.subscribe(()=>{this.zone.run(()=>{this.abortRecording("terminal was closed")})})),r.add(t.sessionChanged$.subscribe(t=>{t||this.zone.run(()=>{this.abortRecording("terminal session ended")})})),e.stopOnTabSwitch&&r.add(this.app.activeTabChange$.subscribe(e=>{(e instanceof o.SplitTabComponent?e.getFocusedTab():e)!==t&&this.zone.run(()=>{this.stopRecording(!0)})})),this.active={terminal:t,buffer:i,recorder:l,idleStop$:n,subscriptions:r},this.notifications.info(`Clipsnag: recording "${t.title}" — press the hotkey again to stop`,`Buffer limit: ${M(s)}`)):this.notifications.notice("Clipsnag: this terminal has no live session yet")}async stopRecording(t=!1){const e=this.active;if(!e)return;this.active=null,e.idleStop$.next(),e.recorder.dispose(),e.subscriptions.unsubscribe();const s=e.buffer.finish(),i=e.buffer.isTruncated,n=e.buffer.byteLength;if(e.buffer.reset(),e.idleStop$.complete(),0===s.length)return void this.notifications.notice("Clipsnag: nothing captured");const o=this.settings();o.copyToClipboardOnStop&&await this.platform.setClipboard({text:s});const r=t?" (idle timeout)":"",a=i?` · truncated at ${M(o.maxCaptureBytes)}`:"",c=o.copyToClipboardOnStop?"copied to clipboard":"captured (clipboard write disabled)";this.notifications.info(`Clipsnag: ${c}${r}`,`${M(n)} of output · ${s.length} characters${a}`)}abortRecording(t){const e=this.active;e&&(this.active=null,e.idleStop$.next(),e.recorder.dispose(),e.subscriptions.unsubscribe(),e.buffer.reset(),e.idleStop$.complete(),this.notifications.notice(`Clipsnag: recording cancelled — ${t}`))}resolveActiveTerminal(){const t=this.app.activeTab;if(!t)return null;if(t instanceof a.BaseTerminalTabComponent)return t;if(t instanceof o.SplitTabComponent){const e=t.getFocusedTab();if(e instanceof a.BaseTerminalTabComponent)return e}return null}settings(){const t=this.config.store.clipsnag,e=Number(t?.maxCaptureBytes),s=Number(t?.autoStopAfterIdleMs);return{maxCaptureBytes:Number.isFinite(e)&&e>0?e:u.maxCaptureBytes,autoStopAfterIdleMs:Number.isFinite(s)&&s>=0?s:u.autoStopAfterIdleMs,copyToClipboardOnStop:t?.copyToClipboardOnStop??u.copyToClipboardOnStop,stopOnTabSwitch:t?.stopOnTabSwitch??u.stopOnTabSwitch}}};function M(t){return t<1024?`${t} B`:t<1048576?`${(t/1024).toFixed(1)} KB`:`${(t/1048576).toFixed(1)} MB`}$=function(t,e,s,i){var n,o=arguments.length,r=o<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,s):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)r=Reflect.decorate(t,e,s,i);else for(var a=t.length-1;a>=0;a--)(n=t[a])&&(r=(o<3?n(r):o>3?n(e,s,r):n(e,s))||r);return o>3&&r&&Object.defineProperty(e,s,r),r}([(0,s.Injectable)({providedIn:"root"}),function(t,e){if("object"==typeof Reflect&&"function"==typeof Reflect.metadata)return Reflect.metadata(t,e)}("design:paramtypes",[o.ConfigService,o.HotkeysService,o.NotificationsService,o.PlatformService,o.AppService,s.NgZone,f])],$);const R=1048576;let O=class{constructor(t){this.config=t,this.ensureSettings()}get maxCaptureMiB(){return k((this.settings().maxCaptureBytes??u.maxCaptureBytes)/R,2)}get autoStopSeconds(){return k((this.settings().autoStopAfterIdleMs??u.autoStopAfterIdleMs)/1e3,2)}setMaxCaptureMiB(t){const e=Number(t),s=Number.isFinite(e)&&e>0?Math.round(e*R):u.maxCaptureBytes;this.settings().maxCaptureBytes=s,this.save()}setAutoStopSeconds(t){const e=Number(t),s=Number.isFinite(e)&&e>=0?Math.round(1e3*e):u.autoStopAfterIdleMs;this.settings().autoStopAfterIdleMs=s,this.save()}setCopyToClipboard(t){this.settings().copyToClipboardOnStop=t,this.save()}setStopOnTabSwitch(t){this.settings().stopOnTabSwitch=t,this.save()}ensureSettings(){const t=this.config.store;t.clipsnag&&"object"==typeof t.clipsnag||(t.clipsnag={...u})}settings(){return this.ensureSettings(),this.config.store.clipsnag}save(){this.config.save()}};O=function(t,e,s,i){var n,o=arguments.length,r=o<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,s):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)r=Reflect.decorate(t,e,s,i);else for(var a=t.length-1;a>=0;a--)(n=t[a])&&(r=(o<3?n(r):o>3?n(e,s,r):n(e,s))||r);return o>3&&r&&Object.defineProperty(e,s,r),r}([(0,s.Component)({selector:"clipsnag-settings-tab",encapsulation:s.ViewEncapsulation.None,template:'\n <h3 class="mb-3">Clipsnag</h3>\n\n <div class="content-box">\n <div class="form-line">\n <div class="header">\n <div class="title">Capture buffer limit (MiB)</div>\n <div class="description">Maximum amount of output kept per recording; older bytes are dropped past this size</div>\n </div>\n <input\n class="form-control"\n type="number"\n min="0.1"\n step="0.1"\n [ngModel]="maxCaptureMiB"\n (ngModelChange)="setMaxCaptureMiB($event)"\n >\n </div>\n\n <div class="form-line">\n <div class="header">\n <div class="title">Stop after idle (seconds)</div>\n <div class="description">Recording stops automatically after this much silence; set to 0 to disable</div>\n </div>\n <input\n class="form-control"\n type="number"\n min="0"\n step="1"\n [ngModel]="autoStopSeconds"\n (ngModelChange)="setAutoStopSeconds($event)"\n >\n </div>\n\n <div class="form-line">\n <div class="header">\n <div class="title">Copy to clipboard on stop</div>\n <div class="description">Write the ANSI-stripped capture to the system clipboard when recording ends</div>\n </div>\n <toggle [ngModel]="settings().copyToClipboardOnStop" (ngModelChange)="setCopyToClipboard($event)"></toggle>\n </div>\n\n <div class="form-line">\n <div class="header">\n <div class="title">Stop on tab switch</div>\n <div class="description">Finish and copy the capture as soon as you switch away from the recorded tab</div>\n </div>\n <toggle [ngModel]="settings().stopOnTabSwitch" (ngModelChange)="setStopOnTabSwitch($event)"></toggle>\n </div>\n </div>\n '}),function(t,e){if("object"==typeof Reflect&&"function"==typeof Reflect.metadata)return Reflect.metadata(t,e)}("design:paramtypes",[o.ConfigService])],O);class x extends c.SettingsTabProvider{constructor(){super(...arguments),this.id="clipsnag",this.icon="clipboard",this.title="Clipsnag",this.weight=10}getComponentType(){return O}}function k(t,e){const s=10**e;return Math.round(t*s)/s}let P=class{constructor(t){this.service=t,this.service.init()}};P=function(t,e,s,i){var n,o=arguments.length,r=o<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,s):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)r=Reflect.decorate(t,e,s,i);else for(var a=t.length-1;a>=0;a--)(n=t[a])&&(r=(o<3?n(r):o>3?n(e,s,r):n(e,s))||r);return o>3&&r&&Object.defineProperty(e,s,r),r}([(0,s.NgModule)({imports:[i.CommonModule,n.FormsModule,r()],declarations:[O],providers:[{provide:o.HotkeyProvider,useClass:p,multi:!0},{provide:o.ConfigProvider,useClass:l,multi:!0},{provide:a.TerminalDecorator,useClass:f,multi:!0},{provide:c.SettingsTabProvider,useClass:x,multi:!0},$,f]}),function(t,e){if("object"==typeof Reflect&&"function"==typeof Reflect.metadata)return Reflect.metadata(t,e)}("design:paramtypes",[$])],P);const j=P;module.exports=e})();
@@ -0,0 +1,39 @@
1
+ import { ConfigService } from 'tabby-core';
2
+ import { SettingsTabProvider } from 'tabby-settings';
3
+ import { ClipsnagConfig } from './config';
4
+ /**
5
+ * Settings UI for the Clipsnag plugin.
6
+ *
7
+ * Binds two-way to `config.store.clipsnag`; every change persists immediately
8
+ * through `ConfigService.save()`, mirroring the behaviour of Tabby's own tabs.
9
+ */
10
+ export declare class ClipsnagSettingsTabComponent {
11
+ readonly config: ConfigService;
12
+ constructor(config: ConfigService);
13
+ /** MiB as shown in the number input. */
14
+ get maxCaptureMiB(): number;
15
+ /** Seconds as shown in the number input. */
16
+ get autoStopSeconds(): number;
17
+ setMaxCaptureMiB(value: number | string): void;
18
+ setAutoStopSeconds(value: number | string): void;
19
+ setCopyToClipboard(value: boolean): void;
20
+ setStopOnTabSwitch(value: boolean): void;
21
+ /**
22
+ * `config.store.clipsnag` is seeded by the config provider, but the settings
23
+ * component is also reachable before the store is merged (e.g. during a
24
+ * config-file reload). Materialize the section so nested bindings never read
25
+ * through `undefined`.
26
+ */
27
+ private ensureSettings;
28
+ /** Config view used by the template; never yields `undefined` fields. */
29
+ settings(): Partial<ClipsnagConfig>;
30
+ private save;
31
+ }
32
+ /** Registers the Clipsnag section in Tabby's settings window. */
33
+ export declare class ClipsnagSettingsTabProvider extends SettingsTabProvider {
34
+ id: string;
35
+ icon: string;
36
+ title: string;
37
+ weight: number;
38
+ getComponentType(): unknown;
39
+ }
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
- {
2
- "name": "tabby-clipsnag",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "tabby-clipsnag",
3
+ "version": "1.0.0",
4
+ "description": "Tabby plugin: hotkey-driven terminal output capture that strips ANSI and copies clean text to the clipboard",
5
+ "keywords": [
6
+ "tabby-plugin",
7
+ "tabby",
8
+ "terminus",
9
+ "clipboard",
10
+ "ansi",
11
+ "terminal"
12
+ ],
13
+ "main": "dist/index.js",
14
+ "types": "dist/index.d.ts",
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "README_ru.md",
19
+ "LICENSE"
20
+ ],
21
+ "author": "alexoffspace",
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/alexoffspace/tabby-clipsnag.git"
26
+ },
27
+ "scripts": {
28
+ "build": "webpack --mode production",
29
+ "watch": "webpack --mode development --watch",
30
+ "typecheck": "tsc --noEmit",
31
+ "lint": "eslint \"src/**/*.ts\"",
32
+ "lint:fix": "eslint \"src/**/*.ts\" --fix",
33
+ "prepublishOnly": "npm run build"
34
+ },
35
+ "dependencies": {
36
+ "strip-ansi": "^7.1.0"
37
+ },
38
+ "peerDependencies": {
39
+ "@angular/common": "^15",
40
+ "@angular/core": "^15",
41
+ "@angular/forms": "^15",
42
+ "@angular/platform-browser": "^15",
43
+ "rxjs": "^7",
44
+ "tabby-core": "*",
45
+ "tabby-terminal": "*"
46
+ },
47
+ "devDependencies": {
48
+ "@angular/common": "^15.2.10",
49
+ "@angular/compiler": "^15.2.10",
50
+ "@angular/compiler-cli": "^15.2.10",
51
+ "@angular/core": "^15.2.10",
52
+ "@angular/platform-browser": "^15.2.10",
53
+ "@types/node": "^20.14.9",
54
+ "@typescript-eslint/eslint-plugin": "^7.16.0",
55
+ "@typescript-eslint/parser": "^7.16.0",
56
+ "eslint": "^8.57.0",
57
+ "ts-loader": "^9.5.1",
58
+ "typescript": "~4.9.5",
59
+ "webpack": "^5.92.1",
60
+ "webpack-cli": "^5.1.4"
61
+ },
62
+ "engines": {
63
+ "node": ">=18"
64
+ }
65
+ }