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 +156 -0
- package/bin/ttc +96 -0
- package/package.json +43 -0
- package/scripts/postinstall.js +32 -0
- package/src/config.js +47 -0
- package/src/screenshot.js +40 -0
- package/src/voice.js +122 -0
- package/src/wrapper.js +178 -0
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;
|