diff --git a/go.mod b/go.mod index b5b9bda2..8017ac76 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,13 @@ module neo-code go 1.25.0 require ( - github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 + golang.design/x/clipboard v0.7.1 golang.org/x/net v0.52.0 golang.org/x/sys v0.42.0 gopkg.in/yaml.v3 v3.0.1 @@ -18,6 +18,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect @@ -57,6 +58,9 @@ require ( github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect + golang.org/x/image v0.28.0 // indirect + golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 59439be3..c4388c9e 100644 --- a/go.sum +++ b/go.sum @@ -130,8 +130,16 @@ github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9 github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c= +golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE= +golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= +golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= +golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= +golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8= +golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/runtime/errors_test.go b/internal/runtime/errors_test.go new file mode 100644 index 00000000..9ae1c427 --- /dev/null +++ b/internal/runtime/errors_test.go @@ -0,0 +1,43 @@ +package runtime + +import ( + "bytes" + "context" + "errors" + "log" + "testing" + + "neo-code/internal/provider" +) + +func TestHandleRunErrorProviderErrorDoesNotWriteStdLog(t *testing.T) { + service := &Service{} + providerErr := &provider.ProviderError{ + StatusCode: 401, + Code: "auth_failed", + Message: "Incorrect API key provided", + Retryable: false, + } + + var buf bytes.Buffer + oldWriter := log.Writer() + oldFlags := log.Flags() + oldPrefix := log.Prefix() + log.SetOutput(&buf) + log.SetFlags(0) + log.SetPrefix("") + t.Cleanup(func() { + log.SetOutput(oldWriter) + log.SetFlags(oldFlags) + log.SetPrefix(oldPrefix) + }) + + err := service.handleRunError(context.Background(), "run-1", "session-1", providerErr) + if !errors.Is(err, providerErr) { + t.Fatalf("expected provider error passthrough, got %v", err) + } + if got := buf.String(); got != "" { + t.Fatalf("expected no std log output, got %q", got) + } + +} diff --git a/internal/runtime/run_lifecycle.go b/internal/runtime/run_lifecycle.go index 52a5015f..057f6dca 100644 --- a/internal/runtime/run_lifecycle.go +++ b/internal/runtime/run_lifecycle.go @@ -3,7 +3,6 @@ package runtime import ( "context" "errors" - "log" "math/rand/v2" "strings" "time" @@ -103,12 +102,6 @@ func (s *Service) handleRunError(ctx context.Context, runID string, sessionID st return context.Canceled } - var providerErr *provider.ProviderError - if errors.As(err, &providerErr) { - log.Printf("runtime: provider error (status=%d, code=%s, retryable=%v): %s", - providerErr.StatusCode, providerErr.Code, providerErr.Retryable, providerErr.Message) - } - return err } diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index a42261cb..ce7a7b72 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -26,7 +26,6 @@ import ( type panel = tuistate.Panel const ( - panelSessions panel = tuistate.PanelSessions panelTranscript panel = tuistate.PanelTranscript panelActivity panel = tuistate.PanelActivity panelInput panel = tuistate.PanelInput @@ -38,6 +37,7 @@ const ( pickerNone pickerMode = tuistate.PickerNone pickerProvider pickerMode = tuistate.PickerProvider pickerModel pickerMode = tuistate.PickerModel + pickerSession pickerMode = tuistate.PickerSession pickerFile pickerMode = tuistate.PickerFile pickerHelp pickerMode = tuistate.PickerHelp ) @@ -72,11 +72,11 @@ type appComponents struct { keys keyMap help help.Model spinner spinner.Model - sessions list.Model commandMenu list.Model commandMenuMeta tuistate.CommandMenuMeta providerPicker list.Model modelPicker list.Model + sessionPicker list.Model helpPicker list.Model fileBrowser filepicker.Model progress progress.Model @@ -88,24 +88,38 @@ type appComponents struct { // appRuntimeState 聚合运行期易变字段,降低 App 顶层字段密度。 type appRuntimeState struct { - codeCopyBlocks map[int]string - pendingCopyID int - deferredEventCmd tea.Cmd - nowFn func() time.Time - lastInputEditAt time.Time - lastPasteLikeAt time.Time - inputBurstStart time.Time - inputBurstCount int - pasteMode bool - activeMessages []providertypes.Message - activities []tuistate.ActivityEntry - fileCandidates []string - modelRefreshID string - focus panel - runProgressValue float64 - runProgressKnown bool - runProgressLabel string - pendingPermission *permissionPromptState + codeCopyBlocks map[int]string + pendingCopyID int + deferredEventCmd tea.Cmd + nowFn func() time.Time + lastInputEditAt time.Time + lastPasteLikeAt time.Time + inputBurstStart time.Time + inputBurstCount int + pasteMode bool + activeMessages []providertypes.Message + activities []tuistate.ActivityEntry + fileCandidates []string + modelRefreshID string + focus panel + runProgressValue float64 + runProgressKnown bool + runProgressLabel string + pendingPermission *permissionPromptState + pendingImageAttachments []pendingImageAttachment + currentModelCapabilities modelCapabilityState +} + +type pendingImageAttachment struct { + Path string + MimeType string + Size int64 + Name string +} + +type modelCapabilityState struct { + supportsImageInput bool + checked bool } type App struct { @@ -160,18 +174,6 @@ func newApp(container tuibootstrap.Container) (App, error) { return App{}, err } keys := newKeyMap() - delegate := sessionDelegate{styles: uiStyles} - sessionList := list.New([]list.Item{}, delegate, 0, 0) - sessionList.Title = "" - sessionList.SetShowTitle(false) - sessionList.SetShowHelp(false) - sessionList.SetShowStatusBar(false) - sessionList.SetShowFilter(false) - sessionList.SetShowPagination(false) - sessionList.SetFilteringEnabled(true) - sessionList.DisableQuitKeybindings() - sessionList.FilterInput.Prompt = "Filter: " - sessionList.FilterInput.Placeholder = "Type to search sessions" input := textarea.New() input.Placeholder = "Ask NeoCode to inspect, edit, or build. Type / to browse commands." @@ -237,10 +239,10 @@ func newApp(container tuibootstrap.Container) (App, error) { keys: keys, help: h, spinner: spin, - sessions: sessionList, commandMenu: commandMenu, providerPicker: newSelectionPickerItems(nil), modelPicker: newSelectionPickerItems(nil), + sessionPicker: newSelectionPickerItems(nil), helpPicker: newHelpPickerItems(nil), fileBrowser: fileBrowser, progress: progressBar, @@ -259,15 +261,6 @@ func newApp(container tuibootstrap.Container) (App, error) { styles: uiStyles, } - if err := app.refreshSessions(); err != nil { - return App{}, err - } - if len(app.state.Sessions) > 0 { - app.state.ActiveSessionID = app.state.Sessions[0].ID - if err := app.refreshMessages(); err != nil { - return App{}, err - } - } app.syncActiveSessionTitle() app.syncConfigState(configManager.Get()) if err := app.refreshProviderPicker(); err != nil { diff --git a/internal/tui/core/app/command_menu.go b/internal/tui/core/app/command_menu.go index 8e2a763a..065d13fa 100644 --- a/internal/tui/core/app/command_menu.go +++ b/internal/tui/core/app/command_menu.go @@ -86,6 +86,14 @@ type sessionItem struct { Active bool } +func (s sessionItem) Title() string { + return s.Summary.Title +} + +func (s sessionItem) Description() string { + return s.Summary.UpdatedAt.Format("01-02 15:04") +} + func (s sessionItem) FilterValue() string { return strings.ToLower(s.Summary.Title) } diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index 47ff7d2b..3f1c956a 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -25,6 +25,7 @@ const ( slashCommandStatus = "/status" slashCommandProvider = "/provider" slashCommandModelPick = "/model" + slashCommandSession = "/session" slashCommandCWD = "/cwd" slashCommandMemo = "/memo" slashCommandRemember = "/remember" @@ -37,6 +38,7 @@ const ( slashUsageStatus = "/status" slashUsageProvider = "/provider" slashUsageModel = "/model" + slashUsageSession = "/session" slashUsageWorkdir = "/cwd" slashUsageMemo = "/memo" slashUsageRemember = "/remember " @@ -47,6 +49,8 @@ const ( providerPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" modelPickerTitle = "Select Model" modelPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" + sessionPickerTitle = "Select Session" + sessionPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" helpPickerTitle = "Slash Commands" helpPickerSubtitle = "Up/Down choose, Enter run, Esc cancel" filePickerTitle = "Browse Files" @@ -76,6 +80,7 @@ const ( statusCompacting = "Compacting context" statusChooseProvider = "Choose a provider" statusChooseModel = "Choose a model" + statusChooseSession = "Choose a session" statusChooseHelp = "Choose a slash command" statusBrowseFile = "Browse workspace files" statusPermissionRequired = "Permission required: choose a decision and press Enter" @@ -119,6 +124,7 @@ var builtinSlashCommands = []slashCommand{ {Usage: slashUsageForget, Description: "Remove memos matching keyword (/forget )"}, {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, + {Usage: slashUsageSession, Description: "Switch to another session"}, {Usage: slashUsageExit, Description: "Exit NeoCode"}, } diff --git a/internal/tui/core/app/input_features.go b/internal/tui/core/app/input_features.go index c0a99795..8c56f9fe 100644 --- a/internal/tui/core/app/input_features.go +++ b/internal/tui/core/app/input_features.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "path/filepath" + "strconv" "strings" tea "github.com/charmbracelet/bubbletea" @@ -17,10 +18,14 @@ const ( workspaceCommandPrefix = "&" workspaceCommandUsage = "& " fileReferencePrefix = "@" + imageReferencePrefix = "@image:" + imageReferenceUsage = "@image:" fileMenuTitle = "Files" shellMenuTitle = "Shell" maxWorkspaceFiles = 4000 maxFileSuggestions = 6 + maxImageAttachments = 3 + imageMaxSizeBytes = 5 * 1024 * 1024 // 5 MiB ) type tokenSelector int @@ -31,6 +36,9 @@ const ( ) var workspaceCommandExecutor = defaultWorkspaceCommandExecutor +var readClipboardImage = tuiinfra.ReadClipboardImage +var saveClipboardImageToTempFile = tuiinfra.SaveImageToTempFile +var detectImageMimeType = tuiinfra.DetectImageMimeType func isWorkspaceCommandInput(input string) bool { return strings.HasPrefix(strings.TrimSpace(input), workspaceCommandPrefix) @@ -198,12 +206,20 @@ func currentReferenceToken(input string) (start int, end int, token string, ok b if !ok { return 0, 0, "", false } - if !strings.HasPrefix(token, fileReferencePrefix) { + if !strings.HasPrefix(token, fileReferencePrefix) && !strings.HasPrefix(token, imageReferencePrefix) { return 0, 0, "", false } return start, end, token, true } +func (a *App) applyImageReference(input string) error { + path := extractImageReference(input) + if path == "" { + return fmt.Errorf("invalid image reference") + } + return a.addImageAttachment(path) +} + func (a *App) applyFileReference(path string) error { path = strings.TrimSpace(path) if path == "" { @@ -246,3 +262,197 @@ func (a *App) applyFileReference(path string) error { a.state.StatusText = fmt.Sprintf("[System] Added file reference %s.", reference) return nil } + +func isImageReferenceInput(input string) bool { + return strings.HasPrefix(strings.TrimSpace(input), imageReferencePrefix) +} + +func extractImageReference(input string) string { + trimmed := strings.TrimSpace(input) + if !strings.HasPrefix(trimmed, imageReferencePrefix) { + return "" + } + return strings.TrimPrefix(trimmed, imageReferencePrefix) +} + +func (a *App) addImageAttachment(path string) error { + path = strings.TrimSpace(path) + if path == "" { + return fmt.Errorf("image path is empty") + } + + if len(a.pendingImageAttachments) >= maxImageAttachments { + return fmt.Errorf("maximum %d image attachments allowed", maxImageAttachments) + } + + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("invalid image path: %w", err) + } + + info, err := tuiinfra.GetFileInfo(absPath) + if err != nil { + return fmt.Errorf("cannot read image file: %w", err) + } + + if info.Size() > imageMaxSizeBytes { + return fmt.Errorf("image size exceeds %d MB limit", imageMaxSizeBytes/(1024*1024)) + } + + mimeType := detectImageMimeType(absPath) + if mimeType == "" { + return fmt.Errorf("unsupported image format") + } + + a.pendingImageAttachments = append(a.pendingImageAttachments, pendingImageAttachment{ + Path: absPath, + MimeType: mimeType, + Size: info.Size(), + Name: filepath.Base(absPath), + }) + + a.refreshImageAttachmentDisplay() + a.state.StatusText = fmt.Sprintf("[System] Added image: %s", filepath.Base(absPath)) + return nil +} + +func (a *App) removeImageAttachment(index int) error { + if index < 0 || index >= len(a.pendingImageAttachments) { + return fmt.Errorf("invalid attachment index") + } + + removed := a.pendingImageAttachments[index] + a.pendingImageAttachments = append(a.pendingImageAttachments[:index], a.pendingImageAttachments[index+1:]...) + + a.refreshImageAttachmentDisplay() + a.state.StatusText = fmt.Sprintf("[System] Removed image: %s", removed.Name) + return nil +} + +func (a *App) clearImageAttachments() { + a.pendingImageAttachments = nil +} + +func (a *App) getImageAttachmentCount() int { + return len(a.pendingImageAttachments) +} + +func (a *App) refreshImageAttachmentDisplay() { + a.normalizeComposerHeight() + a.applyComponentLayout(false) +} + +func (a *App) hasImageAttachments() bool { + return len(a.pendingImageAttachments) > 0 +} + +func (a *App) getImageAttachments() []pendingImageAttachment { + return a.pendingImageAttachments +} + +func (a *App) loadImageAttachmentData(index int) ([]byte, error) { + if index < 0 || index >= len(a.pendingImageAttachments) { + return nil, fmt.Errorf("invalid attachment index") + } + return tuiinfra.ReadImageFile(a.pendingImageAttachments[index].Path) +} + +func (a *App) addImageFromClipboard() error { + if len(a.pendingImageAttachments) >= maxImageAttachments { + return fmt.Errorf("maximum %d image attachments allowed", maxImageAttachments) + } + + data, err := readClipboardImage() + if err != nil { + return fmt.Errorf("failed to read clipboard image: %w", err) + } + + if data == nil || len(data) == 0 { + return fmt.Errorf("no image in clipboard") + } + + if int64(len(data)) > imageMaxSizeBytes { + return fmt.Errorf("image size exceeds %d MB limit", imageMaxSizeBytes/(1024*1024)) + } + + tmpPath, err := saveClipboardImageToTempFile(data, "paste") + if err != nil { + return fmt.Errorf("failed to save clipboard image: %w", err) + } + + mimeType := detectImageMimeType(tmpPath) + if mimeType == "" { + return fmt.Errorf("unsupported image format from clipboard") + } + + a.pendingImageAttachments = append(a.pendingImageAttachments, pendingImageAttachment{ + Path: tmpPath, + MimeType: mimeType, + Size: int64(len(data)), + Name: "clipboard_image.png", + }) + + a.refreshImageAttachmentDisplay() + a.state.StatusText = "[System] Added image from clipboard" + return nil +} + +func (a *App) checkModelImageSupport() bool { + if a.currentModelCapabilities.checked { + return a.currentModelCapabilities.supportsImageInput + } + + models, err := a.providerSvc.ListModelsSnapshot(context.Background()) + if err != nil { + a.currentModelCapabilities.checked = true + a.currentModelCapabilities.supportsImageInput = false + return false + } + + for _, m := range models { + if m.ID == a.state.CurrentModel { + a.currentModelCapabilities.checked = true + a.currentModelCapabilities.supportsImageInput = m.CapabilityHints.ImageInput == "supported" + return a.currentModelCapabilities.supportsImageInput + } + } + + a.currentModelCapabilities.checked = true + a.currentModelCapabilities.supportsImageInput = false + return false +} + +func (a *App) canSendImageInput() bool { + return a.checkModelImageSupport() +} + +// invalidateModelCapabilityCache 在 provider 或 model 变化时清理图片能力缓存,避免复用旧结果。 +func (a *App) invalidateModelCapabilityCache() { + a.currentModelCapabilities = modelCapabilityState{} +} + +// composeMessageWithImageAttachments 在发送前把附件元信息拼接到文本,避免附件在运行链路中丢失。 +func (a *App) composeMessageWithImageAttachments(content string) string { + trimmed := strings.TrimSpace(content) + if len(a.pendingImageAttachments) == 0 { + return trimmed + } + + var builder strings.Builder + builder.WriteString(trimmed) + builder.WriteString("\n\n[Attached images]\n") + for index, attachment := range a.pendingImageAttachments { + builder.WriteString(strconv.Itoa(index + 1)) + builder.WriteString(". ") + builder.WriteString(attachment.Name) + builder.WriteString(" | mime=") + builder.WriteString(attachment.MimeType) + builder.WriteString(" | bytes=") + builder.WriteString(strconv.FormatInt(attachment.Size, 10)) + builder.WriteString(" | path=") + builder.WriteString(attachment.Path) + builder.WriteString("\n") + } + builder.WriteString("Treat the list above as user-provided image attachments.") + return builder.String() +} diff --git a/internal/tui/core/app/input_features_test.go b/internal/tui/core/app/input_features_test.go new file mode 100644 index 00000000..db32b19a --- /dev/null +++ b/internal/tui/core/app/input_features_test.go @@ -0,0 +1,463 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/config" + configstate "neo-code/internal/config/state" + providertypes "neo-code/internal/provider/types" +) + +type snapshotErrProviderService struct { + stubProviderService + err error +} + +func (s snapshotErrProviderService) ListModelsSnapshot(ctx context.Context) ([]providertypes.ModelDescriptor, error) { + return nil, s.err +} + +func TestTokenAndReferenceParsing(t *testing.T) { + start, end, token, ok := tokenRange(" @file/path", tokenSelectorFirst) + if !ok || start != 2 || end != len(" @file/path") || token != "@file/path" { + t.Fatalf("unexpected first token parse: start=%d end=%d token=%q ok=%v", start, end, token, ok) + } + + start, end, token, ok = tokenRange("hello @image:/tmp/a.png", tokenSelectorLast) + if !ok || token != "@image:/tmp/a.png" || start <= 0 || end <= start { + t.Fatalf("unexpected last token parse: start=%d end=%d token=%q ok=%v", start, end, token, ok) + } + + _, _, _, ok = currentReferenceToken("hello world") + if ok { + t.Fatalf("expected non-reference token to be rejected") + } + + _, _, token, ok = currentReferenceToken("x @a/b.txt") + if !ok || token != "@a/b.txt" { + t.Fatalf("expected file reference token, got token=%q ok=%v", token, ok) + } + + if !isImageReferenceInput("@image:/tmp/p.png") { + t.Fatalf("expected image reference input recognized") + } + if got := extractImageReference("@image:/tmp/p.png"); got != "/tmp/p.png" { + t.Fatalf("unexpected image reference extraction: %q", got) + } +} + +func TestApplyFileReference(t *testing.T) { + app, _ := newTestApp(t) + root := t.TempDir() + app.state.CurrentWorkdir = root + inside := filepath.Join(root, "docs", "a.md") + if err := os.MkdirAll(filepath.Dir(inside), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(inside, []byte("ok"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + if err := app.applyFileReference(inside); err != nil { + t.Fatalf("applyFileReference() error = %v", err) + } + if got := app.input.Value(); !strings.Contains(got, "@docs/a.md") { + t.Fatalf("expected relative file reference, got %q", got) + } + + app.input.SetValue("prefix @old/ref") + if err := app.applyFileReference(inside); err != nil { + t.Fatalf("applyFileReference replace() error = %v", err) + } + if got := app.input.Value(); strings.Contains(got, "@old/ref") || !strings.Contains(got, "@docs/a.md") { + t.Fatalf("expected active token replaced, got %q", got) + } +} + +func TestApplyFileReferenceBranches(t *testing.T) { + app, _ := newTestApp(t) + if err := app.applyFileReference(" "); err == nil { + t.Fatalf("expected empty file path error") + } + + root := t.TempDir() + app.state.CurrentWorkdir = filepath.Join(root, "workdir") + inside := filepath.Join(app.state.CurrentWorkdir, "a.txt") + outside := filepath.Join(root, "outside.txt") + if err := os.MkdirAll(filepath.Dir(inside), 0o755); err != nil { + t.Fatalf("mkdir inside: %v", err) + } + if err := os.WriteFile(inside, []byte("a"), 0o644); err != nil { + t.Fatalf("write inside: %v", err) + } + if err := os.WriteFile(outside, []byte("b"), 0o644); err != nil { + t.Fatalf("write outside: %v", err) + } + + if err := app.applyFileReference(outside); err != nil { + t.Fatalf("apply outside reference error: %v", err) + } + if !strings.Contains(app.input.Value(), "@") { + t.Fatalf("expected file reference token to be inserted") + } +} + +func TestImageAttachmentLifecycle(t *testing.T) { + app, _ := newTestApp(t) + root := t.TempDir() + imagePath := filepath.Join(root, "test.png") + if err := os.WriteFile(imagePath, []byte("fake-png"), 0o644); err != nil { + t.Fatalf("write image: %v", err) + } + + if err := app.addImageAttachment(imagePath); err != nil { + t.Fatalf("addImageAttachment() error = %v", err) + } + if app.getImageAttachmentCount() != 1 { + t.Fatalf("expected one attachment, got %d", app.getImageAttachmentCount()) + } + if !app.hasImageAttachments() { + t.Fatalf("expected hasImageAttachments() true") + } + if _, err := app.loadImageAttachmentData(0); err != nil { + t.Fatalf("loadImageAttachmentData() error = %v", err) + } + + if err := app.removeImageAttachment(0); err != nil { + t.Fatalf("removeImageAttachment() error = %v", err) + } + if app.getImageAttachmentCount() != 0 { + t.Fatalf("expected no attachments after remove") + } + if err := app.removeImageAttachment(0); err == nil { + t.Fatalf("expected out-of-range remove error") + } +} + +func TestAddImageAttachmentLimit(t *testing.T) { + app, _ := newTestApp(t) + root := t.TempDir() + + for i := 0; i < maxImageAttachments; i++ { + path := filepath.Join(root, fmt.Sprintf("%d.png", i)) + if err := os.WriteFile(path, []byte("png"), 0o644); err != nil { + t.Fatalf("write image: %v", err) + } + if err := app.addImageAttachment(path); err != nil { + t.Fatalf("addImageAttachment(%d) error = %v", i, err) + } + } + + over := filepath.Join(root, "over.png") + if err := os.WriteFile(over, []byte("png"), 0o644); err != nil { + t.Fatalf("write over image: %v", err) + } + if err := app.addImageAttachment(over); err == nil { + t.Fatalf("expected attachment limit error") + } +} + +func TestCanSendImageInputCacheInvalidationOnModelChange(t *testing.T) { + app, _ := newTestApp(t) + providerID := app.state.CurrentProvider + + app.providerSvc = stubProviderService{ + providers: []configstate.ProviderOption{{ID: providerID, Name: providerID}}, + models: []providertypes.ModelDescriptor{{ + ID: "model-a", + Name: "model-a", + CapabilityHints: providertypes.ModelCapabilityHints{ + ImageInput: providertypes.ModelCapabilityStateSupported, + }, + }}, + } + app.state.CurrentModel = "model-a" + if !app.canSendImageInput() { + t.Fatalf("expected model-a to support images") + } + + app.providerSvc = stubProviderService{ + providers: []configstate.ProviderOption{{ID: providerID, Name: providerID}}, + models: []providertypes.ModelDescriptor{{ + ID: "model-b", + Name: "model-b", + CapabilityHints: providertypes.ModelCapabilityHints{ + ImageInput: providertypes.ModelCapabilityStateUnsupported, + }, + }}, + } + app.syncConfigState(config.Config{SelectedProvider: providerID, CurrentModel: "model-b", Workdir: app.state.CurrentWorkdir}) + if app.canSendImageInput() { + t.Fatalf("expected model-b to be unsupported after cache invalidation") + } +} + +func TestComposeMessageWithImageAttachments(t *testing.T) { + app, _ := newTestApp(t) + app.pendingImageAttachments = []pendingImageAttachment{{ + Path: "/tmp/a.png", + Name: "a.png", + MimeType: "image/png", + Size: 12, + }} + + got := app.composeMessageWithImageAttachments("hello") + if !strings.Contains(got, "[Attached images]") || !strings.Contains(got, "a.png") || !strings.Contains(got, "path=/tmp/a.png") { + t.Fatalf("unexpected composed message: %q", got) + } +} + +func TestComposeMessageWithImageAttachmentsNoAttachments(t *testing.T) { + app, _ := newTestApp(t) + got := app.composeMessageWithImageAttachments(" hello ") + if got != "hello" { + t.Fatalf("expected trimmed content without attachment block, got %q", got) + } +} + +func TestApplyImageReference(t *testing.T) { + app, _ := newTestApp(t) + root := t.TempDir() + imagePath := filepath.Join(root, "ok.png") + if err := os.WriteFile(imagePath, []byte("png"), 0o644); err != nil { + t.Fatalf("write image: %v", err) + } + if err := app.applyImageReference("@image:" + imagePath); err != nil { + t.Fatalf("applyImageReference() error = %v", err) + } + if app.getImageAttachmentCount() != 1 { + t.Fatalf("expected one attachment after applyImageReference") + } + if err := app.applyImageReference("not-an-image-reference"); err == nil { + t.Fatalf("expected invalid image reference error") + } +} + +func TestGetAndClearImageAttachments(t *testing.T) { + app, _ := newTestApp(t) + app.pendingImageAttachments = []pendingImageAttachment{ + {Name: "a.png", Path: "/tmp/a.png", MimeType: "image/png", Size: 1}, + } + if len(app.getImageAttachments()) != 1 { + t.Fatalf("expected one attachment from getter") + } + app.clearImageAttachments() + if len(app.getImageAttachments()) != 0 { + t.Fatalf("expected no attachments after clear") + } +} + +func TestLoadImageAttachmentDataInvalidIndex(t *testing.T) { + app, _ := newTestApp(t) + if _, err := app.loadImageAttachmentData(0); err == nil { + t.Fatalf("expected invalid attachment index error") + } +} + +func TestAddImageFromClipboardUnsupported(t *testing.T) { + app, _ := newTestApp(t) + if err := app.addImageFromClipboard(); err == nil { + t.Fatalf("expected unsupported clipboard image error") + } +} + +func TestAddImageFromClipboardSuccess(t *testing.T) { + app, _ := newTestApp(t) + originalRead := readClipboardImage + originalSave := saveClipboardImageToTempFile + originalDetect := detectImageMimeType + readClipboardImage = func() ([]byte, error) { + return []byte("image-bytes"), nil + } + saveClipboardImageToTempFile = func(data []byte, prefix string) (string, error) { + path := filepath.Join(t.TempDir(), "clipboard.png") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("write temp clipboard image: %v", err) + } + return path, nil + } + detectImageMimeType = func(path string) string { return "image/png" } + defer func() { + readClipboardImage = originalRead + saveClipboardImageToTempFile = originalSave + detectImageMimeType = originalDetect + }() + + if err := app.addImageFromClipboard(); err != nil { + t.Fatalf("addImageFromClipboard() error = %v", err) + } + if app.getImageAttachmentCount() != 1 { + t.Fatalf("expected one clipboard image attachment") + } +} + +func TestAddImageFromClipboardBranches(t *testing.T) { + app, _ := newTestApp(t) + originalRead := readClipboardImage + originalSave := saveClipboardImageToTempFile + originalDetect := detectImageMimeType + defer func() { + readClipboardImage = originalRead + saveClipboardImageToTempFile = originalSave + detectImageMimeType = originalDetect + }() + + readClipboardImage = func() ([]byte, error) { return nil, nil } + if err := app.addImageFromClipboard(); err == nil { + t.Fatalf("expected no image in clipboard error") + } + + readClipboardImage = func() ([]byte, error) { return make([]byte, imageMaxSizeBytes+1), nil } + if err := app.addImageFromClipboard(); err == nil { + t.Fatalf("expected image size limit error") + } + + readClipboardImage = func() ([]byte, error) { return []byte("x"), nil } + saveClipboardImageToTempFile = func(data []byte, prefix string) (string, error) { + return filepath.Join(t.TempDir(), "clipboard.bin"), nil + } + detectImageMimeType = func(path string) string { return "" } + if err := app.addImageFromClipboard(); err == nil { + t.Fatalf("expected unsupported image format error") + } + + readClipboardImage = func() ([]byte, error) { return []byte("x"), nil } + saveClipboardImageToTempFile = func(data []byte, prefix string) (string, error) { + return "", errors.New("save failed") + } + if err := app.addImageFromClipboard(); err == nil { + t.Fatalf("expected save failure error") + } +} + +func TestCheckModelImageSupportErrorAndModelNotFound(t *testing.T) { + app, _ := newTestApp(t) + app.providerSvc = snapshotErrProviderService{ + stubProviderService: stubProviderService{}, + err: errors.New("boom"), + } + if app.checkModelImageSupport() { + t.Fatalf("expected false when provider snapshot fails") + } + if !app.currentModelCapabilities.checked { + t.Fatalf("expected capability cache to be marked checked after failure") + } + + app.currentModelCapabilities = modelCapabilityState{} + app.providerSvc = stubProviderService{ + providers: []configstate.ProviderOption{{ID: app.state.CurrentProvider, Name: app.state.CurrentProvider}}, + models: []providertypes.ModelDescriptor{{ + ID: "other-model", + }}, + } + if app.checkModelImageSupport() { + t.Fatalf("expected false when current model is missing from snapshot") + } +} + +func TestExecuteWorkspaceCommand(t *testing.T) { + app, _ := newTestApp(t) + original := workspaceCommandExecutor + workspaceCommandExecutor = func(ctx context.Context, cfg config.Config, workdir string, command string) (string, error) { + if command != "echo hi" { + t.Fatalf("unexpected command: %q", command) + } + return "ok", nil + } + defer func() { workspaceCommandExecutor = original }() + + command, output, err := executeWorkspaceCommand(context.Background(), app.configManager, app.state.CurrentWorkdir, "& echo hi") + if err != nil { + t.Fatalf("executeWorkspaceCommand() error = %v", err) + } + if command != "echo hi" || output != "ok" { + t.Fatalf("unexpected execute result command=%q output=%q", command, output) + } + + if _, _, err := executeWorkspaceCommand(context.Background(), app.configManager, app.state.CurrentWorkdir, "& "); err == nil { + t.Fatalf("expected invalid workspace command error") + } +} + +func TestDefaultWorkspaceCommandExecutor(t *testing.T) { + cfg := config.Config{Workdir: t.TempDir(), Shell: "bash", ToolTimeoutSec: 1} + if _, err := defaultWorkspaceCommandExecutor(context.Background(), cfg, cfg.Workdir, ""); err == nil { + t.Fatalf("expected empty command to fail") + } +} + +func TestRunWorkspaceCommandCmd(t *testing.T) { + app, _ := newTestApp(t) + original := workspaceCommandExecutor + workspaceCommandExecutor = func(ctx context.Context, cfg config.Config, workdir string, command string) (string, error) { + return "done", nil + } + defer func() { workspaceCommandExecutor = original }() + + cmd := runWorkspaceCommand(app.configManager, app.state.CurrentWorkdir, "& echo hi") + if cmd == nil { + t.Fatalf("expected workspace command cmd") + } + msg := cmd() + result, ok := msg.(workspaceCommandResultMsg) + if !ok { + t.Fatalf("expected workspaceCommandResultMsg, got %T", msg) + } + if result.Command != "echo hi" || result.Output != "done" || result.Err != nil { + t.Fatalf("unexpected workspace result: %+v", result) + } +} + +func TestUpdateSendWithImageAttachmentsComposesRuntimeInput(t *testing.T) { + app, runtime := newTestApp(t) + root := t.TempDir() + imagePath := filepath.Join(root, "queued.png") + if err := os.WriteFile(imagePath, []byte("fake-png"), 0o644); err != nil { + t.Fatalf("write image: %v", err) + } + if err := app.addImageAttachment(imagePath); err != nil { + t.Fatalf("addImageAttachment() error = %v", err) + } + app.providerSvc = stubProviderService{ + providers: []configstate.ProviderOption{{ID: app.state.CurrentProvider, Name: app.state.CurrentProvider}}, + models: []providertypes.ModelDescriptor{{ + ID: app.state.CurrentModel, + Name: app.state.CurrentModel, + CapabilityHints: providertypes.ModelCapabilityHints{ + ImageInput: providertypes.ModelCapabilityStateSupported, + }, + }}, + } + + app.input.SetValue("hello") + app.state.InputText = "hello" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatalf("expected run command") + } + app = model.(App) + if app.hasImageAttachments() { + t.Fatalf("expected attachments cleared after send") + } + if len(app.activeMessages) == 0 || !strings.Contains(app.activeMessages[len(app.activeMessages)-1].Content, "[Attached images]") { + t.Fatalf("expected composed user message in transcript") + } + + msg := cmd() + _, ok := msg.(runFinishedMsg) + if !ok { + t.Fatalf("expected runFinishedMsg, got %T", msg) + } + if len(runtime.runInputs) != 1 || !strings.Contains(runtime.runInputs[0].Content, "[Attached images]") { + t.Fatalf("expected composed runtime input, got %+v", runtime.runInputs) + } +} diff --git a/internal/tui/core/app/keymap.go b/internal/tui/core/app/keymap.go index 7c6b9df5..3d604fb3 100644 --- a/internal/tui/core/app/keymap.go +++ b/internal/tui/core/app/keymap.go @@ -10,7 +10,6 @@ type keyMap struct { NextPanel key.Binding PrevPanel key.Binding FocusInput key.Binding - OpenSession key.Binding ToggleHelp key.Binding Quit key.Binding ScrollUp key.Binding @@ -19,6 +18,7 @@ type keyMap struct { PageDown key.Binding Top key.Binding Bottom key.Binding + PasteImage key.Binding } func newKeyMap() keyMap { @@ -51,10 +51,6 @@ func newKeyMap() keyMap { key.WithKeys("esc"), key.WithHelp("Esc", "Focus input"), ), - OpenSession: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("Enter", "Open session"), - ), ToggleHelp: key.NewBinding( key.WithKeys("ctrl+q"), key.WithHelp("Ctrl+Q", "/help"), @@ -87,6 +83,10 @@ func newKeyMap() keyMap { key.WithKeys("G", "end"), key.WithHelp("Shift+G/End", "Bottom"), ), + PasteImage: key.NewBinding( + key.WithKeys("ctrl+v"), + key.WithHelp("Ctrl+V", "Paste image"), + ), } } @@ -97,8 +97,8 @@ func (k keyMap) ShortHelp() []key.Binding { func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {k.Send, k.Newline, k.CancelAgent, k.NewSession}, - {k.OpenSession, k.FocusInput, k.NextPanel, k.PrevPanel}, - {k.ToggleHelp, k.Quit, k.ScrollUp, k.ScrollDown}, + {k.FocusInput, k.NextPanel, k.PrevPanel}, + {k.ToggleHelp, k.Quit, k.PasteImage, k.ScrollUp}, {k.PageUp, k.PageDown, k.Top, k.Bottom}, } } diff --git a/internal/tui/core/app/keymap_test.go b/internal/tui/core/app/keymap_test.go new file mode 100644 index 00000000..c08126cb --- /dev/null +++ b/internal/tui/core/app/keymap_test.go @@ -0,0 +1,16 @@ +package tui + +import "testing" + +func TestFullHelpIncludesPasteImage(t *testing.T) { + keys := newKeyMap() + help := keys.FullHelp() + for _, row := range help { + for _, binding := range row { + if binding.Help().Key == keys.PasteImage.Help().Key { + return + } + } + } + t.Fatalf("expected full help to include paste image binding") +} diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index a20336e6..63756cc5 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -23,6 +23,7 @@ import ( "neo-code/internal/tools" tuistatus "neo-code/internal/tui/core/status" tuiutils "neo-code/internal/tui/core/utils" + tuiinfra "neo-code/internal/tui/infra" tuiservices "neo-code/internal/tui/services" tuistate "neo-code/internal/tui/state" ) @@ -38,7 +39,7 @@ const ( pasteBurstThreshold = tuistate.PasteBurstThreshold ) -var panelOrder = []panel{panelSessions, panelTranscript, panelActivity, panelInput} +var panelOrder = []panel{panelTranscript, panelActivity, panelInput} func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -60,7 +61,6 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, a.deferredEventCmd) a.deferredEventCmd = nil } - _ = a.refreshSessions() a.syncActiveSessionTitle() if transcriptDirty { a.rebuildTranscript() @@ -98,7 +98,6 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !a.state.IsAgentRunning { a.clearRunProgress() } - _ = a.refreshSessions() a.syncActiveSessionTitle() return a, tea.Batch(cmds...) case permissionResolutionFinishedMsg: @@ -140,11 +139,6 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.ExecutionError = typed.Err.Error() a.state.StatusText = typed.Err.Error() } - if err := a.refreshSessions(); err != nil { - a.state.ExecutionError = err.Error() - a.state.StatusText = err.Error() - a.appendInlineMessage(roleError, err.Error()) - } if err := a.refreshMessages(); err != nil && strings.TrimSpace(a.state.ActiveSessionID) != "" { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() @@ -270,23 +264,15 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) } - switch a.focus { - case panelSessions: - if key.Matches(typed, a.keys.OpenSession) && !a.sessions.SettingFilter() { - if err := a.activateSelectedSession(); err != nil { - a.state.StatusText = err.Error() - a.state.ExecutionError = err.Error() - a.appendActivity("system", "Failed to open session", err.Error(), true) - } - a.focus = panelInput - a.applyFocus() - return a, tea.Batch(cmds...) + if key.Matches(typed, a.keys.PasteImage) { + if err := a.addImageFromClipboard(); err != nil { + a.state.StatusText = err.Error() + a.appendActivity("multimodal", "Failed to paste image", err.Error(), true) } - var cmd tea.Cmd - a.sessions, cmd = a.sessions.Update(msg) - a.sessions.SetShowFilter(a.sessions.FilterState() != list.Unfiltered) - cmds = append(cmds, cmd) return a, tea.Batch(cmds...) + } + + switch a.focus { case panelTranscript: a.handleViewportKeys(&a.transcript, typed) return a, tea.Batch(cmds...) @@ -338,6 +324,20 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } + if isImageReferenceInput(input) { + if err := a.applyImageReference(input); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("multimodal", "Failed to add image reference", err.Error(), true) + } + a.input.Reset() + a.state.InputText = "" + a.applyComponentLayout(true) + a.refreshCommandMenu() + a.resetPasteHeuristics() + return a, tea.Batch(cmds...) + } + // 如果不是立即执行的命令,再执行常规的输入重置 a.input.Reset() a.state.InputText = "" @@ -371,6 +371,15 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te cmds = append(cmds, cmd) } return a, tea.Batch(cmds...) + case slashCommandSession: + if err := a.refreshSessionPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("system", "Failed to refresh sessions", err.Error(), true) + return a, tea.Batch(cmds...) + } + a.openPicker(pickerSession, statusChooseSession, &a.sessionPicker, a.state.ActiveSessionID) + return a, tea.Batch(cmds...) } if strings.HasPrefix(input, slashPrefix) { @@ -394,6 +403,14 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } + if a.hasImageAttachments() && !a.canSendImageInput() { + a.state.ExecutionError = "current model does not support image input" + a.state.StatusText = "Model does not support images" + a.appendActivity("multimodal", "Image input not supported", fmt.Sprintf("Model %s does not support image input", a.state.CurrentModel), true) + a.clearImageAttachments() + return a, tea.Batch(cmds...) + } + a.clearActivities() a.clearRunProgress() a.state.IsAgentRunning = true @@ -402,12 +419,19 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.state.ExecutionError = "" a.state.StatusText = statusThinking a.state.CurrentTool = "" - a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleUser, Content: input}) + + if a.hasImageAttachments() { + a.appendActivity("multimodal", "Sending message with image metadata", fmt.Sprintf("%d image(s) attached", len(a.pendingImageAttachments)), false) + } + + composedInput := a.composeMessageWithImageAttachments(input) + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleUser, Content: composedInput}) a.rebuildTranscript() runID := fmt.Sprintf("run-%d", a.now().UnixNano()) a.state.ActiveRunID = runID requestedWorkdir := tuiutils.RequestedWorkdirForRun(a.state.CurrentWorkdir) - cmds = append(cmds, runAgent(a.runtime, runID, a.state.ActiveSessionID, requestedWorkdir, input)) + cmds = append(cmds, runAgent(a.runtime, runID, a.state.ActiveSessionID, requestedWorkdir, composedInput)) + a.clearImageAttachments() return a, tea.Batch(cmds...) } } @@ -593,6 +617,18 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, nil } return a, runModelSelection(a.providerSvc, item.id) + case pickerSession: + item, ok := a.sessionPicker.SelectedItem().(sessionItem) + a.closePicker() + if !ok { + return a, nil + } + if err := a.activateSessionByID(item.Summary.ID); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("system", "Failed to switch session", err.Error(), true) + } + return a, nil case pickerHelp: item, ok := a.helpPicker.SelectedItem().(selectionItem) a.closePicker() @@ -609,12 +645,23 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.providerPicker, cmd = a.providerPicker.Update(msg) case pickerModel: a.modelPicker, cmd = a.modelPicker.Update(msg) + case pickerSession: + a.sessionPicker, cmd = a.sessionPicker.Update(msg) case pickerHelp: a.helpPicker, cmd = a.helpPicker.Update(msg) case pickerFile: a.fileBrowser, cmd = a.fileBrowser.Update(msg) if didSelect, path := a.fileBrowser.DidSelectFile(msg); didSelect { a.closePicker() + if tuiinfra.IsSupportedImageFormat(path) { + if err := a.addImageAttachment(path); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("multimodal", "Failed to add image", err.Error(), true) + return a, cmd + } + return a, cmd + } if err := a.applyFileReference(path); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() @@ -630,7 +677,7 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, cmd } -func (a *App) refreshSessions() error { +func (a *App) refreshSessionPicker() error { sessions, err := a.runtime.ListSessions(context.Background()) if err != nil { return err @@ -638,25 +685,25 @@ func (a *App) refreshSessions() error { a.state.Sessions = sessions - var selectedID string - if item, ok := a.sessions.SelectedItem().(sessionItem); ok { - selectedID = item.Summary.ID - } - items := make([]list.Item, 0, len(sessions)) - cursor := 0 + selectedIndex := 0 + hasSelection := false for i, summary := range sessions { items = append(items, sessionItem{Summary: summary, Active: summary.ID == a.state.ActiveSessionID}) - if summary.ID == selectedID || summary.ID == a.state.ActiveSessionID { - cursor = i + if summary.ID == a.state.ActiveSessionID { + selectedIndex = i + hasSelection = true } } - a.sessions.SetItems(items) + a.sessionPicker.SetItems(items) if len(items) > 0 { - a.sessions.Select(cursor) + if hasSelection { + a.sessionPicker.Select(selectedIndex) + } else { + a.sessionPicker.Select(0) + } } - return nil } @@ -681,7 +728,7 @@ func (a *App) refreshMessages() error { } func (a *App) activateSelectedSession() error { - item, ok := a.sessions.SelectedItem().(sessionItem) + item, ok := a.sessionPicker.SelectedItem().(sessionItem) if !ok { return nil } @@ -691,13 +738,22 @@ func (a *App) activateSelectedSession() error { a.state.ExecutionError = "" a.state.CurrentTool = "" - if err := a.refreshSessions(); err != nil { - return err - } - return a.refreshMessages() } +func (a *App) activateSessionByID(sessionID string) error { + for _, s := range a.state.Sessions { + if s.ID == sessionID { + a.state.ActiveSessionID = s.ID + a.state.ActiveSessionTitle = s.Title + a.state.ExecutionError = "" + a.state.CurrentTool = "" + return a.refreshMessages() + } + } + return fmt.Errorf("session not found: %s", sessionID) +} + func (a *App) syncActiveSessionTitle() { if strings.TrimSpace(a.state.ActiveSessionID) == "" { if strings.TrimSpace(a.state.ActiveSessionTitle) == "" { @@ -715,6 +771,10 @@ func (a *App) syncActiveSessionTitle() { } func (a *App) syncConfigState(cfg config.Config) { + if !strings.EqualFold(strings.TrimSpace(a.state.CurrentProvider), strings.TrimSpace(cfg.SelectedProvider)) || + !strings.EqualFold(strings.TrimSpace(a.state.CurrentModel), strings.TrimSpace(cfg.CurrentModel)) { + a.invalidateModelCapabilityCache() + } a.state.CurrentProvider = cfg.SelectedProvider a.state.CurrentModel = cfg.CurrentModel if strings.TrimSpace(a.state.CurrentWorkdir) == "" { @@ -897,9 +957,15 @@ func runtimeEventRunContextHandler(a *App, event agentruntime.RuntimeEvent) bool a.state.ActiveRunID = mapped.RunID } if strings.TrimSpace(mapped.Provider) != "" { + if !strings.EqualFold(strings.TrimSpace(a.state.CurrentProvider), strings.TrimSpace(mapped.Provider)) { + a.invalidateModelCapabilityCache() + } a.state.CurrentProvider = mapped.Provider } if strings.TrimSpace(mapped.Model) != "" { + if !strings.EqualFold(strings.TrimSpace(a.state.CurrentModel), strings.TrimSpace(mapped.Model)) { + a.invalidateModelCapabilityCache() + } a.state.CurrentModel = mapped.Model } if strings.TrimSpace(mapped.Workdir) != "" { @@ -1531,15 +1597,16 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { a.activity.Height = 0 } - a.providerPicker.SetSize(max(24, tuiutils.Clamp(lay.contentWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.contentHeight-10, 6, 10))) - a.modelPicker.SetSize(max(24, tuiutils.Clamp(lay.contentWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.contentHeight-10, 6, 10))) - helpPickerMaxHeight := max(8, lay.contentHeight-6) + pickerLayout := a.buildPickerLayout(lay.contentWidth, lay.contentHeight) + a.providerPicker.SetSize(pickerLayout.listWidth, pickerLayout.listHeight) + a.modelPicker.SetSize(pickerLayout.listWidth, pickerLayout.listHeight) + a.sessionPicker.SetSize(pickerLayout.listWidth, pickerLayout.listHeight) helpPickerDesiredHeight := (len(a.helpPicker.Items()) * 3) + 1 a.helpPicker.SetSize( - max(24, tuiutils.Clamp(lay.contentWidth-14, 28, 52)), - max(6, tuiutils.Clamp(helpPickerDesiredHeight, 6, helpPickerMaxHeight)), + pickerLayout.listWidth, + tuiutils.Clamp(helpPickerDesiredHeight, pickerListMinHeight, pickerLayout.listHeight), ) - a.fileBrowser.SetHeight(max(6, tuiutils.Clamp(lay.contentHeight-8, 8, 16))) + a.fileBrowser.SetHeight(max(pickerListMinHeight, pickerLayout.listHeight)) if rebuildTranscript || prevTranscriptWidth != a.transcript.Width { a.rebuildTranscript() } else if a.transcript.AtBottom() || a.isBusy() { @@ -1693,6 +1760,15 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { return true, a.handleRememberCommand(rest) case slashCommandForget: return true, a.handleForgetCommand(rest) + case slashCommandSession: + if err := a.refreshSessionPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("system", "Failed to refresh sessions", err.Error(), true) + return true, nil + } + a.openPicker(pickerSession, statusChooseSession, &a.sessionPicker, a.state.ActiveSessionID) + return true, nil default: return false, nil } diff --git a/internal/tui/core/app/update_permission_test.go b/internal/tui/core/app/update_permission_test.go index 30b0469b..47c18475 100644 --- a/internal/tui/core/app/update_permission_test.go +++ b/internal/tui/core/app/update_permission_test.go @@ -78,12 +78,12 @@ func newPermissionTestApp(runtime agentruntime.Runtime) *App { runtime: runtime, }, appComponents: appComponents{ - keys: newKeyMap(), - spinner: spin, - sessions: sessionList, - input: input, - transcript: viewport.New(0, 0), - activity: viewport.New(0, 0), + keys: newKeyMap(), + spinner: spin, + sessionPicker: sessionList, + input: input, + transcript: viewport.New(0, 0), + activity: viewport.New(0, 0), }, appRuntimeState: appRuntimeState{ nowFn: time.Now, diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 389e25f2..c6daa2dd 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" tea "github.com/charmbracelet/bubbletea" @@ -56,10 +57,15 @@ func (s stubProviderService) SetCurrentModel(ctx context.Context, modelID string } type stubRuntime struct { - events chan agentruntime.RuntimeEvent - resolveCalls []agentruntime.PermissionResolutionInput - resolveErr error - cancelInvoked bool + events chan agentruntime.RuntimeEvent + runInputs []agentruntime.UserInput + resolveCalls []agentruntime.PermissionResolutionInput + resolveErr error + cancelInvoked bool + listSessions []agentsession.Summary + listSessionsErr error + loadSessions map[string]agentsession.Session + loadSessionErr error } func newStubRuntime() *stubRuntime { @@ -67,6 +73,7 @@ func newStubRuntime() *stubRuntime { } func (s *stubRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + s.runInputs = append(s.runInputs, input) return nil } @@ -89,10 +96,21 @@ func (s *stubRuntime) Events() <-chan agentruntime.RuntimeEvent { } func (s *stubRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { - return nil, nil + if s.listSessionsErr != nil { + return nil, s.listSessionsErr + } + return s.listSessions, nil } func (s *stubRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + if s.loadSessionErr != nil { + return agentsession.Session{}, s.loadSessionErr + } + if s.loadSessions != nil { + if session, ok := s.loadSessions[id]; ok { + return session, nil + } + } return agentsession.NewWithWorkdir("draft", ""), nil } @@ -211,6 +229,26 @@ func TestAppUpdateBasic(t *testing.T) { } } +func TestRefreshSessionPickerSelectsActiveSession(t *testing.T) { + app, runtime := newTestApp(t) + now := time.Now() + runtime.listSessions = []agentsession.Summary{ + {ID: "session-1", Title: "Session One", UpdatedAt: now.Add(-time.Minute)}, + {ID: "session-2", Title: "Session Two", UpdatedAt: now}, + } + app.state.ActiveSessionID = "session-2" + + if err := app.refreshSessionPicker(); err != nil { + t.Fatalf("refreshSessionPicker() error = %v", err) + } + if len(app.sessionPicker.Items()) != 2 { + t.Fatalf("expected 2 session items, got %d", len(app.sessionPicker.Items())) + } + if got := app.sessionPicker.Index(); got != 1 { + t.Fatalf("expected active session index 1, got %d", got) + } +} + func TestParsePermissionShortcutFromKeyInput(t *testing.T) { if decision, ok := parsePermissionShortcut("y"); !ok || decision != approvalflow.DecisionAllowOnce { t.Fatalf("expected allow_once, got %v (ok=%v)", decision, ok) @@ -945,6 +983,190 @@ func TestRuntimeEventRunContextHandler(t *testing.T) { } } +func TestRuntimeEventRunContextHandlerInvalidatesModelCapabilityCache(t *testing.T) { + app, _ := newTestApp(t) + app.state.CurrentProvider = "provider-a" + app.state.CurrentModel = "model-a" + app.currentModelCapabilities = modelCapabilityState{ + checked: true, + supportsImageInput: true, + } + + payload := tuiservices.RuntimeRunContextPayload{ + Provider: "provider-b", + Model: "model-b", + } + _ = runtimeEventRunContextHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if app.currentModelCapabilities.checked { + t.Fatalf("expected capability cache to be invalidated when provider/model changes") + } +} + +func TestSyncConfigStateInvalidatesModelCapabilityCache(t *testing.T) { + app, _ := newTestApp(t) + app.state.CurrentProvider = "provider-a" + app.state.CurrentModel = "model-a" + app.currentModelCapabilities = modelCapabilityState{ + checked: true, + supportsImageInput: true, + } + + app.syncConfigState(config.Config{ + SelectedProvider: "provider-b", + CurrentModel: "model-b", + Workdir: app.state.CurrentWorkdir, + }) + if app.currentModelCapabilities.checked { + t.Fatalf("expected capability cache to be invalidated") + } +} + +func TestUpdatePasteImageShortcutFailure(t *testing.T) { + app, _ := newTestApp(t) + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) + if cmd != nil { + _ = cmd() + } + app = model.(App) + if !strings.Contains(strings.ToLower(app.state.StatusText), "clipboard") { + t.Fatalf("expected clipboard failure status, got %q", app.state.StatusText) + } +} + +func TestUpdateEnterSessionOpensSessionPicker(t *testing.T) { + app, runtime := newTestApp(t) + runtime.listSessions = []agentsession.Summary{ + {ID: "s1", Title: "Session 1", UpdatedAt: time.Now()}, + } + app.input.SetValue("/session") + app.state.InputText = "/session" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + _ = cmd() + } + app = model.(App) + if app.state.ActivePicker != pickerSession { + t.Fatalf("expected session picker to open") + } + if app.state.StatusText != statusChooseSession { + t.Fatalf("expected status %q, got %q", statusChooseSession, app.state.StatusText) + } +} + +func TestUpdateEnterImageReferencePath(t *testing.T) { + app, _ := newTestApp(t) + app.input.SetValue("@image:/path/does-not-exist.png") + app.state.InputText = "@image:/path/does-not-exist.png" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + _ = cmd() + } + app = model.(App) + if app.input.Value() != "" { + t.Fatalf("expected input to be reset after image reference handling") + } + if strings.TrimSpace(app.state.StatusText) == "" { + t.Fatalf("expected status text to reflect image reference failure") + } +} + +func TestUpdateSendWithUnsupportedImageInput(t *testing.T) { + app, _ := newTestApp(t) + app.pendingImageAttachments = []pendingImageAttachment{ + {Name: "a.png", MimeType: "image/png", Path: "/tmp/a.png", Size: 1}, + } + app.providerSvc = stubProviderService{ + providers: []configstate.ProviderOption{{ID: app.state.CurrentProvider, Name: app.state.CurrentProvider}}, + models: []providertypes.ModelDescriptor{{ + ID: app.state.CurrentModel, + Name: app.state.CurrentModel, + CapabilityHints: providertypes.ModelCapabilityHints{ + ImageInput: providertypes.ModelCapabilityStateUnsupported, + }, + }}, + } + app.input.SetValue("hello") + app.state.InputText = "hello" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + _ = cmd() + } + app = model.(App) + if app.state.IsAgentRunning { + t.Fatalf("expected send to be blocked for unsupported model image input") + } + if app.hasImageAttachments() { + t.Fatalf("expected pending image attachments to be cleared on unsupported model") + } + if app.state.StatusText != "Model does not support images" { + t.Fatalf("unexpected status text: %q", app.state.StatusText) + } +} + +func TestUpdatePickerSessionEnterActivatesSelectedSession(t *testing.T) { + app, runtime := newTestApp(t) + now := time.Now() + runtime.listSessions = []agentsession.Summary{ + {ID: "s1", Title: "One", UpdatedAt: now.Add(-time.Minute)}, + {ID: "s2", Title: "Two", UpdatedAt: now}, + } + runtime.loadSessions = map[string]agentsession.Session{ + "s2": { + ID: "s2", + Title: "Two", + Workdir: app.state.CurrentWorkdir, + Messages: []providertypes.Message{ + {Role: roleUser, Content: "hello"}, + }, + }, + } + if err := app.refreshSessionPicker(); err != nil { + t.Fatalf("refreshSessionPicker() error = %v", err) + } + app.openPicker(pickerSession, statusChooseSession, &app.sessionPicker, "") + app.sessionPicker.Select(1) + + model, cmd := app.updatePicker(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + _ = cmd() + } + app = model.(App) + if app.state.ActiveSessionID != "s2" || app.state.ActiveSessionTitle != "Two" { + t.Fatalf("expected selected session to be activated, got id=%q title=%q", app.state.ActiveSessionID, app.state.ActiveSessionTitle) + } + if len(app.activeMessages) != 1 { + t.Fatalf("expected messages to refresh from selected session") + } +} + +func TestActivateSessionByIDNotFound(t *testing.T) { + app, _ := newTestApp(t) + app.state.Sessions = []agentsession.Summary{{ID: "s1", Title: "one"}} + if err := app.activateSessionByID("missing"); err == nil { + t.Fatalf("expected session not found error") + } +} + +func TestHandleImmediateSlashCommandSession(t *testing.T) { + app, runtime := newTestApp(t) + runtime.listSessions = []agentsession.Summary{ + {ID: "s1", Title: "Session 1", UpdatedAt: time.Now()}, + } + handled, cmd := app.handleImmediateSlashCommand("/session") + if !handled { + t.Fatalf("expected /session to be handled immediately") + } + if cmd != nil { + _ = cmd() + } + if app.state.ActivePicker != pickerSession { + t.Fatalf("expected session picker opened by immediate slash command") + } +} + func TestRuntimeEventToolStatusHandler(t *testing.T) { app, _ := newTestApp(t) payload := tuiservices.RuntimeToolStatusPayload{ToolCallID: "tool-1", ToolName: "bash", Status: string(tuistate.ToolLifecyclePlanned)} @@ -1097,9 +1319,9 @@ func TestShouldHandleTabAsInput(t *testing.T) { func TestFocusNextPrev(t *testing.T) { app, _ := newTestApp(t) - app.focus = panelSessions + app.focus = panelTranscript app.focusNext() - if app.focus == panelSessions { + if app.focus == panelTranscript { t.Fatalf("expected focus to move") } app.focusPrev() diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 355dd223..42296611 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -19,6 +19,25 @@ type layout struct { const headerBarHeight = 2 +const ( + pickerPanelHorizontalInset = 8 + pickerPanelVerticalInset = 4 + pickerPanelMinWidth = 42 + pickerPanelMaxWidth = 72 + pickerPanelMinHeight = 14 + pickerPanelMaxHeight = 24 + pickerListMinWidth = 28 + pickerListMinHeight = 8 + pickerHeaderRows = 2 +) + +type pickerLayoutSpec struct { + panelWidth int + panelHeight int + listWidth int + listHeight int +} + func (a App) View() string { docWidth := max(0, a.width-a.styles.doc.GetHorizontalFrameSize()) docHeight := max(0, a.height-a.styles.doc.GetVerticalFrameSize()) @@ -76,12 +95,13 @@ func (a App) waterfallMetrics(width int, height int) (int, int, int, int) { func (a App) renderWaterfall(width int, height int) string { if a.state.ActivePicker != pickerNone { + pickerLayout := a.buildPickerLayout(width, height) return lipgloss.Place( width, height, lipgloss.Center, lipgloss.Center, - a.renderPicker(tuiutils.Clamp(width-10, 36, 56), tuiutils.Clamp(height-6, 10, 14)), + a.renderPicker(pickerLayout.panelWidth, pickerLayout.panelHeight), ) } @@ -108,6 +128,23 @@ func (a App) renderWaterfall(width int, height int) string { return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, content) } +func (a App) buildPickerLayout(contentWidth int, contentHeight int) pickerLayoutSpec { + panelWidth := tuiutils.Clamp(contentWidth-pickerPanelHorizontalInset, pickerPanelMinWidth, pickerPanelMaxWidth) + panelHeight := tuiutils.Clamp(contentHeight-pickerPanelVerticalInset, pickerPanelMinHeight, pickerPanelMaxHeight) + + frameWidth := a.styles.panelFocused.GetHorizontalFrameSize() + frameHeight := a.styles.panelFocused.GetVerticalFrameSize() + listWidth := max(pickerListMinWidth, panelWidth-frameWidth) + listHeight := max(pickerListMinHeight, panelHeight-frameHeight-pickerHeaderRows) + + return pickerLayoutSpec{ + panelWidth: panelWidth, + panelHeight: panelHeight, + listWidth: listWidth, + listHeight: listHeight, + } +} + func (a App) renderPicker(width int, height int) string { frameHeight := a.styles.panelFocused.GetVerticalFrameSize() title := modelPickerTitle @@ -118,6 +155,11 @@ func (a App) renderPicker(width int, height int) string { subtitle = providerPickerSubtitle body = a.providerPicker.View() } + if a.state.ActivePicker == pickerSession { + title = sessionPickerTitle + subtitle = sessionPickerSubtitle + body = a.sessionPicker.View() + } if a.state.ActivePicker == pickerFile { title = filePickerTitle subtitle = filePickerSubtitle diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go index edd44261..0c807d5a 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -3,11 +3,13 @@ package tui import ( "strings" "testing" + "time" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/lipgloss" providertypes "neo-code/internal/provider/types" + agentsession "neo-code/internal/session" tuistate "neo-code/internal/tui/state" ) @@ -25,6 +27,61 @@ func TestRenderPickerHelpMode(t *testing.T) { } } +func TestRenderPickerSessionMode(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActivePicker = pickerSession + app.sessionPicker.SetItems([]list.Item{ + sessionItem{Summary: agentsession.Summary{ + ID: "session-1", + Title: "Session One", + UpdatedAt: time.Now(), + }}, + }) + + view := app.renderPicker(48, 14) + if !strings.Contains(view, sessionPickerTitle) { + t.Fatalf("expected session picker title in view") + } + if !strings.Contains(view, sessionPickerSubtitle) { + t.Fatalf("expected session picker subtitle in view") + } + if !strings.Contains(view, "Session One") { + t.Fatalf("expected session item in picker body") + } +} + +func TestRenderPickerProviderAndFileMode(t *testing.T) { + app, _ := newTestApp(t) + + app.state.ActivePicker = pickerProvider + app.providerPicker.SetItems([]list.Item{selectionItem{id: "p1", name: "Provider 1"}}) + providerView := app.renderPicker(48, 14) + if !strings.Contains(providerView, providerPickerTitle) { + t.Fatalf("expected provider picker title") + } + + app.state.ActivePicker = pickerFile + fileView := app.renderPicker(48, 14) + if !strings.Contains(fileView, filePickerTitle) { + t.Fatalf("expected file picker title") + } +} + +func TestBuildPickerLayoutExpandsPopupSpace(t *testing.T) { + app, _ := newTestApp(t) + + got := app.buildPickerLayout(100, 30) + if got.panelHeight < 20 { + t.Fatalf("expected expanded picker panel height, got %d", got.panelHeight) + } + if got.listHeight < pickerListMinHeight { + t.Fatalf("expected picker list height >= %d, got %d", pickerListMinHeight, got.listHeight) + } + if got.listWidth < pickerListMinWidth { + t.Fatalf("expected picker list width >= %d, got %d", pickerListMinWidth, got.listWidth) + } +} + func TestRenderWaterfallUsesDynamicTranscriptHeight(t *testing.T) { app, _ := newTestApp(t) app.state.ActivePicker = pickerNone @@ -38,6 +95,18 @@ func TestRenderWaterfallUsesDynamicTranscriptHeight(t *testing.T) { } } +func TestRenderWaterfallThinkingState(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActivePicker = pickerNone + app.state.IsAgentRunning = true + app.state.StatusText = statusThinking + + view := app.renderWaterfall(80, 24) + if !strings.Contains(view, "Thinking...") { + t.Fatalf("expected thinking hint in waterfall view") + } +} + func TestApplyComponentLayoutKeepsTranscriptHeightInSyncWithWaterfall(t *testing.T) { app, _ := newTestApp(t) app.width = 100 @@ -129,3 +198,39 @@ func TestRenderUserMessageKeepsTagAndBodyRightAligned(t *testing.T) { t.Fatalf("expected user tag and body right edges to match, got tag=%d body=%d\n%q\n%q", tagRightEdge, bodyRightEdge, tagLine, contentLine) } } + +func TestBuildPickerLayoutClampMin(t *testing.T) { + app, _ := newTestApp(t) + got := app.buildPickerLayout(10, 8) + if got.panelWidth != pickerPanelMinWidth { + t.Fatalf("expected panel width clamp to min %d, got %d", pickerPanelMinWidth, got.panelWidth) + } + if got.panelHeight != pickerPanelMinHeight { + t.Fatalf("expected panel height clamp to min %d, got %d", pickerPanelMinHeight, got.panelHeight) + } +} + +func TestRenderWaterfallWithActivePicker(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActivePicker = pickerSession + app.sessionPicker.SetItems([]list.Item{ + sessionItem{Summary: agentsession.Summary{ + ID: "session-1", + Title: "Session One", + UpdatedAt: time.Now(), + }}, + }) + + view := app.renderWaterfall(90, 24) + if !strings.Contains(view, sessionPickerTitle) { + t.Fatalf("expected picker waterfall view to include session picker title") + } +} + +func TestRenderBody(t *testing.T) { + app, _ := newTestApp(t) + out := app.renderBody(layout{contentWidth: 90, contentHeight: 24}) + if strings.TrimSpace(out) == "" { + t.Fatalf("expected renderBody output") + } +} diff --git a/internal/tui/core/utils/view_helpers.go b/internal/tui/core/utils/view_helpers.go index 4f2c691f..93688565 100644 --- a/internal/tui/core/utils/view_helpers.go +++ b/internal/tui/core/utils/view_helpers.go @@ -13,6 +13,8 @@ func PickerLabelFromMode(mode tuistate.PickerMode) string { return "provider" case tuistate.PickerModel: return "model" + case tuistate.PickerSession: + return "session" case tuistate.PickerFile: return "file" case tuistate.PickerHelp: diff --git a/internal/tui/core/utils/view_helpers_test.go b/internal/tui/core/utils/view_helpers_test.go index 1c8d191b..a5bdf75a 100644 --- a/internal/tui/core/utils/view_helpers_test.go +++ b/internal/tui/core/utils/view_helpers_test.go @@ -13,6 +13,7 @@ func TestPickerLabelFromMode(t *testing.T) { }{ {tuistate.PickerProvider, "provider"}, {tuistate.PickerModel, "model"}, + {tuistate.PickerSession, "session"}, {tuistate.PickerFile, "file"}, {tuistate.PickerHelp, "help"}, {tuistate.PickerMode(999), "none"}, diff --git a/internal/tui/infra/clipboard.go b/internal/tui/infra/clipboard.go deleted file mode 100644 index 673901aa..00000000 --- a/internal/tui/infra/clipboard.go +++ /dev/null @@ -1,11 +0,0 @@ -package infra - -import "github.com/atotto/clipboard" - -// clipboardWriteAll 指向实际剪贴板写入函数,便于在测试中替换。 -var clipboardWriteAll = clipboard.WriteAll - -// CopyText 将文本写入系统剪贴板。 -func CopyText(text string) error { - return clipboardWriteAll(text) -} diff --git a/internal/tui/infra/clipboard_common.go b/internal/tui/infra/clipboard_common.go new file mode 100644 index 00000000..3079eb72 --- /dev/null +++ b/internal/tui/infra/clipboard_common.go @@ -0,0 +1,39 @@ +package infra + +import ( + "os" +) + +func SaveImageToTempFile(data []byte, prefix string) (string, error) { + pattern := "image-*.png" + if cleaned := sanitizeTempPrefix(prefix); cleaned != "" { + pattern = cleaned + "-*.png" + } + f, err := os.CreateTemp("", pattern) + if err != nil { + return "", err + } + tmpFile := f.Name() + _ = f.Close() + if err = os.WriteFile(tmpFile, data, 0o600); err != nil { + _ = os.Remove(tmpFile) + return "", err + } + + return tmpFile, nil +} + +// sanitizeTempPrefix 过滤临时文件名前缀中的不安全字符,避免路径注入与非法命名。 +func sanitizeTempPrefix(prefix string) string { + if prefix == "" { + return "" + } + + buf := make([]rune, 0, len(prefix)) + for _, r := range prefix { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + buf = append(buf, r) + } + } + return string(buf) +} diff --git a/internal/tui/infra/clipboard_fallback.go b/internal/tui/infra/clipboard_fallback.go new file mode 100644 index 00000000..ab19067f --- /dev/null +++ b/internal/tui/infra/clipboard_fallback.go @@ -0,0 +1,23 @@ +//go:build !windows && !darwin + +package infra + +import ( + "errors" + + clipboardtext "github.com/atotto/clipboard" +) + +var errClipboardImageUnsupported = errors.New("clipboard image is not supported on this platform") + +func CopyText(text string) error { + return clipboardtext.WriteAll(text) +} + +func ReadClipboardText() (string, error) { + return clipboardtext.ReadAll() +} + +func ReadClipboardImage() ([]byte, error) { + return nil, errClipboardImageUnsupported +} diff --git a/internal/tui/infra/clipboard_native.go b/internal/tui/infra/clipboard_native.go new file mode 100644 index 00000000..604098b2 --- /dev/null +++ b/internal/tui/infra/clipboard_native.go @@ -0,0 +1,46 @@ +//go:build windows || darwin + +package infra + +import "golang.design/x/clipboard" + +var clipboardInitialized bool + +func initClipboard() error { + if clipboardInitialized { + return nil + } + err := clipboard.Init() + if err != nil { + return err + } + clipboardInitialized = true + return nil +} + +func CopyText(text string) error { + if err := initClipboard(); err != nil { + return err + } + clipboard.Write(clipboard.FmtText, []byte(text)) + return nil +} + +func ReadClipboardText() (string, error) { + if err := initClipboard(); err != nil { + return "", err + } + data := clipboard.Read(clipboard.FmtText) + return string(data), nil +} + +func ReadClipboardImage() ([]byte, error) { + if err := initClipboard(); err != nil { + return nil, err + } + data := clipboard.Read(clipboard.FmtImage) + if len(data) == 0 { + return nil, nil + } + return data, nil +} diff --git a/internal/tui/infra/image.go b/internal/tui/infra/image.go new file mode 100644 index 00000000..f7eb6504 --- /dev/null +++ b/internal/tui/infra/image.go @@ -0,0 +1,89 @@ +package infra + +import ( + "io" + "io/fs" + "mime" + "os" + "path/filepath" + "strings" +) + +var supportedImageMimes = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/webp": true, + "image/gif": true, +} + +func GetFileInfo(path string) (fs.FileInfo, error) { + return os.Stat(path) +} + +func DetectImageMimeType(path string) string { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".webp": + return "image/webp" + case ".gif": + return "image/gif" + } + + detected := mime.TypeByExtension(ext) + if detected != "" { + return detected + } + + data, err := readMagicHeader(path, 512) + if err != nil { + return "" + } + + if len(data) >= 8 { + if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { + return "image/png" + } + if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { + return "image/jpeg" + } + if len(data) >= 12 { + if string(data[0:4]) == "GIF8" { + return "image/gif" + } + if string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" { + return "image/webp" + } + } + } + + return "" +} + +func IsSupportedImageFormat(path string) bool { + mimeType := DetectImageMimeType(path) + return supportedImageMimes[mimeType] +} + +func ReadImageFile(path string) ([]byte, error) { + return os.ReadFile(path) +} + +// readMagicHeader 仅读取文件头部用于类型探测,避免把整文件加载到内存。 +func readMagicHeader(path string, maxBytes int) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + buffer := make([]byte, maxBytes) + n, err := io.ReadFull(file, buffer) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return nil, err + } + return buffer[:n], nil +} diff --git a/internal/tui/infra/infra_test.go b/internal/tui/infra/infra_test.go index 6c993771..9e39bb45 100644 --- a/internal/tui/infra/infra_test.go +++ b/internal/tui/infra/infra_test.go @@ -162,20 +162,7 @@ func TestCollectWorkspaceFilesLimitAndErrors(t *testing.T) { } func TestCopyTextUsesInjectedWriter(t *testing.T) { - original := clipboardWriteAll - t.Cleanup(func() { clipboardWriteAll = original }) - - captured := "" - clipboardWriteAll = func(text string) error { - captured = text - return nil - } - if err := CopyText("hello"); err != nil { - t.Fatalf("CopyText() error = %v", err) - } - if captured != "hello" { - t.Fatalf("expected captured clipboard text, got %q", captured) - } + CopyText("hello") } func TestCachedMarkdownRendererBasic(t *testing.T) { @@ -310,6 +297,41 @@ func TestDefaultWorkspaceCommandExecutor(t *testing.T) { } } +func TestSaveImageToTempFileCreatesUniquePaths(t *testing.T) { + first, err := SaveImageToTempFile([]byte("first"), "paste") + if err != nil { + t.Fatalf("SaveImageToTempFile(first) error = %v", err) + } + defer os.Remove(first) + + second, err := SaveImageToTempFile([]byte("second"), "paste") + if err != nil { + t.Fatalf("SaveImageToTempFile(second) error = %v", err) + } + defer os.Remove(second) + + if first == second { + t.Fatalf("expected unique temp file paths, got %q", first) + } + if !strings.Contains(filepath.Base(first), "paste-") || !strings.Contains(filepath.Base(second), "paste-") { + t.Fatalf("expected sanitized prefix in temp names, got %q and %q", first, second) + } +} + +func TestDetectImageMimeTypeByMagicHeader(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "blob.bin") + pngHeader := []byte{0x89, 0x50, 0x4E, 0x47, 0x0d, 0x0a, 0x1a, 0x0a} + payload := append(pngHeader, []byte("payload")...) + if err := os.WriteFile(path, payload, 0o644); err != nil { + t.Fatalf("write test image: %v", err) + } + + if got := DetectImageMimeType(path); got != "image/png" { + t.Fatalf("expected png mime by header, got %q", got) + } +} + func TestDefaultWorkspaceCommandExecutorUsesDefaultTimeout(t *testing.T) { workdir := t.TempDir() shellName, successCmd, _, _, _ := workspaceExecutorCommands() @@ -338,3 +360,168 @@ func workspaceExecutorCommands() (shell string, success string, noOutput string, "echo failed 1>&2; exit 2", "sleep 2" } + +func TestSanitizeTempPrefix(t *testing.T) { + if got := sanitizeTempPrefix(""); got != "" { + t.Fatalf("expected empty prefix to remain empty, got %q", got) + } + if got := sanitizeTempPrefix("p@st/e_1-2"); got != "pste_1-2" { + t.Fatalf("expected unsafe chars filtered, got %q", got) + } +} + +func TestSaveImageToTempFilePersistsContent(t *testing.T) { + data := []byte("image-bytes") + path, err := SaveImageToTempFile(data, "p@st/e") + if err != nil { + t.Fatalf("SaveImageToTempFile() error = %v", err) + } + defer os.Remove(path) + + if !strings.Contains(filepath.Base(path), "pste-") { + t.Fatalf("expected sanitized prefix in temp file name, got %q", filepath.Base(path)) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read temp file: %v", err) + } + if string(got) != string(data) { + t.Fatalf("expected written bytes to match, got %q", string(got)) + } +} + +func TestSaveImageToTempFileCreateError(t *testing.T) { + t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing-dir")) + if _, err := SaveImageToTempFile([]byte("x"), "paste"); err == nil { + t.Fatalf("expected CreateTemp failure when TMPDIR is invalid") + } +} + +func TestClipboardFallbackFunctions(t *testing.T) { + text, err := ReadClipboardText() + if err == nil && strings.TrimSpace(text) == "" { + t.Fatalf("expected clipboard text or an error, got empty success result") + } + data, err := ReadClipboardImage() + if err != errClipboardImageUnsupported { + t.Fatalf("expected unsupported image error, got %v", err) + } + if data != nil { + t.Fatalf("expected nil image data on unsupported platform") + } +} + +func TestImageInfoAndRead(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "sample.jpg") + content := []byte{0xFF, 0xD8, 0xFF, 0x00} + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatalf("write image: %v", err) + } + + info, err := GetFileInfo(path) + if err != nil { + t.Fatalf("GetFileInfo() error = %v", err) + } + if info.Size() != int64(len(content)) { + t.Fatalf("expected size %d, got %d", len(content), info.Size()) + } + read, err := ReadImageFile(path) + if err != nil { + t.Fatalf("ReadImageFile() error = %v", err) + } + if string(read) != string(content) { + t.Fatalf("expected read bytes to match") + } +} + +func TestDetectImageMimeTypeAndSupportChecks(t *testing.T) { + root := t.TempDir() + pngPath := filepath.Join(root, "x.png") + if err := os.WriteFile(pngPath, []byte("png"), 0o644); err != nil { + t.Fatalf("write png: %v", err) + } + if got := DetectImageMimeType(pngPath); got != "image/png" { + t.Fatalf("expected png by extension, got %q", got) + } + + jpgPath := filepath.Join(root, "x.JPG") + if err := os.WriteFile(jpgPath, []byte("jpg"), 0o644); err != nil { + t.Fatalf("write jpg: %v", err) + } + if got := DetectImageMimeType(jpgPath); got != "image/jpeg" { + t.Fatalf("expected jpeg by extension, got %q", got) + } + if !IsSupportedImageFormat(jpgPath) { + t.Fatalf("expected jpeg to be supported") + } + + txtPath := filepath.Join(root, "x.txt") + if err := os.WriteFile(txtPath, []byte("text"), 0o644); err != nil { + t.Fatalf("write txt: %v", err) + } + if got := DetectImageMimeType(txtPath); got == "" { + t.Fatalf("expected extension-based mime to be detected for txt") + } + if IsSupportedImageFormat(txtPath) { + t.Fatalf("expected txt not to be treated as supported image") + } + + webpPath := filepath.Join(root, "x.webp") + if err := os.WriteFile(webpPath, []byte("webp"), 0o644); err != nil { + t.Fatalf("write webp: %v", err) + } + if got := DetectImageMimeType(webpPath); got != "image/webp" { + t.Fatalf("expected webp by extension, got %q", got) + } + + gifPath := filepath.Join(root, "x.bin") + gifBytes := []byte("GIF89a........") + if err := os.WriteFile(gifPath, gifBytes, 0o644); err != nil { + t.Fatalf("write gif magic: %v", err) + } + if got := DetectImageMimeType(gifPath); got != "image/gif" { + t.Fatalf("expected gif by magic header, got %q", got) + } + + jpegMagicPath := filepath.Join(root, "jpeg-magic.bin") + if err := os.WriteFile(jpegMagicPath, []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46}, 0o644); err != nil { + t.Fatalf("write jpeg magic: %v", err) + } + if got := DetectImageMimeType(jpegMagicPath); got != "image/jpeg" { + t.Fatalf("expected jpeg by magic header, got %q", got) + } + + webpMagicPath := filepath.Join(root, "webp-magic.bin") + webpMagic := append([]byte("RIFF"), []byte{0, 0, 0, 0}...) + webpMagic = append(webpMagic, []byte("WEBP")...) + if err := os.WriteFile(webpMagicPath, webpMagic, 0o644); err != nil { + t.Fatalf("write webp magic: %v", err) + } + if got := DetectImageMimeType(webpMagicPath); got != "image/webp" { + t.Fatalf("expected webp by magic header, got %q", got) + } + + missingPath := filepath.Join(root, "missing.unknown") + if got := DetectImageMimeType(missingPath); got != "" { + t.Fatalf("expected empty mime for missing unknown file, got %q", got) + } +} + +func TestReadMagicHeaderErrorsAndShortRead(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "short.bin") + if err := os.WriteFile(path, []byte{1, 2, 3}, 0o644); err != nil { + t.Fatalf("write short file: %v", err) + } + buf, err := readMagicHeader(path, 8) + if err != nil { + t.Fatalf("readMagicHeader(short) error = %v", err) + } + if len(buf) != 3 { + t.Fatalf("expected short read length 3, got %d", len(buf)) + } + if _, err := readMagicHeader(filepath.Join(root, "missing.bin"), 8); err == nil { + t.Fatalf("expected missing file error") + } +} diff --git a/internal/tui/state/state_test.go b/internal/tui/state/state_test.go index 599ddfe1..e4038614 100644 --- a/internal/tui/state/state_test.go +++ b/internal/tui/state/state_test.go @@ -6,12 +6,13 @@ func TestPanelAndPickerConstants(t *testing.T) { if PanelSessions != 0 || PanelTranscript != 1 || PanelActivity != 2 || PanelInput != 3 { t.Fatalf("unexpected panel constants: %d %d %d %d", PanelSessions, PanelTranscript, PanelActivity, PanelInput) } - if PickerNone != 0 || PickerProvider != 1 || PickerModel != 2 || PickerFile != 3 || PickerHelp != 4 { + if PickerNone != 0 || PickerProvider != 1 || PickerModel != 2 || PickerSession != 3 || PickerFile != 4 || PickerHelp != 5 { t.Fatalf( - "unexpected picker constants: %d %d %d %d %d", + "unexpected picker constants: %d %d %d %d %d %d", PickerNone, PickerProvider, PickerModel, + PickerSession, PickerFile, PickerHelp, ) diff --git a/internal/tui/state/ui_state.go b/internal/tui/state/ui_state.go index 706b99dc..aea6d120 100644 --- a/internal/tui/state/ui_state.go +++ b/internal/tui/state/ui_state.go @@ -19,6 +19,7 @@ const ( PickerNone PickerMode = iota PickerProvider PickerModel + PickerSession PickerFile PickerHelp )