Skip to content

Commit 1e3e2ec

Browse files
hyi1233蔡青
authored andcommitted
feat: add command completion notifications (sound, OS notify, highlight)
Add three notification features when a background block's command finishes: 1. **Sound** - plays system bell when command completes in an unfocused block 2. **OS Notification** - shows desktop notification with last terminal line as body, clickable to jump to the completed block 3. **Visual Highlight** - blue border glow for success (exit 0), red for failure, auto-clears on focus Also adds `wsh done` CLI command to explicitly signal command completion, publishing a `block:done` WPS event. Includes bell badge on block header that focuses the block when clicked. Three new settings: term:donenotify, term:donesound, term:doneautofocus.
1 parent 021db67 commit 1e3e2ec

22 files changed

Lines changed: 308 additions & 8 deletions

cmd/wsh/cmd/wshcmd-done.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cmd
5+
6+
import (
7+
"fmt"
8+
"os"
9+
10+
"github.com/spf13/cobra"
11+
"github.com/wavetermdev/waveterm/pkg/wps"
12+
"github.com/wavetermdev/waveterm/pkg/wshrpc"
13+
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
14+
)
15+
16+
var doneExitCode int
17+
var doneTitle string
18+
var doneMessage string
19+
20+
var doneCmd = &cobra.Command{
21+
Use: "done [-t title] [-m message] [-e exitcode]",
22+
Short: "Signal that a command has finished (triggers notification sound, highlight, and OS notification for background blocks)",
23+
Args: cobra.MaximumNArgs(1),
24+
RunE: doneRun,
25+
PreRunE: preRunSetupRpcClient,
26+
}
27+
28+
func init() {
29+
doneCmd.Flags().IntVarP(&doneExitCode, "exitcode", "e", 0, "exit code of the completed command")
30+
doneCmd.Flags().StringVarP(&doneTitle, "title", "t", "", "notification title (default: Command Finished)")
31+
doneCmd.Flags().StringVarP(&doneMessage, "message", "m", "", "notification message")
32+
rootCmd.AddCommand(doneCmd)
33+
}
34+
35+
func doneRun(cmd *cobra.Command, args []string) (rtnErr error) {
36+
defer func() {
37+
sendActivity("done", rtnErr == nil)
38+
}()
39+
blockId := os.Getenv("WAVETERM_BLOCKID")
40+
if blockId == "" {
41+
return fmt.Errorf("WAVETERM_BLOCKID not set, must be run inside a Wave terminal block")
42+
}
43+
44+
if doneMessage == "" && len(args) > 0 {
45+
doneMessage = args[0]
46+
}
47+
48+
err := wshclient.EventPublishCommand(RpcClient, wps.WaveEvent{
49+
Event: "block:done",
50+
Scopes: []string{fmt.Sprintf("block:%s", blockId)},
51+
Data: map[string]any{
52+
"blockid": blockId,
53+
"exitcode": doneExitCode,
54+
"title": doneTitle,
55+
"message": doneMessage,
56+
},
57+
}, &wshrpc.RpcOpts{NoResponse: true})
58+
if err != nil {
59+
return fmt.Errorf("done command: %w", err)
60+
}
61+
return nil
62+
}

emain/emain-ipc.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,32 @@ export function initIpcHandlers() {
504504
bw.destroy();
505505
});
506506

507+
electron.ipcMain.on(
508+
"show-completion-notification",
509+
(event, tabId: string, blockId: string, title: string, body: string) => {
510+
const senderWcId = event.sender.id;
511+
const { Notification: ElectronNotification } = electron;
512+
if (ElectronNotification == null || !ElectronNotification.isSupported()) {
513+
return;
514+
}
515+
const notification = new ElectronNotification({ title, body, silent: false });
516+
notification.on("click", () => {
517+
const ww = getWaveWindowByWebContentsId(senderWcId);
518+
if (ww == null) return;
519+
if (ww.isMinimized()) {
520+
ww.restore();
521+
}
522+
ww.focus();
523+
ww.setActiveTab(tabId, false);
524+
const tabView = ww.allLoadedTabViews.get(tabId);
525+
if (tabView) {
526+
tabView.webContents.send("focus-block", blockId);
527+
}
528+
});
529+
notification.show();
530+
}
531+
);
532+
507533
electron.ipcMain.on("do-refresh", (event) => {
508534
event.sender.reloadIgnoringCache();
509535
});

emain/preload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ contextBridge.exposeInMainWorld("api", {
7373
getPathForFile: (file: File): string => webUtils.getPathForFile(file),
7474
saveTextFile: (fileName: string, content: string) => ipcRenderer.invoke("save-text-file", fileName, content),
7575
setIsActive: () => ipcRenderer.invoke("set-is-active"),
76+
showCompletionNotification: (tabId, blockId, title, body) =>
77+
ipcRenderer.send("show-completion-notification", tabId, blockId, title, body),
78+
onFocusBlock: (callback) =>
79+
ipcRenderer.on("focus-block", (_event, blockId) => callback(blockId)),
7680
});
7781

