From 74d331f58e175945ed38d28a7582e25918bdeb34 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 28 Oct 2025 14:46:43 -0700 Subject: [PATCH 01/19] add write file tools --- pkg/aiusechat/tools.go | 2 + pkg/aiusechat/tools_writefile.go | 272 +++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 pkg/aiusechat/tools_writefile.go diff --git a/pkg/aiusechat/tools.go b/pkg/aiusechat/tools.go index a270f0c24e..ded63e0014 100644 --- a/pkg/aiusechat/tools.go +++ b/pkg/aiusechat/tools.go @@ -156,6 +156,8 @@ func GenerateTabStateAndTools(ctx context.Context, tabid string, widgetAccess bo tools = append(tools, GetCaptureScreenshotToolDefinition(tabid)) tools = append(tools, GetReadTextFileToolDefinition()) tools = append(tools, GetReadDirToolDefinition()) + tools = append(tools, GetWriteTextFileToolDefinition()) + tools = append(tools, GetEditTextFileToolDefinition()) viewTypes := make(map[string]bool) for _, block := range blocks { if block.Meta == nil { diff --git a/pkg/aiusechat/tools_writefile.go b/pkg/aiusechat/tools_writefile.go new file mode 100644 index 0000000000..d4737c8dd2 --- /dev/null +++ b/pkg/aiusechat/tools_writefile.go @@ -0,0 +1,272 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package aiusechat + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/util/fileutil" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/wavebase" +) + +const MaxEditFileSize = 100 * 1024 // 100KB + +type writeTextFileParams struct { + Filename string `json:"filename"` + Contents string `json:"contents"` +} + +func parseWriteTextFileInput(input any) (*writeTextFileParams, error) { + result := &writeTextFileParams{} + + if input == nil { + return nil, fmt.Errorf("input is required") + } + + if err := utilfn.ReUnmarshal(result, input); err != nil { + return nil, fmt.Errorf("invalid input format: %w", err) + } + + if result.Filename == "" { + return nil, fmt.Errorf("missing filename parameter") + } + + if result.Contents == "" { + return nil, fmt.Errorf("missing contents parameter") + } + + return result, nil +} + +func writeTextFileCallback(input any) (any, error) { + params, err := parseWriteTextFileInput(input) + if err != nil { + return nil, err + } + + expandedPath, err := wavebase.ExpandHomeDir(params.Filename) + if err != nil { + return nil, fmt.Errorf("failed to expand path: %w", err) + } + + contentsBytes := []byte(params.Contents) + if utilfn.HasBinaryData(contentsBytes) { + return nil, fmt.Errorf("contents appear to contain binary data") + } + + fileInfo, err := os.Stat(expandedPath) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to stat file: %w", err) + } + if err == nil { + if fileInfo.IsDir() { + return nil, fmt.Errorf("path is a directory, cannot write to it") + } + if fileInfo.Size() > MaxEditFileSize { + return nil, fmt.Errorf("existing file is too large (%d bytes, max %d bytes)", fileInfo.Size(), MaxEditFileSize) + } + if fileInfo.Mode().Perm()&0222 == 0 { + return nil, fmt.Errorf("file is not writable (no write permission)") + } + } + + dirPath := filepath.Dir(expandedPath) + dirInfo, err := os.Stat(dirPath) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to stat directory: %w", err) + } + if err == nil && dirInfo.Mode().Perm()&0222 == 0 { + return nil, fmt.Errorf("directory is not writable (no write permission)") + } + + err = os.MkdirAll(dirPath, 0755) + if err != nil { + return nil, fmt.Errorf("failed to create directory: %w", err) + } + + err = os.WriteFile(expandedPath, contentsBytes, 0644) + if err != nil { + return nil, fmt.Errorf("failed to write file: %w", err) + } + + return map[string]any{ + "success": true, + "message": fmt.Sprintf("Successfully wrote %s (%d bytes)", params.Filename, len(contentsBytes)), + }, nil +} + +func GetWriteTextFileToolDefinition() uctypes.ToolDefinition { + return uctypes.ToolDefinition{ + Name: "write_text_file", + DisplayName: "Write Text File", + Description: "Write a text file to the filesystem. Will create or overwrite the file. Maximum file size: 100KB.", + ToolLogName: "gen:writefile", + Strict: true, + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "filename": map[string]any{ + "type": "string", + "description": "Path to the file to write. Supports '~' for the user's home directory.", + }, + "contents": map[string]any{ + "type": "string", + "description": "The contents to write to the file", + }, + }, + "required": []string{"filename", "contents"}, + "additionalProperties": false, + }, + ToolInputDesc: func(input any) string { + params, err := parseWriteTextFileInput(input) + if err != nil { + return fmt.Sprintf("error parsing input: %v", err) + } + return fmt.Sprintf("writing %q", params.Filename) + }, + ToolAnyCallback: writeTextFileCallback, + ToolApproval: func(input any) string { + return uctypes.ApprovalNeedsApproval + }, + } +} + +type editTextFileParams struct { + Filename string `json:"filename"` + Edits []fileutil.EditSpec `json:"edits"` +} + +func parseEditTextFileInput(input any) (*editTextFileParams, error) { + result := &editTextFileParams{} + + if input == nil { + return nil, fmt.Errorf("input is required") + } + + if err := utilfn.ReUnmarshal(result, input); err != nil { + return nil, fmt.Errorf("invalid input format: %w", err) + } + + if result.Filename == "" { + return nil, fmt.Errorf("missing filename parameter") + } + + if len(result.Edits) == 0 { + return nil, fmt.Errorf("missing edits parameter") + } + + return result, nil +} + +func editTextFileCallback(input any) (any, error) { + params, err := parseEditTextFileInput(input) + if err != nil { + return nil, err + } + + expandedPath, err := wavebase.ExpandHomeDir(params.Filename) + if err != nil { + return nil, fmt.Errorf("failed to expand path: %w", err) + } + + fileInfo, err := os.Stat(expandedPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("file does not exist and cannot be edited: %s", params.Filename) + } + return nil, fmt.Errorf("failed to stat file: %w", err) + } + + if fileInfo.IsDir() { + return nil, fmt.Errorf("path is a directory, cannot edit it") + } + + if fileInfo.Size() > MaxEditFileSize { + return nil, fmt.Errorf("file is too large (%d bytes, max %d bytes)", fileInfo.Size(), MaxEditFileSize) + } + + if fileInfo.Mode().Perm()&0222 == 0 { + return nil, fmt.Errorf("file is not writable (no write permission)") + } + + fileData, err := os.ReadFile(expandedPath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + if utilfn.HasBinaryData(fileData) { + return nil, fmt.Errorf("file appears to contain binary data") + } + + err = fileutil.ReplaceInFile(expandedPath, params.Edits) + if err != nil { + return nil, err + } + + return map[string]any{ + "success": true, + "message": fmt.Sprintf("Successfully edited %s with %d changes", params.Filename, len(params.Edits)), + }, nil +} + +func GetEditTextFileToolDefinition() uctypes.ToolDefinition { + return uctypes.ToolDefinition{ + Name: "edit_text_file", + DisplayName: "Edit Text File", + Description: "Edit a text file 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. " + + "Maximum file size: 100KB.", + ToolLogName: "gen:editfile", + Strict: true, + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "filename": map[string]any{ + "type": "string", + "description": "Path to the file to edit. Supports '~' for the user's home directory.", + }, + "edits": map[string]any{ + "type": "array", + "description": "Array of edit specifications. All edits are applied atomically - if any edit fails, none are applied.", + "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.", + }, + "new_str": map[string]any{ + "type": "string", + "description": "The string to replace with", + }, + "desc": map[string]any{ + "type": "string", + "description": "Description of what this edit does", + }, + }, + "required": []string{"old_str", "new_str"}, + }, + }, + }, + "required": []string{"filename", "edits"}, + "additionalProperties": false, + }, + ToolInputDesc: func(input any) string { + params, err := parseEditTextFileInput(input) + if err != nil { + return fmt.Sprintf("error parsing input: %v", err) + } + return fmt.Sprintf("editing %q (%d edits)", params.Filename, len(params.Edits)) + }, + ToolAnyCallback: editTextFileCallback, + ToolApproval: func(input any) string { + return uctypes.ApprovalNeedsApproval + }, + } +} From a7a48f869cc2b3f6e8f7544ef7b09a94e6b0fa09 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 28 Oct 2025 14:52:21 -0700 Subject: [PATCH 02/19] oops currentappgofile is for builder not wave ai --- pkg/aiusechat/usechat.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index f470984d35..273c4e7a07 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -77,7 +77,6 @@ var SystemPromptText_OpenAI = strings.Join([]string{ `User-attached directories use the tag JSON DirInfo.`, `If multiple attached files exist, treat each as a separate source file with its own file_name.`, `When the user refers to these files, use their inline content directly; do NOT call any read_text_file or file-access tools to re-read them unless asked.`, - `The current "app.go" file will be provided with the tag \ncontent\n (use this as the basis for your app.go file edits)`, // Output & formatting `When presenting commands or any runnable multi-line code, always use fenced Markdown code blocks.`, @@ -121,6 +120,7 @@ var BuilderSystemPromptText_OpenAI = strings.Join([]string{ `- User-attached text files appear inline as \ncontent\n`, `- User-attached directories use JSON DirInfo`, `- When users refer to attached files, use their inline content directly; do NOT attempt to read them again`, + `The current "app.go" file will be provided with the tag \ncontent\n (use this as the basis for your app.go file edits)`, ``, `**Code Output:**`, `- Do NOT output code in fenced code blocks or inline code snippets`, From fe1354c49a00d440e50166f4ef916a12a9d06040 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 28 Oct 2025 16:00:02 -0700 Subject: [PATCH 03/19] create a blocked files list to never allow reading/writing of... --- pkg/aiusechat/tools_readfile.go | 100 +++++++++++++++++++++++++++++++ pkg/aiusechat/tools_writefile.go | 50 ++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/pkg/aiusechat/tools_readfile.go b/pkg/aiusechat/tools_readfile.go index 459ed3c4b9..7067f05ed9 100644 --- a/pkg/aiusechat/tools_readfile.go +++ b/pkg/aiusechat/tools_readfile.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "time" @@ -102,6 +103,101 @@ func truncateData(data string, origin string, maxBytes int) string { return data[:truncateIdx+1] } +func isBlockedFile(expandedPath string) (bool, string) { + homeDir := os.Getenv("HOME") + if homeDir == "" { + homeDir = os.Getenv("USERPROFILE") + } + + cleanPath := filepath.Clean(expandedPath) + baseName := filepath.Base(cleanPath) + + exactPaths := []struct { + path string + reason string + }{ + {filepath.Join(homeDir, ".aws", "credentials"), "AWS credentials file"}, + {filepath.Join(homeDir, ".git-credentials"), "Git credentials file"}, + {filepath.Join(homeDir, ".netrc"), "netrc credentials file"}, + {filepath.Join(homeDir, ".pgpass"), "PostgreSQL password file"}, + {filepath.Join(homeDir, ".my.cnf"), "MySQL credentials file"}, + {filepath.Join(homeDir, ".kube", "config"), "Kubernetes config file"}, + {"/etc/shadow", "system password file"}, + {"/etc/sudoers", "system sudoers file"}, + } + + for _, ep := range exactPaths { + if cleanPath == ep.path { + return true, ep.reason + } + } + + dirPrefixes := []struct { + prefix string + reason string + }{ + {filepath.Join(homeDir, ".gnupg") + string(filepath.Separator), "GPG directory"}, + {filepath.Join(homeDir, ".password-store") + string(filepath.Separator), "password store directory"}, + {"/etc/sudoers.d/", "system sudoers directory"}, + {"/Library/Keychains/", "macOS keychain directory"}, + {filepath.Join(homeDir, "Library", "Keychains") + string(filepath.Separator), "macOS keychain directory"}, + } + + for _, dp := range dirPrefixes { + if strings.HasPrefix(cleanPath, dp.prefix) { + return true, dp.reason + } + } + + if strings.Contains(cleanPath, filepath.Join(homeDir, ".secrets")) { + return true, "secrets directory" + } + + if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { + credPath := filepath.Join(localAppData, "Microsoft", "Credentials") + if strings.HasPrefix(cleanPath, credPath) { + return true, "Windows credentials" + } + } + if appData := os.Getenv("APPDATA"); appData != "" { + credPath := filepath.Join(appData, "Microsoft", "Credentials") + if strings.HasPrefix(cleanPath, credPath) { + return true, "Windows credentials" + } + } + + if strings.HasPrefix(baseName, "id_") && strings.Contains(cleanPath, ".ssh") { + return true, "SSH private key" + } + if strings.Contains(baseName, "id_rsa") { + return true, "SSH private key" + } + if strings.HasPrefix(baseName, "ssh_host_") && strings.Contains(baseName, "key") { + return true, "SSH host key" + } + + extensions := map[string]string{ + ".pem": "certificate/key file", + ".p12": "certificate file", + ".key": "key file", + ".pfx": "certificate file", + ".pkcs12": "certificate file", + ".keystore": "Java keystore file", + ".jks": "Java keystore file", + } + + if reason, exists := extensions[filepath.Ext(baseName)]; exists { + return true, reason + } + + if baseName == ".git-credentials" { + return true, "Git credentials file" + } + + return false, "" +} + + func readTextFileCallback(input any) (any, error) { const ReadLimit = 1024 * 1024 * 1024 @@ -115,6 +211,10 @@ func readTextFileCallback(input any) (any, error) { return nil, fmt.Errorf("failed to expand path: %w", err) } + if blocked, reason := isBlockedFile(expandedPath); blocked { + return nil, fmt.Errorf("access denied: potentially sensitive file: %s", reason) + } + fileInfo, err := os.Stat(expandedPath) if err != nil { return nil, fmt.Errorf("failed to stat file: %w", err) diff --git a/pkg/aiusechat/tools_writefile.go b/pkg/aiusechat/tools_writefile.go index d4737c8dd2..2988ee1beb 100644 --- a/pkg/aiusechat/tools_writefile.go +++ b/pkg/aiusechat/tools_writefile.go @@ -4,16 +4,58 @@ package aiusechat import ( + "crypto/sha256" + "encoding/hex" "fmt" "os" "path/filepath" + "time" + "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" ) +func makeFileBackup(absFilePath string) error { + fileData, err := os.ReadFile(absFilePath) + if err != nil { + return fmt.Errorf("failed to read file for backup: %w", err) + } + + dir := filepath.Dir(absFilePath) + basename := filepath.Base(absFilePath) + + hash := sha256.Sum256([]byte(dir)) + dirHash8 := hex.EncodeToString(hash[:])[:8] + + uuidV7, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("failed to generate UUID: %w", err) + } + uuidStr := uuidV7.String() + + now := time.Now() + dateStr := now.Format("2006-01-02") + + backupDir := filepath.Join(wavebase.GetWaveCachesDir(), "waveai-backups", dateStr) + err = os.MkdirAll(backupDir, 0700) + if err != nil { + return fmt.Errorf("failed to create backup directory: %w", err) + } + + backupName := fmt.Sprintf("%s.%s.%s.bak", basename, dirHash8, uuidStr) + backupPath := filepath.Join(backupDir, backupName) + + err = os.WriteFile(backupPath, fileData, 0600) + if err != nil { + return fmt.Errorf("failed to write backup file: %w", err) + } + + return nil +} + const MaxEditFileSize = 100 * 1024 // 100KB type writeTextFileParams struct { @@ -54,6 +96,10 @@ func writeTextFileCallback(input any) (any, error) { return nil, fmt.Errorf("failed to expand path: %w", err) } + if blocked, reason := isBlockedFile(expandedPath); blocked { + return nil, fmt.Errorf("access denied: potentially sensitive file: %s", reason) + } + contentsBytes := []byte(params.Contents) if utilfn.HasBinaryData(contentsBytes) { return nil, fmt.Errorf("contents appear to contain binary data") @@ -174,6 +220,10 @@ func editTextFileCallback(input any) (any, error) { return nil, fmt.Errorf("failed to expand path: %w", err) } + if blocked, reason := isBlockedFile(expandedPath); blocked { + return nil, fmt.Errorf("access denied: potentially sensitive file: %s", reason) + } + fileInfo, err := os.Stat(expandedPath) if err != nil { if os.IsNotExist(err) { From ae52ac30d94015515f7db1ce634fe43621a58482 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 28 Oct 2025 16:26:00 -0700 Subject: [PATCH 04/19] file backups etc --- cmd/server/main-server.go | 21 +++++ pkg/aiusechat/tools_writefile.go | 67 ++++++---------- pkg/filebackup/filebackup.go | 130 +++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 43 deletions(-) create mode 100644 pkg/filebackup/filebackup.go diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 9a1314012c..552512037d 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -17,6 +17,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/authkey" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/blocklogger" + "github.com/wavetermdev/waveterm/pkg/filebackup" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" @@ -51,6 +52,8 @@ const TelemetryTick = 2 * time.Minute const TelemetryInterval = 4 * time.Hour const TelemetryInitialCountsWait = 5 * time.Second const TelemetryCountsInterval = 1 * time.Hour +const BackupCleanupTick = 2 * time.Minute +const BackupCleanupInterval = 4 * time.Hour var shutdownOnce sync.Once @@ -114,6 +117,23 @@ func telemetryLoop() { } } +func backupCleanupLoop() { + defer func() { + panichandler.PanicHandler("backupCleanupLoop", recover()) + }() + var nextCleanup int64 + for { + if time.Now().Unix() > nextCleanup { + nextCleanup = time.Now().Add(BackupCleanupInterval).Unix() + err := filebackup.CleanupOldBackups() + if err != nil { + log.Printf("error cleaning up old backups: %v\n", err) + } + } + time.Sleep(BackupCleanupTick) + } +} + func panicTelemetryHandler(panicName string) { activity := wshrpc.ActivityUpdate{NumPanics: 1} err := telemetry.UpdateActivity(context.Background(), activity) @@ -413,6 +433,7 @@ func main() { go stdinReadWatch() go telemetryLoop() go updateTelemetryCountsLoop() + go backupCleanupLoop() go startupActivityUpdate(firstLaunch) // must be after startConfigWatcher() blocklogger.InitBlockLogger() go wavebase.GetSystemSummary() // get this cached (used in AI) diff --git a/pkg/aiusechat/tools_writefile.go b/pkg/aiusechat/tools_writefile.go index 2988ee1beb..3eac11dba7 100644 --- a/pkg/aiusechat/tools_writefile.go +++ b/pkg/aiusechat/tools_writefile.go @@ -4,58 +4,17 @@ package aiusechat import ( - "crypto/sha256" - "encoding/hex" "fmt" "os" "path/filepath" - "time" - "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/filebackup" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" ) -func makeFileBackup(absFilePath string) error { - fileData, err := os.ReadFile(absFilePath) - if err != nil { - return fmt.Errorf("failed to read file for backup: %w", err) - } - - dir := filepath.Dir(absFilePath) - basename := filepath.Base(absFilePath) - - hash := sha256.Sum256([]byte(dir)) - dirHash8 := hex.EncodeToString(hash[:])[:8] - - uuidV7, err := uuid.NewV7() - if err != nil { - return fmt.Errorf("failed to generate UUID: %w", err) - } - uuidStr := uuidV7.String() - - now := time.Now() - dateStr := now.Format("2006-01-02") - - backupDir := filepath.Join(wavebase.GetWaveCachesDir(), "waveai-backups", dateStr) - err = os.MkdirAll(backupDir, 0700) - if err != nil { - return fmt.Errorf("failed to create backup directory: %w", err) - } - - backupName := fmt.Sprintf("%s.%s.%s.bak", basename, dirHash8, uuidStr) - backupPath := filepath.Join(backupDir, backupName) - - err = os.WriteFile(backupPath, fileData, 0600) - if err != nil { - return fmt.Errorf("failed to write backup file: %w", err) - } - - return nil -} - const MaxEditFileSize = 100 * 1024 // 100KB type writeTextFileParams struct { @@ -105,14 +64,24 @@ func writeTextFileCallback(input any) (any, error) { return nil, fmt.Errorf("contents appear to contain binary data") } - fileInfo, err := os.Stat(expandedPath) + fileInfo, err := os.Lstat(expandedPath) if err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("failed to stat file: %w", err) } if err == nil { + if fileInfo.Mode()&os.ModeSymlink != 0 { + target, _ := os.Readlink(expandedPath) + if target == "" { + target = "(unknown)" + } + return nil, fmt.Errorf("cannot write to symlinks (target: %s). edit the target file directly if needed", utilfn.MarshalJSONString(target)) + } if fileInfo.IsDir() { return nil, fmt.Errorf("path is a directory, cannot write to it") } + if !fileInfo.Mode().IsRegular() { + return nil, fmt.Errorf("path is not a regular file (devices, pipes, sockets not supported)") + } if fileInfo.Size() > MaxEditFileSize { return nil, fmt.Errorf("existing file is too large (%d bytes, max %d bytes)", fileInfo.Size(), MaxEditFileSize) } @@ -135,6 +104,13 @@ func writeTextFileCallback(input any) (any, error) { return nil, fmt.Errorf("failed to create directory: %w", err) } + if fileInfo != nil { + err = filebackup.MakeFileBackup(expandedPath) + if err != nil { + return nil, fmt.Errorf("failed to create backup: %w", err) + } + } + err = os.WriteFile(expandedPath, contentsBytes, 0644) if err != nil { return nil, fmt.Errorf("failed to write file: %w", err) @@ -253,6 +229,11 @@ func editTextFileCallback(input any) (any, error) { return nil, fmt.Errorf("file appears to contain binary data") } + err = filebackup.MakeFileBackup(expandedPath) + if err != nil { + return nil, fmt.Errorf("failed to create backup: %w", err) + } + err = fileutil.ReplaceInFile(expandedPath, params.Edits) if err != nil { return nil, err diff --git a/pkg/filebackup/filebackup.go b/pkg/filebackup/filebackup.go new file mode 100644 index 0000000000..d3e4e654dd --- /dev/null +++ b/pkg/filebackup/filebackup.go @@ -0,0 +1,130 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package filebackup + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/wavebase" +) + +type BackupMetadata struct { + FullPath string `json:"fullpath"` + Timestamp string `json:"timestamp"` + Perm string `json:"perm"` +} + +func MakeFileBackup(absFilePath string) error { + fileInfo, err := os.Stat(absFilePath) + if err != nil { + return fmt.Errorf("failed to stat file for backup: %w", err) + } + + fileData, err := os.ReadFile(absFilePath) + if err != nil { + return fmt.Errorf("failed to read file for backup: %w", err) + } + + dir := filepath.Dir(absFilePath) + basename := filepath.Base(absFilePath) + + hash := sha256.Sum256([]byte(dir)) + dirHash8 := hex.EncodeToString(hash[:])[:8] + + uuidV7, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("failed to generate UUID: %w", err) + } + uuidStr := uuidV7.String() + + now := time.Now() + dateStr := now.Format("2006-01-02") + + backupDir := filepath.Join(wavebase.GetWaveCachesDir(), "waveai-backups", dateStr) + err = os.MkdirAll(backupDir, 0700) + if err != nil { + return fmt.Errorf("failed to create backup directory: %w", err) + } + + backupName := fmt.Sprintf("%s.%s.%s.bak", basename, dirHash8, uuidStr) + backupPath := filepath.Join(backupDir, backupName) + + err = os.WriteFile(backupPath, fileData, 0600) + if err != nil { + return fmt.Errorf("failed to write backup file: %w", err) + } + + metadata := BackupMetadata{ + FullPath: absFilePath, + Timestamp: now.Format(time.RFC3339), + Perm: fmt.Sprintf("%04o", fileInfo.Mode().Perm()), + } + + metadataJSON, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal backup metadata: %w", err) + } + + metadataName := fmt.Sprintf("%s.%s.%s.json", basename, dirHash8, uuidStr) + metadataPath := filepath.Join(backupDir, metadataName) + + err = os.WriteFile(metadataPath, metadataJSON, 0600) + if err != nil { + return fmt.Errorf("failed to write backup metadata: %w", err) + } + + return nil +} + +func CleanupOldBackups() error { + backupBaseDir := filepath.Join(wavebase.GetWaveCachesDir(), "waveai-backups") + + if _, err := os.Stat(backupBaseDir); os.IsNotExist(err) { + return nil + } + + entries, err := os.ReadDir(backupBaseDir) + if err != nil { + return fmt.Errorf("failed to read backup directory: %w", err) + } + + cutoffTime := time.Now().Add(-5 * 24 * time.Hour) + var removedCount int + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + dirPath := filepath.Join(backupBaseDir, entry.Name()) + info, err := entry.Info() + if err != nil { + log.Printf("failed to get info for backup dir %s: %v\n", entry.Name(), err) + continue + } + + if info.ModTime().Before(cutoffTime) { + err = os.RemoveAll(dirPath) + if err != nil { + log.Printf("failed to remove old backup dir %s: %v\n", entry.Name(), err) + } else { + removedCount++ + } + } + } + + if removedCount > 0 { + log.Printf("cleaned up %d old backup directories\n", removedCount) + } + + return nil +} \ No newline at end of file From 6a3b400bb7364df7e0b91ca098063aac46224e41 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 28 Oct 2025 16:33:05 -0700 Subject: [PATCH 05/19] add delete text file, standardize "text file" checks --- pkg/aiusechat/tools.go | 1 + pkg/aiusechat/tools_writefile.go | 214 +++++++++++++++++++++---------- 2 files changed, 149 insertions(+), 66 deletions(-) diff --git a/pkg/aiusechat/tools.go b/pkg/aiusechat/tools.go index ded63e0014..84ae0bdf1c 100644 --- a/pkg/aiusechat/tools.go +++ b/pkg/aiusechat/tools.go @@ -158,6 +158,7 @@ func GenerateTabStateAndTools(ctx context.Context, tabid string, widgetAccess bo tools = append(tools, GetReadDirToolDefinition()) tools = append(tools, GetWriteTextFileToolDefinition()) tools = append(tools, GetEditTextFileToolDefinition()) + tools = append(tools, GetDeleteTextFileToolDefinition()) viewTypes := make(map[string]bool) for _, block := range blocks { if block.Meta == nil { diff --git a/pkg/aiusechat/tools_writefile.go b/pkg/aiusechat/tools_writefile.go index 3eac11dba7..91192f0a1d 100644 --- a/pkg/aiusechat/tools_writefile.go +++ b/pkg/aiusechat/tools_writefile.go @@ -17,6 +17,63 @@ import ( const MaxEditFileSize = 100 * 1024 // 100KB +func validateTextFile(expandedPath string, verb string, mustExist bool) (os.FileInfo, error) { + if blocked, reason := isBlockedFile(expandedPath); blocked { + return nil, fmt.Errorf("access denied: potentially sensitive file: %s", reason) + } + + fileInfo, err := os.Lstat(expandedPath) + if err != nil { + if os.IsNotExist(err) { + if mustExist { + return nil, fmt.Errorf("file does not exist: %s", expandedPath) + } + return nil, nil + } + return nil, fmt.Errorf("failed to stat file: %w", err) + } + + if fileInfo.Mode()&os.ModeSymlink != 0 { + target, _ := os.Readlink(expandedPath) + if target == "" { + target = "(unknown)" + } + return nil, fmt.Errorf("cannot %s symlinks (target: %s). %s the target file directly if needed", verb, utilfn.MarshalJSONString(target), verb) + } + + if fileInfo.IsDir() { + return nil, fmt.Errorf("path is a directory, cannot %s it", verb) + } + + if !fileInfo.Mode().IsRegular() { + return nil, fmt.Errorf("path is not a regular file (devices, pipes, sockets not supported)") + } + + if fileInfo.Size() > MaxEditFileSize { + return nil, fmt.Errorf("file is too large (%d bytes, max %d bytes)", fileInfo.Size(), MaxEditFileSize) + } + + fileData, err := os.ReadFile(expandedPath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + if utilfn.HasBinaryData(fileData) { + return nil, fmt.Errorf("file appears to contain binary data") + } + + dirPath := filepath.Dir(expandedPath) + dirInfo, err := os.Stat(dirPath) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to stat directory: %w", err) + } + if err == nil && dirInfo.Mode().Perm()&0222 == 0 { + return nil, fmt.Errorf("directory is not writable (no write permission)") + } + + return fileInfo, nil +} + type writeTextFileParams struct { Filename string `json:"filename"` Contents string `json:"contents"` @@ -55,50 +112,17 @@ func writeTextFileCallback(input any) (any, error) { return nil, fmt.Errorf("failed to expand path: %w", err) } - if blocked, reason := isBlockedFile(expandedPath); blocked { - return nil, fmt.Errorf("access denied: potentially sensitive file: %s", reason) - } - contentsBytes := []byte(params.Contents) if utilfn.HasBinaryData(contentsBytes) { return nil, fmt.Errorf("contents appear to contain binary data") } - fileInfo, err := os.Lstat(expandedPath) - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("failed to stat file: %w", err) - } - if err == nil { - if fileInfo.Mode()&os.ModeSymlink != 0 { - target, _ := os.Readlink(expandedPath) - if target == "" { - target = "(unknown)" - } - return nil, fmt.Errorf("cannot write to symlinks (target: %s). edit the target file directly if needed", utilfn.MarshalJSONString(target)) - } - if fileInfo.IsDir() { - return nil, fmt.Errorf("path is a directory, cannot write to it") - } - if !fileInfo.Mode().IsRegular() { - return nil, fmt.Errorf("path is not a regular file (devices, pipes, sockets not supported)") - } - if fileInfo.Size() > MaxEditFileSize { - return nil, fmt.Errorf("existing file is too large (%d bytes, max %d bytes)", fileInfo.Size(), MaxEditFileSize) - } - if fileInfo.Mode().Perm()&0222 == 0 { - return nil, fmt.Errorf("file is not writable (no write permission)") - } + fileInfo, err := validateTextFile(expandedPath, "write to", false) + if err != nil { + return nil, err } dirPath := filepath.Dir(expandedPath) - dirInfo, err := os.Stat(dirPath) - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("failed to stat directory: %w", err) - } - if err == nil && dirInfo.Mode().Perm()&0222 == 0 { - return nil, fmt.Errorf("directory is not writable (no write permission)") - } - err = os.MkdirAll(dirPath, 0755) if err != nil { return nil, fmt.Errorf("failed to create directory: %w", err) @@ -196,37 +220,9 @@ func editTextFileCallback(input any) (any, error) { return nil, fmt.Errorf("failed to expand path: %w", err) } - if blocked, reason := isBlockedFile(expandedPath); blocked { - return nil, fmt.Errorf("access denied: potentially sensitive file: %s", reason) - } - - fileInfo, err := os.Stat(expandedPath) + _, err = validateTextFile(expandedPath, "edit", true) if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("file does not exist and cannot be edited: %s", params.Filename) - } - return nil, fmt.Errorf("failed to stat file: %w", err) - } - - if fileInfo.IsDir() { - return nil, fmt.Errorf("path is a directory, cannot edit it") - } - - if fileInfo.Size() > MaxEditFileSize { - return nil, fmt.Errorf("file is too large (%d bytes, max %d bytes)", fileInfo.Size(), MaxEditFileSize) - } - - if fileInfo.Mode().Perm()&0222 == 0 { - return nil, fmt.Errorf("file is not writable (no write permission)") - } - - fileData, err := os.ReadFile(expandedPath) - if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) - } - - if utilfn.HasBinaryData(fileData) { - return nil, fmt.Errorf("file appears to contain binary data") + return nil, err } err = filebackup.MakeFileBackup(expandedPath) @@ -301,3 +297,89 @@ func GetEditTextFileToolDefinition() uctypes.ToolDefinition { }, } } + +type deleteTextFileParams struct { + Filename string `json:"filename"` +} + +func parseDeleteTextFileInput(input any) (*deleteTextFileParams, error) { + result := &deleteTextFileParams{} + + if input == nil { + return nil, fmt.Errorf("input is required") + } + + if err := utilfn.ReUnmarshal(result, input); err != nil { + return nil, fmt.Errorf("invalid input format: %w", err) + } + + if result.Filename == "" { + return nil, fmt.Errorf("missing filename parameter") + } + + return result, nil +} + +func deleteTextFileCallback(input any) (any, error) { + params, err := parseDeleteTextFileInput(input) + if err != nil { + return nil, err + } + + expandedPath, err := wavebase.ExpandHomeDir(params.Filename) + if err != nil { + return nil, fmt.Errorf("failed to expand path: %w", err) + } + + _, err = validateTextFile(expandedPath, "delete", true) + if err != nil { + return nil, err + } + + err = filebackup.MakeFileBackup(expandedPath) + if err != nil { + return nil, fmt.Errorf("failed to create backup: %w", err) + } + + err = os.Remove(expandedPath) + if err != nil { + return nil, fmt.Errorf("failed to delete file: %w", err) + } + + return map[string]any{ + "success": true, + "message": fmt.Sprintf("Successfully deleted %s", params.Filename), + }, nil +} + +func GetDeleteTextFileToolDefinition() uctypes.ToolDefinition { + return uctypes.ToolDefinition{ + Name: "delete_text_file", + DisplayName: "Delete Text File", + Description: "Delete a text file from the filesystem. A backup is created before deletion. Maximum file size: 100KB.", + ToolLogName: "gen:deletefile", + Strict: true, + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "filename": map[string]any{ + "type": "string", + "description": "Path to the file to delete. Supports '~' for the user's home directory.", + }, + }, + "required": []string{"filename"}, + "additionalProperties": false, + }, + ToolInputDesc: func(input any) string { + params, err := parseDeleteTextFileInput(input) + if err != nil { + return fmt.Sprintf("error parsing input: %v", err) + } + return fmt.Sprintf("deleting %q", params.Filename) + }, + ToolAnyCallback: deleteTextFileCallback, + ToolApproval: func(input any) string { + return uctypes.ApprovalNeedsApproval + }, + } +} From 7aaa21a5e278106916d9fbf6ebd370c993a2025d Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 29 Oct 2025 14:22:23 -0700 Subject: [PATCH 06/19] add a pprof label for rpc. create a dry run func in tools_writefile --- pkg/aiusechat/tools_writefile.go | 31 +++++++++++++++++++++ pkg/util/fileutil/fileutil.go | 47 ++++++++++++++++++++------------ pkg/wshutil/wshrpc.go | 7 +++++ 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/pkg/aiusechat/tools_writefile.go b/pkg/aiusechat/tools_writefile.go index 91192f0a1d..a6f3157d02 100644 --- a/pkg/aiusechat/tools_writefile.go +++ b/pkg/aiusechat/tools_writefile.go @@ -209,6 +209,37 @@ func parseEditTextFileInput(input any) (*editTextFileParams, error) { return result, nil } +// EditTextFileDryRun applies edits to a file and returns the original and modified content +// without writing to disk. Takes the same input format as editTextFileCallback. +func EditTextFileDryRun(input any) ([]byte, []byte, error) { + params, err := parseEditTextFileInput(input) + if err != nil { + return nil, nil, err + } + + expandedPath, err := wavebase.ExpandHomeDir(params.Filename) + if err != nil { + return nil, nil, fmt.Errorf("failed to expand path: %w", err) + } + + _, err = validateTextFile(expandedPath, "edit", true) + if err != nil { + return nil, nil, err + } + + originalContent, err := os.ReadFile(expandedPath) + if err != nil { + return nil, nil, fmt.Errorf("failed to read file: %w", err) + } + + modifiedContent, err := fileutil.ApplyEdits(originalContent, params.Edits) + if err != nil { + return nil, nil, err + } + + return originalContent, modifiedContent, nil +} + func editTextFileCallback(input any) (any, error) { params, err := parseEditTextFileInput(input) if err != nil { diff --git a/pkg/util/fileutil/fileutil.go b/pkg/util/fileutil/fileutil.go index 708eb5c725..f8f12ca4c8 100644 --- a/pkg/util/fileutil/fileutil.go +++ b/pkg/util/fileutil/fileutil.go @@ -4,6 +4,7 @@ package fileutil import ( + "bytes" "fmt" "io" "io/fs" @@ -261,6 +262,31 @@ type EditSpec struct { Desc string `json:"desc,omitempty"` } +// 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. +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) + } + + 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) + } + + modifiedContents = bytes.Replace(modifiedContents, oldBytes, []byte(edit.NewStr), 1) + } + + return modifiedContents, nil +} + func ReplaceInFile(filePath string, edits []EditSpec) error { fileInfo, err := os.Stat(filePath) if err != nil { @@ -280,25 +306,12 @@ func ReplaceInFile(filePath string, edits []EditSpec) error { return fmt.Errorf("failed to read file: %w", err) } - modifiedContents := string(contents) - - for i, edit := range edits { - if edit.OldStr == "" { - return fmt.Errorf("edit %d (%s): OldStr cannot be empty", i, edit.Desc) - } - - count := strings.Count(modifiedContents, edit.OldStr) - if count == 0 { - return fmt.Errorf("edit %d (%s): OldStr not found in file", i, edit.Desc) - } - if count > 1 { - return fmt.Errorf("edit %d (%s): OldStr appears %d times, must appear exactly once", i, edit.Desc, count) - } - - modifiedContents = strings.Replace(modifiedContents, edit.OldStr, edit.NewStr, 1) + modifiedContents, err := ApplyEdits(contents, edits) + if err != nil { + return err } - if err := os.WriteFile(filePath, []byte(modifiedContents), fileInfo.Mode()); err != nil { + if err := os.WriteFile(filePath, modifiedContents, fileInfo.Mode()); err != nil { return fmt.Errorf("failed to write file: %w", err) } diff --git a/pkg/wshutil/wshrpc.go b/pkg/wshutil/wshrpc.go index 128987137e..ff6bec23e8 100644 --- a/pkg/wshutil/wshrpc.go +++ b/pkg/wshutil/wshrpc.go @@ -10,6 +10,7 @@ import ( "fmt" "log" "reflect" + "runtime/pprof" "sync" "sync/atomic" "time" @@ -268,6 +269,12 @@ func (w *WshRpc) cancelRequest(reqId string) { } func (w *WshRpc) handleRequest(req *RpcMessage) { + pprof.Do(context.Background(), pprof.Labels("rpc", req.Command), func(ctx context.Context) { + w.handleRequestInternal(req) + }) +} + +func (w *WshRpc) handleRequestInternal(req *RpcMessage) { // events first if req.Command == wshrpc.Command_EventRecv { if req.Data == nil { From 17575df0820cc4abb92cec32bd03651d8f9d344d Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 29 Oct 2025 16:01:27 -0700 Subject: [PATCH 07/19] rpc call for getting the tool diff, verify func so we dont ask for approval if a tool will fail input validation, cleanup of tool call func --- frontend/app/store/wshclientapi.ts | 5 + frontend/types/gotypes.d.ts | 12 ++ pkg/aiusechat/openai/openai-convertmessage.go | 15 ++ pkg/aiusechat/tools_writefile.go | 62 +++++++- pkg/aiusechat/uctypes/usechat-types.go | 40 +++--- pkg/aiusechat/usechat.go | 135 ++++++++++++++---- pkg/wshrpc/wshclient/wshclient.go | 6 + pkg/wshrpc/wshrpctypes.go | 12 ++ pkg/wshrpc/wshserver/wshserver.go | 12 ++ 9 files changed, 250 insertions(+), 49 deletions(-) diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index c10f3547b7..02950236ee 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -542,6 +542,11 @@ class RpcApiType { return client.wshRpcCall("waveaienabletelemetry", null, opts); } + // command "waveaigettooldiff" [call] + WaveAIGetToolDiffCommand(client: WshClient, data: CommandWaveAIGetToolDiffData, opts?: RpcOpts): Promise { + return client.wshRpcCall("waveaigettooldiff", data, opts); + } + // command "waveaitoolapprove" [call] WaveAIToolApproveCommand(client: WshClient, data: CommandWaveAIToolApproveData, opts?: RpcOpts): Promise { return client.wshRpcCall("waveaitoolapprove", data, opts); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 93147c67f6..fcff599dc6 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -394,6 +394,18 @@ declare global { newchat?: boolean; }; + // wshrpc.CommandWaveAIGetToolDiffData + type CommandWaveAIGetToolDiffData = { + chatid: string; + toolcallid: string; + }; + + // wshrpc.CommandWaveAIGetToolDiffRtnData + type CommandWaveAIGetToolDiffRtnData = { + originalcontents64: string; + modifiedcontents64: string; + }; + // wshrpc.CommandWaveAIToolApproveData type CommandWaveAIToolApproveData = { toolcallid: string; diff --git a/pkg/aiusechat/openai/openai-convertmessage.go b/pkg/aiusechat/openai/openai-convertmessage.go index e3505e8938..f0d3c47c57 100644 --- a/pkg/aiusechat/openai/openai-convertmessage.go +++ b/pkg/aiusechat/openai/openai-convertmessage.go @@ -654,3 +654,18 @@ func ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) { Messages: uiMessages, }, nil } + +// GetFunctionCallInputByToolCallId returns the OpenAIFunctionCallInput associated with the given ToolCallId, +// or nil if not found in the AIChat +func GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *OpenAIFunctionCallInput { + for _, nativeMsg := range aiChat.NativeMessages { + openaiMsg, ok := nativeMsg.(*OpenAIChatMessage) + if !ok { + continue + } + if openaiMsg.FunctionCall != nil && openaiMsg.FunctionCall.CallId == toolCallId { + return openaiMsg.FunctionCall + } + } + return nil +} diff --git a/pkg/aiusechat/tools_writefile.go b/pkg/aiusechat/tools_writefile.go index a6f3157d02..0229ef4a53 100644 --- a/pkg/aiusechat/tools_writefile.go +++ b/pkg/aiusechat/tools_writefile.go @@ -101,6 +101,26 @@ func parseWriteTextFileInput(input any) (*writeTextFileParams, error) { return result, nil } +func verifyWriteTextFileInput(input any) error { + params, err := parseWriteTextFileInput(input) + if err != nil { + return err + } + + expandedPath, err := wavebase.ExpandHomeDir(params.Filename) + if err != nil { + return fmt.Errorf("failed to expand path: %w", err) + } + + contentsBytes := []byte(params.Contents) + if utilfn.HasBinaryData(contentsBytes) { + return fmt.Errorf("contents appear to contain binary data") + } + + _, err = validateTextFile(expandedPath, "write to", false) + return err +} + func writeTextFileCallback(input any) (any, error) { params, err := parseWriteTextFileInput(input) if err != nil { @@ -179,6 +199,7 @@ func GetWriteTextFileToolDefinition() uctypes.ToolDefinition { ToolApproval: func(input any) string { return uctypes.ApprovalNeedsApproval }, + ToolVerifyInput: verifyWriteTextFileInput, } } @@ -209,9 +230,24 @@ func parseEditTextFileInput(input any) (*editTextFileParams, error) { return result, nil } +func verifyEditTextFileInput(input any) error { + params, err := parseEditTextFileInput(input) + if err != nil { + return err + } + + expandedPath, err := wavebase.ExpandHomeDir(params.Filename) + if err != nil { + return fmt.Errorf("failed to expand path: %w", err) + } + + _, err = validateTextFile(expandedPath, "edit", true) + return err +} + // EditTextFileDryRun applies edits to a file and returns the original and modified content // without writing to disk. Takes the same input format as editTextFileCallback. -func EditTextFileDryRun(input any) ([]byte, []byte, error) { +func EditTextFileDryRun(input any, fileOverride string) ([]byte, []byte, error) { params, err := parseEditTextFileInput(input) if err != nil { return nil, nil, err @@ -227,7 +263,12 @@ func EditTextFileDryRun(input any) ([]byte, []byte, error) { return nil, nil, err } - originalContent, err := os.ReadFile(expandedPath) + readPath := expandedPath + if fileOverride != "" { + readPath = fileOverride + } + + originalContent, err := os.ReadFile(readPath) if err != nil { return nil, nil, fmt.Errorf("failed to read file: %w", err) } @@ -326,6 +367,7 @@ func GetEditTextFileToolDefinition() uctypes.ToolDefinition { ToolApproval: func(input any) string { return uctypes.ApprovalNeedsApproval }, + ToolVerifyInput: verifyEditTextFileInput, } } @@ -351,6 +393,21 @@ func parseDeleteTextFileInput(input any) (*deleteTextFileParams, error) { return result, nil } +func verifyDeleteTextFileInput(input any) error { + params, err := parseDeleteTextFileInput(input) + if err != nil { + return err + } + + expandedPath, err := wavebase.ExpandHomeDir(params.Filename) + if err != nil { + return fmt.Errorf("failed to expand path: %w", err) + } + + _, err = validateTextFile(expandedPath, "delete", true) + return err +} + func deleteTextFileCallback(input any) (any, error) { params, err := parseDeleteTextFileInput(input) if err != nil { @@ -412,5 +469,6 @@ func GetDeleteTextFileToolDefinition() uctypes.ToolDefinition { ToolApproval: func(input any) string { return uctypes.ApprovalNeedsApproval }, + ToolVerifyInput: verifyDeleteTextFileInput, } } diff --git a/pkg/aiusechat/uctypes/usechat-types.go b/pkg/aiusechat/uctypes/usechat-types.go index 19a1aefa5d..5fdee2ba0c 100644 --- a/pkg/aiusechat/uctypes/usechat-types.go +++ b/pkg/aiusechat/uctypes/usechat-types.go @@ -87,6 +87,7 @@ type ToolDefinition struct { ToolAnyCallback func(any) (any, error) `json:"-"` ToolInputDesc func(any) string `json:"-"` ToolApproval func(any) string `json:"-"` + ToolVerifyInput func(any) error `json:"-"` } func (td *ToolDefinition) Clean() *ToolDefinition { @@ -134,13 +135,14 @@ const ( ) type UIMessageDataToolUse struct { - ToolCallId string `json:"toolcallid"` - ToolName string `json:"toolname"` - ToolDesc string `json:"tooldesc"` - Status string `json:"status"` - ErrorMessage string `json:"errormessage,omitempty"` - Approval string `json:"approval,omitempty"` - BlockId string `json:"blockid,omitempty"` + ToolCallId string `json:"toolcallid"` + ToolName string `json:"toolname"` + ToolDesc string `json:"tooldesc"` + Status string `json:"status"` + ErrorMessage string `json:"errormessage,omitempty"` + Approval string `json:"approval,omitempty"` + BlockId string `json:"blockid,omitempty"` + WriteBackupFileName string `json:"writebackupfilename,omitempty"` } func (d *UIMessageDataToolUse) IsApproved() bool { @@ -418,18 +420,18 @@ func (m *UIMessage) GetContent() string { } type WaveChatOpts struct { - ChatId string - ClientId string - Config AIOptsType - Tools []ToolDefinition - SystemPrompt []string - TabStateGenerator func() (string, []ToolDefinition, string, error) - BuilderAppGenerator func() (string, string, error) - WidgetAccess bool - RegisterToolApproval func(string) - AllowNativeWebSearch bool - BuilderId string - BuilderAppId string + ChatId string + ClientId string + Config AIOptsType + Tools []ToolDefinition + SystemPrompt []string + TabStateGenerator func() (string, []ToolDefinition, string, error) + BuilderAppGenerator func() (string, string, error) + WidgetAccess bool + RegisterToolApproval func(string) + AllowNativeWebSearch bool + BuilderId string + BuilderAppId string // ephemeral to the step TabState string diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index 273c4e7a07..fd49b8a1ff 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -26,6 +26,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/util/logutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveappstore" + "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/web/sse" "github.com/wavetermdev/waveterm/pkg/wps" @@ -286,32 +287,20 @@ func updateToolUseDataInChat(chatOpts uctypes.WaveChatOpts, toolCallID string, t } } -func processToolCall(toolCall uctypes.WaveToolCall, chatOpts uctypes.WaveChatOpts, sseHandler *sse.SSEHandlerCh, metrics *uctypes.AIMetrics) uctypes.AIToolResult { - +func processToolCallInternal(toolCall uctypes.WaveToolCall, chatOpts uctypes.WaveChatOpts, toolDef *uctypes.ToolDefinition, sseHandler *sse.SSEHandlerCh) uctypes.AIToolResult { if toolCall.ToolUseData == nil { - errorMsg := "Invalid Tool Call" - log.Printf(" error=%s\n", errorMsg) - metrics.ToolUseErrorCount++ return uctypes.AIToolResult{ ToolName: toolCall.Name, ToolUseID: toolCall.ID, - ErrorText: errorMsg, + ErrorText: "Invalid Tool Call", } } - inputJSON, _ := json.Marshal(toolCall.Input) - logutil.DevPrintf("TOOLUSE name=%s id=%s input=%s approval=%q\n", toolCall.Name, toolCall.ID, utilfn.TruncateString(string(inputJSON), 40), toolCall.ToolUseData.Approval) - if toolCall.ToolUseData.Status == uctypes.ToolUseStatusError { errorMsg := toolCall.ToolUseData.ErrorMessage if errorMsg == "" { errorMsg = "Unspecified Tool Error" } - log.Printf(" error=%s\n", errorMsg) - metrics.ToolUseErrorCount++ - _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData) - updateToolUseDataInChat(chatOpts, toolCall.ID, toolCall.ToolUseData) - return uctypes.AIToolResult{ ToolName: toolCall.Name, ToolUseID: toolCall.ID, @@ -319,6 +308,19 @@ func processToolCall(toolCall uctypes.WaveToolCall, chatOpts uctypes.WaveChatOpt } } + if toolDef != nil && toolDef.ToolVerifyInput != nil { + if err := toolDef.ToolVerifyInput(toolCall.Input); err != nil { + errorMsg := fmt.Sprintf("Input validation failed: %v", err) + toolCall.ToolUseData.Status = uctypes.ToolUseStatusError + toolCall.ToolUseData.ErrorMessage = errorMsg + return uctypes.AIToolResult{ + ToolName: toolCall.Name, + ToolUseID: toolCall.ID, + ErrorText: errorMsg, + } + } + } + if toolCall.ToolUseData.Approval == uctypes.ApprovalNeedsApproval { log.Printf(" waiting for approval...\n") approval := WaitForToolApproval(toolCall.ID) @@ -334,13 +336,8 @@ func processToolCall(toolCall uctypes.WaveToolCall, chatOpts uctypes.WaveChatOpt } else if approval == uctypes.ApprovalTimeout { errorMsg = "Tool approval timed out" } - log.Printf(" error=%s\n", errorMsg) - metrics.ToolUseErrorCount++ toolCall.ToolUseData.Status = uctypes.ToolUseStatusError toolCall.ToolUseData.ErrorMessage = errorMsg - _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData) - updateToolUseDataInChat(chatOpts, toolCall.ID, toolCall.ToolUseData) - return uctypes.AIToolResult{ ToolName: toolCall.Name, ToolUseID: toolCall.ID, @@ -348,29 +345,45 @@ func processToolCall(toolCall uctypes.WaveToolCall, chatOpts uctypes.WaveChatOpt } } + // this still happens here because we need to update the FE to say the tool call was approved _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData) updateToolUseDataInChat(chatOpts, toolCall.ID, toolCall.ToolUseData) } result := ResolveToolCall(toolCall, chatOpts) - // Track tool usage by ToolLogName - toolDef := chatOpts.GetToolDefinition(toolCall.Name) - if toolDef != nil && toolDef.ToolLogName != "" { - metrics.ToolDetail[toolDef.ToolLogName]++ - } - if result.ErrorText != "" { toolCall.ToolUseData.Status = uctypes.ToolUseStatusError toolCall.ToolUseData.ErrorMessage = result.ErrorText + } else { + toolCall.ToolUseData.Status = uctypes.ToolUseStatusCompleted + } + + return result +} + +func processToolCall(toolCall uctypes.WaveToolCall, chatOpts uctypes.WaveChatOpts, sseHandler *sse.SSEHandlerCh, metrics *uctypes.AIMetrics) uctypes.AIToolResult { + inputJSON, _ := json.Marshal(toolCall.Input) + logutil.DevPrintf("TOOLUSE name=%s id=%s input=%s approval=%q\n", toolCall.Name, toolCall.ID, utilfn.TruncateString(string(inputJSON), 40), toolCall.ToolUseData.Approval) + + toolDef := chatOpts.GetToolDefinition(toolCall.Name) + result := processToolCallInternal(toolCall, chatOpts, toolDef, sseHandler) + + if result.ErrorText != "" { log.Printf(" error=%s\n", result.ErrorText) metrics.ToolUseErrorCount++ } else { - toolCall.ToolUseData.Status = uctypes.ToolUseStatusCompleted log.Printf(" result=%s\n", utilfn.TruncateString(result.Text, 40)) } - _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData) - updateToolUseDataInChat(chatOpts, toolCall.ID, toolCall.ToolUseData) + + if toolDef != nil && toolDef.ToolLogName != "" { + metrics.ToolDetail[toolDef.ToolLogName]++ + } + + if toolCall.ToolUseData != nil { + _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData) + updateToolUseDataInChat(chatOpts, toolCall.ID, toolCall.ToolUseData) + } return result } @@ -786,3 +799,69 @@ func WaveAIGetChatHandler(w http.ResponseWriter, r *http.Request) { return } } + +// CreateWriteTextFileDiff generates a diff for write_text_file or edit_text_file tool calls. +// Returns the original content, modified content, and any error. +// For Anthropic, this returns an unimplemented error. +func CreateWriteTextFileDiff(ctx context.Context, chatId string, toolCallId string) ([]byte, []byte, error) { + aiChat := chatstore.DefaultChatStore.Get(chatId) + if aiChat == nil { + return nil, nil, fmt.Errorf("chat not found: %s", chatId) + } + + if aiChat.APIType == APIType_Anthropic { + return nil, nil, fmt.Errorf("CreateWriteTextFileDiff is not implemented for Anthropic") + } + + if aiChat.APIType != APIType_OpenAI { + return nil, nil, fmt.Errorf("unsupported API type: %s", aiChat.APIType) + } + + funcCallInput := openai.GetFunctionCallInputByToolCallId(*aiChat, toolCallId) + if funcCallInput == nil { + return nil, nil, fmt.Errorf("tool call not found: %s", toolCallId) + } + + toolName := funcCallInput.Name + if toolName != "write_text_file" && toolName != "edit_text_file" { + return nil, nil, fmt.Errorf("tool call %s is not a write_text_file or edit_text_file (got: %s)", toolCallId, toolName) + } + + var backupFileName string + if funcCallInput.ToolUseData != nil { + backupFileName = funcCallInput.ToolUseData.WriteBackupFileName + } + + if toolName == "edit_text_file" { + originalContent, modifiedContent, err := EditTextFileDryRun(funcCallInput.Arguments, backupFileName) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate diff: %w", err) + } + return originalContent, modifiedContent, nil + } + + params, err := parseWriteTextFileInput(funcCallInput.Arguments) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse write_text_file input: %w", err) + } + + var originalContent []byte + if backupFileName != "" { + originalContent, err = os.ReadFile(backupFileName) + if err != nil { + return nil, nil, fmt.Errorf("failed to read backup file: %w", err) + } + } else { + expandedPath, err := wavebase.ExpandHomeDir(params.Filename) + if err != nil { + return nil, nil, fmt.Errorf("failed to expand path: %w", err) + } + originalContent, err = os.ReadFile(expandedPath) + if err != nil && !os.IsNotExist(err) { + return nil, nil, fmt.Errorf("failed to read original file: %w", err) + } + } + + modifiedContent := []byte(params.Contents) + return originalContent, modifiedContent, nil +} diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 82cf14b00d..1ee753ac52 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -647,6 +647,12 @@ func WaveAIEnableTelemetryCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error return err } +// command "waveaigettooldiff", wshserver.WaveAIGetToolDiffCommand +func WaveAIGetToolDiffCommand(w *wshutil.WshRpc, data wshrpc.CommandWaveAIGetToolDiffData, opts *wshrpc.RpcOpts) (*wshrpc.CommandWaveAIGetToolDiffRtnData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.CommandWaveAIGetToolDiffRtnData](w, "waveaigettooldiff", data, opts) + return resp, err +} + // command "waveaitoolapprove", wshserver.WaveAIToolApproveCommand func WaveAIToolApproveCommand(w *wshutil.WshRpc, data wshrpc.CommandWaveAIToolApproveData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "waveaitoolapprove", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index a15075663d..6b7a33b357 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -144,6 +144,7 @@ const ( Command_GetWaveAIRateLimit = "getwaveairatelimit" Command_WaveAIToolApprove = "waveaitoolapprove" Command_WaveAIAddContext = "waveaiaddcontext" + Command_WaveAIGetToolDiff = "waveaigettooldiff" Command_CaptureBlockScreenshot = "captureblockscreenshot" @@ -287,6 +288,7 @@ type WshRpcInterface interface { GetWaveAIRateLimitCommand(ctx context.Context) (*uctypes.RateLimitInfo, error) WaveAIToolApproveCommand(ctx context.Context, data CommandWaveAIToolApproveData) error WaveAIAddContextCommand(ctx context.Context, data CommandWaveAIAddContextData) error + WaveAIGetToolDiffCommand(ctx context.Context, data CommandWaveAIGetToolDiffData) (*CommandWaveAIGetToolDiffRtnData, error) // screenshot CaptureBlockScreenshotCommand(ctx context.Context, data CommandCaptureBlockScreenshotData) (string, error) @@ -774,6 +776,16 @@ type CommandWaveAIAddContextData struct { NewChat bool `json:"newchat,omitempty"` } +type CommandWaveAIGetToolDiffData struct { + ChatId string `json:"chatid"` + ToolCallId string `json:"toolcallid"` +} + +type CommandWaveAIGetToolDiffRtnData struct { + OriginalContents64 string `json:"originalcontents64"` + ModifiedContents64 string `json:"modifiedcontents64"` +} + type CommandCaptureBlockScreenshotData struct { BlockId string `json:"blockid" wshcontext:"BlockId"` } diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index df90065157..bb4ce2a529 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -1098,6 +1098,18 @@ func (ws *WshServer) WaveAIToolApproveCommand(ctx context.Context, data wshrpc.C return aiusechat.UpdateToolApproval(data.ToolCallId, data.Approval, data.KeepAlive) } +func (ws *WshServer) WaveAIGetToolDiffCommand(ctx context.Context, data wshrpc.CommandWaveAIGetToolDiffData) (*wshrpc.CommandWaveAIGetToolDiffRtnData, error) { + originalContent, modifiedContent, err := aiusechat.CreateWriteTextFileDiff(ctx, data.ChatId, data.ToolCallId) + if err != nil { + return nil, err + } + + return &wshrpc.CommandWaveAIGetToolDiffRtnData{ + OriginalContents64: base64.StdEncoding.EncodeToString(originalContent), + ModifiedContents64: base64.StdEncoding.EncodeToString(modifiedContent), + }, nil +} + var wshActivityRe = regexp.MustCompile(`^[a-z:#]+$`) func (ws *WshServer) WshActivityCommand(ctx context.Context, data map[string]int) error { From b9e23653f45a8525ecb70af5ebbb2841da82b0c6 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 30 Oct 2025 14:17:16 -0700 Subject: [PATCH 08/19] update roadmap --- ROADMAP.md | 132 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 77 insertions(+), 55 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a4fab2951a..2cfd899c4c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,59 +6,81 @@ Want input on the roadmap? Join the discussion on [Discord](https://discord.gg/X Legend: βœ… Done | πŸ”§ In Progress | πŸ”· Planned | 🀞 Stretch Goal -## v0.11.0 - -Released on 1/25/25 - -- βœ… File/Directory Preview improvements - - βœ… Reworked fileshare layer running over RPC - - βœ… Expanded URI types supported by `wsh file ...` - - βœ… EC-TIME timeout when transferring large files -- βœ… Fixes for reducing 2FA requests on connect -- βœ… WebLinks in the terminal working again -- βœ… Search in Web Views -- βœ… Search in the Terminal -- βœ… Custom init files for widgets and terminal blocks -- βœ… Multi-Input between terminal blocks on the same tab -- βœ… Gemini AI support -- βœ… Various Connection Bugs + Improvements -- βœ… More Connection Config Options - -## v0.11.1 - -Targeting 1/31/25 - -- πŸ”§ Reduce main-line 2FA requests to 1 per connection -- πŸ”§ Remote S3 bucket browsing (directory + files) -- πŸ”· Drag & drop between preview blocks -- πŸ”· Drag into/out of a preview block from native file explorer -- πŸ”· Wave Apps (Go SDK) -- πŸ”· JSON schema support (basic) -- 🀞 Frontend Only Widgets, React + Babel Transpiling in an iframe/webview - -## v0.12 - -Targeting mid-February. - -- πŸ”· Import/Export Tab Layouts and Widgets -- πŸ”· log viewer -- πŸ”· binary viewer -- πŸ”· New layout actions (splitting, replacing blocks) -- πŸ”· Rewrite of window/tab system -- πŸ”· Minimized / Non-Visible blocks -- πŸ”· Custom keybindings to quickly switch / invoke built-in and custom widgets -- πŸ”· More Drag & Drop support of files/URLs to create blocks -- πŸ”· Tab Templates - -## Planned (Unscheduled) - -- πŸ”· Customizable Keybindings - - πŸ”· Launch widgets with custom keybindings - - πŸ”· Re-assign system keybindings +## Current AI Capabilities + +Wave Terminal's AI assistant is already powerful and continues to evolve. Here's what works today: + +### AI Provider Support + +- βœ… OpenAI (including gpt-5 and gpt-5-mini models) + +### Context & Input + +- βœ… Widget context integration - AI sees your open terminals, web views, and other widgets +- βœ… Image and document upload - Attach images and files to conversations +- βœ… Local file reading - Read text files and directory listings on local machine +- βœ… Web search - Native web search capability for current information +- βœ… Shell integration awareness - AI understands terminal state (shell, version, OS, etc.) + +### Widget Interaction Tools + +- βœ… Widget screenshots - Capture visual state of any widget +- βœ… Terminal scrollback access - Read terminal history and output +- βœ… Web navigation - Control browser widgets + +## ROADMAP Enhanced AI Capabilities + +### AI Configuration & Flexibility + +- πŸ”· BYOK (Bring Your Own Key) - Use your own API keys for any supported provider +- πŸ”§ Enhanced provider configuration options + +### Expanded Provider Support + +Top priorities are Claude (for better coding support), and the OpenAI Completions API which will allow us to interface with +many more local/open models. + +- πŸ”· Anthropic Claude - Full integration with extended thinking and tool use +- πŸ”· OpenAI Completions API - Support for older model formats +- 🀞 Google Gemini - Complete integration +- 🀞 Local AI agents - Run AI models locally on your machine + +### Advanced AI Tools + +#### File Operations + +- πŸ”§ AI file writing with intelligent diff previews +- πŸ”§ Rollback support for AI-made changes +- πŸ”· Multi-file editing workflows +- πŸ”· Safe file modification patterns + +#### Terminal Command Execution + +- πŸ”§ Execute commands directly from AI +- πŸ”§ Intelligent terminal state detection +- πŸ”§ Command result capture and parsing + +### Remote & Advanced Capabilities + +- πŸ”· Remote file operations - Read and write files on SSH connections +- πŸ”· Custom AI-powered widgets (Tsunami framework) +- πŸ”· AI Can spawn Wave Blocks +- πŸ”· Drag&Drop from Preview Widgets to Wave AI + +### Wave AI Widget Builder + +- πŸ”· Visual builder for creating custom AI-powered widgets +- πŸ”· Template library for common AI workflows +- πŸ”· Rapid prototyping and iteration tools + +## Other Platform & UX Improvements (Non AI) + +- πŸ”· Import/Export tab layouts and widgets +- πŸ”§ Enhanced layout actions (splitting, replacing blocks) +- πŸ”· Extended drag & drop for files/URLs +- πŸ”· Tab templates for quick workspace setup +- πŸ”· Advanced keybinding customization + - πŸ”· Widget launch shortcuts + - πŸ”· System keybinding reassignment - πŸ”· Command Palette -- πŸ”· AI Context -- πŸ”· Monaco Theming -- πŸ”· File system watching for Preview -- πŸ”· File system watching for drag and drop -- 🀞 Explore VSCode Extension Compatibility with standalone Monaco Editor (language servers) -- 🀞 VSCode File Icons in Preview +- πŸ”· Monaco Editor theming From 0367cc27b11301abebeecfb740e8e898ba06f290 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 30 Oct 2025 15:35:07 -0700 Subject: [PATCH 09/19] add a diff viewer view, hook up to edit_text_file and write_text_file. --- ROADMAP.md | 1 + docs/docs/config.mdx | 1 + frontend/app/aipanel/aitooluse.tsx | 69 ++++++---- frontend/app/aipanel/aitypes.ts | 3 + frontend/app/aipanel/waveai-model.tsx | 25 +++- frontend/app/block/block.tsx | 2 + frontend/app/store/keymodel.ts | 20 +++ frontend/app/view/aifilediff/aifilediff.tsx | 136 ++++++++++++++++++++ frontend/app/view/codeeditor/diffviewer.tsx | 69 ++++++++++ frontend/types/gotypes.d.ts | 3 + pkg/aiusechat/tools_writefile.go | 12 +- pkg/aiusechat/uctypes/usechat-types.go | 1 + pkg/aiusechat/usechat.go | 29 ++++- pkg/waveobj/metaconsts.go | 3 + pkg/waveobj/wtypemeta.go | 3 + pkg/wconfig/metaconsts.go | 1 + pkg/wconfig/settingsconfig.go | 1 + schema/settings.json | 3 + 18 files changed, 351 insertions(+), 31 deletions(-) create mode 100644 frontend/app/view/aifilediff/aifilediff.tsx create mode 100644 frontend/app/view/codeeditor/diffviewer.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 2cfd899c4c..20e472b52a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,6 +34,7 @@ Wave Terminal's AI assistant is already powerful and continues to evolve. Here's - πŸ”· BYOK (Bring Your Own Key) - Use your own API keys for any supported provider - πŸ”§ Enhanced provider configuration options +- πŸ”· Context (add markdown files to give persistent system context) ### Expanded Provider Support diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 5f016d70d9..2eb77ded60 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -64,6 +64,7 @@ wsh editconfig | editor:stickyscrollenabled | bool | enables monaco editor's stickyScroll feature (pinning headers of current context, e.g. class names, method names, etc.), defaults to false | | editor:wordwrap | bool | set to true to enable word wrapping in the editor (defaults to false) | | editor:fontsize | float64 | set the font size for the editor (defaults to 12px) | +| editor:inlinediff | bool | set to true to show diffs inline instead of side-by-side, false for side-by-side (defaults to undefined which uses Monaco's responsive behavior) | | preview:showhiddenfiles | bool | set to false to disable showing hidden files in the directory preview (defaults to true) | | markdown:fontsize | float64 | font size for the normal text when rendering markdown in preview. headers are scaled up from this size, (default 14px) | | markdown:fixedfontsize | float64 | font size for the code blocks when rendering markdown in preview (default is 12px) | diff --git a/frontend/app/aipanel/aitooluse.tsx b/frontend/app/aipanel/aitooluse.tsx index 3230fc1036..73e61007ad 100644 --- a/frontend/app/aipanel/aitooluse.tsx +++ b/frontend/app/aipanel/aitooluse.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { BlockModel } from "@/app/block/block-model"; -import { cn } from "@/util/util"; +import { cn, fireAndForget } from "@/util/util"; import { memo, useEffect, useRef, useState } from "react"; import { WaveUIMessagePart } from "./aitypes"; import { WaveAIModel } from "./waveai-model"; @@ -141,6 +141,8 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { const baseApproval = userApprovalOverride || toolData.approval; const effectiveApproval = !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval; + const isFileWriteTool = toolData.toolname === "write_text_file" || toolData.toolname === "edit_text_file"; + useEffect(() => { if (!isStreaming || effectiveApproval !== "needs-approval") return; @@ -204,6 +206,10 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { } }; + const handleOpenDiff = () => { + fireAndForget(() => WaveAIModel.getInstance().openDiff(toolData.inputfilename, toolData.toolcallid)); + }; + return (
{ )}
+ {isFileWriteTool && toolData.inputfilename && ( + + )} ); }); @@ -232,47 +248,50 @@ interface AIToolUseGroupProps { isStreaming: boolean; } +type ToolGroupItem = + | { type: "batch"; parts: Array } + | { type: "single"; part: WaveUIMessagePart & { type: "data-tooluse" } }; + export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) => { const isFileOp = (part: WaveUIMessagePart & { type: "data-tooluse" }) => { const toolName = part.data?.toolname; return toolName === "read_text_file" || toolName === "read_dir"; }; - const fileOpsNeedApproval: Array = []; - const fileOpsNoApproval: Array = []; - const otherTools: Array = []; + const groupedItems: ToolGroupItem[] = []; + let currentBatch: Array = []; for (const part of parts) { if (isFileOp(part)) { - if (part.data.approval === "needs-approval") { - fileOpsNeedApproval.push(part); - } else { - fileOpsNoApproval.push(part); - } + currentBatch.push(part); } else { - otherTools.push(part); + if (currentBatch.length > 0) { + groupedItems.push({ type: "batch", parts: currentBatch }); + currentBatch = []; + } + groupedItems.push({ type: "single", part }); } } + if (currentBatch.length > 0) { + groupedItems.push({ type: "batch", parts: currentBatch }); + } + return ( <> - {fileOpsNoApproval.length > 0 && ( -
- -
+ {groupedItems.map((item, idx) => + item.type === "batch" ? ( +
+ +
+ ) : ( +
+ +
+ ) )} - {fileOpsNeedApproval.length > 0 && ( -
- -
- )} - {otherTools.map((tool, idx) => ( -
- -
- ))} ); }); -AIToolUseGroup.displayName = "AIToolUseGroup"; \ No newline at end of file +AIToolUseGroup.displayName = "AIToolUseGroup"; diff --git a/frontend/app/aipanel/aitypes.ts b/frontend/app/aipanel/aitypes.ts index 921eb6368f..83a741fc4e 100644 --- a/frontend/app/aipanel/aitypes.ts +++ b/frontend/app/aipanel/aitypes.ts @@ -4,12 +4,14 @@ import { ChatRequestOptions, FileUIPart, UIMessage, UIMessagePart } from "ai"; type WaveUIDataTypes = { + // pkg/aiusechat/uctypes/usechat-types.go UIMessageDataUserFile userfile: { filename: string; size: number; mimetype: string; previewurl?: string; }; + // pkg/aiusechat/uctypes/usechat-types.go UIMessageDataToolUse tooluse: { toolcallid: string; toolname: string; @@ -18,6 +20,7 @@ type WaveUIDataTypes = { errormessage?: string; approval?: "needs-approval" | "user-approved" | "user-denied" | "auto-approved" | "timeout"; blockid?: string; + inputfilename?: string; }; }; diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx index 98942100b9..457603e485 100644 --- a/frontend/app/aipanel/waveai-model.tsx +++ b/frontend/app/aipanel/waveai-model.tsx @@ -8,7 +8,7 @@ import { WaveUIMessagePart, } from "@/app/aipanel/aitypes"; import { FocusManager } from "@/app/store/focusManager"; -import { atoms, getOrefMetaKeyAtom } from "@/app/store/global"; +import { atoms, createBlock, getOrefMetaKeyAtom } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import * as WOS from "@/app/store/wos"; import { RpcApi } from "@/app/store/wshclientapi"; @@ -412,6 +412,10 @@ export class WaveAIModel { } } + getChatId(): string { + return globalStore.get(this.chatId); + } + toolUseKeepalive(toolcallid: string) { RpcApi.WaveAIToolApproveCommand( TabRpcClient, @@ -429,4 +433,23 @@ export class WaveAIModel { approval: approval, }); } + + async openDiff(fileName: string, toolcallid: string) { + const chatId = this.getChatId(); + + if (!chatId || !fileName) { + console.error("Missing chatId or fileName for opening diff", chatId, fileName); + return; + } + + const blockDef: BlockDef = { + meta: { + view: "aifilediff", + file: fileName, + "aifilediff:chatid": chatId, + "aifilediff:toolcallid": toolcallid, + }, + }; + await createBlock(blockDef, false, true); + } } diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 81896572f7..f71260a738 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -9,6 +9,7 @@ import { FullSubBlockProps, SubBlockProps, } from "@/app/block/blocktypes"; +import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff"; import { LauncherViewModel } from "@/app/view/launcher/launcher"; import { PreviewModel } from "@/app/view/preview/preview-model"; import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; @@ -50,6 +51,7 @@ BlockRegistry.set("tips", QuickTipsViewModel); BlockRegistry.set("help", HelpViewModel); BlockRegistry.set("launcher", LauncherViewModel); BlockRegistry.set("tsunami", TsunamiViewModel); +BlockRegistry.set("aifilediff", AiFileDiffViewModel); function makeViewModel(blockId: string, blockView: string, nodeModel: BlockNodeModel): ViewModel { const ctor = BlockRegistry.get(blockView); diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index f13e742e95..0c9b037bd7 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -152,10 +152,19 @@ function uxCloseBlock(blockId: string) { return; } } + + const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); + const blockData = globalStore.get(blockAtom); + const isAIFileDiff = blockData?.meta?.view === "aifilediff"; + const layoutModel = getLayoutModelForStaticTab(); const node = layoutModel.getNodeByBlockId(blockId); if (node) { fireAndForget(() => layoutModel.closeNode(node.id)); + + if (isAIFileDiff && isAIPanelOpen) { + setTimeout(() => WaveAIModel.getInstance().focusInput(), 50); + } } } @@ -190,8 +199,19 @@ function genericClose() { simpleCloseStaticTab(); return; } + const layoutModel = getLayoutModelForStaticTab(); + const focusedNode = globalStore.get(layoutModel.focusedNode); + const blockId = focusedNode?.data?.blockId; + const blockAtom = blockId ? WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)) : null; + const blockData = blockAtom ? globalStore.get(blockAtom) : null; + const isAIFileDiff = blockData?.meta?.view === "aifilediff"; + fireAndForget(layoutModel.closeFocusedNode.bind(layoutModel)); + + if (isAIFileDiff && isAIPanelOpen) { + setTimeout(() => WaveAIModel.getInstance().focusInput(), 50); + } } function switchBlockByBlockNum(index: number) { diff --git a/frontend/app/view/aifilediff/aifilediff.tsx b/frontend/app/view/aifilediff/aifilediff.tsx new file mode 100644 index 0000000000..68409b0137 --- /dev/null +++ b/frontend/app/view/aifilediff/aifilediff.tsx @@ -0,0 +1,136 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { DiffViewer } from "@/app/view/codeeditor/diffviewer"; +import { globalStore, WOS } from "@/store/global"; +import * as jotai from "jotai"; +import { useEffect } from "react"; + +type DiffData = { + original: string; + modified: string; + fileName: string; +}; + +export class AiFileDiffViewModel implements ViewModel { + blockId: string; + viewType = "aifilediff"; + blockAtom: jotai.Atom; + diffDataAtom: jotai.PrimitiveAtom; + errorAtom: jotai.PrimitiveAtom; + loadingAtom: jotai.PrimitiveAtom; + viewIcon: jotai.Atom; + viewName: jotai.Atom; + viewText: jotai.Atom; + + constructor(blockId: string) { + this.blockId = blockId; + this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + this.diffDataAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.errorAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.loadingAtom = jotai.atom(true); + this.viewIcon = jotai.atom("file-lines"); + this.viewName = jotai.atom("AI Diff Viewer"); + this.viewText = jotai.atom((get) => { + const diffData = get(this.diffDataAtom); + return diffData?.fileName ?? ""; + }); + } + + get viewComponent(): ViewComponent { + return AiFileDiffView; + } +} + +const AiFileDiffView: React.FC> = ({ blockId, model }) => { + const blockData = jotai.useAtomValue(model.blockAtom); + const diffData = jotai.useAtomValue(model.diffDataAtom); + const error = jotai.useAtomValue(model.errorAtom); + const loading = jotai.useAtomValue(model.loadingAtom); + + useEffect(() => { + async function loadDiffData() { + const chatId = blockData?.meta?.["aifilediff:chatid"]; + const toolCallId = blockData?.meta?.["aifilediff:toolcallid"]; + const fileName = blockData?.meta?.file; + + if (!chatId || !toolCallId) { + globalStore.set(model.errorAtom, "Missing chatId or toolCallId in block metadata"); + globalStore.set(model.loadingAtom, false); + return; + } + + if (!fileName) { + globalStore.set(model.errorAtom, "Missing file name in block metadata"); + globalStore.set(model.loadingAtom, false); + return; + } + + try { + const result = await RpcApi.WaveAIGetToolDiffCommand(TabRpcClient, { + chatid: chatId, + toolcallid: toolCallId, + }); + + if (!result) { + globalStore.set(model.errorAtom, "No diff data returned from server"); + globalStore.set(model.loadingAtom, false); + return; + } + + const originalContent = atob(result.originalcontents64); + const modifiedContent = atob(result.modifiedcontents64); + + globalStore.set(model.diffDataAtom, { + original: originalContent, + modified: modifiedContent, + fileName: fileName, + }); + globalStore.set(model.loadingAtom, false); + } catch (e) { + console.error("Error loading diff data:", e); + globalStore.set(model.errorAtom, `Error loading diff data: ${e.message}`); + globalStore.set(model.loadingAtom, false); + } + } + + loadDiffData(); + }, [blockData?.meta?.["aifilediff:chatid"], blockData?.meta?.["aifilediff:toolcallid"], blockData?.meta?.file]); + + if (loading) { + return ( +
+
Loading diff...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + if (!diffData) { + return ( +
+
No diff data available
+
+ ); + } + + return ( + + ); +}; + +export default AiFileDiffView; diff --git a/frontend/app/view/codeeditor/diffviewer.tsx b/frontend/app/view/codeeditor/diffviewer.tsx new file mode 100644 index 0000000000..b012db55c1 --- /dev/null +++ b/frontend/app/view/codeeditor/diffviewer.tsx @@ -0,0 +1,69 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { useOverrideConfigAtom } from "@/app/store/global"; +import { DiffEditor } from "@monaco-editor/react"; +import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api"; +import React, { useMemo } from "react"; + +import { boundNumber } from "@/util/util"; + +interface DiffViewerProps { + blockId: string; + original: string; + modified: string; + language?: string; + fileName: string; +} + +function defaultDiffEditorOptions(): MonacoTypes.editor.IDiffEditorOptions { + const opts: MonacoTypes.editor.IDiffEditorOptions = { + scrollBeyondLastLine: false, + fontSize: 12, + fontFamily: "Hack", + smoothScrolling: true, + scrollbar: { + useShadows: false, + verticalScrollbarSize: 5, + horizontalScrollbarSize: 5, + }, + minimap: { + enabled: true, + }, + readOnly: true, + renderSideBySide: true, + originalEditable: false, + }; + return opts; +} + +export function DiffViewer({ blockId, original, modified, language, fileName }: DiffViewerProps) { + const minimapEnabled = useOverrideConfigAtom(blockId, "editor:minimapenabled") ?? false; + const fontSize = boundNumber(useOverrideConfigAtom(blockId, "editor:fontsize"), 6, 64); + const inlineDiff = useOverrideConfigAtom(blockId, "editor:inlinediff"); + const theme = "wave-theme-dark"; + + const editorOpts = useMemo(() => { + const opts = defaultDiffEditorOptions(); + opts.minimap.enabled = minimapEnabled; + opts.fontSize = fontSize; + if (inlineDiff != null) { + opts.renderSideBySide = !inlineDiff; + } + return opts; + }, [minimapEnabled, fontSize, inlineDiff]); + + return ( +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index fcff599dc6..ceee4aee85 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -722,6 +722,8 @@ declare global { "ai:apiversion"?: string; "ai:maxtokens"?: number; "ai:timeoutms"?: number; + "aifilediff:chatid"?: string; + "aifilediff:toolcallid"?: string; "editor:*"?: boolean; "editor:minimapenabled"?: boolean; "editor:stickyscrollenabled"?: boolean; @@ -924,6 +926,7 @@ declare global { "editor:stickyscrollenabled"?: boolean; "editor:wordwrap"?: boolean; "editor:fontsize"?: number; + "editor:inlinediff"?: boolean; "web:*"?: boolean; "web:openlinksinternally"?: boolean; "web:defaulturl"?: string; diff --git a/pkg/aiusechat/tools_writefile.go b/pkg/aiusechat/tools_writefile.go index 0229ef4a53..f2ffc6cf87 100644 --- a/pkg/aiusechat/tools_writefile.go +++ b/pkg/aiusechat/tools_writefile.go @@ -346,10 +346,11 @@ func GetEditTextFileToolDefinition() uctypes.ToolDefinition { }, "desc": map[string]any{ "type": "string", - "description": "Description of what this edit does", + "description": "Description of what this edit does (keep it VERY short, one sentence max)", }, }, - "required": []string{"old_str", "new_str"}, + "required": []string{"old_str", "new_str", "desc"}, + "additionalProperties": false, }, }, }, @@ -361,7 +362,12 @@ func GetEditTextFileToolDefinition() uctypes.ToolDefinition { if err != nil { return fmt.Sprintf("error parsing input: %v", err) } - return fmt.Sprintf("editing %q (%d edits)", params.Filename, len(params.Edits)) + editCount := len(params.Edits) + editWord := "edits" + if editCount == 1 { + editWord = "edit" + } + return fmt.Sprintf("editing %q (%d %s)", params.Filename, editCount, editWord) }, ToolAnyCallback: editTextFileCallback, ToolApproval: func(input any) string { diff --git a/pkg/aiusechat/uctypes/usechat-types.go b/pkg/aiusechat/uctypes/usechat-types.go index 5fdee2ba0c..988d8aa3bf 100644 --- a/pkg/aiusechat/uctypes/usechat-types.go +++ b/pkg/aiusechat/uctypes/usechat-types.go @@ -143,6 +143,7 @@ type UIMessageDataToolUse struct { Approval string `json:"approval,omitempty"` BlockId string `json:"blockid,omitempty"` WriteBackupFileName string `json:"writebackupfilename,omitempty"` + InputFileName string `json:"inputfilename,omitempty"` } func (d *UIMessageDataToolUse) IsApproved() bool { diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index fd49b8a1ff..46a834794e 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -296,6 +296,17 @@ func processToolCallInternal(toolCall uctypes.WaveToolCall, chatOpts uctypes.Wav } } + // InputFileName should already be set in processToolCalls, but double-check here + if toolCall.ToolUseData.InputFileName == "" { + if inputMap, ok := toolCall.Input.(map[string]any); ok { + if filename, ok := inputMap["filename"].(string); ok { + toolCall.ToolUseData.InputFileName = filename + } else if filename, ok := inputMap["file_name"].(string); ok { + toolCall.ToolUseData.InputFileName = filename + } + } + } + if toolCall.ToolUseData.Status == uctypes.ToolUseStatusError { errorMsg := toolCall.ToolUseData.ErrorMessage if errorMsg == "" { @@ -397,8 +408,17 @@ func processToolCalls(stopReason *uctypes.WaveStopReason, chatOpts uctypes.WaveC // Send all data-tooluse packets at the beginning for _, toolCall := range stopReason.ToolCalls { if toolCall.ToolUseData != nil { + // Extract filename from tool input for UI display before sending + if inputMap, ok := toolCall.Input.(map[string]any); ok { + if filename, ok := inputMap["filename"].(string); ok { + toolCall.ToolUseData.InputFileName = filename + } else if filename, ok := inputMap["file_name"].(string); ok { + toolCall.ToolUseData.InputFileName = filename + } + } log.Printf("AI data-tooluse %s\n", toolCall.ID) _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData) + updateToolUseDataInChat(chatOpts, toolCall.ID, toolCall.ToolUseData) } } @@ -832,15 +852,20 @@ func CreateWriteTextFileDiff(ctx context.Context, chatId string, toolCallId stri backupFileName = funcCallInput.ToolUseData.WriteBackupFileName } + var parsedArguments any + if err := json.Unmarshal([]byte(funcCallInput.Arguments), &parsedArguments); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal arguments: %w", err) + } + if toolName == "edit_text_file" { - originalContent, modifiedContent, err := EditTextFileDryRun(funcCallInput.Arguments, backupFileName) + originalContent, modifiedContent, err := EditTextFileDryRun(parsedArguments, backupFileName) if err != nil { return nil, nil, fmt.Errorf("failed to generate diff: %w", err) } return originalContent, modifiedContent, nil } - params, err := parseWriteTextFileInput(funcCallInput.Arguments) + params, err := parseWriteTextFileInput(parsedArguments) if err != nil { return nil, nil, fmt.Errorf("failed to parse write_text_file input: %w", err) } diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index ba6ab067b1..64fcc7643b 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -73,6 +73,9 @@ const ( MetaKey_AiMaxTokens = "ai:maxtokens" MetaKey_AiTimeoutMs = "ai:timeoutms" + MetaKey_AiFileDiffChatId = "aifilediff:chatid" + MetaKey_AiFileDiffToolCallId = "aifilediff:toolcallid" + MetaKey_EditorClear = "editor:*" MetaKey_EditorMinimapEnabled = "editor:minimapenabled" MetaKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index 9649e4a189..99a168c126 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -75,6 +75,9 @@ type MetaTSType struct { AiMaxTokens float64 `json:"ai:maxtokens,omitempty"` AiTimeoutMs float64 `json:"ai:timeoutms,omitempty"` + AiFileDiffChatId string `json:"aifilediff:chatid,omitempty"` + AiFileDiffToolCallId string `json:"aifilediff:toolcallid,omitempty"` + EditorClear bool `json:"editor:*,omitempty"` EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"` EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"` diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index ec64ddac57..7f2c364131 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -44,6 +44,7 @@ const ( ConfigKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled" ConfigKey_EditorWordWrap = "editor:wordwrap" ConfigKey_EditorFontSize = "editor:fontsize" + ConfigKey_EditorInlineDiff = "editor:inlinediff" ConfigKey_WebClear = "web:*" ConfigKey_WebOpenLinksInternally = "web:openlinksinternally" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 14a7014157..db2719d9df 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -90,6 +90,7 @@ type SettingsType struct { 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"` diff --git a/schema/settings.json b/schema/settings.json index 3c9ebbc1cb..ff41826645 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -113,6 +113,9 @@ "editor:fontsize": { "type": "number" }, + "editor:inlinediff": { + "type": "boolean" + }, "web:*": { "type": "boolean" }, From 9b3e732cf2610db8c824bd80de52491ecf70f297 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 30 Oct 2025 17:12:02 -0700 Subject: [PATCH 10/19] fix orphaned blocks issue --- frontend/app/store/services.ts | 5 ++++ frontend/app/view/codeeditor/diffviewer.tsx | 4 +-- frontend/layout/lib/layoutModel.ts | 27 +++++++++++++++++++++ pkg/aiusechat/tools.go | 2 ++ pkg/service/blockservice/blockservice.go | 22 +++++++++++++++++ pkg/wcore/layout.go | 1 + 6 files changed, 59 insertions(+), 2 deletions(-) diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index 0d3e887ca5..ed7b519644 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -7,6 +7,11 @@ import * as WOS from "./wos"; // blockservice.BlockService (block) class BlockServiceType { + // queue a layout action to cleanup orphaned blocks in the tab + // @returns object updates + CleanupOrphanedBlocks(tabId: string): Promise { + return WOS.callBackendService("block", "CleanupOrphanedBlocks", Array.from(arguments)) + } GetControllerStatus(arg2: string): Promise { return WOS.callBackendService("block", "GetControllerStatus", Array.from(arguments)) } diff --git a/frontend/app/view/codeeditor/diffviewer.tsx b/frontend/app/view/codeeditor/diffviewer.tsx index b012db55c1..eac0f9e8db 100644 --- a/frontend/app/view/codeeditor/diffviewer.tsx +++ b/frontend/app/view/codeeditor/diffviewer.tsx @@ -4,7 +4,7 @@ import { useOverrideConfigAtom } from "@/app/store/global"; import { DiffEditor } from "@monaco-editor/react"; import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api"; -import React, { useMemo } from "react"; +import { useMemo } from "react"; import { boundNumber } from "@/util/util"; @@ -66,4 +66,4 @@ export function DiffViewer({ blockId, original, modified, language, fileName }: ); -} \ No newline at end of file +} diff --git a/frontend/layout/lib/layoutModel.ts b/frontend/layout/lib/layoutModel.ts index e066afa27a..4d1b7935f4 100644 --- a/frontend/layout/lib/layoutModel.ts +++ b/frontend/layout/lib/layoutModel.ts @@ -3,6 +3,7 @@ import { FocusManager } from "@/app/store/focusManager"; import { getSettingsKeyAtom } from "@/app/store/global"; +import { BlockService } from "@/app/store/services"; import { atomWithThrottle, boundNumber, fireAndForget } from "@/util/util"; import { Atom, atom, Getter, PrimitiveAtom, Setter } from "jotai"; import { splitAtom } from "jotai/utils"; @@ -406,6 +407,26 @@ export class LayoutModel { this.persistToBackend(); } + private async cleanupOrphanedBlocks() { + const tab = this.getter(this.tabAtom); + const layoutBlockIds = new Set(); + + walkNodes(this.treeState.rootNode, (node) => { + if (node.data?.blockId) { + layoutBlockIds.add(node.data.blockId); + } + }); + + for (const blockId of tab.blockids || []) { + if (!layoutBlockIds.has(blockId)) { + console.log("Cleaning up orphaned block:", blockId); + if (this.onNodeDelete) { + await this.onNodeDelete({ blockId }); + } + } + } + } + private async handleBackendAction(action: LayoutActionData) { switch (action.actiontype) { case LayoutTreeActionType.InsertNode: { @@ -537,6 +558,10 @@ export class LayoutModel { this.treeReducer(splitAction, false); break; } + case "cleanuporphaned": { + await this.cleanupOrphanedBlocks(); + break; + } default: console.warn("unsupported layout action", action); break; @@ -574,6 +599,8 @@ export class LayoutModel { if (contents.gapSizePx !== undefined) { this.setter(this.gapSizePx, contents.gapSizePx); } + const tab = this.getter(this.tabAtom); + fireAndForget(() => BlockService.CleanupOrphanedBlocks(tab.oid)); } /** diff --git a/pkg/aiusechat/tools.go b/pkg/aiusechat/tools.go index 84ae0bdf1c..a095d05aae 100644 --- a/pkg/aiusechat/tools.go +++ b/pkg/aiusechat/tools.go @@ -120,6 +120,8 @@ func MakeBlockShortDesc(block *waveobj.Block) string { return "placeholder widget used to launch other widgets" case "tsunami": return handleTsunamiBlockDesc(block) + case "aifilediff": + return "" // AI doesn't need to see these default: return fmt.Sprintf("unknown widget with type %q", viewType) } diff --git a/pkg/service/blockservice/blockservice.go b/pkg/service/blockservice/blockservice.go index 68d8bc9980..4770931935 100644 --- a/pkg/service/blockservice/blockservice.go +++ b/pkg/service/blockservice/blockservice.go @@ -9,10 +9,12 @@ import ( "fmt" "time" + "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -87,3 +89,23 @@ func (bs *BlockService) SaveWaveAiData(ctx context.Context, blockId string, hist } return nil } + +func (*BlockService) CleanupOrphanedBlocks_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + Desc: "queue a layout action to cleanup orphaned blocks in the tab", + ArgNames: []string{"ctx", "tabId"}, + } +} + +func (bs *BlockService) CleanupOrphanedBlocks(ctx context.Context, tabId string) (waveobj.UpdatesRtnType, error) { + ctx = waveobj.ContextWithUpdates(ctx) + layoutAction := waveobj.LayoutActionData{ + ActionType: wcore.LayoutActionDataType_CleanupOrphaned, + ActionId: uuid.NewString(), + } + err := wcore.QueueLayoutActionForTab(ctx, tabId, layoutAction) + if err != nil { + return nil, fmt.Errorf("error queuing cleanup layout action: %w", err) + } + return waveobj.ContextGetUpdatesRtn(ctx), nil +} diff --git a/pkg/wcore/layout.go b/pkg/wcore/layout.go index 6a5b7c22f6..173b8218e4 100644 --- a/pkg/wcore/layout.go +++ b/pkg/wcore/layout.go @@ -22,6 +22,7 @@ const ( LayoutActionDataType_Replace = "replace" LayoutActionDataType_SplitHorizontal = "splithorizontal" LayoutActionDataType_SplitVertical = "splitvertical" + LayoutActionDataType_CleanupOrphaned = "cleanuporphaned" ) type PortableLayout []struct { From c9a0400e8ccaa4001e243c65cfb7bb3125ebdcba Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 30 Oct 2025 17:16:23 -0700 Subject: [PATCH 11/19] add border to ephemeral blocks even when not focused... --- frontend/app/block/blockframe.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 7920c5e8b3..d15f5df02b 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -18,9 +18,9 @@ import { WOS, } from "@/app/store/global"; import { uxCloseBlock } from "@/app/store/keymodel"; -import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { ErrorBoundary } from "@/element/errorboundary"; import { IconButton, ToggleIconButton } from "@/element/iconbutton"; import { MagnifyIcon } from "@/element/magnify"; @@ -482,6 +482,7 @@ const ConnStatusOverlay = React.memo( const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => { const isFocused = jotai.useAtomValue(nodeModel.isFocused); + const isEphemeral = jotai.useAtomValue(nodeModel.isEphemeral); const blockNum = jotai.useAtomValue(nodeModel.blockNum); const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); const showOverlayBlockNums = jotai.useAtomValue(getSettingsKeyAtom("app:showoverlayblocknums")) ?? true; @@ -489,7 +490,7 @@ const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => { const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); const style: React.CSSProperties = {}; let showBlockMask = false; - + if (isFocused) { const tabData = jotai.useAtomValue(atoms.tabAtom); const tabActiveBorderColor = tabData?.meta?.["bg:activebordercolor"]; @@ -508,12 +509,15 @@ const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => { if (blockData?.meta?.["frame:bordercolor"]) { style.borderColor = blockData.meta["frame:bordercolor"]; } + if (isEphemeral && !style.borderColor) { + style.borderColor = "rgba(255, 255, 255, 0.7)"; + } } - + if (blockHighlight && !style.borderColor) { style.borderColor = "rgb(59, 130, 246)"; } - + let innerElem = null; if (isLayoutMode && showOverlayBlockNums) { showBlockMask = true; @@ -531,9 +535,12 @@ const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => { ); } - + return ( -
+
{innerElem}
); From ed7d421a5f2f2855f25217cdb428971ba322c1fb Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 30 Oct 2025 18:30:57 -0700 Subject: [PATCH 12/19] fix tool grouping and ordering --- frontend/app/aipanel/aitooluse.tsx | 49 +++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/frontend/app/aipanel/aitooluse.tsx b/frontend/app/aipanel/aitooluse.tsx index 73e61007ad..dc6a34a9f0 100644 --- a/frontend/app/aipanel/aitooluse.tsx +++ b/frontend/app/aipanel/aitooluse.tsx @@ -7,6 +7,10 @@ import { memo, useEffect, useRef, useState } from "react"; import { WaveUIMessagePart } from "./aitypes"; import { WaveAIModel } from "./waveai-model"; +function getEffectiveApprovalStatus(baseApproval: string, isStreaming: boolean): string { + return !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval; +} + interface AIToolApprovalButtonsProps { count: number; onApprove: () => void; @@ -73,10 +77,10 @@ interface AIToolUseBatchProps { const AIToolUseBatch = memo(({ parts, isStreaming }: AIToolUseBatchProps) => { const [userApprovalOverride, setUserApprovalOverride] = useState(null); + // All parts in a batch have the same approval status (enforced by grouping logic in AIToolUseGroup) const firstTool = parts[0].data; const baseApproval = userApprovalOverride || firstTool.approval; - const effectiveApproval = !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval; - const allNeedApproval = parts.every((p) => (userApprovalOverride || p.data.approval) === "needs-approval"); + const effectiveApproval = getEffectiveApprovalStatus(baseApproval, isStreaming); useEffect(() => { if (!isStreaming || effectiveApproval !== "needs-approval") return; @@ -113,7 +117,7 @@ const AIToolUseBatch = memo(({ parts, isStreaming }: AIToolUseBatchProps) => { ))}
- {allNeedApproval && effectiveApproval === "needs-approval" && ( + {effectiveApproval === "needs-approval" && ( )} @@ -139,7 +143,7 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { toolData.status === "completed" ? "text-success" : toolData.status === "error" ? "text-error" : "text-gray-400"; const baseApproval = userApprovalOverride || toolData.approval; - const effectiveApproval = !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval; + const effectiveApproval = getEffectiveApprovalStatus(baseApproval, isStreaming); const isFileWriteTool = toolData.toolname === "write_text_file" || toolData.toolname === "edit_text_file"; @@ -258,23 +262,40 @@ export const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) return toolName === "read_text_file" || toolName === "read_dir"; }; - const groupedItems: ToolGroupItem[] = []; - let currentBatch: Array = []; + const needsApproval = (part: WaveUIMessagePart & { type: "data-tooluse" }) => { + return getEffectiveApprovalStatus(part.data?.approval, isStreaming) === "needs-approval"; + }; + + const readFileNeedsApproval: Array = []; + const readFileOther: Array = []; for (const part of parts) { if (isFileOp(part)) { - currentBatch.push(part); - } else { - if (currentBatch.length > 0) { - groupedItems.push({ type: "batch", parts: currentBatch }); - currentBatch = []; + if (needsApproval(part)) { + readFileNeedsApproval.push(part); + } else { + readFileOther.push(part); } - groupedItems.push({ type: "single", part }); } } - if (currentBatch.length > 0) { - groupedItems.push({ type: "batch", parts: currentBatch }); + const groupedItems: ToolGroupItem[] = []; + let addedApprovalBatch = false; + let addedOtherBatch = false; + + for (const part of parts) { + const isFileOpPart = isFileOp(part); + const partNeedsApproval = needsApproval(part); + + if (isFileOpPart && partNeedsApproval && !addedApprovalBatch) { + groupedItems.push({ type: "batch", parts: readFileNeedsApproval }); + addedApprovalBatch = true; + } else if (isFileOpPart && !partNeedsApproval && !addedOtherBatch) { + groupedItems.push({ type: "batch", parts: readFileOther }); + addedOtherBatch = true; + } else if (!isFileOpPart) { + groupedItems.push({ type: "single", part }); + } } return ( From ed75e098dc578c4b73ad327f4675256cacb5a3e7 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 30 Oct 2025 18:41:19 -0700 Subject: [PATCH 13/19] fix layout of tooluse box... --- frontend/app/aipanel/aimessage.tsx | 4 ++- frontend/app/aipanel/aitooluse.tsx | 39 ++++++++++++++++-------------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/frontend/app/aipanel/aimessage.tsx b/frontend/app/aipanel/aimessage.tsx index 7a4eecfdd0..10bd6c2620 100644 --- a/frontend/app/aipanel/aimessage.tsx +++ b/frontend/app/aipanel/aimessage.tsx @@ -214,7 +214,9 @@ export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => {
*:first-child]:!mt-0", - message.role === "user" ? "py-2 bg-accent-800 text-white max-w-[calc(100%-20px)]" : null + message.role === "user" + ? "py-2 bg-accent-800 text-white max-w-[calc(100%-20px)]" + : "min-w-[min(100%,500px)]" )} > {displayParts.length === 0 && !isStreaming && !thinkingData ? ( diff --git a/frontend/app/aipanel/aitooluse.tsx b/frontend/app/aipanel/aitooluse.tsx index dc6a34a9f0..577f3a656d 100644 --- a/frontend/app/aipanel/aitooluse.tsx +++ b/frontend/app/aipanel/aitooluse.tsx @@ -216,30 +216,33 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { return (
- {statusIcon} -
+
+ {statusIcon}
{toolData.toolname}
- {toolData.tooldesc &&
{toolData.tooldesc}
} - {(toolData.errormessage || effectiveApproval === "timeout") && ( -
{toolData.errormessage || "Not approved"}
- )} - {effectiveApproval === "needs-approval" && ( - +
+ {isFileWriteTool && toolData.inputfilename && ( + )}
- {isFileWriteTool && toolData.inputfilename && ( - + {toolData.tooldesc &&
{toolData.tooldesc}
} + {(toolData.errormessage || effectiveApproval === "timeout") && ( +
{toolData.errormessage || "Not approved"}
+ )} + {effectiveApproval === "needs-approval" && ( +
+ +
)}
); From 1075526b375a903173454c9dea465771be961cae Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 30 Oct 2025 20:24:20 -0700 Subject: [PATCH 14/19] set WriteBackupFileName and InputFileName more consistently in the tool calls by passing the toolusedata struct --- pkg/aiusechat/tools.go | 2 +- pkg/aiusechat/tools_builder.go | 6 ++-- pkg/aiusechat/tools_readdir.go | 2 +- pkg/aiusechat/tools_readdir_test.go | 10 +++--- pkg/aiusechat/tools_readfile.go | 2 +- pkg/aiusechat/tools_term.go | 4 +-- pkg/aiusechat/tools_tsunami.go | 8 ++--- pkg/aiusechat/tools_web.go | 2 +- pkg/aiusechat/tools_writefile.go | 42 ++++++++++++++++++-------- pkg/aiusechat/uctypes/usechat-types.go | 25 +++++++-------- pkg/aiusechat/usechat.go | 15 ++------- pkg/filebackup/filebackup.go | 24 ++++++++------- 12 files changed, 77 insertions(+), 65 deletions(-) diff --git a/pkg/aiusechat/tools.go b/pkg/aiusechat/tools.go index a095d05aae..ab443c9037 100644 --- a/pkg/aiusechat/tools.go +++ b/pkg/aiusechat/tools.go @@ -271,7 +271,7 @@ func GetAdderToolDefinition() uctypes.ToolDefinition { "required": []string{"values"}, "additionalProperties": false, }, - ToolAnyCallback: func(input any) (any, error) { + ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { inputMap, ok := input.(map[string]any) if !ok { return nil, fmt.Errorf("invalid input format") diff --git a/pkg/aiusechat/tools_builder.go b/pkg/aiusechat/tools_builder.go index 4e36cb2dd4..c6fd0c4443 100644 --- a/pkg/aiusechat/tools_builder.go +++ b/pkg/aiusechat/tools_builder.go @@ -58,7 +58,7 @@ func GetBuilderWriteAppFileToolDefinition(appId string) uctypes.ToolDefinition { ToolInputDesc: func(input any) string { return fmt.Sprintf("writing app.go for %s", appId) }, - ToolAnyCallback: func(input any) (any, error) { + ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseBuilderWriteAppFileInput(input) if err != nil { return nil, err @@ -149,7 +149,7 @@ func GetBuilderEditAppFileToolDefinition(appId string) uctypes.ToolDefinition { } return fmt.Sprintf("editing app.go for %s (%d edits)", appId, len(params.Edits)) }, - ToolAnyCallback: func(input any) (any, error) { + ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseBuilderEditAppFileInput(input) if err != nil { return nil, err @@ -188,7 +188,7 @@ func GetBuilderListFilesToolDefinition(appId string) uctypes.ToolDefinition { ToolInputDesc: func(input any) string { return fmt.Sprintf("listing files for %s", appId) }, - ToolAnyCallback: func(input any) (any, error) { + ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { result, err := waveappstore.ListAllAppFiles(appId) if err != nil { return nil, err diff --git a/pkg/aiusechat/tools_readdir.go b/pkg/aiusechat/tools_readdir.go index e2ff281775..cb75dd58ae 100644 --- a/pkg/aiusechat/tools_readdir.go +++ b/pkg/aiusechat/tools_readdir.go @@ -50,7 +50,7 @@ func parseReadDirInput(input any) (*readDirParams, error) { return result, nil } -func readDirCallback(input any) (any, error) { +func readDirCallback(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseReadDirInput(input) if err != nil { return nil, err diff --git a/pkg/aiusechat/tools_readdir_test.go b/pkg/aiusechat/tools_readdir_test.go index 305c0bfcbd..d73d5a6d89 100644 --- a/pkg/aiusechat/tools_readdir_test.go +++ b/pkg/aiusechat/tools_readdir_test.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" ) func TestReadDirCallback(t *testing.T) { @@ -39,7 +41,7 @@ func TestReadDirCallback(t *testing.T) { "path": tmpDir, } - result, err := readDirCallback(input) + result, err := readDirCallback(input, &uctypes.UIMessageDataToolUse{}) if err != nil { t.Fatalf("readDirCallback failed: %v", err) } @@ -100,7 +102,7 @@ func TestReadDirOnFile(t *testing.T) { "path": tmpFile.Name(), } - _, err = readDirCallback(input) + _, err = readDirCallback(input, &uctypes.UIMessageDataToolUse{}) if err == nil { t.Fatalf("Expected error when reading a file with read_dir, got nil") } @@ -134,7 +136,7 @@ func TestReadDirMaxEntries(t *testing.T) { "max_entries": maxEntries, } - result, err := readDirCallback(input) + result, err := readDirCallback(input, &uctypes.UIMessageDataToolUse{}) if err != nil { t.Fatalf("readDirCallback failed: %v", err) } @@ -200,7 +202,7 @@ func TestReadDirSortBeforeTruncate(t *testing.T) { "max_entries": maxEntries, } - result, err := readDirCallback(input) + result, err := readDirCallback(input, &uctypes.UIMessageDataToolUse{}) if err != nil { t.Fatalf("readDirCallback failed: %v", err) } diff --git a/pkg/aiusechat/tools_readfile.go b/pkg/aiusechat/tools_readfile.go index 7067f05ed9..f8cf4b7556 100644 --- a/pkg/aiusechat/tools_readfile.go +++ b/pkg/aiusechat/tools_readfile.go @@ -198,7 +198,7 @@ func isBlockedFile(expandedPath string) (bool, string) { } -func readTextFileCallback(input any) (any, error) { +func readTextFileCallback(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { const ReadLimit = 1024 * 1024 * 1024 params, err := parseReadTextFileInput(input) diff --git a/pkg/aiusechat/tools_term.go b/pkg/aiusechat/tools_term.go index 57c9164bbe..8d9040df16 100644 --- a/pkg/aiusechat/tools_term.go +++ b/pkg/aiusechat/tools_term.go @@ -190,7 +190,7 @@ func GetTermGetScrollbackToolDefinition(tabId string) uctypes.ToolDefinition { lineEnd := parsed.LineStart + parsed.Count return fmt.Sprintf("reading terminal output from %s (lines %d-%d)", parsed.WidgetId, parsed.LineStart, lineEnd) }, - ToolAnyCallback: func(input any) (any, error) { + ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { parsed, err := parseTermGetScrollbackInput(input) if err != nil { return nil, err @@ -266,7 +266,7 @@ func GetTermCommandOutputToolDefinition(tabId string) uctypes.ToolDefinition { } return fmt.Sprintf("reading last command output from %s", parsed.WidgetId) }, - ToolAnyCallback: func(input any) (any, error) { + ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { parsed, err := parseTermCommandOutputInput(input) if err != nil { return nil, err diff --git a/pkg/aiusechat/tools_tsunami.go b/pkg/aiusechat/tools_tsunami.go index 98e4bad199..e6ab8756fd 100644 --- a/pkg/aiusechat/tools_tsunami.go +++ b/pkg/aiusechat/tools_tsunami.go @@ -31,8 +31,8 @@ func handleTsunamiBlockDesc(block *waveobj.Block) string { return "tsunami widget - unknown description" } -func makeTsunamiGetCallback(status *blockcontroller.BlockControllerRuntimeStatus, apiPath string) func(any) (any, error) { - return func(input any) (any, error) { +func makeTsunamiGetCallback(status *blockcontroller.BlockControllerRuntimeStatus, apiPath string) func(any, *uctypes.UIMessageDataToolUse) (any, error) { + return func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { if status.TsunamiPort == 0 { return nil, fmt.Errorf("tsunami port not available") } @@ -66,8 +66,8 @@ func makeTsunamiGetCallback(status *blockcontroller.BlockControllerRuntimeStatus } } -func makeTsunamiPostCallback(status *blockcontroller.BlockControllerRuntimeStatus, apiPath string) func(any) (any, error) { - return func(input any) (any, error) { +func makeTsunamiPostCallback(status *blockcontroller.BlockControllerRuntimeStatus, apiPath string) func(any, *uctypes.UIMessageDataToolUse) (any, error) { + return func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { if status.TsunamiPort == 0 { return nil, fmt.Errorf("tsunami port not available") } diff --git a/pkg/aiusechat/tools_web.go b/pkg/aiusechat/tools_web.go index 3ead729ccd..c707c14479 100644 --- a/pkg/aiusechat/tools_web.go +++ b/pkg/aiusechat/tools_web.go @@ -77,7 +77,7 @@ func GetWebNavigateToolDefinition(tabId string) uctypes.ToolDefinition { } return fmt.Sprintf("navigating web widget %s to %q", parsed.WidgetId, parsed.Url) }, - ToolAnyCallback: func(input any) (any, error) { + ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { parsed, err := parseWebNavigateInput(input) if err != nil { return nil, err diff --git a/pkg/aiusechat/tools_writefile.go b/pkg/aiusechat/tools_writefile.go index f2ffc6cf87..5d6ffeebcc 100644 --- a/pkg/aiusechat/tools_writefile.go +++ b/pkg/aiusechat/tools_writefile.go @@ -101,7 +101,7 @@ func parseWriteTextFileInput(input any) (*writeTextFileParams, error) { return result, nil } -func verifyWriteTextFileInput(input any) error { +func verifyWriteTextFileInput(input any, toolUseData *uctypes.UIMessageDataToolUse) error { params, err := parseWriteTextFileInput(input) if err != nil { return err @@ -118,10 +118,15 @@ func verifyWriteTextFileInput(input any) error { } _, err = validateTextFile(expandedPath, "write to", false) - return err + if err != nil { + return err + } + + toolUseData.InputFileName = params.Filename + return nil } -func writeTextFileCallback(input any) (any, error) { +func writeTextFileCallback(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseWriteTextFileInput(input) if err != nil { return nil, err @@ -149,10 +154,11 @@ func writeTextFileCallback(input any) (any, error) { } if fileInfo != nil { - err = filebackup.MakeFileBackup(expandedPath) + backupPath, err := filebackup.MakeFileBackup(expandedPath) if err != nil { return nil, fmt.Errorf("failed to create backup: %w", err) } + toolUseData.WriteBackupFileName = backupPath } err = os.WriteFile(expandedPath, contentsBytes, 0644) @@ -230,7 +236,7 @@ func parseEditTextFileInput(input any) (*editTextFileParams, error) { return result, nil } -func verifyEditTextFileInput(input any) error { +func verifyEditTextFileInput(input any, toolUseData *uctypes.UIMessageDataToolUse) error { params, err := parseEditTextFileInput(input) if err != nil { return err @@ -242,7 +248,12 @@ func verifyEditTextFileInput(input any) error { } _, err = validateTextFile(expandedPath, "edit", true) - return err + if err != nil { + return err + } + + toolUseData.InputFileName = params.Filename + return nil } // EditTextFileDryRun applies edits to a file and returns the original and modified content @@ -281,7 +292,7 @@ func EditTextFileDryRun(input any, fileOverride string) ([]byte, []byte, error) return originalContent, modifiedContent, nil } -func editTextFileCallback(input any) (any, error) { +func editTextFileCallback(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseEditTextFileInput(input) if err != nil { return nil, err @@ -297,10 +308,11 @@ func editTextFileCallback(input any) (any, error) { return nil, err } - err = filebackup.MakeFileBackup(expandedPath) + backupPath, err := filebackup.MakeFileBackup(expandedPath) if err != nil { return nil, fmt.Errorf("failed to create backup: %w", err) } + toolUseData.WriteBackupFileName = backupPath err = fileutil.ReplaceInFile(expandedPath, params.Edits) if err != nil { @@ -399,7 +411,7 @@ func parseDeleteTextFileInput(input any) (*deleteTextFileParams, error) { return result, nil } -func verifyDeleteTextFileInput(input any) error { +func verifyDeleteTextFileInput(input any, toolUseData *uctypes.UIMessageDataToolUse) error { params, err := parseDeleteTextFileInput(input) if err != nil { return err @@ -411,10 +423,15 @@ func verifyDeleteTextFileInput(input any) error { } _, err = validateTextFile(expandedPath, "delete", true) - return err + if err != nil { + return err + } + + toolUseData.InputFileName = params.Filename + return nil } -func deleteTextFileCallback(input any) (any, error) { +func deleteTextFileCallback(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseDeleteTextFileInput(input) if err != nil { return nil, err @@ -430,10 +447,11 @@ func deleteTextFileCallback(input any) (any, error) { return nil, err } - err = filebackup.MakeFileBackup(expandedPath) + backupPath, err := filebackup.MakeFileBackup(expandedPath) if err != nil { return nil, fmt.Errorf("failed to create backup: %w", err) } + toolUseData.WriteBackupFileName = backupPath err = os.Remove(expandedPath) if err != nil { diff --git a/pkg/aiusechat/uctypes/usechat-types.go b/pkg/aiusechat/uctypes/usechat-types.go index 988d8aa3bf..cfd785c892 100644 --- a/pkg/aiusechat/uctypes/usechat-types.go +++ b/pkg/aiusechat/uctypes/usechat-types.go @@ -76,18 +76,19 @@ type UIMessageDataUserFile struct { // ToolDefinition represents a tool that can be used by the AI model type ToolDefinition struct { - Name string `json:"name"` - DisplayName string `json:"displayname,omitempty"` // internal field (cannot marshal to API, must be stripped) - Description string `json:"description"` - ShortDescription string `json:"shortdescription,omitempty"` // internal field (cannot marshal to API, must be stripped) - ToolLogName string `json:"-"` // short name for telemetry (e.g., "term:getscrollback") - InputSchema map[string]any `json:"input_schema"` - Strict bool `json:"strict,omitempty"` - ToolTextCallback func(any) (string, error) `json:"-"` - ToolAnyCallback func(any) (any, error) `json:"-"` - ToolInputDesc func(any) string `json:"-"` - ToolApproval func(any) string `json:"-"` - ToolVerifyInput func(any) error `json:"-"` + Name string `json:"name"` + DisplayName string `json:"displayname,omitempty"` // internal field (cannot marshal to API, must be stripped) + Description string `json:"description"` + ShortDescription string `json:"shortdescription,omitempty"` // internal field (cannot marshal to API, must be stripped) + ToolLogName string `json:"-"` // short name for telemetry (e.g., "term:getscrollback") + InputSchema map[string]any `json:"input_schema"` + Strict bool `json:"strict,omitempty"` + + ToolTextCallback func(any) (string, error) `json:"-"` + ToolAnyCallback func(any, *UIMessageDataToolUse) (any, error) `json:"-"` + ToolInputDesc func(any) string `json:"-"` + ToolApproval func(any) string `json:"-"` + ToolVerifyInput func(any, *UIMessageDataToolUse) error `json:"-"` } func (td *ToolDefinition) Clean() *ToolDefinition { diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index 46a834794e..77c06a5c17 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -296,17 +296,6 @@ func processToolCallInternal(toolCall uctypes.WaveToolCall, chatOpts uctypes.Wav } } - // InputFileName should already be set in processToolCalls, but double-check here - if toolCall.ToolUseData.InputFileName == "" { - if inputMap, ok := toolCall.Input.(map[string]any); ok { - if filename, ok := inputMap["filename"].(string); ok { - toolCall.ToolUseData.InputFileName = filename - } else if filename, ok := inputMap["file_name"].(string); ok { - toolCall.ToolUseData.InputFileName = filename - } - } - } - if toolCall.ToolUseData.Status == uctypes.ToolUseStatusError { errorMsg := toolCall.ToolUseData.ErrorMessage if errorMsg == "" { @@ -320,7 +309,7 @@ func processToolCallInternal(toolCall uctypes.WaveToolCall, chatOpts uctypes.Wav } if toolDef != nil && toolDef.ToolVerifyInput != nil { - if err := toolDef.ToolVerifyInput(toolCall.Input); err != nil { + if err := toolDef.ToolVerifyInput(toolCall.Input, toolCall.ToolUseData); err != nil { errorMsg := fmt.Sprintf("Input validation failed: %v", err) toolCall.ToolUseData.Status = uctypes.ToolUseStatusError toolCall.ToolUseData.ErrorMessage = errorMsg @@ -573,7 +562,7 @@ func ResolveToolCall(toolCall uctypes.WaveToolCall, chatOpts uctypes.WaveChatOpt result.Text = text } } else if toolDef.ToolAnyCallback != nil { - output, err := toolDef.ToolAnyCallback(toolCall.Input) + output, err := toolDef.ToolAnyCallback(toolCall.Input, toolCall.ToolUseData) if err != nil { result.ErrorText = err.Error() } else { diff --git a/pkg/filebackup/filebackup.go b/pkg/filebackup/filebackup.go index d3e4e654dd..a4280fecf8 100644 --- a/pkg/filebackup/filebackup.go +++ b/pkg/filebackup/filebackup.go @@ -17,21 +17,23 @@ import ( "github.com/wavetermdev/waveterm/pkg/wavebase" ) +const BackupRetentionPeriod = 5 * 24 * time.Hour + type BackupMetadata struct { FullPath string `json:"fullpath"` Timestamp string `json:"timestamp"` Perm string `json:"perm"` } -func MakeFileBackup(absFilePath string) error { +func MakeFileBackup(absFilePath string) (string, error) { fileInfo, err := os.Stat(absFilePath) if err != nil { - return fmt.Errorf("failed to stat file for backup: %w", err) + return "", fmt.Errorf("failed to stat file for backup: %w", err) } fileData, err := os.ReadFile(absFilePath) if err != nil { - return fmt.Errorf("failed to read file for backup: %w", err) + return "", fmt.Errorf("failed to read file for backup: %w", err) } dir := filepath.Dir(absFilePath) @@ -42,7 +44,7 @@ func MakeFileBackup(absFilePath string) error { uuidV7, err := uuid.NewV7() if err != nil { - return fmt.Errorf("failed to generate UUID: %w", err) + return "", fmt.Errorf("failed to generate UUID: %w", err) } uuidStr := uuidV7.String() @@ -52,7 +54,7 @@ func MakeFileBackup(absFilePath string) error { backupDir := filepath.Join(wavebase.GetWaveCachesDir(), "waveai-backups", dateStr) err = os.MkdirAll(backupDir, 0700) if err != nil { - return fmt.Errorf("failed to create backup directory: %w", err) + return "", fmt.Errorf("failed to create backup directory: %w", err) } backupName := fmt.Sprintf("%s.%s.%s.bak", basename, dirHash8, uuidStr) @@ -60,7 +62,7 @@ func MakeFileBackup(absFilePath string) error { err = os.WriteFile(backupPath, fileData, 0600) if err != nil { - return fmt.Errorf("failed to write backup file: %w", err) + return "", fmt.Errorf("failed to write backup file: %w", err) } metadata := BackupMetadata{ @@ -71,7 +73,7 @@ func MakeFileBackup(absFilePath string) error { metadataJSON, err := json.MarshalIndent(metadata, "", " ") if err != nil { - return fmt.Errorf("failed to marshal backup metadata: %w", err) + return "", fmt.Errorf("failed to marshal backup metadata: %w", err) } metadataName := fmt.Sprintf("%s.%s.%s.json", basename, dirHash8, uuidStr) @@ -79,10 +81,10 @@ func MakeFileBackup(absFilePath string) error { err = os.WriteFile(metadataPath, metadataJSON, 0600) if err != nil { - return fmt.Errorf("failed to write backup metadata: %w", err) + return "", fmt.Errorf("failed to write backup metadata: %w", err) } - return nil + return backupPath, nil } func CleanupOldBackups() error { @@ -97,7 +99,7 @@ func CleanupOldBackups() error { return fmt.Errorf("failed to read backup directory: %w", err) } - cutoffTime := time.Now().Add(-5 * 24 * time.Hour) + cutoffTime := time.Now().Add(-BackupRetentionPeriod) var removedCount int for _, entry := range entries { @@ -127,4 +129,4 @@ func CleanupOldBackups() error { } return nil -} \ No newline at end of file +} From 02de9a1b8f1c087169161553af431e8b3d9b2f1e Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 31 Oct 2025 09:46:16 -0700 Subject: [PATCH 15/19] add runts to the toolcall data... --- frontend/app/aipanel/aitypes.ts | 1 + pkg/aiusechat/uctypes/usechat-types.go | 3 +++ pkg/aiusechat/usechat.go | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/app/aipanel/aitypes.ts b/frontend/app/aipanel/aitypes.ts index 83a741fc4e..c1aaaeed5e 100644 --- a/frontend/app/aipanel/aitypes.ts +++ b/frontend/app/aipanel/aitypes.ts @@ -17,6 +17,7 @@ type WaveUIDataTypes = { toolname: string; tooldesc: string; status: "pending" | "error" | "completed"; + runts?: number; errormessage?: string; approval?: "needs-approval" | "user-approved" | "user-denied" | "auto-approved" | "timeout"; blockid?: string; diff --git a/pkg/aiusechat/uctypes/usechat-types.go b/pkg/aiusechat/uctypes/usechat-types.go index cfd785c892..55a6804bf9 100644 --- a/pkg/aiusechat/uctypes/usechat-types.go +++ b/pkg/aiusechat/uctypes/usechat-types.go @@ -67,6 +67,7 @@ type UIMessagePart struct { ProviderMetadata map[string]any `json:"providerMetadata,omitempty"` } +// when updating this struct, also modify frontend/app/aipanel/aitypes.ts WaveUIDataTypes.userfile type UIMessageDataUserFile struct { FileName string `json:"filename,omitempty"` Size int `json:"size,omitempty"` @@ -135,11 +136,13 @@ const ( ApprovalAutoApproved = "auto-approved" ) +// when updating this struct, also modify frontend/app/aipanel/aitypes.ts WaveUIDataTypes.tooluse type UIMessageDataToolUse struct { ToolCallId string `json:"toolcallid"` ToolName string `json:"toolname"` ToolDesc string `json:"tooldesc"` Status string `json:"status"` + RunTs int64 `json:"runts,omitempty"` ErrorMessage string `json:"errormessage,omitempty"` Approval string `json:"approval,omitempty"` BlockId string `json:"blockid,omitempty"` diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index 77c06a5c17..a81cf2c66e 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -349,7 +349,8 @@ func processToolCallInternal(toolCall uctypes.WaveToolCall, chatOpts uctypes.Wav _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData) updateToolUseDataInChat(chatOpts, toolCall.ID, toolCall.ToolUseData) } - + + toolCall.ToolUseData.RunTs = time.Now().UnixMilli() result := ResolveToolCall(toolCall, chatOpts) if result.ErrorText != "" { From 478c8c47ad38e5960dff59b60e0787d00c880980 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 31 Oct 2025 12:18:56 -0700 Subject: [PATCH 16/19] working on file revert modal... --- frontend/app/aipanel/aimessage.tsx | 36 ++++++++++----- frontend/app/aipanel/aitooluse.tsx | 64 +++++++++++++++++++++++++++ frontend/app/aipanel/aitypes.ts | 1 + frontend/app/aipanel/waveai-model.tsx | 14 ++++++ frontend/layout/lib/layoutModel.ts | 1 - 5 files changed, 105 insertions(+), 11 deletions(-) diff --git a/frontend/app/aipanel/aimessage.tsx b/frontend/app/aipanel/aimessage.tsx index 10bd6c2620..b1079db1ff 100644 --- a/frontend/app/aipanel/aimessage.tsx +++ b/frontend/app/aipanel/aimessage.tsx @@ -11,7 +11,15 @@ import { WaveUIMessage, WaveUIMessagePart } from "./aitypes"; import { WaveAIModel } from "./waveai-model"; const AIThinking = memo( - ({ message = "AI is thinking...", reasoningText }: { message?: string; reasoningText?: string }) => { + ({ + message = "AI is thinking...", + reasoningText, + isWaitingApproval = false, + }: { + message?: string; + reasoningText?: string; + isWaitingApproval?: boolean; + }) => { const scrollRef = useRef(null); useEffect(() => { @@ -30,17 +38,21 @@ const AIThinking = memo( return (
-
- - - -
+ {isWaitingApproval ? ( + + ) : ( +
+ + + +
+ )} {message && {message}}
{displayText && (
{displayText}
@@ -172,7 +184,7 @@ const getThinkingMessage = ( parts: WaveUIMessagePart[], isStreaming: boolean, role: string -): { message: string; reasoningText?: string } | null => { +): { message: string; reasoningText?: string; isWaitingApproval?: boolean } | null => { if (!isStreaming || role !== "assistant") { return null; } @@ -182,7 +194,7 @@ const getThinkingMessage = ( ); if (hasPendingApprovals) { - return { message: "Waiting for Tool Approvals..." }; + return { message: "Waiting for Tool Approvals...", isWaitingApproval: true }; } const lastPart = parts[parts.length - 1]; @@ -234,7 +246,11 @@ export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => { )} {thinkingData != null && (
- +
)} diff --git a/frontend/app/aipanel/aitooluse.tsx b/frontend/app/aipanel/aitooluse.tsx index 577f3a656d..b372a18fba 100644 --- a/frontend/app/aipanel/aitooluse.tsx +++ b/frontend/app/aipanel/aitooluse.tsx @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { BlockModel } from "@/app/block/block-model"; +import { Modal } from "@/app/modals/modal"; import { cn, fireAndForget } from "@/util/util"; +import { useAtomValue } from "jotai"; import { memo, useEffect, useRef, useState } from "react"; import { WaveUIMessagePart } from "./aitypes"; import { WaveAIModel } from "./waveai-model"; @@ -127,6 +129,54 @@ const AIToolUseBatch = memo(({ parts, isStreaming }: AIToolUseBatchProps) => { AIToolUseBatch.displayName = "AIToolUseBatch"; +interface RestoreBackupModalProps { + part: WaveUIMessagePart & { type: "data-tooluse" }; +} + +const RestoreBackupModal = memo(({ part }: RestoreBackupModalProps) => { + const model = WaveAIModel.getInstance(); + const toolData = part.data; + + const formatTimestamp = (ts: number) => { + if (!ts) return ""; + const date = new Date(ts); + return date.toLocaleString(); + }; + + const handleConfirm = () => { + model.restoreBackup(toolData.toolcallid, toolData.inputfilename); + }; + + const handleCancel = () => { + model.closeRestoreBackupModal(); + }; + + return ( + +
+
Restore File Backup
+
+ This will restore {toolData.inputfilename}{" "} + to its state before this edit was made + {toolData.runts && ({formatTimestamp(toolData.runts)})}. +
+
+ Any changes made by this edit and subsequent edits will be lost. +
+
+
+ ); +}); + +RestoreBackupModal.displayName = "RestoreBackupModal"; + interface AIToolUseProps { part: WaveUIMessagePart & { type: "data-tooluse" }; isStreaming: boolean; @@ -135,6 +185,9 @@ interface AIToolUseProps { const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { const toolData = part.data; const [userApprovalOverride, setUserApprovalOverride] = useState(null); + const model = WaveAIModel.getInstance(); + const restoreModalToolCallId = useAtomValue(model.restoreBackupModalToolCallId); + const showRestoreModal = restoreModalToolCallId === toolData.toolcallid; const highlightTimeoutRef = useRef(null); const highlightedBlockIdRef = useRef(null); @@ -224,6 +277,16 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { {statusIcon}
{toolData.toolname}
+ {isFileWriteTool && toolData.inputfilename && toolData.writebackupfilename && ( + + )} {isFileWriteTool && toolData.inputfilename && (
)} + {showRestoreModal && }
); }); diff --git a/frontend/app/aipanel/aitypes.ts b/frontend/app/aipanel/aitypes.ts index c1aaaeed5e..1b5d4122f9 100644 --- a/frontend/app/aipanel/aitypes.ts +++ b/frontend/app/aipanel/aitypes.ts @@ -21,6 +21,7 @@ type WaveUIDataTypes = { errormessage?: string; approval?: "needs-approval" | "user-approved" | "user-denied" | "auto-approved" | "timeout"; blockid?: string; + writebackupfilename?: string; inputfilename?: string; }; }; diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx index 457603e485..a726e1910d 100644 --- a/frontend/app/aipanel/waveai-model.tsx +++ b/frontend/app/aipanel/waveai-model.tsx @@ -56,6 +56,7 @@ export class WaveAIModel { isChatEmpty: boolean = true; isWaveAIFocusedAtom!: jotai.Atom; panelVisibleAtom!: jotai.Atom; + restoreBackupModalToolCallId: jotai.PrimitiveAtom = jotai.atom(null) as jotai.PrimitiveAtom; private constructor(orefContext: ORef, inBuilder: boolean) { this.orefContext = orefContext; @@ -452,4 +453,17 @@ export class WaveAIModel { }; await createBlock(blockDef, false, true); } + + openRestoreBackupModal(toolcallid: string) { + globalStore.set(this.restoreBackupModalToolCallId, toolcallid); + } + + closeRestoreBackupModal() { + globalStore.set(this.restoreBackupModalToolCallId, null); + } + + async restoreBackup(toolcallid: string, filename: string) { + console.log("Restore backup called for:", { toolcallid, filename }); + this.closeRestoreBackupModal(); + } } diff --git a/frontend/layout/lib/layoutModel.ts b/frontend/layout/lib/layoutModel.ts index 4d1b7935f4..cdd58322c3 100644 --- a/frontend/layout/lib/layoutModel.ts +++ b/frontend/layout/lib/layoutModel.ts @@ -748,7 +748,6 @@ export class LayoutModel { // Process ephemeral node, if present. const ephemeralNode = this.getter(this.ephemeralNode); if (ephemeralNode) { - console.log("updateTree ephemeralNode", ephemeralNode); this.updateEphemeralNodeProps( ephemeralNode, newAdditionalProps, From 84f1cee08378b39f5d659d0fb997b9cf3bf30e9a Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 31 Oct 2025 13:24:24 -0700 Subject: [PATCH 17/19] complete the "restore file" feature for Wave AI write file tools... --- .roo/rules/rules.md | 7 +++ frontend/app/aipanel/aitooluse.tsx | 69 ++++++++++++++++++++++----- frontend/app/aipanel/waveai-model.tsx | 23 +++++++-- frontend/app/modals/modal.tsx | 14 ++++-- frontend/app/store/wshclientapi.ts | 5 ++ frontend/types/gotypes.d.ts | 6 +++ pkg/aiusechat/usechat.go | 8 ++-- pkg/filebackup/filebackup.go | 36 ++++++++++++++ pkg/wshrpc/wshclient/wshclient.go | 6 +++ pkg/wshrpc/wshrpctypes.go | 7 +++ pkg/wshrpc/wshserver/wshserver.go | 7 ++- 11 files changed, 163 insertions(+), 25 deletions(-) diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index 0ac189a328..54ef512159 100644 --- a/.roo/rules/rules.md +++ b/.roo/rules/rules.md @@ -35,6 +35,7 @@ It has a TypeScript/React frontend and a Go backend. They talk together over `ws - Import the "cn" function from "@/util/util" to do classname / clsx class merge (it uses twMerge underneath) - For element variants use class-variance-authority - Do NOT create private fields in classes (they are impossible to inspect) + - Use PascalCase for global consts at the top of files - **Component Practices**: - Make sure to add cursor-pointer to buttons/links and clickable items - NEVER use cursor-help (it looks terrible) @@ -48,6 +49,12 @@ It has a TypeScript/React frontend and a Go backend. They talk together over `ws - _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. +### RPC System + +To define a new RPC call, add the new definition to `pkg/wshrpc/wshrpctypes.go` including any input/output data that is required. After modifying wshrpctypes.go run `task generate` to generate the client APIs. + +For normal "server" RPCs (where a frontend client is calling the main server) you should implement the RPC call in `pkg/wshrpc/wshserver.go`. + ### Code Generation - **TypeScript Types**: TypeScript types are automatically generated from Go types. After modifying Go types in `pkg/wshrpc/wshrpctypes.go`, run `task generate` to update the TypeScript type definitions in `frontend/types/gotypes.d.ts`. diff --git a/frontend/app/aipanel/aitooluse.tsx b/frontend/app/aipanel/aitooluse.tsx index b372a18fba..06a0264c12 100644 --- a/frontend/app/aipanel/aitooluse.tsx +++ b/frontend/app/aipanel/aitooluse.tsx @@ -9,6 +9,9 @@ import { memo, useEffect, useRef, useState } from "react"; import { WaveUIMessagePart } from "./aitypes"; import { WaveAIModel } from "./waveai-model"; +// matches pkg/filebackup/filebackup.go +const BackupRetentionDays = 5; + function getEffectiveApprovalStatus(baseApproval: string, isStreaming: boolean): string { return !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval; } @@ -136,6 +139,8 @@ interface RestoreBackupModalProps { const RestoreBackupModal = memo(({ part }: RestoreBackupModalProps) => { const model = WaveAIModel.getInstance(); const toolData = part.data; + const status = useAtomValue(model.restoreBackupStatus); + const error = useAtomValue(model.restoreBackupError); const formatTimestamp = (ts: number) => { if (!ts) return ""; @@ -144,21 +149,57 @@ const RestoreBackupModal = memo(({ part }: RestoreBackupModalProps) => { }; const handleConfirm = () => { - model.restoreBackup(toolData.toolcallid, toolData.inputfilename); + model.restoreBackup(toolData.toolcallid, toolData.writebackupfilename, toolData.inputfilename); }; const handleCancel = () => { model.closeRestoreBackupModal(); }; + const handleClose = () => { + model.closeRestoreBackupModal(); + }; + + if (status === "success") { + return ( + +
+
Backup Successfully Restored
+
+ The file {toolData.inputfilename} has + been restored to its previous state. +
+
+
+ ); + } + + if (status === "error") { + return ( + +
+
Failed to Restore Backup
+
+ An error occurred while restoring the backup: +
+
{error}
+
+
+ ); + } + + const isProcessing = status === "processing"; + return (
Restore File Backup
@@ -277,16 +318,20 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { {statusIcon}
{toolData.toolname}
- {isFileWriteTool && toolData.inputfilename && toolData.writebackupfilename && ( - - )} + {isFileWriteTool && + toolData.inputfilename && + toolData.writebackupfilename && + toolData.runts && + Date.now() - toolData.runts < BackupRetentionDays * 24 * 60 * 60 * 1000 && ( + + )} {isFileWriteTool && toolData.inputfilename && (
{renderFooter() && ( - + )}
@@ -62,17 +64,19 @@ interface ModalFooterProps { cancelLabel?: string; onOk?: () => void; onCancel?: () => void; + okDisabled?: boolean; + cancelDisabled?: boolean; } -const ModalFooter = ({ onCancel, onOk, cancelLabel = "Cancel", okLabel = "Ok" }: ModalFooterProps) => { +const ModalFooter = ({ onCancel, onOk, cancelLabel = "Cancel", okLabel = "Ok", okDisabled, cancelDisabled }: ModalFooterProps) => { return (
{onCancel && ( - )} - {onOk && } + {onOk && }
); }; diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 02950236ee..e1e71f3bca 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -247,6 +247,11 @@ class RpcApiType { return client.wshRpcStream("filereadstream", data, opts); } + // command "filerestorebackup" [call] + FileRestoreBackupCommand(client: WshClient, data: CommandFileRestoreBackupData, opts?: RpcOpts): Promise { + return client.wshRpcCall("filerestorebackup", data, opts); + } + // command "filesharecapability" [call] FileShareCapabilityCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { return client.wshRpcCall("filesharecapability", data, opts); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index ceee4aee85..86dc207daa 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -240,6 +240,12 @@ declare global { opts?: FileCopyOpts; }; + // wshrpc.CommandFileRestoreBackupData + type CommandFileRestoreBackupData = { + backupfilepath: string; + restoretofilename: string; + }; + // wshrpc.CommandGetMetaData type CommandGetMetaData = { oref: ORef; diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index a81cf2c66e..ffd5ba9390 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -92,9 +92,9 @@ var SystemPromptText_OpenAI = strings.Join([]string{ `If a request would execute dangerous or destructive actions, warn briefly and provide a safer alternative.`, `If output is very long, prefer a brief summary plus a copy-ready fenced block or offer a follow-up chunking strategy.`, - `You cannot yet execute shell commands, run code in the terminal, or directly edit files on disk. You also cannot read remote files.`, - `If the user asks you to do one of those things, say that these features aren’t available yet AND make sure to say that they are coming soon (stay tuned for updates).`, - `Instead, show them exactly what command or code they could copy-paste to perform it manually.`, + `You can write and edit local files on disk using available tools, but you cannot execute shell commands, run code in the terminal, or read/write remote files.`, + `If the user asks you to execute commands or run code, or deal with remote files say that these features aren't available yet AND make sure to say that they are coming soon (stay tuned for updates).`, + `Instead, show them exactly what command or code they could copy-paste to run manually.`, // Final reminder `You have NO API access to widgets or Wave unless provided via an explicit tool.`, @@ -349,7 +349,7 @@ func processToolCallInternal(toolCall uctypes.WaveToolCall, chatOpts uctypes.Wav _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData) updateToolUseDataInChat(chatOpts, toolCall.ID, toolCall.ToolUseData) } - + toolCall.ToolUseData.RunTs = time.Now().UnixMilli() result := ResolveToolCall(toolCall, chatOpts) diff --git a/pkg/filebackup/filebackup.go b/pkg/filebackup/filebackup.go index a4280fecf8..eea4905dcc 100644 --- a/pkg/filebackup/filebackup.go +++ b/pkg/filebackup/filebackup.go @@ -87,6 +87,42 @@ func MakeFileBackup(absFilePath string) (string, error) { return backupPath, nil } +func RestoreBackup(backupFilePath string, restoreToFileName string) error { + backupData, err := os.ReadFile(backupFilePath) + if err != nil { + return fmt.Errorf("failed to read backup file: %w", err) + } + + metadataPath := backupFilePath[:len(backupFilePath)-4] + ".json" + metadataData, err := os.ReadFile(metadataPath) + if err != nil { + return fmt.Errorf("failed to read backup metadata: %w", err) + } + + var metadata BackupMetadata + err = json.Unmarshal(metadataData, &metadata) + if err != nil { + return fmt.Errorf("failed to unmarshal backup metadata: %w", err) + } + + if metadata.FullPath != restoreToFileName { + return fmt.Errorf("backup metadata mismatch: expected %s, got %s", restoreToFileName, metadata.FullPath) + } + + var perm os.FileMode + _, err = fmt.Sscanf(metadata.Perm, "%o", &perm) + if err != nil { + return fmt.Errorf("failed to parse file permissions: %w", err) + } + + err = os.WriteFile(restoreToFileName, backupData, perm) + if err != nil { + return fmt.Errorf("failed to restore file: %w", err) + } + + return nil +} + func CleanupOldBackups() error { backupBaseDir := filepath.Join(wavebase.GetWaveCachesDir(), "waveai-backups") diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 1ee753ac52..2a6210358c 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -303,6 +303,12 @@ func FileReadStreamCommand(w *wshutil.WshRpc, data wshrpc.FileData, opts *wshrpc return sendRpcRequestResponseStreamHelper[wshrpc.FileData](w, "filereadstream", data, opts) } +// command "filerestorebackup", wshserver.FileRestoreBackupCommand +func FileRestoreBackupCommand(w *wshutil.WshRpc, data wshrpc.CommandFileRestoreBackupData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "filerestorebackup", data, opts) + return err +} + // command "filesharecapability", wshserver.FileShareCapabilityCommand func FileShareCapabilityCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (wshrpc.FileShareCapability, error) { resp, err := sendRpcRequestCallHelper[wshrpc.FileShareCapability](w, "filesharecapability", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 6b7a33b357..9b6dd8d8d1 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -82,6 +82,7 @@ const ( Command_FileAppendIJson = "fileappendijson" Command_FileJoin = "filejoin" Command_FileShareCapability = "filesharecapability" + Command_FileRestoreBackup = "filerestorebackup" Command_EventPublish = "eventpublish" Command_EventRecv = "eventrecv" @@ -210,6 +211,7 @@ type WshRpcInterface interface { FileListStreamCommand(ctx context.Context, data FileListData) <-chan RespOrErrorUnion[CommandRemoteListEntriesRtnData] FileShareCapabilityCommand(ctx context.Context, path string) (FileShareCapability, error) + FileRestoreBackupCommand(ctx context.Context, data CommandFileRestoreBackupData) error EventPublishCommand(ctx context.Context, data wps.WaveEvent) error EventSubCommand(ctx context.Context, data wps.SubscriptionRequest) error EventUnsubCommand(ctx context.Context, data string) error @@ -597,6 +599,11 @@ type CommandFileCopyData struct { Opts *FileCopyOpts `json:"opts,omitempty"` } +type CommandFileRestoreBackupData struct { + BackupFilePath string `json:"backupfilepath"` + RestoreToFileName string `json:"restoretofilename"` +} + type CommandRemoteStreamTarData struct { Path string `json:"path"` Opts *FileCopyOpts `json:"opts,omitempty"` diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index bb4ce2a529..73639c42a7 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -26,6 +26,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/buildercontroller" + "github.com/wavetermdev/waveterm/pkg/filebackup" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/genconn" "github.com/wavetermdev/waveterm/pkg/panichandler" @@ -439,6 +440,10 @@ func (ws *WshServer) FileShareCapabilityCommand(ctx context.Context, path string return fileshare.GetCapability(ctx, path) } +func (ws *WshServer) FileRestoreBackupCommand(ctx context.Context, data wshrpc.CommandFileRestoreBackupData) error { + return filebackup.RestoreBackup(data.BackupFilePath, data.RestoreToFileName) +} + func (ws *WshServer) DeleteSubBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error { err := wcore.DeleteBlock(ctx, data.BlockId, false) if err != nil { @@ -1103,7 +1108,7 @@ func (ws *WshServer) WaveAIGetToolDiffCommand(ctx context.Context, data wshrpc.C if err != nil { return nil, err } - + return &wshrpc.CommandWaveAIGetToolDiffRtnData{ OriginalContents64: base64.StdEncoding.EncodeToString(originalContent), ModifiedContents64: base64.StdEncoding.EncodeToString(modifiedContent), From 0186cfae20b7ad603dedffea09f5987d1e047601 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 31 Oct 2025 14:12:09 -0700 Subject: [PATCH 18/19] add this protective return if rootNode is null, even though i don't believe it is possible for rootNode to be null at this point. --- frontend/layout/lib/layoutModel.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/layout/lib/layoutModel.ts b/frontend/layout/lib/layoutModel.ts index cdd58322c3..29f71e1d49 100644 --- a/frontend/layout/lib/layoutModel.ts +++ b/frontend/layout/lib/layoutModel.ts @@ -411,6 +411,10 @@ export class LayoutModel { const tab = this.getter(this.tabAtom); const layoutBlockIds = new Set(); + if (this.treeState.rootNode == null) { + return; + } + walkNodes(this.treeState.rootNode, (node) => { if (node.data?.blockId) { layoutBlockIds.add(node.data.blockId); From c3f691f1dedd3ac10526c45abd8e65302d265de7 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 31 Oct 2025 14:36:17 -0700 Subject: [PATCH 19/19] remove old inputfilename hack code (now set in the ToolVerifyInput callbacks) --- pkg/aiusechat/usechat.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index ffd5ba9390..2d75bc4c1f 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -398,14 +398,6 @@ func processToolCalls(stopReason *uctypes.WaveStopReason, chatOpts uctypes.WaveC // Send all data-tooluse packets at the beginning for _, toolCall := range stopReason.ToolCalls { if toolCall.ToolUseData != nil { - // Extract filename from tool input for UI display before sending - if inputMap, ok := toolCall.Input.(map[string]any); ok { - if filename, ok := inputMap["filename"].(string); ok { - toolCall.ToolUseData.InputFileName = filename - } else if filename, ok := inputMap["file_name"].(string); ok { - toolCall.ToolUseData.InputFileName = filename - } - } log.Printf("AI data-tooluse %s\n", toolCall.ID) _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData) updateToolUseDataInChat(chatOpts, toolCall.ID, toolCall.ToolUseData)