From d6f9d951ecc9551e7400e231968b17f4d96c5bdb Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 11 Nov 2025 20:21:06 -0800 Subject: [PATCH 01/21] generate tsunami manifest --- tsunami/build/build.go | 73 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/tsunami/build/build.go b/tsunami/build/build.go index 402f92202..46df672ad 100644 --- a/tsunami/build/build.go +++ b/tsunami/build/build.go @@ -662,7 +662,13 @@ func TsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) { } // Build the Go application - if err := runGoBuild(tempDir, opts); err != nil { + outputPath, err := runGoBuild(tempDir, opts) + if err != nil { + return buildEnv, err + } + + // Generate manifest + if err := generateManifest(tempDir, outputPath, opts); err != nil { return buildEnv, err } @@ -722,34 +728,49 @@ func moveFilesBack(tempDir, originalDir string, verbose bool, oc *OutputCapture) oc.Printf("Moved tw.css back to %s", twCssDest) } + // Move manifest.json back to original directory (only if it exists) + manifestSrc := filepath.Join(tempDir, "manifest.json") + if _, err := os.Stat(manifestSrc); err == nil { + manifestDest := filepath.Join(originalDir, "manifest.json") + if err := copyFile(manifestSrc, manifestDest); err != nil { + return fmt.Errorf("failed to copy manifest.json back: %w", err) + } + if verbose { + oc.Printf("Moved manifest.json back to %s", manifestDest) + } + } + return nil } -func runGoBuild(tempDir string, opts BuildOpts) error { +func runGoBuild(tempDir string, opts BuildOpts) (string, error) { oc := opts.OutputCapture var outputPath string + var absOutputPath string if opts.OutputFile != "" { // Convert to absolute path resolved against current working directory var err error - outputPath, err = filepath.Abs(opts.OutputFile) + absOutputPath, err = filepath.Abs(opts.OutputFile) if err != nil { - return fmt.Errorf("failed to resolve output path: %w", err) + return "", fmt.Errorf("failed to resolve output path: %w", err) } + outputPath = absOutputPath } else { binDir := filepath.Join(tempDir, "bin") if err := os.MkdirAll(binDir, 0755); err != nil { - return fmt.Errorf("failed to create bin directory: %w", err) + return "", fmt.Errorf("failed to create bin directory: %w", err) } outputPath = "bin/app" + absOutputPath = filepath.Join(tempDir, "bin", "app") } goFiles, err := listGoFilesInDir(tempDir) if err != nil { - return fmt.Errorf("failed to list go files: %w", err) + return "", fmt.Errorf("failed to list go files: %w", err) } if len(goFiles) == 0 { - return fmt.Errorf("no .go files found in %s", tempDir) + return "", fmt.Errorf("no .go files found in %s", tempDir) } // Build command with explicit go files @@ -770,18 +791,44 @@ func runGoBuild(tempDir string, opts BuildOpts) error { } if err := buildCmd.Run(); err != nil { - return fmt.Errorf("compilation failed (see output for errors)") + return "", fmt.Errorf("compilation failed (see output for errors)") } if oc != nil { oc.Flush() } if opts.Verbose { - if opts.OutputFile != "" { - oc.Printf("Application built successfully at %s", outputPath) - } else { - oc.Printf("Application built successfully at %s", filepath.Join(tempDir, "bin", "app")) - } + oc.Printf("Application built successfully") + oc.Printf("[debug] Output path: %s", absOutputPath) + } + + return absOutputPath, nil +} + +func generateManifest(tempDir, exePath string, opts BuildOpts) error { + oc := opts.OutputCapture + + manifestCmd := exec.Command(exePath, "--manifest") + manifestCmd.Dir = tempDir + + if opts.Verbose { + oc.Printf("[debug] Running: %s --manifest", exePath) + oc.Printf("Generating manifest...") + } + + manifestOutput, err := manifestCmd.Output() + if err != nil { + return fmt.Errorf("manifest generation failed: %w", err) + } + + manifestPath := filepath.Join(tempDir, "manifest.json") + if err := os.WriteFile(manifestPath, manifestOutput, 0644); err != nil { + return fmt.Errorf("failed to write manifest.json: %w", err) + } + + if opts.Verbose { + oc.Printf("Manifest generated successfully") + oc.Printf("[debug] Manifest path: %s", manifestPath) } return nil From ea1627eca4ac197c317982e31e08f4a7b73c6291 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 11 Nov 2025 20:50:32 -0800 Subject: [PATCH 02/21] add accelerator, set window title, move files back --- emain/emain-menu.ts | 1 + frontend/builder/app-selection-modal.tsx | 2 ++ frontend/wave.ts | 8 ++++---- pkg/buildercontroller/buildercontroller.go | 1 + 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts index 2f4263f26..83a32b8c2 100644 --- a/emain/emain-menu.ts +++ b/emain/emain-menu.ts @@ -127,6 +127,7 @@ function makeFileMenu(numWaveWindows: number, callbacks: AppMenuCallbacks): Elec if (isDev) { fileMenu.splice(1, 0, { label: "New WaveApp Builder Window", + accelerator: unamePlatform === "darwin" ? "Command+Shift+B" : "Alt+Shift+B", click: () => fireAndForget(() => createBuilderWindow("")), }); } diff --git a/frontend/builder/app-selection-modal.tsx b/frontend/builder/app-selection-modal.tsx index efea628cc..ef1856a6f 100644 --- a/frontend/builder/app-selection-modal.tsx +++ b/frontend/builder/app-selection-modal.tsx @@ -127,6 +127,7 @@ export function AppSelectionModal() { data: { "builder:appid": appId }, }); globalStore.set(atoms.builderAppId, appId); + document.title = `WaveApp Builder (${appId})`; }; const handleCreateNew = async (appName: string) => { @@ -138,6 +139,7 @@ export function AppSelectionModal() { data: { "builder:appid": draftAppId }, }); globalStore.set(atoms.builderAppId, draftAppId); + document.title = `WaveApp Builder (${draftAppId})`; }; const isDraftApp = (appId: string) => { diff --git a/frontend/wave.ts b/frontend/wave.ts index 902001242..05eb88cae 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -248,7 +248,7 @@ async function reinitBuilder() { ); await WOS.reloadWaveObject(WOS.makeORef("client", savedBuilderInitOpts.clientId)); - document.title = `Tsunami Builder - ${savedBuilderInitOpts.appId}`; + document.title = savedBuilderInitOpts.appId ? `WaveApp Builder (${savedBuilderInitOpts.appId})` : "WaveApp Builder"; getApi().setWindowInitStatus("wave-ready"); globalStore.set(atoms.reinitVersion, globalStore.get(atoms.reinitVersion) + 1); globalStore.set(atoms.updaterStatusAtom, getApi().getUpdaterStatus()); @@ -273,7 +273,7 @@ async function initBuilder(initOpts: BuilderInitOpts) { platform ); - document.title = `Tsunami Builder - ${initOpts.appId}`; + document.title = initOpts.appId ? `WaveApp Builder (${initOpts.appId})` : "WaveApp Builder"; initGlobal({ clientId: initOpts.clientId, @@ -288,7 +288,7 @@ async function initBuilder(initOpts: BuilderInitOpts) { (window as any).globalWS = globalWS; (window as any).TabRpcClient = TabRpcClient; await loadConnStatus(); - + let appIdToUse = initOpts.appId; try { const oref = WOS.makeORef("builder", initOpts.builderId); @@ -299,7 +299,7 @@ async function initBuilder(initOpts: BuilderInitOpts) { } catch (e) { console.log("Could not load saved builder appId from rtinfo:", e); } - + globalStore.set(atoms.builderAppId, appIdToUse); const client = await WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)); diff --git a/pkg/buildercontroller/buildercontroller.go b/pkg/buildercontroller/buildercontroller.go index 040167849..f7bcd9ec9 100644 --- a/pkg/buildercontroller/buildercontroller.go +++ b/pkg/buildercontroller/buildercontroller.go @@ -248,6 +248,7 @@ func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, buil NodePath: nodePath, GoPath: goPath, OutputCapture: outputCapture, + MoveFileBack: true, }) for _, line := range outputCapture.GetLines() { From 714823ddeb372916d05b5339221006318ee89c42 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 11 Nov 2025 21:02:20 -0800 Subject: [PATCH 03/21] add methods to read manifest + secrets bindings --- pkg/waveappstore/waveappstore.go | 60 ++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/pkg/waveappstore/waveappstore.go b/pkg/waveappstore/waveappstore.go index 4f63d9e9a..1b9d258dc 100644 --- a/pkg/waveappstore/waveappstore.go +++ b/pkg/waveappstore/waveappstore.go @@ -4,6 +4,7 @@ package waveappstore import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -13,6 +14,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/tsunami/engine" ) const ( @@ -21,6 +23,9 @@ const ( MaxNamespaceLen = 30 MaxAppNameLen = 50 + + ManifestFileName = "manifest.json" + SecretBindingsFileName = "secret-bindings.json" ) var ( @@ -644,3 +649,58 @@ func RenameLocalApp(appName string, newAppName string) error { return nil } + +func ReadAppManifest(appId string) (*engine.AppManifest, error) { + if err := ValidateAppId(appId); err != nil { + return nil, fmt.Errorf("invalid appId: %w", err) + } + + appDir, err := GetAppDir(appId) + if err != nil { + return nil, err + } + + manifestPath := filepath.Join(appDir, ManifestFileName) + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", ManifestFileName, err) + } + + var manifest engine.AppManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", ManifestFileName, err) + } + + return &manifest, nil +} + +func ReadAppSecretBindings(appId string) (map[string]string, error) { + if err := ValidateAppId(appId); err != nil { + return nil, fmt.Errorf("invalid appId: %w", err) + } + + appDir, err := GetAppDir(appId) + if err != nil { + return nil, err + } + + bindingsPath := filepath.Join(appDir, SecretBindingsFileName) + data, err := os.ReadFile(bindingsPath) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]string), nil + } + return nil, fmt.Errorf("failed to read %s: %w", SecretBindingsFileName, err) + } + + var bindings map[string]string + if err := json.Unmarshal(data, &bindings); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", SecretBindingsFileName, err) + } + + if bindings == nil { + bindings = make(map[string]string) + } + + return bindings, nil +} From 5a2620c7a57e057b0f2945d54256ab249ebd3fc3 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 11 Nov 2025 21:52:49 -0800 Subject: [PATCH 04/21] write the app to the waveapps dir (bin dir) --- pkg/buildercontroller/buildercontroller.go | 54 +++++++++++----------- pkg/waveappstore/waveappstore.go | 42 +++++++++++++++++ 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/pkg/buildercontroller/buildercontroller.go b/pkg/buildercontroller/buildercontroller.go index f7bcd9ec9..17563d064 100644 --- a/pkg/buildercontroller/buildercontroller.go +++ b/pkg/buildercontroller/buildercontroller.go @@ -95,30 +95,23 @@ func DeleteController(builderId string) { if bc != nil { bc.Stop() } - - cachesDir := wavebase.GetWaveCachesDir() - builderDir := filepath.Join(cachesDir, "builder", builderId) - if err := os.RemoveAll(builderDir); err != nil { - log.Printf("failed to remove builder cache directory for %s: %v", builderId, err) - } } -func GetBuilderAppExecutablePath(builderId string, appName string) (string, error) { - cachesDir := wavebase.GetWaveCachesDir() - builderDir := filepath.Join(cachesDir, "builder", builderId) +func GetBuilderAppExecutablePath(appPath string) (string, error) { + binDir := filepath.Join(appPath, "bin") - binaryName := appName + binaryName := "app" if runtime.GOOS == "windows" { - binaryName = binaryName + ".exe" + binaryName = "app.exe" } - cachePath := filepath.Join(builderDir, binaryName) + binPath := filepath.Join(binDir, binaryName) - err := wavebase.TryMkdirs(builderDir, 0755, "builder cache directory") + err := wavebase.TryMkdirs(binDir, 0755, "app bin directory") if err != nil { - return "", fmt.Errorf("failed to create builder cache directory: %w", err) + return "", fmt.Errorf("failed to create app bin directory: %w", err) } - return cachePath, nil + return binPath, nil } func Shutdown() { @@ -132,12 +125,6 @@ func Shutdown() { for _, bc := range controllers { bc.Stop() } - - cachesDir := wavebase.GetWaveCachesDir() - builderCacheDir := filepath.Join(cachesDir, "builder") - if err := os.RemoveAll(builderCacheDir); err != nil { - log.Printf("failed to remove builder cache directory: %v", err) - } } func (bc *BuilderController) waitForBuildDone(ctx context.Context) error { @@ -208,9 +195,7 @@ func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, buil return } - appName := build.GetAppName(appPath) - - cachePath, err := GetBuilderAppExecutablePath(bc.builderId, appName) + cachePath, err := GetBuilderAppExecutablePath(appPath) if err != nil { bc.handleBuildError(fmt.Errorf("failed to get builder executable path: %w", err), resultCh) return @@ -286,7 +271,7 @@ func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, buil } } - process, err := bc.runBuilderApp(ctx, cachePath, builderEnv) + process, err := bc.runBuilderApp(ctx, appId, cachePath, builderEnv) if err != nil { bc.handleBuildError(fmt.Errorf("failed to run app: %w", err), resultCh) return @@ -309,7 +294,24 @@ func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, buil }() } -func (bc *BuilderController) runBuilderApp(ctx context.Context, appBinPath string, builderEnv map[string]string) (*BuilderProcess, error) { +func (bc *BuilderController) runBuilderApp(ctx context.Context, appId string, appBinPath string, builderEnv map[string]string) (*BuilderProcess, error) { + manifest, err := waveappstore.ReadAppManifest(appId) + if err != nil { + return nil, fmt.Errorf("failed to read app manifest: %w", err) + } + + secretEnv, err := waveappstore.BuildAppSecretEnv(appId, manifest) + if err != nil { + return nil, fmt.Errorf("failed to build secret environment: %w", err) + } + + if builderEnv == nil { + builderEnv = make(map[string]string) + } + for k, v := range secretEnv { + builderEnv[k] = v + } + cmd := exec.Command(appBinPath) cmd.Env = append(os.Environ(), "TSUNAMI_CLOSEONSTDIN=1") diff --git a/pkg/waveappstore/waveappstore.go b/pkg/waveappstore/waveappstore.go index 1b9d258dc..ba5e13487 100644 --- a/pkg/waveappstore/waveappstore.go +++ b/pkg/waveappstore/waveappstore.go @@ -11,6 +11,7 @@ import ( "regexp" "strings" + "github.com/wavetermdev/waveterm/pkg/secretstore" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wshrpc" @@ -704,3 +705,44 @@ func ReadAppSecretBindings(appId string) (map[string]string, error) { return bindings, nil } + +func BuildAppSecretEnv(appId string, manifest *engine.AppManifest) (map[string]string, error) { + if manifest == nil { + return make(map[string]string), nil + } + + bindings, err := ReadAppSecretBindings(appId) + if err != nil { + return nil, fmt.Errorf("failed to read secret bindings: %w", err) + } + + secretEnv := make(map[string]string) + + for secretName, secretMeta := range manifest.Secrets { + boundSecretName, hasBinding := bindings[secretName] + + if !secretMeta.Optional && !hasBinding { + return nil, fmt.Errorf("required secret %q is not bound", secretName) + } + + if !hasBinding { + continue + } + + secretValue, exists, err := secretstore.GetSecret(boundSecretName) + if err != nil { + return nil, fmt.Errorf("failed to get secret %q: %w", boundSecretName, err) + } + + if !exists { + if !secretMeta.Optional { + return nil, fmt.Errorf("required secret %q is bound to %q which does not exist in secret store", secretName, boundSecretName) + } + continue + } + + secretEnv[secretName] = secretValue + } + + return secretEnv, nil +} From cbd5c2a58fce0ad5dfab22c187e6af44fb727ed7 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 11 Nov 2025 22:34:15 -0800 Subject: [PATCH 05/21] add manifest and secret bindings to status --- pkg/buildercontroller/buildercontroller.go | 42 ++++++++++++++++++---- pkg/waveappstore/waveappstore.go | 7 ++-- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/pkg/buildercontroller/buildercontroller.go b/pkg/buildercontroller/buildercontroller.go index 17563d064..2cc2c107d 100644 --- a/pkg/buildercontroller/buildercontroller.go +++ b/pkg/buildercontroller/buildercontroller.go @@ -24,6 +24,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/tsunami/build" + "github.com/wavetermdev/waveterm/tsunami/engine" ) const ( @@ -300,7 +301,12 @@ func (bc *BuilderController) runBuilderApp(ctx context.Context, appId string, ap return nil, fmt.Errorf("failed to read app manifest: %w", err) } - secretEnv, err := waveappstore.BuildAppSecretEnv(appId, manifest) + secretBindings, err := waveappstore.ReadAppSecretBindings(appId) + if err != nil { + return nil, fmt.Errorf("failed to read secret bindings: %w", err) + } + + secretEnv, err := waveappstore.BuildAppSecretEnv(appId, manifest, secretBindings) if err != nil { return nil, fmt.Errorf("failed to build secret environment: %w", err) } @@ -494,13 +500,32 @@ func (bc *BuilderController) GetStatus() BuilderStatusData { defer bc.statusLock.Unlock() bc.statusVersion++ - return BuilderStatusData{ + statusData := BuilderStatusData{ Status: bc.status, Port: bc.port, ExitCode: bc.exitCode, ErrorMsg: bc.errorMsg, Version: bc.statusVersion, } + + if bc.appId != "" { + manifest, err := waveappstore.ReadAppManifest(bc.appId) + if err == nil { + statusData.Manifest = manifest + } + + secretBindings, err := waveappstore.ReadAppSecretBindings(bc.appId) + if err == nil { + statusData.SecretBindings = secretBindings + } + + if manifest != nil && secretBindings != nil { + _, err := waveappstore.BuildAppSecretEnv(bc.appId, manifest, secretBindings) + statusData.SecretBindingsComplete = (err == nil) + } + } + + return statusData } func (bc *BuilderController) GetOutput() []string { @@ -542,11 +567,14 @@ func (bc *BuilderController) publishOutputLine(line string, reset bool) { } type BuilderStatusData struct { - Status string `json:"status"` - Port int `json:"port,omitempty"` - ExitCode int `json:"exitcode,omitempty"` - ErrorMsg string `json:"errormsg,omitempty"` - Version int `json:"version"` + Status string `json:"status"` + Port int `json:"port,omitempty"` + ExitCode int `json:"exitcode,omitempty"` + ErrorMsg string `json:"errormsg,omitempty"` + Version int `json:"version"` + Manifest *engine.AppManifest `json:"manifest,omitempty"` + SecretBindings map[string]string `json:"secretbindings,omitempty"` + SecretBindingsComplete bool `json:"secretbindingscomplete"` } func exitCodeFromWaitErr(waitErr error) int { diff --git a/pkg/waveappstore/waveappstore.go b/pkg/waveappstore/waveappstore.go index ba5e13487..dce788c5f 100644 --- a/pkg/waveappstore/waveappstore.go +++ b/pkg/waveappstore/waveappstore.go @@ -706,14 +706,13 @@ func ReadAppSecretBindings(appId string) (map[string]string, error) { return bindings, nil } -func BuildAppSecretEnv(appId string, manifest *engine.AppManifest) (map[string]string, error) { +func BuildAppSecretEnv(appId string, manifest *engine.AppManifest, bindings map[string]string) (map[string]string, error) { if manifest == nil { return make(map[string]string), nil } - bindings, err := ReadAppSecretBindings(appId) - if err != nil { - return nil, fmt.Errorf("failed to read secret bindings: %w", err) + if bindings == nil { + bindings = make(map[string]string) } secretEnv := make(map[string]string) From ec3de025dbacda4ab223f39321d2c4e5ffc4e5cb Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 11 Nov 2025 22:51:26 -0800 Subject: [PATCH 06/21] update status types, make consistent --- frontend/types/gotypes.d.ts | 27 ++++++++++++++++++ pkg/buildercontroller/buildercontroller.go | 33 ++++++++++++---------- pkg/wshrpc/wshrpctypes.go | 26 +++++++++++++---- pkg/wshrpc/wshserver/wshserver.go | 9 +----- 4 files changed, 67 insertions(+), 28 deletions(-) diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 276ec4ed1..652754644 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -62,6 +62,15 @@ declare global { modtime: number; }; + // engine.AppManifest + type AppManifest = { + apptitle: string; + appshortdesc: string; + configschema: {[key: string]: any}; + dataschema: {[key: string]: any}; + secrets: {[key: string]: SecretMeta}; + }; + // waveobj.Block type Block = WaveObj & { parentoref?: string; @@ -126,6 +135,18 @@ declare global { version: number; }; + // buildercontroller.BuilderStatusData + type BuilderStatusData = { + status: string; + port?: number; + exitcode?: number; + errormsg?: string; + version: number; + manifest?: AppManifest; + secretbindings?: {[key: string]: string}; + secretbindingscomplete: boolean; + }; + // waveobj.Client type Client = WaveObj & { windowids: string[]; @@ -950,6 +971,12 @@ declare global { winsize?: WinSize; }; + // engine.SecretMeta + type SecretMeta = { + desc: string; + optional: boolean; + }; + // webcmd.SetBlockTermSizeWSCommand type SetBlockTermSizeWSCommand = { wscommand: "setblocktermsize"; diff --git a/pkg/buildercontroller/buildercontroller.go b/pkg/buildercontroller/buildercontroller.go index 2cc2c107d..2394a3131 100644 --- a/pkg/buildercontroller/buildercontroller.go +++ b/pkg/buildercontroller/buildercontroller.go @@ -23,8 +23,8 @@ import ( "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/tsunami/build" - "github.com/wavetermdev/waveterm/tsunami/engine" ) const ( @@ -495,12 +495,12 @@ func (bc *BuilderController) stopProcess_nolock() { bc.process = nil } -func (bc *BuilderController) GetStatus() BuilderStatusData { +func (bc *BuilderController) GetStatus() wshrpc.BuilderStatusData { bc.statusLock.Lock() defer bc.statusLock.Unlock() bc.statusVersion++ - statusData := BuilderStatusData{ + statusData := wshrpc.BuilderStatusData{ Status: bc.status, Port: bc.port, ExitCode: bc.exitCode, @@ -510,8 +510,21 @@ func (bc *BuilderController) GetStatus() BuilderStatusData { if bc.appId != "" { manifest, err := waveappstore.ReadAppManifest(bc.appId) - if err == nil { - statusData.Manifest = manifest + if err == nil && manifest != nil { + wshrpcManifest := &wshrpc.AppManifest{ + AppTitle: manifest.AppTitle, + AppShortDesc: manifest.AppShortDesc, + ConfigSchema: manifest.ConfigSchema, + DataSchema: manifest.DataSchema, + Secrets: make(map[string]wshrpc.SecretMeta), + } + for k, v := range manifest.Secrets { + wshrpcManifest.Secrets[k] = wshrpc.SecretMeta{ + Desc: v.Desc, + Optional: v.Optional, + } + } + statusData.Manifest = wshrpcManifest } secretBindings, err := waveappstore.ReadAppSecretBindings(bc.appId) @@ -566,16 +579,6 @@ func (bc *BuilderController) publishOutputLine(line string, reset bool) { }) } -type BuilderStatusData struct { - Status string `json:"status"` - Port int `json:"port,omitempty"` - ExitCode int `json:"exitcode,omitempty"` - ErrorMsg string `json:"errormsg,omitempty"` - Version int `json:"version"` - Manifest *engine.AppManifest `json:"manifest,omitempty"` - SecretBindings map[string]string `json:"secretbindings,omitempty"` - SecretBindingsComplete bool `json:"secretbindingscomplete"` -} func exitCodeFromWaitErr(waitErr error) int { if waitErr == nil { diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index e7d2e4665..f3f06ff82 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -1029,12 +1029,28 @@ type RestartBuilderAndWaitResult struct { BuildOutput string `json:"buildoutput"` } +type SecretMeta struct { + Desc string `json:"desc"` + Optional bool `json:"optional"` +} + +type AppManifest struct { + AppTitle string `json:"apptitle"` + AppShortDesc string `json:"appshortdesc"` + ConfigSchema map[string]any `json:"configschema"` + DataSchema map[string]any `json:"dataschema"` + Secrets map[string]SecretMeta `json:"secrets"` +} + type BuilderStatusData struct { - Status string `json:"status"` - Port int `json:"port,omitempty"` - ExitCode int `json:"exitcode,omitempty"` - ErrorMsg string `json:"errormsg,omitempty"` - Version int `json:"version"` + Status string `json:"status"` + Port int `json:"port,omitempty"` + ExitCode int `json:"exitcode,omitempty"` + ErrorMsg string `json:"errormsg,omitempty"` + Version int `json:"version"` + Manifest *AppManifest `json:"manifest,omitempty"` + SecretBindings map[string]string `json:"secretbindings,omitempty"` + SecretBindingsComplete bool `json:"secretbindingscomplete"` } type CommandCheckGoVersionRtnData struct { diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index d493948e7..073074bfb 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -1079,20 +1079,13 @@ func (ws *WshServer) RestartBuilderAndWaitCommand(ctx context.Context, data wshr }, nil } - func (ws *WshServer) GetBuilderStatusCommand(ctx context.Context, builderId string) (*wshrpc.BuilderStatusData, error) { if builderId == "" { return nil, fmt.Errorf("must provide a builderId to GetBuilderStatusCommand") } bc := buildercontroller.GetOrCreateController(builderId) status := bc.GetStatus() - return &wshrpc.BuilderStatusData{ - Status: status.Status, - Port: status.Port, - ExitCode: status.ExitCode, - ErrorMsg: status.ErrorMsg, - Version: status.Version, - }, nil + return &status, nil } func (ws *WshServer) GetBuilderOutputCommand(ctx context.Context, builderId string) ([]string, error) { From b7367115a28daebf2101a33c748ca3d7a6b204e8 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 11 Nov 2025 22:54:57 -0800 Subject: [PATCH 07/21] move restart app button --- frontend/builder/builder-apppanel.tsx | 8 ------- frontend/builder/builder-buildpanel.tsx | 31 ++++++++++++++++++------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index ba940ef27..0f8eb103f 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -217,14 +217,6 @@ const BuilderAppPanel = memo(() => { onClick={() => handleTabClick("env")} /> - {activeTab === "preview" && ( - - )} {activeTab === "code" && ( +
Date: Tue, 11 Nov 2025 22:58:45 -0800
Subject: [PATCH 08/21] fix ask AI to Fix button styling