7882
// Custom event for "new-window"

frontend/app/block/block-model.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ export interface BlockHighlightType {
1212
export class BlockModel {
1313
private static instance: BlockModel | null = null;
1414
private blockHighlightAtomCache = new Map<string, jotai.Atom<BlockHighlightType | null>>();
15+
private completionHighlightAtomCache = new Map<string, jotai.Atom<number | null>>();
1516

1617
blockHighlightAtom: jotai.PrimitiveAtom<BlockHighlightType> = jotai.atom(null) as jotai.PrimitiveAtom<BlockHighlightType>;
18+
completionHighlightAtom: jotai.PrimitiveAtom<Map<string, number>> = jotai.atom(new Map()) as jotai.PrimitiveAtom<Map<string, number>>;
1719

18-
private constructor() {
19-
// Empty for now
20-
}
20+
private constructor() {}
2121

2222
getBlockHighlightAtom(blockId: string): jotai.Atom<BlockHighlightType | null> {
2323
let atom = this.blockHighlightAtomCache.get(blockId);
@@ -38,6 +38,32 @@ export class BlockModel {
3838
globalStore.set(this.blockHighlightAtom, highlight);
3939
}
4040

41+
getCompletionHighlightAtom(blockId: string): jotai.Atom<number | null> {
42+
let atom = this.completionHighlightAtomCache.get(blockId);
43+
if (!atom) {
44+
atom = jotai.atom((get) => {
45+
const map = get(this.completionHighlightAtom);
46+
return map.get(blockId) ?? null;
47+
});
48+
this.completionHighlightAtomCache.set(blockId, atom);
49+
}
50+
return atom;
51+
}
52+
53+
setCompletionHighlight(blockId: string, exitCode: number) {
54+
const currentMap = new Map(globalStore.get(this.completionHighlightAtom));
55+
currentMap.clear();
56+
currentMap.set(blockId, exitCode);
57+
globalStore.set(this.completionHighlightAtom, currentMap);
58+
}
59+
60+
clearCompletionHighlight(blockId: string) {
61+
const currentMap = new Map(globalStore.get(this.completionHighlightAtom));
62+
if (!currentMap.has(blockId)) return;
63+
currentMap.delete(blockId);
64+
globalStore.set(this.completionHighlightAtom, currentMap);
65+
}
66+
4167
static getInstance(): BlockModel {
4268
if (!BlockModel.instance) {
4369
BlockModel.instance = new BlockModel();
@@ -48,4 +74,4 @@ export class BlockModel {
4874
static resetInstance(): void {
4975
BlockModel.instance = null;
5076
}
51-
}
77+
}

frontend/app/block/block.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,5 +444,9 @@
444444
}
445445
}
446446
}
447+
448+
.block-mask.completion-highlight {
449+
transition: border-color 0.3s ease-out, box-shadow 0.3s ease-out;
450+
}
447451
}
448452
}

frontend/app/block/blockframe-header.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { uxCloseBlock } from "@/app/store/keymodel";
2323
import { TabRpcClient } from "@/app/store/wshrpcutil";
2424
import { useWaveEnv } from "@/app/waveenv/waveenv";
2525
import { IconButton } from "@/element/iconbutton";
26-
import { NodeModel } from "@/layout/index";
26+
import { NodeModel, getLayoutModelForStaticTab } from "@/layout/index";
2727
import * as util from "@/util/util";
2828
import { cn, makeIconClass } from "@/util/util";
2929
import * as jotai from "jotai";
@@ -281,7 +281,17 @@ const BlockFrame_Header = ({
281281
/>
282282
)}
283283
{useTermHeader && badge && (
284-
<div className="pointer-events-none flex items-center px-1" style={{ color: badge.color || "#fbbf24" }}>
284+
<div
285+
className="flex cursor-pointer items-center px-1"
286+
style={{ color: badge.color || "#fbbf24" }}
287+
onClick={() => {
288+
const layoutModel = getLayoutModelForStaticTab();
289+
const node = layoutModel?.getNodeByBlockId(nodeModel.blockId);
290+
if (node?.id) {
291+
layoutModel.focusNode(node.id);
292+
}
293+
}}
294+
>
285295
<i className={makeIconClass(badge.icon, true, { defaultIcon: "circle-small" })} />
286296
</div>
287297
)}

