Tab Indicators, Confirm on Quit, etc (#2811)

* Adds a Confirm on Quit dialog (and new config to disable it)
* New MacOS keybinding for Cmd:ArrowLeft/Cmd:ArrowRight to send Ctrl-A
and Ctrl-E respectively
* Fix Ctrl-V regression on windows to allow config setting to override
* Remove questionnaire in bug template
* Full featured tab indicators -- icon, color, priority, clear features.
Can be manipulated by new `wsh tabindicator` command
* Hook up BEL to new tab indicator system. BEL can now play a sound
and/or set an indicator (controlled by two new config options)
This commit is contained in:
Mike Sawka 2026-01-29 17:04:29 -08:00 committed by GitHub
parent 9d7a457c55
commit 73bb5beb3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 611 additions and 27 deletions

View file

@ -84,14 +84,3 @@ body:
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
- type: checkboxes
attributes:
label: Questionnaire
description: "If you feel up to the challenge, please check one of the boxes below:"
options:
- label: I'm interested in fixing this myself but don't know where to start
required: false
- label: I would like to fix and I have a solution
required: false
- label: I don't have time to fix this right now, but maybe later
required: false

View file

@ -29,6 +29,7 @@ tasks:
WCLOUD_PING_ENDPOINT: "https://ping-dev.waveterm.dev/central"
WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev/central"
WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev"
WAVETERM_NOCONFIRMQUIT: "1"
electron:start:
desc: Run the Electron application directly.
@ -55,6 +56,7 @@ tasks:
WCLOUD_PING_ENDPOINT: "https://ping-dev.waveterm.dev/central"
WCLOUD_ENDPOINT: "https://api-dev.waveterm.dev/central"
WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev/"
WAVETERM_NOCONFIRMQUIT: "1"
electron:winquickdev:
desc: Run the Electron application via the Vite dev server (quick dev - Windows amd64 only, no generate, no wsh).

View file

@ -568,6 +568,7 @@ func main() {
go startupActivityUpdate(firstLaunch) // must be after startConfigWatcher()
blocklogger.InitBlockLogger()
jobcontroller.InitJobController()
wcore.InitTabIndicatorStore()
go func() {
defer func() {
panichandler.PanicHandler("GetSystemSummary", recover())

View file

@ -0,0 +1,100 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
)
var tabIndicatorCmd = &cobra.Command{
Use: "tabindicator [icon]",
Short: "set or clear a tab indicator",
Args: cobra.MaximumNArgs(1),
RunE: tabIndicatorRun,
PreRunE: preRunSetupRpcClient,
}
var (
tabIndicatorTabId string
tabIndicatorColor string
tabIndicatorPriority float64
tabIndicatorClear bool
tabIndicatorPersistent bool
tabIndicatorBeep bool
)
func init() {
rootCmd.AddCommand(tabIndicatorCmd)
tabIndicatorCmd.Flags().StringVar(&tabIndicatorTabId, "tabid", "", "tab id (defaults to WAVETERM_TABID)")
tabIndicatorCmd.Flags().StringVar(&tabIndicatorColor, "color", "", "indicator color")
tabIndicatorCmd.Flags().Float64Var(&tabIndicatorPriority, "priority", 0, "indicator priority")
tabIndicatorCmd.Flags().BoolVar(&tabIndicatorClear, "clear", false, "clear the indicator")
tabIndicatorCmd.Flags().BoolVar(&tabIndicatorPersistent, "persistent", false, "make indicator persistent (don't clear on focus)")
tabIndicatorCmd.Flags().BoolVar(&tabIndicatorBeep, "beep", false, "play system bell sound")
}
func tabIndicatorRun(cmd *cobra.Command, args []string) (rtnErr error) {
defer func() {
sendActivity("tabindicator", rtnErr == nil)
}()
tabId := tabIndicatorTabId
if tabId == "" {
tabId = os.Getenv("WAVETERM_TABID")
}
if tabId == "" {
return fmt.Errorf("no tab id specified (use --tabid or set WAVETERM_TABID)")
}
var indicator *wshrpc.TabIndicator
if !tabIndicatorClear {
icon := "bell"
if len(args) > 0 {
icon = args[0]
}
indicator = &wshrpc.TabIndicator{
Icon: icon,
Color: tabIndicatorColor,
Priority: tabIndicatorPriority,
ClearOnFocus: !tabIndicatorPersistent,
}
}
eventData := wshrpc.TabIndicatorEventData{
TabId: tabId,
Indicator: indicator,
}
event := wps.WaveEvent{
Event: wps.Event_TabIndicator,
Scopes: []string{waveobj.MakeORef(waveobj.OType_Tab, tabId).String()},
Data: eventData,
}
err := wshclient.EventPublishCommand(RpcClient, event, &wshrpc.RpcOpts{NoResponse: true})
if err != nil {
return fmt.Errorf("publishing tab indicator event: %v", err)
}
if tabIndicatorBeep {
err = wshclient.ElectronSystemBellCommand(RpcClient, &wshrpc.RpcOpts{Route: "electron"})
if err != nil {
return fmt.Errorf("playing system bell: %v", err)
}
}
if tabIndicatorClear {
fmt.Printf("tab indicator cleared\n")
} else {
fmt.Printf("tab indicator set\n")
}
return nil
}

View file

@ -38,6 +38,7 @@ wsh editconfig
| app:defaultnewblock | string | Sets the default new block (Cmd:n, Cmd:d). "term" for terminal block, "launcher" for launcher block (default = "term") |
| app:showoverlayblocknums | bool | Set to false to disable the Ctrl+Shift block number overlay that appears when holding Ctrl+Shift (defaults to true) |
| app:ctrlvpaste | bool | On Windows/Linux, when null (default) uses Control+V on Windows only. Set to true to force Control+V on all non-macOS platforms, false to disable the accelerator. macOS always uses Command+V regardless of this setting |
| app:confirmquit | bool | Set to false to disable the quit confirmation dialog when closing Wave Terminal (defaults to true, requires app restart) |
| ai:preset | string | the default AI preset to use |
| ai:baseurl | string | Set the AI Base Url (must be OpenAI compatible) |
| ai:apitoken | string | your AI api token |
@ -62,6 +63,8 @@ wsh editconfig
| term:allowbracketedpaste | bool | allow bracketed paste mode in terminal (default false) |
| term:shiftenternewline | bool | when enabled, Shift+Enter sends escape sequence + newline (\u001b\n) instead of carriage return, useful for claude code and similar AI coding tools (default false) |
| term:macoptionismeta | bool | on macOS, treat the Option key as Meta key for terminal keybindings (default false) |
| term:bellsound | bool | when enabled, plays the system beep sound when the terminal bell (BEL character) is received (default false) |
| term:bellindicator | bool | when enabled, shows a visual indicator in the tab when the terminal bell is received (default true) |
| editor:minimapenabled | bool | set to false to disable editor minimap |
| editor:stickyscrollenabled | bool | enables monaco editor's stickyScroll feature (pinning headers of current context, e.g. class names, method names, etc.), defaults to false |
| editor:wordwrap | bool | set to true to enable word wrapping in the editor (defaults to false) |
@ -108,6 +111,7 @@ For reference, this is the current default configuration (v0.11.5):
"ai:maxtokens": 4000,
"ai:timeoutms": 60000,
"app:defaultnewblock": "term",
"app:confirmquit": true,
"autoupdate:enabled": true,
"autoupdate:installonquit": true,
"autoupdate:intervalms": 3600000,
@ -126,7 +130,11 @@ For reference, this is the current default configuration (v0.11.5):
"window:confirmclose": true,
"window:savelastwindow": true,
"telemetry:enabled": true,
"term:copyonselect": true
"term:bellsound": false,
"term:bellindicator": true,
"term:copyonselect": true,
"waveai:showcloudmodes": true,
"waveai:defaultmode": "waveai@balanced"
}
```

View file

@ -100,6 +100,8 @@ Chords are shown with a + between the keys. You have 2 seconds to hit the 2nd ch
| <Kbd k="Shift:End"/> | Scroll to bottom |
| <Kbd k="Cmd:Home" windows="N/A" linux="N/A"/> | Scroll to top (macOS only) |
| <Kbd k="Cmd:End" windows="N/A" linux="N/A"/> | Scroll to bottom (macOS only)|
| <Kbd k="Cmd:ArrowLeft" windows="N/A" linux="N/A"/> | Move to beginning of line (macOS only) |
| <Kbd k="Cmd:ArrowRight" windows="N/A" linux="N/A"/> | Move to end of line (macOS only) |
| <Kbd k="Shift:PageUp"/> | Scroll up one page |
| <Kbd k="Shift:PageDown"/>| Scroll down one page |

View file

@ -8,6 +8,7 @@ let globalIsQuitting = false;
let globalIsStarting = true;
let globalIsRelaunching = false;
let forceQuit = false;
let userConfirmedQuit = false;
let termCommandsRun = 0;
export function setWasActive(val: boolean) {
@ -54,6 +55,14 @@ export function getForceQuit(): boolean {
return forceQuit;
}
export function setUserConfirmedQuit(val: boolean) {
userConfirmedQuit = val;
}
export function getUserConfirmedQuit(): boolean {
return userConfirmedQuit;
}
export function incrementTermCommandsRun() {
termCommandsRun++;
}

View file

@ -20,7 +20,11 @@ import {
} from "./emain-util";
import { ElectronWshClient } from "./emain-wsh";
function handleWindowsMenuAccelerators(waveEvent: WaveKeyboardEvent, tabView: WaveTabView): boolean {
function handleWindowsMenuAccelerators(
waveEvent: WaveKeyboardEvent,
tabView: WaveTabView,
fullConfig: FullConfigType
): boolean {
const waveWindow = getWaveWindowById(tabView.waveWindowId);
if (checkKeyPressed(waveEvent, "Ctrl:Shift:n")) {
@ -34,6 +38,11 @@ function handleWindowsMenuAccelerators(waveEvent: WaveKeyboardEvent, tabView: Wa
}
if (checkKeyPressed(waveEvent, "Ctrl:v")) {
const ctrlVPaste = fullConfig?.settings?.["app:ctrlvpaste"];
const shouldPaste = ctrlVPaste ?? true;
if (!shouldPaste) {
return false;
}
tabView.webContents.paste();
return true;
}
@ -324,7 +333,7 @@ export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: stri
}
if (unamePlatform === "win32" && input.type == "keyDown") {
if (handleWindowsMenuAccelerators(waveEvent, tabView)) {
if (handleWindowsMenuAccelerators(waveEvent, tabView, fullConfig)) {
e.preventDefault();
return;
}

View file

@ -6,7 +6,7 @@ import * as child_process from "node:child_process";
import * as readline from "readline";
import { WebServerEndpointVarName, WSServerEndpointVarName } from "../frontend/util/endpoints";
import { AuthKey, WaveAuthKeyEnv } from "./authkey";
import { setForceQuit } from "./emain-activity";
import { setForceQuit, setUserConfirmedQuit } from "./emain-activity";
import {
getElectronAppResourcesPath,
getElectronAppUnpackedBasePath,
@ -112,6 +112,7 @@ export function runWaveSrv(handleWSEvent: (evtMsg: WSEventType) => void): Promis
);
if (startParams == null) {
console.log("error parsing WAVESRV-ESTART line", line);
setUserConfirmedQuit(true);
electron.app.quit();
return;
}

View file

@ -4,7 +4,7 @@
import { WindowService } from "@/app/store/services";
import { RpcResponseHelper, WshClient } from "@/app/store/wshclient";
import { RpcApi } from "@/app/store/wshclientapi";
import { Notification, net, safeStorage } from "electron";
import { Notification, net, safeStorage, shell } from "electron";
import { getResolvedUpdateChannel } from "emain/updater";
import { unamePlatform } from "./emain-platform";
import { getWebContentsByBlockId, webGetSelector } from "./emain-web";
@ -106,6 +106,10 @@ export class ElectronWshClientType extends WshClient {
return net.isOnline();
}
async handle_electronsystembell(rh: RpcResponseHelper): Promise<void> {
shell.beep();
}
// async handle_workspaceupdate(rh: RpcResponseHelper) {
// console.log("workspaceupdate");
// fireAndForget(async () => {

View file

@ -15,9 +15,11 @@ import {
getAndClearTermCommandsRun,
getForceQuit,
getGlobalIsRelaunching,
getUserConfirmedQuit,
setForceQuit,
setGlobalIsQuitting,
setGlobalIsStarting,
setUserConfirmedQuit,
setWasActive,
setWasInFg,
} from "./emain-activity";
@ -53,6 +55,8 @@ import { configureAutoUpdater, updater } from "./updater";
const electronApp = electron.app;
let confirmQuit = true;
const waveDataDir = getWaveDataDir();
const waveConfigDir = getWaveConfigDir();
@ -238,10 +242,37 @@ electronApp.on("window-all-closed", () => {
return;
}
if (unamePlatform !== "darwin") {
setUserConfirmedQuit(true);
electronApp.quit();
}
});
electronApp.on("before-quit", (e) => {
const allWindows = getAllWaveWindows();
const allBuilders = getAllBuilderWindows();
if (
confirmQuit &&
!getForceQuit() &&
!getUserConfirmedQuit() &&
(allWindows.length > 0 || allBuilders.length > 0) &&
!getIsWaveSrvDead() &&
!process.env.WAVETERM_NOCONFIRMQUIT
) {
e.preventDefault();
const choice = electron.dialog.showMessageBoxSync(null, {
type: "question",
buttons: ["Cancel", "Quit"],
title: "Confirm Quit",
message: "Are you sure you want to quit Wave Terminal?",
defaultId: 0,
cancelId: 0,
});
if (choice === 0) {
return;
}
setUserConfirmedQuit(true);
electronApp.quit();
return;
}
setGlobalIsQuitting(true);
updater?.stop();
if (unamePlatform == "win32") {
@ -255,11 +286,9 @@ electronApp.on("before-quit", (e) => {
return;
}
e.preventDefault();
const allWindows = getAllWaveWindows();
for (const window of allWindows) {
hideWindowWithCatch(window);
}
const allBuilders = getAllBuilderWindows();
for (const builder of allBuilders) {
builder.hide();
}
@ -277,14 +306,17 @@ electronApp.on("before-quit", (e) => {
});
process.on("SIGINT", () => {
console.log("Caught SIGINT, shutting down");
setUserConfirmedQuit(true);
electronApp.quit();
});
process.on("SIGHUP", () => {
console.log("Caught SIGHUP, shutting down");
setUserConfirmedQuit(true);
electronApp.quit();
});
process.on("SIGTERM", () => {
console.log("Caught SIGTERM, shutting down");
setUserConfirmedQuit(true);
electronApp.quit();
});
let caughtException = false;
@ -304,6 +336,7 @@ process.on("uncaughtException", (error) => {
console.log("Uncaught Exception, shutting down: ", error);
console.log("Stack Trace:", error.stack);
// Optionally, handle cleanup or exit the app
setUserConfirmedQuit(true);
electronApp.quit();
});
@ -332,6 +365,7 @@ async function appMain() {
const instanceLock = electronApp.requestSingleInstanceLock();
if (!instanceLock) {
console.log("waveterm-app could not get single-instance-lock, shutting down");
setUserConfirmedQuit(true);
electronApp.quit();
return;
}
@ -356,6 +390,9 @@ async function appMain() {
}
const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient);
checkIfRunningUnderARM64Translation(fullConfig);
if (fullConfig?.settings?.["app:confirmquit"] != null) {
confirmQuit = fullConfig.settings["app:confirmquit"];
}
ensureHotSpareTab(fullConfig);
await relaunchBrowserWindows();
setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe
@ -383,5 +420,6 @@ async function appMain() {
appMain().catch((e) => {
console.log("appMain error", e);
setUserConfirmedQuit(true);
electronApp.quit();
});

View file

@ -6,7 +6,16 @@ import { GlobalModel } from "@/app/store/global-model";
import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model";
import { Workspace } from "@/app/workspace/workspace";
import { ContextMenuModel } from "@/store/contextmenu";
import { atoms, createBlock, getSettingsPrefixAtom, globalStore, isDev, removeFlashError } from "@/store/global";
import {
atoms,
clearTabIndicatorFromFocus,
createBlock,
getSettingsPrefixAtom,
getTabIndicatorAtom,
globalStore,
isDev,
removeFlashError,
} from "@/store/global";
import { appHandleKeyDown, keyboardMouseDownHandler } from "@/store/keymodel";
import { getElemAsStr } from "@/util/focusutil";
import * as keyutil from "@/util/keyutil";
@ -205,6 +214,29 @@ const AppKeyHandlers = () => {
return null;
};
const TabIndicatorAutoClearing = () => {
const tabId = useAtomValue(atoms.staticTabId);
const indicator = useAtomValue(getTabIndicatorAtom(tabId));
const documentHasFocus = useAtomValue(atoms.documentHasFocus);
useEffect(() => {
if (!indicator || !documentHasFocus || !indicator.clearonfocus) {
return;
}
const timeoutId = setTimeout(() => {
const currentIndicator = globalStore.get(getTabIndicatorAtom(tabId));
if (globalStore.get(atoms.documentHasFocus) && currentIndicator?.clearonfocus) {
clearTabIndicatorFromFocus(tabId);
}
}, 3000);
return () => clearTimeout(timeoutId);
}, [tabId, indicator, documentHasFocus]);
return null;
};
const FlashError = () => {
const flashErrors = useAtomValue(atoms.flashErrors);
const [hoveredId, setHoveredId] = useState<string>(null);
@ -304,6 +336,7 @@ const AppInner = () => {
<AppKeyHandlers />
<AppFocusHandler />
<AppSettingsUpdater />
<TabIndicatorAutoClearing />
<DndProvider backend={HTML5Backend}>
<Workspace />
</DndProvider>

View file

@ -38,6 +38,7 @@ let globalPrimaryTabStartup: boolean = false;
const blockComponentModelMap = new Map<string, BlockComponentModel>();
const Counters = new Map<string, number>();
const ConnStatusMapAtom = atom(new Map<string, PrimitiveAtom<ConnStatus>>());
const TabIndicatorMap = new Map<string, PrimitiveAtom<TabIndicator>>();
const orefAtomCache = new Map<string, Map<string, Atom<any>>>();
function initGlobal(initOpts: GlobalInitOptions) {
@ -146,6 +147,17 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
});
}
const documentHasFocusAtom = atom(true) as PrimitiveAtom<boolean>;
if (globalThis.window != null) {
globalStore.set(documentHasFocusAtom, document.hasFocus());
window.addEventListener("focus", () => {
globalStore.set(documentHasFocusAtom, true);
});
window.addEventListener("blur", () => {
globalStore.set(documentHasFocusAtom, false);
});
}
const modalOpen = atom(false);
const allConnStatusAtom = atom<ConnStatus[]>((get) => {
const connStatusMap = get(ConnStatusMapAtom);
@ -174,6 +186,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
controlShiftDelayAtom,
updaterStatusAtom,
prefersReducedMotionAtom,
documentHasFocus: documentHasFocusAtom,
modalOpen,
allConnStatus: allConnStatusAtom,
flashErrors: flashErrorsAtom,
@ -235,6 +248,13 @@ function initGlobalWaveEventSubs(initOpts: WaveInitOpts) {
const rateLimitInfo: RateLimitInfo = event.data;
globalStore.set(atoms.waveAIRateLimitInfoAtom, rateLimitInfo);
},
},
{
eventType: "tab:indicator",
handler: (event) => {
const data: TabIndicatorEventData = event.data;
setTabIndicatorInternal(data.tabid, data.indicator);
},
}
);
}
@ -688,6 +708,17 @@ async function loadConnStatus() {
}
}
async function loadTabIndicators() {
const tabIndicators = await RpcApi.GetAllTabIndicatorsCommand(TabRpcClient);
if (tabIndicators == null) {
return;
}
for (const [tabId, indicator] of Object.entries(tabIndicators)) {
const curAtom = getTabIndicatorAtom(tabId);
globalStore.set(curAtom, indicator);
}
}
function subscribeToConnEvents() {
waveEventSubscribe({
eventType: "connchange",
@ -741,6 +772,76 @@ function getConnStatusAtom(conn: string): PrimitiveAtom<ConnStatus> {
return rtn;
}
function getTabIndicatorAtom(tabId: string): PrimitiveAtom<TabIndicator> {
let rtn = TabIndicatorMap.get(tabId);
if (rtn == null) {
rtn = atom(null) as PrimitiveAtom<TabIndicator>;
TabIndicatorMap.set(tabId, rtn);
}
return rtn;
}
function setTabIndicatorInternal(tabId: string, indicator: TabIndicator) {
if (indicator == null) {
const indicatorAtom = getTabIndicatorAtom(tabId);
globalStore.set(indicatorAtom, null);
return;
}
const indicatorAtom = getTabIndicatorAtom(tabId);
const currentIndicator = globalStore.get(indicatorAtom);
if (currentIndicator == null) {
globalStore.set(indicatorAtom, indicator);
return;
}
if (indicator.priority >= currentIndicator.priority) {
if (indicator.clearonfocus && !currentIndicator.clearonfocus) {
indicator.persistentindicator = currentIndicator;
}
globalStore.set(indicatorAtom, indicator);
}
}
function setTabIndicator(tabId: string, indicator: TabIndicator) {
setTabIndicatorInternal(tabId, indicator);
const eventData: WaveEvent = {
event: "tab:indicator",
scopes: [WOS.makeORef("tab", tabId)],
data: {
tabid: tabId,
indicator: indicator,
} as TabIndicatorEventData,
};
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
}
function clearTabIndicatorFromFocus(tabId: string) {
const indicatorAtom = getTabIndicatorAtom(tabId);
const currentIndicator = globalStore.get(indicatorAtom);
if (currentIndicator == null) {
return;
}
const persistentIndicator = currentIndicator.persistentindicator;
const eventData: WaveEvent = {
event: "tab:indicator",
scopes: [WOS.makeORef("tab", tabId)],
data: {
tabid: tabId,
indicator: persistentIndicator ?? null,
} as TabIndicatorEventData,
};
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
}
function clearAllTabIndicators() {
for (const [tabId, indicatorAtom] of TabIndicatorMap.entries()) {
const indicator = globalStore.get(indicatorAtom);
if (indicator != null) {
setTabIndicator(tabId, null);
}
}
}
function pushFlashError(ferr: FlashErrorType) {
if (ferr.expiration == null) {
ferr.expiration = Date.now() + 5000;
@ -803,6 +904,8 @@ function recordTEvent(event: string, props?: TEventProps) {
export {
atoms,
clearAllTabIndicators,
clearTabIndicatorFromFocus,
counterInc,
countersClear,
countersPrint,
@ -823,17 +926,19 @@ export {
getOverrideConfigAtom,
getSettingsKeyAtom,
getSettingsPrefixAtom,
getTabIndicatorAtom,
getUserName,
globalPrimaryTabStartup,
globalStore,
readAtom,
initGlobal,
initGlobalWaveEventSubs,
isDev,
loadConnStatus,
loadTabIndicators,
openLink,
pushFlashError,
pushNotification,
readAtom,
recordTEvent,
refocusNode,
registerBlockComponentModel,
@ -844,6 +949,7 @@ export {
setActiveTab,
setNodeFocus,
setPlatform,
setTabIndicator,
subscribeToConnEvents,
unregisterBlockComponentModel,
useBlockAtom,

View file

@ -187,6 +187,11 @@ class RpcApiType {
return client.wshRpcCall("electronencrypt", data, opts);
}
// command "electronsystembell" [call]
ElectronSystemBellCommand(client: WshClient, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("electronsystembell", null, opts);
}
// command "eventpublish" [call]
EventPublishCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("eventpublish", data, opts);
@ -302,6 +307,11 @@ class RpcApiType {
return client.wshRpcCall("focuswindow", data, opts);
}
// command "getalltabindicators" [call]
GetAllTabIndicatorsCommand(client: WshClient, opts?: RpcOpts): Promise<{[key: string]: TabIndicator}> {
return client.wshRpcCall("getalltabindicators", null, opts);
}
// command "getallvars" [call]
GetAllVarsCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise<CommandVarResponseData[]> {
return client.wshRpcCall("getallvars", data, opts);

View file

@ -81,6 +81,21 @@
}
}
.tab-indicator {
position: absolute;
top: 50%;
left: 4px;
transform: translate3d(0, -50%, 0);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
z-index: var(--zindex-tab-name);
padding: 1px 2px;
transition: none !important;
}
.wave-button {
position: absolute;
top: 50%;

View file

@ -1,13 +1,23 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { atoms, globalStore, recordTEvent, refocusNode } from "@/app/store/global";
import {
atoms,
clearAllTabIndicators,
clearTabIndicatorFromFocus,
getTabIndicatorAtom,
globalStore,
recordTEvent,
refocusNode,
setTabIndicator,
} from "@/app/store/global";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { Button } from "@/element/button";
import { ContextMenuModel } from "@/store/contextmenu";
import { fireAndForget } from "@/util/util";
import { fireAndForget, makeIconClass } from "@/util/util";
import clsx from "clsx";
import { useAtomValue } from "jotai";
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { ObjectService } from "../store/services";
import { makeORef, useWaveObjectValue } from "../store/wos";
@ -36,6 +46,7 @@ const Tab = memo(
const [tabData, _] = useWaveObjectValue<Tab>(makeORef("tab", id));
const [originalName, setOriginalName] = useState("");
const [isEditable, setIsEditable] = useState(false);
const indicator = useAtomValue(getTabIndicatorAtom(id));
const editableRef = useRef<HTMLDivElement>(null);
const editableTimeoutRef = useRef<NodeJS.Timeout>(null);
@ -133,17 +144,40 @@ const Tab = memo(
event.stopPropagation();
};
const handleTabClick = () => {
const currentIndicator = globalStore.get(getTabIndicatorAtom(id));
if (currentIndicator?.clearonfocus) {
clearTabIndicatorFromFocus(id);
}
onSelect();
};
const handleContextMenu = useCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.preventDefault();
let menu: ContextMenuItem[] = [
let menu: ContextMenuItem[] = [];
const currentIndicator = globalStore.get(getTabIndicatorAtom(id));
if (currentIndicator) {
menu.push(
{
label: "Clear Tab Indicator",
click: () => setTabIndicator(id, null),
},
{
label: "Clear All Indicators",
click: () => clearAllTabIndicators(),
},
{ type: "separator" }
);
}
menu.push(
{ label: "Rename Tab", click: () => handleRenameTab(null) },
{
label: "Copy TabId",
click: () => fireAndForget(() => navigator.clipboard.writeText(id)),
},
{ type: "separator" },
];
{ type: "separator" }
);
const fullConfig = globalStore.get(atoms.fullConfigAtom);
const bgPresets: string[] = [];
for (const key in fullConfig?.presets ?? {}) {
@ -192,7 +226,7 @@ const Tab = memo(
"new-tab": isNew,
})}
onMouseDown={onDragStart}
onClick={onSelect}
onClick={handleTabClick}
onContextMenu={handleContextMenu}
data-tab-id={id}
>
@ -208,6 +242,15 @@ const Tab = memo(
>
{tabData?.name}
</div>
{indicator && (
<div
className="tab-indicator pointer-events-none"
style={{ color: indicator.color || "#fbbf24" }}
title="Activity notification"
>
<i className={makeIconClass(indicator.icon, true, { defaultIcon: "bell" })} />
</div>
)}
<Button
className="ghost grey close"
onClick={onClose}

View file

@ -588,6 +588,21 @@ export class TermViewModel implements ViewModel {
event.stopPropagation();
return false;
}
if (isMacOS()) {
if (keyutil.checkKeyPressed(waveEvent, "Cmd:ArrowLeft")) {
this.sendDataToController("\x01"); // Ctrl-A (beginning of line)
event.preventDefault();
event.stopPropagation();
return false;
}
if (keyutil.checkKeyPressed(waveEvent, "Cmd:ArrowRight")) {
this.sendDataToController("\x05"); // Ctrl-E (end of line)
event.preventDefault();
event.stopPropagation();
return false;
}
}
if (keyutil.checkKeyPressed(waveEvent, "Shift:Enter")) {
const shiftEnterNewlineAtom = getOverrideConfigAtom(this.blockId, "term:shiftenternewline");
const shiftEnterNewlineEnabled = globalStore.get(shiftEnterNewlineAtom) ?? true;

View file

@ -5,7 +5,18 @@ import type { BlockNodeModel } from "@/app/block/blocktypes";
import { getFileSubject } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { WOS, fetchWaveFile, getApi, getSettingsKeyAtom, globalStore, openLink, recordTEvent } from "@/store/global";
import {
atoms,
fetchWaveFile,
getApi,
getOverrideConfigAtom,
getSettingsKeyAtom,
globalStore,
openLink,
recordTEvent,
setTabIndicator,
WOS,
} from "@/store/global";
import * as services from "@/store/services";
import { PLATFORM, PlatformMacOS } from "@/util/platformutil";
import { base64ToArray, base64ToString, fireAndForget } from "@/util/util";
@ -478,6 +489,26 @@ export class TermWrap {
this.terminal.parser.registerOscHandler(16162, (data: string) => {
return handleOsc16162Command(data, this.blockId, this.loaded, this);
});
this.toDispose.push(
this.terminal.onBell(() => {
if (!this.loaded) {
return true;
}
console.log("BEL received in terminal", this.blockId);
const bellSoundEnabled =
globalStore.get(getOverrideConfigAtom(this.blockId, "term:bellsound")) ?? false;
if (bellSoundEnabled) {
fireAndForget(() => RpcApi.ElectronSystemBellCommand(TabRpcClient, { route: "electron" }));
}
const bellIndicatorEnabled =
globalStore.get(getOverrideConfigAtom(this.blockId, "term:bellindicator")) ?? false;
if (bellIndicatorEnabled) {
const tabId = globalStore.get(atoms.staticTabId);
setTabIndicator(tabId, { icon: "bell", color: "#fbbf24", clearonfocus: true, priority: 1 });
}
return true;
})
);
this.terminal.attachCustomKeyEventHandler(waveOptions.keydownHandler);
this.connectElem = connectElem;
this.mainFileSubject = null;

View file

@ -21,6 +21,7 @@ declare global {
zoomFactorAtom: jotai.PrimitiveAtom<number>;
controlShiftDelayAtom: jotai.PrimitiveAtom<boolean>;
prefersReducedMotionAtom: jotai.Atom<boolean>;
documentHasFocus: jotai.PrimitiveAtom<boolean>;
updaterStatusAtom: jotai.PrimitiveAtom<UpdaterStatus>;
modalOpen: jotai.PrimitiveAtom<boolean>;
allConnStatus: jotai.Atom<ConnStatus[]>;

View file

@ -1063,6 +1063,8 @@ declare global {
"term:shiftenternewline"?: boolean;
"term:macoptionismeta"?: boolean;
"term:conndebug"?: string;
"term:bellsound"?: boolean;
"term:bellindicator"?: boolean;
"web:zoom"?: number;
"web:hidenav"?: boolean;
"web:partition"?: string;
@ -1214,6 +1216,7 @@ declare global {
"app:defaultnewblock"?: string;
"app:showoverlayblocknums"?: boolean;
"app:ctrlvpaste"?: boolean;
"app:confirmquit"?: boolean;
"feature:waveappbuilder"?: boolean;
"ai:*"?: boolean;
"ai:preset"?: string;
@ -1245,6 +1248,8 @@ declare global {
"term:allowbracketedpaste"?: boolean;
"term:shiftenternewline"?: boolean;
"term:macoptionismeta"?: boolean;
"term:bellsound"?: boolean;
"term:bellindicator"?: boolean;
"editor:minimapenabled"?: boolean;
"editor:stickyscrollenabled"?: boolean;
"editor:wordwrap"?: boolean;
@ -1483,6 +1488,21 @@ declare global {
blockids: string[];
};
// wshrpc.TabIndicator
type TabIndicator = {
icon: string;
color?: string;
priority: number;
clearonfocus?: boolean;
persistentindicator?: TabIndicator;
};
// wshrpc.TabIndicatorEventData
type TabIndicatorEventData = {
tabid: string;
indicator: TabIndicator;
};
// waveobj.TermSize
type TermSize = {
rows: number;

View file

@ -26,6 +26,7 @@ import {
initGlobal,
initGlobalWaveEventSubs,
loadConnStatus,
loadTabIndicators,
pushFlashError,
pushNotification,
removeNotificationById,
@ -166,6 +167,7 @@ async function initWave(initOpts: WaveInitOpts) {
(window as any).globalWS = globalWS;
(window as any).TabRpcClient = TabRpcClient;
await loadConnStatus();
await loadTabIndicators();
initGlobalWaveEventSubs(initOpts);
subscribeToConnEvents();

View file

@ -54,6 +54,7 @@ var ExtraTypes = []any{
waveobj.ObjRTInfo{},
uctypes.RateLimitInfo{},
wconfig.AIModeConfigUpdate{},
wshrpc.TabIndicatorEventData{},
}
// add extra type unions to generate here

View file

@ -34,6 +34,7 @@ const (
WaveDevVarName = "WAVETERM_DEV"
WaveDevViteVarName = "WAVETERM_DEV_VITE"
WaveWshForceUpdateVarName = "WAVETERM_WSHFORCEUPDATE"
WaveNoConfirmQuitVarName = "WAVETERM_NOCONFIRMQUIT"
WaveJwtTokenVarName = "WAVETERM_JWT"
WaveSwapTokenVarName = "WAVETERM_SWAPTOKEN"
@ -106,6 +107,7 @@ func CacheAndRemoveEnvVars() error {
Dev_VarCache = os.Getenv(WaveDevVarName)
os.Unsetenv(WaveDevVarName)
os.Unsetenv(WaveDevViteVarName)
os.Unsetenv(WaveNoConfirmQuitVarName)
return nil
}

View file

@ -117,6 +117,8 @@ const (
MetaKey_TermShiftEnterNewline = "term:shiftenternewline"
MetaKey_TermMacOptionIsMeta = "term:macoptionismeta"
MetaKey_TermConnDebug = "term:conndebug"
MetaKey_TermBellSound = "term:bellsound"
MetaKey_TermBellIndicator = "term:bellindicator"
MetaKey_WebZoom = "web:zoom"
MetaKey_WebHideNav = "web:hidenav"

View file

@ -121,6 +121,8 @@ type MetaTSType struct {
TermShiftEnterNewline *bool `json:"term:shiftenternewline,omitempty"`
TermMacOptionIsMeta *bool `json:"term:macoptionismeta,omitempty"`
TermConnDebug string `json:"term:conndebug,omitempty"` // null, info, debug
TermBellSound *bool `json:"term:bellsound,omitempty"`
TermBellIndicator *bool `json:"term:bellindicator,omitempty"`
WebZoom float64 `json:"web:zoom,omitempty"`
WebHideNav *bool `json:"web:hidenav,omitempty"`

View file

@ -4,6 +4,7 @@
"ai:maxtokens": 4000,
"ai:timeoutms": 60000,
"app:defaultnewblock": "term",
"app:confirmquit": true,
"autoupdate:enabled": true,
"autoupdate:installonquit": true,
"autoupdate:intervalms": 3600000,
@ -23,6 +24,8 @@
"window:confirmclose": true,
"window:savelastwindow": true,
"telemetry:enabled": true,
"term:bellsound": false,
"term:bellindicator": true,
"term:copyonselect": true,
"waveai:showcloudmodes": true,
"waveai:defaultmode": "waveai@balanced"

View file

@ -12,6 +12,7 @@ const (
ConfigKey_AppDefaultNewBlock = "app:defaultnewblock"
ConfigKey_AppShowOverlayBlockNums = "app:showoverlayblocknums"
ConfigKey_AppCtrlVPaste = "app:ctrlvpaste"
ConfigKey_AppConfirmQuit = "app:confirmquit"
ConfigKey_FeatureWaveAppBuilder = "feature:waveappbuilder"
@ -47,6 +48,8 @@ const (
ConfigKey_TermAllowBracketedPaste = "term:allowbracketedpaste"
ConfigKey_TermShiftEnterNewline = "term:shiftenternewline"
ConfigKey_TermMacOptionIsMeta = "term:macoptionismeta"
ConfigKey_TermBellSound = "term:bellsound"
ConfigKey_TermBellIndicator = "term:bellindicator"
ConfigKey_EditorMinimapEnabled = "editor:minimapenabled"
ConfigKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled"

View file

@ -59,6 +59,7 @@ type SettingsType struct {
AppDefaultNewBlock string `json:"app:defaultnewblock,omitempty"`
AppShowOverlayBlockNums *bool `json:"app:showoverlayblocknums,omitempty"`
AppCtrlVPaste *bool `json:"app:ctrlvpaste,omitempty"`
AppConfirmQuit *bool `json:"app:confirmquit,omitempty"`
FeatureWaveAppBuilder bool `json:"feature:waveappbuilder,omitempty"`
@ -94,6 +95,8 @@ type SettingsType struct {
TermAllowBracketedPaste *bool `json:"term:allowbracketedpaste,omitempty"`
TermShiftEnterNewline *bool `json:"term:shiftenternewline,omitempty"`
TermMacOptionIsMeta *bool `json:"term:macoptionismeta,omitempty"`
TermBellSound *bool `json:"term:bellsound,omitempty"`
TermBellIndicator *bool `json:"term:bellindicator,omitempty"`
EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"`
EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"`

88
pkg/wcore/tabindicator.go Normal file
View file

@ -0,0 +1,88 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package wcore
import (
"log"
"sync"
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
)
type TabIndicatorStore struct {
lock *sync.Mutex
indicators map[string]*wshrpc.TabIndicator
}
var globalTabIndicatorStore = &TabIndicatorStore{
lock: &sync.Mutex{},
indicators: make(map[string]*wshrpc.TabIndicator),
}
func InitTabIndicatorStore() {
log.Printf("initializing tab indicator store\n")
rpcClient := wshclient.GetBareRpcClient()
rpcClient.EventListener.On(wps.Event_TabIndicator, handleTabIndicatorEvent)
wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{
Event: wps.Event_TabIndicator,
AllScopes: true,
}, nil)
}
func handleTabIndicatorEvent(event *wps.WaveEvent) {
if event.Event != wps.Event_TabIndicator {
return
}
var data wshrpc.TabIndicatorEventData
err := utilfn.ReUnmarshal(&data, event.Data)
if err != nil {
log.Printf("error unmarshaling TabIndicatorEventData: %v\n", err)
return
}
setTabIndicator(data.TabId, data.Indicator)
}
func setTabIndicator(tabId string, indicator *wshrpc.TabIndicator) {
globalTabIndicatorStore.lock.Lock()
defer globalTabIndicatorStore.lock.Unlock()
if indicator == nil {
delete(globalTabIndicatorStore.indicators, tabId)
log.Printf("tab indicator cleared: tabId=%s\n", tabId)
return
}
currentIndicator := globalTabIndicatorStore.indicators[tabId]
if currentIndicator == nil {
globalTabIndicatorStore.indicators[tabId] = indicator
log.Printf("tab indicator set: tabId=%s indicator=%v\n", tabId, indicator)
return
}
if indicator.Priority >= currentIndicator.Priority {
if indicator.ClearOnFocus && !currentIndicator.ClearOnFocus {
indicator.PersistentIndicator = currentIndicator
}
globalTabIndicatorStore.indicators[tabId] = indicator
log.Printf("tab indicator updated: tabId=%s indicator=%v\n", tabId, indicator)
} else {
log.Printf("tab indicator not updated (lower priority): tabId=%s currentPriority=%v newPriority=%v\n", tabId, currentIndicator.Priority, indicator.Priority)
}
}
func GetTabIndicator(tabId string) *wshrpc.TabIndicator {
globalTabIndicatorStore.lock.Lock()
defer globalTabIndicatorStore.lock.Unlock()
return globalTabIndicatorStore.indicators[tabId]
}
func GetAllTabIndicators() map[string]*wshrpc.TabIndicator {
globalTabIndicatorStore.lock.Lock()
defer globalTabIndicatorStore.lock.Unlock()
result := make(map[string]*wshrpc.TabIndicator)
for tabId, indicator := range globalTabIndicatorStore.indicators {
result[tabId] = indicator
}
return result
}

View file

@ -23,6 +23,7 @@ const (
Event_WaveAppAppGoUpdated = "waveapp:appgoupdated"
Event_TsunamiUpdateMeta = "tsunami:updatemeta"
Event_AIModeConfig = "waveai:modeconfig"
Event_TabIndicator = "tab:indicator"
)
type WaveEvent struct {

View file

@ -232,6 +232,12 @@ func ElectronEncryptCommand(w *wshutil.WshRpc, data wshrpc.CommandElectronEncryp
return resp, err
}
// command "electronsystembell", wshserver.ElectronSystemBellCommand
func ElectronSystemBellCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "electronsystembell", nil, opts)
return err
}
// command "eventpublish", wshserver.EventPublishCommand
func EventPublishCommand(w *wshutil.WshRpc, data wps.WaveEvent, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "eventpublish", data, opts)
@ -368,6 +374,12 @@ func FocusWindowCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) er
return err
}
// command "getalltabindicators", wshserver.GetAllTabIndicatorsCommand
func GetAllTabIndicatorsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (map[string]*wshrpc.TabIndicator, error) {
resp, err := sendRpcRequestCallHelper[map[string]*wshrpc.TabIndicator](w, "getalltabindicators", nil, opts)
return resp, err
}
// command "getallvars", wshserver.GetAllVarsCommand
func GetAllVarsCommand(w *wshutil.WshRpc, data wshrpc.CommandVarData, opts *wshrpc.RpcOpts) ([]wshrpc.CommandVarResponseData, error) {
resp, err := sendRpcRequestCallHelper[[]wshrpc.CommandVarResponseData](w, "getallvars", data, opts)

View file

@ -87,6 +87,7 @@ type WshRpcInterface interface {
FetchSuggestionsCommand(ctx context.Context, data FetchSuggestionsData) (*FetchSuggestionsResponse, error)
DisposeSuggestionsCommand(ctx context.Context, widgetId string) error
GetTabCommand(ctx context.Context, tabId string) (*waveobj.Tab, error)
GetAllTabIndicatorsCommand(ctx context.Context) (map[string]*TabIndicator, error)
// connection functions
ConnStatusCommand(ctx context.Context) ([]ConnStatus, error)
@ -123,6 +124,7 @@ type WshRpcInterface interface {
ElectronEncryptCommand(ctx context.Context, data CommandElectronEncryptData) (*CommandElectronEncryptRtnData, error)
ElectronDecryptCommand(ctx context.Context, data CommandElectronDecryptData) (*CommandElectronDecryptRtnData, error)
NetworkOnlineCommand(ctx context.Context) (bool, error)
ElectronSystemBellCommand(ctx context.Context) error
// secrets
GetSecretsCommand(ctx context.Context, names []string) (map[string]string, error)
@ -840,3 +842,16 @@ type WaveFileInfo struct {
ModTs int64 `json:"modts"`
Meta FileMeta `json:"meta"`
}
type TabIndicator struct {
Icon string `json:"icon"`
Color string `json:"color,omitempty"`
Priority float64 `json:"priority"`
ClearOnFocus bool `json:"clearonfocus,omitempty"`
PersistentIndicator *TabIndicator `json:"persistentindicator,omitempty"`
}
type TabIndicatorEventData struct {
TabId string `json:"tabid"`
Indicator *TabIndicator `json:"indicator"`
}

View file

@ -1402,6 +1402,10 @@ func (ws *WshServer) GetTabCommand(ctx context.Context, tabId string) (*waveobj.
return tab, nil
}
func (ws *WshServer) GetAllTabIndicatorsCommand(ctx context.Context) (map[string]*wshrpc.TabIndicator, error) {
return wcore.GetAllTabIndicators(), nil
}
func (ws *WshServer) GetSecretsCommand(ctx context.Context, names []string) (map[string]string, error) {
result := make(map[string]string)
for _, name := range names {

View file

@ -23,6 +23,9 @@
"app:ctrlvpaste": {
"type": "boolean"
},
"app:confirmquit": {
"type": "boolean"
},
"feature:waveappbuilder": {
"type": "boolean"
},
@ -119,6 +122,12 @@
"term:macoptionismeta": {
"type": "boolean"
},
"term:bellsound": {
"type": "boolean"
},
"term:bellindicator": {
"type": "boolean"
},
"editor:minimapenabled": {
"type": "boolean"
},