/ src / ink / useTerminalNotification.ts
useTerminalNotification.ts
  1  import { createContext, useCallback, useContext, useMemo } from 'react'
  2  import { isProgressReportingAvailable, type Progress } from './terminal.js'
  3  import { BEL } from './termio/ansi.js'
  4  import { ITERM2, OSC, osc, PROGRESS, wrapForMultiplexer } from './termio/osc.js'
  5  
  6  type WriteRaw = (data: string) => void
  7  
  8  export const TerminalWriteContext = createContext<WriteRaw | null>(null)
  9  
 10  export const TerminalWriteProvider = TerminalWriteContext.Provider
 11  
 12  export type TerminalNotification = {
 13    notifyITerm2: (opts: { message: string; title?: string }) => void
 14    notifyKitty: (opts: { message: string; title: string; id: number }) => void
 15    notifyGhostty: (opts: { message: string; title: string }) => void
 16    notifyBell: () => void
 17    /**
 18     * Report progress to the terminal via OSC 9;4 sequences.
 19     * Supported terminals: ConEmu, Ghostty 1.2.0+, iTerm2 3.6.6+
 20     * Pass state=null to clear progress.
 21     */
 22    progress: (state: Progress['state'] | null, percentage?: number) => void
 23  }
 24  
 25  export function useTerminalNotification(): TerminalNotification {
 26    const writeRaw = useContext(TerminalWriteContext)
 27    if (!writeRaw) {
 28      throw new Error(
 29        'useTerminalNotification must be used within TerminalWriteProvider',
 30      )
 31    }
 32  
 33    const notifyITerm2 = useCallback(
 34      ({ message, title }: { message: string; title?: string }) => {
 35        const displayString = title ? `${title}:\n${message}` : message
 36        writeRaw(wrapForMultiplexer(osc(OSC.ITERM2, `\n\n${displayString}`)))
 37      },
 38      [writeRaw],
 39    )
 40  
 41    const notifyKitty = useCallback(
 42      ({
 43        message,
 44        title,
 45        id,
 46      }: {
 47        message: string
 48        title: string
 49        id: number
 50      }) => {
 51        writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=0:p=title`, title)))
 52        writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:p=body`, message)))
 53        writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=1:a=focus`, '')))
 54      },
 55      [writeRaw],
 56    )
 57  
 58    const notifyGhostty = useCallback(
 59      ({ message, title }: { message: string; title: string }) => {
 60        writeRaw(wrapForMultiplexer(osc(OSC.GHOSTTY, 'notify', title, message)))
 61      },
 62      [writeRaw],
 63    )
 64  
 65    const notifyBell = useCallback(() => {
 66      // Raw BEL — inside tmux this triggers tmux's bell-action (window flag).
 67      // Wrapping would make it opaque DCS payload and lose that fallback.
 68      writeRaw(BEL)
 69    }, [writeRaw])
 70  
 71    const progress = useCallback(
 72      (state: Progress['state'] | null, percentage?: number) => {
 73        if (!isProgressReportingAvailable()) {
 74          return
 75        }
 76        if (!state) {
 77          writeRaw(
 78            wrapForMultiplexer(
 79              osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.CLEAR, ''),
 80            ),
 81          )
 82          return
 83        }
 84        const pct = Math.max(0, Math.min(100, Math.round(percentage ?? 0)))
 85        switch (state) {
 86          case 'completed':
 87            writeRaw(
 88              wrapForMultiplexer(
 89                osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.CLEAR, ''),
 90              ),
 91            )
 92            break
 93          case 'error':
 94            writeRaw(
 95              wrapForMultiplexer(
 96                osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.ERROR, pct),
 97              ),
 98            )
 99            break
100          case 'indeterminate':
101            writeRaw(
102              wrapForMultiplexer(
103                osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.INDETERMINATE, ''),
104              ),
105            )
106            break
107          case 'running':
108            writeRaw(
109              wrapForMultiplexer(
110                osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.SET, pct),
111              ),
112            )
113            break
114          case null:
115            // Handled by the if guard above
116            break
117        }
118      },
119      [writeRaw],
120    )
121  
122    return useMemo(
123      () => ({ notifyITerm2, notifyKitty, notifyGhostty, notifyBell, progress }),
124      [notifyITerm2, notifyKitty, notifyGhostty, notifyBell, progress],
125    )
126  }