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.
Files changed (3) hide show
  1. package/README.md +33 -30
  2. package/init.lua +462 -66
  3. 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/). Designed for managing multiple Claude Code terminal sessions.
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
  ![Claude Dock](https://img.shields.io/badge/macOS-Hammerspoon-blue)
6
6
  [![npm version](https://img.shields.io/npm/v/claude-dock.svg)](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 Claude** - New terminals automatically run `claude` command
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 empty
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 terminal |
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 `claude`
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 `init.lua` to customize:
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
- -- Configuration
89
- local slotWidth = 140 -- Width of each slot
90
- local slotHeight = 60 -- Height of each slot
91
- local gap = 8 -- Gap between slots
92
- local margin = 10 -- Dock padding
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
- ### Using a Different Terminal
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
- To use iTerm instead of Terminal.app, modify the `onSlotClick` function:
108
+ ### Other Options
100
109
 
101
110
  ```lua
102
- -- Change this:
103
- hs.applescript([[
104
- tell application "Terminal"
105
- do script "claude"
106
- activate
107
- end tell
108
- ]])
109
-
110
- -- To this:
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 Code sessions
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 = 4, -- bg, border, title, status
17
- baseElements = 6, -- dock bg + border + help btn + help text + minimize btn + minimize text
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 leftButtonWidth + (config.slotWidth * slotCount) + (config.gap * (slotCount - 1)) + config.gap + rightButtonWidth + (config.margin * 2)
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
- if buttonId == "minBtn" or buttonId == "helpBtn" then
192
- -- Left side utility buttons
193
- btnX = dockFrame.x + config.margin
194
- btnWidth = config.utilityButtonWidth
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
- -- Right side add button
197
- btnX = dockFrame.x + config.margin + config.utilityButtonWidth + config.gap + (slotCount * config.slotWidth) + (slotCount * config.gap)
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 "Terminal"
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
- -- Don't clear customName if we're waiting for a window to spawn
363
- if not slot.pending then
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 Claude Terminal",
635
+ "New " .. agent.name .. " Terminal",
466
636
  "Enter a name for this terminal:",
467
- "Claude " .. slotIndex,
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 ("Claude " .. slotIndex)
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 "claude"
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
- -- Utility buttons (left side, stacked: help on top, minimize on bottom)
533
- local utilBtnX = config.margin
534
- local btnGap = 4
535
- local btnHeight = (config.slotHeight - btnGap) / 2
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
- -- Help button (top)
538
- local helpBtnY = config.margin
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 = utilBtnX, y = helpBtnY, w = config.utilityButtonWidth, h = btnHeight },
751
+ frame = { x = minBtnX, y = utilBtnY, w = minBtnWidth, h = utilBtnSize },
543
752
  roundedRectRadii = { xRadius = 6, yRadius = 6 },
544
- fillColor = config.colors.helpBtnBg,
753
+ fillColor = config.colors.minBtnBg,
545
754
  trackMouseUp = true,
546
755
  trackMouseEnterExit = true,
547
- id = "helpBtn",
756
+ id = "minBtn",
548
757
  })
549
758
  dock:appendElements({
550
759
  type = "text",
551
- frame = { x = utilBtnX, y = helpBtnY + 4, w = config.utilityButtonWidth, h = btnHeight },
552
- text = "?",
760
+ frame = { x = minBtnX, y = utilBtnY + 2, w = minBtnWidth, h = utilBtnSize },
761
+ text = "Hide",
553
762
  textAlignment = "center",
554
- textColor = config.colors.helpBtnText,
555
- textSize = 16,
763
+ textColor = config.colors.minBtnText,
764
+ textSize = 11,
556
765
  textFont = ".AppleSystemUIFontBold",
557
766
  trackMouseUp = true,
558
- id = "helpBtn",
767
+ id = "minBtn",
559
768
  })
560
769
 
561
- -- Minimize button (bottom)
562
- local minBtnY = config.margin + btnHeight + btnGap
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 = utilBtnX, y = minBtnY, w = config.utilityButtonWidth, h = btnHeight },
776
+ frame = { x = helpBtnX, y = utilBtnY, w = helpBtnWidth, h = utilBtnSize },
567
777
  roundedRectRadii = { xRadius = 6, yRadius = 6 },
568
- fillColor = config.colors.minBtnBg,
778
+ fillColor = config.colors.helpBtnBg,
569
779
  trackMouseUp = true,
570
780
  trackMouseEnterExit = true,
571
- id = "minBtn",
781
+ id = "helpBtn",
572
782
  })
573
783
  dock:appendElements({
574
784
  type = "text",
575
- frame = { x = utilBtnX, y = minBtnY + 4, w = config.utilityButtonWidth, h = btnHeight },
576
- text = "",
785
+ frame = { x = helpBtnX, y = utilBtnY + 2, w = helpBtnWidth, h = utilBtnSize },
786
+ text = "Help",
577
787
  textAlignment = "center",
578
- textColor = config.colors.minBtnText,
579
- textSize = 16,
580
- textFont = ".AppleSystemUIFont",
788
+ textColor = config.colors.helpBtnText,
789
+ textSize = 11,
790
+ textFont = ".AppleSystemUIFontBold",
581
791
  trackMouseUp = true,
582
- id = "minBtn",
792
+ id = "helpBtn",
583
793
  })
584
794
 
585
- -- Slots (offset by utility button)
586
- local slotsStartX = config.margin + config.utilityButtonWidth + config.gap
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 = config.margin, w = config.slotWidth, h = config.slotHeight },
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 = config.margin, w = config.slotWidth, h = config.slotHeight },
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 = config.margin + 8, w = config.slotWidth - 12, h = 24 },
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 = config.margin + 32, w = config.slotWidth - 12, h = 20 },
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 = config.margin, w = config.addButtonWidth, h = config.slotHeight },
878
+ frame = { x = addBtnX, y = slotY, w = config.addButtonWidth, h = config.slotHeight },
636
879
  roundedRectRadii = { xRadius = 10, yRadius = 10 },
637
- fillColor = config.colors.addBtnBg,
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 = config.margin + 13, w = config.addButtonWidth, h = 30 },
887
+ frame = { x = addBtnX, y = slotY + 13, w = config.addButtonWidth, h = 30 },
645
888
  text = "+",
646
889
  textAlignment = "center",
647
- textColor = config.colors.addBtnText,
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.1.0",
4
- "description": "A lightweight terminal dock for macOS to manage Claude Code sessions",
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",