claude-dock 1.1.0 → 1.3.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 +33 -30
- package/init.lua +462 -66
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
# Claude Dock
|
|
2
2
|
|
|
3
|
-
A lightweight, expandable terminal dock for macOS built with [Hammerspoon](https://www.hammerspoon.org/).
|
|
3
|
+
A lightweight, expandable terminal dock for macOS built with [Hammerspoon](https://www.hammerspoon.org/). Manage multiple AI coding agent sessions - **Claude Code**, **Amp**, and **Codex**.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
[](https://www.npmjs.com/package/claude-dock)
|
|
7
7
|
|
|
8
8
|
## Features
|
|
9
9
|
|
|
10
|
+
- **Multi-agent support** - Works with Claude Code, Sourcegraph Amp, and OpenAI Codex
|
|
10
11
|
- **Expandable dock** - Start with 3 slots, add more with "+" button or hotkey
|
|
11
12
|
- **Terminal management** - Each slot tracks a specific terminal window
|
|
12
|
-
- **Auto-launch
|
|
13
|
+
- **Auto-launch** - New terminals automatically run your configured agent
|
|
13
14
|
- **Custom naming** - Name your terminals for easy identification
|
|
14
|
-
- **Visual status** - See which terminals are active, minimized, or
|
|
15
|
+
- **Visual status** - See which terminals are active, minimized, or on other spaces
|
|
16
|
+
- **Notification badges** - Red dot appears when a terminal has activity while unfocused
|
|
15
17
|
- **Quick access** - Click to focus/unminimize terminals
|
|
16
18
|
- **Keyboard shortcuts** - Full hotkey support
|
|
17
19
|
|
|
@@ -61,6 +63,7 @@ System Settings → Privacy & Security → Accessibility → Enable Hammerspoon
|
|
|
61
63
|
|----------|--------|
|
|
62
64
|
| `Cmd+Option+T` | Toggle dock visibility |
|
|
63
65
|
| `Cmd+Option+N` | Add new slot + launch terminal |
|
|
66
|
+
| `Cmd+Option+M` | Minimize all terminals |
|
|
64
67
|
| `Cmd+Option+R` | Reload configuration |
|
|
65
68
|
| `Option+Click` | Rename a slot |
|
|
66
69
|
|
|
@@ -70,11 +73,12 @@ System Settings → Privacy & Security → Accessibility → Enable Hammerspoon
|
|
|
70
73
|
|-------|--------|
|
|
71
74
|
| Gray | Empty - click to open new terminal |
|
|
72
75
|
| Green | Active terminal |
|
|
73
|
-
| Blue | Minimized
|
|
76
|
+
| Blue | Minimized or on other space |
|
|
77
|
+
| Red dot | Terminal has new activity |
|
|
74
78
|
|
|
75
79
|
### Click Actions
|
|
76
80
|
|
|
77
|
-
- **Click empty slot** - Prompts for name, opens terminal, runs
|
|
81
|
+
- **Click empty slot** - Prompts for name, opens terminal, runs agent
|
|
78
82
|
- **Click active slot** - Focuses that terminal window
|
|
79
83
|
- **Click minimized slot** - Unminimizes and focuses
|
|
80
84
|
- **Click "+" button** - Adds new slot and launches terminal
|
|
@@ -82,38 +86,37 @@ System Settings → Privacy & Security → Accessibility → Enable Hammerspoon
|
|
|
82
86
|
|
|
83
87
|
## Configuration
|
|
84
88
|
|
|
85
|
-
Edit
|
|
89
|
+
Edit `~/.hammerspoon/init.lua` to customize.
|
|
90
|
+
|
|
91
|
+
### Changing the Agent
|
|
92
|
+
|
|
93
|
+
By default, claude-dock launches `claude`. To use a different agent:
|
|
86
94
|
|
|
87
95
|
```lua
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
local bottomOffset = 5 -- Distance from screen bottom
|
|
94
|
-
local slotCount = 3 -- Initial number of slots
|
|
96
|
+
local config = {
|
|
97
|
+
-- Agent to launch: "claude", "amp", or "codex"
|
|
98
|
+
agent = "amp", -- Change to your preferred agent
|
|
99
|
+
...
|
|
100
|
+
}
|
|
95
101
|
```
|
|
96
102
|
|
|
97
|
-
|
|
103
|
+
Supported agents:
|
|
104
|
+
- `"claude"` - [Claude Code](https://claude.ai/code) (default)
|
|
105
|
+
- `"amp"` - [Sourcegraph Amp](https://ampcode.com/)
|
|
106
|
+
- `"codex"` - [OpenAI Codex](https://openai.com/codex/)
|
|
98
107
|
|
|
99
|
-
|
|
108
|
+
### Other Options
|
|
100
109
|
|
|
101
110
|
```lua
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
hs.applescript([[
|
|
112
|
-
tell application "iTerm"
|
|
113
|
-
create window with default profile command "claude"
|
|
114
|
-
activate
|
|
115
|
-
end tell
|
|
116
|
-
]])
|
|
111
|
+
local config = {
|
|
112
|
+
agent = "claude", -- Which AI agent to launch
|
|
113
|
+
slotWidth = 140, -- Width of each slot
|
|
114
|
+
slotHeight = 60, -- Height of each slot
|
|
115
|
+
gap = 8, -- Gap between slots
|
|
116
|
+
margin = 10, -- Dock padding
|
|
117
|
+
bottomOffset = 5, -- Distance from screen bottom
|
|
118
|
+
initialSlots = 3, -- Starting number of slots
|
|
119
|
+
}
|
|
117
120
|
```
|
|
118
121
|
|
|
119
122
|
## Running Tests
|
package/init.lua
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
-- Claude Dock: Terminal dock for managing Claude
|
|
1
|
+
-- Claude Dock: Terminal dock for managing AI coding agent sessions (Claude, Amp, Codex)
|
|
2
2
|
-- https://github.com/YOUR_USERNAME/claude-dock
|
|
3
3
|
|
|
4
4
|
require("hs.ipc")
|
|
5
5
|
|
|
6
6
|
-- Configuration
|
|
7
7
|
local config = {
|
|
8
|
+
-- Agent to launch: "claude", "amp", or "codex"
|
|
9
|
+
agent = "claude",
|
|
8
10
|
slotWidth = 140,
|
|
9
11
|
slotHeight = 60,
|
|
10
12
|
gap = 8,
|
|
@@ -13,8 +15,8 @@ local config = {
|
|
|
13
15
|
addButtonWidth = 40,
|
|
14
16
|
utilityButtonWidth = 28,
|
|
15
17
|
initialSlots = 3,
|
|
16
|
-
elementsPerSlot =
|
|
17
|
-
baseElements =
|
|
18
|
+
elementsPerSlot = 7, -- bg, border, title, status, badge glow outer, badge glow inner, badge
|
|
19
|
+
baseElements = 12, -- dock bg + border + 6 tab elements + 4 utility btn elements
|
|
18
20
|
windowCaptureDelay = 0.3,
|
|
19
21
|
windowCaptureRetries = 5,
|
|
20
22
|
colors = {
|
|
@@ -32,9 +34,40 @@ local config = {
|
|
|
32
34
|
minBtnText = { red = 0.6, green = 0.6, blue = 0.9, alpha = 1 },
|
|
33
35
|
helpBtnBg = { red = 0.2, green = 0.18, blue = 0.12, alpha = 1 },
|
|
34
36
|
helpBtnText = { red = 0.9, green = 0.8, blue = 0.5, alpha = 1 },
|
|
35
|
-
|
|
37
|
+
notificationBadge = { red = 1, green = 0.3, blue = 0.3, alpha = 1 },
|
|
38
|
+
},
|
|
39
|
+
notificationBadgeSize = 12,
|
|
40
|
+
tabHeight = 28,
|
|
41
|
+
tabWidth = 60,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
-- Supported agents (order matters for tab display)
|
|
45
|
+
local agentOrder = { "claude", "amp", "codex" }
|
|
46
|
+
local agents = {
|
|
47
|
+
claude = {
|
|
48
|
+
command = "claude",
|
|
49
|
+
name = "Claude",
|
|
50
|
+
shortName = "Claude",
|
|
51
|
+
color = { red = 0.76, green = 0.37, blue = 0.24, alpha = 1 }, -- #C15F3C
|
|
52
|
+
},
|
|
53
|
+
amp = {
|
|
54
|
+
command = "amp",
|
|
55
|
+
name = "Amp",
|
|
56
|
+
shortName = "Amp",
|
|
57
|
+
color = { red = 0.6, green = 0.2, blue = 0.8, alpha = 1 }, -- Purple
|
|
58
|
+
},
|
|
59
|
+
codex = {
|
|
60
|
+
command = "codex",
|
|
61
|
+
name = "Codex",
|
|
62
|
+
shortName = "Codex",
|
|
63
|
+
color = { red = 0.0, green = 0.65, blue = 0.52, alpha = 1 }, -- OpenAI teal #00A67E
|
|
64
|
+
},
|
|
36
65
|
}
|
|
37
66
|
|
|
67
|
+
local function getAgent()
|
|
68
|
+
return agents[config.agent] or agents.claude
|
|
69
|
+
end
|
|
70
|
+
|
|
38
71
|
-- State
|
|
39
72
|
local slotCount = config.initialSlots
|
|
40
73
|
local slots = {}
|
|
@@ -42,6 +75,8 @@ local dock = nil
|
|
|
42
75
|
local tooltip = nil
|
|
43
76
|
local helpPanel = nil
|
|
44
77
|
local macOSDockAtBottom = false
|
|
78
|
+
local pulseTimer = nil
|
|
79
|
+
local pulsePhase = 0
|
|
45
80
|
|
|
46
81
|
-- Check macOS dock position
|
|
47
82
|
local function getMacOSDockPosition()
|
|
@@ -78,6 +113,10 @@ local function cleanup()
|
|
|
78
113
|
updateTimer:stop()
|
|
79
114
|
updateTimer = nil
|
|
80
115
|
end
|
|
116
|
+
if pulseTimer then
|
|
117
|
+
pulseTimer:stop()
|
|
118
|
+
pulseTimer = nil
|
|
119
|
+
end
|
|
81
120
|
if screenWatcher then
|
|
82
121
|
screenWatcher:stop()
|
|
83
122
|
screenWatcher = nil
|
|
@@ -100,7 +139,7 @@ cleanup() -- Clean up any previous instance
|
|
|
100
139
|
local function initSlots()
|
|
101
140
|
for i = 1, slotCount do
|
|
102
141
|
if not slots[i] then
|
|
103
|
-
slots[i] = { windowId = nil, customName = nil, pending = false }
|
|
142
|
+
slots[i] = { windowId = nil, customName = nil, pending = false, hasNotification = false }
|
|
104
143
|
end
|
|
105
144
|
end
|
|
106
145
|
end
|
|
@@ -108,14 +147,12 @@ initSlots()
|
|
|
108
147
|
|
|
109
148
|
-- Calculate dock dimensions
|
|
110
149
|
local function getDockWidth()
|
|
111
|
-
-- Left: small utility button (minimize), Right: add button
|
|
112
|
-
local leftButtonWidth = config.utilityButtonWidth + config.gap
|
|
113
150
|
local rightButtonWidth = config.addButtonWidth
|
|
114
|
-
return
|
|
151
|
+
return (config.slotWidth * slotCount) + (config.gap * (slotCount - 1)) + config.gap + rightButtonWidth + (config.margin * 2)
|
|
115
152
|
end
|
|
116
153
|
|
|
117
154
|
local function getDockHeight()
|
|
118
|
-
return config.slotHeight + (config.margin * 2)
|
|
155
|
+
return config.tabHeight + config.slotHeight + (config.margin * 2)
|
|
119
156
|
end
|
|
120
157
|
|
|
121
158
|
local function getDockFrame()
|
|
@@ -152,6 +189,37 @@ local function getWindowTitle(win)
|
|
|
152
189
|
return title
|
|
153
190
|
end
|
|
154
191
|
|
|
192
|
+
-- Find slot index by window ID
|
|
193
|
+
local function findSlotByWindowId(windowId)
|
|
194
|
+
if not windowId then return nil end
|
|
195
|
+
for i, slot in ipairs(slots) do
|
|
196
|
+
if slot.windowId == windowId then
|
|
197
|
+
return i
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
return nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
-- Forward declaration for pulse animation
|
|
204
|
+
local startPulseAnimation
|
|
205
|
+
|
|
206
|
+
-- Set notification for a slot (only if window is not focused)
|
|
207
|
+
local function setSlotNotification(slotIndex)
|
|
208
|
+
local slot = slots[slotIndex]
|
|
209
|
+
if not slot then return end
|
|
210
|
+
|
|
211
|
+
local win = getWindow(slot.windowId)
|
|
212
|
+
if win then
|
|
213
|
+
local focusedWin = hs.window.focusedWindow()
|
|
214
|
+
-- Only show notification if window is not currently focused
|
|
215
|
+
if not focusedWin or focusedWin:id() ~= slot.windowId then
|
|
216
|
+
slot.hasNotification = true
|
|
217
|
+
updateSlotDisplay(slotIndex)
|
|
218
|
+
if startPulseAnimation then startPulseAnimation() end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
155
223
|
-- Tooltip helpers
|
|
156
224
|
local function showTooltipAt(text, x, y)
|
|
157
225
|
if tooltip then tooltip:delete() end
|
|
@@ -187,14 +255,23 @@ local function showButtonTooltip(text, buttonId)
|
|
|
187
255
|
local tipWidth = 50
|
|
188
256
|
local tipHeight = 24
|
|
189
257
|
local btnX, btnWidth
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
258
|
+
local dockWidth = getDockWidth()
|
|
259
|
+
local utilBtnSize = config.tabHeight - 8
|
|
260
|
+
|
|
261
|
+
if buttonId == "minBtn" then
|
|
262
|
+
-- Top right minimize button
|
|
263
|
+
local minBtnWidth = 32
|
|
264
|
+
btnX = dockFrame.x + dockWidth - config.margin - minBtnWidth
|
|
265
|
+
btnWidth = minBtnWidth
|
|
266
|
+
elseif buttonId == "helpBtn" then
|
|
267
|
+
-- Top right help button (left of minimize)
|
|
268
|
+
local minBtnWidth = 32
|
|
269
|
+
local helpBtnWidth = 36
|
|
270
|
+
btnX = dockFrame.x + dockWidth - config.margin - minBtnWidth - 4 - helpBtnWidth
|
|
271
|
+
btnWidth = helpBtnWidth
|
|
195
272
|
elseif buttonId == "addBtn" then
|
|
196
|
-
--
|
|
197
|
-
btnX = dockFrame.x + config.margin +
|
|
273
|
+
-- Bottom right add button
|
|
274
|
+
btnX = dockFrame.x + config.margin + (slotCount * config.slotWidth) + (slotCount * config.gap)
|
|
198
275
|
btnWidth = config.addButtonWidth
|
|
199
276
|
end
|
|
200
277
|
|
|
@@ -349,8 +426,9 @@ local function updateSlotDisplay(slotIndex)
|
|
|
349
426
|
local title, status, bgColor
|
|
350
427
|
local win = getWindow(slot.windowId)
|
|
351
428
|
|
|
429
|
+
local agent = getAgent()
|
|
352
430
|
if win then
|
|
353
|
-
title = slot.customName or getWindowTitle(win) or
|
|
431
|
+
title = slot.customName or getWindowTitle(win) or agent.name
|
|
354
432
|
if win:isMinimized() then
|
|
355
433
|
status = "(minimized)"
|
|
356
434
|
bgColor = config.colors.slotMinimized
|
|
@@ -358,16 +436,19 @@ local function updateSlotDisplay(slotIndex)
|
|
|
358
436
|
status = "active"
|
|
359
437
|
bgColor = config.colors.slotActive
|
|
360
438
|
end
|
|
439
|
+
elseif slot.windowId then
|
|
440
|
+
-- Window exists but not visible (probably on another space)
|
|
441
|
+
title = slot.customName or agent.name
|
|
442
|
+
status = "(other space)"
|
|
443
|
+
bgColor = config.colors.slotMinimized
|
|
361
444
|
else
|
|
362
|
-
--
|
|
363
|
-
if
|
|
364
|
-
slot.windowId = nil
|
|
365
|
-
slot.customName = nil
|
|
366
|
-
title = "Empty"
|
|
367
|
-
status = "click to open"
|
|
368
|
-
else
|
|
445
|
+
-- No window assigned
|
|
446
|
+
if slot.pending then
|
|
369
447
|
title = slot.customName or "Opening..."
|
|
370
448
|
status = "launching"
|
|
449
|
+
else
|
|
450
|
+
title = "Empty"
|
|
451
|
+
status = "click to open"
|
|
371
452
|
end
|
|
372
453
|
bgColor = config.colors.slotEmpty
|
|
373
454
|
end
|
|
@@ -381,6 +462,28 @@ local function updateSlotDisplay(slotIndex)
|
|
|
381
462
|
if dock[baseIdx + 3] then
|
|
382
463
|
dock[baseIdx + 3].text = status
|
|
383
464
|
end
|
|
465
|
+
-- Update notification badge with glow
|
|
466
|
+
local badgeColor = config.colors.notificationBadge
|
|
467
|
+
local hidden = { red = 0, green = 0, blue = 0, alpha = 0 }
|
|
468
|
+
|
|
469
|
+
-- Outer glow (baseIdx + 4)
|
|
470
|
+
if dock[baseIdx + 4] then
|
|
471
|
+
dock[baseIdx + 4].fillColor = slot.hasNotification
|
|
472
|
+
and { red = badgeColor.red, green = badgeColor.green, blue = badgeColor.blue, alpha = 0.2 }
|
|
473
|
+
or hidden
|
|
474
|
+
end
|
|
475
|
+
-- Inner glow (baseIdx + 5)
|
|
476
|
+
if dock[baseIdx + 5] then
|
|
477
|
+
dock[baseIdx + 5].fillColor = slot.hasNotification
|
|
478
|
+
and { red = badgeColor.red, green = badgeColor.green, blue = badgeColor.blue, alpha = 0.4 }
|
|
479
|
+
or hidden
|
|
480
|
+
end
|
|
481
|
+
-- Main badge (baseIdx + 6)
|
|
482
|
+
if dock[baseIdx + 6] then
|
|
483
|
+
dock[baseIdx + 6].fillColor = slot.hasNotification
|
|
484
|
+
and badgeColor
|
|
485
|
+
or hidden
|
|
486
|
+
end
|
|
384
487
|
end
|
|
385
488
|
|
|
386
489
|
updateAllSlots = function()
|
|
@@ -389,6 +492,69 @@ updateAllSlots = function()
|
|
|
389
492
|
end
|
|
390
493
|
end
|
|
391
494
|
|
|
495
|
+
-- Pulse animation for notification badges
|
|
496
|
+
local function updatePulse()
|
|
497
|
+
if not dock then return end
|
|
498
|
+
|
|
499
|
+
pulsePhase = pulsePhase + 0.15
|
|
500
|
+
if pulsePhase > math.pi * 2 then
|
|
501
|
+
pulsePhase = 0
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
-- Pulsing multiplier: oscillates between 0.5 and 1.0
|
|
505
|
+
local pulse = 0.75 + 0.25 * math.sin(pulsePhase)
|
|
506
|
+
|
|
507
|
+
local badgeColor = config.colors.notificationBadge
|
|
508
|
+
local hidden = { red = 0, green = 0, blue = 0, alpha = 0 }
|
|
509
|
+
|
|
510
|
+
local hasAnyNotification = false
|
|
511
|
+
for i = 1, slotCount do
|
|
512
|
+
local slot = slots[i]
|
|
513
|
+
if slot and slot.hasNotification then
|
|
514
|
+
hasAnyNotification = true
|
|
515
|
+
local baseIdx = config.baseElements + 1 + ((i - 1) * config.elementsPerSlot)
|
|
516
|
+
|
|
517
|
+
-- Outer glow pulses
|
|
518
|
+
if dock[baseIdx + 4] then
|
|
519
|
+
dock[baseIdx + 4].fillColor = {
|
|
520
|
+
red = badgeColor.red,
|
|
521
|
+
green = badgeColor.green,
|
|
522
|
+
blue = badgeColor.blue,
|
|
523
|
+
alpha = 0.2 * pulse
|
|
524
|
+
}
|
|
525
|
+
end
|
|
526
|
+
-- Inner glow pulses
|
|
527
|
+
if dock[baseIdx + 5] then
|
|
528
|
+
dock[baseIdx + 5].fillColor = {
|
|
529
|
+
red = badgeColor.red,
|
|
530
|
+
green = badgeColor.green,
|
|
531
|
+
blue = badgeColor.blue,
|
|
532
|
+
alpha = 0.5 * pulse
|
|
533
|
+
}
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
-- Stop timer if no notifications
|
|
539
|
+
if not hasAnyNotification and pulseTimer then
|
|
540
|
+
pulseTimer:stop()
|
|
541
|
+
pulseTimer = nil
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
startPulseAnimation = function()
|
|
546
|
+
if not pulseTimer then
|
|
547
|
+
pulseTimer = hs.timer.doEvery(0.05, updatePulse)
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
local function stopPulseAnimation()
|
|
552
|
+
if pulseTimer then
|
|
553
|
+
pulseTimer:stop()
|
|
554
|
+
pulseTimer = nil
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
392
558
|
-- Rename a slot
|
|
393
559
|
local function renameSlot(slotIndex)
|
|
394
560
|
local slot = slots[slotIndex]
|
|
@@ -454,6 +620,9 @@ local function onSlotClick(slotIndex, isOptionClick)
|
|
|
454
620
|
return
|
|
455
621
|
end
|
|
456
622
|
|
|
623
|
+
-- Clear notification when clicked
|
|
624
|
+
slot.hasNotification = false
|
|
625
|
+
|
|
457
626
|
local win = getWindow(slot.windowId)
|
|
458
627
|
if win then
|
|
459
628
|
if win:isMinimized() then
|
|
@@ -461,10 +630,11 @@ local function onSlotClick(slotIndex, isOptionClick)
|
|
|
461
630
|
end
|
|
462
631
|
win:focus()
|
|
463
632
|
else
|
|
633
|
+
local agent = getAgent()
|
|
464
634
|
local button, newName = hs.dialog.textPrompt(
|
|
465
|
-
"New
|
|
635
|
+
"New " .. agent.name .. " Terminal",
|
|
466
636
|
"Enter a name for this terminal:",
|
|
467
|
-
"
|
|
637
|
+
agent.shortName .. " " .. slotIndex,
|
|
468
638
|
"Create", "Cancel"
|
|
469
639
|
)
|
|
470
640
|
|
|
@@ -472,12 +642,12 @@ local function onSlotClick(slotIndex, isOptionClick)
|
|
|
472
642
|
return
|
|
473
643
|
end
|
|
474
644
|
|
|
475
|
-
slot.customName = (newName and newName ~= "") and newName or ("
|
|
645
|
+
slot.customName = (newName and newName ~= "") and newName or (agent.shortName .. " " .. slotIndex)
|
|
476
646
|
slot.pending = true
|
|
477
647
|
|
|
478
648
|
hs.applescript([[
|
|
479
649
|
tell application "Terminal"
|
|
480
|
-
do script "
|
|
650
|
+
do script "]] .. agent.command .. [["
|
|
481
651
|
activate
|
|
482
652
|
end tell
|
|
483
653
|
]])
|
|
@@ -491,7 +661,7 @@ end
|
|
|
491
661
|
-- Add a new slot and launch terminal
|
|
492
662
|
local function addSlot()
|
|
493
663
|
slotCount = slotCount + 1
|
|
494
|
-
slots[slotCount] = { windowId = nil, customName = nil, pending = false }
|
|
664
|
+
slots[slotCount] = { windowId = nil, customName = nil, pending = false, hasNotification = false }
|
|
495
665
|
|
|
496
666
|
if dock then
|
|
497
667
|
dock:delete()
|
|
@@ -529,68 +699,109 @@ createDock = function()
|
|
|
529
699
|
frame = { x = 0, y = 0, w = "100%", h = "100%" },
|
|
530
700
|
})
|
|
531
701
|
|
|
532
|
-
--
|
|
533
|
-
local
|
|
534
|
-
local
|
|
535
|
-
|
|
702
|
+
-- Agent tabs (top left)
|
|
703
|
+
local tabY = 6
|
|
704
|
+
local tabStartX = config.margin
|
|
705
|
+
for i, agentKey in ipairs(agentOrder) do
|
|
706
|
+
local agent = agents[agentKey]
|
|
707
|
+
local tabX = tabStartX + ((i - 1) * (config.tabWidth + 4))
|
|
708
|
+
local isSelected = (config.agent == agentKey)
|
|
709
|
+
|
|
710
|
+
-- Tab background
|
|
711
|
+
dock:appendElements({
|
|
712
|
+
type = "rectangle",
|
|
713
|
+
action = "fill",
|
|
714
|
+
frame = { x = tabX, y = tabY, w = config.tabWidth, h = config.tabHeight - 8 },
|
|
715
|
+
roundedRectRadii = { xRadius = 6, yRadius = 6 },
|
|
716
|
+
fillColor = isSelected and agent.color or { red = 0.15, green = 0.15, blue = 0.15, alpha = 1 },
|
|
717
|
+
trackMouseUp = true,
|
|
718
|
+
id = "tab_" .. agentKey,
|
|
719
|
+
})
|
|
536
720
|
|
|
537
|
-
|
|
538
|
-
|
|
721
|
+
-- Tab text
|
|
722
|
+
dock:appendElements({
|
|
723
|
+
type = "text",
|
|
724
|
+
frame = { x = tabX, y = tabY + 2, w = config.tabWidth, h = config.tabHeight - 8 },
|
|
725
|
+
text = agent.name,
|
|
726
|
+
textAlignment = "center",
|
|
727
|
+
textColor = isSelected
|
|
728
|
+
and { red = 1, green = 1, blue = 1, alpha = 1 }
|
|
729
|
+
or { red = 0.5, green = 0.5, blue = 0.5, alpha = 1 },
|
|
730
|
+
textSize = 12,
|
|
731
|
+
textFont = isSelected and ".AppleSystemUIFontBold" or ".AppleSystemUIFont",
|
|
732
|
+
trackMouseUp = true,
|
|
733
|
+
id = "tab_" .. agentKey,
|
|
734
|
+
})
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
-- Content area starts below tabs
|
|
738
|
+
local contentY = config.tabHeight
|
|
739
|
+
|
|
740
|
+
-- Utility buttons (top right, in tab bar area - same Y as tabs)
|
|
741
|
+
local utilBtnSize = config.tabHeight - 8
|
|
742
|
+
local utilBtnY = 6
|
|
743
|
+
local dockWidth = getDockWidth()
|
|
744
|
+
|
|
745
|
+
-- Minimize button (rightmost)
|
|
746
|
+
local minBtnWidth = 32
|
|
747
|
+
local minBtnX = dockWidth - config.margin - minBtnWidth
|
|
539
748
|
dock:appendElements({
|
|
540
749
|
type = "rectangle",
|
|
541
750
|
action = "fill",
|
|
542
|
-
frame = { x =
|
|
751
|
+
frame = { x = minBtnX, y = utilBtnY, w = minBtnWidth, h = utilBtnSize },
|
|
543
752
|
roundedRectRadii = { xRadius = 6, yRadius = 6 },
|
|
544
|
-
fillColor = config.colors.
|
|
753
|
+
fillColor = config.colors.minBtnBg,
|
|
545
754
|
trackMouseUp = true,
|
|
546
755
|
trackMouseEnterExit = true,
|
|
547
|
-
id = "
|
|
756
|
+
id = "minBtn",
|
|
548
757
|
})
|
|
549
758
|
dock:appendElements({
|
|
550
759
|
type = "text",
|
|
551
|
-
frame = { x =
|
|
552
|
-
text = "
|
|
760
|
+
frame = { x = minBtnX, y = utilBtnY + 2, w = minBtnWidth, h = utilBtnSize },
|
|
761
|
+
text = "Hide",
|
|
553
762
|
textAlignment = "center",
|
|
554
|
-
textColor = config.colors.
|
|
555
|
-
textSize =
|
|
763
|
+
textColor = config.colors.minBtnText,
|
|
764
|
+
textSize = 11,
|
|
556
765
|
textFont = ".AppleSystemUIFontBold",
|
|
557
766
|
trackMouseUp = true,
|
|
558
|
-
id = "
|
|
767
|
+
id = "minBtn",
|
|
559
768
|
})
|
|
560
769
|
|
|
561
|
-
--
|
|
562
|
-
local
|
|
770
|
+
-- Help button (left of minimize)
|
|
771
|
+
local helpBtnWidth = 36
|
|
772
|
+
local helpBtnX = minBtnX - helpBtnWidth - 4
|
|
563
773
|
dock:appendElements({
|
|
564
774
|
type = "rectangle",
|
|
565
775
|
action = "fill",
|
|
566
|
-
frame = { x =
|
|
776
|
+
frame = { x = helpBtnX, y = utilBtnY, w = helpBtnWidth, h = utilBtnSize },
|
|
567
777
|
roundedRectRadii = { xRadius = 6, yRadius = 6 },
|
|
568
|
-
fillColor = config.colors.
|
|
778
|
+
fillColor = config.colors.helpBtnBg,
|
|
569
779
|
trackMouseUp = true,
|
|
570
780
|
trackMouseEnterExit = true,
|
|
571
|
-
id = "
|
|
781
|
+
id = "helpBtn",
|
|
572
782
|
})
|
|
573
783
|
dock:appendElements({
|
|
574
784
|
type = "text",
|
|
575
|
-
frame = { x =
|
|
576
|
-
text = "
|
|
785
|
+
frame = { x = helpBtnX, y = utilBtnY + 2, w = helpBtnWidth, h = utilBtnSize },
|
|
786
|
+
text = "Help",
|
|
577
787
|
textAlignment = "center",
|
|
578
|
-
textColor = config.colors.
|
|
579
|
-
textSize =
|
|
580
|
-
textFont = ".
|
|
788
|
+
textColor = config.colors.helpBtnText,
|
|
789
|
+
textSize = 11,
|
|
790
|
+
textFont = ".AppleSystemUIFontBold",
|
|
581
791
|
trackMouseUp = true,
|
|
582
|
-
id = "
|
|
792
|
+
id = "helpBtn",
|
|
583
793
|
})
|
|
584
794
|
|
|
585
|
-
-- Slots
|
|
586
|
-
local slotsStartX = config.margin
|
|
795
|
+
-- Slots
|
|
796
|
+
local slotsStartX = config.margin
|
|
797
|
+
local slotY = contentY + config.margin
|
|
587
798
|
for i = 1, slotCount do
|
|
588
799
|
local slotX = slotsStartX + ((i - 1) * (config.slotWidth + config.gap))
|
|
589
800
|
|
|
590
801
|
dock:appendElements({
|
|
591
802
|
type = "rectangle",
|
|
592
803
|
action = "fill",
|
|
593
|
-
frame = { x = slotX, y =
|
|
804
|
+
frame = { x = slotX, y = slotY, w = config.slotWidth, h = config.slotHeight },
|
|
594
805
|
roundedRectRadii = { xRadius = 10, yRadius = 10 },
|
|
595
806
|
fillColor = config.colors.slotEmpty,
|
|
596
807
|
trackMouseUp = true,
|
|
@@ -600,7 +811,7 @@ createDock = function()
|
|
|
600
811
|
dock:appendElements({
|
|
601
812
|
type = "rectangle",
|
|
602
813
|
action = "stroke",
|
|
603
|
-
frame = { x = slotX, y =
|
|
814
|
+
frame = { x = slotX, y = slotY, w = config.slotWidth, h = config.slotHeight },
|
|
604
815
|
roundedRectRadii = { xRadius = 10, yRadius = 10 },
|
|
605
816
|
strokeColor = config.colors.slotBorder,
|
|
606
817
|
strokeWidth = 1,
|
|
@@ -608,7 +819,7 @@ createDock = function()
|
|
|
608
819
|
|
|
609
820
|
dock:appendElements({
|
|
610
821
|
type = "text",
|
|
611
|
-
frame = { x = slotX + 6, y =
|
|
822
|
+
frame = { x = slotX + 6, y = slotY + 8, w = config.slotWidth - 12, h = 24 },
|
|
612
823
|
text = "Empty",
|
|
613
824
|
textAlignment = "center",
|
|
614
825
|
textColor = config.colors.textPrimary,
|
|
@@ -618,13 +829,45 @@ createDock = function()
|
|
|
618
829
|
|
|
619
830
|
dock:appendElements({
|
|
620
831
|
type = "text",
|
|
621
|
-
frame = { x = slotX + 6, y =
|
|
832
|
+
frame = { x = slotX + 6, y = slotY + 32, w = config.slotWidth - 12, h = 20 },
|
|
622
833
|
text = "click to open",
|
|
623
834
|
textAlignment = "center",
|
|
624
835
|
textColor = config.colors.textSecondary,
|
|
625
836
|
textSize = 10,
|
|
626
837
|
textFont = ".AppleSystemUIFont",
|
|
627
838
|
})
|
|
839
|
+
|
|
840
|
+
-- Notification badge with glow (top-right corner, true corner position)
|
|
841
|
+
local badgeSize = config.notificationBadgeSize
|
|
842
|
+
local badgeCenterX = slotX + config.slotWidth - 4
|
|
843
|
+
local badgeCenterY = slotY + 4
|
|
844
|
+
|
|
845
|
+
-- Outer glow
|
|
846
|
+
dock:appendElements({
|
|
847
|
+
type = "circle",
|
|
848
|
+
action = "fill",
|
|
849
|
+
center = { x = badgeCenterX, y = badgeCenterY },
|
|
850
|
+
radius = badgeSize,
|
|
851
|
+
fillColor = { red = 0, green = 0, blue = 0, alpha = 0 }, -- Hidden by default
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
-- Inner glow
|
|
855
|
+
dock:appendElements({
|
|
856
|
+
type = "circle",
|
|
857
|
+
action = "fill",
|
|
858
|
+
center = { x = badgeCenterX, y = badgeCenterY },
|
|
859
|
+
radius = badgeSize * 0.75,
|
|
860
|
+
fillColor = { red = 0, green = 0, blue = 0, alpha = 0 }, -- Hidden by default
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
-- Main badge
|
|
864
|
+
dock:appendElements({
|
|
865
|
+
type = "circle",
|
|
866
|
+
action = "fill",
|
|
867
|
+
center = { x = badgeCenterX, y = badgeCenterY },
|
|
868
|
+
radius = badgeSize / 2,
|
|
869
|
+
fillColor = { red = 0, green = 0, blue = 0, alpha = 0 }, -- Hidden by default
|
|
870
|
+
})
|
|
628
871
|
end
|
|
629
872
|
|
|
630
873
|
-- Add button (right side)
|
|
@@ -632,19 +875,19 @@ createDock = function()
|
|
|
632
875
|
dock:appendElements({
|
|
633
876
|
type = "rectangle",
|
|
634
877
|
action = "fill",
|
|
635
|
-
frame = { x = addBtnX, y =
|
|
878
|
+
frame = { x = addBtnX, y = slotY, w = config.addButtonWidth, h = config.slotHeight },
|
|
636
879
|
roundedRectRadii = { xRadius = 10, yRadius = 10 },
|
|
637
|
-
fillColor =
|
|
880
|
+
fillColor = { red = 0.95, green = 0.95, blue = 0.95, alpha = 1 },
|
|
638
881
|
trackMouseUp = true,
|
|
639
882
|
trackMouseEnterExit = true,
|
|
640
883
|
id = "addBtn",
|
|
641
884
|
})
|
|
642
885
|
dock:appendElements({
|
|
643
886
|
type = "text",
|
|
644
|
-
frame = { x = addBtnX, y =
|
|
887
|
+
frame = { x = addBtnX, y = slotY + 13, w = config.addButtonWidth, h = 30 },
|
|
645
888
|
text = "+",
|
|
646
889
|
textAlignment = "center",
|
|
647
|
-
textColor =
|
|
890
|
+
textColor = { red = 0.3, green = 0.3, blue = 0.3, alpha = 1 },
|
|
648
891
|
textSize = 28,
|
|
649
892
|
textFont = ".AppleSystemUIFont",
|
|
650
893
|
trackMouseUp = true,
|
|
@@ -659,6 +902,14 @@ createDock = function()
|
|
|
659
902
|
minimizeAllTerminals()
|
|
660
903
|
elseif id == "helpBtn" then
|
|
661
904
|
showHelpPanel()
|
|
905
|
+
elseif id and id:match("^tab_") then
|
|
906
|
+
local agentKey = id:match("^tab_(.+)$")
|
|
907
|
+
if agentKey and agents[agentKey] then
|
|
908
|
+
config.agent = agentKey
|
|
909
|
+
-- Recreate dock to update tab visuals
|
|
910
|
+
dock:delete()
|
|
911
|
+
createDock()
|
|
912
|
+
end
|
|
662
913
|
elseif id and id:match("^slot") then
|
|
663
914
|
local idx = tonumber(id:match("%d+"))
|
|
664
915
|
if idx then
|
|
@@ -710,12 +961,65 @@ end
|
|
|
710
961
|
-- Window event watcher for immediate updates
|
|
711
962
|
windowFilter = hs.window.filter.new("Terminal")
|
|
712
963
|
windowFilter:subscribe({
|
|
713
|
-
hs.window.filter.windowDestroyed,
|
|
714
964
|
hs.window.filter.windowMinimized,
|
|
715
965
|
hs.window.filter.windowUnminimized,
|
|
716
|
-
hs.window.filter.windowFocused,
|
|
717
966
|
}, updateAllSlots)
|
|
718
967
|
|
|
968
|
+
-- Handle window destruction - clear the slot
|
|
969
|
+
windowFilter:subscribe(hs.window.filter.windowDestroyed, function(win, appName, event)
|
|
970
|
+
-- win may be nil at this point, so we need to check all slots
|
|
971
|
+
for i, slot in ipairs(slots) do
|
|
972
|
+
if slot.windowId then
|
|
973
|
+
local existingWin = getWindow(slot.windowId)
|
|
974
|
+
if not existingWin then
|
|
975
|
+
-- Try to verify window is truly gone (not just on another space)
|
|
976
|
+
-- by checking all Terminal windows
|
|
977
|
+
local allTerminals = hs.window.filter.new("Terminal"):getWindows()
|
|
978
|
+
local found = false
|
|
979
|
+
for _, w in ipairs(allTerminals) do
|
|
980
|
+
if w:id() == slot.windowId then
|
|
981
|
+
found = true
|
|
982
|
+
break
|
|
983
|
+
end
|
|
984
|
+
end
|
|
985
|
+
if not found then
|
|
986
|
+
slot.windowId = nil
|
|
987
|
+
slot.customName = nil
|
|
988
|
+
slot.hasNotification = false
|
|
989
|
+
end
|
|
990
|
+
end
|
|
991
|
+
end
|
|
992
|
+
end
|
|
993
|
+
updateAllSlots()
|
|
994
|
+
end)
|
|
995
|
+
|
|
996
|
+
-- Clear notification when window is focused
|
|
997
|
+
windowFilter:subscribe(hs.window.filter.windowFocused, function(win)
|
|
998
|
+
if win then
|
|
999
|
+
local slotIndex = findSlotByWindowId(win:id())
|
|
1000
|
+
if slotIndex then
|
|
1001
|
+
slots[slotIndex].hasNotification = false
|
|
1002
|
+
end
|
|
1003
|
+
end
|
|
1004
|
+
updateAllSlots()
|
|
1005
|
+
end)
|
|
1006
|
+
|
|
1007
|
+
-- Watch for window title changes (indicates terminal activity)
|
|
1008
|
+
windowFilter:subscribe(hs.window.filter.windowTitleChanged, function(win)
|
|
1009
|
+
if win then
|
|
1010
|
+
local slotIndex = findSlotByWindowId(win:id())
|
|
1011
|
+
if slotIndex then
|
|
1012
|
+
local focusedWin = hs.window.focusedWindow()
|
|
1013
|
+
-- Only show notification if this window isn't focused
|
|
1014
|
+
if not focusedWin or focusedWin:id() ~= win:id() then
|
|
1015
|
+
slots[slotIndex].hasNotification = true
|
|
1016
|
+
updateSlotDisplay(slotIndex)
|
|
1017
|
+
startPulseAnimation()
|
|
1018
|
+
end
|
|
1019
|
+
end
|
|
1020
|
+
end
|
|
1021
|
+
end)
|
|
1022
|
+
|
|
719
1023
|
-- Periodic refresh as fallback
|
|
720
1024
|
updateTimer = hs.timer.doEvery(2, function()
|
|
721
1025
|
if dock and dock:isShowing() then
|
|
@@ -798,6 +1102,26 @@ if showRepositionedMsg then
|
|
|
798
1102
|
end)
|
|
799
1103
|
end
|
|
800
1104
|
|
|
1105
|
+
-- Global function to trigger notification on a slot (for testing or external use)
|
|
1106
|
+
-- Usage: triggerNotification(1) to trigger on slot 1
|
|
1107
|
+
function triggerNotification(slotIndex)
|
|
1108
|
+
if slotIndex and slots[slotIndex] then
|
|
1109
|
+
setSlotNotification(slotIndex)
|
|
1110
|
+
return true
|
|
1111
|
+
end
|
|
1112
|
+
return false
|
|
1113
|
+
end
|
|
1114
|
+
|
|
1115
|
+
-- Global function to clear notification on a slot
|
|
1116
|
+
function clearNotification(slotIndex)
|
|
1117
|
+
if slotIndex and slots[slotIndex] then
|
|
1118
|
+
slots[slotIndex].hasNotification = false
|
|
1119
|
+
updateSlotDisplay(slotIndex)
|
|
1120
|
+
return true
|
|
1121
|
+
end
|
|
1122
|
+
return false
|
|
1123
|
+
end
|
|
1124
|
+
|
|
801
1125
|
-- ===================
|
|
802
1126
|
-- TESTS (run with: hs -c "runTests()")
|
|
803
1127
|
-- ===================
|
|
@@ -974,6 +1298,78 @@ function runTests()
|
|
|
974
1298
|
assert(type(hideHelpPanel) == "function", "hideHelpPanel should be a function")
|
|
975
1299
|
end)
|
|
976
1300
|
|
|
1301
|
+
-- Notification badge tests
|
|
1302
|
+
test("config has notification badge color", function()
|
|
1303
|
+
assert(config.colors.notificationBadge, "notificationBadge color should exist")
|
|
1304
|
+
assert(config.notificationBadgeSize, "notificationBadgeSize should exist")
|
|
1305
|
+
end)
|
|
1306
|
+
|
|
1307
|
+
test("slots have hasNotification field", function()
|
|
1308
|
+
slots = {}
|
|
1309
|
+
slotCount = 2
|
|
1310
|
+
initSlots()
|
|
1311
|
+
assert(slots[1].hasNotification == false, "slot should have hasNotification = false")
|
|
1312
|
+
restore()
|
|
1313
|
+
end)
|
|
1314
|
+
|
|
1315
|
+
test("findSlotByWindowId returns nil for unknown window", function()
|
|
1316
|
+
assertEqual(findSlotByWindowId(999999999), nil)
|
|
1317
|
+
end)
|
|
1318
|
+
|
|
1319
|
+
test("findSlotByWindowId returns nil for nil input", function()
|
|
1320
|
+
assertEqual(findSlotByWindowId(nil), nil)
|
|
1321
|
+
end)
|
|
1322
|
+
|
|
1323
|
+
test("triggerNotification is a function", function()
|
|
1324
|
+
assert(type(triggerNotification) == "function", "triggerNotification should be a function")
|
|
1325
|
+
end)
|
|
1326
|
+
|
|
1327
|
+
test("clearNotification is a function", function()
|
|
1328
|
+
assert(type(clearNotification) == "function", "clearNotification should be a function")
|
|
1329
|
+
end)
|
|
1330
|
+
|
|
1331
|
+
test("triggerNotification returns false for invalid slot", function()
|
|
1332
|
+
assertEqual(triggerNotification(9999), false)
|
|
1333
|
+
end)
|
|
1334
|
+
|
|
1335
|
+
test("clearNotification returns false for invalid slot", function()
|
|
1336
|
+
assertEqual(clearNotification(9999), false)
|
|
1337
|
+
end)
|
|
1338
|
+
|
|
1339
|
+
-- Agent configuration tests
|
|
1340
|
+
test("config has agent field", function()
|
|
1341
|
+
assert(config.agent, "config.agent should exist")
|
|
1342
|
+
end)
|
|
1343
|
+
|
|
1344
|
+
test("agents table has claude, amp, codex", function()
|
|
1345
|
+
assert(agents.claude, "agents.claude should exist")
|
|
1346
|
+
assert(agents.amp, "agents.amp should exist")
|
|
1347
|
+
assert(agents.codex, "agents.codex should exist")
|
|
1348
|
+
end)
|
|
1349
|
+
|
|
1350
|
+
test("each agent has command and name", function()
|
|
1351
|
+
for name, agent in pairs(agents) do
|
|
1352
|
+
assert(agent.command, name .. " should have command")
|
|
1353
|
+
assert(agent.name, name .. " should have name")
|
|
1354
|
+
assert(agent.shortName, name .. " should have shortName")
|
|
1355
|
+
end
|
|
1356
|
+
end)
|
|
1357
|
+
|
|
1358
|
+
test("getAgent returns valid agent", function()
|
|
1359
|
+
local agent = getAgent()
|
|
1360
|
+
assert(agent, "getAgent should return an agent")
|
|
1361
|
+
assert(agent.command, "agent should have command")
|
|
1362
|
+
assert(agent.name, "agent should have name")
|
|
1363
|
+
end)
|
|
1364
|
+
|
|
1365
|
+
test("getAgent falls back to claude for invalid config", function()
|
|
1366
|
+
local originalAgent = config.agent
|
|
1367
|
+
config.agent = "invalid"
|
|
1368
|
+
local agent = getAgent()
|
|
1369
|
+
assertEqual(agent.command, "claude")
|
|
1370
|
+
config.agent = originalAgent
|
|
1371
|
+
end)
|
|
1372
|
+
|
|
977
1373
|
print("\n=== Results: " .. passed .. " passed, " .. failed .. " failed ===\n")
|
|
978
1374
|
return failed == 0
|
|
979
1375
|
end
|
package/package.json
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-dock",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "A lightweight terminal dock for macOS to manage Claude Code
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "A lightweight terminal dock for macOS to manage AI coding agent sessions - Claude Code, Amp, and Codex",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude-dock": "bin/cli.js"
|
|
7
7
|
},
|
|
8
8
|
"keywords": [
|
|
9
9
|
"claude",
|
|
10
|
+
"claude-code",
|
|
11
|
+
"amp",
|
|
12
|
+
"codex",
|
|
13
|
+
"openai",
|
|
14
|
+
"sourcegraph",
|
|
15
|
+
"ai-coding",
|
|
10
16
|
"hammerspoon",
|
|
11
17
|
"macos",
|
|
12
18
|
"terminal",
|