Skip to content

Commit 5d35982

Browse files
committed
opencode notification
ghostty improve
1 parent a6c24f4 commit 5d35982

6 files changed

Lines changed: 315 additions & 7 deletions

File tree

ghostty/config

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
copy-on-select = clipboard
2-
cursor-style = bar
3-
cursor-style-blink = true
1+
theme = Gruvbox Dark
42
font-family = "MesloLGS NF Regular"
53
font-size = 16
4+
65
maximize = true
76
macos-titlebar-style = hidden
8-
scrollback-limit = 500000
9-
theme = Gruvbox Dark
7+
macos-option-as-alt = true
108
window-padding-x = 8
119
window-padding-y = 8
10+
11+
copy-on-select = clipboard
12+
scrollback-limit = 500000
13+
shell-integration-features = cursor,sudo,title,ssh-env,ssh-terminfo

opencode/plugins/notifier.ts

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
import type { Plugin } from "@opencode-ai/plugin"
2+
import { exec } from "node:child_process"
3+
import { basename } from "node:path"
4+
5+
const execWithTimeout = (cmd: string, timeoutMs = 500): Promise<string> =>
6+
new Promise((resolve, reject) => {
7+
exec(cmd, { timeout: timeoutMs }, (err, stdout) => {
8+
if (err) return reject(err)
9+
resolve((stdout ?? "").trim())
10+
})
11+
})
12+
13+
const getFrontmostPid = async (): Promise<number | null> => {
14+
try {
15+
const result = await execWithTimeout(
16+
`osascript -e 'tell application "System Events" to get unix id of first application process whose frontmost is true'`,
17+
)
18+
const pid = parseInt(result, 10)
19+
return Number.isFinite(pid) ? pid : null
20+
} catch {
21+
return null
22+
}
23+
}
24+
25+
const getAncestorPids = async (startPid: number): Promise<Set<number>> => {
26+
const ancestors = new Set<number>()
27+
try {
28+
const result = await execWithTimeout(`ps -eo pid=,ppid=`, 1000)
29+
const parentMap = new Map<number, number>()
30+
for (const line of result.split("\n")) {
31+
const parts = line.trim().split(/\s+/)
32+
if (parts.length === 2)
33+
parentMap.set(parseInt(parts[0], 10), parseInt(parts[1], 10))
34+
}
35+
let pid = startPid
36+
while (pid > 1) {
37+
ancestors.add(pid)
38+
const ppid = parentMap.get(pid)
39+
if (ppid === undefined || ppid === pid) break
40+
pid = ppid
41+
}
42+
} catch {}
43+
return ancestors
44+
}
45+
46+
const getMultiplexerWindowLabel = async (): Promise<string | null> => {
47+
if (process.env.TMUX) {
48+
const pane = process.env.TMUX_PANE
49+
if (!pane) return null
50+
try {
51+
return (await execWithTimeout(
52+
`tmux display-message -t ${pane} -p '#{session_name}-#{window_index}'`,
53+
)) || null
54+
} catch {
55+
return null
56+
}
57+
}
58+
if (process.env.STY) {
59+
const windowId = process.env.WINDOW
60+
if (!windowId) return null
61+
const name = process.env.STY.split(".").slice(1).join(".")
62+
return name ? `${name}-${windowId}` : null
63+
}
64+
return null
65+
}
66+
67+
const getTmuxClientPid = async (): Promise<number | null> => {
68+
try {
69+
const target = process.env.TMUX_PANE ? `-t ${process.env.TMUX_PANE} ` : ""
70+
const result = await execWithTimeout(
71+
`tmux display-message ${target}-p '#{client_pid}'`,
72+
)
73+
const pid = parseInt(result, 10)
74+
return Number.isFinite(pid) ? pid : null
75+
} catch {
76+
return null
77+
}
78+
}
79+
80+
const isTmuxPaneActive = async (): Promise<boolean> => {
81+
const pane = process.env.TMUX_PANE
82+
if (!pane) return true
83+
try {
84+
const result = await execWithTimeout(
85+
`tmux display-message -t ${pane} -p '#{session_attached} #{window_active} #{pane_active}'`,
86+
)
87+
const parts = result.split(" ")
88+
return parts[0] === "1" && parts[1] === "1" && parts[2] === "1"
89+
} catch {
90+
return true
91+
}
92+
}
93+
94+
const getScreenClientPid = async (): Promise<number | null> => {
95+
const sty = process.env.STY
96+
if (!sty) return null
97+
try {
98+
const screenServerPid = parseInt(sty.split(".")[0], 10)
99+
if (!Number.isFinite(screenServerPid)) return null
100+
const result = await execWithTimeout(
101+
`ps -eo pid=,ppid=,command= | grep '[s]creen'`,
102+
1000,
103+
)
104+
for (const line of result.split("\n")) {
105+
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/)
106+
if (!match) continue
107+
const pid = parseInt(match[1], 10)
108+
const ppid = parseInt(match[2], 10)
109+
if (pid === screenServerPid || ppid === screenServerPid) continue
110+
if (match[3].includes("screen") && match[3].includes(sty)) return pid
111+
}
112+
return null
113+
} catch {
114+
return null
115+
}
116+
}
117+
118+
const isScreenWindowActive = async (): Promise<boolean> => {
119+
const sty = process.env.STY
120+
if (!sty) return true
121+
const windowId = process.env.WINDOW
122+
if (!windowId) return true
123+
try {
124+
const result = await execWithTimeout(`screen -S ${sty} -Q number`, 1000)
125+
const match = result.match(/^(\d+)/)
126+
return match ? match[1] === windowId : true
127+
} catch {
128+
return true
129+
}
130+
}
131+
132+
const isTerminalFocused = async (): Promise<boolean> => {
133+
try {
134+
const frontPid = await getFrontmostPid()
135+
if (frontPid === null) return false
136+
137+
let startPid: number
138+
let extraCheck: (() => Promise<boolean>) | null = null
139+
140+
if (process.env.TMUX) {
141+
const clientPid = await getTmuxClientPid()
142+
if (clientPid === null) return false
143+
startPid = clientPid
144+
extraCheck = isTmuxPaneActive
145+
} else if (process.env.STY) {
146+
const clientPid = await getScreenClientPid()
147+
if (clientPid === null) return false
148+
startPid = clientPid
149+
extraCheck = isScreenWindowActive
150+
} else {
151+
startPid = process.pid
152+
}
153+
154+
const ancestors = await getAncestorPids(startPid)
155+
if (!ancestors.has(frontPid)) return false
156+
return extraCheck ? extraCheck() : true
157+
} catch {
158+
return false
159+
}
160+
}
161+
162+
const notifyDebounce = new Map<string, number>()
163+
164+
const sendNotification = async (title: string, message: string): Promise<void> => {
165+
const key = `${title}:${message}`
166+
const now = Date.now()
167+
if ((notifyDebounce.get(key) ?? 0) > now - 1000) return
168+
notifyDebounce.set(key, now)
169+
170+
const esc = (s: string) => s.replace(/"/g, '\\"')
171+
try {
172+
await execWithTimeout(
173+
`osascript -e 'display notification "${esc(message)}" with title "${esc(title)}"'`,
174+
3000,
175+
)
176+
} catch {}
177+
}
178+
179+
const playSound = (): Promise<void> =>
180+
new Promise((resolve) => {
181+
exec(`afplay /System/Library/Sounds/Blow.aiff`, { timeout: 5000 }, () => resolve())
182+
})
183+
184+
const notify = async (message: string, projectName: string): Promise<void> => {
185+
if (await isTerminalFocused()) return
186+
187+
const windowLabel = await getMultiplexerWindowLabel()
188+
const title = `OpenCode - ${windowLabel ?? projectName}`
189+
190+
await Promise.allSettled([
191+
sendNotification(title, message),
192+
playSound(),
193+
])
194+
}
195+
196+
export const NotifierPlugin: Plugin = async ({ directory }) => {
197+
if (process.env.OPENCODE_CLIENT && process.env.OPENCODE_CLIENT !== "cli") return {}
198+
199+
const projectName = basename(directory)
200+
const childSessions = new Set<string>()
201+
const sessionBusyTimes = new Map<string, number>()
202+
const sessionIdleSeqs = new Map<string, number>()
203+
const pendingIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
204+
const sessionNotifiedEvents = new Map<string, Set<string>>()
205+
206+
const cleanupInterval = setInterval(() => {
207+
const cutoff = Date.now() - 30 * 60 * 1000
208+
for (const [id, time] of sessionBusyTimes) {
209+
if (time < cutoff) {
210+
sessionBusyTimes.delete(id)
211+
sessionIdleSeqs.delete(id)
212+
sessionNotifiedEvents.delete(id)
213+
childSessions.delete(id)
214+
}
215+
}
216+
}, 5 * 60 * 1000)
217+
cleanupInterval.unref?.()
218+
219+
const dedup = (message: string, sessionId?: string): boolean => {
220+
if (!sessionId) return false
221+
const notified = sessionNotifiedEvents.get(sessionId)
222+
if (notified?.has(message)) return true
223+
if (!notified) sessionNotifiedEvents.set(sessionId, new Set([message]))
224+
else notified.add(message)
225+
return false
226+
}
227+
228+
const cancelPendingIdle = (sessionId: string) => {
229+
const timer = pendingIdleTimers.get(sessionId)
230+
if (timer) {
231+
clearTimeout(timer)
232+
pendingIdleTimers.delete(sessionId)
233+
}
234+
sessionIdleSeqs.set(sessionId, (sessionIdleSeqs.get(sessionId) ?? 0) + 1)
235+
}
236+
237+
const scheduleSessionIdle = (sessionId: string) => {
238+
cancelPendingIdle(sessionId)
239+
const seq = sessionIdleSeqs.get(sessionId) ?? 0
240+
241+
const timer = setTimeout(async () => {
242+
pendingIdleTimers.delete(sessionId)
243+
if ((sessionIdleSeqs.get(sessionId) ?? 0) !== seq) return
244+
if (childSessions.has(sessionId)) return
245+
await notify("Session has finished", projectName)
246+
}, 350)
247+
248+
timer.unref?.()
249+
pendingIdleTimers.set(sessionId, timer)
250+
}
251+
252+
return {
253+
event: async ({ event }) => {
254+
switch (event.type) {
255+
case "session.created": {
256+
const info = (event.properties as any).info
257+
if (info?.parentID) childSessions.add(info.id)
258+
break
259+
}
260+
case "permission.asked": {
261+
const sid = (event.properties as any).sessionID ?? ""
262+
if (!dedup("permission", sid)) await notify("Session needs permission", projectName)
263+
break
264+
}
265+
case "session.idle": {
266+
scheduleSessionIdle(event.properties.sessionID)
267+
break
268+
}
269+
case "session.status": {
270+
const { sessionID, status } = event.properties
271+
if (status.type === "busy") {
272+
sessionBusyTimes.set(sessionID, Date.now())
273+
cancelPendingIdle(sessionID)
274+
sessionNotifiedEvents.delete(sessionID)
275+
}
276+
break
277+
}
278+
case "session.error": {
279+
const { sessionID, error } = event.properties as any
280+
cancelPendingIdle(sessionID ?? "")
281+
if (error?.name !== "MessageAbortedError") await notify("Session encountered an error", projectName)
282+
break
283+
}
284+
case "question.asked": {
285+
const qSid = (event.properties as any).sessionID ?? ""
286+
if (!dedup("question", qSid)) await notify("Session has a question", projectName)
287+
break
288+
}
289+
}
290+
},
291+
"permission.ask": async (input, _output) => {
292+
const sid = (input as any).sessionID ?? ""
293+
if (!dedup("permission", sid)) await notify("Session needs permission", projectName)
294+
},
295+
"tool.execute.before": async (input, _output) => {
296+
if (input.tool === "question" && !dedup("question", input.sessionID))
297+
await notify("Session has a question", projectName)
298+
},
299+
}
300+
}
301+
302+
export default NotifierPlugin

opencode/tui.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
{
22
"$schema": "https://opencode.ai/tui.json",
33
"theme": "gruvbox",
4+
"keybinds": {
5+
"session_new": "ctrl+alt+l"
6+
},
47
"scroll_acceleration": {
58
"enabled": true
69
}

sesh/sesh.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[default_session]
2-
startup_command = "tmux rename-window opencode; tmux new-window; tmux new-window -d; oc --continue"
2+
startup_command = "tmux rename-window opencode; tmux new-window; tmux new-window -d; oc"
33
preview_command = "eza --all --git --icons --color=always {}"

tmux/tmux.conf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ set -g mouse on
2525
set -g renumber-windows on
2626
set -g set-clipboard on
2727
set -g window-size largest
28+
set -g allow-passthrough on
2829

2930
# Enable extended keys for proper modifier key support
3031
set -g extended-keys on

zsh/.zshrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ alias kns="kubens"
6767
alias ks="k9s"
6868
alias n="nvim"
6969
alias o="open ."
70-
alias oc="opencode --port"
70+
alias oc="opencode --port --continue"
7171
alias tf="terraform"
7272

7373
## Configuration Reloads & Updates

0 commit comments

Comments
 (0)