claude-dock 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 +21 -0
- package/README.md +116 -0
- package/bin/cli.js +168 -0
- package/init.lua +603 -0
- package/package.json +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Claude Dock
|
|
2
|
+
|
|
3
|
+
A lightweight, expandable terminal dock for macOS built with [Hammerspoon](https://www.hammerspoon.org/). Designed for managing multiple Claude Code terminal sessions.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Expandable dock** - Start with 3 slots, add more with "+" button or hotkey
|
|
10
|
+
- **Terminal management** - Each slot tracks a specific terminal window
|
|
11
|
+
- **Auto-launch Claude** - New terminals automatically run `claude` command
|
|
12
|
+
- **Custom naming** - Name your terminals for easy identification
|
|
13
|
+
- **Visual status** - See which terminals are active, minimized, or empty
|
|
14
|
+
- **Quick access** - Click to focus/unminimize terminals
|
|
15
|
+
- **Keyboard shortcuts** - Full hotkey support
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
### Prerequisites
|
|
20
|
+
|
|
21
|
+
1. Install [Hammerspoon](https://www.hammerspoon.org/):
|
|
22
|
+
```bash
|
|
23
|
+
brew install --cask hammerspoon
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
2. Grant Accessibility permissions:
|
|
27
|
+
- System Settings > Privacy & Security > Accessibility
|
|
28
|
+
- Enable Hammerspoon
|
|
29
|
+
|
|
30
|
+
### Setup
|
|
31
|
+
|
|
32
|
+
1. Clone this repo (or download the ZIP from GitHub)
|
|
33
|
+
|
|
34
|
+
2. Copy to Hammerspoon config:
|
|
35
|
+
```bash
|
|
36
|
+
cp claude-dock/init.lua ~/.hammerspoon/init.lua
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
3. Launch Hammerspoon (or reload if already running)
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
### Keyboard Shortcuts
|
|
44
|
+
|
|
45
|
+
| Shortcut | Action |
|
|
46
|
+
|----------|--------|
|
|
47
|
+
| `Cmd+Option+T` | Toggle dock visibility |
|
|
48
|
+
| `Cmd+Option+N` | Add new slot + launch terminal |
|
|
49
|
+
| `Cmd+Option+R` | Reload configuration |
|
|
50
|
+
| `Option+Click` | Rename a slot |
|
|
51
|
+
|
|
52
|
+
### Slot States
|
|
53
|
+
|
|
54
|
+
| Color | Status |
|
|
55
|
+
|-------|--------|
|
|
56
|
+
| Gray | Empty - click to open new terminal |
|
|
57
|
+
| Green | Active terminal |
|
|
58
|
+
| Blue | Minimized terminal |
|
|
59
|
+
|
|
60
|
+
### Click Actions
|
|
61
|
+
|
|
62
|
+
- **Click empty slot** - Prompts for name, opens terminal, runs `claude`
|
|
63
|
+
- **Click active slot** - Focuses that terminal window
|
|
64
|
+
- **Click minimized slot** - Unminimizes and focuses
|
|
65
|
+
- **Click "+" button** - Adds new slot and launches terminal
|
|
66
|
+
- **Option+Click any slot** - Rename it
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
Edit `init.lua` to customize:
|
|
71
|
+
|
|
72
|
+
```lua
|
|
73
|
+
-- Configuration
|
|
74
|
+
local slotWidth = 140 -- Width of each slot
|
|
75
|
+
local slotHeight = 60 -- Height of each slot
|
|
76
|
+
local gap = 8 -- Gap between slots
|
|
77
|
+
local margin = 10 -- Dock padding
|
|
78
|
+
local bottomOffset = 5 -- Distance from screen bottom
|
|
79
|
+
local slotCount = 3 -- Initial number of slots
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Using a Different Terminal
|
|
83
|
+
|
|
84
|
+
To use iTerm instead of Terminal.app, modify the `onSlotClick` function:
|
|
85
|
+
|
|
86
|
+
```lua
|
|
87
|
+
-- Change this:
|
|
88
|
+
hs.applescript([[
|
|
89
|
+
tell application "Terminal"
|
|
90
|
+
do script "claude"
|
|
91
|
+
activate
|
|
92
|
+
end tell
|
|
93
|
+
]])
|
|
94
|
+
|
|
95
|
+
-- To this:
|
|
96
|
+
hs.applescript([[
|
|
97
|
+
tell application "iTerm"
|
|
98
|
+
create window with default profile command "claude"
|
|
99
|
+
activate
|
|
100
|
+
end tell
|
|
101
|
+
]])
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Running Tests
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
hs -c "runTests()"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
113
|
+
|
|
114
|
+
## Contributing
|
|
115
|
+
|
|
116
|
+
Contributions welcome! Please open an issue or PR.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { execSync, spawn } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const readline = require('readline');
|
|
7
|
+
|
|
8
|
+
const HAMMERSPOON_DIR = path.join(process.env.HOME, '.hammerspoon');
|
|
9
|
+
const INIT_LUA_PATH = path.join(HAMMERSPOON_DIR, 'init.lua');
|
|
10
|
+
const BACKUP_PATH = path.join(HAMMERSPOON_DIR, 'init.lua.backup');
|
|
11
|
+
|
|
12
|
+
const colors = {
|
|
13
|
+
reset: '\x1b[0m',
|
|
14
|
+
green: '\x1b[32m',
|
|
15
|
+
yellow: '\x1b[33m',
|
|
16
|
+
blue: '\x1b[34m',
|
|
17
|
+
red: '\x1b[31m',
|
|
18
|
+
dim: '\x1b[2m',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function log(msg) {
|
|
22
|
+
console.log(msg);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function success(msg) {
|
|
26
|
+
console.log(`${colors.green}✓${colors.reset} ${msg}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function warn(msg) {
|
|
30
|
+
console.log(`${colors.yellow}!${colors.reset} ${msg}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function info(msg) {
|
|
34
|
+
console.log(`${colors.blue}→${colors.reset} ${msg}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function error(msg) {
|
|
38
|
+
console.log(`${colors.red}✗${colors.reset} ${msg}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function commandExists(cmd) {
|
|
42
|
+
try {
|
|
43
|
+
execSync(`which ${cmd}`, { stdio: 'ignore' });
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hammerspoonInstalled() {
|
|
51
|
+
return fs.existsSync('/Applications/Hammerspoon.app');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function prompt(question) {
|
|
55
|
+
const rl = readline.createInterface({
|
|
56
|
+
input: process.stdin,
|
|
57
|
+
output: process.stdout,
|
|
58
|
+
});
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
rl.question(question, (answer) => {
|
|
61
|
+
rl.close();
|
|
62
|
+
resolve(answer.toLowerCase());
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function installHammerspoon() {
|
|
68
|
+
if (!commandExists('brew')) {
|
|
69
|
+
error('Homebrew is required to install Hammerspoon.');
|
|
70
|
+
log(' Install it from: https://brew.sh');
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
info('Installing Hammerspoon via Homebrew...');
|
|
75
|
+
try {
|
|
76
|
+
execSync('brew install --cask hammerspoon', { stdio: 'inherit' });
|
|
77
|
+
success('Hammerspoon installed');
|
|
78
|
+
} catch (e) {
|
|
79
|
+
error('Failed to install Hammerspoon');
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function backupExistingConfig() {
|
|
85
|
+
if (fs.existsSync(INIT_LUA_PATH)) {
|
|
86
|
+
fs.copyFileSync(INIT_LUA_PATH, BACKUP_PATH);
|
|
87
|
+
success(`Backed up existing config to ${colors.dim}${BACKUP_PATH}${colors.reset}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function installConfig() {
|
|
92
|
+
// Ensure .hammerspoon directory exists
|
|
93
|
+
if (!fs.existsSync(HAMMERSPOON_DIR)) {
|
|
94
|
+
fs.mkdirSync(HAMMERSPOON_DIR, { recursive: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Copy init.lua from package
|
|
98
|
+
const sourcePath = path.join(__dirname, '..', 'init.lua');
|
|
99
|
+
fs.copyFileSync(sourcePath, INIT_LUA_PATH);
|
|
100
|
+
success('Installed Claude Dock config');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function launchHammerspoon() {
|
|
104
|
+
try {
|
|
105
|
+
// Try to reload if already running
|
|
106
|
+
execSync('hs -c "hs.reload()"', { stdio: 'ignore', timeout: 3000 });
|
|
107
|
+
success('Reloaded Hammerspoon');
|
|
108
|
+
} catch {
|
|
109
|
+
// Not running, launch it
|
|
110
|
+
info('Launching Hammerspoon...');
|
|
111
|
+
spawn('open', ['-a', 'Hammerspoon'], { detached: true, stdio: 'ignore' }).unref();
|
|
112
|
+
success('Hammerspoon launched');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function main() {
|
|
117
|
+
log('');
|
|
118
|
+
log(`${colors.blue}Claude Dock${colors.reset} - Terminal dock for Claude Code sessions`);
|
|
119
|
+
log('');
|
|
120
|
+
|
|
121
|
+
// Check platform
|
|
122
|
+
if (process.platform !== 'darwin') {
|
|
123
|
+
error('Claude Dock only works on macOS');
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Check/install Hammerspoon
|
|
128
|
+
if (!hammerspoonInstalled()) {
|
|
129
|
+
warn('Hammerspoon is not installed');
|
|
130
|
+
const answer = await prompt(' Install it now? (Y/n) ');
|
|
131
|
+
if (answer === 'n' || answer === 'no') {
|
|
132
|
+
log('');
|
|
133
|
+
log('Install Hammerspoon manually:');
|
|
134
|
+
log(' brew install --cask hammerspoon');
|
|
135
|
+
log('');
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
await installHammerspoon();
|
|
139
|
+
log('');
|
|
140
|
+
} else {
|
|
141
|
+
success('Hammerspoon found');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Backup and install config
|
|
145
|
+
backupExistingConfig();
|
|
146
|
+
installConfig();
|
|
147
|
+
|
|
148
|
+
// Launch/reload Hammerspoon
|
|
149
|
+
launchHammerspoon();
|
|
150
|
+
|
|
151
|
+
// Final instructions
|
|
152
|
+
log('');
|
|
153
|
+
log(`${colors.yellow}Important:${colors.reset} Grant Accessibility permissions to Hammerspoon`);
|
|
154
|
+
log(` System Settings → Privacy & Security → Accessibility → Enable Hammerspoon`);
|
|
155
|
+
log('');
|
|
156
|
+
log(`${colors.green}Done!${colors.reset} Claude Dock is ready.`);
|
|
157
|
+
log('');
|
|
158
|
+
log(`Shortcuts:`);
|
|
159
|
+
log(` ${colors.dim}⌘⌥T${colors.reset} Toggle dock`);
|
|
160
|
+
log(` ${colors.dim}⌘⌥N${colors.reset} Add new terminal`);
|
|
161
|
+
log(` ${colors.dim}⌘⌥R${colors.reset} Reload config`);
|
|
162
|
+
log('');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
main().catch((e) => {
|
|
166
|
+
error(e.message);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
});
|
package/init.lua
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
-- Claude Dock: Terminal dock for managing Claude Code sessions
|
|
2
|
+
-- https://github.com/YOUR_USERNAME/claude-dock
|
|
3
|
+
|
|
4
|
+
require("hs.ipc")
|
|
5
|
+
|
|
6
|
+
-- Configuration
|
|
7
|
+
local config = {
|
|
8
|
+
slotWidth = 140,
|
|
9
|
+
slotHeight = 60,
|
|
10
|
+
gap = 8,
|
|
11
|
+
margin = 10,
|
|
12
|
+
bottomOffset = 5,
|
|
13
|
+
addButtonWidth = 40,
|
|
14
|
+
initialSlots = 3,
|
|
15
|
+
elementsPerSlot = 4, -- bg, border, title, status
|
|
16
|
+
baseElements = 2, -- dock bg + border
|
|
17
|
+
windowCaptureDelay = 0.3,
|
|
18
|
+
windowCaptureRetries = 5,
|
|
19
|
+
colors = {
|
|
20
|
+
dockBg = { red = 0.08, green = 0.08, blue = 0.08, alpha = 0.95 },
|
|
21
|
+
dockBorder = { red = 1, green = 1, blue = 1, alpha = 0.1 },
|
|
22
|
+
slotEmpty = { red = 0.15, green = 0.15, blue = 0.15, alpha = 1 },
|
|
23
|
+
slotActive = { red = 0.1, green = 0.2, blue = 0.1, alpha = 1 },
|
|
24
|
+
slotMinimized = { red = 0.12, green = 0.12, blue = 0.18, alpha = 1 },
|
|
25
|
+
slotBorder = { red = 1, green = 1, blue = 1, alpha = 0.15 },
|
|
26
|
+
textPrimary = { red = 0.9, green = 0.9, blue = 0.9, alpha = 1 },
|
|
27
|
+
textSecondary = { red = 0.5, green = 0.5, blue = 0.5, alpha = 1 },
|
|
28
|
+
addBtnBg = { red = 0.2, green = 0.25, blue = 0.2, alpha = 1 },
|
|
29
|
+
addBtnText = { red = 0.6, green = 0.8, blue = 0.6, alpha = 1 },
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
-- State
|
|
34
|
+
local slotCount = config.initialSlots
|
|
35
|
+
local slots = {}
|
|
36
|
+
local dock = nil
|
|
37
|
+
local tooltip = nil
|
|
38
|
+
|
|
39
|
+
-- Resource handles (for cleanup on reload)
|
|
40
|
+
local windowFilter = nil
|
|
41
|
+
local updateTimer = nil
|
|
42
|
+
local screenWatcher = nil
|
|
43
|
+
|
|
44
|
+
-- Cleanup previous instances on reload
|
|
45
|
+
local function cleanup()
|
|
46
|
+
if windowFilter then
|
|
47
|
+
windowFilter:unsubscribeAll()
|
|
48
|
+
windowFilter = nil
|
|
49
|
+
end
|
|
50
|
+
if updateTimer then
|
|
51
|
+
updateTimer:stop()
|
|
52
|
+
updateTimer = nil
|
|
53
|
+
end
|
|
54
|
+
if screenWatcher then
|
|
55
|
+
screenWatcher:stop()
|
|
56
|
+
screenWatcher = nil
|
|
57
|
+
end
|
|
58
|
+
if dock then
|
|
59
|
+
dock:delete()
|
|
60
|
+
dock = nil
|
|
61
|
+
end
|
|
62
|
+
if tooltip then
|
|
63
|
+
tooltip:delete()
|
|
64
|
+
tooltip = nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
cleanup() -- Clean up any previous instance
|
|
68
|
+
|
|
69
|
+
local function initSlots()
|
|
70
|
+
for i = 1, slotCount do
|
|
71
|
+
if not slots[i] then
|
|
72
|
+
slots[i] = { windowId = nil, customName = nil, pending = false }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
initSlots()
|
|
77
|
+
|
|
78
|
+
-- Calculate dock dimensions
|
|
79
|
+
local function getDockWidth()
|
|
80
|
+
return (config.slotWidth * slotCount) + (config.gap * slotCount) + (config.margin * 2) + config.addButtonWidth
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
local function getDockHeight()
|
|
84
|
+
return config.slotHeight + (config.margin * 2)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
local function getDockFrame()
|
|
88
|
+
local screen = hs.screen.mainScreen()
|
|
89
|
+
if not screen then return nil end
|
|
90
|
+
local frame = screen:fullFrame()
|
|
91
|
+
local dockWidth = getDockWidth()
|
|
92
|
+
local dockHeight = getDockHeight()
|
|
93
|
+
return {
|
|
94
|
+
x = (frame.w - dockWidth) / 2,
|
|
95
|
+
y = frame.h - dockHeight - config.bottomOffset,
|
|
96
|
+
w = dockWidth,
|
|
97
|
+
h = dockHeight
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
-- Window helpers
|
|
102
|
+
local function getWindow(windowId)
|
|
103
|
+
if not windowId then return nil end
|
|
104
|
+
return hs.window.get(windowId)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
local function getWindowTitle(win)
|
|
108
|
+
if not win then return nil end
|
|
109
|
+
local title = win:title() or ""
|
|
110
|
+
if #title > 18 then
|
|
111
|
+
title = title:sub(1, 15) .. "..."
|
|
112
|
+
end
|
|
113
|
+
return title
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
-- Tooltip helpers
|
|
117
|
+
local function showTooltip(text)
|
|
118
|
+
if tooltip then tooltip:delete() end
|
|
119
|
+
local dockFrame = getDockFrame()
|
|
120
|
+
if not dockFrame then return end
|
|
121
|
+
|
|
122
|
+
local tipWidth = 50
|
|
123
|
+
local tipHeight = 24
|
|
124
|
+
local addBtnX = dockFrame.x + config.margin + (slotCount * (config.slotWidth + config.gap))
|
|
125
|
+
local tipX = addBtnX + (config.addButtonWidth - tipWidth) / 2
|
|
126
|
+
local tipY = dockFrame.y - tipHeight - 5
|
|
127
|
+
|
|
128
|
+
tooltip = hs.canvas.new({ x = tipX, y = tipY, w = tipWidth, h = tipHeight })
|
|
129
|
+
tooltip:appendElements({
|
|
130
|
+
type = "rectangle",
|
|
131
|
+
action = "fill",
|
|
132
|
+
roundedRectRadii = { xRadius = 6, yRadius = 6 },
|
|
133
|
+
fillColor = { red = 0.2, green = 0.2, blue = 0.2, alpha = 0.95 },
|
|
134
|
+
frame = { x = 0, y = 0, w = "100%", h = "100%" },
|
|
135
|
+
})
|
|
136
|
+
tooltip:appendElements({
|
|
137
|
+
type = "text",
|
|
138
|
+
frame = { x = 0, y = 4, w = tipWidth, h = tipHeight },
|
|
139
|
+
text = text,
|
|
140
|
+
textAlignment = "center",
|
|
141
|
+
textColor = { red = 1, green = 1, blue = 1, alpha = 1 },
|
|
142
|
+
textSize = 12,
|
|
143
|
+
textFont = ".AppleSystemUIFont",
|
|
144
|
+
})
|
|
145
|
+
tooltip:level(hs.canvas.windowLevels.floating)
|
|
146
|
+
tooltip:show()
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
local function hideTooltip()
|
|
150
|
+
if tooltip then
|
|
151
|
+
tooltip:delete()
|
|
152
|
+
tooltip = nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
-- Forward declarations
|
|
157
|
+
local createDock
|
|
158
|
+
local updateAllSlots
|
|
159
|
+
|
|
160
|
+
-- Update slot display
|
|
161
|
+
local function updateSlotDisplay(slotIndex)
|
|
162
|
+
if not dock then return end
|
|
163
|
+
if slotIndex > slotCount then return end
|
|
164
|
+
|
|
165
|
+
local slot = slots[slotIndex]
|
|
166
|
+
local baseIdx = config.baseElements + 1 + ((slotIndex - 1) * config.elementsPerSlot)
|
|
167
|
+
|
|
168
|
+
local title, status, bgColor
|
|
169
|
+
local win = getWindow(slot.windowId)
|
|
170
|
+
|
|
171
|
+
if win then
|
|
172
|
+
title = slot.customName or getWindowTitle(win) or "Terminal"
|
|
173
|
+
if win:isMinimized() then
|
|
174
|
+
status = "(minimized)"
|
|
175
|
+
bgColor = config.colors.slotMinimized
|
|
176
|
+
else
|
|
177
|
+
status = "active"
|
|
178
|
+
bgColor = config.colors.slotActive
|
|
179
|
+
end
|
|
180
|
+
else
|
|
181
|
+
-- Don't clear customName if we're waiting for a window to spawn
|
|
182
|
+
if not slot.pending then
|
|
183
|
+
slot.windowId = nil
|
|
184
|
+
slot.customName = nil
|
|
185
|
+
title = "Empty"
|
|
186
|
+
status = "click to open"
|
|
187
|
+
else
|
|
188
|
+
title = slot.customName or "Opening..."
|
|
189
|
+
status = "launching"
|
|
190
|
+
end
|
|
191
|
+
bgColor = config.colors.slotEmpty
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
if dock[baseIdx] then
|
|
195
|
+
dock[baseIdx].fillColor = bgColor
|
|
196
|
+
end
|
|
197
|
+
if dock[baseIdx + 2] then
|
|
198
|
+
dock[baseIdx + 2].text = title
|
|
199
|
+
end
|
|
200
|
+
if dock[baseIdx + 3] then
|
|
201
|
+
dock[baseIdx + 3].text = status
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
updateAllSlots = function()
|
|
206
|
+
for i = 1, slotCount do
|
|
207
|
+
updateSlotDisplay(i)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
-- Rename a slot
|
|
212
|
+
local function renameSlot(slotIndex)
|
|
213
|
+
local slot = slots[slotIndex]
|
|
214
|
+
local button, newName = hs.dialog.textPrompt(
|
|
215
|
+
"Rename Slot " .. slotIndex,
|
|
216
|
+
"Enter a name for this slot:",
|
|
217
|
+
slot.customName or "",
|
|
218
|
+
"Save", "Cancel"
|
|
219
|
+
)
|
|
220
|
+
if button == "Save" and newName and newName ~= "" then
|
|
221
|
+
slot.customName = newName
|
|
222
|
+
updateSlotDisplay(slotIndex)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
-- Capture newly created terminal window with retries
|
|
227
|
+
local function captureNewWindow(slot, retryCount)
|
|
228
|
+
retryCount = retryCount or 0
|
|
229
|
+
if retryCount >= config.windowCaptureRetries then
|
|
230
|
+
slot.pending = false
|
|
231
|
+
updateAllSlots()
|
|
232
|
+
return
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
local termApp = hs.application.get("Terminal")
|
|
236
|
+
if not termApp then
|
|
237
|
+
hs.timer.doAfter(config.windowCaptureDelay, function()
|
|
238
|
+
captureNewWindow(slot, retryCount + 1)
|
|
239
|
+
end)
|
|
240
|
+
return
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
local wins = termApp:allWindows()
|
|
244
|
+
for _, w in ipairs(wins) do
|
|
245
|
+
local winId = w:id()
|
|
246
|
+
local isTracked = false
|
|
247
|
+
for _, s in ipairs(slots) do
|
|
248
|
+
if s.windowId == winId then
|
|
249
|
+
isTracked = true
|
|
250
|
+
break
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
if not isTracked then
|
|
254
|
+
slot.windowId = winId
|
|
255
|
+
slot.pending = false
|
|
256
|
+
updateAllSlots()
|
|
257
|
+
return
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
-- Window not found yet, retry
|
|
262
|
+
hs.timer.doAfter(config.windowCaptureDelay, function()
|
|
263
|
+
captureNewWindow(slot, retryCount + 1)
|
|
264
|
+
end)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
-- Handle slot click
|
|
268
|
+
local function onSlotClick(slotIndex, isOptionClick)
|
|
269
|
+
local slot = slots[slotIndex]
|
|
270
|
+
|
|
271
|
+
if isOptionClick then
|
|
272
|
+
renameSlot(slotIndex)
|
|
273
|
+
return
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
local win = getWindow(slot.windowId)
|
|
277
|
+
if win then
|
|
278
|
+
if win:isMinimized() then
|
|
279
|
+
win:unminimize()
|
|
280
|
+
end
|
|
281
|
+
win:focus()
|
|
282
|
+
else
|
|
283
|
+
local button, newName = hs.dialog.textPrompt(
|
|
284
|
+
"New Claude Terminal",
|
|
285
|
+
"Enter a name for this terminal:",
|
|
286
|
+
"Claude " .. slotIndex,
|
|
287
|
+
"Create", "Cancel"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if button ~= "Create" then
|
|
291
|
+
return
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
slot.customName = (newName and newName ~= "") and newName or ("Claude " .. slotIndex)
|
|
295
|
+
slot.pending = true
|
|
296
|
+
|
|
297
|
+
hs.applescript([[
|
|
298
|
+
tell application "Terminal"
|
|
299
|
+
do script "claude"
|
|
300
|
+
activate
|
|
301
|
+
end tell
|
|
302
|
+
]])
|
|
303
|
+
|
|
304
|
+
captureNewWindow(slot, 0)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
updateSlotDisplay(slotIndex)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
-- Add a new slot and launch terminal
|
|
311
|
+
local function addSlot()
|
|
312
|
+
slotCount = slotCount + 1
|
|
313
|
+
slots[slotCount] = { windowId = nil, customName = nil, pending = false }
|
|
314
|
+
|
|
315
|
+
if dock then
|
|
316
|
+
dock:delete()
|
|
317
|
+
end
|
|
318
|
+
createDock()
|
|
319
|
+
onSlotClick(slotCount, false)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
-- Create the dock UI
|
|
323
|
+
createDock = function()
|
|
324
|
+
local frame = getDockFrame()
|
|
325
|
+
if not frame then
|
|
326
|
+
hs.alert.show("Claude Dock: No screen found")
|
|
327
|
+
return
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
dock = hs.canvas.new(frame)
|
|
331
|
+
|
|
332
|
+
-- Background
|
|
333
|
+
dock:appendElements({
|
|
334
|
+
type = "rectangle",
|
|
335
|
+
action = "fill",
|
|
336
|
+
roundedRectRadii = { xRadius = 14, yRadius = 14 },
|
|
337
|
+
fillColor = config.colors.dockBg,
|
|
338
|
+
frame = { x = 0, y = 0, w = "100%", h = "100%" },
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
-- Border
|
|
342
|
+
dock:appendElements({
|
|
343
|
+
type = "rectangle",
|
|
344
|
+
action = "stroke",
|
|
345
|
+
roundedRectRadii = { xRadius = 14, yRadius = 14 },
|
|
346
|
+
strokeColor = config.colors.dockBorder,
|
|
347
|
+
strokeWidth = 1,
|
|
348
|
+
frame = { x = 0, y = 0, w = "100%", h = "100%" },
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
-- Slots
|
|
352
|
+
for i = 1, slotCount do
|
|
353
|
+
local slotX = config.margin + ((i - 1) * (config.slotWidth + config.gap))
|
|
354
|
+
|
|
355
|
+
dock:appendElements({
|
|
356
|
+
type = "rectangle",
|
|
357
|
+
action = "fill",
|
|
358
|
+
frame = { x = slotX, y = config.margin, w = config.slotWidth, h = config.slotHeight },
|
|
359
|
+
roundedRectRadii = { xRadius = 10, yRadius = 10 },
|
|
360
|
+
fillColor = config.colors.slotEmpty,
|
|
361
|
+
trackMouseUp = true,
|
|
362
|
+
id = "slot" .. i,
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
dock:appendElements({
|
|
366
|
+
type = "rectangle",
|
|
367
|
+
action = "stroke",
|
|
368
|
+
frame = { x = slotX, y = config.margin, w = config.slotWidth, h = config.slotHeight },
|
|
369
|
+
roundedRectRadii = { xRadius = 10, yRadius = 10 },
|
|
370
|
+
strokeColor = config.colors.slotBorder,
|
|
371
|
+
strokeWidth = 1,
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
dock:appendElements({
|
|
375
|
+
type = "text",
|
|
376
|
+
frame = { x = slotX + 6, y = config.margin + 8, w = config.slotWidth - 12, h = 24 },
|
|
377
|
+
text = "Empty",
|
|
378
|
+
textAlignment = "center",
|
|
379
|
+
textColor = config.colors.textPrimary,
|
|
380
|
+
textSize = 13,
|
|
381
|
+
textFont = ".AppleSystemUIFont",
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
dock:appendElements({
|
|
385
|
+
type = "text",
|
|
386
|
+
frame = { x = slotX + 6, y = config.margin + 32, w = config.slotWidth - 12, h = 20 },
|
|
387
|
+
text = "click to open",
|
|
388
|
+
textAlignment = "center",
|
|
389
|
+
textColor = config.colors.textSecondary,
|
|
390
|
+
textSize = 10,
|
|
391
|
+
textFont = ".AppleSystemUIFont",
|
|
392
|
+
})
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
-- Add button
|
|
396
|
+
local addBtnX = config.margin + (slotCount * (config.slotWidth + config.gap))
|
|
397
|
+
dock:appendElements({
|
|
398
|
+
type = "rectangle",
|
|
399
|
+
action = "fill",
|
|
400
|
+
frame = { x = addBtnX, y = config.margin, w = config.addButtonWidth, h = config.slotHeight },
|
|
401
|
+
roundedRectRadii = { xRadius = 10, yRadius = 10 },
|
|
402
|
+
fillColor = config.colors.addBtnBg,
|
|
403
|
+
trackMouseUp = true,
|
|
404
|
+
trackMouseEnterExit = true,
|
|
405
|
+
id = "addBtn",
|
|
406
|
+
})
|
|
407
|
+
dock:appendElements({
|
|
408
|
+
type = "text",
|
|
409
|
+
frame = { x = addBtnX, y = config.margin + 13, w = config.addButtonWidth, h = 30 },
|
|
410
|
+
text = "+",
|
|
411
|
+
textAlignment = "center",
|
|
412
|
+
textColor = config.colors.addBtnText,
|
|
413
|
+
textSize = 28,
|
|
414
|
+
textFont = ".AppleSystemUIFont",
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
dock:mouseCallback(function(_, event, id)
|
|
418
|
+
if event == "mouseUp" then
|
|
419
|
+
if id == "addBtn" then
|
|
420
|
+
addSlot()
|
|
421
|
+
elseif id and id:match("^slot") then
|
|
422
|
+
local idx = tonumber(id:match("%d+"))
|
|
423
|
+
if idx then
|
|
424
|
+
local mods = hs.eventtap.checkKeyboardModifiers()
|
|
425
|
+
onSlotClick(idx, mods.alt)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
elseif event == "mouseEnter" and id == "addBtn" then
|
|
429
|
+
showTooltip("⌘⌥N")
|
|
430
|
+
elseif event == "mouseExit" and id == "addBtn" then
|
|
431
|
+
hideTooltip()
|
|
432
|
+
end
|
|
433
|
+
end)
|
|
434
|
+
|
|
435
|
+
dock:level(hs.canvas.windowLevels.floating)
|
|
436
|
+
dock:behavior(hs.canvas.windowBehaviors.canJoinAllSpaces)
|
|
437
|
+
dock:show()
|
|
438
|
+
updateAllSlots()
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
local function toggleDock()
|
|
442
|
+
if dock then
|
|
443
|
+
if dock:isShowing() then dock:hide() else dock:show() end
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
-- Window event watcher for immediate updates
|
|
448
|
+
windowFilter = hs.window.filter.new("Terminal")
|
|
449
|
+
windowFilter:subscribe({
|
|
450
|
+
hs.window.filter.windowDestroyed,
|
|
451
|
+
hs.window.filter.windowMinimized,
|
|
452
|
+
hs.window.filter.windowUnminimized,
|
|
453
|
+
hs.window.filter.windowFocused,
|
|
454
|
+
}, updateAllSlots)
|
|
455
|
+
|
|
456
|
+
-- Periodic refresh as fallback
|
|
457
|
+
updateTimer = hs.timer.doEvery(2, function()
|
|
458
|
+
if dock and dock:isShowing() then
|
|
459
|
+
updateAllSlots()
|
|
460
|
+
end
|
|
461
|
+
end)
|
|
462
|
+
|
|
463
|
+
-- Screen change handler
|
|
464
|
+
screenWatcher = hs.screen.watcher.new(function()
|
|
465
|
+
if dock then
|
|
466
|
+
local frame = getDockFrame()
|
|
467
|
+
if frame then
|
|
468
|
+
dock:frame(frame)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end)
|
|
472
|
+
screenWatcher:start()
|
|
473
|
+
|
|
474
|
+
-- Initialize
|
|
475
|
+
createDock()
|
|
476
|
+
|
|
477
|
+
-- Hotkeys
|
|
478
|
+
hs.hotkey.bind({"cmd", "alt"}, "T", toggleDock)
|
|
479
|
+
hs.hotkey.bind({"cmd", "alt"}, "N", addSlot)
|
|
480
|
+
hs.hotkey.bind({"cmd", "alt"}, "R", hs.reload)
|
|
481
|
+
|
|
482
|
+
hs.alert.show("Claude Dock Ready")
|
|
483
|
+
|
|
484
|
+
-- ===================
|
|
485
|
+
-- TESTS (run with: hs -c "runTests()")
|
|
486
|
+
-- ===================
|
|
487
|
+
|
|
488
|
+
function runTests()
|
|
489
|
+
local passed, failed = 0, 0
|
|
490
|
+
local savedSlotCount, savedSlots = slotCount, slots
|
|
491
|
+
|
|
492
|
+
local function test(name, fn)
|
|
493
|
+
local ok, err = pcall(fn)
|
|
494
|
+
if ok then
|
|
495
|
+
print("✓ " .. name)
|
|
496
|
+
passed = passed + 1
|
|
497
|
+
else
|
|
498
|
+
print("✗ " .. name .. ": " .. tostring(err))
|
|
499
|
+
failed = failed + 1
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
local function assertEqual(a, b, msg)
|
|
504
|
+
if a ~= b then
|
|
505
|
+
error((msg or "assertEqual") .. ": expected " .. tostring(b) .. ", got " .. tostring(a))
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
local function restore()
|
|
510
|
+
slotCount, slots = savedSlotCount, savedSlots
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
print("\n=== Claude Dock Tests ===\n")
|
|
514
|
+
|
|
515
|
+
test("getWindow returns nil for nil input", function()
|
|
516
|
+
assertEqual(getWindow(nil), nil)
|
|
517
|
+
end)
|
|
518
|
+
|
|
519
|
+
test("getWindow returns nil for invalid windowId", function()
|
|
520
|
+
assertEqual(getWindow(999999999), nil)
|
|
521
|
+
end)
|
|
522
|
+
|
|
523
|
+
test("getWindowTitle returns nil for nil window", function()
|
|
524
|
+
assertEqual(getWindowTitle(nil), nil)
|
|
525
|
+
end)
|
|
526
|
+
|
|
527
|
+
test("slot clears windowId when window gone", function()
|
|
528
|
+
local testSlot = { windowId = 999999999 }
|
|
529
|
+
if not getWindow(testSlot.windowId) then
|
|
530
|
+
testSlot.windowId = nil
|
|
531
|
+
end
|
|
532
|
+
assertEqual(testSlot.windowId, nil)
|
|
533
|
+
end)
|
|
534
|
+
|
|
535
|
+
test("slot clears customName when window gone", function()
|
|
536
|
+
local testSlot = { windowId = 999999999, customName = "Test" }
|
|
537
|
+
if not getWindow(testSlot.windowId) then
|
|
538
|
+
testSlot.windowId = nil
|
|
539
|
+
testSlot.customName = nil
|
|
540
|
+
end
|
|
541
|
+
assertEqual(testSlot.customName, nil)
|
|
542
|
+
end)
|
|
543
|
+
|
|
544
|
+
test("getDockWidth scales with slotCount", function()
|
|
545
|
+
local w1 = getDockWidth()
|
|
546
|
+
slotCount = slotCount + 1
|
|
547
|
+
local w2 = getDockWidth()
|
|
548
|
+
restore()
|
|
549
|
+
assert(w2 > w1, "width should increase")
|
|
550
|
+
end)
|
|
551
|
+
|
|
552
|
+
test("getDockHeight is constant", function()
|
|
553
|
+
local h1 = getDockHeight()
|
|
554
|
+
slotCount = slotCount + 5
|
|
555
|
+
local h2 = getDockHeight()
|
|
556
|
+
restore()
|
|
557
|
+
assertEqual(h1, h2)
|
|
558
|
+
end)
|
|
559
|
+
|
|
560
|
+
test("initSlots creates correct slots", function()
|
|
561
|
+
slots = {}
|
|
562
|
+
slotCount = 3
|
|
563
|
+
initSlots()
|
|
564
|
+
assert(slots[1] and slots[2] and slots[3], "slots 1-3 should exist")
|
|
565
|
+
assert(not slots[4], "slot 4 should not exist")
|
|
566
|
+
restore()
|
|
567
|
+
end)
|
|
568
|
+
|
|
569
|
+
test("toggleDock changes visibility", function()
|
|
570
|
+
local was = dock:isShowing()
|
|
571
|
+
toggleDock()
|
|
572
|
+
assertEqual(dock:isShowing(), not was)
|
|
573
|
+
toggleDock()
|
|
574
|
+
end)
|
|
575
|
+
|
|
576
|
+
test("updateSlotDisplay handles invalid index", function()
|
|
577
|
+
local ok = pcall(updateSlotDisplay, 9999)
|
|
578
|
+
assert(ok, "should not error")
|
|
579
|
+
end)
|
|
580
|
+
|
|
581
|
+
test("windowFilter exists", function()
|
|
582
|
+
assert(windowFilter, "windowFilter should exist")
|
|
583
|
+
end)
|
|
584
|
+
|
|
585
|
+
test("cleanup function exists", function()
|
|
586
|
+
assert(type(cleanup) == "function", "cleanup should be a function")
|
|
587
|
+
end)
|
|
588
|
+
|
|
589
|
+
test("config has required color fields", function()
|
|
590
|
+
assert(config.colors.slotEmpty, "slotEmpty color")
|
|
591
|
+
assert(config.colors.slotActive, "slotActive color")
|
|
592
|
+
assert(config.colors.slotMinimized, "slotMinimized color")
|
|
593
|
+
end)
|
|
594
|
+
|
|
595
|
+
test("getDockFrame returns table with x,y,w,h", function()
|
|
596
|
+
local frame = getDockFrame()
|
|
597
|
+
assert(frame, "frame should exist")
|
|
598
|
+
assert(frame.x and frame.y and frame.w and frame.h, "frame should have x,y,w,h")
|
|
599
|
+
end)
|
|
600
|
+
|
|
601
|
+
print("\n=== Results: " .. passed .. " passed, " .. failed .. " failed ===\n")
|
|
602
|
+
return failed == 0
|
|
603
|
+
end
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-dock",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A lightweight terminal dock for macOS to manage Claude Code sessions",
|
|
5
|
+
"bin": {
|
|
6
|
+
"claude-dock": "./bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"keywords": [
|
|
9
|
+
"claude",
|
|
10
|
+
"hammerspoon",
|
|
11
|
+
"macos",
|
|
12
|
+
"terminal",
|
|
13
|
+
"dock",
|
|
14
|
+
"cli"
|
|
15
|
+
],
|
|
16
|
+
"author": "Matthew Molinar",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/matthewmolinar/claude-dock.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/matthewmolinar/claude-dock",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=14"
|
|
25
|
+
},
|
|
26
|
+
"os": [
|
|
27
|
+
"darwin"
|
|
28
|
+
],
|
|
29
|
+
"files": [
|
|
30
|
+
"bin/",
|
|
31
|
+
"init.lua"
|
|
32
|
+
]
|
|
33
|
+
}
|