waveterm/pkg/wconfig/settingsconfig.go

948 lines
33 KiB
Go
Raw Normal View History

// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package wconfig
import (
2024-08-28 01:49:49 +00:00
"bytes"
"encoding/json"
"fmt"
"io/fs"
"log"
2024-08-28 01:49:49 +00:00
"os"
"path/filepath"
2024-08-28 01:49:49 +00:00
"reflect"
"sort"
"strings"
Make Wave home config writes atomic and serialized to avoid watcher partial reads (#2945) `WriteWaveHomeConfigFile()` previously used direct `os.WriteFile`, which can expose truncation/partial-write states to the JSON file watcher. This change switches config persistence to temp-file + rename semantics and serializes writes through a single process-wide lock for config file writes. - **Atomic file write helper** - Added `AtomicWriteFile()` in `pkg/util/fileutil/fileutil.go`. - Writes to `<filename>.tmp` in the same directory, then renames to the target path. - Performs temp-file cleanup on error paths. - Introduced a shared suffix constant (`TempFileSuffix`) used by implementation/tests. - **Config write path update** - Updated `WriteWaveHomeConfigFile()` in `pkg/wconfig/settingsconfig.go` to: - Use a package-level mutex (`configWriteLock`) so only one config write runs at a time (across all config files). - Call `fileutil.AtomicWriteFile(...)` instead of direct `os.WriteFile(...)`. - **Focused coverage for atomic behavior** - Added `pkg/util/fileutil/fileutil_test.go` with tests for: - Successful atomic write (target file contains expected payload and no leftover `.tmp` file). - Rename-failure path cleanup (temp file is removed). ```go func WriteWaveHomeConfigFile(fileName string, m waveobj.MetaMapType) error { configWriteLock.Lock() defer configWriteLock.Unlock() fullFileName := filepath.Join(wavebase.GetWaveConfigDir(), fileName) barr, err := jsonMarshalConfigInOrder(m) if err != nil { return err } return fileutil.AtomicWriteFile(fullFileName, barr, 0644) } ``` <!-- START COPILOT CODING AGENT TIPS --> --- 🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
2026-02-27 23:43:37 +00:00
"sync"
Make Wave home config writes atomic and serialized to avoid watcher partial reads (#2945) `WriteWaveHomeConfigFile()` previously used direct `os.WriteFile`, which can expose truncation/partial-write states to the JSON file watcher. This change switches config persistence to temp-file + rename semantics and serializes writes through a single process-wide lock for config file writes. - **Atomic file write helper** - Added `AtomicWriteFile()` in `pkg/util/fileutil/fileutil.go`. - Writes to `<filename>.tmp` in the same directory, then renames to the target path. - Performs temp-file cleanup on error paths. - Introduced a shared suffix constant (`TempFileSuffix`) used by implementation/tests. - **Config write path update** - Updated `WriteWaveHomeConfigFile()` in `pkg/wconfig/settingsconfig.go` to: - Use a package-level mutex (`configWriteLock`) so only one config write runs at a time (across all config files). - Call `fileutil.AtomicWriteFile(...)` instead of direct `os.WriteFile(...)`. - **Focused coverage for atomic behavior** - Added `pkg/util/fileutil/fileutil_test.go` with tests for: - Successful atomic write (target file contains expected payload and no leftover `.tmp` file). - Rename-failure path cleanup (temp file is removed). ```go func WriteWaveHomeConfigFile(fileName string, m waveobj.MetaMapType) error { configWriteLock.Lock() defer configWriteLock.Unlock() fullFileName := filepath.Join(wavebase.GetWaveConfigDir(), fileName) barr, err := jsonMarshalConfigInOrder(m) if err != nil { return err } return fileutil.AtomicWriteFile(fullFileName, barr, 0644) } ``` <!-- START COPILOT CODING AGENT TIPS --> --- 🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
2026-02-27 23:43:37 +00:00
"github.com/wavetermdev/waveterm/pkg/util/fileutil"
2024-09-05 21:25:45 +00:00
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
"github.com/wavetermdev/waveterm/pkg/wavebase"
2024-09-05 21:25:45 +00:00
"github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wconfig/defaultconfig"
)
2024-08-28 01:49:49 +00:00
const SettingsFile = "settings.json"
2024-11-28 00:52:00 +00:00
const ConnectionsFile = "connections.json"
const ProfilesFile = "profiles.json"
Make Wave home config writes atomic and serialized to avoid watcher partial reads (#2945) `WriteWaveHomeConfigFile()` previously used direct `os.WriteFile`, which can expose truncation/partial-write states to the JSON file watcher. This change switches config persistence to temp-file + rename semantics and serializes writes through a single process-wide lock for config file writes. - **Atomic file write helper** - Added `AtomicWriteFile()` in `pkg/util/fileutil/fileutil.go`. - Writes to `<filename>.tmp` in the same directory, then renames to the target path. - Performs temp-file cleanup on error paths. - Introduced a shared suffix constant (`TempFileSuffix`) used by implementation/tests. - **Config write path update** - Updated `WriteWaveHomeConfigFile()` in `pkg/wconfig/settingsconfig.go` to: - Use a package-level mutex (`configWriteLock`) so only one config write runs at a time (across all config files). - Call `fileutil.AtomicWriteFile(...)` instead of direct `os.WriteFile(...)`. - **Focused coverage for atomic behavior** - Added `pkg/util/fileutil/fileutil_test.go` with tests for: - Successful atomic write (target file contains expected payload and no leftover `.tmp` file). - Rename-failure path cleanup (temp file is removed). ```go func WriteWaveHomeConfigFile(fileName string, m waveobj.MetaMapType) error { configWriteLock.Lock() defer configWriteLock.Unlock() fullFileName := filepath.Join(wavebase.GetWaveConfigDir(), fileName) barr, err := jsonMarshalConfigInOrder(m) if err != nil { return err } return fileutil.AtomicWriteFile(fullFileName, barr, 0644) } ``` <!-- START COPILOT CODING AGENT TIPS --> --- 🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
2026-02-27 23:43:37 +00:00
var configWriteLock sync.Mutex
const AnySchema = `
{
"type": "object",
"additionalProperties": true
}
`
// old AI Widget presets (deprecated)
type AiSettingsType struct {
AiClear bool `json:"ai:*,omitempty"`
AiPreset string `json:"ai:preset,omitempty"`
AiApiType string `json:"ai:apitype,omitempty"`
AiBaseURL string `json:"ai:baseurl,omitempty"`
AiApiToken string `json:"ai:apitoken,omitempty"`
AiName string `json:"ai:name,omitempty"`
AiModel string `json:"ai:model,omitempty"`
AiOrgID string `json:"ai:orgid,omitempty"`
AIApiVersion string `json:"ai:apiversion,omitempty"`
AiMaxTokens float64 `json:"ai:maxtokens,omitempty"`
AiTimeoutMs float64 `json:"ai:timeoutms,omitempty"`
AiProxyUrl string `json:"ai:proxyurl,omitempty"`
AiFontSize float64 `json:"ai:fontsize,omitempty"`
AiFixedFontSize float64 `json:"ai:fixedfontsize,omitempty"`
DisplayName string `json:"display:name,omitempty"`
DisplayOrder float64 `json:"display:order,omitempty"`
}
type SettingsType struct {
AppClear bool `json:"app:*,omitempty"`
AppGlobalHotkey string `json:"app:globalhotkey,omitempty"`
AppDismissArchitectureWarning bool `json:"app:dismissarchitecturewarning,omitempty"`
2025-02-12 05:58:03 +00:00
AppDefaultNewBlock string `json:"app:defaultnewblock,omitempty"`
AppShowOverlayBlockNums *bool `json:"app:showoverlayblocknums,omitempty"`
AppCtrlVPaste *bool `json:"app:ctrlvpaste,omitempty"`
AppConfirmQuit *bool `json:"app:confirmquit,omitempty"`
AppHideAiButton bool `json:"app:hideaibutton,omitempty"`
AppDisableCtrlShiftArrows bool `json:"app:disablectrlshiftarrows,omitempty"`
AppDisableCtrlShiftDisplay bool `json:"app:disablectrlshiftdisplay,omitempty"`
AppFocusFollowsCursor string `json:"app:focusfollowscursor,omitempty" jsonschema:"enum=off,enum=on,enum=term"`
2025-11-15 00:35:37 +00:00
FeatureWaveAppBuilder bool `json:"feature:waveappbuilder,omitempty"`
AiClear bool `json:"ai:*,omitempty"`
AiPreset string `json:"ai:preset,omitempty"`
AiApiType string `json:"ai:apitype,omitempty"`
AiBaseURL string `json:"ai:baseurl,omitempty"`
AiApiToken string `json:"ai:apitoken,omitempty"`
AiName string `json:"ai:name,omitempty"`
AiModel string `json:"ai:model,omitempty"`
AiOrgID string `json:"ai:orgid,omitempty"`
AIApiVersion string `json:"ai:apiversion,omitempty"`
AiMaxTokens float64 `json:"ai:maxtokens,omitempty"`
AiTimeoutMs float64 `json:"ai:timeoutms,omitempty"`
AiProxyUrl string `json:"ai:proxyurl,omitempty"`
AiFontSize float64 `json:"ai:fontsize,omitempty"`
AiFixedFontSize float64 `json:"ai:fixedfontsize,omitempty"`
WaveAiShowCloudModes bool `json:"waveai:showcloudmodes,omitempty"`
WaveAiDefaultMode string `json:"waveai:defaultmode,omitempty"`
TermClear bool `json:"term:*,omitempty"`
TermFontSize float64 `json:"term:fontsize,omitempty"`
TermFontFamily string `json:"term:fontfamily,omitempty"`
TermTheme string `json:"term:theme,omitempty"`
TermDisableWebGl bool `json:"term:disablewebgl,omitempty"`
TermLocalShellPath string `json:"term:localshellpath,omitempty"`
TermLocalShellOpts []string `json:"term:localshellopts,omitempty"`
TermGitBashPath string `json:"term:gitbashpath,omitempty"`
TermScrollback *int64 `json:"term:scrollback,omitempty"`
TermCopyOnSelect *bool `json:"term:copyonselect,omitempty"`
TermTransparency *float64 `json:"term:transparency,omitempty"`
TermAllowBracketedPaste *bool `json:"term:allowbracketedpaste,omitempty"`
TermShiftEnterNewline *bool `json:"term:shiftenternewline,omitempty"`
TermMacOptionIsMeta *bool `json:"term:macoptionismeta,omitempty"`
Add terminal cursor style/blink config with block-level overrides (#2933) Adds support for configuring terminal cursor style and blink behavior in terminal blocks, with hierarchical resolution (block metadata → connection overrides → global settings). New keys are `term:cursor` (`block`/`underline`/`bar`, default `block`) and `term:cursorblink` (`bool`, default `false`). - **Config surface: new terminal keys** - Added to global settings schema/types: - `pkg/wconfig/settingsconfig.go` - Added to block metadata typing: - `pkg/waveobj/wtypemeta.go` - Added default values: - `pkg/wconfig/defaultconfig/settings.json` - `"term:cursor": "block"` - `"term:cursorblink": false` - **Frontend terminal behavior (xterm options)** - `frontend/app/view/term/termwrap.ts` - Added `setCursorStyle()` with value normalization (`underline`/`bar` else fallback `block`) - Added `setCursorBlink()` - Applies both options on terminal construction via `getOverrideConfigAtom(...)` - `frontend/app/view/term/term-model.ts` - Subscribes to `term:cursor` and `term:cursorblink` override atoms - Propagates live updates to `term.options.cursorStyle` / `term.options.cursorBlink` - Cleans up subscriptions in `dispose()` - **Generated artifacts** - Regenerated config/type outputs after Go type additions: - `schema/settings.json` - `pkg/wconfig/metaconsts.go` - `pkg/waveobj/metaconsts.go` - `frontend/types/gotypes.d.ts` - **Docs** - Updated config reference and default config example: - `docs/docs/config.mdx` ```ts // termwrap.ts this.setCursorStyle(globalStore.get(getOverrideConfigAtom(this.blockId, "term:cursor"))); this.setCursorBlink(globalStore.get(getOverrideConfigAtom(this.blockId, "term:cursorblink")) ?? false); // term-model.ts (live updates) const termCursorAtom = getOverrideConfigAtom(blockId, "term:cursor"); this.termCursorUnsubFn = globalStore.sub(termCursorAtom, () => { this.termRef.current?.setCursorStyle(globalStore.get(termCursorAtom)); }); ``` <!-- START COPILOT CODING AGENT TIPS --> --- 🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> Co-authored-by: sawka <mike@commandline.dev>
2026-02-25 20:40:20 +00:00
TermCursor string `json:"term:cursor,omitempty"`
TermCursorBlink *bool `json:"term:cursorblink,omitempty"`
TermBellSound *bool `json:"term:bellsound,omitempty"`
TermBellIndicator *bool `json:"term:bellindicator,omitempty"`
TermOsc52 string `json:"term:osc52,omitempty" jsonschema:"enum=focus,enum=always"`
TermDurable *bool `json:"term:durable,omitempty"`
EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"`
EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"`
EditorWordWrap bool `json:"editor:wordwrap,omitempty"`
EditorFontSize float64 `json:"editor:fontsize,omitempty"`
EditorInlineDiff bool `json:"editor:inlinediff,omitempty"`
WebClear bool `json:"web:*,omitempty"`
WebOpenLinksInternally bool `json:"web:openlinksinternally,omitempty"`
WebDefaultUrl string `json:"web:defaulturl,omitempty"`
WebDefaultSearch string `json:"web:defaultsearch,omitempty"`
AutoUpdateClear bool `json:"autoupdate:*,omitempty"`
AutoUpdateEnabled bool `json:"autoupdate:enabled,omitempty"`
AutoUpdateIntervalMs float64 `json:"autoupdate:intervalms,omitempty"`
AutoUpdateInstallOnQuit bool `json:"autoupdate:installonquit,omitempty"`
Add release channels (#385) ## New release flow 1. Run "Bump Version" workflow with the desired version bump and the prerelease flag set to `true`. This will push a new version bump to the target branch and create a new git tag. - See below for more info on how the version bumping works. 2. A new "Build Helper" workflow run will kick off automatically for the new tag. Once it is complete, test the new build locally by downloading with the [download script](https://github.com/wavetermdev/thenextwave/blob/main/scripts/artifacts/download-staged-artifact.sh). 3. Release the new build using the [publish script](https://github.com/wavetermdev/thenextwave/blob/main/scripts/artifacts/publish-from-staging.sh). This will trigger electron-updater to distribute the package to beta users. 4. Run "Bump Version" again with a release bump (either `major`, `minor`, or `patch`) and the prerelease flag set to `false`. 6. Release the new build to all channels using the [publish script](https://github.com/wavetermdev/thenextwave/blob/main/scripts/artifacts/publish-from-staging.sh). This will trigger electron-updater to distribute the package to all users. ## Change Summary Creates a new "Bump Version" workflow to manage versioning and tag creation. Build Helper is now automated. ### Version bumps Updates the `version.cjs` script so that an argument can be passed to trigger a version bump. Under the hood, this utilizes NPM's `semver` package. If arguments are present, the version will be bumped. If only a single argument is given, the following are valid inputs: - `none`: No-op. - `patch`: Bumps the patch version. - `minor`: Bumps the minor version. - `major`: Bumps the major version. - '1', 'true': Bumps the prerelease version. If two arguments are given, the first argument must be either `none`, `patch`, `minor`, or `major`. The second argument must be `1` or `true` to bump the prerelease version. ### electron-builder We are now using the release channels support in electron-builder. This will automatically detect the channel being built based on the package version to determine which channel update files need to be generated. See [here](https://www.electron.build/tutorials/release-using-channels.html) for more information. ### Github Actions #### Bump Version This adds a new "Bump Version" workflow for managing versioning and queuing new builds. When run, this workflow will bump the version, create a new tag, and push the changes to the target branch. There is a new dropdown when queuing the "Bump Version" workflow to select what kind of version bump to perform. A bump must always be performed when running a new build to ensure consistency. I had to create a GitHub App to grant write permissions to our main branch for the version bump commits. I've made a separate workflow file to manage the version bump commits, which should help prevent tampering. Thanks to using the GitHub API directly, I am able to make these commits signed! #### Build Helper Build Helper is now triggered when new tags are created, rather than being triggered automatically. This ensures we're always creating artifacts from known checkpoints. ### Settings Adds a new `autoupdate:channel` configuration to the settings file. If unset, the default from the artifact will be used (should correspond to the channel of the artifact when downloaded). ## Future Work I want to add a release workflow that will automatically copy over the corresponding version artifacts to the release bucket when a new GitHub Release is created. I also want to separate versions into separate subdirectories in the release bucket so we can clean them up more-easily. --------- Co-authored-by: wave-builder <builds@commandline.dev> Co-authored-by: wave-builder[bot] <181805596+wave-builder[bot]@users.noreply.github.com>
2024-09-17 20:10:35 +00:00
AutoUpdateChannel string `json:"autoupdate:channel,omitempty"`
2024-12-17 00:04:07 +00:00
MarkdownFontSize float64 `json:"markdown:fontsize,omitempty"`
MarkdownFixedFontSize float64 `json:"markdown:fixedfontsize,omitempty"`
PreviewShowHiddenFiles *bool `json:"preview:showhiddenfiles,omitempty"`
TabPreset string `json:"tab:preset,omitempty"`
TabConfirmClose bool `json:"tab:confirmclose,omitempty"`
2024-11-08 00:05:42 +00:00
WidgetClear bool `json:"widget:*,omitempty"`
WidgetShowHelp *bool `json:"widget:showhelp,omitempty"`
WindowClear bool `json:"window:*,omitempty"`
WindowFullscreenOnLaunch bool `json:"window:fullscreenonlaunch,omitempty"`
WindowTransparent bool `json:"window:transparent,omitempty"`
WindowBlur bool `json:"window:blur,omitempty"`
WindowOpacity *float64 `json:"window:opacity,omitempty"`
WindowBgColor string `json:"window:bgcolor,omitempty"`
WindowReducedMotion bool `json:"window:reducedmotion,omitempty"`
WindowTileGapSize *int64 `json:"window:tilegapsize,omitempty"`
WindowShowMenuBar bool `json:"window:showmenubar,omitempty"`
WindowNativeTitleBar bool `json:"window:nativetitlebar,omitempty"`
WindowDisableHardwareAcceleration bool `json:"window:disablehardwareacceleration,omitempty"`
WindowMaxTabCacheSize int `json:"window:maxtabcachesize,omitempty"`
WindowMagnifiedBlockOpacity *float64 `json:"window:magnifiedblockopacity,omitempty"`
WindowMagnifiedBlockSize *float64 `json:"window:magnifiedblocksize,omitempty"`
WindowMagnifiedBlockBlurPrimaryPx *int64 `json:"window:magnifiedblockblurprimarypx,omitempty"`
WindowMagnifiedBlockBlurSecondaryPx *int64 `json:"window:magnifiedblockblursecondarypx,omitempty"`
WindowConfirmClose bool `json:"window:confirmclose,omitempty"`
WindowSaveLastWindow bool `json:"window:savelastwindow,omitempty"`
WindowDimensions string `json:"window:dimensions,omitempty"`
WindowZoom *float64 `json:"window:zoom,omitempty"`
2024-08-28 01:49:49 +00:00
TelemetryClear bool `json:"telemetry:*,omitempty"`
TelemetryEnabled bool `json:"telemetry:enabled,omitempty"`
ConnClear bool `json:"conn:*,omitempty"`
ConnAskBeforeWshInstall *bool `json:"conn:askbeforewshinstall,omitempty"`
ConnWshEnabled bool `json:"conn:wshenabled,omitempty"`
ConnLocalHostnameDisplay *string `json:"conn:localhostdisplayname,omitempty"`
DebugClear bool `json:"debug:*,omitempty"`
DebugPprofPort *int `json:"debug:pprofport,omitempty"`
DebugPprofMemProfileRate *int `json:"debug:pprofmemprofilerate,omitempty"`
2025-11-08 02:19:52 +00:00
TsunamiClear bool `json:"tsunami:*,omitempty"`
TsunamiScaffoldPath string `json:"tsunami:scaffoldpath,omitempty"`
TsunamiSdkReplacePath string `json:"tsunami:sdkreplacepath,omitempty"`
TsunamiSdkVersion string `json:"tsunami:sdkversion,omitempty"`
TsunamiGoPath string `json:"tsunami:gopath,omitempty"`
}
func (s *SettingsType) GetAiSettings() *AiSettingsType {
return &AiSettingsType{
AiClear: s.AiClear,
AiPreset: s.AiPreset,
AiApiType: s.AiApiType,
AiBaseURL: s.AiBaseURL,
AiApiToken: s.AiApiToken,
AiName: s.AiName,
AiModel: s.AiModel,
AiOrgID: s.AiOrgID,
AIApiVersion: s.AIApiVersion,
AiMaxTokens: s.AiMaxTokens,
AiTimeoutMs: s.AiTimeoutMs,
AiProxyUrl: s.AiProxyUrl,
AiFontSize: s.AiFontSize,
AiFixedFontSize: s.AiFixedFontSize,
}
}
func MergeAiSettings(settings ...*AiSettingsType) *AiSettingsType {
result := &AiSettingsType{}
for _, s := range settings {
if s == nil {
continue
}
// If this setting has AiClear=true, replace result with this entire setting
if s.AiClear {
result = s
result.AiClear = false
continue
}
// Merge non-empty values
if s.AiPreset != "" {
result.AiPreset = s.AiPreset
}
if s.AiApiType != "" {
result.AiApiType = s.AiApiType
}
if s.AiBaseURL != "" {
result.AiBaseURL = s.AiBaseURL
}
if s.AiApiToken != "" {
result.AiApiToken = s.AiApiToken
}
if s.AiName != "" {
result.AiName = s.AiName
}
if s.AiModel != "" {
result.AiModel = s.AiModel
}
if s.AiOrgID != "" {
result.AiOrgID = s.AiOrgID
}
if s.AIApiVersion != "" {
result.AIApiVersion = s.AIApiVersion
}
if s.AiProxyUrl != "" {
result.AiProxyUrl = s.AiProxyUrl
}
if s.AiMaxTokens != 0 {
result.AiMaxTokens = s.AiMaxTokens
}
if s.AiTimeoutMs != 0 {
result.AiTimeoutMs = s.AiTimeoutMs
}
if s.AiFontSize != 0 {
result.AiFontSize = s.AiFontSize
}
if s.AiFixedFontSize != 0 {
result.AiFixedFontSize = s.AiFixedFontSize
}
if s.DisplayName != "" {
result.DisplayName = s.DisplayName
}
if s.DisplayOrder != 0 {
result.DisplayOrder = s.DisplayOrder
}
}
return result
}
2024-08-28 01:49:49 +00:00
type ConfigError struct {
File string `json:"file"`
Err string `json:"err"`
}
2025-02-08 00:11:40 +00:00
type WebBookmark struct {
Url string `json:"url"`
Title string `json:"title,omitempty"`
Icon string `json:"icon,omitempty"`
IconColor string `json:"iconcolor,omitempty"`
IconUrl string `json:"iconurl,omitempty"`
DisplayOrder float64 `json:"display:order,omitempty"`
}
// Wave AI panel mode configuration (NEW)
type AIModeConfigType struct {
DisplayName string `json:"display:name"`
DisplayOrder float64 `json:"display:order,omitempty"`
DisplayIcon string `json:"display:icon,omitempty"`
DisplayDescription string `json:"display:description,omitempty"`
Add `groq` AI mode provider defaults and docs (#2942) This change adds first-class `groq` provider support to Wave AI mode resolution and documents it in the Wave AI modes guide. Users can now configure Groq modes via `ai:provider` with provider defaults applied automatically. - **Provider support in backend config resolution** - Added `groq` as a recognized AI provider constant. - Added Groq provider defaults in mode resolution: - `ai:apitype`: `openai-chat` - `ai:endpoint`: `https://api.groq.com/openai/v1/chat/completions` - `ai:apitokensecretname`: `GROQ_KEY` - **Schema/config surface update** - Extended `AIModeConfigType` provider enum to include `groq`, so `ai:provider: "groq"` is valid in Wave AI config. - **Documentation updates (`waveai-modes.mdx`)** - Added `groq` to supported providers. - Added a Groq-specific configuration example and default behavior notes. - Updated provider reference and capability guidance to include Groq. - **Focused coverage** - Added a targeted unit test for Groq provider default application in `applyProviderDefaults`. ```json { "groq-kimi-k2": { "display:name": "Groq - Kimi K2", "ai:provider": "groq", "ai:model": "moonshotai/kimi-k2-instruct" } } ``` <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
2026-02-26 17:50:05 +00:00
Provider string `json:"ai:provider,omitempty" jsonschema:"enum=wave,enum=google,enum=groq,enum=openrouter,enum=nanogpt,enum=openai,enum=azure,enum=azure-legacy,enum=custom"`
Add Google Gemini backend for AI chat (#2602) - [x] Add new API type constant for Google Gemini in uctypes.go - [x] Create gemini directory under pkg/aiusechat/ - [x] Implement gemini-backend.go with streaming chat support - [x] Implement gemini-convertmessage.go for message conversion - [x] Implement gemini-types.go for Google-specific types - [x] Add gemini backend to usechat-backend.go - [x] Support tool calling with structured arguments - [x] Support image upload (base64 inline data) - [x] Support PDF upload (base64 inline data) - [x] Support file upload (text files, directory listings) - [x] Build verification passed - [x] Add documentation for Gemini backend usage - [x] Security scan passed (CodeQL found 0 issues) - [x] Code review passed with no comments - [x] Revert tsunami demo go.mod/go.sum files (per feedback - twice) - [x] Add `--gemini` flag to main-testai.go for testing - [x] Fix schema validation for tool calling (clean unsupported fields) - [x] Preserve non-map property values in schema cleaning ## Summary Successfully implemented a complete Google Gemini backend for WaveTerm's AI chat system. The implementation: - **Follows existing patterns**: Matches the structure of OpenAI and Anthropic backends - **Fully featured**: Supports all required capabilities including tool calling, images, PDFs, and files - **Properly tested**: Builds successfully with no errors or warnings - **Secure**: Passed CodeQL security scanning with 0 issues - **Well documented**: Includes comprehensive package documentation with usage examples - **Minimal changes**: Only affects backend code under pkg/aiusechat (tsunami demo files reverted twice) - **Testable**: Added `--gemini` flag to main-testai.go for easy testing with SSE output - **Schema compatible**: Cleans JSON schemas to remove fields unsupported by Gemini API while preserving valid structure ## Testing To test the Gemini backend using main-testai.go: ```bash export GOOGLE_APIKEY="your-api-key" cd cmd/testai go run main-testai.go --gemini 'What is 2+2?' go run main-testai.go --gemini --model gemini-1.5-pro 'Explain quantum computing' go run main-testai.go --gemini --tools 'Help me configure GitHub Actions monitoring' ``` Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
2025-12-05 20:43:42 +00:00
APIType string `json:"ai:apitype,omitempty" jsonschema:"enum=google-gemini,enum=openai-responses,enum=openai-chat"`
Model string `json:"ai:model,omitempty"`
ThinkingLevel string `json:"ai:thinkinglevel,omitempty" jsonschema:"enum=low,enum=medium,enum=high"`
Add configurable verbosity for OpenAI Responses API (#2776) # Add configurable verbosity for OpenAI Responses API Fixes #2775 ## Problem Models like `gpt-5.2-codex` and other newer OpenAI models only support `medium` reasoning and verbosity levels, but the codebase was using `low` by default. This caused 400 Bad Request errors: ``` Failed to stream openai-responses chat: openai 400 Bad Request: Unsupported value: 'low' is not supported with the 'gpt-5.2-codex' model. Supported values are: 'medium'. ``` ## Solution This PR implements a scalable, user-configurable approach instead of hardcoding model-specific constraints: 1. **Changed default verbosity** from `"low"` to `"medium"` - more widely supported across OpenAI models 2. **Added `ai:verbosity` config option** - allows users to configure verbosity per model in `waveai.json` 3. **Changed rate limit fallback** from `low` to `medium` thinking level for better compatibility 4. **Removed hardcoded model checks** - solution is scalable for future models ## Changes ### Backend Changes - `pkg/aiusechat/openai/openai-convertmessage.go` - Use configurable verbosity with safe defaults - `pkg/aiusechat/uctypes/uctypes.go` - Add `Verbosity` field to `AIOptsType` - `pkg/aiusechat/usechat.go` - Pass verbosity from config to options - `pkg/wconfig/settingsconfig.go` - Add `Verbosity` to `AIModeConfigType` ### Schema Changes - `schema/waveai.json` - Add `ai:verbosity` with enum values (low/medium/high) - `frontend/types/gotypes.d.ts` - Auto-generated TypeScript types ### Configuration Example Users can now configure both thinking level and verbosity per model: ```json { "openai-gpt52-codex": { "display:name": "GPT-5.2 Codex", "ai:provider": "openai", "ai:model": "gpt-5.2-codex", "ai:thinkinglevel": "medium", "ai:verbosity": "medium" } } ```
2026-02-05 02:31:57 +00:00
Verbosity string `json:"ai:verbosity,omitempty" jsonschema:"enum=low,enum=medium,enum=high,description=Text verbosity level (OpenAI Responses API only)"`
Endpoint string `json:"ai:endpoint,omitempty"`
Centralize proxy HTTP client creation in aiutil and remove redundant backend tests (#2961) `makeHTTPClient(proxyURL)` had been duplicated across AI backends with equivalent behavior. This change consolidates the logic into a single helper in `aiutil` and updates backends to consume it, then removes backend-local tests that only re-verified that shared utility behavior. - **Shared client construction** - Added `aiutil.MakeHTTPClient(proxyURL string) (*http.Client, error)` in `pkg/aiusechat/aiutil/aiutil.go`. - Standardizes proxy parsing and `http.Transport.Proxy` setup in one place. - Keeps streaming-safe client semantics (`Timeout: 0`) and existing invalid proxy URL error behavior. - **Backend refactor** - Removed duplicated client/proxy setup blocks from: - `pkg/aiusechat/openaichat/openaichat-backend.go` - `pkg/aiusechat/gemini/gemini-backend.go` - `pkg/aiusechat/openai/openai-backend.go` - `pkg/aiusechat/anthropic/anthropic-backend.go` - Replaced with direct calls to the shared helper. - **Test cleanup** - Deleted backend tests that only covered basic proxy client creation and no backend-specific behavior: - `pkg/aiusechat/openaichat/openaichat-backend_test.go` - `pkg/aiusechat/gemini/gemini-backend_test.go` ```go httpClient, err := aiutil.MakeHTTPClient(chatOpts.Config.ProxyURL) if err != nil { return nil, nil, nil, err } resp, err := httpClient.Do(req) ``` <!-- START COPILOT CODING AGENT TIPS --> --- 💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
2026-03-02 19:16:50 +00:00
ProxyURL string `json:"ai:proxyurl,omitempty"`
AzureAPIVersion string `json:"ai:azureapiversion,omitempty"`
APIToken string `json:"ai:apitoken,omitempty"`
APITokenSecretName string `json:"ai:apitokensecretname,omitempty"`
AzureResourceName string `json:"ai:azureresourcename,omitempty"`
AzureDeployment string `json:"ai:azuredeployment,omitempty"`
Capabilities []string `json:"ai:capabilities,omitempty" jsonschema:"enum=pdfs,enum=images,enum=tools"`
SwitchCompat []string `json:"ai:switchcompat,omitempty"`
WaveAICloud bool `json:"waveai:cloud,omitempty"`
WaveAIPremium bool `json:"waveai:premium,omitempty"`
}
2025-12-09 05:58:54 +00:00
type AIModeConfigUpdate struct {
Configs map[string]AIModeConfigType `json:"configs"`
}
2024-08-28 01:49:49 +00:00
type FullConfigType struct {
2024-11-28 00:52:00 +00:00
Settings SettingsType `json:"settings" merge:"meta"`
MimeTypes map[string]MimeTypeConfigType `json:"mimetypes"`
DefaultWidgets map[string]WidgetConfigType `json:"defaultwidgets"`
Widgets map[string]WidgetConfigType `json:"widgets"`
Presets map[string]waveobj.MetaMapType `json:"presets"`
TermThemes map[string]TermThemeType `json:"termthemes"`
Connections map[string]ConnKeywords `json:"connections"`
2025-02-08 00:11:40 +00:00
Bookmarks map[string]WebBookmark `json:"bookmarks"`
WaveAIModes map[string]AIModeConfigType `json:"waveai"`
2024-11-28 00:52:00 +00:00
ConfigErrors []ConfigError `json:"configerrors" configfile:"-"`
}
type ConnKeywords struct {
ConnWshEnabled *bool `json:"conn:wshenabled,omitempty"`
ConnAskBeforeWshInstall *bool `json:"conn:askbeforewshinstall,omitempty"`
ConnWshPath string `json:"conn:wshpath,omitempty"`
ConnShellPath string `json:"conn:shellpath,omitempty"`
ConnIgnoreSshConfig *bool `json:"conn:ignoresshconfig,omitempty"`
DisplayHidden *bool `json:"display:hidden,omitempty"`
DisplayOrder float32 `json:"display:order,omitempty"`
TermClear bool `json:"term:*,omitempty"`
TermFontSize float64 `json:"term:fontsize,omitempty"`
TermFontFamily string `json:"term:fontfamily,omitempty"`
TermTheme string `json:"term:theme,omitempty"`
TermDurable *bool `json:"term:durable,omitempty"`
CmdEnv map[string]string `json:"cmd:env,omitempty"`
CmdInitScript string `json:"cmd:initscript,omitempty"`
CmdInitScriptSh string `json:"cmd:initscript.sh,omitempty"`
CmdInitScriptBash string `json:"cmd:initscript.bash,omitempty"`
CmdInitScriptZsh string `json:"cmd:initscript.zsh,omitempty"`
CmdInitScriptPwsh string `json:"cmd:initscript.pwsh,omitempty"`
CmdInitScriptFish string `json:"cmd:initscript.fish,omitempty"`
SshUser *string `json:"ssh:user,omitempty"`
SshHostName *string `json:"ssh:hostname,omitempty"`
SshPort *string `json:"ssh:port,omitempty"`
SshIdentityFile []string `json:"ssh:identityfile,omitempty"`
SshPasswordSecretName *string `json:"ssh:passwordsecretname,omitempty"`
SshBatchMode *bool `json:"ssh:batchmode,omitempty"`
SshPubkeyAuthentication *bool `json:"ssh:pubkeyauthentication,omitempty"`
SshPasswordAuthentication *bool `json:"ssh:passwordauthentication,omitempty"`
SshKbdInteractiveAuthentication *bool `json:"ssh:kbdinteractiveauthentication,omitempty"`
SshPreferredAuthentications []string `json:"ssh:preferredauthentications,omitempty"`
SshAddKeysToAgent *bool `json:"ssh:addkeystoagent,omitempty"`
SshIdentityAgent *string `json:"ssh:identityagent,omitempty"`
SshIdentitiesOnly *bool `json:"ssh:identitiesonly,omitempty"`
SshProxyJump []string `json:"ssh:proxyjump,omitempty"`
SshUserKnownHostsFile []string `json:"ssh:userknownhostsfile,omitempty"`
SshGlobalKnownHostsFile []string `json:"ssh:globalknownhostsfile,omitempty"`
}
func DefaultBoolPtr(arg *bool, def bool) bool {
if arg == nil {
return def
}
return *arg
}
func goBackWS(barr []byte, offset int) int {
if offset >= len(barr) {
offset = offset - 1
}
for i := offset - 1; i >= 0; i-- {
if barr[i] == ' ' || barr[i] == '\t' || barr[i] == '\n' || barr[i] == '\r' {
continue
}
return i
}
return 0
}
func isTrailingCommaError(barr []byte, offset int) bool {
if offset >= len(barr) {
offset = offset - 1
}
offset = goBackWS(barr, offset)
if barr[offset] == '}' {
offset = goBackWS(barr, offset)
if barr[offset] == ',' {
return true
}
}
return false
}
func resolveEnvReplacements(m waveobj.MetaMapType) {
if m == nil {
return
}
for key, value := range m {
switch v := value.(type) {
case string:
if resolved, ok := resolveEnvValue(v); ok {
m[key] = resolved
}
case map[string]interface{}:
resolveEnvReplacements(waveobj.MetaMapType(v))
case []interface{}:
resolveEnvArray(v)
}
}
}
func resolveEnvArray(arr []interface{}) {
for i, value := range arr {
switch v := value.(type) {
case string:
if resolved, ok := resolveEnvValue(v); ok {
arr[i] = resolved
}
case map[string]interface{}:
resolveEnvReplacements(waveobj.MetaMapType(v))
case []interface{}:
resolveEnvArray(v)
}
}
}
func resolveEnvValue(value string) (string, bool) {
if !strings.HasPrefix(value, "$ENV:") {
return "", false
}
envSpec := value[5:] // Remove "$ENV:" prefix
parts := strings.SplitN(envSpec, ":", 2)
envVar := parts[0]
var fallback string
if len(parts) > 1 {
fallback = parts[1]
}
// Get the environment variable value
if envValue, exists := os.LookupEnv(envVar); exists {
return envValue, true
}
// Return fallback if provided, otherwise return empty string
if fallback != "" {
return fallback, true
}
return "", true
}
2024-08-28 01:49:49 +00:00
func readConfigHelper(fileName string, barr []byte, readErr error) (waveobj.MetaMapType, []ConfigError) {
var cerrs []ConfigError
if readErr != nil && !os.IsNotExist(readErr) {
cerrs = append(cerrs, ConfigError{File: fileName, Err: readErr.Error()})
2024-08-28 01:49:49 +00:00
}
if len(barr) == 0 {
return nil, cerrs
}
var rtn waveobj.MetaMapType
err := json.Unmarshal(barr, &rtn)
if err != nil {
if syntaxErr, ok := err.(*json.SyntaxError); ok {
offset := syntaxErr.Offset
if offset > 0 {
offset = offset - 1
}
lineNum, colNum := utilfn.GetLineColFromOffset(barr, int(offset))
isTrailingComma := isTrailingCommaError(barr, int(offset))
if isTrailingComma {
err = fmt.Errorf("json syntax error at line %d, col %d: probably an extra trailing comma: %v", lineNum, colNum, syntaxErr)
} else {
err = fmt.Errorf("json syntax error at line %d, col %d: %v", lineNum, colNum, syntaxErr)
}
}
cerrs = append(cerrs, ConfigError{File: fileName, Err: err.Error()})
2024-08-28 01:49:49 +00:00
}
// Resolve environment variable replacements
if rtn != nil {
resolveEnvReplacements(rtn)
}
2024-08-28 01:49:49 +00:00
return rtn, cerrs
}
func readConfigFileFS(fsys fs.FS, logPrefix string, fileName string) (waveobj.MetaMapType, []ConfigError) {
barr, readErr := fs.ReadFile(fsys, fileName)
if readErr != nil {
// If we get an error, we may be using the wrong path separator for the given FS interface. Try switching the separator.
barr, readErr = fs.ReadFile(fsys, filepath.ToSlash(fileName))
}
return readConfigHelper(logPrefix+fileName, barr, readErr)
}
2024-08-28 01:49:49 +00:00
func ReadDefaultsConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) {
return readConfigFileFS(defaultconfig.ConfigFS, "defaults:", fileName)
}
2024-08-28 01:49:49 +00:00
func ReadWaveHomeConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) {
configDirAbsPath := wavebase.GetWaveConfigDir()
configDirFsys := os.DirFS(configDirAbsPath)
return readConfigFileFS(configDirFsys, "", fileName)
}
2024-08-28 01:49:49 +00:00
func WriteWaveHomeConfigFile(fileName string, m waveobj.MetaMapType) error {
Make Wave home config writes atomic and serialized to avoid watcher partial reads (#2945) `WriteWaveHomeConfigFile()` previously used direct `os.WriteFile`, which can expose truncation/partial-write states to the JSON file watcher. This change switches config persistence to temp-file + rename semantics and serializes writes through a single process-wide lock for config file writes. - **Atomic file write helper** - Added `AtomicWriteFile()` in `pkg/util/fileutil/fileutil.go`. - Writes to `<filename>.tmp` in the same directory, then renames to the target path. - Performs temp-file cleanup on error paths. - Introduced a shared suffix constant (`TempFileSuffix`) used by implementation/tests. - **Config write path update** - Updated `WriteWaveHomeConfigFile()` in `pkg/wconfig/settingsconfig.go` to: - Use a package-level mutex (`configWriteLock`) so only one config write runs at a time (across all config files). - Call `fileutil.AtomicWriteFile(...)` instead of direct `os.WriteFile(...)`. - **Focused coverage for atomic behavior** - Added `pkg/util/fileutil/fileutil_test.go` with tests for: - Successful atomic write (target file contains expected payload and no leftover `.tmp` file). - Rename-failure path cleanup (temp file is removed). ```go func WriteWaveHomeConfigFile(fileName string, m waveobj.MetaMapType) error { configWriteLock.Lock() defer configWriteLock.Unlock() fullFileName := filepath.Join(wavebase.GetWaveConfigDir(), fileName) barr, err := jsonMarshalConfigInOrder(m) if err != nil { return err } return fileutil.AtomicWriteFile(fullFileName, barr, 0644) } ``` <!-- START COPILOT CODING AGENT TIPS --> --- 🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
2026-02-27 23:43:37 +00:00
configWriteLock.Lock()
defer configWriteLock.Unlock()
configDirAbsPath := wavebase.GetWaveConfigDir()
2024-08-28 01:49:49 +00:00
fullFileName := filepath.Join(configDirAbsPath, fileName)
barr, err := jsonMarshalConfigInOrder(m)
if err != nil {
return err
}
Make Wave home config writes atomic and serialized to avoid watcher partial reads (#2945) `WriteWaveHomeConfigFile()` previously used direct `os.WriteFile`, which can expose truncation/partial-write states to the JSON file watcher. This change switches config persistence to temp-file + rename semantics and serializes writes through a single process-wide lock for config file writes. - **Atomic file write helper** - Added `AtomicWriteFile()` in `pkg/util/fileutil/fileutil.go`. - Writes to `<filename>.tmp` in the same directory, then renames to the target path. - Performs temp-file cleanup on error paths. - Introduced a shared suffix constant (`TempFileSuffix`) used by implementation/tests. - **Config write path update** - Updated `WriteWaveHomeConfigFile()` in `pkg/wconfig/settingsconfig.go` to: - Use a package-level mutex (`configWriteLock`) so only one config write runs at a time (across all config files). - Call `fileutil.AtomicWriteFile(...)` instead of direct `os.WriteFile(...)`. - **Focused coverage for atomic behavior** - Added `pkg/util/fileutil/fileutil_test.go` with tests for: - Successful atomic write (target file contains expected payload and no leftover `.tmp` file). - Rename-failure path cleanup (temp file is removed). ```go func WriteWaveHomeConfigFile(fileName string, m waveobj.MetaMapType) error { configWriteLock.Lock() defer configWriteLock.Unlock() fullFileName := filepath.Join(wavebase.GetWaveConfigDir(), fileName) barr, err := jsonMarshalConfigInOrder(m) if err != nil { return err } return fileutil.AtomicWriteFile(fullFileName, barr, 0644) } ``` <!-- START COPILOT CODING AGENT TIPS --> --- 🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
2026-02-27 23:43:37 +00:00
return fileutil.AtomicWriteFile(fullFileName, barr, 0644)
}
2024-08-28 01:49:49 +00:00
// simple merge that overwrites
func mergeMetaMapSimple(m waveobj.MetaMapType, toMerge waveobj.MetaMapType) waveobj.MetaMapType {
if m == nil {
return toMerge
}
if toMerge == nil {
return m
}
for k, v := range toMerge {
if v == nil {
delete(m, k)
continue
}
m[k] = v
}
if len(m) == 0 {
return nil
}
return m
2024-07-31 06:22:41 +00:00
}
func mergeMetaMap(m waveobj.MetaMapType, toMerge waveobj.MetaMapType, simpleMerge bool) waveobj.MetaMapType {
2024-08-28 01:49:49 +00:00
if simpleMerge {
return mergeMetaMapSimple(m, toMerge)
2024-08-28 01:49:49 +00:00
} else {
return waveobj.MergeMeta(m, toMerge, true)
}
}
func selectDirEntsBySuffix(dirEnts []fs.DirEntry, fileNameSuffix string) []fs.DirEntry {
var rtn []fs.DirEntry
for _, ent := range dirEnts {
if ent.IsDir() {
continue
}
if !strings.HasSuffix(ent.Name(), fileNameSuffix) {
continue
}
rtn = append(rtn, ent)
2024-08-28 01:49:49 +00:00
}
return rtn
}
func SortFileNameDescend(files []fs.DirEntry) {
sort.Slice(files, func(i, j int) bool {
return files[i].Name() > files[j].Name()
})
}
// Read and merge all files in the specified directory matching the supplied suffix
func readConfigFilesForDir(fsys fs.FS, logPrefix string, dirName string, fileName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) {
dirEnts, _ := fs.ReadDir(fsys, dirName)
suffixEnts := selectDirEntsBySuffix(dirEnts, fileName+".json")
SortFileNameDescend(suffixEnts)
var rtn waveobj.MetaMapType
var errs []ConfigError
for _, ent := range suffixEnts {
fileVal, cerrs := readConfigFileFS(fsys, logPrefix, filepath.Join(dirName, ent.Name()))
rtn = mergeMetaMap(rtn, fileVal, simpleMerge)
errs = append(errs, cerrs...)
}
return rtn, errs
}
// Read and merge all files in the specified config filesystem matching the patterns `<partName>.json` and `<partName>/*.json`
func readConfigPartForFS(fsys fs.FS, logPrefix string, partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) {
config, errs := readConfigFilesForDir(fsys, logPrefix, partName, "", simpleMerge)
allErrs := errs
rtn := config
config, errs = readConfigFileFS(fsys, logPrefix, partName+".json")
allErrs = append(allErrs, errs...)
return mergeMetaMap(rtn, config, simpleMerge), allErrs
}
// Combine files from the defaults and home directory for the specified config part name
func readConfigPart(partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) {
configDirAbsPath := wavebase.GetWaveConfigDir()
configDirFsys := os.DirFS(configDirAbsPath)
defaultConfigs, cerrs := readConfigPartForFS(defaultconfig.ConfigFS, "defaults:", partName, simpleMerge)
homeConfigs, cerrs1 := readConfigPartForFS(configDirFsys, "", partName, simpleMerge)
rtn := defaultConfigs
allErrs := append(cerrs, cerrs1...)
return mergeMetaMap(rtn, homeConfigs, simpleMerge), allErrs
2024-07-31 06:22:41 +00:00
}
// this function should only be called by the wconfig code.
// in golang code, the best way to get the current config is via the watcher -- wconfig.GetWatcher().GetFullConfig()
2024-08-28 01:49:49 +00:00
func ReadFullConfig() FullConfigType {
var fullConfig FullConfigType
configRType := reflect.TypeOf(fullConfig)
configRVal := reflect.ValueOf(&fullConfig).Elem()
for fieldIdx := 0; fieldIdx < configRType.NumField(); fieldIdx++ {
field := configRType.Field(fieldIdx)
if field.PkgPath != "" {
continue
}
configFile := field.Tag.Get("configfile")
if configFile == "-" {
continue
}
2024-08-28 05:02:21 +00:00
jsonTag := utilfn.GetJsonTag(field)
simpleMerge := field.Tag.Get("merge") == ""
var configPart waveobj.MetaMapType
var errs []ConfigError
2024-08-28 01:49:49 +00:00
if jsonTag == "-" || jsonTag == "" {
continue
} else {
configPart, errs = readConfigPart(jsonTag, simpleMerge)
2024-08-28 01:49:49 +00:00
}
fullConfig.ConfigErrors = append(fullConfig.ConfigErrors, errs...)
2024-08-28 01:49:49 +00:00
if configPart != nil {
fieldPtr := configRVal.Field(fieldIdx).Addr().Interface()
utilfn.ReUnmarshal(fieldPtr, configPart)
}
}
return fullConfig
2024-07-31 06:22:41 +00:00
}
func GetConfigSubdirs() []string {
var fullConfig FullConfigType
configRType := reflect.TypeOf(fullConfig)
var retVal []string
configDirAbsPath := wavebase.GetWaveConfigDir()
for fieldIdx := 0; fieldIdx < configRType.NumField(); fieldIdx++ {
field := configRType.Field(fieldIdx)
if field.PkgPath != "" {
continue
}
configFile := field.Tag.Get("configfile")
if configFile == "-" {
continue
}
jsonTag := utilfn.GetJsonTag(field)
if jsonTag != "-" && jsonTag != "" && jsonTag != "settings" {
retVal = append(retVal, filepath.Join(configDirAbsPath, jsonTag))
}
}
log.Printf("subdirs: %v\n", retVal)
return retVal
}
2024-08-28 01:49:49 +00:00
func getConfigKeyType(configKey string) reflect.Type {
ctype := reflect.TypeOf(SettingsType{})
for i := 0; i < ctype.NumField(); i++ {
field := ctype.Field(i)
2024-08-28 05:02:21 +00:00
jsonTag := utilfn.GetJsonTag(field)
if jsonTag == configKey {
2024-08-28 01:49:49 +00:00
return field.Type
}
}
return nil
2024-07-31 06:22:41 +00:00
}
2024-08-28 01:49:49 +00:00
func getConfigKeyNamespace(key string) string {
colonIdx := strings.Index(key, ":")
if colonIdx == -1 {
return ""
}
return key[:colonIdx]
2024-07-31 06:22:41 +00:00
}
2024-08-28 01:49:49 +00:00
func orderConfigKeys(m waveobj.MetaMapType) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
2024-08-28 01:49:49 +00:00
sort.Slice(keys, func(i, j int) bool {
k1 := keys[i]
k2 := keys[j]
k1ns := getConfigKeyNamespace(k1)
k2ns := getConfigKeyNamespace(k2)
if k1ns != k2ns {
return k1ns < k2ns
}
2024-08-28 01:49:49 +00:00
return k1 < k2
})
return keys
}
func reindentJson(barr []byte, indentStr string) []byte {
if len(barr) < 2 {
return barr
}
2024-08-28 01:49:49 +00:00
if barr[0] != '{' && barr[0] != '[' {
return barr
}
2024-11-28 00:52:00 +00:00
if !bytes.Contains(barr, []byte("\n")) {
2024-08-28 01:49:49 +00:00
return barr
}
2024-08-28 01:49:49 +00:00
outputLines := bytes.Split(barr, []byte("\n"))
for i, line := range outputLines {
2024-11-28 00:52:00 +00:00
if i == 0 {
2024-08-28 01:49:49 +00:00
continue
}
outputLines[i] = append([]byte(indentStr), line...)
}
2024-08-28 01:49:49 +00:00
return bytes.Join(outputLines, []byte("\n"))
}
func jsonMarshalConfigInOrder(m waveobj.MetaMapType) ([]byte, error) {
if len(m) == 0 {
return []byte("{}"), nil
2024-07-31 06:22:41 +00:00
}
2024-08-28 01:49:49 +00:00
var buf bytes.Buffer
orderedKeys := orderConfigKeys(m)
buf.WriteString("{\n")
for idx, key := range orderedKeys {
val := m[key]
keyBarr, err := json.Marshal(key)
if err != nil {
return nil, err
}
valBarr, err := json.MarshalIndent(val, "", " ")
if err != nil {
return nil, err
}
valBarr = reindentJson(valBarr, " ")
buf.WriteString(" ")
buf.Write(keyBarr)
buf.WriteString(": ")
buf.Write(valBarr)
if idx < len(orderedKeys)-1 {
buf.WriteString(",")
}
buf.WriteString("\n")
2024-07-31 06:22:41 +00:00
}
2024-08-28 01:49:49 +00:00
buf.WriteString("}")
return buf.Bytes(), nil
}
var dummyNumber json.Number
func convertJsonNumber(num json.Number, ctype reflect.Type) (interface{}, error) {
// ctype might be int64, float64, string, *int64, *float64, *string
// switch on ctype first
if ctype.Kind() == reflect.Pointer {
ctype = ctype.Elem()
}
if reflect.Int64 == ctype.Kind() {
if ival, err := num.Int64(); err == nil {
return ival, nil
}
return nil, fmt.Errorf("invalid number for int64: %s", num)
}
if reflect.Float64 == ctype.Kind() {
if fval, err := num.Float64(); err == nil {
return fval, nil
}
return nil, fmt.Errorf("invalid number for float64: %s", num)
}
if reflect.String == ctype.Kind() {
return num.String(), nil
}
return nil, fmt.Errorf("cannot convert number to %s", ctype)
}
2024-08-28 05:02:21 +00:00
func SetBaseConfigValue(toMerge waveobj.MetaMapType) error {
2024-08-28 01:49:49 +00:00
m, cerrs := ReadWaveHomeConfigFile(SettingsFile)
if len(cerrs) > 0 {
return fmt.Errorf("error reading config file: %v", cerrs[0])
2024-07-31 06:22:41 +00:00
}
2024-08-28 01:49:49 +00:00
if m == nil {
m = make(waveobj.MetaMapType)
2024-07-31 06:22:41 +00:00
}
2024-08-28 05:02:21 +00:00
for configKey, val := range toMerge {
ctype := getConfigKeyType(configKey)
if ctype == nil {
return fmt.Errorf("invalid config key: %s", configKey)
}
if val == nil {
delete(m, configKey)
} else {
rtype := reflect.TypeOf(val)
if rtype == reflect.TypeOf(dummyNumber) {
convertedVal, err := convertJsonNumber(val.(json.Number), ctype)
if err != nil {
return fmt.Errorf("cannot convert %s: %v", configKey, err)
}
val = convertedVal
rtype = reflect.TypeOf(val)
}
if rtype != ctype {
if ctype == reflect.PointerTo(rtype) {
m[configKey] = &val
} else {
return fmt.Errorf("invalid value type for %s: %T", configKey, val)
}
2024-08-28 05:02:21 +00:00
}
m[configKey] = val
2024-08-28 01:49:49 +00:00
}
2024-07-31 06:22:41 +00:00
}
2024-08-28 01:49:49 +00:00
return WriteWaveHomeConfigFile(SettingsFile, m)
}
2024-11-28 00:52:00 +00:00
func SetConnectionsConfigValue(connName string, toMerge waveobj.MetaMapType) error {
m, cerrs := ReadWaveHomeConfigFile(ConnectionsFile)
if len(cerrs) > 0 {
return fmt.Errorf("error reading config file: %v", cerrs[0])
}
if m == nil {
m = make(waveobj.MetaMapType)
}
connData := m.GetMap(connName)
if connData == nil {
connData = make(waveobj.MetaMapType)
}
for configKey, val := range toMerge {
connData[configKey] = val
}
m[connName] = connData
return WriteWaveHomeConfigFile(ConnectionsFile, m)
}
2024-08-28 01:49:49 +00:00
type WidgetConfigType struct {
DisplayOrder float64 `json:"display:order,omitempty"`
DisplayHidden bool `json:"display:hidden,omitempty"`
Icon string `json:"icon,omitempty"`
Color string `json:"color,omitempty"`
Label string `json:"label,omitempty"`
Description string `json:"description,omitempty"`
Add optional per-workspace widget visibility via `workspaces` in `widgets.json` (#2898) This change adds an optional `workspaces` field to widget config entries so widgets can be scoped to specific workspace UUIDs. Widgets with no `workspaces` field (or an empty array) continue to render globally, preserving current `widgets.json` behavior. - **Config model updates** - Added `workspaces []string` to `wconfig.WidgetConfigType` with `json:"workspaces,omitempty"`. - Updated frontend generated type `WidgetConfigType` with `workspaces?: string[]`. - **Sidebar widget filtering** - `frontend/app/workspace/widgets.tsx` now applies workspace-aware filtering when building the sidebar widget list: - include if `workspaces` is missing or empty - include if current `workspace.oid` is present in `workspaces` - otherwise exclude - Existing `defwidget@ai` filtering logic remains intact. - **Isolated filtering logic + coverage** - Added `frontend/app/workspace/widgetfilter.ts` with: - `shouldIncludeWidgetForWorkspace(widget, workspaceId)` - Added focused tests in `frontend/app/workspace/widgetfilter.test.ts` for: - missing/empty `workspaces` - matching/non-matching workspace IDs - missing active workspace ID ```ts function shouldIncludeWidgetForWorkspace(widget: WidgetConfigType, workspaceId?: string): boolean { const workspaces = widget.workspaces; return !Array.isArray(workspaces) || workspaces.length === 0 || (workspaceId != null && workspaces.includes(workspaceId)); } ``` - **Screenshot** - <screenshot>https://github.com/user-attachments/assets/b1727003-793b-4eff-8fc1-00eac9c50b83</screenshot> <!-- START COPILOT CODING AGENT TIPS --> --- 🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> Co-authored-by: sawka <mike@commandline.dev>
2026-02-20 00:14:22 +00:00
Workspaces []string `json:"workspaces,omitempty"`
Magnified bool `json:"magnified,omitempty"`
BlockDef waveobj.BlockDef `json:"blockdef"`
2024-08-28 01:49:49 +00:00
}
type BgPresetsType struct {
BgClear bool `json:"bg:*,omitempty"`
Bg string `json:"bg,omitempty" jsonschema_description:"CSS background property value"`
BgOpacity float64 `json:"bg:opacity,omitempty" jsonschema_description:"Background opacity (0.0-1.0)"`
BgBlendMode string `json:"bg:blendmode,omitempty" jsonschema_description:"CSS background-blend-mode property value"`
BgBorderColor string `json:"bg:bordercolor,omitempty" jsonschema_description:"Block frame border color"`
BgActiveBorderColor string `json:"bg:activebordercolor,omitempty" jsonschema_description:"Block frame focused border color"`
DisplayName string `json:"display:name,omitempty" jsonschema_description:"The name shown in the context menu"`
DisplayOrder float64 `json:"display:order,omitempty" jsonschema_description:"Determines the order of the background in the context menu"`
}
2024-08-28 01:49:49 +00:00
type MimeTypeConfigType struct {
Icon string `json:"icon"`
Color string `json:"color"`
}
type TermThemeType struct {
DisplayName string `json:"display:name"`
DisplayOrder float64 `json:"display:order"`
Black string `json:"black"`
Red string `json:"red"`
Green string `json:"green"`
Yellow string `json:"yellow"`
Blue string `json:"blue"`
Magenta string `json:"magenta"`
Cyan string `json:"cyan"`
White string `json:"white"`
BrightBlack string `json:"brightBlack"`
BrightRed string `json:"brightRed"`
BrightGreen string `json:"brightGreen"`
BrightYellow string `json:"brightYellow"`
BrightBlue string `json:"brightBlue"`
BrightMagenta string `json:"brightMagenta"`
BrightCyan string `json:"brightCyan"`
BrightWhite string `json:"brightWhite"`
Gray string `json:"gray"`
CmdText string `json:"cmdtext"`
Foreground string `json:"foreground"`
SelectionBackground string `json:"selectionBackground"`
Background string `json:"background"`
Cursor string `json:"cursor"`
}
2025-08-26 23:08:39 +00:00
// CountCustomWidgets returns the number of custom widgets the user has defined.
// Custom widgets are identified as widgets whose ID doesn't start with "defwidget@".
func (fc *FullConfigType) CountCustomWidgets() int {
count := 0
for widgetID := range fc.Widgets {
if !strings.HasPrefix(widgetID, "defwidget@") {
count++
}
}
return count
}
// CountCustomAIPresets returns the number of custom AI presets the user has defined.
// Custom AI presets are identified as presets that start with "ai@" but aren't "ai@global" or "ai@wave".
func (fc *FullConfigType) CountCustomAIPresets() int {
count := 0
for presetID := range fc.Presets {
if strings.HasPrefix(presetID, "ai@") && presetID != "ai@global" && presetID != "ai@wave" {
count++
}
}
return count
}
// CountCustomAIModes returns the number of custom AI modes the user has defined.
// Custom AI modes are identified as modes that don't start with "waveai@".
func (fc *FullConfigType) CountCustomAIModes() int {
count := 0
for modeID := range fc.WaveAIModes {
if !strings.HasPrefix(modeID, "waveai@") {
count++
}
}
return count
}
2025-08-26 23:08:39 +00:00
// CountCustomSettings returns the number of settings in the user's settings file.
// This excludes telemetry:enabled and autoupdate:channel which don't count as customizations.
2025-08-26 23:08:39 +00:00
func CountCustomSettings() int {
// Load user settings
userSettings, _ := ReadWaveHomeConfigFile("settings.json")
if userSettings == nil {
return 0
}
// Count all keys except telemetry:enabled and autoupdate:channel
2025-08-26 23:08:39 +00:00
count := 0
for key := range userSettings {
if key == "telemetry:enabled" || key == "autoupdate:channel" {
2025-08-26 23:08:39 +00:00
continue
}
count++
}
return count
}