-
-
Notifications
You must be signed in to change notification settings - Fork 955
feat: command completion notifications (sound, OS notify, highlight) #3294
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| // Copyright 2026, Command Line Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package cmd | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os" | ||
|
|
||
| "github.com/spf13/cobra" | ||
| "github.com/wavetermdev/waveterm/pkg/wps" | ||
| "github.com/wavetermdev/waveterm/pkg/wshrpc" | ||
| "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" | ||
| ) | ||
|
|
||
| var doneExitCode int | ||
| var doneTitle string | ||
| var doneMessage string | ||
|
|
||
| var doneCmd = &cobra.Command{ | ||
| Use: "done [-t title] [-m message] [-e exitcode]", | ||
| Short: "Signal that a command has finished (triggers notification sound, highlight, and OS notification for background blocks)", | ||
| Args: cobra.MaximumNArgs(1), | ||
| RunE: doneRun, | ||
| PreRunE: preRunSetupRpcClient, | ||
| } | ||
|
|
||
| func init() { | ||
| doneCmd.Flags().IntVarP(&doneExitCode, "exitcode", "e", 0, "exit code of the completed command") | ||
| doneCmd.Flags().StringVarP(&doneTitle, "title", "t", "", "notification title (default: Command Finished)") | ||
| doneCmd.Flags().StringVarP(&doneMessage, "message", "m", "", "notification message") | ||
| rootCmd.AddCommand(doneCmd) | ||
| } | ||
|
|
||
| func doneRun(cmd *cobra.Command, args []string) (rtnErr error) { | ||
| defer func() { | ||
| sendActivity("done", rtnErr == nil) | ||
| }() | ||
| blockId := os.Getenv("WAVETERM_BLOCKID") | ||
| if blockId == "" { | ||
| return fmt.Errorf("WAVETERM_BLOCKID not set, must be run inside a Wave terminal block") | ||
| } | ||
|
|
||
| if doneMessage == "" && len(args) > 0 { | ||
| doneMessage = args[0] | ||
| } | ||
|
|
||
| err := wshclient.EventPublishCommand(RpcClient, wps.WaveEvent{ | ||
| Event: wps.Event_BlockDone, | ||
| Scopes: []string{fmt.Sprintf("block:%s", blockId)}, | ||
| Data: wshrpc.BlockDoneEventData{ | ||
| BlockId: blockId, | ||
| ExitCode: doneExitCode, | ||
| Title: doneTitle, | ||
| Message: doneMessage, | ||
| }, | ||
| }, &wshrpc.RpcOpts{NoResponse: true}) | ||
| if err != nil { | ||
| return fmt.Errorf("done command: %w", err) | ||
| } | ||
| return nil | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -73,6 +73,10 @@ contextBridge.exposeInMainWorld("api", { | |
| getPathForFile: (file: File): string => webUtils.getPathForFile(file), | ||
| saveTextFile: (fileName: string, content: string) => ipcRenderer.invoke("save-text-file", fileName, content), | ||
| setIsActive: () => ipcRenderer.invoke("set-is-active"), | ||
| showCompletionNotification: (tabId, blockId, title, body) => | ||
| ipcRenderer.send("show-completion-notification", tabId, blockId, title, body), | ||
| onFocusBlock: (callback) => | ||
| ipcRenderer.on("focus-block", (_event, blockId) => callback(blockId)), | ||
|
Comment on lines
+76
to
+79
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Inspect the main-process IPC handler for show-completion-notification to confirm any length/sanitization handling.
rg -nP -C3 'show-completion-notification' --type=tsRepository: wavetermdev/waveterm Length of output: 1764 🏁 Script executed: sed -n '507,530p' emain/emain-ipc.tsRepository: wavetermdev/waveterm Length of output: 1108 🏁 Script executed: rg -nP 'showCompletionNotification\(' --type=tsRepository: wavetermdev/waveterm Length of output: 202 🏁 Script executed: sed -n '610,635p' frontend/app/view/term/term-model.tsRepository: wavetermdev/waveterm Length of output: 1212 🏁 Script executed: sed -n '570,625p' frontend/app/view/term/term-model.tsRepository: wavetermdev/waveterm Length of output: 2277 Consistent with existing preload patterns; one heads-up on downstream handling. The wiring is correct and matches the conventions used by neighboring API methods (sync One thing to double-check on the main side: the 🤖 Prompt for AI Agents |
||
| }); | ||
|
|
||
| // Custom event for "new-window" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,9 +2,12 @@ | |
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { WaveAIModel } from "@/app/aipanel/waveai-model"; | ||
| import { BlockModel } from "@/app/block/block-model"; | ||
| import { BlockNodeModel } from "@/app/block/blocktypes"; | ||
| import { appHandleKeyDown } from "@/app/store/keymodel"; | ||
| import { FocusManager } from "@/app/store/focusManager"; | ||
| import { modalsModel } from "@/app/store/modalmodel"; | ||
| import { setBadge } from "@/app/store/badge"; | ||
| import type { TabModel } from "@/app/store/tab-model"; | ||
| import { waveEventSubscribeSingle } from "@/app/store/wps"; | ||
| import { RpcApi } from "@/app/store/wshclientapi"; | ||
|
|
@@ -14,6 +17,7 @@ import { TermClaudeIcon, TerminalView } from "@/app/view/term/term"; | |
| import { TermWshClient } from "@/app/view/term/term-wsh"; | ||
| import { VDomModel } from "@/app/view/vdom/vdom-model"; | ||
| import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; | ||
| import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks"; | ||
| import { | ||
| atoms, | ||
| createBlock, | ||
|
|
@@ -73,6 +77,7 @@ export class TermViewModel implements ViewModel { | |
| shellProcFullStatus: jotai.PrimitiveAtom<BlockControllerRuntimeStatus>; | ||
| shellProcStatus: jotai.Atom<string>; | ||
| shellProcStatusUnsubFn: () => void; | ||
| blockDoneUnsubFn: () => void; | ||
| blockJobStatusAtom: jotai.PrimitiveAtom<BlockJobStatusData>; | ||
| blockJobStatusVersionTs: number; | ||
| blockJobStatusUnsubFn: () => void; | ||
|
|
@@ -346,6 +351,13 @@ export class TermViewModel implements ViewModel { | |
| this.updateShellProcStatus(event.data); | ||
| }, | ||
| }); | ||
| this.blockDoneUnsubFn = waveEventSubscribeSingle({ | ||
| eventType: "block:done", | ||
| scope: WOS.makeORef("block", blockId), | ||
| handler: (event) => { | ||
| this.handleBlockDoneEvent(event.data); | ||
| }, | ||
| }); | ||
| this.shellProcStatus = jotai.atom((get) => { | ||
| const fullStatus = get(this.shellProcFullStatus); | ||
| return fullStatus?.shellprocstatus ?? "init"; | ||
|
|
@@ -565,6 +577,75 @@ export class TermViewModel implements ViewModel { | |
| } | ||
| } | ||
|
|
||
| getLastTerminalLine(): string { | ||
| const term = this.termRef.current?.terminal; | ||
| if (term == null) return ""; | ||
| const buf = term.buffer.active; | ||
| const start = Math.max(0, buf.length - 200); | ||
| for (let i = buf.length - 1; i >= start; i--) { | ||
| const line = buf.getLine(i); | ||
| if (line == null) continue; | ||
| const text = line.translateToString(true).trim(); | ||
| if (text.length > 0) return text; | ||
| } | ||
| return ""; | ||
| } | ||
|
|
||
| handleBlockDoneEvent(data: BlockDoneEventData) { | ||
| if (data == null || data.blockid !== this.blockId) { | ||
| return; | ||
| } | ||
| const exitCode = data.exitcode ?? 0; | ||
| const title = data.title || (exitCode === 0 ? "Command Finished" : "Command Failed"); | ||
| let body = data.message; | ||
| if (!body) { | ||
| body = this.getLastTerminalLine() || `exit code ${exitCode}`; | ||
| } | ||
| this.triggerCompletionNotifications(exitCode, title, body); | ||
| } | ||
|
|
||
| triggerCompletionNotifications(exitCode: number, title: string, notifyBody?: string) { | ||
| const focusManager = FocusManager.getInstance(); | ||
| const focusedBlockId = globalStore.get(focusManager.blockFocusAtom); | ||
| if (focusedBlockId === this.blockId) { | ||
| return; | ||
| } | ||
|
|
||
| const doneSoundEnabled = globalStore.get(getOverrideConfigAtom(this.blockId, "term:donesound")) ?? true; | ||
| if (doneSoundEnabled) { | ||
| fireAndForget(() => | ||
| RpcApi.ElectronSystemBellCommand(TabRpcClient, { route: "electron" }) | ||
| ); | ||
| } | ||
|
|
||
| const doneNotifyEnabled = globalStore.get(getOverrideConfigAtom(this.blockId, "term:donenotify")) ?? true; | ||
| if (doneNotifyEnabled) { | ||
| const body = notifyBody || `exit code ${exitCode}`; | ||
| getApi().showCompletionNotification(this.tabModel.tabId, this.blockId, title, body); | ||
| } | ||
|
|
||
| const doneAutoFocusEnabled = globalStore.get(getOverrideConfigAtom(this.blockId, "term:doneautofocus")) ?? false; | ||
| if (doneAutoFocusEnabled) { | ||
| getApi().setActiveTab(this.tabModel.tabId); | ||
| setTimeout(() => { | ||
| const layoutModel = getLayoutModelForStaticTab(); | ||
| const node = layoutModel?.getNodeByBlockId(this.blockId); | ||
| if (node?.id) { | ||
| layoutModel.focusNode(node.id); | ||
| } | ||
| }, 150); | ||
| } | ||
|
|
||
| BlockModel.getInstance().setCompletionHighlight(this.blockId, exitCode); | ||
|
|
||
| setBadge(this.blockId, { | ||
| badgeid: `done-${this.blockId}`, | ||
| icon: "bell", | ||
| color: exitCode === 0 ? "#3b82f6" : "#ef4444", | ||
| priority: 5, | ||
| }); | ||
| } | ||
|
Comment on lines
+607
to
+647
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify wsh done CLI semantics for -t and -m flags and how empty values are handled.
fd -t f 'wshcmd-done.go' | xargs -I{} cat {}Repository: wavetermdev/waveterm Length of output: 1805 🏁 Script executed: rg -n "block:done" --type ts --type tsx -B 5 -A 15Repository: wavetermdev/waveterm Length of output: 92 🏁 Script executed: rg "block:done" -B 5 -A 15 --type tsRepository: wavetermdev/waveterm Length of output: 3736 🏁 Script executed: rg "handleBlockDoneEvent" -B 2 -A 20 --type tsRepository: wavetermdev/waveterm Length of output: 3691 🏁 Script executed: rg "termThemeNameAtom|termTransparencyAtom" -B 3 -A 3 --type tsRepository: wavetermdev/waveterm Length of output: 3998 🏁 Script executed: rg "useBlockAtom" -B 2 -A 10 --type ts | head -60Repository: wavetermdev/waveterm Length of output: 4784 🏁 Script executed: rg "getLayoutModelForStaticTab" -B 3 -A 8 --type tsRepository: wavetermdev/waveterm Length of output: 35136 🏁 Script executed: cat -n frontend/app/view/term/term-model.ts | sed -n '593,610p'Repository: wavetermdev/waveterm Length of output: 978 🏁 Script executed: rg "setActiveTab" -B 2 -A 5 --type ts | head -40Repository: wavetermdev/waveterm Length of output: 2425 🏁 Script executed: rg "staticTabId" -B 2 -A 2 --type ts | head -50Repository: wavetermdev/waveterm Length of output: 3569 🏁 Script executed: rg "onFocusBlock" -B 5 -A 10 --type tsRepository: wavetermdev/waveterm Length of output: 4387 A few concerns in
🤖 Prompt for AI Agents |
||
|
|
||
| getVDomModel(): VDomModel { | ||
| const vdomBlockId = globalStore.get(this.vdomBlockId); | ||
| if (!vdomBlockId) { | ||
|
|
@@ -592,6 +673,7 @@ export class TermViewModel implements ViewModel { | |
| dispose() { | ||
| DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId)); | ||
| this.shellProcStatusUnsubFn?.(); | ||
| this.blockDoneUnsubFn?.(); | ||
| this.blockJobStatusUnsubFn?.(); | ||
| this.termBPMUnsubFn?.(); | ||
| this.termCursorUnsubFn?.(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Await tab switch completion before sending
focus-block.Line 523 triggers an async tab switch, but Lines 524-527 execute immediately. If the tab isn’t loaded yet, notification click won’t focus the target block.
Suggested fix
🤖 Prompt for AI Agents