talk-to-copilot 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/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # talk-to-copilot
2
+
3
+ A transparent PTY wrapper for [GitHub Copilot CLI](https://github.com/github/copilot-cli) that adds **voice input** and **screenshot attachment** — without changing how you use Copilot at all.
4
+
5
+ Run `ttc` instead of `copilot`. Everything works identically, plus two new hotkeys.
6
+
7
+ ```
8
+ Ctrl+R → Start / stop voice recording (transcription injected as text)
9
+ Ctrl+P → Interactive screenshot picker (injected as @/path/to/file.png)
10
+ ```
11
+
12
+ ---
13
+
14
+ ## Installation
15
+
16
+ ### Homebrew (recommended — installs ffmpeg + whisper-cpp automatically)
17
+
18
+ ```bash
19
+ brew tap Errr0rr404/ttc
20
+ brew install ttc
21
+ whisper-cpp-download-ggml-model base.en # one-time: download speech model
22
+ ttc --setup # verify everything is ready
23
+ ```
24
+
25
+ ### npm
26
+
27
+ ```bash
28
+ npm install -g talk-to-copilot
29
+ # You still need ffmpeg and whisper-cpp:
30
+ brew install ffmpeg whisper-cpp
31
+ whisper-cpp-download-ggml-model base.en
32
+ ttc --setup
33
+ ```
34
+
35
+ ---
36
+
37
+ ## How it works
38
+
39
+ ```
40
+ ┌─────────────────────────────────────────────────────────┐
41
+ │ ttc (PTY wrapper) │
42
+ │ │
43
+ │ stdin ──► intercept Ctrl+R / Ctrl+P │
44
+ │ │ │ │
45
+ │ ▼ ▼ │
46
+ │ voice recorder screencapture -i │
47
+ │ ffmpeg + whisper-cli saves PNG to /tmp │
48
+ │ │ │ │
49
+ │ └──────────┬────────────────┘ │
50
+ │ ▼ │
51
+ │ inject text / @path │
52
+ │ │ │
53
+ │ copilot (PTY child) ◄──┘ (all other keystrokes pass │
54
+ │ through unchanged) │
55
+ └─────────────────────────────────────────────────────────┘
56
+ ```
57
+
58
+ Transcriptions are injected as raw text — **no Enter is pressed automatically** so you can review and edit before sending. Screenshots are injected as `@/tmp/copilot-screenshots/screenshot-<ts>.png` which Copilot CLI's `@` file-mention picks up.
59
+
60
+ ---
61
+
62
+ ## Prerequisites
63
+
64
+ | Tool | Install |
65
+ |------|---------|
66
+ | [GitHub Copilot CLI](https://github.com/github/copilot-cli) | see their docs |
67
+ | [ffmpeg](https://ffmpeg.org) | `brew install ffmpeg` |
68
+ | [whisper.cpp](https://github.com/ggerganov/whisper.cpp) | `brew install whisper-cpp` |
69
+ | A whisper model | `whisper-cpp-download-ggml-model base.en` |
70
+ | Node.js ≥ 18 | `brew install node` |
71
+
72
+ > **Apple Silicon note:** The `base.en` model runs in ~1–2 s on M1/M2/M3. Use `small.en` for better accuracy at ~3–4 s.
73
+
74
+ ---
75
+
76
+ ## Installation
77
+
78
+ ```bash
79
+ git clone https://github.com/yourname/talk-to-copilot
80
+ cd talk-to-copilot
81
+ npm install
82
+ npm link # makes `ttc` available system-wide
83
+ ```
84
+
85
+ Verify everything is wired up:
86
+
87
+ ```bash
88
+ talk --setup
89
+ ```
90
+
91
+ ---
92
+
93
+ ## Usage
94
+
95
+ ```bash
96
+ talk # drop-in replacement for `copilot`
97
+ talk --setup # check dependencies and show config
98
+ ```
99
+
100
+ Any flags you pass are forwarded to `copilot` directly:
101
+
102
+ ```bash
103
+ talk --experimental
104
+ talk --banner
105
+ ```
106
+
107
+ ### Voice recording
108
+
109
+ 1. Press **Ctrl+R** — the terminal title changes to `🎙 Recording…` and a macOS notification appears.
110
+ 2. Speak your prompt.
111
+ 3. Press **Ctrl+R** again — transcription begins (`⏳ Transcribing…`).
112
+ 4. The transcribed text appears in the Copilot input. Review it, then press **Enter** to send.
113
+ 5. Press **Ctrl+C** while recording to cancel without transcribing.
114
+
115
+ ### Screenshot
116
+
117
+ 1. Press **Ctrl+P** — the macOS screenshot overlay appears (same as ⌘⇧4).
118
+ 2. Draw a selection around the area you want to share.
119
+ 3. The path is injected as `@/tmp/copilot-screenshots/screenshot-<ts>.png`.
120
+ 4. Type any additional context, then press **Enter**.
121
+
122
+ ---
123
+
124
+ ## Configuration
125
+
126
+ Config is stored at `~/.copilot/talk-to-copilot.json`:
127
+
128
+ ```json
129
+ {
130
+ "modelPath": "/opt/homebrew/share/whisper.cpp/models/ggml-base.en.bin",
131
+ "audioDevice": ":0",
132
+ "autoSubmit": false
133
+ }
134
+ ```
135
+
136
+ | Key | Default | Description |
137
+ |-----|---------|-------------|
138
+ | `modelPath` | auto-detected | Path to your `.bin` whisper model |
139
+ | `audioDevice` | `:0` | ffmpeg avfoundation mic index (run `ffmpeg -f avfoundation -list_devices true -i ""` to list) |
140
+ | `autoSubmit` | `false` | Set to `true` to auto-press Enter after transcription |
141
+
142
+ ---
143
+
144
+ ## Troubleshooting
145
+
146
+ **`Error: could not open input device`**
147
+ Grant microphone access: *System Settings → Privacy & Security → Microphone → Terminal*.
148
+
149
+ **`No whisper model found`**
150
+ Run `whisper-cpp-download-ggml-model base.en`, then `talk --setup` to verify.
151
+
152
+ **Transcription is empty or garbled**
153
+ Try a larger model: `whisper-cpp-download-ggml-model small.en`, then update `modelPath` in your config.
154
+
155
+ **Wrong microphone is used**
156
+ Run `ffmpeg -f avfoundation -list_devices true -i ""` and set `audioDevice` in the config (e.g. `":1"`).
package/bin/ttc ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { execFile } = require('child_process');
5
+ const fs = require('fs');
6
+ const cfg = require('../src/config');
7
+ const CopilotWrapper = require('../src/wrapper');
8
+
9
+ // ─── Setup mode ────────────────────────────────────────────────────────────────
10
+ if (process.argv.includes('--setup')) {
11
+ runSetup(); // process exits inside runSetup via setTimeout
12
+ return;
13
+ }
14
+
15
+ // ─── Normal mode ───────────────────────────────────────────────────────────────
16
+ const config = cfg.load();
17
+ const args = process.argv.slice(2);
18
+
19
+ const wrapper = new CopilotWrapper(args, config);
20
+ wrapper.start();
21
+
22
+ // ─── Setup helper ──────────────────────────────────────────────────────────────
23
+ function runSetup() {
24
+ const config = cfg.load();
25
+
26
+ console.log('\n🛠 talk-to-copilot setup\n');
27
+ console.log('Checking dependencies…\n');
28
+
29
+ let allGood = true;
30
+
31
+ // Check copilot
32
+ execFile('which', ['copilot'], (err) => {
33
+ if (err) {
34
+ console.log('❌ copilot — not found in PATH');
35
+ console.log(' Install: https://github.com/github/copilot-cli\n');
36
+ allGood = false;
37
+ } else {
38
+ console.log('✅ copilot — found');
39
+ }
40
+ });
41
+
42
+ // Check ffmpeg
43
+ execFile('which', ['ffmpeg'], (err) => {
44
+ if (err) {
45
+ console.log('❌ ffmpeg — not found');
46
+ console.log(' Install: brew install ffmpeg\n');
47
+ allGood = false;
48
+ } else {
49
+ console.log('✅ ffmpeg — found');
50
+ }
51
+ });
52
+
53
+ // Check whisper-cli
54
+ execFile('which', ['whisper-cli'], (err) => {
55
+ if (err) {
56
+ console.log('❌ whisper-cli — not found');
57
+ console.log(' Install: brew install whisper-cpp');
58
+ console.log(' Model: whisper-cpp-download-ggml-model base.en\n');
59
+ allGood = false;
60
+ } else {
61
+ console.log('✅ whisper-cli — found');
62
+
63
+ const model = cfg.findWhisperModel();
64
+ if (model) {
65
+ console.log(`✅ model — ${model}`);
66
+ } else {
67
+ console.log('❌ model — no model file found');
68
+ console.log(' Run: whisper-cpp-download-ggml-model base.en\n');
69
+ allGood = false;
70
+ }
71
+ }
72
+ });
73
+
74
+ // Print config & hotkey summary after a short delay (so async checks print first)
75
+ setTimeout(() => {
76
+ console.log('\n─────────────────────────────────────');
77
+ console.log('Config:', cfg.CONFIG_PATH);
78
+ console.log(` modelPath: ${config.modelPath || '(not set)'}`);
79
+ console.log(` audioDevice: ${config.audioDevice} (avfoundation mic index)`);
80
+ console.log(` autoSubmit: ${config.autoSubmit} (auto-press Enter after transcription)`);
81
+ console.log('\nHotkeys (inside talk):');
82
+ console.log(' Ctrl+R → Start / stop voice recording');
83
+ console.log(' Ctrl+P → Take a screenshot (injected as @path)');
84
+ console.log('\nUsage:');
85
+ console.log(' talk → launch copilot with voice + screenshot support');
86
+ console.log(' talk --setup → show this screen');
87
+ console.log(' talk --help → pass --help through to copilot\n');
88
+
89
+ if (allGood) {
90
+ console.log('✅ All dependencies found. Run `talk` to start!\n');
91
+ } else {
92
+ console.log('⚠️ Fix the issues above, then re-run `talk --setup`.\n');
93
+ }
94
+ process.exit(0);
95
+ }, 400);
96
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "talk-to-copilot",
3
+ "version": "1.0.0",
4
+ "description": "Voice + screenshot input wrapper for GitHub Copilot CLI — use your mic and screen instead of typing",
5
+ "bin": {
6
+ "ttc": "bin/ttc"
7
+ },
8
+ "scripts": {
9
+ "setup": "node bin/ttc --setup",
10
+ "postinstall": "node scripts/postinstall.js"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "src/",
15
+ "scripts/"
16
+ ],
17
+ "keywords": [
18
+ "copilot",
19
+ "github-copilot",
20
+ "voice",
21
+ "speech",
22
+ "whisper",
23
+ "cli",
24
+ "ai",
25
+ "screenshot"
26
+ ],
27
+ "author": "Errr0rr404",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/Errr0rr404/talk-to-copilot.git"
32
+ },
33
+ "homepage": "https://github.com/Errr0rr404/talk-to-copilot#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/Errr0rr404/talk-to-copilot/issues"
36
+ },
37
+ "dependencies": {
38
+ "node-pty": "^1.0.0"
39
+ },
40
+ "engines": {
41
+ "node": ">=18"
42
+ }
43
+ }
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ // Automatically fix node-pty spawn-helper permissions after npm install.
4
+ // node-pty ships prebuilt binaries without the executable bit set on macOS,
5
+ // which causes "posix_spawnp failed" at runtime without this fix.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+
11
+ if (os.platform() !== 'darwin' && os.platform() !== 'linux') process.exit(0);
12
+
13
+ const prebuildDir = path.join(__dirname, '..', 'node_modules', 'node-pty', 'prebuilds');
14
+ if (!fs.existsSync(prebuildDir)) process.exit(0);
15
+
16
+ const platform = `${os.platform()}-${os.arch()}`;
17
+ const targets = [
18
+ path.join(prebuildDir, platform, 'spawn-helper'),
19
+ path.join(prebuildDir, platform, 'pty.node'),
20
+ ];
21
+
22
+ let fixed = 0;
23
+ for (const t of targets) {
24
+ if (fs.existsSync(t)) {
25
+ fs.chmodSync(t, 0o755);
26
+ fixed++;
27
+ }
28
+ }
29
+
30
+ if (fixed > 0) {
31
+ console.log(`[talk-to-copilot] Fixed node-pty permissions for ${platform} (${fixed} files)`);
32
+ }
package/src/config.js ADDED
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const CONFIG_PATH = path.join(os.homedir(), '.copilot', 'talk-to-copilot.json');
8
+
9
+ const WHISPER_MODEL_CANDIDATES = [
10
+ path.join(os.homedir(), '.copilot', 'whisper-model.bin'),
11
+ path.join(__dirname, '..', 'models', 'ggml-base.en.bin'),
12
+ path.join(__dirname, '..', 'models', 'ggml-small.en.bin'),
13
+ path.join(__dirname, '..', 'models', 'ggml-tiny.en.bin'),
14
+ '/opt/homebrew/share/whisper.cpp/models/ggml-base.en.bin',
15
+ '/opt/homebrew/share/whisper.cpp/models/ggml-small.en.bin',
16
+ '/opt/homebrew/share/whisper.cpp/models/ggml-tiny.en.bin',
17
+ '/usr/local/share/whisper.cpp/models/ggml-base.en.bin',
18
+ ];
19
+
20
+ function findWhisperModel() {
21
+ return WHISPER_MODEL_CANDIDATES.find(p => fs.existsSync(p)) || null;
22
+ }
23
+
24
+ function load() {
25
+ const defaults = {
26
+ modelPath: findWhisperModel(),
27
+ audioDevice: ':0', // avfoundation default mic
28
+ autoSubmit: false, // whether to press Enter after injecting transcription
29
+ recordKey: 'ctrl+r',
30
+ screenshotKey: 'ctrl+p',
31
+ };
32
+
33
+ if (!fs.existsSync(CONFIG_PATH)) return defaults;
34
+
35
+ try {
36
+ return Object.assign(defaults, JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')));
37
+ } catch {
38
+ return defaults;
39
+ }
40
+ }
41
+
42
+ function save(config) {
43
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
44
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
45
+ }
46
+
47
+ module.exports = { load, save, findWhisperModel, CONFIG_PATH };
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+
8
+ const SCREENSHOTS_DIR = path.join(os.tmpdir(), 'copilot-screenshots');
9
+
10
+ fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
11
+
12
+ /**
13
+ * Launch the macOS interactive screenshot picker.
14
+ * Resolves to the saved file path, or null if the user cancelled.
15
+ * @returns {Promise<string|null>}
16
+ */
17
+ function capture() {
18
+ const filePath = path.join(SCREENSHOTS_DIR, `screenshot-${Date.now()}.png`);
19
+
20
+ return new Promise((resolve, reject) => {
21
+ const proc = spawn('screencapture', [
22
+ '-i', // interactive selection
23
+ '-x', // no shutter sound
24
+ filePath,
25
+ ]);
26
+
27
+ proc.on('error', reject);
28
+
29
+ proc.on('exit', code => {
30
+ // screencapture exits 0 even on ESC but doesn't create the file
31
+ if (code === 0 && fs.existsSync(filePath)) {
32
+ resolve(filePath);
33
+ } else {
34
+ resolve(null);
35
+ }
36
+ });
37
+ });
38
+ }
39
+
40
+ module.exports = { capture };
package/src/voice.js ADDED
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ const { spawn, execFile } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+
8
+ class VoiceRecorder {
9
+ constructor(config) {
10
+ this.config = config;
11
+ this._proc = null;
12
+ this._audioFile = null;
13
+ }
14
+
15
+ get isRecording() {
16
+ return this._proc !== null;
17
+ }
18
+
19
+ /** Start recording from the microphone. Returns immediately. */
20
+ start() {
21
+ if (this._proc) return;
22
+
23
+ this._audioFile = path.join(os.tmpdir(), `copilot-voice-${Date.now()}.wav`);
24
+
25
+ this._proc = spawn('ffmpeg', [
26
+ '-f', 'avfoundation',
27
+ '-i', this.config.audioDevice,
28
+ '-ar', '16000', // 16kHz — whisper requirement
29
+ '-ac', '1', // mono
30
+ '-y',
31
+ this._audioFile,
32
+ ], {
33
+ stdio: ['pipe', 'ignore', 'ignore'],
34
+ });
35
+
36
+ this._proc.on('error', err => {
37
+ this._cleanup();
38
+ throw err;
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Stop recording and transcribe. Returns the transcribed text, or '' if nothing was heard.
44
+ * @returns {Promise<string>}
45
+ */
46
+ async stopAndTranscribe() {
47
+ if (!this._proc) return '';
48
+
49
+ const audioFile = this._audioFile;
50
+ const proc = this._proc;
51
+ this._proc = null;
52
+ this._audioFile = null;
53
+
54
+ // Ask ffmpeg to stop gracefully; it finalises the WAV header before exit
55
+ await new Promise((resolve, reject) => {
56
+ proc.stdin.write('q');
57
+ proc.stdin.end();
58
+ const timer = setTimeout(() => { proc.kill('SIGTERM'); }, 3000);
59
+ proc.on('exit', () => { clearTimeout(timer); resolve(); });
60
+ proc.on('error', reject);
61
+ });
62
+
63
+ if (!fs.existsSync(audioFile)) {
64
+ throw new Error('Audio file was not created — is the microphone accessible?');
65
+ }
66
+
67
+ try {
68
+ return await this._transcribe(audioFile);
69
+ } finally {
70
+ fs.unlink(audioFile, () => {});
71
+ }
72
+ }
73
+
74
+ /** Cancel in-progress recording without transcribing. */
75
+ cancel() {
76
+ if (!this._proc) return;
77
+ this._proc.kill('SIGTERM');
78
+ this._cleanup();
79
+ }
80
+
81
+ _cleanup() {
82
+ this._proc = null;
83
+ if (this._audioFile) {
84
+ fs.unlink(this._audioFile, () => {});
85
+ this._audioFile = null;
86
+ }
87
+ }
88
+
89
+ /** @returns {Promise<string>} */
90
+ _transcribe(audioFile) {
91
+ const { modelPath } = this.config;
92
+
93
+ if (!modelPath) {
94
+ return Promise.reject(new Error(
95
+ 'No whisper model found. Run: talk --setup'
96
+ ));
97
+ }
98
+
99
+ return new Promise((resolve, reject) => {
100
+ execFile('whisper-cli', [
101
+ '-m', modelPath,
102
+ '-f', audioFile,
103
+ '-np', // no extra prints
104
+ '-nt', // no timestamps
105
+ ], (err, stdout) => {
106
+ if (err) return reject(err);
107
+
108
+ const text = stdout
109
+ .split('\n')
110
+ .map(l => l.trim())
111
+ .filter(Boolean)
112
+ // whisper sometimes emits noise-only lines like "[BLANK_AUDIO]"
113
+ .filter(l => !l.startsWith('[') || !l.endsWith(']'))
114
+ .join(' ');
115
+
116
+ resolve(text);
117
+ });
118
+ });
119
+ }
120
+ }
121
+
122
+ module.exports = VoiceRecorder;
package/src/wrapper.js ADDED
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ const pty = require('node-pty');
4
+ const { execFile, execFileSync } = require('child_process');
5
+
6
+ const config = require('./config');
7
+ const VoiceRecorder = require('./voice');
8
+ const screenshot = require('./screenshot');
9
+
10
+ // Byte sequences we intercept before forwarding to copilot
11
+ const CTRL_R = '\x12'; // voice toggle
12
+ const CTRL_P = '\x10'; // screenshot
13
+ const CTRL_C = '\x03';
14
+
15
+ /** Resolve the absolute path to a binary so node-pty's posix_spawnp can find it. */
16
+ function resolveBin(name) {
17
+ try {
18
+ return execFileSync('which', [name], { encoding: 'utf8' }).trim();
19
+ } catch {
20
+ return name; // fall back to letting PATH sort it out
21
+ }
22
+ }
23
+
24
+ class CopilotWrapper {
25
+ constructor(args, cfg) {
26
+ this.args = args;
27
+ this.cfg = cfg;
28
+ this.voice = new VoiceRecorder(cfg);
29
+ this._busy = false; // prevent overlapping async operations
30
+ }
31
+
32
+ start() {
33
+ const shell = pty.spawn(resolveBin('copilot'), this.args, {
34
+ name: process.env.TERM || 'xterm-256color',
35
+ cols: process.stdout.columns || 80,
36
+ rows: process.stdout.rows || 24,
37
+ cwd: process.cwd(),
38
+ env: process.env,
39
+ });
40
+
41
+ this._shell = shell;
42
+
43
+ // PTY → real terminal
44
+ shell.onData(data => process.stdout.write(data));
45
+
46
+ shell.onExit(({ exitCode }) => process.exit(exitCode));
47
+
48
+ // Resize relay
49
+ process.stdout.on('resize', () => {
50
+ try { shell.resize(process.stdout.columns, process.stdout.rows); } catch {}
51
+ });
52
+
53
+ // Real terminal → PTY (with hotkey interception)
54
+ process.stdin.setRawMode(true);
55
+ process.stdin.resume();
56
+ process.stdin.on('data', data => this._handleInput(data));
57
+
58
+ // Graceful shutdown: stop any in-progress recording
59
+ process.on('exit', () => { if (this.voice.isRecording) this.voice.cancel(); });
60
+ process.on('SIGTERM', () => process.exit(0));
61
+ }
62
+
63
+ _handleInput(data) {
64
+ const key = data.toString();
65
+
66
+ if (key === CTRL_R) {
67
+ if (this.voice.isRecording) {
68
+ this._stopVoice();
69
+ } else {
70
+ this._startVoice();
71
+ }
72
+ return;
73
+ }
74
+
75
+ if (key === CTRL_P) {
76
+ this._doScreenshot();
77
+ return;
78
+ }
79
+
80
+ // Ctrl+C while recording cancels the recording; still forward to copilot
81
+ if (key === CTRL_C && this.voice.isRecording) {
82
+ this.voice.cancel();
83
+ this._setTitle('copilot');
84
+ this._notify('🚫 Recording cancelled', '');
85
+ }
86
+
87
+ this._shell.write(key);
88
+ }
89
+
90
+ // --- Voice ---
91
+
92
+ _startVoice() {
93
+ if (this._busy) return;
94
+ try {
95
+ this.voice.start();
96
+ this._setTitle('🎙 Recording… (Ctrl+R to stop, Ctrl+C to cancel)');
97
+ this._notify('🎙 Recording started', 'Press Ctrl+R to stop');
98
+ } catch (err) {
99
+ this._notify('❌ Could not start recording', err.message);
100
+ }
101
+ }
102
+
103
+ _stopVoice() {
104
+ if (this._busy) return;
105
+ this._busy = true;
106
+ this._setTitle('⏳ Transcribing…');
107
+ this._notify('⏳ Transcribing…', 'Please wait');
108
+
109
+ this.voice.stopAndTranscribe()
110
+ .then(text => {
111
+ this._setTitle('copilot');
112
+ if (text) {
113
+ this._shell.write(text + (this.cfg.autoSubmit ? '\r' : ''));
114
+ this._notify('✅ Done', text.length > 80 ? text.slice(0, 77) + '…' : text);
115
+ } else {
116
+ this._notify('⚠️ Nothing heard', 'Try speaking more clearly');
117
+ }
118
+ })
119
+ .catch(err => {
120
+ this._setTitle('copilot');
121
+ this._notify('❌ Transcription failed', err.message.slice(0, 80));
122
+ })
123
+ .finally(() => { this._busy = false; });
124
+ }
125
+
126
+ // --- Screenshot ---
127
+
128
+ _doScreenshot() {
129
+ if (this._busy) return;
130
+ this._busy = true;
131
+ this._setTitle('📸 Select area…');
132
+ this._notify('📸 Screenshot', 'Draw to select · Esc to cancel');
133
+
134
+ screenshot.capture()
135
+ .then(filePath => {
136
+ this._setTitle('copilot');
137
+ if (filePath) {
138
+ // Inject @path so the user can see it and optionally add a prompt before sending
139
+ this._shell.write(`@${filePath} `);
140
+ this._notify('✅ Screenshot attached', filePath);
141
+ // Force copilot TUI to repaint after screencapture overlay closes
142
+ this._nudgeResize();
143
+ } else {
144
+ this._notify('📸 Cancelled', '');
145
+ }
146
+ })
147
+ .catch(err => {
148
+ this._setTitle('copilot');
149
+ this._notify('❌ Screenshot failed', err.message.slice(0, 80));
150
+ })
151
+ .finally(() => { this._busy = false; });
152
+ }
153
+
154
+ // --- Helpers ---
155
+
156
+ /** Flicker the PTY size by ±1 to trigger a TUI repaint. */
157
+ _nudgeResize() {
158
+ const cols = process.stdout.columns || 80;
159
+ const rows = process.stdout.rows || 24;
160
+ try {
161
+ this._shell.resize(cols, rows + 1);
162
+ setTimeout(() => { try { this._shell.resize(cols, rows); } catch {} }, 60);
163
+ } catch {}
164
+ }
165
+
166
+ _setTitle(title) {
167
+ process.stdout.write(`\x1b]0;${title}\x07`);
168
+ }
169
+
170
+ _notify(title, subtitle) {
171
+ execFile('osascript', [
172
+ '-e',
173
+ `display notification ${JSON.stringify(subtitle)} with title ${JSON.stringify(title)}`,
174
+ ]).unref();
175
+ }
176
+ }
177
+
178
+ module.exports = CopilotWrapper;