---
 .roo/rules/rules.md                          | 1 +
 frontend/builder/tabs/builder-previewtab.tsx | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md
index d455e6927..6de67ed5c 100644
--- a/.roo/rules/rules.md
+++ b/.roo/rules/rules.md
@@ -50,6 +50,7 @@ It has a TypeScript/React frontend and a Go backend. They talk together over `ws
 - We use **Tailwind v4** to style. Custom stuff is defined in frontend/tailwindsetup.css
 - _never_ use cursor-help, or cursor-not-allowed (it looks terrible)
 - We have custom CSS setup as well, so it is a hybrid system. For new code we prefer tailwind, and are working to migrate code to all use tailwind.
+- For accent buttons, use "bg-accent/80 text-primary rounded hover:bg-accent transition-colors cursor-pointer" (if you do "bg-accent hover:bg-accent/80" it looks weird as on hover the button gets darker instead of lighter)
 
 ### RPC System
 
diff --git a/frontend/builder/tabs/builder-previewtab.tsx b/frontend/builder/tabs/builder-previewtab.tsx
index b1f586679..61f8157a0 100644
--- a/frontend/builder/tabs/builder-previewtab.tsx
+++ b/frontend/builder/tabs/builder-previewtab.tsx
@@ -73,7 +73,7 @@ const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => {
                             
                             

From 51db4742f78bc9049ebbfb7a18b33ff6b1f3823f Mon Sep 17 00:00:00 2001
From: sawka 
Date: Tue, 11 Nov 2025 23:02:03 -0800
Subject: [PATCH 09/21] remove blank lines

---
 frontend/builder/builder-buildpanel.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/frontend/builder/builder-buildpanel.tsx b/frontend/builder/builder-buildpanel.tsx
index bd1a441fe..b080cdb74 100644
--- a/frontend/builder/builder-buildpanel.tsx
+++ b/frontend/builder/builder-buildpanel.tsx
@@ -82,7 +82,7 @@ const BuilderBuildPanel = memo(() => {
         BuilderAppPanelModel.getInstance().restartBuilder();
     }, []);
 
-    const filteredLines = showDebug ? outputLines : outputLines.filter((line) => !line.startsWith("[debug]"));
+    const filteredLines = showDebug ? outputLines : outputLines.filter((line) => !line.startsWith("[debug]") && line.trim().length > 0);
 
     return (
         
From 65a18c72c39b70db67afe630e77b39a2a6bb4038 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 11 Nov 2025 23:16:46 -0800 Subject: [PATCH 10/21] rpc to write secret-bindings.json --- frontend/app/store/wshclientapi.ts | 5 +++ frontend/types/gotypes.d.ts | 19 +++++------ pkg/waveappstore/waveappstore.go | 27 +++++++++++++++ pkg/wshrpc/wshclient/wshclient.go | 6 ++++ pkg/wshrpc/wshrpctypes.go | 55 +++++++++++++++++------------- pkg/wshrpc/wshserver/wshserver.go | 7 ++++ 6 files changed, 84 insertions(+), 35 deletions(-) diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 3925ddd5e..781fcfba0 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -622,6 +622,11 @@ class RpcApiType { return client.wshRpcCall("writeappfile", data, opts); } + // command "writeappsecretbindings" [call] + WriteAppSecretBindingsCommand(client: WshClient, data: CommandWriteAppSecretBindingsData, opts?: RpcOpts): Promise { + return client.wshRpcCall("writeappsecretbindings", data, opts); + } + // command "writetempfile" [call] WriteTempFileCommand(client: WshClient, data: CommandWriteTempFileData, opts?: RpcOpts): Promise { return client.wshRpcCall("writetempfile", data, opts); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 652754644..d39c70a06 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -62,7 +62,7 @@ declare global { modtime: number; }; - // engine.AppManifest + // wshrpc.AppManifest type AppManifest = { apptitle: string; appshortdesc: string; @@ -127,15 +127,6 @@ declare global { }; // wshrpc.BuilderStatusData - type BuilderStatusData = { - status: string; - port?: number; - exitcode?: number; - errormsg?: string; - version: number; - }; - - // buildercontroller.BuilderStatusData type BuilderStatusData = { status: string; port?: number; @@ -503,6 +494,12 @@ declare global { data64: string; }; + // wshrpc.CommandWriteAppSecretBindingsData + type CommandWriteAppSecretBindingsData = { + appid: string; + bindings: {[key: string]: string}; + }; + // wshrpc.CommandWriteTempFileData type CommandWriteTempFileData = { filename: string; @@ -971,7 +968,7 @@ declare global { winsize?: WinSize; }; - // engine.SecretMeta + // wshrpc.SecretMeta type SecretMeta = { desc: string; optional: boolean; diff --git a/pkg/waveappstore/waveappstore.go b/pkg/waveappstore/waveappstore.go index dce788c5f..777cab7d6 100644 --- a/pkg/waveappstore/waveappstore.go +++ b/pkg/waveappstore/waveappstore.go @@ -706,6 +706,33 @@ func ReadAppSecretBindings(appId string) (map[string]string, error) { return bindings, nil } +func WriteAppSecretBindings(appId string, bindings map[string]string) error { + if err := ValidateAppId(appId); err != nil { + return fmt.Errorf("invalid appId: %w", err) + } + + appDir, err := GetAppDir(appId) + if err != nil { + return err + } + + if bindings == nil { + bindings = make(map[string]string) + } + + data, err := json.MarshalIndent(bindings, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal bindings: %w", err) + } + + bindingsPath := filepath.Join(appDir, SecretBindingsFileName) + if err := os.WriteFile(bindingsPath, data, 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", SecretBindingsFileName, err) + } + + return nil +} + func BuildAppSecretEnv(appId string, manifest *engine.AppManifest, bindings map[string]string) (map[string]string, error) { if manifest == nil { return make(map[string]string), nil diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index c92a33055..3f3693170 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -743,6 +743,12 @@ func WriteAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppFileData, return err } +// command "writeappsecretbindings", wshserver.WriteAppSecretBindingsCommand +func WriteAppSecretBindingsCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppSecretBindingsData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "writeappsecretbindings", data, opts) + return err +} + // command "writetempfile", wshserver.WriteTempFileCommand func WriteTempFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteTempFileData, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "writetempfile", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index f3f06ff82..4db4d515a 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -157,18 +157,19 @@ const ( Command_TermGetScrollbackLines = "termgetscrollbacklines" // builder - Command_ListAllEditableApps = "listalleditableapps" - Command_ListAllAppFiles = "listallappfiles" - Command_ReadAppFile = "readappfile" - Command_WriteAppFile = "writeappfile" - Command_DeleteAppFile = "deleteappfile" - Command_RenameAppFile = "renameappfile" - Command_DeleteBuilder = "deletebuilder" - Command_StartBuilder = "startbuilder" - Command_RestartBuilderAndWait = "restartbuilderandwait" - Command_GetBuilderStatus = "getbuilderstatus" - Command_GetBuilderOutput = "getbuilderoutput" - Command_CheckGoVersion = "checkgoversion" + Command_ListAllEditableApps = "listalleditableapps" + Command_ListAllAppFiles = "listallappfiles" + Command_ReadAppFile = "readappfile" + Command_WriteAppFile = "writeappfile" + Command_DeleteAppFile = "deleteappfile" + Command_RenameAppFile = "renameappfile" + Command_WriteAppSecretBindings = "writeappsecretbindings" + Command_DeleteBuilder = "deletebuilder" + Command_StartBuilder = "startbuilder" + Command_RestartBuilderAndWait = "restartbuilderandwait" + Command_GetBuilderStatus = "getbuilderstatus" + Command_GetBuilderOutput = "getbuilderoutput" + Command_CheckGoVersion = "checkgoversion" // electron Command_ElectronEncrypt = "electronencrypt" @@ -333,6 +334,7 @@ type WshRpcInterface interface { WriteAppFileCommand(ctx context.Context, data CommandWriteAppFileData) error DeleteAppFileCommand(ctx context.Context, data CommandDeleteAppFileData) error RenameAppFileCommand(ctx context.Context, data CommandRenameAppFileData) error + WriteAppSecretBindingsCommand(ctx context.Context, data CommandWriteAppSecretBindingsData) error DeleteBuilderCommand(ctx context.Context, builderId string) error StartBuilderCommand(ctx context.Context, data CommandStartBuilderData) error RestartBuilderAndWaitCommand(ctx context.Context, data CommandRestartBuilderAndWaitData) (*RestartBuilderAndWaitResult, error) @@ -1015,6 +1017,11 @@ type CommandRenameAppFileData struct { ToFileName string `json:"tofilename"` } +type CommandWriteAppSecretBindingsData struct { + AppId string `json:"appid"` + Bindings map[string]string `json:"bindings"` +} + type CommandStartBuilderData struct { BuilderId string `json:"builderid"` } @@ -1035,22 +1042,22 @@ type SecretMeta struct { } type AppManifest struct { - AppTitle string `json:"apptitle"` - AppShortDesc string `json:"appshortdesc"` - ConfigSchema map[string]any `json:"configschema"` - DataSchema map[string]any `json:"dataschema"` - Secrets map[string]SecretMeta `json:"secrets"` + AppTitle string `json:"apptitle"` + AppShortDesc string `json:"appshortdesc"` + ConfigSchema map[string]any `json:"configschema"` + DataSchema map[string]any `json:"dataschema"` + Secrets map[string]SecretMeta `json:"secrets"` } type BuilderStatusData struct { - Status string `json:"status"` - Port int `json:"port,omitempty"` - ExitCode int `json:"exitcode,omitempty"` - ErrorMsg string `json:"errormsg,omitempty"` - Version int `json:"version"` - Manifest *AppManifest `json:"manifest,omitempty"` + Status string `json:"status"` + Port int `json:"port,omitempty"` + ExitCode int `json:"exitcode,omitempty"` + ErrorMsg string `json:"errormsg,omitempty"` + Version int `json:"version"` + Manifest *AppManifest `json:"manifest,omitempty"` SecretBindings map[string]string `json:"secretbindings,omitempty"` - SecretBindingsComplete bool `json:"secretbindingscomplete"` + SecretBindingsComplete bool `json:"secretbindingscomplete"` } type CommandCheckGoVersionRtnData struct { diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 073074bfb..a7a1748b7 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -1027,6 +1027,13 @@ func (ws *WshServer) RenameAppFileCommand(ctx context.Context, data wshrpc.Comma return waveappstore.RenameAppFile(data.AppId, data.FromFileName, data.ToFileName) } +func (ws *WshServer) WriteAppSecretBindingsCommand(ctx context.Context, data wshrpc.CommandWriteAppSecretBindingsData) error { + if data.AppId == "" { + return fmt.Errorf("must provide an appId to WriteAppSecretBindingsCommand") + } + return waveappstore.WriteAppSecretBindings(data.AppId, data.Bindings) +} + func (ws *WshServer) DeleteBuilderCommand(ctx context.Context, builderId string) error { if builderId == "" { return fmt.Errorf("must provide a builderId to DeleteBuilderCommand") From 4883879a7c9c09ebd40fdad13a8cc1efa689e46f Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 11 Nov 2025 23:19:46 -0800 Subject: [PATCH 11/21] add debug tags to moveFilesBack prints --- tsunami/build/build.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tsunami/build/build.go b/tsunami/build/build.go index 46df672ad..7d01b75c2 100644 --- a/tsunami/build/build.go +++ b/tsunami/build/build.go @@ -694,7 +694,7 @@ func moveFilesBack(tempDir, originalDir string, verbose bool, oc *OutputCapture) return fmt.Errorf("failed to copy go.mod back: %w", err) } if verbose { - oc.Printf("Moved go.mod back to %s", goModDest) + oc.Printf("[debug] Moved go.mod back to %s", goModDest) } // Move go.sum back to original directory (only if it exists) @@ -705,7 +705,7 @@ func moveFilesBack(tempDir, originalDir string, verbose bool, oc *OutputCapture) return fmt.Errorf("failed to copy go.sum back: %w", err) } if verbose { - oc.Printf("Moved go.sum back to %s", goSumDest) + oc.Printf("[debug] Moved go.sum back to %s", goSumDest) } } @@ -715,7 +715,7 @@ func moveFilesBack(tempDir, originalDir string, verbose bool, oc *OutputCapture) return fmt.Errorf("failed to create static directory: %w", err) } if verbose { - oc.Printf("Ensured static directory exists at %s", staticDir) + oc.Printf("[debug] Ensured static directory exists at %s", staticDir) } // Move tw.css back to original directory @@ -725,7 +725,7 @@ func moveFilesBack(tempDir, originalDir string, verbose bool, oc *OutputCapture) return fmt.Errorf("failed to copy tw.css back: %w", err) } if verbose { - oc.Printf("Moved tw.css back to %s", twCssDest) + oc.Printf("[debug] Moved tw.css back to %s", twCssDest) } // Move manifest.json back to original directory (only if it exists) @@ -736,7 +736,7 @@ func moveFilesBack(tempDir, originalDir string, verbose bool, oc *OutputCapture) return fmt.Errorf("failed to copy manifest.json back: %w", err) } if verbose { - oc.Printf("Moved manifest.json back to %s", manifestDest) + oc.Printf("[debug] Moved manifest.json back to %s", manifestDest) } } From 700b244c846a91d9d96d362346080620211c4cc9 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 12 Nov 2025 14:08:15 -0800 Subject: [PATCH 12/21] update builder tools... specifically edit to allow partial writes instead of rolling back everything --- frontend/app/aipanel/aitypes.ts | 6 ++ pkg/aiusechat/tools_builder.go | 28 ++++--- pkg/aiusechat/uctypes/usechat-types.go | 7 ++ pkg/util/fileutil/fileutil.go | 111 ++++++++++++++++++++++--- pkg/waveappstore/waveappstore.go | 18 ++++ 5 files changed, 148 insertions(+), 22 deletions(-) diff --git a/frontend/app/aipanel/aitypes.ts b/frontend/app/aipanel/aitypes.ts index 1b5d4122f..a1192ec7e 100644 --- a/frontend/app/aipanel/aitypes.ts +++ b/frontend/app/aipanel/aitypes.ts @@ -24,6 +24,12 @@ type WaveUIDataTypes = { writebackupfilename?: string; inputfilename?: string; }; + + toolprogress: { + toolcallid: string; + toolname: string; + statuslines: string[]; + }; }; export type WaveUIMessage = UIMessage; diff --git a/pkg/aiusechat/tools_builder.go b/pkg/aiusechat/tools_builder.go index 0fe117c6e..4f1107dcf 100644 --- a/pkg/aiusechat/tools_builder.go +++ b/pkg/aiusechat/tools_builder.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "log" + "strings" "time" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" @@ -91,7 +92,15 @@ func GetBuilderWriteAppFileToolDefinition(appId string, builderId string) uctype "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { - return fmt.Sprintf("writing app.go for %s", appId) + if output != nil { + params, err := parseBuilderWriteAppFileInput(input) + if err == nil { + lineCount := len(strings.Split(params.Contents, "\n")) + return fmt.Sprintf("wrote app.go (%d lines)", lineCount) + } + return "wrote app.go" + } + return "writing app.go" }, ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseBuilderWriteAppFileInput(input) @@ -154,7 +163,7 @@ func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes DisplayName: "Edit App File", Description: "Edit the app.go file for this app using precise search and replace. " + "Each old_str must appear EXACTLY ONCE in the file or the edit will fail. " + - "All edits are applied atomically - if any single edit fails, the entire operation fails and no changes are made.", + "Edits are applied sequentially - if an edit fails, all previous edits are kept and subsequent edits are skipped.", ToolLogName: "builder:edit_app", Strict: false, InputSchema: map[string]any{ @@ -162,13 +171,13 @@ func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes "properties": map[string]any{ "edits": map[string]any{ "type": "array", - "description": "Array of edit specifications. All edits are applied atomically - if any edit fails, none are applied.", + "description": "Array of edit specifications. Edits are applied sequentially - if one fails, previous edits are kept but remaining edits are skipped.", "items": map[string]any{ "type": "object", "properties": map[string]any{ "old_str": map[string]any{ "type": "string", - "description": "The exact string to find and replace. MUST appear exactly once in the file - if it appears zero times or multiple times, the entire edit operation will fail.", + "description": "The exact string to find and replace. MUST appear exactly once in the file - if it appears zero times or multiple times, this edit will fail.", }, "new_str": map[string]any{ "type": "string", @@ -176,7 +185,7 @@ func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes }, "desc": map[string]any{ "type": "string", - "description": "Description of what this edit does", + "description": "Description of what this edit does (keep short, half a line of text max)", }, }, "required": []string{"old_str", "new_str"}, @@ -196,7 +205,7 @@ func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes if numEdits == 1 { editStr = "edit" } - return fmt.Sprintf("editing app.go for %s (%d %s)", appId, numEdits, editStr) + return fmt.Sprintf("editing app.go (%d %s)", numEdits, editStr) }, ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseBuilderEditAppFileInput(input) @@ -204,7 +213,7 @@ func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes return nil, err } - err = waveappstore.ReplaceInAppFile(appId, BuilderAppFileName, params.Edits) + editResults, err := waveappstore.ReplaceInAppFilePartial(appId, BuilderAppFileName, params.Edits) if err != nil { return nil, err } @@ -215,8 +224,7 @@ func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes }) result := map[string]any{ - "success": true, - "message": fmt.Sprintf("Successfully edited %s with %d changes", BuilderAppFileName, len(params.Edits)), + "edits": editResults, } if builderId != "" { @@ -244,7 +252,7 @@ func GetBuilderListFilesToolDefinition(appId string) uctypes.ToolDefinition { "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { - return fmt.Sprintf("listing files for %s", appId) + return "listing files" }, ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { result, err := waveappstore.ListAllAppFiles(appId) diff --git a/pkg/aiusechat/uctypes/usechat-types.go b/pkg/aiusechat/uctypes/usechat-types.go index 8415fd56e..6a4a385ee 100644 --- a/pkg/aiusechat/uctypes/usechat-types.go +++ b/pkg/aiusechat/uctypes/usechat-types.go @@ -161,6 +161,13 @@ func (d *UIMessageDataToolUse) IsApproved() bool { return d.Approval == "" || d.Approval == ApprovalUserApproved || d.Approval == ApprovalAutoApproved } +// when updating this struct, also modify frontend/app/aipanel/aitypes.ts WaveUIDataTypes.toolprogress +type UIMessageDataToolProgress struct { + ToolCallId string `json:"toolcallid"` + ToolName string `json:"toolname"` + StatusLines []string `json:"statuslines"` +} + type StopReasonKind string const ( diff --git a/pkg/util/fileutil/fileutil.go b/pkg/util/fileutil/fileutil.go index f8f12ca4c..85f48077a 100644 --- a/pkg/util/fileutil/fileutil.go +++ b/pkg/util/fileutil/fileutil.go @@ -262,29 +262,86 @@ type EditSpec struct { Desc string `json:"desc,omitempty"` } +type EditResult struct { + Applied bool `json:"applied"` + Desc string `json:"desc"` + Error string `json:"error,omitempty"` +} + +// applyEdit applies a single edit to the content and returns the modified content and result. +func applyEdit(content []byte, edit EditSpec, index int) ([]byte, EditResult) { + result := EditResult{ + Desc: edit.Desc, + } + if result.Desc == "" { + result.Desc = fmt.Sprintf("Edit %d", index+1) + } + + if edit.OldStr == "" { + result.Applied = false + result.Error = "old_str cannot be empty" + return content, result + } + + oldBytes := []byte(edit.OldStr) + count := bytes.Count(content, oldBytes) + if count == 0 { + result.Applied = false + result.Error = "old_str not found in file" + return content, result + } + if count > 1 { + result.Applied = false + result.Error = fmt.Sprintf("old_str appears %d times, must appear exactly once", count) + return content, result + } + + modifiedContent := bytes.Replace(content, oldBytes, []byte(edit.NewStr), 1) + result.Applied = true + return modifiedContent, result +} + // ApplyEdits applies a series of edits to the given content and returns the modified content. -// Each edit's OldStr must appear exactly once in the content or an error is returned. +// This is atomic - all edits succeed or all fail. func ApplyEdits(originalContent []byte, edits []EditSpec) ([]byte, error) { modifiedContents := originalContent for i, edit := range edits { - if edit.OldStr == "" { - return nil, fmt.Errorf("edit %d (%s): old_str cannot be empty", i, edit.Desc) + var result EditResult + modifiedContents, result = applyEdit(modifiedContents, edit, i) + if !result.Applied { + return nil, fmt.Errorf("edit %d (%s): %s", i, result.Desc, result.Error) } + } - oldBytes := []byte(edit.OldStr) - count := bytes.Count(modifiedContents, oldBytes) - if count == 0 { - return nil, fmt.Errorf("edit %d (%s): old_str not found in file", i, edit.Desc) - } - if count > 1 { - return nil, fmt.Errorf("edit %d (%s): old_str appears %d times, must appear exactly once", i, edit.Desc, count) + return modifiedContents, nil +} + +// ApplyEditsPartial applies edits incrementally, continuing until the first failure. +// Returns the modified content (potentially partially applied) and results for each edit. +func ApplyEditsPartial(originalContent []byte, edits []EditSpec) ([]byte, []EditResult) { + modifiedContents := originalContent + results := make([]EditResult, len(edits)) + failed := false + + for i, edit := range edits { + if failed { + results[i].Desc = edit.Desc + if results[i].Desc == "" { + results[i].Desc = fmt.Sprintf("Edit %d", i+1) + } + results[i].Applied = false + results[i].Error = "previous edit failed" + continue } - modifiedContents = bytes.Replace(modifiedContents, oldBytes, []byte(edit.NewStr), 1) + modifiedContents, results[i] = applyEdit(modifiedContents, edit, i) + if !results[i].Applied { + failed = true + } } - return modifiedContents, nil + return modifiedContents, results } func ReplaceInFile(filePath string, edits []EditSpec) error { @@ -317,3 +374,33 @@ func ReplaceInFile(filePath string, edits []EditSpec) error { return nil } + +// ReplaceInFilePartial applies edits incrementally up to the first failure. +// Returns the results for each edit and writes the partially modified content. +func ReplaceInFilePartial(filePath string, edits []EditSpec) ([]EditResult, error) { + fileInfo, err := os.Stat(filePath) + if err != nil { + return nil, fmt.Errorf("failed to stat file: %w", err) + } + + if !fileInfo.Mode().IsRegular() { + return nil, fmt.Errorf("not a regular file: %s", filePath) + } + + if fileInfo.Size() > MaxEditFileSize { + return nil, fmt.Errorf("file too large for editing: %d bytes (max: %d)", fileInfo.Size(), MaxEditFileSize) + } + + contents, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + modifiedContents, results := ApplyEditsPartial(contents, edits) + + if err := os.WriteFile(filePath, modifiedContents, fileInfo.Mode()); err != nil { + return nil, fmt.Errorf("failed to write file: %w", err) + } + + return results, nil +} diff --git a/pkg/waveappstore/waveappstore.go b/pkg/waveappstore/waveappstore.go index 777cab7d6..c9af0a677 100644 --- a/pkg/waveappstore/waveappstore.go +++ b/pkg/waveappstore/waveappstore.go @@ -356,6 +356,24 @@ func ReplaceInAppFile(appId string, fileName string, edits []fileutil.EditSpec) return fileutil.ReplaceInFile(filePath, edits) } +func ReplaceInAppFilePartial(appId string, fileName string, edits []fileutil.EditSpec) ([]fileutil.EditResult, error) { + if err := ValidateAppId(appId); err != nil { + return nil, fmt.Errorf("invalid appId: %w", err) + } + + appDir, err := GetAppDir(appId) + if err != nil { + return nil, err + } + + filePath, err := validateAndResolveFilePath(appDir, fileName) + if err != nil { + return nil, err + } + + return fileutil.ReplaceInFilePartial(filePath, edits) +} + func RenameAppFile(appId string, fromFileName string, toFileName string) error { if err := ValidateAppId(appId); err != nil { return fmt.Errorf("invalid appId: %w", err) From 1438eb97d3b48394810ca3c5013ef112f3146a15 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 12 Nov 2025 14:57:10 -0800 Subject: [PATCH 13/21] decent partial json parser (for tools) --- pkg/util/utilfn/partial.go | 175 ++++++++++++++++++++++++++++++++ pkg/util/utilfn/partial_test.go | 135 ++++++++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 pkg/util/utilfn/partial.go create mode 100644 pkg/util/utilfn/partial_test.go diff --git a/pkg/util/utilfn/partial.go b/pkg/util/utilfn/partial.go new file mode 100644 index 000000000..84e204e33 --- /dev/null +++ b/pkg/util/utilfn/partial.go @@ -0,0 +1,175 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package utilfn + +import ( + "encoding/json" +) + +type stackItem int + +const ( + stackInvalid stackItem = iota + stackLBrace + stackLBrack + stackBeforeKey + stackKey + stackKeyColon + stackQuote +) + +type jsonStack []stackItem + +func (s *jsonStack) push(item stackItem) { + *s = append(*s, item) +} + +func (s *jsonStack) pop() stackItem { + if len(*s) == 0 { + return stackInvalid + } + item := (*s)[len(*s)-1] + *s = (*s)[:len(*s)-1] + return item +} + +func (s jsonStack) peek() stackItem { + if len(s) == 0 { + return stackInvalid + } + return s[len(s)-1] +} +func (s jsonStack) isTop(items ...stackItem) bool { + top := s.peek() + for _, item := range items { + if top == item { + return true + } + } + return false +} + +func (s *jsonStack) replaceTop(item stackItem) { + if len(*s) > 0 { + (*s)[len(*s)-1] = item + } +} + +func repairJson(data []byte) []byte { + if len(data) == 0 { + return data + } + + var stack jsonStack + inString := false + escaped := false + lastComma := false + + for i := 0; i < len(data); i++ { + b := data[i] + + if escaped { + escaped = false + continue + } + + if inString { + if b == '\\' { + escaped = true + continue + } + if b == '"' { + inString = false + } + continue + } + + if b == ' ' || b == '\t' || b == '\n' || b == '\r' { + continue + } + valueStart := b == '{' || b == '[' || b == 'n' || b == 't' || b == 'f' || b == '"' || (b >= '0' && b <= '9') || b == '-' + if valueStart && lastComma { + lastComma = false + } + if valueStart && stack.isTop(stackKeyColon) { + stack.pop() + } + if valueStart && stack.isTop(stackBeforeKey) { + stack.replaceTop(stackKey) + } + switch b { + case '{': + stack.push(stackLBrace) + stack.push(stackBeforeKey) + case '[': + stack.push(stackLBrack) + case '}': + if stack.isTop(stackBeforeKey) { + stack.pop() + } + if stack.isTop(stackLBrace) { + stack.pop() + } + case ']': + if stack.isTop(stackLBrack) { + stack.pop() + } + case '"': + inString = true + case ':': + if stack.isTop(stackKey) { + stack.replaceTop(stackKeyColon) + } + case ',': + lastComma = true + if stack.isTop(stackLBrace) { + stack.push(stackBeforeKey) + } + default: + } + } + + if len(stack) == 0 && !inString { + return data + } + + result := append([]byte{}, data...) + if escaped && len(result) > 0 { + result = result[:len(result)-1] + } + if inString { + result = append(result, '"') + } + if lastComma { + for i := len(result) - 1; i >= 0; i-- { + if result[i] == ',' { + result = result[:i] + break + } + } + } + for i := len(stack) - 1; i >= 0; i-- { + switch stack[i] { + case stackKeyColon: + result = append(result, []byte("null")...) + case stackKey: + result = append(result, []byte(": null")...) + case stackLBrace: + result = append(result, '}') + case stackLBrack: + result = append(result, ']') + } + } + return result +} + +func ParseParialJson(data []byte) (any, error) { + fixedData := repairJson(data) + var output any + err := json.Unmarshal(fixedData, &output) + if err != nil { + return nil, err + } + return output, nil +} diff --git a/pkg/util/utilfn/partial_test.go b/pkg/util/utilfn/partial_test.go new file mode 100644 index 000000000..a676c4de3 --- /dev/null +++ b/pkg/util/utilfn/partial_test.go @@ -0,0 +1,135 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package utilfn + +import ( + "encoding/json" + "testing" +) + +func TestRepairJson(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "open bracket", + input: "[", + expected: "[]", + }, + { + name: "empty array", + input: "[]", + expected: "[]", + }, + { + name: "unclosed string in array", + input: `["a`, + expected: `["a"]`, + }, + { + name: "unclosed array with string", + input: `["a"`, + expected: `["a"]`, + }, + { + name: "unclosed array with number", + input: `[5`, + expected: `[5]`, + }, + { + name: "array with trailing comma", + input: `["a",`, + expected: `["a"]`, + }, + { + name: "array with unclosed second string", + input: `["a","`, + expected: `["a",""]`, + }, + { + name: "unclosed array with string and number", + input: `["a",5`, + expected: `["a",5]`, + }, + { + name: "open brace", + input: "{", + expected: "{}", + }, + { + name: "empty object", + input: "{}", + expected: "{}", + }, + { + name: "unclosed key", + input: `{"a`, + expected: `{"a": null}`, + }, + { + name: "key without colon", + input: `{"a"`, + expected: `{"a": null}`, + }, + { + name: "key with colon no value", + input: `{"a": `, + expected: `{"a": null}`, + }, + { + name: "unclosed object with number value", + input: `{"a": 5`, + expected: `{"a": 5}`, + }, + { + name: "unclosed object with true", + input: `{"a": true`, + expected: `{"a": true}`, + }, + // { + // name: "unclosed object with partial value", + // input: `{"a": fa`, + // expected: `{"a": fa}`, + // }, + { + name: "object with trailing comma", + input: `{"a": true,`, + expected: `{"a": true}`, + }, + { + name: "object with unclosed second key", + input: `{"a": true, "`, + expected: `{"a": true, "": null}`, + }, + { + name: "complete object", + input: `{"a": true, "b": false}`, + expected: `{"a": true, "b": false}`, + }, + { + name: "nested incomplete", + input: `[1, {"a": true, "b`, + expected: `[1, {"a": true, "b": null}]`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := repairJson([]byte(tt.input)) + resultStr := string(result) + + if resultStr != tt.expected { + t.Errorf("repairJson() of %s = %s, expected %s", tt.input, resultStr, tt.expected) + } + + var parsed any + err := json.Unmarshal(result, &parsed) + if err != nil { + t.Errorf("repaired JSON is not valid: %v\nInput: %q\nOutput: %q", err, tt.input, resultStr) + } + }) + } +} From 27d54be16a400f5031e3fb8236f9176a016a22f0 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 12 Nov 2025 15:28:01 -0800 Subject: [PATCH 14/21] working on tool progress for builder tools, fix when we register approval, parse partial tool json --- pkg/aiusechat/openai/openai-backend.go | 37 +++++++++++++++++++++++--- pkg/aiusechat/tools_builder.go | 26 ++++++++++++++++++ pkg/aiusechat/uctypes/usechat-types.go | 1 + pkg/aiusechat/usechat.go | 3 +++ 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/pkg/aiusechat/openai/openai-backend.go b/pkg/aiusechat/openai/openai-backend.go index 34be1c872..10f3fd7c0 100644 --- a/pkg/aiusechat/openai/openai-backend.go +++ b/pkg/aiusechat/openai/openai-backend.go @@ -382,6 +382,7 @@ type openaiBlockState struct { toolCallID string // For function calls toolName string // For function calls summaryCount int // For reasoning: number of summary parts seen + partialJSON []byte // For function calls: accumulated JSON arguments } type openaiStreamingState struct { @@ -857,7 +858,25 @@ func handleOpenAIEvent( _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil } - // Noop as requested + if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockToolUse { + st.partialJSON = append(st.partialJSON, []byte(ev.Delta)...) + + toolDef := state.chatOpts.GetToolDefinition(st.toolName) + if toolDef != nil && toolDef.ToolProgressDesc != nil { + parsedJSON, err := utilfn.ParseParialJson(st.partialJSON) + if err == nil { + statusLines, err := toolDef.ToolProgressDesc(parsedJSON) + if err == nil { + progressData := &uctypes.UIMessageDataToolProgress{ + ToolCallId: st.toolCallID, + ToolName: st.toolName, + StatusLines: statusLines, + } + _ = sse.AiMsgData("data-toolprogress", "progress-"+st.toolCallID, progressData) + } + } + } + } return nil, nil case "response.function_call_arguments.done": @@ -876,8 +895,20 @@ func handleOpenAIEvent( toolDef := state.chatOpts.GetToolDefinition(st.toolName) toolUseData := createToolUseData(st.toolCallID, st.toolName, toolDef, ev.Arguments, state.chatOpts) state.toolUseData[st.toolCallID] = toolUseData - if toolUseData.Approval == uctypes.ApprovalNeedsApproval && state.chatOpts.RegisterToolApproval != nil { - state.chatOpts.RegisterToolApproval(st.toolCallID) + + if toolDef != nil && toolDef.ToolProgressDesc != nil { + var parsedJSON any + if err := json.Unmarshal([]byte(ev.Arguments), &parsedJSON); err == nil { + statusLines, err := toolDef.ToolProgressDesc(parsedJSON) + if err == nil { + progressData := &uctypes.UIMessageDataToolProgress{ + ToolCallId: st.toolCallID, + ToolName: st.toolName, + StatusLines: statusLines, + } + _ = sse.AiMsgData("data-toolprogress", "progress-"+st.toolCallID, progressData) + } + } } } return nil, nil diff --git a/pkg/aiusechat/tools_builder.go b/pkg/aiusechat/tools_builder.go index 4f1107dcf..4d751aaec 100644 --- a/pkg/aiusechat/tools_builder.go +++ b/pkg/aiusechat/tools_builder.go @@ -102,6 +102,14 @@ func GetBuilderWriteAppFileToolDefinition(appId string, builderId string) uctype } return "writing app.go" }, + ToolProgressDesc: func(input any) ([]string, error) { + params, err := parseBuilderWriteAppFileInput(input) + if err != nil { + return nil, err + } + lineCount := len(strings.Split(params.Contents, "\n")) + return []string{fmt.Sprintf("writing app.go (%d lines)", lineCount)}, nil + }, ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseBuilderWriteAppFileInput(input) if err != nil { @@ -207,6 +215,24 @@ func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes } return fmt.Sprintf("editing app.go (%d %s)", numEdits, editStr) }, + ToolProgressDesc: func(input any) ([]string, error) { + params, err := parseBuilderEditAppFileInput(input) + if err != nil { + return nil, err + } + + result := make([]string, len(params.Edits)) + for i, edit := range params.Edits { + newLines := len(strings.Split(edit.NewStr, "\n")) + oldLines := len(strings.Split(edit.OldStr, "\n")) + desc := edit.Desc + if desc == "" { + desc = "edit" + } + result[i] = fmt.Sprintf("%s (+%d -%d)", desc, newLines, oldLines) + } + return result, nil + }, ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseBuilderEditAppFileInput(input) if err != nil { diff --git a/pkg/aiusechat/uctypes/usechat-types.go b/pkg/aiusechat/uctypes/usechat-types.go index 6a4a385ee..9e7fe4a9d 100644 --- a/pkg/aiusechat/uctypes/usechat-types.go +++ b/pkg/aiusechat/uctypes/usechat-types.go @@ -91,6 +91,7 @@ type ToolDefinition struct { ToolCallDesc func(any, any, *UIMessageDataToolUse) string `json:"-"` // passed input, output (may be nil), *UIMessageDataToolUse (may be nil) ToolApproval func(any) string `json:"-"` ToolVerifyInput func(any, *UIMessageDataToolUse) error `json:"-"` // *UIMessageDataToolUse will NOT be nil + ToolProgressDesc func(any) ([]string, error) `json:"-"` } func (td *ToolDefinition) Clean() *ToolDefinition { diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index d6b866bf9..a87ebdad1 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -376,6 +376,9 @@ func processToolCalls(stopReason *uctypes.WaveStopReason, chatOpts uctypes.WaveC log.Printf("AI data-tooluse %s\n", toolCall.ID) _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData) updateToolUseDataInChat(chatOpts, toolCall.ID, toolCall.ToolUseData) + if toolCall.ToolUseData.Approval == uctypes.ApprovalNeedsApproval && chatOpts.RegisterToolApproval != nil { + chatOpts.RegisterToolApproval(toolCall.ID) + } } } From 1118094cdccb1821efadf6a3fc7ca274aaa18eb7 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 12 Nov 2025 16:48:45 -0800 Subject: [PATCH 15/21] progress data messages working! --- frontend/app/aipanel/aimessage.tsx | 9 ++-- frontend/app/aipanel/aitooluse.tsx | 77 ++++++++++++++++++++++++------ 2 files changed, 67 insertions(+), 19 deletions(-) diff --git a/frontend/app/aipanel/aimessage.tsx b/frontend/app/aipanel/aimessage.tsx index 2872d1f24..3ee034a49 100644 --- a/frontend/app/aipanel/aimessage.tsx +++ b/frontend/app/aipanel/aimessage.tsx @@ -147,21 +147,22 @@ const isDisplayPart = (part: WaveUIMessagePart): boolean => { return ( part.type === "text" || part.type === "data-tooluse" || + part.type === "data-toolprogress" || (part.type.startsWith("tool-") && "state" in part && part.state === "input-available") ); }; type MessagePart = | { type: "single"; part: WaveUIMessagePart } - | { type: "toolgroup"; parts: Array }; + | { type: "toolgroup"; parts: Array }; const groupMessageParts = (parts: WaveUIMessagePart[]): MessagePart[] => { const grouped: MessagePart[] = []; - let currentToolGroup: Array = []; + let currentToolGroup: Array = []; for (const part of parts) { - if (part.type === "data-tooluse") { - currentToolGroup.push(part as WaveUIMessagePart & { type: "data-tooluse" }); + if (part.type === "data-tooluse" || part.type === "data-toolprogress") { + currentToolGroup.push(part as WaveUIMessagePart & { type: "data-tooluse" | "data-toolprogress" }); } else { if (currentToolGroup.length > 0) { grouped.push({ type: "toolgroup", parts: currentToolGroup }); diff --git a/frontend/app/aipanel/aitooluse.tsx b/frontend/app/aipanel/aitooluse.tsx index 993e3132b..3c57a27b2 100644 --- a/frontend/app/aipanel/aitooluse.tsx +++ b/frontend/app/aipanel/aitooluse.tsx @@ -370,16 +370,49 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { AIToolUse.displayName = "AIToolUse"; +interface AIToolProgressProps { + part: WaveUIMessagePart & { type: "data-toolprogress" }; +} + +const AIToolProgress = memo(({ part }: AIToolProgressProps) => { + const progressData = part.data; + + return ( +
+
+ +
{progressData.toolname}
+
+ {progressData.statuslines && progressData.statuslines.length > 0 && ( +
+ {progressData.statuslines.map((line, idx) => ( +
{line}
+ ))} +
+ )} +
+ ); +}); + +AIToolProgress.displayName = "AIToolProgress"; + interface AIToolUseGroupProps { - parts: Array; + parts: Array; isStreaming: boolean; } type ToolGroupItem = | { type: "batch"; parts: Array } - | { type: "single"; part: WaveUIMessagePart & { type: "data-tooluse" } }; + | { type: "single"; part: WaveUIMessagePart & { type: "data-tooluse" } } + | { type: "progress"; part: WaveUIMessagePart & { type: "data-toolprogress" } }; export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) => { + const tooluseParts = parts.filter((p) => p.type === "data-tooluse") as Array; + const toolprogressParts = parts.filter((p) => p.type === "data-toolprogress") as Array; + + const tooluseCallIds = new Set(tooluseParts.map((p) => p.data.toolcallid)); + const filteredProgressParts = toolprogressParts.filter((p) => !tooluseCallIds.has(p.data.toolcallid)); + const isFileOp = (part: WaveUIMessagePart & { type: "data-tooluse" }) => { const toolName = part.data?.toolname; return toolName === "read_text_file" || toolName === "read_dir"; @@ -392,7 +425,7 @@ export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) const readFileNeedsApproval: Array = []; const readFileOther: Array = []; - for (const part of parts) { + for (const part of tooluseParts) { if (isFileOp(part)) { if (needsApproval(part)) { readFileNeedsApproval.push(part); @@ -406,7 +439,7 @@ export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) let addedApprovalBatch = false; let addedOtherBatch = false; - for (const part of parts) { + for (const part of tooluseParts) { const isFileOpPart = isFileOp(part); const partNeedsApproval = needsApproval(part); @@ -425,19 +458,33 @@ export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) } } + filteredProgressParts.forEach((part) => { + groupedItems.push({ type: "progress", part }); + }); + return ( <> - {groupedItems.map((item, idx) => - item.type === "batch" ? ( -
- -
- ) : ( -
- -
- ) - )} + {groupedItems.map((item, idx) => { + if (item.type === "batch") { + return ( +
+ +
+ ); + } else if (item.type === "progress") { + return ( +
+ +
+ ); + } else { + return ( +
+ +
+ ); + } + })} ); }); From a06fa55ca426d34c2d73bc61542bb4b9f01fb8d1 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 12 Nov 2025 17:53:48 -0800 Subject: [PATCH 16/21] unify tool output descriptions + progress --- frontend/app/aipanel/aitooluse.tsx | 71 +++++++++++++++++++++++++++--- pkg/aiusechat/tools_builder.go | 61 ++++++++++++++----------- 2 files changed, 100 insertions(+), 32 deletions(-) diff --git a/frontend/app/aipanel/aitooluse.tsx b/frontend/app/aipanel/aitooluse.tsx index 3c57a27b2..a2487d942 100644 --- a/frontend/app/aipanel/aitooluse.tsx +++ b/frontend/app/aipanel/aitooluse.tsx @@ -13,6 +13,69 @@ import { WaveAIModel } from "./waveai-model"; // matches pkg/filebackup/filebackup.go const BackupRetentionDays = 5; +interface ToolDescLineProps { + text: string; +} + +const ToolDescLine = memo(({ text }: ToolDescLineProps) => { + let displayText = text; + + if (displayText.startsWith("* ")) { + displayText = "• " + displayText.slice(2); + } + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + const regex = /\b([+-])(\d+)\b/g; + let match; + + while ((match = regex.exec(displayText)) !== null) { + if (match.index > lastIndex) { + parts.push(displayText.slice(lastIndex, match.index)); + } + + const sign = match[1]; + const number = match[2]; + const colorClass = sign === '+' ? 'text-green-500' : 'text-red-500'; + parts.push( + + {sign}{number} + + ); + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < displayText.length) { + parts.push(displayText.slice(lastIndex)); + } + + return
{parts.length > 0 ? parts : displayText}
; +}); + +ToolDescLine.displayName = "ToolDescLine"; + +interface ToolDescProps { + text: string | string[]; + className?: string; +} + +const ToolDesc = memo(({ text, className }: ToolDescProps) => { + const lines = Array.isArray(text) ? text : text.split("\n"); + + if (lines.length === 0) return null; + + return ( +
+ {lines.map((line, idx) => ( + + ))} +
+ ); +}); + +ToolDesc.displayName = "ToolDesc"; + function getEffectiveApprovalStatus(baseApproval: string, isStreaming: boolean): string { return !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval; } @@ -354,7 +417,7 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { )}
- {toolData.tooldesc &&
{toolData.tooldesc}
} + {toolData.tooldesc && } {(toolData.errormessage || effectiveApproval === "timeout") && (
{toolData.errormessage || "Not approved"}
)} @@ -384,11 +447,7 @@ const AIToolProgress = memo(({ part }: AIToolProgressProps) => {
{progressData.toolname}
{progressData.statuslines && progressData.statuslines.length > 0 && ( -
- {progressData.statuslines.map((line, idx) => ( -
{line}
- ))} -
+ )} ); diff --git a/pkg/aiusechat/tools_builder.go b/pkg/aiusechat/tools_builder.go index 4d751aaec..1df1cd022 100644 --- a/pkg/aiusechat/tools_builder.go +++ b/pkg/aiusechat/tools_builder.go @@ -92,15 +92,18 @@ func GetBuilderWriteAppFileToolDefinition(appId string, builderId string) uctype "additionalProperties": false, }, ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string { - if output != nil { - params, err := parseBuilderWriteAppFileInput(input) - if err == nil { - lineCount := len(strings.Split(params.Contents, "\n")) - return fmt.Sprintf("wrote app.go (%d lines)", lineCount) + params, err := parseBuilderWriteAppFileInput(input) + if err != nil { + if output != nil { + return "wrote app.go" } - return "wrote app.go" + return "writing app.go" } - return "writing app.go" + lineCount := len(strings.Split(params.Contents, "\n")) + if output != nil { + return fmt.Sprintf("wrote app.go (+%d lines)", lineCount) + } + return fmt.Sprintf("writing app.go (+%d lines)", lineCount) }, ToolProgressDesc: func(input any) ([]string, error) { params, err := parseBuilderWriteAppFileInput(input) @@ -108,7 +111,7 @@ func GetBuilderWriteAppFileToolDefinition(appId string, builderId string) uctype return nil, err } lineCount := len(strings.Split(params.Contents, "\n")) - return []string{fmt.Sprintf("writing app.go (%d lines)", lineCount)}, nil + return []string{fmt.Sprintf("writing app.go (+%d lines)", lineCount)}, nil }, ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseBuilderWriteAppFileInput(input) @@ -165,6 +168,28 @@ func parseBuilderEditAppFileInput(input any) (*builderEditAppFileParams, error) return result, nil } +func formatEditDescriptions(edits []fileutil.EditSpec) []string { + numEdits := len(edits) + editStr := "edits" + if numEdits == 1 { + editStr = "edit" + } + + result := make([]string, len(edits)+1) + result[0] = fmt.Sprintf("editing app.go (%d %s)", numEdits, editStr) + + for i, edit := range edits { + newLines := len(strings.Split(edit.NewStr, "\n")) + oldLines := len(strings.Split(edit.OldStr, "\n")) + desc := edit.Desc + if desc == "" { + desc = fmt.Sprintf("edit #%d", i+1) + } + result[i+1] = fmt.Sprintf("* %s (+%d -%d)", desc, newLines, oldLines) + } + return result +} + func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "builder_edit_app_file", @@ -208,30 +233,14 @@ func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes if err != nil { return fmt.Sprintf("error parsing input: %v", err) } - numEdits := len(params.Edits) - editStr := "edits" - if numEdits == 1 { - editStr = "edit" - } - return fmt.Sprintf("editing app.go (%d %s)", numEdits, editStr) + return strings.Join(formatEditDescriptions(params.Edits), "\n") }, ToolProgressDesc: func(input any) ([]string, error) { params, err := parseBuilderEditAppFileInput(input) if err != nil { return nil, err } - - result := make([]string, len(params.Edits)) - for i, edit := range params.Edits { - newLines := len(strings.Split(edit.NewStr, "\n")) - oldLines := len(strings.Split(edit.OldStr, "\n")) - desc := edit.Desc - if desc == "" { - desc = "edit" - } - result[i] = fmt.Sprintf("%s (+%d -%d)", desc, newLines, oldLines) - } - return result, nil + return formatEditDescriptions(params.Edits), nil }, ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseBuilderEditAppFileInput(input) From 07cff6d97dd181f8fd4df9d08a2112d9e32d8afc Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 12 Nov 2025 21:21:00 -0800 Subject: [PATCH 17/21] updates for formatting --- frontend/app/aipanel/aimessage.tsx | 7 ++--- frontend/app/aipanel/aipanel.tsx | 2 +- frontend/app/aipanel/aipanelmessages.tsx | 2 +- frontend/app/aipanel/aitooluse.tsx | 38 +++++++++++++----------- frontend/app/aipanel/thinkingmode.tsx | 2 +- 5 files changed, 26 insertions(+), 25 deletions(-) diff --git a/frontend/app/aipanel/aimessage.tsx b/frontend/app/aipanel/aimessage.tsx index 3ee034a49..f3a1f91c9 100644 --- a/frontend/app/aipanel/aimessage.tsx +++ b/frontend/app/aipanel/aimessage.tsx @@ -49,10 +49,7 @@ const AIThinking = memo( )} {message && {message}} -
+
{displayText}
@@ -226,7 +223,7 @@ export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => { className={cn( "px-2 rounded-lg [&>*:first-child]:!mt-0", message.role === "user" - ? "py-2 bg-accent-800 text-white max-w-[calc(100%-20px)]" + ? "py-2 bg-accent-800 text-white max-w-[calc(90%-10px)]" : "min-w-[min(100%,500px)]" )} > diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 09bc6b875..300840488 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -497,7 +497,7 @@ const AIPanelComponentInner = memo(() => { className="flex-1 overflow-y-auto p-2 relative" onContextMenu={(e) => handleWaveAIContextMenu(e, true)} > -
+
{model.inBuilder ? : } diff --git a/frontend/app/aipanel/aipanelmessages.tsx b/frontend/app/aipanel/aipanelmessages.tsx index a6ef0538b..a8f72f86b 100644 --- a/frontend/app/aipanel/aipanelmessages.tsx +++ b/frontend/app/aipanel/aipanelmessages.tsx @@ -47,7 +47,7 @@ export const AIPanelMessages = memo(({ messages, status, onContextMenu }: AIPane className="flex-1 overflow-y-auto p-2 space-y-4 relative" onContextMenu={onContextMenu} > -
+
{messages.map((message, index) => { diff --git a/frontend/app/aipanel/aitooluse.tsx b/frontend/app/aipanel/aitooluse.tsx index a2487d942..bcd79e2ef 100644 --- a/frontend/app/aipanel/aitooluse.tsx +++ b/frontend/app/aipanel/aitooluse.tsx @@ -19,37 +19,37 @@ interface ToolDescLineProps { const ToolDescLine = memo(({ text }: ToolDescLineProps) => { let displayText = text; - if (displayText.startsWith("* ")) { displayText = "• " + displayText.slice(2); } - + const parts: React.ReactNode[] = []; let lastIndex = 0; - const regex = /\b([+-])(\d+)\b/g; + const regex = /(? lastIndex) { parts.push(displayText.slice(lastIndex, match.index)); } - + const sign = match[1]; const number = match[2]; - const colorClass = sign === '+' ? 'text-green-500' : 'text-red-500'; + const colorClass = sign === "+" ? "text-green-600" : "text-red-600"; parts.push( - {sign}{number} + {sign} + {number} ); - + lastIndex = match.index + match[0].length; } - + if (lastIndex < displayText.length) { parts.push(displayText.slice(lastIndex)); } - + return
{parts.length > 0 ? parts : displayText}
; }); @@ -62,9 +62,9 @@ interface ToolDescProps { const ToolDesc = memo(({ text, className }: ToolDescProps) => { const lines = Array.isArray(text) ? text : text.split("\n"); - + if (lines.length === 0) return null; - + return (
{lines.map((line, idx) => ( @@ -439,11 +439,11 @@ interface AIToolProgressProps { const AIToolProgress = memo(({ part }: AIToolProgressProps) => { const progressData = part.data; - + return (
- +
{progressData.toolname}
{progressData.statuslines && progressData.statuslines.length > 0 && ( @@ -466,9 +466,13 @@ type ToolGroupItem = | { type: "progress"; part: WaveUIMessagePart & { type: "data-toolprogress" } }; export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) => { - const tooluseParts = parts.filter((p) => p.type === "data-tooluse") as Array; - const toolprogressParts = parts.filter((p) => p.type === "data-toolprogress") as Array; - + const tooluseParts = parts.filter((p) => p.type === "data-tooluse") as Array< + WaveUIMessagePart & { type: "data-tooluse" } + >; + const toolprogressParts = parts.filter((p) => p.type === "data-toolprogress") as Array< + WaveUIMessagePart & { type: "data-toolprogress" } + >; + const tooluseCallIds = new Set(tooluseParts.map((p) => p.data.toolcallid)); const filteredProgressParts = toolprogressParts.filter((p) => !tooluseCallIds.has(p.data.toolcallid)); diff --git a/frontend/app/aipanel/thinkingmode.tsx b/frontend/app/aipanel/thinkingmode.tsx index 007dff135..870634db7 100644 --- a/frontend/app/aipanel/thinkingmode.tsx +++ b/frontend/app/aipanel/thinkingmode.tsx @@ -75,7 +75,7 @@ export const ThinkingLevelDropdown = memo(() => { {isOpen && ( <>
setIsOpen(false)} /> -
+
{(Object.keys(ThinkingModeData) as ThinkingMode[]).map((mode, index) => { const metadata = ThinkingModeData[mode]; const isFirst = index === 0; From ea6e2561ceb52191e74c81c3c0bd4bdd10a9daab Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 12 Nov 2025 22:10:17 -0800 Subject: [PATCH 18/21] hook up publish app button w/ rpc --- frontend/app/modals/modalregistry.tsx | 2 + frontend/app/store/wshclientapi.ts | 5 ++ frontend/builder/builder-app.tsx | 2 + frontend/builder/builder-apppanel.tsx | 70 ++++++++++++++++++++++++++- frontend/types/gotypes.d.ts | 10 ++++ pkg/wshrpc/wshclient/wshclient.go | 6 +++ pkg/wshrpc/wshrpctypes.go | 10 ++++ pkg/wshrpc/wshserver/wshserver.go | 10 ++++ 8 files changed, 114 insertions(+), 1 deletion(-) diff --git a/frontend/app/modals/modalregistry.tsx b/frontend/app/modals/modalregistry.tsx index 4733d33cf..88fc1c7da 100644 --- a/frontend/app/modals/modalregistry.tsx +++ b/frontend/app/modals/modalregistry.tsx @@ -4,6 +4,7 @@ import { MessageModal } from "@/app/modals/messagemodal"; import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding"; import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade"; +import { PublishAppModal } from "@/builder/builder-apppanel"; import { AboutModal } from "./about"; import { UserInputModal } from "./userinputmodal"; @@ -13,6 +14,7 @@ const modalRegistry: { [key: string]: React.ComponentType } = { [UserInputModal.displayName || "UserInputModal"]: UserInputModal, [AboutModal.displayName || "AboutModal"]: AboutModal, [MessageModal.displayName || "MessageModal"]: MessageModal, + [PublishAppModal.displayName || "PublishAppModal"]: PublishAppModal, }; export const getModalComponent = (key: string): React.ComponentType | undefined => { diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 781fcfba0..b1a561604 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -382,6 +382,11 @@ class RpcApiType { return client.wshRpcCall("path", data, opts); } + // command "publishapp" [call] + PublishAppCommand(client: WshClient, data: CommandPublishAppData, opts?: RpcOpts): Promise { + return client.wshRpcCall("publishapp", data, opts); + } + // command "readappfile" [call] ReadAppFileCommand(client: WshClient, data: CommandReadAppFileData, opts?: RpcOpts): Promise { return client.wshRpcCall("readappfile", data, opts); diff --git a/frontend/builder/builder-app.tsx b/frontend/builder/builder-app.tsx index dea4c9dbf..fd1b55fd0 100644 --- a/frontend/builder/builder-app.tsx +++ b/frontend/builder/builder-app.tsx @@ -3,6 +3,7 @@ import { AppSelectionModal } from "@/builder/app-selection-modal"; import { BuilderWorkspace } from "@/builder/builder-workspace"; +import { ModalsRenderer } from "@/app/modals/modalsrenderer"; import { atoms, globalStore } from "@/store/global"; import { appHandleKeyDown } from "@/store/keymodel"; import * as keyutil from "@/util/keyutil"; @@ -46,6 +47,7 @@ function BuilderAppInner() { {isBlank(builderAppId) ? : } +
); } diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index 0f8eb103f..5b361cc10 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -1,6 +1,10 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { Modal } from "@/app/modals/modal"; +import { modalsModel } from "@/app/store/modalmodel"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BuilderAppPanelModel, type TabType } from "@/builder/store/builder-apppanel-model"; import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { BuilderCodeTab } from "@/builder/tabs/builder-codetab"; @@ -96,6 +100,56 @@ const ErrorStrip = memo(() => { ErrorStrip.displayName = "ErrorStrip"; +const PublishAppModal = memo(({ appName }: { appName: string }) => { + const builderAppId = useAtomValue(atoms.builderAppId); + + const handlePublish = async () => { + if (!builderAppId) { + console.error("No builder app ID found"); + modalsModel.popModal(); + return; + } + + try { + const result = await RpcApi.PublishAppCommand(TabRpcClient, { appid: builderAppId }); + console.log("App published successfully:", result.publishedappid); + modalsModel.popModal(); + } catch (error) { + console.error("Failed to publish app:", error); + } + }; + + const handleCancel = () => { + modalsModel.popModal(); + }; + + return ( + +
+

Publish App

+
+

+ This will publish your app to local/{appName} +

+

+ + This will overwrite any existing app with the same name. Are you sure? +

+
+
+
+ ); +}); + +PublishAppModal.displayName = "PublishAppModal"; + const BuilderAppPanel = memo(() => { const model = BuilderAppPanelModel.getInstance(); const focusElemRef = useRef(null); @@ -166,6 +220,12 @@ const BuilderAppPanel = memo(() => { model.restartBuilder(); }, [model]); + const handlePublishClick = useCallback(() => { + if (!builderAppId) return; + const appName = builderAppId.replace("draft/", ""); + modalsModel.pushModal("PublishAppModal", { appName }); + }, [builderAppId]); + return (
{ onClick={() => handleTabClick("env")} />
+
+ +
{activeTab === "code" && (