frontend/app/block/blockframe.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { BlockFrame_Header } from "@/app/block/blockframe-header";
66
import { blockViewToIcon, getViewIconElem, useTabBackground } from "@/app/block/blockutil";
77
import { ConnStatusOverlay } from "@/app/block/connstatusoverlay";
88
import { ChangeConnectionBlockModal } from "@/app/modals/conntypeahead";
9+
import { clearBadgesForBlockOnFocus } from "@/app/store/badge";
910
import { getBlockComponentModel, globalStore, useBlockAtom } from "@/app/store/global";
1011
import { useTabModel } from "@/app/store/tab-model";
1112
import { TabRpcClient } from "@/app/store/wshrpcutil";
@@ -32,6 +33,9 @@ const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => {
3233
const isLayoutMode = jotai.useAtomValue(waveEnv.atoms.controlShiftDelayAtom);
3334
const showOverlayBlockNums = jotai.useAtomValue(waveEnv.getSettingsKeyAtom("app:showoverlayblocknums")) ?? true;
3435
const blockHighlight = jotai.useAtomValue(BlockModel.getInstance().getBlockHighlightAtom(nodeModel.blockId));
36+
const completionHighlight = jotai.useAtomValue(
37+
BlockModel.getInstance().getCompletionHighlightAtom(nodeModel.blockId)
38+
);
3539
const frameActiveBorderColor = jotai.useAtomValue(
3640
waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:activebordercolor")
3741
);
@@ -63,6 +67,24 @@ const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => {
6367
style.borderColor = "rgb(59, 130, 246)";
6468
}
6569

70+
if (completionHighlight != null && !isFocused) {
71+
const highlightColor = completionHighlight === 0 ? "rgb(59, 130, 246)" : "rgb(239, 68, 68)";
72+
style.borderColor = highlightColor;
73+
style.boxShadow = `0 0 8px 2px ${highlightColor}`;
74+
}
75+
76+
if (isFocused && completionHighlight != null) {
77+
style.borderColor = "";
78+
style.boxShadow = "";
79+
}
80+
81+
React.useEffect(() => {
82+
if (isFocused && completionHighlight != null) {
83+
BlockModel.getInstance().clearCompletionHighlight(nodeModel.blockId);
84+
clearBadgesForBlockOnFocus(nodeModel.blockId);
85+
}
86+
}, [isFocused, completionHighlight]);
87+
6688
let innerElem = null;
6789
if (isLayoutMode && showOverlayBlockNums) {
6890
showBlockMask = true;
@@ -83,7 +105,11 @@ const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => {
83105

84106
return (
85107
<div
86-
className={clsx("block-mask", { "show-block-mask": showBlockMask, "bg-blue-500/10": blockHighlight })}
108+
className={clsx("block-mask", {
109+
"show-block-mask": showBlockMask,
110+
"bg-blue-500/10": blockHighlight,
111+
"completion-highlight": completionHighlight != null && !isFocused,
112+
})}
87113
style={style}
88114
>
89115
{innerElem}

frontend/app/view/term/term-model.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { WaveAIModel } from "@/app/aipanel/waveai-model";
5+
import { BlockModel } from "@/app/block/block-model";
56
import { BlockNodeModel } from "@/app/block/blocktypes";
67
import { appHandleKeyDown } from "@/app/store/keymodel";
8+
import { FocusManager } from "@/app/store/focusManager";
79
import { modalsModel } from "@/app/store/modalmodel";
10+
import { setBadge } from "@/app/store/badge";
811
import type { TabModel } from "@/app/store/tab-model";
912
import { waveEventSubscribeSingle } from "@/app/store/wps";
1013
import { RpcApi } from "@/app/store/wshclientapi";
@@ -14,6 +17,7 @@ import { TermClaudeIcon, TerminalView } from "@/app/view/term/term";
1417
import { TermWshClient } from "@/app/view/term/term-wsh";
1518
import { VDomModel } from "@/app/view/vdom/vdom-model";
1619
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
20+
import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks";
1721
import {
1822
atoms,
1923
createBlock,
@@ -73,6 +77,7 @@ export class TermViewModel implements ViewModel {
7377
shellProcFullStatus: jotai.PrimitiveAtom<BlockControllerRuntimeStatus>;
7478
shellProcStatus: jotai.Atom<string>;
7579
shellProcStatusUnsubFn: () => void;
80+
blockDoneUnsubFn: () => void;
7681
blockJobStatusAtom: jotai.PrimitiveAtom<BlockJobStatusData>;
7782
blockJobStatusVersionTs: number;
7883
blockJobStatusUnsubFn: () => void;
@@ -346,6 +351,13 @@ export class TermViewModel implements ViewModel {
346351
this.updateShellProcStatus(event.data);
347352
},
348353
});
354+
this.blockDoneUnsubFn = waveEventSubscribeSingle({
355+
eventType: "block:done",
356+
scope: WOS.makeORef("block", blockId),
357+
handler: (event) => {
358+
this.handleBlockDoneEvent(event.data);
359+
},
360+
});
349361
this.shellProcStatus = jotai.atom((get) => {
350362
const fullStatus = get(this.shellProcFullStatus);
351363
return fullStatus?.shellprocstatus ?? "init";
@@ -565,6 +577,74 @@ export class TermViewModel implements ViewModel {
565577
}
566578
}
567579

580+
getLastTerminalLine(): string {
581+
const term = this.termRef.current?.terminal;
582+
if (term == null) return "";
583+
const buf = term.buffer.active;
584+
for (let i = buf.length - 1; i >= 0; i--) {
585+
const line = buf.getLine(i);
586+
if (line == null) continue;
587+
const text = line.translateToString(true).trim();
588+
if (text.length > 0) return text;
589+
}
590+
return "";
591+
}
592+
593+
handleBlockDoneEvent(data: BlockDoneEventData) {
594+
if (data == null || data.blockid !== this.blockId) {
595+
return;
596+
}
597+
const exitCode = data.exitcode ?? 0;
598+
const title = data.title || (exitCode === 0 ? "Command Finished" : "Command Failed");
599+
let body = data.message;
600+
if (!body) {
601+
body = this.getLastTerminalLine() || `exit code ${exitCode}`;
602+
}
603+
this.triggerCompletionNotifications(exitCode, title, body);
604+
}
605+
606+
triggerCompletionNotifications(exitCode: number, title: string, notifyBody?: string) {
607+
const focusManager = FocusManager.getInstance();
608+
const focusedBlockId = globalStore.get(focusManager.blockFocusAtom);
609+
if (focusedBlockId === this.blockId) {
610+
return;
611+
}
612+
613+
const doneSoundEnabled = globalStore.get(getOverrideConfigAtom(this.blockId, "term:donesound")) ?? true;
614+
if (doneSoundEnabled) {
615+
fireAndForget(() =>
616+
RpcApi.ElectronSystemBellCommand(TabRpcClient, { route: "electron" })
617+
);
618+
}
619+
620+
const doneNotifyEnabled = globalStore.get(getOverrideConfigAtom(this.blockId, "term:donenotify")) ?? true;
621+
if (doneNotifyEnabled) {
622+
const body = notifyBody || `exit code ${exitCode}`;
623+
getApi().showCompletionNotification(this.tabModel.tabId, this.blockId, title, body);
624+
}
625+
626+
const doneAutoFocusEnabled = globalStore.get(getOverrideConfigAtom(this.blockId, "term:doneautofocus")) ?? false;
627+
if (doneAutoFocusEnabled) {
628+
getApi().setActiveTab(this.tabModel.tabId);
629+
setTimeout(() => {
630+
const layoutModel = getLayoutModelForStaticTab();
631+
const node = layoutModel?.getNodeByBlockId(this.blockId);
632+
if (node?.id) {
633+
layoutModel.focusNode(node.id);
634+
}
635+
}, 150);
636+
}
637+
638+
BlockModel.getInstance().setCompletionHighlight(this.blockId, exitCode);
639+
640+
setBadge(this.blockId, {
641+
badgeid: `done-${this.blockId}`,
642+
icon: "bell",
643+
color: exitCode === 0 ? "#3b82f6" : "#ef4444",
644+
priority: 5,
645+
});
646+
}
647+
568648
getVDomModel(): VDomModel {
569649
const vdomBlockId = globalStore.get(this.vdomBlockId);
570650
if (!vdomBlockId) {

frontend/preview/mock/preview-electron-api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ const previewElectronApi: ElectronApi = {
5959
doRefresh: () => {},
6060
saveTextFile: (_fileName: string, _content: string) => Promise.resolve(false),
6161
setIsActive: async () => {},
62+
showCompletionNotification: (_tabId: string, _blockId: string, _title: string, _body: string) => {},
63+
onFocusBlock: (_callback: (blockId: string) => void) => {},
6264
};
6365

6466
function installPreviewElectronApi() {

frontend/types/custom.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ declare global {
136136
getPathForFile: (file: File) => string; // webUtils.getPathForFile
137137
saveTextFile: (fileName: string, content: string) => Promise<boolean>; // save-text-file
138138
setIsActive: () => Promise<void>; // set-is-active
139+
showCompletionNotification: (tabId: string, blockId: string, title: string, body: string) => void; // show-completion-notification
140+
onFocusBlock: (callback: (blockId: string) => void) => void; // focus-block
139141
};
140142

141143
type ElectronContextMenuItem = {

0 commit comments

Comments
 (0)