From 41721a504881f82eded44c0df8ecc82c076664c1 Mon Sep 17 00:00:00 2001 From: Sir <777x777@protonmail.com> Date: Wed, 11 Feb 2026 15:37:11 +0400 Subject: [PATCH] feat: tab background glow with synchronized breathing animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a term:exitindicator config that shows colored tab indicators when processes exit — amber breathing while running, green check on success, red X on error. Uses CSS @property for a synchronized breathing phase across all tabs (16s ease-in-out cycle). Tab rendering: icon="none" hides the icon for glow-only working state, has-indicator class adds colored background, indicator-breathing class adds the breathing pulse. Active tabs get slightly higher opacity. Go backend: ShellController publishes amber running indicator for cmd blocks on start, green/red exit indicator on process exit. Respects per-block and global term:exitindicator setting. Clears indicators before block deletion on close-on-exit. --- docs/docs/config.mdx | 1 + frontend/app/tab/tab.scss | 54 +++++++++++++ frontend/app/tab/tab.tsx | 11 ++- frontend/types/gotypes.d.ts | 2 + pkg/blockcontroller/shellcontroller.go | 101 +++++++++++++++++++++++- pkg/waveobj/metaconsts.go | 1 + pkg/waveobj/wtypemeta.go | 1 + pkg/wconfig/defaultconfig/settings.json | 1 + pkg/wconfig/metaconsts.go | 1 + pkg/wconfig/settingsconfig.go | 1 + schema/settings.json | 3 + 11 files changed, 174 insertions(+), 3 deletions(-) diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 152655a0b0..7a830f4873 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -70,6 +70,7 @@ wsh editconfig | 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 false) | +| term:exitindicator | bool | when enabled, shows a colored tab indicator when a process exits — green for success, red for error (default false) | | term:durable | bool | makes remote terminal sessions durable across network disconnects (defaults to false) | | 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 | diff --git a/frontend/app/tab/tab.scss b/frontend/app/tab/tab.scss index 3739752eee..a0ce7c586e 100644 --- a/frontend/app/tab/tab.scss +++ b/frontend/app/tab/tab.scss @@ -1,6 +1,28 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +// Animate a shared breathing phase on :root so all indicator tabs pulse in sync. +// Uses CSS @property for an animatable custom property (Chromium 78+). +@property --breathe-phase { + syntax: ""; + initial-value: 0; + inherits: true; +} + +:root { + animation: indicatorBreathePhase 16s ease-in-out infinite; +} + +@keyframes indicatorBreathePhase { + 0%, + 100% { + --breathe-phase: 0; + } + 50% { + --breathe-phase: 1; + } +} + .tab { position: absolute; width: 130px; @@ -46,6 +68,8 @@ .name { color: var(--main-text-color); + font-weight: 700; + filter: brightness(1.15); } & + .tab::after, @@ -96,6 +120,24 @@ transition: none !important; } + &.has-indicator { + .tab-inner { + background: rgb(from var(--tab-indicator-color) r g b / 0.15); + } + &.active .tab-inner { + background: rgb(from var(--tab-indicator-color) r g b / 0.2); + } + } + + &.indicator-breathing { + .tab-inner { + background: rgb(from var(--tab-indicator-color) r g b / calc(0.08 + var(--breathe-phase) * 0.14)); + } + &.active .tab-inner { + background: rgb(from var(--tab-indicator-color) r g b / calc(0.12 + var(--breathe-phase) * 0.14)); + } + } + .wave-button { position: absolute; top: 50%; @@ -129,6 +171,18 @@ body:not(.nohover) .tab.dragging { border-color: transparent; background: rgb(from var(--main-text-color) r g b / 0.1); } + &.has-indicator .tab-inner { + background: rgb(from var(--tab-indicator-color) r g b / 0.15); + } + &.has-indicator.active .tab-inner { + background: rgb(from var(--tab-indicator-color) r g b / 0.2); + } + &.indicator-breathing .tab-inner { + background: rgb(from var(--tab-indicator-color) r g b / calc(0.08 + var(--breathe-phase) * 0.14)); + } + &.indicator-breathing.active .tab-inner { + background: rgb(from var(--tab-indicator-color) r g b / calc(0.12 + var(--breathe-phase) * 0.14)); + } .close { visibility: visible; &:hover { diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 9a3d0f9925..ae03334a5a 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -224,7 +224,14 @@ const Tab = memo( dragging: isDragging, "before-active": isBeforeActive, "new-tab": isNew, + "has-indicator": indicator != null, + "indicator-breathing": indicator != null && indicator.icon === "none", })} + style={ + indicator != null + ? ({ "--tab-indicator-color": indicator.color || "#f59e0b" } as React.CSSProperties) + : undefined + } onMouseDown={onDragStart} onClick={handleTabClick} onContextMenu={handleContextMenu} @@ -242,10 +249,10 @@ const Tab = memo( > {tabData?.name} - {indicator && ( + {indicator && indicator.icon !== "none" && (
diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index dada3a248b..f9f29db405 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1098,6 +1098,7 @@ declare global { "term:conndebug"?: string; "term:bellsound"?: boolean; "term:bellindicator"?: boolean; + "term:exitindicator"?: boolean; "term:durable"?: boolean; "web:zoom"?: number; "web:hidenav"?: boolean; @@ -1287,6 +1288,7 @@ declare global { "term:macoptionismeta"?: boolean; "term:bellsound"?: boolean; "term:bellindicator"?: boolean; + "term:exitindicator"?: boolean; "term:durable"?: boolean; "editor:minimapenabled"?: boolean; "editor:stickyscrollenabled"?: boolean; diff --git a/pkg/blockcontroller/shellcontroller.go b/pkg/blockcontroller/shellcontroller.go index a410225394..57e93c6925 100644 --- a/pkg/blockcontroller/shellcontroller.go +++ b/pkg/blockcontroller/shellcontroller.go @@ -355,7 +355,7 @@ func (bc *ShellController) getConnUnion(logCtx context.Context, remoteName strin if err != nil { return ConnUnion{}, fmt.Errorf("invalid ssh remote name (%s): %w", remoteName, err) } - conn := conncontroller.MaybeGetConn(opts) + conn := conncontroller.GetConn(opts) if conn == nil { return ConnUnion{}, fmt.Errorf("ssh connection not found: %s", remoteName) } @@ -526,6 +526,34 @@ func (bc *ShellController) manageRunningShellProcess(shellProc *shellexec.ShellP shellInputCh := make(chan *BlockInputUnion, 32) bc.ShellInputCh = shellInputCh + // Fire amber "running" indicator for cmd blocks + if bc.ControllerType == BlockController_Cmd { + exitIndicatorEnabled := blockMeta.GetBool(waveobj.MetaKey_TermExitIndicator, false) + if !blockMeta.HasKey(waveobj.MetaKey_TermExitIndicator) { + if globalVal := wconfig.GetWatcher().GetFullConfig().Settings.TermExitIndicator; globalVal != nil { + exitIndicatorEnabled = *globalVal + } + } + if exitIndicatorEnabled { + indicator := wshrpc.TabIndicator{ + Icon: "spinner+spin", + Color: "#f59e0b", + Priority: 1.5, + ClearOnFocus: false, + } + eventData := wshrpc.TabIndicatorEventData{ + TabId: bc.TabId, + Indicator: &indicator, + } + event := wps.WaveEvent{ + Event: wps.Event_TabIndicator, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Tab, bc.TabId).String()}, + Data: eventData, + } + wps.Broker.Publish(event) + } + } + go func() { // handles regular output from the pty (goes to the blockfile and xterm) defer func() { @@ -616,6 +644,77 @@ func (bc *ShellController) manageRunningShellProcess(shellProc *shellexec.ShellP msg = fmt.Sprintf("%s (exit code %d)", baseMsg, exitCode) } bc.writeMutedMessageToTerminal("[" + msg + "]") + go func(exitCode int, exitSignal string) { + defer func() { + panichandler.PanicHandler("blockcontroller:exit-indicator", recover()) + }() + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, bc.BlockId) + if err != nil { + log.Printf("error getting block data for exit indicator: %v\n", err) + return + } + exitIndicatorEnabled := blockData.Meta.GetBool(waveobj.MetaKey_TermExitIndicator, false) + if !blockData.Meta.HasKey(waveobj.MetaKey_TermExitIndicator) { + if globalVal := wconfig.GetWatcher().GetFullConfig().Settings.TermExitIndicator; globalVal != nil { + exitIndicatorEnabled = *globalVal + } + } + if !exitIndicatorEnabled { + return + } + closeOnExit := blockData.Meta.GetBool(waveobj.MetaKey_CmdCloseOnExit, false) + closeOnExitForce := blockData.Meta.GetBool(waveobj.MetaKey_CmdCloseOnExitForce, false) + if closeOnExitForce || (closeOnExit && exitCode == 0) { + // Clear running indicator before block gets deleted + if bc.ControllerType == BlockController_Cmd { + clearEvent := wps.WaveEvent{ + Event: wps.Event_TabIndicator, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Tab, bc.TabId).String()}, + Data: wshrpc.TabIndicatorEventData{TabId: bc.TabId, Indicator: nil}, + } + wps.Broker.Publish(clearEvent) + } + return + } + // Clear running indicator before exit indicator to prevent + // PersistentIndicator from resurrecting the amber glow + if bc.ControllerType == BlockController_Cmd { + clearEvent := wps.WaveEvent{ + Event: wps.Event_TabIndicator, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Tab, bc.TabId).String()}, + Data: wshrpc.TabIndicatorEventData{TabId: bc.TabId, Indicator: nil}, + } + wps.Broker.Publish(clearEvent) + } + var indicator wshrpc.TabIndicator + if exitCode == 0 && exitSignal == "" { + indicator = wshrpc.TabIndicator{ + Icon: "check", + Color: "#4ade80", + Priority: 2, + ClearOnFocus: true, + } + } else { + indicator = wshrpc.TabIndicator{ + Icon: "xmark-large", + Color: "#f87171", + Priority: 2, + ClearOnFocus: true, + } + } + eventData := wshrpc.TabIndicatorEventData{ + TabId: bc.TabId, + Indicator: &indicator, + } + event := wps.WaveEvent{ + Event: wps.Event_TabIndicator, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Tab, bc.TabId).String()}, + Data: eventData, + } + wps.Broker.Publish(event) + }(exitCode, exitSignal) go checkCloseOnExit(bc.BlockId, exitCode) }() return nil diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index c1383ee32c..c9c2a02462 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -119,6 +119,7 @@ const ( MetaKey_TermConnDebug = "term:conndebug" MetaKey_TermBellSound = "term:bellsound" MetaKey_TermBellIndicator = "term:bellindicator" + MetaKey_TermExitIndicator = "term:exitindicator" MetaKey_TermDurable = "term:durable" MetaKey_WebZoom = "web:zoom" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index 73fcf52fd7..bfb80a0923 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -123,6 +123,7 @@ type MetaTSType struct { TermConnDebug string `json:"term:conndebug,omitempty"` // null, info, debug TermBellSound *bool `json:"term:bellsound,omitempty"` TermBellIndicator *bool `json:"term:bellindicator,omitempty"` + TermExitIndicator *bool `json:"term:exitindicator,omitempty"` TermDurable *bool `json:"term:durable,omitempty"` WebZoom float64 `json:"web:zoom,omitempty"` diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index f381020129..84069cd636 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -29,6 +29,7 @@ "telemetry:enabled": true, "term:bellsound": false, "term:bellindicator": false, + "term:exitindicator": false, "term:copyonselect": true, "term:durable": false, "waveai:showcloudmodes": true, diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 179c7927fd..c8bbac9ad5 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -53,6 +53,7 @@ const ( ConfigKey_TermMacOptionIsMeta = "term:macoptionismeta" ConfigKey_TermBellSound = "term:bellsound" ConfigKey_TermBellIndicator = "term:bellindicator" + ConfigKey_TermExitIndicator = "term:exitindicator" ConfigKey_TermDurable = "term:durable" ConfigKey_EditorMinimapEnabled = "editor:minimapenabled" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index c744f77aa3..0352a27e3b 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -100,6 +100,7 @@ type SettingsType struct { TermMacOptionIsMeta *bool `json:"term:macoptionismeta,omitempty"` TermBellSound *bool `json:"term:bellsound,omitempty"` TermBellIndicator *bool `json:"term:bellindicator,omitempty"` + TermExitIndicator *bool `json:"term:exitindicator,omitempty"` TermDurable *bool `json:"term:durable,omitempty"` EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"` diff --git a/schema/settings.json b/schema/settings.json index 0f6365d711..fa7fc6ebc2 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -137,6 +137,9 @@ "term:bellindicator": { "type": "boolean" }, + "term:exitindicator": { + "type": "boolean" + }, "term:durable": { "type": "boolean" },