diff --git a/.gitignore b/.gitignore index 5360073..f23a375 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ *.so *.dylib +# Compiled CLI binary at repo root (use make build → bin/) +/cli + # Test binary, built with `go test -c` *.test @@ -46,6 +49,7 @@ Thumbs.db .openclaude-profile.json .blackboxcli/ claude-code/ +private/ # Temporary files *.log diff --git a/api b/api new file mode 100755 index 0000000..b8eff78 Binary files /dev/null and b/api differ diff --git a/cli b/cli new file mode 100755 index 0000000..a5355bb Binary files /dev/null and b/cli differ diff --git a/cmd/cli/appdir/appdir.go b/cmd/cli/appdir/appdir.go new file mode 100644 index 0000000..a9b0af4 --- /dev/null +++ b/cmd/cli/appdir/appdir.go @@ -0,0 +1,105 @@ +// Package appdir is the single source of truth for the CLI's filesystem layout. +// +// All persistent data lives under Root() (~/.config/nexus-cli/ by default, or +// the value of NEXUS_RUNTIME_ROOT). Session-scoped data is isolated under +// sessions/{session_id}/ so deleting a session is a single os.RemoveAll call. +// +// Directory layout: +// +// ~/.config/nexus-cli/ +// ├── logs/ +// ├── documents/ ← user-uploaded PDFs and docs (global, persistent) +// ├── rag/ ← RAG-indexed documents (global, persistent) +// └── sessions/ +// └── {session_id}/ +// ├── screenshots/ ← browser screenshots +// ├── plans/ ← plan-mode markdown files +// ├── tools/ ← browser downloads +// └── artifacts/ +// ├── web/ ← web-scraped content +// ├── images/ ← AI-generated images +// └── audio/ ← TTS / STT audio +package appdir + +import ( + "os" + "path/filepath" + + "github.com/EngineerProjects/nexus-engine/pkg/runtimepath" +) + +// Root returns the application root directory, resolved via NEXUS_RUNTIME_ROOT +// or the platform default (~/.config/nexus-cli/ on Linux/macOS). +func Root() string { return runtimepath.ResolveRoot("") } + +// ─── Global directories ─────────────────────────────────────────────────────── + +func LogsDir() string { return runtimepath.LogsDir("") } +func SessionsDir() string { return runtimepath.SessionsDir("") } + +// ─── Per-session directories ────────────────────────────────────────────────── + +func SessionDir(sessionID string) string { return runtimepath.SessionDir("", sessionID) } +func SessionScreenshotsDir(sessionID string) string { + return runtimepath.SessionScreenshotsDir("", sessionID) +} +func SessionPlansDir(sessionID string) string { return runtimepath.SessionPlansDir("", sessionID) } +func SessionToolsDir(sessionID string) string { return runtimepath.SessionToolsDir("", sessionID) } +func SessionLogPath(sessionID string) string { return runtimepath.SessionLogPath("", sessionID) } + +// Artifact subdirectories — agent-produced content, session-scoped. +func SessionArtifactsDir(sessionID string) string { + return runtimepath.SessionArtifactsDir("", sessionID) +} +func SessionArtifactsWebDir(sessionID string) string { + return runtimepath.SessionArtifactsWebDir("", sessionID) +} +func SessionArtifactsImagesDir(sessionID string) string { + return runtimepath.SessionArtifactsImagesDir("", sessionID) +} +func SessionArtifactsAudioDir(sessionID string) string { + return runtimepath.SessionArtifactsAudioDir("", sessionID) +} + +// ─── Lifecycle helpers ──────────────────────────────────────────────────────── + +// EnsureAppDirs creates the top-level directories required at startup. +// Safe to call multiple times (uses os.MkdirAll). +func EnsureAppDirs() error { + dirs := []string{ + LogsDir(), + SessionsDir(), + } + for _, d := range dirs { + if err := os.MkdirAll(d, 0o700); err != nil { + return err + } + } + return nil +} + +// EnsureSessionDir creates sessions/{id}/ and all standard subdirectories. +// Call this when a session starts, before any tools run. Safe to call multiple times. +func EnsureSessionDir(sessionID string) error { + dirs := []string{ + SessionScreenshotsDir(sessionID), + SessionPlansDir(sessionID), + SessionToolsDir(sessionID), + SessionArtifactsWebDir(sessionID), + SessionArtifactsImagesDir(sessionID), + SessionArtifactsAudioDir(sessionID), + } + for _, d := range dirs { + if err := os.MkdirAll(d, 0o700); err != nil { + return err + } + } + return nil +} + +// DeleteSessionDir removes sessions/{id}/ and all its contents in one call. +// Covers screenshots, plans, tools, artifacts, logs — everything. +// Errors are intentionally ignored; DB cleanup is the authoritative deletion. +func DeleteSessionDir(sessionID string) { + _ = os.RemoveAll(filepath.Join(SessionsDir(), sessionID)) +} diff --git a/cmd/cli/config.go b/cmd/cli/config.go index 244b1f3..6b4f767 100644 --- a/cmd/cli/config.go +++ b/cmd/cli/config.go @@ -6,6 +6,7 @@ import ( "flag" "fmt" "io" + "os" "strconv" "strings" @@ -16,14 +17,16 @@ import ( // credentialKey constants — these are the keys used in the credentials table. const ( - credKeyAPIKey = "api_key" - credKeyBaseURL = "provider_base_url" - credKeyRegion = "provider_region" - credKeyProjectID = "provider_project_id" - credKeyResource = "provider_resource" - credKeyTavily = "TAVILY_API_KEY" - credKeyExa = "EXA_API_KEY" - credKeyJina = "JINA_API_KEY" + credKeyModel = "model" + credKeyAPIKey = "api_key" + credKeyBaseURL = "provider_base_url" + credKeyRegion = "provider_region" + credKeyProjectID = "provider_project_id" + credKeyResource = "provider_resource" + credKeyTavily = "TAVILY_API_KEY" + credKeyExa = "EXA_API_KEY" + credKeyJina = "JINA_API_KEY" + credKeyLangSearch = "LANGSEARCH_API_KEY" ) func runConfig(args []string, stdin io.Reader, stdout, stderr io.Writer) error { @@ -153,6 +156,11 @@ func runConfig(args []string, stdin io.Reader, stdout, stderr io.Writer) error { return err } + // Persist model selection to DB so it survives YAML resets. + if err := saveCredential(database, credKeyModel, config.Model); err != nil { + return err + } + // Strip runtime-only secrets from YAML before saving. yamlConfig := stripRuntimeSecrets(config) if err := engineconfig.Save(yamlConfig); err != nil { @@ -166,12 +174,15 @@ func runConfig(args []string, stdin io.Reader, stdout, stderr io.Writer) error { return nil } -// configureSearchKeys prompts for Tavily / Exa / Jina API keys. +// configureSearchKeys prompts for Tavily / Exa / Jina / LangSearch API keys. func configureSearchKeys(reader *bufio.Reader, stdout io.Writer, database *db.DB, config *engineconfig.Config) error { fmt.Fprintln(stdout, "\n─── Search tool API keys ────────────────────────────────────────") fmt.Fprintln(stdout, "Leave blank to keep the current value. Enter \"-\" to clear.") fmt.Fprintln(stdout) + // LangSearch has no Config struct field — use a local var and apply as env var. + langSearchCurrent := os.Getenv("LANGSEARCH_API_KEY") + fields := []struct { credKey string label string @@ -181,6 +192,7 @@ func configureSearchKeys(reader *bufio.Reader, stdout io.Writer, database *db.DB {credKeyTavily, "Tavily API key", "TAVILY_API_KEY", &config.TavilyAPIKey}, {credKeyExa, "Exa API key", "EXA_API_KEY", &config.ExaAPIKey}, {credKeyJina, "Jina AI API key", "JINA_API_KEY", &config.JinaAPIKey}, + {credKeyLangSearch, "LangSearch API key", "LANGSEARCH_API_KEY", &langSearchCurrent}, } for _, f := range fields { @@ -244,6 +256,7 @@ func printConfigSummary(out io.Writer, config engineconfig.Config, model sdk.Mod {"tavily", config.TavilyAPIKey}, {"exa", config.ExaAPIKey}, {"jina", config.JinaAPIKey}, + {"langsearch", os.Getenv("LANGSEARCH_API_KEY")}, } anySearch := false for _, f := range searchFields { @@ -411,18 +424,35 @@ func loadCredsIntoConfig(database *db.DB, config *engineconfig.Config) { // TUI config panel), then falls back to the global key (written by `nexus config`). loadCredScoped := func(fieldKey, providerID string) string { if providerID != "" { - if v := loadCred(fieldKey + ":" + strings.ToLower(providerID)); v != "" { + // Normalize providerID to ensure consistent lookups (e.g. "zai" -> "z-ai"). + normalized := string(engineconfig.ResolveProvider(providerID)) + if normalized == "" { + normalized = strings.ToLower(providerID) + } + if v := loadCred(fieldKey + ":" + normalized); v != "" { return v } } return loadCred(fieldKey) } + // Load persisted model selection from DB when YAML has none. + // This is the primary source of truth for which provider is "active". + if strings.TrimSpace(config.Model) == "" { + if v := loadCred(credKeyModel); v != "" { + config.Model = v + } + } + // Determine the active provider from the config model string. + // We use the raw model string to avoid circularity in resolveModel. activeProvider := "" - if m := resolveModel(*config); m.Provider != "" { + if m := engineconfig.ParseModelIdentifier(config.Model); m.Provider != "" { activeProvider = string(m.Provider) } + if activeProvider == "" { + activeProvider = string(engineconfig.DetectProviderFromModel(config.Model)) + } if v := loadCredScoped(credKeyAPIKey, activeProvider); v != "" { config.APIKey = v @@ -442,9 +472,22 @@ func loadCredsIntoConfig(database *db.DB, config *engineconfig.Config) { config.TavilyAPIKey = loadCred(credKeyTavily) config.ExaAPIKey = loadCred(credKeyExa) config.JinaAPIKey = loadCred(credKeyJina) + + if config.WebSearchProvider == "" { + config.WebSearchProvider = loadCred("WEB_SEARCH_PROVIDER") + } + + // LangSearch and SearXNG have no Config struct field — apply directly as env vars. + if v := loadCred(credKeyLangSearch); v != "" && os.Getenv("LANGSEARCH_API_KEY") == "" { + os.Setenv("LANGSEARCH_API_KEY", v) + } + if v := loadCred(credKeySearXNG); v != "" && os.Getenv("SEARXNG_BASE_URL") == "" { + os.Setenv("SEARXNG_BASE_URL", v) + } } func stripRuntimeSecrets(config engineconfig.Config) engineconfig.Config { + // Strip secrets — stored in the DB, never in YAML. config.APIKey = "" config.ProviderBaseURL = "" config.ProviderRegion = "" @@ -453,6 +496,10 @@ func stripRuntimeSecrets(config engineconfig.Config) engineconfig.Config { config.TavilyAPIKey = "" config.ExaAPIKey = "" config.JinaAPIKey = "" + // RuntimeRoot is always re-computed at startup from NEXUS_RUNTIME_ROOT or + // the XDG default (~/.config/nexus-cli). Never persist it so the YAML stays + // portable and doesn't hard-code absolute paths. + config.RuntimeRoot = "" return config } diff --git a/cmd/cli/main.go b/cmd/cli/main.go index ae8f8c8..1cc7925 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -5,9 +5,20 @@ import ( "fmt" "os" "os/signal" + "path/filepath" + + "github.com/EngineerProjects/nexus-engine/pkg/runtimepath" ) func main() { + // Pin the CLI runtime root to ~/.config/nexus-cli, isolated from the + // nexus-product backend (~/.config/nexus). NEXUS_RUNTIME_ROOT takes precedence. + if os.Getenv(runtimepath.EnvRuntimeRoot) == "" { + if home, err := os.UserHomeDir(); err == nil { + os.Setenv(runtimepath.EnvRuntimeRoot, filepath.Join(home, ".config", "nexus-cli")) + } + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() diff --git a/cmd/cli/run.go b/cmd/cli/run.go index bc63132..4f72611 100644 --- a/cmd/cli/run.go +++ b/cmd/cli/run.go @@ -208,7 +208,11 @@ func loadChatSession(ctx context.Context, client *sdk.Client, sessionID string) func printChatBanner(out io.Writer, options runtimeOptions, session *sdk.Session, showThinking bool) { fmt.Fprintf(out, "Nexus dev chat\n") fmt.Fprintf(out, "workspace: %s\n", options.WorkingDir) - fmt.Fprintf(out, "model: %s/%s\n", options.Model.Provider, options.Model.Model) + modelLabel := options.Model.String() + if options.Model.Provider == "" { + modelLabel = "(not configured — run `nexus config`)" + } + fmt.Fprintf(out, "model: %s\n", modelLabel) fmt.Fprintf(out, "permission: %s\n", options.PermissionMode) fmt.Fprintf(out, "session: %s\n", session.GetID().String()) if showThinking { @@ -280,6 +284,9 @@ func splitCommand(raw string) (string, string) { } func validateProviderSetup(options runtimeOptions) error { + if options.Model.Provider == "" { + return nil // nothing configured yet; TUI will guide the user + } config, err := engineconfig.Load() if err != nil { return err @@ -287,7 +294,7 @@ func validateProviderSetup(options runtimeOptions) error { config.Model = options.Model.String() config.APIKey = options.APIKey if validateErr := engineconfig.ValidateProviderSetup(config, options.Model.Provider); validateErr != nil { - return fmt.Errorf("%w; run `go run ./cmd/cli config` first", validateErr) + return fmt.Errorf("%w; run `nexus config` first", validateErr) } return nil } diff --git a/cmd/cli/runtime.go b/cmd/cli/runtime.go index 81a9054..9ac0aa1 100644 --- a/cmd/cli/runtime.go +++ b/cmd/cli/runtime.go @@ -2,12 +2,18 @@ package main import ( "fmt" + "log" "os" "path/filepath" "strings" "time" + "github.com/EngineerProjects/nexus-engine/internal/providers" + internalrag "github.com/EngineerProjects/nexus-engine/internal/rag" + "github.com/EngineerProjects/nexus-engine/internal/rag/embedder" + "github.com/EngineerProjects/nexus-engine/internal/vector" engineconfig "github.com/EngineerProjects/nexus-engine/pkg/config" + "github.com/EngineerProjects/nexus-engine/pkg/runtimepath" "github.com/EngineerProjects/nexus-engine/pkg/sdk" ) @@ -17,6 +23,10 @@ type runtimeOptions struct { WorkingDir string SQLitePath string APIKey string + ProviderBaseURL string + ProviderRegion string + ProviderProjectID string + ProviderResource string BrowserRemoteControlURL string BrowserExecutablePath string DoclingURL string @@ -26,6 +36,10 @@ type runtimeOptions struct { StorageGCNamespaces []string Debug bool + // RAGService is the embedded HNSW-backed RAG service. + // Nil when the embedding provider is not configured (RAG_EMBEDDING_URL / RAG_EMBEDDING_MODEL absent). + RAGService *sdk.RAGService + // Monitoring is an optional pre-built monitoring system. // Set by runInteractive to redirect logs away from stdout/stderr when // running in TUI (alt-screen) mode. @@ -46,6 +60,14 @@ func loadRuntimeOptions(overrides runtimeOverrides) (runtimeOptions, error) { return runtimeOptions{}, err } + // Apply the model override before loading credentials so that + // loadCredsIntoConfig resolves the API key for the correct provider. + // Without this, loadCredsIntoConfig sees config.Model="" and falls back + // to the default provider (anthropic), missing the scoped key for z-ai etc. + if value := strings.TrimSpace(overrides.Model); value != "" { + config.Model = value + } + // Overlay secrets from the credentials DB so that search keys and provider // API keys stored there take effect without being in the YAML file. if database, dbErr := openCredentialsDB(config); dbErr == nil { @@ -57,9 +79,6 @@ func loadRuntimeOptions(overrides runtimeOverrides) (runtimeOptions, error) { if overrides.Debug != nil { config.Debug = *overrides.Debug } - if value := strings.TrimSpace(overrides.Model); value != "" { - config.Model = value - } if value := strings.TrimSpace(overrides.WorkingDir); value != "" { config.Cwd = value } @@ -87,12 +106,18 @@ func loadRuntimeOptions(overrides runtimeOverrides) (runtimeOptions, error) { model := resolveModel(config) apiKey := engineconfig.ResolveAPIKey(config, model.Provider) + hnswDir := runtimepath.HNSWDataDir(config.RuntimeRoot) + return runtimeOptions{ Model: model, PermissionMode: permissionMode, WorkingDir: workingDir, SQLitePath: engineconfig.EffectiveSessionDBPath(config), APIKey: apiKey, + ProviderBaseURL: config.ProviderBaseURL, + ProviderRegion: config.ProviderRegion, + ProviderProjectID: config.ProviderProjectID, + ProviderResource: config.ProviderResource, BrowserRemoteControlURL: strings.TrimSpace(config.BrowserRemoteControlURL), BrowserExecutablePath: strings.TrimSpace(config.BrowserExecutablePath), DoclingURL: strings.TrimSpace(config.DoclingURL), @@ -101,6 +126,7 @@ func loadRuntimeOptions(overrides runtimeOverrides) (runtimeOptions, error) { StorageGCLimit: config.StorageGCLimit, StorageGCNamespaces: splitCommaList(config.StorageGCNamespaces), Debug: config.Debug, + RAGService: buildRAGService(hnswDir), }, nil } @@ -122,6 +148,25 @@ func newClient( } } + // Build the provider configuration. + providerConfig := providers.GetProviderConfig(options.Model.Provider) + if providerConfig == nil { + providerConfig = &providers.Config{Provider: options.Model.Provider} + } + providerConfig.APIKey = options.APIKey + if options.ProviderBaseURL != "" { + providerConfig.BaseURL = options.ProviderBaseURL + } + if options.ProviderRegion != "" { + providerConfig.Region = options.ProviderRegion + } + if options.ProviderProjectID != "" { + providerConfig.ProjectID = options.ProviderProjectID + } + if options.ProviderResource != "" && options.Model.Provider == sdk.APIProviderFoundry { + providerConfig.Region = options.ProviderResource + } + // EnableMonitoring must be true so initMonitoringSystem honours // options.Monitoring (the TUI file logger) instead of short-circuiting. enableMonitoring := options.Monitoring != nil @@ -146,6 +191,8 @@ func newClient( PreToolHooks: preToolHooks, EnableMonitoring: enableMonitoring, Monitoring: options.Monitoring, + RAGService: options.RAGService, + ProviderConfig: providerConfig, }) if err != nil { return nil, fmt.Errorf("create SDK client: %w", err) @@ -189,22 +236,39 @@ func parsePermissionMode(raw string) (sdk.PermissionMode, error) { return "", fmt.Errorf("unsupported permission mode %q: plan is now an execution mode, not a permission mode", raw) } - switch sdk.PermissionMode(strings.ToLower(value)) { - case sdk.PermissionModeOnRequest: + switch { + case strings.EqualFold(value, string(sdk.PermissionModeOnRequest)): return sdk.PermissionModeOnRequest, nil - case sdk.PermissionModeAuto: + case strings.EqualFold(value, string(sdk.PermissionModeAuto)): return sdk.PermissionModeAuto, nil - case sdk.PermissionMode("acceptedits"): + case strings.EqualFold(value, "acceptEdits") || strings.EqualFold(value, "acceptedits"): return sdk.PermissionMode("acceptEdits"), nil - case sdk.PermissionModeBypass: + case strings.EqualFold(value, string(sdk.PermissionModeBypass)): return sdk.PermissionModeBypass, nil - case sdk.PermissionModeNever: + case strings.EqualFold(value, string(sdk.PermissionModeNever)): return sdk.PermissionModeNever, nil default: return "", fmt.Errorf("unsupported permission mode %q", raw) } } +// buildRAGService creates an HNSW-backed RAG service when an embedding provider +// is configured via env vars (RAG_EMBEDDING_URL + RAG_EMBEDDING_MODEL). +// Returns nil when embedding is not configured — rag_ingest / rag_search tools +// will then be unavailable but all other tools continue working normally. +func buildRAGService(hnswDir string) *sdk.RAGService { + emb := embedder.NewFromEnv() + if emb == nil { + return nil + } + store, err := vector.NewHNSWStore(hnswDir) + if err != nil { + log.Printf("[cli] hnsw vector store unavailable, rag disabled: %v", err) + return nil + } + return internalrag.NewService(nil, store, emb, nil) +} + func resolveModel(config engineconfig.Config) sdk.ModelIdentifier { raw := strings.TrimSpace(config.Model) model := engineconfig.ParseModelIdentifier(raw) diff --git a/cmd/cli/runtime_test.go b/cmd/cli/runtime_test.go index 1b44a54..8384b91 100644 --- a/cmd/cli/runtime_test.go +++ b/cmd/cli/runtime_test.go @@ -37,3 +37,35 @@ func TestParsePermissionModeRejectsPlan(t *testing.T) { t.Fatalf("expected execution mode guidance, got %q", err) } } + +func TestParsePermissionModeCaseInsensitive(t *testing.T) { + tests := []struct { + input string + want sdk.PermissionMode + }{ + {"onRequest", sdk.PermissionModeOnRequest}, + {"onrequest", sdk.PermissionModeOnRequest}, + {"ONREQUEST", sdk.PermissionModeOnRequest}, + {"auto", sdk.PermissionModeAuto}, + {"AUTO", sdk.PermissionModeAuto}, + {"acceptEdits", sdk.PermissionMode("acceptEdits")}, + {"acceptedits", sdk.PermissionMode("acceptEdits")}, + {"ACCEPTEDITS", sdk.PermissionMode("acceptEdits")}, + {"bypass", sdk.PermissionModeBypass}, + {"BYPASS", sdk.PermissionModeBypass}, + {"never", sdk.PermissionModeNever}, + {"NEVER", sdk.PermissionModeNever}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + got, err := parsePermissionMode(tc.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} diff --git a/cmd/cli/tui.go b/cmd/cli/tui.go index d9ea562..2aabb90 100644 --- a/cmd/cli/tui.go +++ b/cmd/cli/tui.go @@ -2,6 +2,8 @@ package main import ( "context" + "encoding/json" + "fmt" "io" "log" "os" @@ -13,6 +15,9 @@ import ( "time" tea "charm.land/bubbletea/v2" + "github.com/EngineerProjects/nexus-engine/cmd/cli/appdir" + coreagent "github.com/EngineerProjects/nexus-engine/internal/agent" + db "github.com/EngineerProjects/nexus-engine/internal/db" "github.com/EngineerProjects/nexus-engine/internal/monitoring" "github.com/EngineerProjects/nexus-engine/internal/providers" "github.com/EngineerProjects/nexus-engine/internal/tui" @@ -75,7 +80,9 @@ func (d *chunkDebounce) forceFlush() { // It bridges the engine's callback-based event model with the BubbleTea // tea.Program.Send() pattern, with 33ms streaming debounce (crush pattern). type nexusWorkspace struct { + clientMu sync.RWMutex client *sdk.Client + clientOpts runtimeOptions model string workDir string permMode string @@ -87,23 +94,103 @@ type nexusWorkspace struct { sessionMu sync.Mutex session *sdk.Session + // reloadMu serialises reloadClient calls and lets CreateSession/LoadSession + // wait for any in-flight provider reload before they bind to a client. + reloadMu sync.Mutex + busy atomic.Bool debounce *chunkDebounce + + // submitMu guards submitCancel, which is non-nil while a Submit goroutine runs. + submitMu sync.Mutex + submitCancel context.CancelFunc +} + +// credKeyOllamaModels is the DB key for the cached Ollama model list. +const credKeyOllamaModels = "ollama:models" + +type ollamaCachedModel struct { + ID string `json:"id"` + Context int `json:"ctx,omitempty"` +} + +// probeOllamaInBackground discovers Ollama models, caches them in the DB, +// and sends a ModelListMsg so the picker refreshes if it happens to be open. +// Safe to call multiple times; each call spawns a goroutine that runs to completion. +func (w *nexusWorkspace) probeOllamaInBackground() { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + + cfg, _ := engineconfig.Load() + database, _ := openCredentialsDB(cfg) + if database != nil { + defer database.Close() + } + + baseURL := ollamaBaseURLFromDB(context.Background(), database) + + fetched, err := providers.FetchModels(ctx, "ollama", baseURL, "") + if err != nil || len(fetched) == 0 { + return + } + + // Persist to DB cache. + cached := make([]ollamaCachedModel, 0, len(fetched)) + for _, m := range fetched { + cached = append(cached, ollamaCachedModel{ID: m.ModelID, Context: m.ContextWindow}) + } + if data, jerr := json.Marshal(cached); jerr == nil && database != nil { + _ = database.UpsertCredential(context.Background(), credKeyOllamaModels, string(data)) + } + + // Trigger a full model-list refresh so the picker updates if open. + w.ListModels(context.Background()) + }() +} + +// ollamaBaseURLFromDB returns the user-configured Ollama endpoint from the DB, +// or empty string (which makes FetchModels fall back to localhost:11434). +func ollamaBaseURLFromDB(ctx context.Context, database interface { + GetCredential(ctx context.Context, key string) (string, bool, error) +}) string { + if database == nil { + return "" + } + if v, ok, _ := database.GetCredential(ctx, "provider_base_url:ollama"); ok && v != "" { + return v + } + return "" } // newNexusWorkspace creates a nexusWorkspace. The workspace registers its own // callbacks on the ClientConfig so that events are forwarded to the TUI. func newNexusWorkspace(options runtimeOptions) (*nexusWorkspace, error) { + _ = appdir.EnsureAppDirs() + // Keep w.model as "" when no provider is configured — the TUI uses this + // to detect first-run and auto-open the provider settings panel. + modelStr := "" + if options.Model.Provider != "" { + modelStr = options.Model.String() + } w := &nexusWorkspace{ - model: options.Model.String(), + model: modelStr, workDir: options.WorkingDir, permMode: string(options.PermissionMode), sqlitePath: options.SQLitePath, + clientOpts: options, } w.debounce = newChunkDebounce(33*time.Millisecond, func(text string) { w.send(tui.ChunkMsg{Text: text}) }) + // When no provider is configured, fall back to the SDK default so the + // client can be initialized. The user will configure the real provider + // through the settings panel before submitting the first message. + if options.Model.Provider == "" { + options.Model = sdk.DefaultClientConfig().Model + } + client, err := newClient( options, w.promptFn, @@ -114,6 +201,8 @@ func newNexusWorkspace(options runtimeOptions) (*nexusWorkspace, error) { return nil, err } w.client = client + // Probe Ollama in background so model cache is warm by the time the picker opens. + w.probeOllamaInBackground() return w, nil } @@ -134,9 +223,83 @@ func (w *nexusWorkspace) send(msg tea.Msg) { } } +func (w *nexusWorkspace) reloadClient(ctx context.Context, modelOverride string) error { + w.reloadMu.Lock() + defer w.reloadMu.Unlock() + + if w.busy.Load() { + return fmt.Errorf("cannot reload provider configuration while a turn is running") + } + + overrides := runtimeOverrides{ + Model: strings.TrimSpace(modelOverride), + PermissionMode: w.permMode, + WorkingDir: w.workDir, + SQLitePath: w.sqlitePath, + } + if overrides.Model == "" { + overrides.Model = strings.TrimSpace(w.model) + } + + opts, err := loadRuntimeOptions(overrides) + if err != nil { + return err + } + opts.Monitoring = w.clientOpts.Monitoring + + displayModel := "" + if opts.Model.Provider != "" { + displayModel = opts.Model.String() + } + clientOpts := opts + if clientOpts.Model.Provider == "" { + clientOpts.Model = sdk.DefaultClientConfig().Model + } + + client, err := newClient(clientOpts, w.promptFn, w.onProgress, w.onChunk) + if err != nil { + return err + } + + activeID := w.ActiveSessionID() + var session *sdk.Session + if activeID != "" { + session, err = client.LoadSession(ctx, sdk.SessionID(activeID)) + if err != nil { + _ = client.Close() + return fmt.Errorf("reload active session: %w", err) + } + } + + w.clientMu.Lock() + oldClient := w.client + w.client = client + w.clientOpts = opts + w.clientMu.Unlock() + + w.sessionMu.Lock() + w.session = session + w.sessionMu.Unlock() + + w.model = displayModel + w.workDir = opts.WorkingDir + w.permMode = string(opts.PermissionMode) + w.sqlitePath = opts.SQLitePath + + if oldClient != nil { + go func(c *sdk.Client) { + _ = c.Close() + }(oldClient) + } + return nil +} + func (w *nexusWorkspace) ListSessions(ctx context.Context) { go func() { - infos, err := w.client.ListSessions() + w.clientMu.RLock() + client := w.client + infos, err := client.ListSessions() + w.clientMu.RUnlock() if err != nil { w.send(tui.SessionListMsg{Err: err}) return @@ -148,10 +311,13 @@ func (w *nexusWorkspace) ListSessions(ctx context.Context) { } id := string(info.ID) sessions = append(sessions, tui.SessionInfo{ - ID: id, - ShortID: shortIDStr(id), - Turns: info.TotalTurns, - Tokens: info.TotalTokens, + ID: id, + ShortID: shortIDStr(id), + Turns: info.TotalTurns, + Tokens: info.TotalTokens, + UpdatedAt: time.Unix(info.UpdatedAt, 0), + CreatedAt: time.Unix(info.CreatedAt, 0), + Preview: info.Preview, }) } w.send(tui.SessionListMsg{Sessions: sessions}) @@ -160,11 +326,20 @@ func (w *nexusWorkspace) ListSessions(ctx context.Context) { func (w *nexusWorkspace) CreateSession(ctx context.Context) { go func() { - sess, err := w.client.CreateSession(ctx) + // Wait for any in-flight provider reload to finish so the session is + // always bound to the most recent (correctly keyed) client. + w.reloadMu.Lock() + w.reloadMu.Unlock() //nolint:staticcheck + + w.clientMu.RLock() + client := w.client + sess, err := client.CreateSession(ctx) + w.clientMu.RUnlock() if err != nil { w.send(tui.SessionCreatedMsg{Err: err}) return } + _ = appdir.EnsureSessionDir(string(sess.GetID())) w.sessionMu.Lock() w.session = sess w.sessionMu.Unlock() @@ -174,20 +349,159 @@ func (w *nexusWorkspace) CreateSession(ctx context.Context) { func (w *nexusWorkspace) LoadSession(ctx context.Context, id string) { go func() { - sess, err := w.client.LoadSession(ctx, sdk.SessionID(id)) + w.reloadMu.Lock() + w.reloadMu.Unlock() //nolint:staticcheck + + w.clientMu.RLock() + client := w.client + sess, err := client.LoadSession(ctx, sdk.SessionID(id)) + w.clientMu.RUnlock() if err != nil { w.send(tui.SessionLoadedMsg{Err: err}) return } + _ = appdir.EnsureSessionDir(id) w.sessionMu.Lock() w.session = sess w.sessionMu.Unlock() - w.send(tui.SessionLoadedMsg{ID: string(sess.GetID())}) + messages := sess.GetMessages() + history := buildSessionHistory(messages) + w.send(tui.SessionLoadedMsg{ID: string(sess.GetID()), History: history}) + + // Backfill session_files for sessions that predate live recording. + go func(sessionID string, msgs []sdk.Message) { + cfg, err := engineconfig.Load() + if err != nil { + return + } + database, err := openCredentialsDB(cfg) + if err != nil { + return + } + defer database.Close() + ctx := context.Background() + if already, _ := database.HasSessionFileEntry(ctx, sessionID); already { + return // already populated, nothing to do + } + backfillSessionFiles(ctx, database, sessionID, msgs) + }(string(sess.GetID()), messages) }() } +// buildSessionHistory converts raw SDK messages into a flat list of HistoryEntry +// values suitable for replaying in the TUI chat component. +// ToolResultContent.Metadata already carries the full TUI metadata map +// (content, execution_duration_ms, lines_added, exit_code, …) written by +// buildToolResultMessages in the engine — no data is lost. +func buildSessionHistory(messages []sdk.Message) []tui.HistoryEntry { + // Pre-pass: collect tool result metadata keyed by tool_use_id. + // Both the raw content string and the full metadata map are captured. + type toolResult struct { + content string + metadata map[string]any + } + resultFor := make(map[string]toolResult, len(messages)) + for _, msg := range messages { + if msg.Role != sdk.RoleUser { + continue + } + for _, block := range msg.Content { + if tr, ok := block.(sdk.ToolResultContent); ok { + r := toolResult{content: tr.Content} + if tr.Metadata != nil { + r.metadata = *tr.Metadata + } + resultFor[tr.ToolUseID] = r + } + } + } + + var entries []tui.HistoryEntry + for _, msg := range messages { + switch msg.Role { + case sdk.RoleUser: + var texts []string + for _, block := range msg.Content { + if t, ok := block.(sdk.TextContent); ok { + if s := strings.TrimSpace(t.Text); s != "" { + texts = append(texts, s) + } + } + } + if len(texts) > 0 { + entries = append(entries, tui.HistoryEntry{ + Role: "user", + Text: strings.Join(texts, "\n"), + }) + } + + case sdk.RoleAssistant: + entry := tui.HistoryEntry{Role: "assistant"} + if msg.Metadata != nil { + if msg.Metadata.Usage != nil { + entry.InputTokens = msg.Metadata.Usage.InputTokens + entry.OutputTokens = msg.Metadata.Usage.OutputTokens + } + entry.StopReason = msg.Metadata.StopReason + } + for _, block := range msg.Content { + switch b := block.(type) { + case sdk.ThinkingContent: + entry.Thinking = b.Thinking + case sdk.TextContent: + if entry.Text != "" { + entry.Text += "\n" + } + entry.Text += b.Text + case sdk.ToolUseContent: + tool := tui.HistoryTool{ + ID: b.ID, + Name: b.Name, + Input: b.Input, + } + if r, ok := resultFor[b.ID]; ok { + // Use the persisted metadata map directly; fall back to + // building a minimal one from the raw content string. + if r.metadata != nil { + tool.Metadata = r.metadata + } else if r.content != "" { + tool.Metadata = map[string]any{"content": r.content} + } + } + entry.Tools = append(entry.Tools, tool) + } + } + if entry.Text != "" || entry.Thinking != "" || len(entry.Tools) > 0 { + entries = append(entries, entry) + } + } + } + return entries +} + func (w *nexusWorkspace) DeleteSession(_ context.Context, id string) error { - return w.client.DeleteSession(sdk.SessionID(id)) + w.sessionMu.Lock() + active := w.session != nil && string(w.session.GetID()) == id + var sess *sdk.Session + if active { + sess = w.session + w.session = nil + } + w.sessionMu.Unlock() + if sess != nil { + _ = sess.Interrupt() + _ = sess.Close() + } + + w.clientMu.RLock() + err := w.client.DeleteSession(sdk.SessionID(id)) + w.clientMu.RUnlock() + if err != nil { + return err + } + // Remove the entire session directory: images, plans, tools, logs — one call. + appdir.DeleteSessionDir(id) + return nil } func (w *nexusWorkspace) Submit(ctx context.Context, prompt string) { @@ -200,11 +514,23 @@ func (w *nexusWorkspace) Submit(ctx context.Context, prompt string) { } w.busy.Store(true) + // Wrap with a per-submit cancel so Cancel() can interrupt the API call. + submitCtx, cancel := context.WithCancel(ctx) + w.submitMu.Lock() + w.submitCancel = cancel + w.submitMu.Unlock() + go func() { + defer func() { + w.submitMu.Lock() + w.submitCancel = nil + w.submitMu.Unlock() + cancel() + }() w.send(tui.TurnStartMsg{ SessionID: string(sess.GetID()), }) - resp, err := sess.SubmitMessage(ctx, prompt) + resp, err := sess.SubmitMessage(submitCtx, prompt) w.busy.Store(false) done := tui.TurnDoneMsg{ @@ -222,10 +548,30 @@ func (w *nexusWorkspace) Submit(ctx context.Context, prompt string) { }() } +func (w *nexusWorkspace) cancelAsyncAgents() int { + return coreagent.GetDefaultAsyncManager().CloseAllAgents() +} + func (w *nexusWorkspace) Cancel() { - // The SDK doesn't expose a per-session cancel yet; cancel via context - // when the workspace is closed or a parent context is cancelled. - // For now, mark as not busy so the UI unblocks. + w.submitMu.Lock() + cancel := w.submitCancel + w.submitMu.Unlock() + if cancel != nil { + cancel() + } + + w.sessionMu.Lock() + sess := w.session + w.sessionMu.Unlock() + if sess != nil { + _ = sess.Interrupt() + } + + if closed := w.cancelAsyncAgents(); closed > 0 { + log.Printf("[tui] cancelled %d async sub-agent(s)", closed) + } + + // Safety net: unblock the UI immediately even if TurnDoneMsg is delayed. w.busy.Store(false) } @@ -248,12 +594,128 @@ func (w *nexusWorkspace) PermissionMode() string { return w.permMode } func (w *nexusWorkspace) ListModels(ctx context.Context) { go func() { + cfg, _ := engineconfig.Load() + database, _ := openCredentialsDB(cfg) + if database != nil { + defer database.Close() + } + + // Resolve which provider the global (non-scoped) api_key belongs to, + // based on the persisted model selection. + globalKeyProvider := sdk.APIProvider("") + if database != nil { + if modelStr, ok, _ := database.GetCredential(ctx, credKeyModel); ok && modelStr != "" { + globalKeyProvider = engineconfig.ParseModelIdentifier(modelStr).Provider + } + } + + // isConfigured returns true when the provider has usable credentials + // (env var, scoped DB key, or global DB key attributed to this provider). + isConfigured := func(provider sdk.APIProvider) bool { + for _, ev := range engineconfig.ProviderCredentialEnvVars(provider) { + if strings.TrimSpace(os.Getenv(ev)) != "" { + return true + } + } + if database == nil { + return false + } + pid := strings.ToLower(string(provider)) + // Scoped key written by the TUI config panel. + if v, ok, _ := database.GetCredential(ctx, "api_key:"+pid); ok && v != "" { + return true + } + // Global key written by `nexus config`, attributed to its provider. + if provider == globalKeyProvider { + if v, ok, _ := database.GetCredential(ctx, credKeyAPIKey); ok && v != "" { + return true + } + } + // Cloud providers (Bedrock, Vertex) store region/project instead of a key. + if len(engineconfig.ProviderCredentialEnvVars(provider)) == 0 { + for _, ck := range []string{"provider_region:" + pid, "provider_project_id:" + pid} { + if v, ok, _ := database.GetCredential(ctx, ck); ok && v != "" { + return true + } + } + } + return false + } + all := providers.AllProvidersInfo() var models []tui.ProviderModel + for provider, info := range all { + providerStr := string(provider) + + if provider == sdk.APIProviderOllama { + // Quick live refresh — longer timeout since /api/show is called per model. + // Falls back to startup-cached list on timeout or error. + ollamaURL := ollamaBaseURLFromDB(ctx, database) + liveCtx, liveCancel := context.WithTimeout(ctx, 8*time.Second) + fetched, liveErr := providers.FetchModels(liveCtx, providerStr, ollamaURL, "") + liveCancel() + + if liveErr == nil && len(fetched) > 0 { + // Update the cache in background so future opens are fast. + go func(list []providers.FetchedModel) { + cached := make([]ollamaCachedModel, 0, len(list)) + for _, m := range list { + cached = append(cached, ollamaCachedModel{ID: m.ModelID, Context: m.ContextWindow}) + } + if data, err := json.Marshal(cached); err == nil { + if cfg2, err := engineconfig.Load(); err == nil { + if db2, err := openCredentialsDB(cfg2); err == nil { + _ = db2.UpsertCredential(context.Background(), credKeyOllamaModels, string(data)) + _ = db2.Close() + } + } + } + }(fetched) + for _, m := range fetched { + desc := m.DisplayName + if m.ContextWindow > 0 { + desc = fmt.Sprintf("%s · %dk ctx", m.DisplayName, m.ContextWindow/1000) + } + models = append(models, tui.ProviderModel{ + Provider: providerStr, + Identifier: m.ModelID, + DisplayName: m.DisplayName, + Description: desc, + Context: m.ContextWindow, + }) + } + } else if database != nil { + // Live fetch failed or timed out — serve the startup cache. + if raw, ok, _ := database.GetCredential(ctx, credKeyOllamaModels); ok && raw != "" { + var cached []ollamaCachedModel + if json.Unmarshal([]byte(raw), &cached) == nil { + for _, m := range cached { + desc := m.ID + if m.Context > 0 { + desc = fmt.Sprintf("%s · %dk ctx", m.ID, m.Context/1000) + } + models = append(models, tui.ProviderModel{ + Provider: providerStr, + Identifier: m.ID, + DisplayName: m.ID, + Description: desc, + Context: m.Context, + }) + } + } + } + } + continue + } + + if !isConfigured(provider) { + continue + } + for _, m := range info.Models { models = append(models, tui.ProviderModel{ - Provider: string(provider), + Provider: providerStr, Identifier: m.Identifier, DisplayName: info.DisplayName + " / " + m.Identifier, Description: m.Description, @@ -261,15 +723,33 @@ func (w *nexusWorkspace) ListModels(ctx context.Context) { }) } } + w.send(tui.ModelListMsg{Models: models}) }() } func (w *nexusWorkspace) SetModel(providerID, modelID string) { + modelStr := providerID + ":" + modelID w.mu.Lock() - w.model = providerID + ":" + modelID + w.model = modelStr w.mu.Unlock() - w.send(tui.ModelChangedMsg{Provider: providerID, Model: modelID}) + // Persist asynchronously — DB I/O must not block the BubbleTea event loop. + // Do NOT call w.send here: p.Send is a blocking channel write in BubbleTea v2, + // so calling it from within Update (the event loop goroutine) causes a deadlock. + // The header reads ModelString() directly, so no message is needed. + go func() { + if cfg, err := engineconfig.Load(); err == nil { + if db, err := openCredentialsDB(cfg); err == nil { + _ = db.UpsertCredential(context.Background(), credKeyModel, modelStr) + _ = db.Close() + } + } + }() + go func() { + if err := w.reloadClient(context.Background(), modelStr); err != nil { + w.send(tui.ErrMsg{Err: err}) + } + }() } // ─── Provider configuration ──────────────────────────────────────────────────── @@ -340,7 +820,27 @@ func (w *nexusWorkspace) SaveProviderField(ctx context.Context, providerID, fiel return err } defer database.Close() - return database.UpsertCredential(ctx, providerCredKey(fieldKey, providerID), value) + if err := database.UpsertCredential(ctx, providerCredKey(fieldKey, providerID), value); err != nil { + return err + } + // Re-probe Ollama whenever its endpoint is saved so the model cache stays current. + if strings.ToLower(providerID) == "ollama" && fieldKey == "provider_base_url" { + w.probeOllamaInBackground() + } + + // Always reload when saving a credential. If no model is selected yet (w.model == ""), + // reloadClient will still build a keyed client using the default provider + // (anthropic) or whatever provider was just configured, ensuring that the + // first session creation has valid credentials. + currentModel := strings.TrimSpace(w.model) + if currentModel == "" { + return w.reloadClient(ctx, "") + } + + if parts := strings.SplitN(currentModel, ":", 2); strings.EqualFold(parts[0], providerID) { + return w.reloadClient(ctx, "") + } + return nil } func (w *nexusWorkspace) DeleteProviderField(ctx context.Context, providerID, fieldKey string) error { @@ -350,11 +850,102 @@ func (w *nexusWorkspace) DeleteProviderField(ctx context.Context, providerID, fi return err } defer database.Close() - return database.DeleteCredential(ctx, providerCredKey(fieldKey, providerID)) + if err := database.DeleteCredential(ctx, providerCredKey(fieldKey, providerID)); err != nil { + return err + } + // Re-probe with default URL when the Ollama endpoint is cleared. + if strings.ToLower(providerID) == "ollama" && fieldKey == "provider_base_url" { + w.probeOllamaInBackground() + } + return w.reloadClient(ctx, "") +} + +// searchProviderCatalog is the static metadata for each search provider. +const credKeySearXNG = "SEARXNG_BASE_URL" + +var searchProviderCatalog = []tui.SearchKeyStatus{ + {ID: "tavily", DisplayName: "Tavily", Description: "AI-optimised search", EnvVar: "TAVILY_API_KEY", DBKey: "TAVILY_API_KEY", NeedsKey: true}, + {ID: "exa", DisplayName: "Exa", Description: "Neural search engine", EnvVar: "EXA_API_KEY", DBKey: "EXA_API_KEY", NeedsKey: true}, + {ID: "jina", DisplayName: "Jina AI", Description: "Reader-based web retrieval", EnvVar: "JINA_API_KEY", DBKey: "JINA_API_KEY", NeedsKey: true}, + {ID: "langsearch", DisplayName: "LangSearch", Description: "Free AI-optimised search", EnvVar: "LANGSEARCH_API_KEY", DBKey: "LANGSEARCH_API_KEY", NeedsKey: true}, + {ID: "searxng", DisplayName: "SearXNG", Description: "Self-hosted meta-search (needs instance URL)", EnvVar: "SEARXNG_BASE_URL", DBKey: credKeySearXNG, NeedsKey: true, FieldLabel: "Instance URL"}, + {ID: "ddg", DisplayName: "DuckDuckGo", Description: "Privacy-friendly fallback", NeedsKey: false}, +} + +func (w *nexusWorkspace) LoadSearchConfig(_ context.Context) tui.SearchConfig { + cfg, _ := engineconfig.Load() + database, dbErr := openCredentialsDB(cfg) + if dbErr == nil { + defer database.Close() + } + + mode, _, _ := func() (string, bool, error) { + if database == nil { + return "", false, nil + } + return database.GetCredential(context.Background(), "WEB_SEARCH_PROVIDER") + }() + if mode == "" { + mode = os.Getenv("WEB_SEARCH_PROVIDER") + } + if mode == "" { + mode = "auto" + } + + providers := make([]tui.SearchKeyStatus, len(searchProviderCatalog)) + copy(providers, searchProviderCatalog) + for i, p := range providers { + if !p.NeedsKey { + continue + } + if database != nil { + if _, ok, _ := database.GetCredential(context.Background(), p.DBKey); ok { + providers[i].IsSet = true + continue + } + } + // Fallback: check env var (e.g. set by ApplySearchKeys). + if os.Getenv(p.EnvVar) != "" { + providers[i].IsSet = true + } + } + return tui.SearchConfig{Mode: mode, Providers: providers} +} + +func (w *nexusWorkspace) SaveSearchKey(ctx context.Context, dbKey, value string) error { + cfg, _ := engineconfig.Load() + database, err := openCredentialsDB(cfg) + if err != nil { + return err + } + defer database.Close() + if err := database.UpsertCredential(ctx, dbKey, value); err != nil { + return err + } + // Apply immediately so the current process uses the new key. + os.Setenv(strings.TrimPrefix(dbKey, "search:"), value) + return w.reloadClient(ctx, "") +} + +func (w *nexusWorkspace) SaveSearchMode(ctx context.Context, mode string) error { + cfg, _ := engineconfig.Load() + database, err := openCredentialsDB(cfg) + if err != nil { + return err + } + defer database.Close() + if err := database.UpsertCredential(ctx, "WEB_SEARCH_PROVIDER", mode); err != nil { + return err + } + os.Setenv("WEB_SEARCH_PROVIDER", mode) + return w.reloadClient(ctx, "") } func (w *nexusWorkspace) LoadToolCatalog(ctx context.Context) []tui.ToolInfo { - surface, err := w.client.BuildToolSurface(ctx) + w.clientMu.RLock() + client := w.client + surface, err := client.BuildToolSurface(ctx) + w.clientMu.RUnlock() if err != nil || surface == nil { return nil } @@ -373,7 +964,9 @@ func (w *nexusWorkspace) LoadToolCatalog(ctx context.Context) []tui.ToolInfo { } func (w *nexusWorkspace) LoadMCPServers(_ context.Context) []tui.MCPServerInfo { + w.clientMu.RLock() result := w.client.MCPResult() + w.clientMu.RUnlock() if result == nil { return nil } @@ -426,7 +1019,15 @@ func (w *nexusWorkspace) LoadSkills(_ context.Context) []tui.SkillInfo { } func (w *nexusWorkspace) Close() { - w.client.Close() + if closed := w.cancelAsyncAgents(); closed > 0 { + log.Printf("[tui] closed %d async sub-agent(s) during shutdown", closed) + } + w.clientMu.RLock() + client := w.client + w.clientMu.RUnlock() + if client != nil { + _ = client.Close() + } } // ─── SDK callback bridges ────────────────────────────────────────────────────── @@ -460,6 +1061,150 @@ func (w *nexusWorkspace) onProgress(progress sdk.ToolProgress) { Label: label, Metadata: progress.Metadata, }) + // Record file operations in session_files as they complete. + if string(progress.Stage) == "completed" { + switch progress.ToolName { + case "write_file", "edit_file", "apply_patch": + w.recordSessionFile(progress) + } + } +} + +// recordSessionFile persists a completed file-write operation to session_files. +// Runs asynchronously so it never blocks the TUI event loop. +func (w *nexusWorkspace) recordSessionFile(progress sdk.ToolProgress) { + w.sessionMu.Lock() + sess := w.session + w.sessionMu.Unlock() + if sess == nil { + return + } + sessionID := string(sess.GetID()) + meta := progress.Metadata + + filePath, _ := meta["file_path"].(string) + if filePath == "" { + return + } + op := fileOperation(progress.ToolName, meta) + linesAdded, _ := intFromAny(meta["lines_added"]) + linesRemoved, _ := intFromAny(meta["lines_removed"]) + + go func() { + cfg, err := engineconfig.Load() + if err != nil { + return + } + database, err := openCredentialsDB(cfg) + if err != nil { + return + } + defer database.Close() + _ = database.UpsertSessionFile(context.Background(), db.SessionFile{ + SessionID: sessionID, + ToolUseID: progress.ToolUseID, + FilePath: filePath, + Operation: op, + LinesAdded: linesAdded, + LinesRemoved: linesRemoved, + }) + }() +} + +// backfillSessionFiles scans a transcript and populates session_files for any +// write_file, edit_file, or apply_patch tool results found there. +func backfillSessionFiles(ctx context.Context, database *db.DB, sessionID string, messages []sdk.Message) { + // Build a map: tool_use_id → ToolResultContent.Metadata for file ops. + type resultMeta struct { + metadata map[string]any + ts int64 + } + resultMap := make(map[string]resultMeta) + for _, msg := range messages { + ts := msg.Timestamp.Unix() + if ts <= 0 { + ts = time.Now().Unix() + } + for _, block := range msg.Content { + if tr, ok := block.(sdk.ToolResultContent); ok && tr.Metadata != nil { + resultMap[tr.ToolUseID] = resultMeta{metadata: *tr.Metadata, ts: ts} + } + } + } + + for _, msg := range messages { + if msg.Role != sdk.RoleAssistant { + continue + } + for _, block := range msg.Content { + tu, ok := block.(sdk.ToolUseContent) + if !ok { + continue + } + switch tu.Name { + case "write_file", "edit_file", "apply_patch": + default: + continue + } + r, hasResult := resultMap[tu.ID] + var meta map[string]any + if hasResult { + meta = r.metadata + } else { + meta = map[string]any{} + } + filePath, _ := meta["file_path"].(string) + if filePath == "" { + filePath, _ = tu.Input["file_path"].(string) + } + if filePath == "" { + continue + } + op := fileOperation(tu.Name, meta) + linesAdded, _ := intFromAny(meta["lines_added"]) + linesRemoved, _ := intFromAny(meta["lines_removed"]) + ts := r.ts + if ts == 0 { + ts = time.Now().Unix() + } + _ = database.UpsertSessionFile(ctx, db.SessionFile{ + SessionID: sessionID, + ToolUseID: tu.ID, + FilePath: filePath, + Operation: op, + TimestampUnix: ts, + LinesAdded: linesAdded, + LinesRemoved: linesRemoved, + }) + } + } +} + +func fileOperation(toolName string, meta map[string]any) string { + switch toolName { + case "write_file": + if t, _ := meta["type"].(string); t != "" { + return t // "create" or "update" + } + return "write" + case "edit_file": + return "edit" + case "apply_patch": + return "patch" + } + return toolName +} + +func intFromAny(v any) (int, bool) { + switch n := v.(type) { + case int: + return n, true + case int64: + return int(n), true + case float64: + return int(n), true + } + return 0, false } // promptFn blocks the calling (agent) goroutine until the TUI resolves it. @@ -501,7 +1246,11 @@ func runInteractive(ctx context.Context, options runtimeOptions) error { // Redirect all log output to a file before entering alt-screen (crush pattern). // Without this, monitoring logs and stdlib log output bleed into the TUI. options.Monitoring = buildTUIMonitoring() - log.SetOutput(io.Discard) + if lf := openCLILogFile(); lf != nil { + log.SetOutput(lf) + } else { + log.SetOutput(io.Discard) + } ws, err := newNexusWorkspace(options) if err != nil { @@ -537,6 +1286,24 @@ func nexusLogDir() string { return filepath.Join(home, ".nexus") } +// openCLILogFile opens (or creates) ~/.config/nexus-cli/logs/cli.log for appending. +// Returns nil if the file cannot be created — caller falls back to io.Discard. +func openCLILogFile() *os.File { + config, err := engineconfig.Load() + if err != nil { + return nil + } + logDir := filepath.Join(config.RuntimeRoot, "logs") + if err := os.MkdirAll(logDir, 0o755); err != nil { + return nil + } + f, err := os.OpenFile(filepath.Join(logDir, "cli.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o640) + if err != nil { + return nil + } + return f +} + // ─── Helpers ───────────────────────────────────────────────────────────────── func shortIDStr(id string) string { diff --git a/docs/captures/working1.png b/docs/captures/working1.png index 8b538e8..df519f6 100644 Binary files a/docs/captures/working1.png and b/docs/captures/working1.png differ diff --git a/docs/database-schema.md b/docs/database-schema.md new file mode 100644 index 0000000..f089058 --- /dev/null +++ b/docs/database-schema.md @@ -0,0 +1,815 @@ +# Nexus CLI — Database Schema & Configuration Reference + +> Scope: CLI only (`~/.config/nexus-cli/`). The backend server uses a separate +> schema managed in the private product repository. + +--- + +## 1. Runtime Root & File Layout + +The CLI sets `NEXUS_RUNTIME_ROOT=~/.config/nexus-cli` before calling +`pkg/config.Load()`. Everything is derived from that root. + +``` +~/.config/nexus-cli/ ← NEXUS_RUNTIME_ROOT (CLI) +├── config.yaml ← optional YAML config file +├── data/ +│ └── nexus.db ← single SQLite file (all persistence) +│ └── nexus.db-shm ← WAL shared-memory file (auto-managed) +│ └── nexus.db-wal ← WAL journal (auto-managed) +├── skills/ ← cloned skill repositories +├── cache/ +├── logs/ +├── storage/ ← local file-storage backend +└── tmp/ + ├── tasks/ + └── bash-tasks/ + +~/.config/nexus/ ← DEFAULT runtime root (server / general) +└── secret.key ← 32-byte AES-256 encryption key (0600) + shared between CLI and server on the + same machine — intentional design +``` + +**Path helpers** (`pkg/runtimepath`): + +| Function | Returns | +|---|---| +| `ResolveRoot(explicit)` | Resolves root: explicit → `NEXUS_RUNTIME_ROOT` → `~/.config/nexus` → `$TMP/nexus` | +| `BackendDBPath(root)` | `/data/nexus.db` | +| `DataDir(root)` | `/data` | +| `SkillsDir(root)` | `/skills` | +| `PlansDir(root)` | `/plans` | +| `TasksDir(root)` | `/tmp/tasks` | +| `StorageDir(root)` | `/storage` | + +--- + +## 2. Database Connection Configuration + +**Driver**: SQLite only for the CLI. Multi-driver support exists in the codebase +(`postgres`, `mysql`) but `cmd/cli` always calls `db.DefaultSQLiteConfig(path)`. + +**Open sequence** (`internal/db/db.go`): + +``` +db.Open(ctx, db.DefaultSQLiteConfig(path)) + 1. gorm.Open(glebarez/sqlite, DSN) ← pure-Go SQLite driver, no CGO + 2. db.configure(ctx, cfg) ← apply pragmas + pool settings + 3. sqlDB.PingContext(ctx) + 4. db.Initialize(ctx) ← run sqliteCoreMigrations() if AutoMigrate=true +``` + +**SQLite pragmas** (applied on every connection open): + +```sql +PRAGMA foreign_keys = ON -- FK constraints enforced +PRAGMA journal_mode = WAL -- concurrent reads during writes +PRAGMA synchronous = NORMAL -- fsync after WAL checkpoint, not every write +PRAGMA cache_size = -20000 -- 20 MB page cache (default ~2 MB) +PRAGMA mmap_size = 134217728 -- 128 MB memory-mapped I/O +PRAGMA temp_store = MEMORY -- temp tables in RAM, never disk +PRAGMA wal_autocheckpoint = 1000 -- checkpoint every 1 000 WAL pages +PRAGMA busy_timeout = 5000 -- wait 5 s on lock before SQLITE_BUSY +``` + +**`PRAGMA optimize`** is called in `db.Close()` to update query planner statistics. + +**Connection pool** (SQLite limitation): + +```go +sqlDB.SetMaxOpenConns(1) // SQLite is single-writer +sqlDB.SetMaxIdleConns(1) +``` + +--- + +## 3. Complete Schema — Core SQLite Tables + +All tables below are created by versioned migrations in +`internal/db/migrations_sqlite_core.go` and tracked in `nexus_schema_migrations`. + +--- + +### 3.1 `nexus_schema_migrations` — Migration Tracking + +```sql +CREATE TABLE nexus_schema_migrations ( + id TEXT NOT NULL, -- migration ID string + scope TEXT NOT NULL, -- "core_sqlite" for CLI tables + applied_at_unix INTEGER NOT NULL, + PRIMARY KEY (id, scope) +); +``` + +--- + +### 3.2 `session_metadata` — Session Registry + +One row per conversation session. + +```sql +CREATE TABLE session_metadata ( + session_id TEXT PRIMARY KEY, -- UUID string + status TEXT NOT NULL, -- "active" | "archived" | ... + created_at_unix INTEGER NOT NULL, + updated_at_unix INTEGER NOT NULL, + metadata_json TEXT NOT NULL -- JSON blob of SessionMetadata struct +); + +CREATE INDEX idx_session_metadata_updated_at + ON session_metadata(updated_at_unix DESC); +``` + +**Query served**: `ListSessions()` — ordered by most recently updated. + +--- + +### 3.3 `session_transcript_entries` — Message History + +One row per SDK message in a session. + +```sql +CREATE TABLE session_transcript_entries ( + session_id TEXT NOT NULL, + entry_index INTEGER NOT NULL, + entry_json TEXT NOT NULL, -- JSON blob of TranscriptEntry + PRIMARY KEY (session_id, entry_index), + FOREIGN KEY (session_id) REFERENCES session_metadata(session_id) + ON DELETE CASCADE +); +``` + +**`entry_json` structure** (relevant TUI fields): +```json +{ + "role": "assistant", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01...", + "content": [...], + "metadata": { + "content": "raw file content", + "type": "read_file | write_file | edit_file ...", + "file_path": "/abs/path/to/file", + "execution_duration_ms": 120, + "lines_added": 5, + "lines_removed": 2, + "structured_patch": "unified diff string", + "git_diff": "git diff output", + "original_file": "content before edit" + } + } + ] +} +``` + +**Search**: via `session_transcript_fts` FTS5 table (see §3.8). + +--- + +### 3.4 `session_checkpoints` — Conversation State Snapshot + +One row per session, overwritten on each checkpoint save. + +```sql +CREATE TABLE session_checkpoints ( + session_id TEXT PRIMARY KEY, + updated_at_unix INTEGER NOT NULL, + checkpoint_json TEXT NOT NULL, -- JSON blob of Checkpoint struct + FOREIGN KEY (session_id) REFERENCES session_metadata(session_id) + ON DELETE CASCADE +); +``` + +--- + +### 3.5 `session_files` — File Operations per Session + +One row per file operation (write_file, edit_file, apply_patch). +`tool_use_id` links back to `session_transcript_entries.entry_json` +to retrieve the full diff/metadata without scanning the transcript. + +```sql +CREATE TABLE session_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + tool_use_id TEXT NOT NULL DEFAULT '', -- pointer into transcript JSON + file_path TEXT NOT NULL, + operation TEXT NOT NULL, -- "create" | "update" | "edit" | "patch" + timestamp_unix INTEGER NOT NULL, + lines_added INTEGER NOT NULL DEFAULT 0, + lines_removed INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (session_id) REFERENCES session_metadata(session_id) + ON DELETE CASCADE +); + +-- Primary access pattern: all file ops for a session, chronologically +CREATE INDEX idx_session_files_session + ON session_files(session_id, timestamp_unix); + +-- Reverse lookup: which sessions touched a file +CREATE INDEX idx_session_files_path + ON session_files(file_path); + +-- Direct lookup of transcript metadata for a specific tool call +CREATE INDEX idx_session_files_tool_use + ON session_files(tool_use_id); + +-- Prevents duplicate rows when live-recording and backfill goroutines race +CREATE UNIQUE INDEX idx_session_files_tool_use_unique + ON session_files(tool_use_id) WHERE tool_use_id != ''; +``` + +**Write paths** (both in `cmd/cli/tui.go` only, never in internal packages): + +- `recordSessionFile()` — called async when a tool completes during a live turn +- `backfillSessionFiles()` — called once on session load if no rows exist yet (idempotent) + +--- + +### 3.6 `vector_records` — Embedding Store + +Key-value store for RAG embeddings. Namespace groups records by domain. + +```sql +CREATE TABLE vector_records ( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + text TEXT NOT NULL DEFAULT '', + vector BLOB NOT NULL, -- raw float32 or float64 bytes + metadata TEXT NOT NULL DEFAULT '{}', -- JSON extra fields + PRIMARY KEY (namespace, key) +); + +CREATE INDEX idx_vector_records_namespace + ON vector_records(namespace); +``` + +--- + +### 3.7 `vector_records_fts` — FTS5 Hybrid Search + +BM25 full-text index over the `text` column of `vector_records`. +Used alongside cosine-similarity vector search for hybrid retrieval. + +```sql +CREATE VIRTUAL TABLE vector_records_fts USING fts5( + namespace UNINDEXED, + key UNINDEXED, + text, + tokenize = 'unicode61 remove_diacritics 1' +); +``` + +--- + +### 3.8 `session_transcript_fts` — Transcript Full-Text Search + +FTS5 index over `session_transcript_entries.entry_json`. +Replaces the previous O(n) `LIKE` scan with O(log n) MATCH queries. + +```sql +CREATE VIRTUAL TABLE session_transcript_fts USING fts5( + session_id UNINDEXED, + entry_json, + tokenize = 'unicode61 remove_diacritics 1' +); +``` + +**Synchronization triggers** (fire automatically, including for CASCADE deletes): + +```sql +-- Sync on insert +CREATE TRIGGER trg_transcript_fts_insert +AFTER INSERT ON session_transcript_entries BEGIN + INSERT OR REPLACE INTO session_transcript_fts(rowid, session_id, entry_json) + VALUES (new.rowid, new.session_id, new.entry_json); +END; + +-- Sync on delete (fires for CASCADE removes triggered by deleting session_metadata) +CREATE TRIGGER trg_transcript_fts_delete +AFTER DELETE ON session_transcript_entries BEGIN + INSERT INTO session_transcript_fts(session_transcript_fts, rowid) + VALUES ('delete', old.rowid); +END; +``` + +--- + +### 3.9 `credentials` — Encrypted Key-Value Store + +All secrets (API keys, provider credentials, search keys) are stored here, +never in the YAML config file. + +```sql +CREATE TABLE credentials ( + key TEXT PRIMARY KEY, -- max 191 chars + cipher_text TEXT NOT NULL, -- base64(AES-256-GCM nonce || ciphertext) + created_at_unix INTEGER NOT NULL, + updated_at_unix INTEGER NOT NULL +); +``` + +**Encryption**: AES-256-GCM. Key file: `~/.config/nexus/secret.key` (32 bytes, mode 0600). + +**Credential keys used by the CLI**: + +| DB key | Purpose | +|---|---| +| `api_key` | Provider API key (scoped: `api_key:`) | +| `provider_base_url` | Custom base URL for Ollama / Foundry | +| `provider_region` | AWS region (Bedrock) or GCP region (Vertex) | +| `provider_project_id` | GCP project ID (Vertex) | +| `provider_resource` | Azure Foundry resource ID | +| `model` | Persisted model selection (e.g. `anthropic:claude-opus-4-8`) | +| `TAVILY_API_KEY` | Tavily web search | +| `EXA_API_KEY` | Exa web search | +| `JINA_API_KEY` | Jina web search | +| `web_search_provider` | Active search provider mode (`auto`, `tavily`, …) | + +--- + +### 3.10 `agent_profiles` — Multi-Agent Profiles + +```sql +CREATE TABLE agent_profiles ( + id TEXT PRIMARY KEY, -- UUID (size 36) + nickname TEXT NOT NULL, + role TEXT NOT NULL, + team_id TEXT, + system_prompt_template TEXT NOT NULL, -- {{.Nickname}} resolved at runtime + model TEXT, -- optional model override + skills_json TEXT NOT NULL DEFAULT '[]', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at_unix INTEGER NOT NULL, + updated_at_unix INTEGER NOT NULL +); + +CREATE INDEX idx_agent_profiles_role ON agent_profiles(role); +CREATE INDEX idx_agent_profiles_team_id ON agent_profiles(team_id); +``` + +--- + +### 3.11 `mailbox_messages` — Inter-Agent Messaging + +```sql +CREATE TABLE mailbox_messages ( + id TEXT PRIMARY KEY, -- UUID + kind TEXT NOT NULL, -- message type + from_agent TEXT NOT NULL, + to_agent TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + reply_to TEXT, -- parent message ID for threading + team_id TEXT, + read_at INTEGER, -- NULL = unread + created_at INTEGER NOT NULL +); + +-- Single-column indexes (GORM AutoMigrate) +CREATE INDEX idx_mailbox_messages_kind ON mailbox_messages(kind); +CREATE INDEX idx_mailbox_messages_from_agent ON mailbox_messages(from_agent); +CREATE INDEX idx_mailbox_messages_to_agent ON mailbox_messages(to_agent); +CREATE INDEX idx_mailbox_messages_reply_to ON mailbox_messages(reply_to); +CREATE INDEX idx_mailbox_messages_team_id ON mailbox_messages(team_id); +CREATE INDEX idx_mailbox_messages_created_at ON mailbox_messages(created_at); + +-- Composite indexes (migration 008) +-- GetUnreadMessages: to_agent + unread filter + ASC order +CREATE INDEX idx_mailbox_to_agent_unread + ON mailbox_messages(to_agent, created_at ASC) WHERE read_at IS NULL; + +-- GetMessageHistory: to_agent + newest-first order +CREATE INDEX idx_mailbox_to_agent_history + ON mailbox_messages(to_agent, created_at DESC); +``` + +--- + +### 3.12 `schema_migrations` — Legacy Tracking (backward compat) + +```sql +CREATE TABLE schema_migrations ( + id TEXT PRIMARY KEY, + applied_at_unix INTEGER NOT NULL DEFAULT (unixepoch()) +); +``` + +Kept for databases created before `nexus_schema_migrations` was introduced. +Not written by any current migration. + +--- + +## 4. Entity Relationship Diagram + +``` +┌──────────────────────────────────────────────────────────┐ +│ session_metadata │ +│ PK: session_id TEXT │ +│ status, created_at_unix, updated_at_unix │ +│ metadata_json │ +│ IDX: updated_at_unix DESC │ +└──────┬───────────────────┬──────────────────┬────────────┘ + │ CASCADE │ CASCADE │ CASCADE + ▼ ▼ ▼ +┌──────────────────┐ ┌────────────────┐ ┌────────────────────────────────┐ +│session_transcript│ │session_ │ │session_files │ +│_entries │ │checkpoints │ │ PK: id AUTOINCREMENT │ +│ PK: (session_id,│ │ PK: session_id│ │ FK: session_id → session_meta │ +│ entry_index)│ │ FK: session_id│ │ tool_use_id ─────────────┐ │ +│ FK: session_id │ │ checkpoint_ │ │ file_path, operation │ │ +│ entry_json TEXT │ │ json TEXT │ │ timestamp_unix │ │ +│ │ └────────────────┘ │ lines_added, lines_removed│ │ +│ rowid ──────────┼──────────────────────────────────────────────────┐ │ +└──────────────────┘ └────────────────────────────────┘ │ + │ │ + │ triggers (INSERT/DELETE) │ + ▼ │ +┌──────────────────────────────────────────────┐ │ +│ session_transcript_fts (FTS5 virtual) │ │ +│ session_id UNINDEXED │ │ +│ entry_json (BM25 indexed) │ │ +│ tokenize: unicode61, remove_diacritics=1 │ │ +└──────────────────────────────────────────────┘ │ + │ + ┌───────────────────────────────────────────────────────────────────┘ + │ logical pointer (no SQL FK) + │ tool_use_id → session_transcript_entries.entry_json + │ → ToolResultContent.Metadata + │ → { content, structured_patch, git_diff, … } + ▼ + session_transcript_entries (look up by rowid via idx_session_files_tool_use) + + +┌──────────────────────────────────────────────┐ +│ vector_records │ +│ PK: (namespace, key) │ +│ text, vector BLOB, metadata │ +│ IDX: namespace │ +└──────────┬───────────────────────────────────┘ + │ INSERT OR IGNORE backfill on migration + ▼ +┌──────────────────────────────────────────────┐ +│ vector_records_fts (FTS5 virtual) │ +│ namespace UNINDEXED, key UNINDEXED, text │ +│ tokenize: unicode61, remove_diacritics=1 │ +└──────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────┐ +│ credentials │ +│ PK: key TEXT │ +│ cipher_text (AES-256-GCM base64) │ +│ created_at_unix, updated_at_unix │ +└──────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────┐ +│ agent_profiles │ +│ PK: id UUID │ +│ IDX: role, team_id │ +└──────────────────────────────────────────────┘ + (no FK to sessions — profiles are global) + +┌──────────────────────────────────────────────┐ +│ mailbox_messages │ +│ PK: id UUID │ +│ reply_to → id (self-referencing, logical) │ +│ IDX: kind, from_agent, to_agent, reply_to, │ +│ team_id, created_at │ +│ IDX: (to_agent, created_at) WHERE unread │ +│ IDX: (to_agent, created_at DESC) │ +└──────────────────────────────────────────────┘ +``` + +--- + +## 5. Index Coverage Map + +| Table | Query | Index used | Complexity | +|---|---|---|---| +| `session_metadata` | List sessions by recency | `idx_session_metadata_updated_at` | O(log n) | +| `session_transcript_entries` | Load full transcript | PK `(session_id, entry_index)` | O(log n) | +| `session_transcript_entries` | Count rows | PK scan | O(log n) | +| `session_transcript_fts` | Search text content | FTS5 MATCH | O(log n) | +| `session_checkpoints` | Load checkpoint | PK `session_id` | O(1) | +| `session_files` | Files for a session | `idx_session_files_session` | O(log n) | +| `session_files` | Sessions for a file | `idx_session_files_path` | O(log n) | +| `session_files` | Metadata by tool_use_id | `idx_session_files_tool_use` | O(log n) | +| `vector_records` | All records in namespace | `idx_vector_records_namespace` | O(log n) | +| `vector_records_fts` | BM25 text search | FTS5 MATCH | O(log n) | +| `credentials` | Single key lookup | PK `key` | O(1) | +| `credentials` | List all keys | PK full scan | O(n) | +| `agent_profiles` | By role | `idx_agent_profiles_role` | O(log n) | +| `agent_profiles` | By team | `idx_agent_profiles_team_id` | O(log n) | +| `mailbox_messages` | Unread for agent | `idx_mailbox_to_agent_unread` (partial) | O(log n) | +| `mailbox_messages` | History for agent | `idx_mailbox_to_agent_history` | O(log n) | +| `mailbox_messages` | Thread by reply_to | `idx_mailbox_messages_reply_to` | O(log n) | +| `mailbox_messages` | Team broadcast | `idx_mailbox_messages_team_id` | O(log n) | + +--- + +## 6. Migration System + +**Tracking table**: `nexus_schema_migrations` (PK: `id + scope`) + +**Scope**: `core_sqlite` — all CLI/engine-owned migrations + +**Applied via**: `db.Initialize(ctx)` → `runSQLiteCoreMigrations()` → `applyMigrations()` + +Each migration runs in its own transaction. If it fails, the transaction is +rolled back and startup aborts. Migrations are idempotent (all DDL uses +`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`). + +**Migration history**: + +| ID | Description | +|---|---| +| `20260514_001_runtime_session_tables` | `session_metadata`, `session_transcript_entries`, `session_checkpoints` | +| `20260514_002_runtime_vector_records` | `vector_records` | +| `20260531_003_vector_records_fts5` | `vector_records_fts` FTS5 virtual table | +| `20260603_004_credentials_table` | `credentials` | +| `20260604_005_agent_profiles` | `agent_profiles` | +| `20260604_006_mailbox_messages` | `mailbox_messages` | +| `20260607_007_session_files` | `session_files` + 3 indexes | +| `20260607_008_indexes_and_constraints` | Unique constraint on `session_files.tool_use_id`; composite indexes on `mailbox_messages` | +| `20260607_009_transcript_fts5` | `session_transcript_fts` FTS5 + INSERT/DELETE triggers | + +--- + +## 7. Configuration Chain + +``` +cmd/cli main() + └─ os.Setenv("NEXUS_RUNTIME_ROOT", "~/.config/nexus-cli") + └─ pkg/config.Load() + └─ viper reads ~/.config/nexus-cli/config.yaml (if present) + └─ viper reads env vars (prefix: NEXUS_) + └─ config.RuntimeRoot = runtimepath.ResolveRoot("") + → "~/.config/nexus-cli" + └─ ExpandShellValues(config) -- resolves $(...) in yaml values + └─ ApplyRuntimeEnv(config) -- sets env vars for sub-packages + + └─ openCredentialsDB(config) + └─ engineconfig.EffectiveSessionDBPath(config) + → config.SessionDBPath if set (NEXUS_SESSION_DB_PATH) + → config.DBPath if set (NEXUS_DB_PATH) + → runtimepath.BackendDBPath(config.RuntimeRoot) + → ~/.config/nexus-cli/data/nexus.db + └─ db.Open(ctx, db.DefaultSQLiteConfig(path)) + → applies all pragmas + → runs sqliteCoreMigrations() + → returns *db.DB handle + + └─ loadCredsIntoConfig(config, database) + -- pulls api_key, model, search keys from credentials table + -- decrypts with AES-256-GCM key from ~/.config/nexus/secret.key + + └─ ApplySearchKeys(config) + -- sets TAVILY_API_KEY, EXA_API_KEY, JINA_API_KEY env vars +``` + +--- + +## 8. Config File (`~/.config/nexus-cli/config.yaml`) + +Secrets are **never stored** in this file (`stripRuntimeSecrets()` removes them +before saving). Use the TUI settings screen or `NEXUS_*` env vars instead. + +```yaml +# Model — override the persisted selection from credentials table +model: "anthropic:claude-opus-4-8" + +# Working directory for file tools +cwd: "." + +# Generation parameters +max_tokens: 4096 +temperature: 0.7 + +# Feature flags +mcp_enabled: true +skills_enabled: true +debug: false + +# Override database paths (rarely needed) +db_path: "" +session_db_path: "" + +# Web search provider (auto | tavily | exa | jina | langsearch | ddg) +web_search_provider: "auto" + +# Hooks: shell commands that fire on lifecycle events +hooks: + pre_tool_use: + - command: "echo tool called" + matcher: "bash" # optional regex match on tool name + timeout: 10 # seconds +``` + +**All config fields with their env vars**: + +| Field | Env var | Default | Notes | +|---|---|---|---| +| `runtime_root` | `NEXUS_RUNTIME_ROOT` | `~/.config/nexus` | CLI overrides to `~/.config/nexus-cli` | +| `cwd` | `NEXUS_CWD` | `.` | Working directory for file tools | +| `model` | `NEXUS_MODEL` | `""` | Format: `provider:model` or bare model name | +| `max_tokens` | — | `4096` | Max output tokens per turn | +| `temperature` | — | `0.7` | Sampling temperature | +| `api_key` | `NEXUS_API_KEY` | `""` | Loaded from credentials DB at runtime | +| `db_path` | `NEXUS_DB_PATH` | `""` | Overrides default SQLite path | +| `session_db_path` | `NEXUS_SESSION_DB_PATH` | `""` | Separate session DB (optional) | +| `provider_base_url` | `NEXUS_PROVIDER_BASE_URL` | `""` | Ollama/Foundry custom endpoint | +| `provider_region` | `NEXUS_PROVIDER_REGION` | `""` | AWS/GCP region | +| `provider_project_id` | `NEXUS_PROVIDER_PROJECT_ID` | `""` | GCP project (Vertex) | +| `provider_resource` | `NEXUS_PROVIDER_RESOURCE` | `""` | Azure Foundry resource ID | +| `mcp_enabled` | — | `true` | Enable MCP server integrations | +| `skills_enabled` | — | `true` | Enable slash-command skills | +| `debug` | `NEXUS_DEBUG` | `false` | Verbose logging | +| `web_search_provider` | `WEB_SEARCH_PROVIDER` | `"auto"` | Active search provider | +| `skill_repos` | `NEXUS_SKILL_REPOS` | `""` | Comma-separated git URLs to clone | +| `default_skill_repo` | `NEXUS_DEFAULT_SKILL_REPO` | canonical nexus-skills URL | Set to `"none"` to disable | +| `hooks` | — | `{}` | Lifecycle hook commands | + +--- + +## 9. Provider & Model Configuration + +**Provider catalog** (`pkg/config/provider_catalog.go`): + +| Provider ID | Display name | Auth type | Key env var | +|---|---|---|---| +| `anthropic` | Anthropic | API key | `ANTHROPIC_API_KEY` | +| `openai` | OpenAI | API key | `OPENAI_API_KEY` | +| `codex` | Codex | API key | `CODEX_API_KEY` | +| `gemini` | Google Gemini | API key | `GOOGLE_API_KEY` | +| `z-ai` | Z.AI (ZhipuAI) | API key | `ZHIPUAI_API_KEY` | +| `openrouter` | OpenRouter | API key | `OPENROUTER_API_KEY` | +| `deepseek` | DeepSeek | API key | `DEEPSEEK_API_KEY` | +| `opencode` | OpenCode | API key | `OPENCODE_API_KEY` | +| `mistral` | Mistral AI | API key | `MISTRAL_API_KEY` | +| `minimax` | MiniMax | API key | `MINIMAX_API_KEY` | +| `workers-ai` | Cloudflare Workers AI | API key | `CLOUDFLARE_API_KEY` | +| `ollama` | Ollama (local) | None | — | +| `bedrock` | AWS Bedrock | AWS env credentials | — | +| `vertex` | Google Vertex | GCP application credentials | — | +| `foundry` | Azure Foundry | API key + base URL or resource | `ANTHROPIC_FOUNDRY_API_KEY` | + +**Model identifier format**: `provider:model-name` + +``` +anthropic:claude-opus-4-8 +openai:gpt-4o +ollama:llama3.3:70b +bedrock:us.anthropic.claude-opus-4-8-20251101-v1:0 +``` + +**Resolution order** (`EffectiveAPIKeyAndProvider`): +1. Explicit provider prefix in model string +2. Model name pattern match (detect provider from model name) +3. Check provider env vars in priority order +4. `config.APIKey` fallback + provider inference from key prefix +5. Provider inferred from model name alone + +**Credential DB keys per provider** (stored as `api_key:`): + +``` +api_key:anthropic → ANTHROPIC_API_KEY +api_key:openai → OPENAI_API_KEY +api_key:ollama → (not needed) +provider_base_url → Ollama host or Foundry base URL +provider_region → AWS_REGION or CLOUD_ML_REGION +provider_project_id → ANTHROPIC_VERTEX_PROJECT_ID +provider_resource → ANTHROPIC_FOUNDRY_RESOURCE +``` + +--- + +## 10. Credentials / Auth + +**Storage**: `credentials` table, encrypted at rest with AES-256-GCM. + +**Encryption flow**: +``` +loadOrCreateEncryptionKey() + 1. Read ~/.config/nexus/secret.key (32 bytes) + 2. If absent, try legacy ~/.nexus_secret (migration) + 3. If absent, generate new key with crypto/rand, write with mode 0600 + +encryptAESGCM(key, plaintext) + → nonce (12 bytes random) || ciphertext (AES-256-GCM sealed) + → base64-encode the whole thing → stored as cipher_text + +decryptAESGCM(key, encoded) + → base64-decode → split nonce | ciphertext → GCM.Open → plaintext +``` + +**Key note**: The secret key lives in `~/.config/nexus/secret.key` even when +the CLI uses `~/.config/nexus-cli/data/nexus.db`. This is intentional — both +CLI and server on the same machine share one key so credentials can be +migrated or shared between the two DB files if needed. + +**Session auth / JWT**: Not applicable to the CLI. The CLI authenticates to +provider APIs directly using the stored API key. There is no JWT or session +cookie in the CLI flow. + +--- + +## 11. Skills System + +**Location**: `~/.config/nexus-cli/skills//` + +Skills are cloned git repositories. Each skill is a YAML file: + +```yaml +name: my-skill +description: "One-line description" +when_to_use: "When to invoke this skill" +content: | + # Skill instructions sent to the model + ... +``` + +**Config fields**: + +| Field | Purpose | +|---|---| +| `NEXUS_SKILL_REPOS` | Comma-separated git URLs cloned at startup | +| `NEXUS_DEFAULT_SKILL_REPO` | Official nexus-skills repo (auto-cloned on first boot) | +| `NEXUS_FEATURED_SKILL_REPOS` | Shown as installable catalog in the UI | +| `NEXUS_SKILL_REPO_HOSTS` | Allowed git hosts for skill installation (default: github.com, gitlab.com, bitbucket.org, codeberg.org) | + +Skills are exposed as `/skill-name` slash commands in the TUI. +`LoadSkills(ctx)` in the `Workspace` interface returns all available skills. + +--- + +## 12. Vector Store / RAG + +**Default backend**: SQLite (`vector_records` table). + +**Alternative backends** (configured via env or config.yaml): + +| Backend | Config key | Notes | +|---|---|---| +| `sqlite` | `NEXUS_VECTOR_BACKEND=sqlite` | Default, no extra deps | +| `pgvector` | `NEXUS_PGVECTOR_DSN=postgres://...` | Requires pgvector extension | +| `qdrant` | `QDRANT_HOST`, `QDRANT_PORT` | External Qdrant service | +| `chroma` | `CHROMA_URL` | External Chroma service | +| `memory` | `NEXUS_VECTOR_BACKEND=memory` | In-process, no persistence | + +**Embedder config**: + +| Env var | Purpose | +|---|---| +| `RAG_EMBEDDING_URL` | Base URL for embedding API | +| `RAG_EMBEDDING_API_KEY` | Embedding API key | +| `RAG_EMBEDDING_MODEL` | Model identifier for embeddings | +| `RAG_EMBEDDING_PROVIDER` | Provider name | + +--- + +## 13. MCP Servers + +MCP (Model Context Protocol) server configuration is stored in the credentials +table (under `mcp_servers_json` key) and managed through the TUI settings screen. +`LoadMCPServers(ctx)` on the `Workspace` interface returns connected servers and +their tool counts. + +MCP is enabled by default (`mcp_enabled: true`). Set `mcp_enabled: false` in +`config.yaml` or `NEXUS_MCP_ENABLED=false` to disable. + +--- + +## 14. Hooks + +Hooks are shell commands that fire on lifecycle events. Defined in `config.yaml`: + +```yaml +hooks: + pre_tool_use: + - command: "my-audit-script" + matcher: "bash" # optional: only fire for tools matching this regex + timeout: 30 # seconds before the hook is killed +``` + +**Supported events**: `pre_tool_use` (fires before every tool call). + +Hook output is shown in the TUI as a system message. A non-zero exit code +blocks the tool call and shows the error to the user. + +--- + +## 15. Execution Modes + +The engine supports three modes, switchable via special tools: + +| Mode | DB key | TUI badge | Tools | +|---|---|---|---| +| `execute` | default | `● execute` (muted) | All tools enabled | +| `plan` | `enter_plan_mode` / `exit_plan_mode` | `◈ plan` (primary) | Restricted: no write tools | +| `pair_programming` | `enter_pair_programming_mode` / `exit_pair_programming_mode` | `◎ pair` (secondary) | All tools, user confirms each | + +Mode transitions are tracked in `chat.planDepth` / `chat.pairDepth` counters +(incremented/decremented as enter/exit tools complete). diff --git a/docs/issues/codebase-audit-2026-06.md b/docs/issues/codebase-audit-2026-06.md new file mode 100644 index 0000000..611a4f8 --- /dev/null +++ b/docs/issues/codebase-audit-2026-06.md @@ -0,0 +1,234 @@ +# Codebase Audit — Nexus Engine CLI (June 2026) + +Audit statique complet de `internal/`, `cmd/`, `pkg/` effectué après la session +d'optimisation DB + HNSW. Chaque finding est classé **NOW** (à corriger dans le sprint +courant) ou **LATER** (issue communauté open-source). + +--- + +## NOW — Critiques (correctness / data integrity) + +### C1 · Session leak dans le task manager ✅ FIXÉ +**Fichier :** `internal/runtime/tasks/manager.go` +**Problème :** Si `session.RegisterTools()` échoue après `client.NewSession()`, la session +n'est jamais fermée → fuite de handle de session. +**Fix :** Pattern `committed bool` + defer conditionnel. + +--- + +### C2 · HNSW — partial write non détecté ✅ FIXÉ +**Fichier :** `internal/vector/hnsw_store.go` +**Problème :** Si `graph.Save()` réussit mais `saveMeta()` échoue (ou les deux échouent), +seule la première erreur est retournée. Index et métadonnées sont désynchronisés en +silence. +**Fix :** `errors.Join(saveErr, metaErr)` → les deux erreurs remontent. + +--- + +### C3 · FTS5 — erreurs silencieusement ignorées ✅ FIXÉ +**Fichier :** `internal/db/migrations_sqlite_core.go` +**Problème :** `_ = err` dans `migrateSQLiteVectorFTS5` et `migrateSQLiteTranscriptFTS5`. +Si la migration FTS5 échoue, la recherche hybride dégrade en O(n) LIKE sans aucun signal +à l'opérateur. +**Fix :** `log.Printf("[db] fts5 migration warning: …")` — non-fatal mais visible. + +--- + +### C4 · JSON unmarshal metadata — silencieux ✅ FIXÉ +**Fichiers :** `internal/vector/hnsw_store.go:92`, `internal/vector/sqlite_store.go:293` +**Problème :** `_ = json.Unmarshal(...)` sur les métadonnées stockées. Corruption en base +→ record retourné avec metadata nil sans aucun signal. +**Fix :** `log.Printf("[vector] metadata unmarshal warning: …")`. + +--- + +### C5 · `context.Background()` hardcodé dans sqlite_backend ✅ FIXÉ +**Fichier :** `internal/runtime/state/sqlite_backend.go:103,140,162` +**Problème :** `DeleteSession`, `AppendTranscriptEntries`, `ReplaceTranscript` utilisent +`context.Background()` → impossible d'annuler une opération longue ou d'enforcer un +timeout depuis l'appelant. +**Fix (pragmatique) :** `context.WithTimeout(context.Background(), N*time.Second)`. +**Fix (complet, LATER) :** Ajouter `ctx context.Context` sur l'interface `Backend` et +propager jusqu'aux callers (refactor C5-full ci-dessous). + +--- + +### M1 · Dimension des embeddings jamais validée ✅ FIXÉ +**Fichier :** `internal/rag/embedder/embedder.go` +**Problème :** Le nombre de vecteurs retournés est vérifié mais pas leur dimension. Si le +provider renvoie 768 dims au lieu de 1536, les vecteurs sont stockés silencieusement et +les recherches donnent des résultats incohérents. +**Fix :** Vérifier `len(out[0])` contre la dimension attendue. + +--- + +### M2 · Fallback FTS5 → LIKE non loggué +**Fichier :** `internal/runtime/state/sqlite_backend.go` — `SearchTranscriptsByContent` +**Problème :** La dégradation FTS5 → LIKE scan est silencieuse. Chaque search fait un +full-scan O(n) sans que l'opérateur le sache. +**Statut :** À adresser avec C5-full (même fichier). + +--- + +## LATER — Issues communauté open-source + +Ces items sont adaptés pour être des GitHub Issues avec label `good first issue` ou +`help wanted` selon la complexité. + +--- + +### L-A · Propagation complète de `ctx` sur l'interface `Backend` +**Complexité :** Élevée (~15 fichiers) +**Fichiers :** `internal/runtime/state/backend.go`, `sqlite_backend.go`, +`filesystem_backend.go`, `store.go`, `sync.go`, `engine/session.go`, `pkg/sdk/client.go`, +`cmd/cli/sessions.go` +**Description :** L'interface `Backend` (SaveSession, LoadSession, DeleteSession, +AppendTranscriptEntries, ReplaceTranscript) n'accepte pas de `ctx context.Context`. Cela +empêche toute propagation de timeout ou d'annulation depuis les callers. +**Label :** `refactor`, `good first issue` (bien documenté, impact clair) + +--- + +### L-B · Nettoyage des fichiers de sortie des tasks +**Complexité :** Faible +**Fichier :** `internal/runtime/tasks/manager.go` +**Description :** Les fichiers `.output` et `.exit` générés par les tasks ne sont jamais +supprimés — accumulation disque infinie. Ajouter une TTL (ex : 7 jours) et un GC +périodique. +**Label :** `bug`, `good first issue` + +--- + +### L-C · Goroutines sans shutdown propre dans `internal/agent/` +**Complexité :** Moyenne +**Fichiers :** `internal/agent/runner.go:195,322`, `internal/agent/events.go:142,332` +**Description :** Les goroutines créées avec `context.WithCancel(context.Background())` +ne sont pas arrêtées à la fermeture du client. Sur des runs longues, cela accumule des +goroutines en fuite. +**Label :** `bug`, `help wanted` + +--- + +### L-D · Rebuild automatique si index HNSW corrompu +**Complexité :** Moyenne +**Fichier :** `internal/vector/hnsw_store.go` +**Description :** Si le fichier `.hnsw` est corrompu, `LoadSavedGraph` retourne une +erreur qui bloque le namespace entier pour toute la durée du process. Ajouter une logique +de reconstruction depuis le fichier `.meta.json` (les vecteurs ne sont pas dans le meta, +donc reconstruction complète nécessite re-embedding — à documenter). +**Label :** `enhancement`, `help wanted` + +--- + +### L-E · Race window entre SaveSession et AppendTranscriptEntries +**Complexité :** Moyenne +**Fichier :** `internal/runtime/state/sqlite_backend.go` +**Description :** Les deux opérations ne sont pas dans la même transaction SQL. Si une +suppression concurrente intervient entre les deux, la FK CASCADE supprime les rows de +session_metadata avant que le transcript soit écrit, laissant des entries orphelines. +**Fix :** Wrapper les deux opérations dans une transaction explicite. +**Label :** `bug`, `help wanted` + +--- + +### L-F · Validation de la dimension vectorielle à l'Upsert +**Complexité :** Faible +**Fichiers :** `internal/vector/*.go` (tous backends) +**Description :** Aucun backend ne valide que toutes les entrées d'un même namespace ont +la même dimension. Des vecteurs de dimensions mixtes sont acceptés silencieusement et +corrompent les résultats de search. +**Label :** `bug`, `good first issue` + +--- + +### L-G · Fallback pgvector `searchHybrid` → `searchVector` sans log +**Complexité :** Très faible +**Fichier :** `internal/vector/pgvector_store.go` +**Description :** En cas d'erreur `ts_rank`, le fallback vers pure-vector search est +silencieux. Ajouter un `log.Printf` avant le fallback. +**Label :** `good first issue` + +--- + +### L-H · Config.Validate() au chargement +**Complexité :** Moyenne +**Fichier :** `pkg/config/config.go` +**Description :** La config est chargée sans validation d'incohérences internes. Ex : +`VectorBackend=pgvector` avec une DB SQLite → erreur seulement au premier query. +`RAG_EMBEDDING_URL` set mais `RAG_EMBEDDING_MODEL` vide → erreur au premier appel RAG. +**Label :** `enhancement`, `good first issue` + +--- + +### L-I · Métriques de latence pour les opérations store +**Complexité :** Moyenne +**Fichiers :** `internal/vector/*`, `internal/runtime/state/*` +**Description :** Aucune métrique exposée (Prometheus ou autre) pour les latences de +search, append, ou compaction. Impossible de détecter des régressions de performance en +production. +**Label :** `enhancement`, `observability` + +--- + +### L-J · Tests manquants — `internal/db/` +**Complexité :** Moyenne +**Fichiers :** `internal/db/*.go` +**Description :** Zéro fichier de test dans le package DB. Migrations, triggers FTS5, +cascade deletes, deduplication session_files — aucune couverture. +Priorité : idempotence des migrations (run twice → même schéma). +**Label :** `testing`, `good first issue` + +--- + +### L-K · Tests manquants — RAG end-to-end +**Complexité :** Moyenne +**Fichier :** `internal/rag/` +**Description :** Pas de test du flow complet `Ingest → chunk → embed → store → Search`. +La couverture est partielle : seules les unités individuelles sont testées. +**Label :** `testing`, `help wanted` + +--- + +### L-L · Tests manquants — agent runner + task manager +**Complexité :** Élevée +**Fichiers :** `internal/agent/runner.go`, `internal/runtime/tasks/manager.go` +**Description :** Tests d'intégration pour le cycle complet task create → run → complete +→ output absent. +**Label :** `testing`, `help wanted` + +--- + +### L-M · `PRAGMA integrity_check` au démarrage +**Complexité :** Très faible +**Fichier :** `internal/db/db.go` +**Description :** Aucune vérification d'intégrité SQLite au démarrage. Une corruption de +la table FTS5 ou du WAL peut produire des erreurs opaques difficiles à diagnostiquer. +Ajouter un `PRAGMA integrity_check(1)` (quick check) lors de `Open` en mode SQLite. +**Label :** `reliability`, `good first issue` + +--- + +## Résumé des statuts + +| ID | Titre | Bucket | Statut | +|----|-------|--------|--------| +| C1 | Session leak task manager | NOW | ✅ Fixé | +| C2 | HNSW partial write | NOW | ✅ Fixé | +| C3 | FTS5 silent errors | NOW | ✅ Fixé | +| C4 | JSON unmarshal metadata silent | NOW | ✅ Fixé | +| C5 | context.Background() sqlite_backend | NOW | ✅ Fixé (timeout) | +| M1 | Dimension embeddings non validée | NOW | ✅ Fixé | +| M2 | Fallback FTS5→LIKE non loggué | NOW | Lié à C5 | +| L-A | ctx complet sur interface Backend | LATER | Issue | +| L-B | Nettoyage files tasks | LATER | Issue | +| L-C | Goroutines agent sans shutdown | LATER | Issue | +| L-D | Rebuild HNSW sur corruption | LATER | Issue | +| L-E | Race SaveSession + AppendTranscript | LATER | Issue | +| L-F | Validation dimension vectorielle | LATER | Issue | +| L-G | pgvector hybrid fallback log | LATER | Issue | +| L-H | Config.Validate() | LATER | Issue | +| L-I | Métriques latence store | LATER | Issue | +| L-J | Tests internal/db | LATER | Issue | +| L-K | Tests RAG end-to-end | LATER | Issue | +| L-L | Tests agent + task manager | LATER | Issue | +| L-M | PRAGMA integrity_check startup | LATER | Issue | diff --git a/docs/issues/session-directory-layout.md b/docs/issues/session-directory-layout.md new file mode 100644 index 0000000..89f6119 --- /dev/null +++ b/docs/issues/session-directory-layout.md @@ -0,0 +1,232 @@ +# Session-Scoped Directory Layout + +## Problème actuel + +Le répertoire de travail `~/.config/nexus-cli/` est un patchwork de conventions disparates : + +``` +~/.config/nexus-cli/ +├── nexus.yaml +├── secret.key +├── data/ +│ ├── nexus.db # SQLite : sessions, transcripts, credentials +│ └── hnsw/ # index vectoriel +├── plans/ +│ └── {slug}.md # slug aléatoire, pas lié au session_id dans le nom +├── storage/ +│ └── artifacts/ +│ └── browser/ +│ ├── screenshots/{session_id}/{page_id}/{date}/{ts-file} +│ └── downloads/{session_id}/{page_id}/{date}/{ts-file} +├── logs/ +│ └── cli.log +├── cache/ +└── tmp/ + ├── tasks/ + └── bash-tasks/ +``` + +**Points de friction concrets :** + +1. **Plans non traçables** — les fichiers de plan utilisent un slug aléatoire (`algorithm-spectrum.md`). Le lien slug↔session_id n'existe qu'en mémoire vive. Si le processus redémarre, on ne peut plus retrouver le plan d'une session passée. + +2. **Suppression en plusieurs étapes** — supprimer une session force à : + - lister les artifacts par préfixe dans le store (`artifacts/browser/screenshots/{id}/…`) + - supprimer chaque fichier un par un + - nettoyer le slug cache et le fichier plan séparément + - supprimer la rangée SQLite + +3. **Chemins hardcodés éparpillés** — `os.UserHomeDir()` est appelé à 8 endroits différents dans le code ; `~/.config/nexus-cli` apparaît sous plusieurs formes dans `main.go`, `config.go`, `tui.go`, `credentials.go`, `plan.go`, etc. + +4. **Pas de log par session** — un seul `cli.log` global mélange toutes les sessions ; déboguer une session précise oblige à filtrer manuellement. + +5. **Nom de répertoire ambigu** — `nexus-cli` désignait l'outil CLI, mais l'application est maintenant un TUI complet. `nexus-tui` est plus précis et évite les collisions avec le backend (`~/.config/nexus`). + +--- + +## Proposition + +### Nouveau répertoire racine + +| Plateforme | Chemin par défaut | +|------------|------------------------------| +| Linux | `~/.config/nexus-tui/` | +| macOS | `~/.config/nexus-tui/` | +| Windows | `%APPDATA%\nexus-tui\` | + +La variable d'environnement `NEXUS_RUNTIME_ROOT` continue de prendre la priorité pour les usages avancés. + +### Arborescence cible + +``` +~/.config/nexus-tui/ +├── config.yaml # configuration utilisateur +├── secret.key # clé AES-256 (mode 0600) +├── nexus.db # SQLite : metadata sessions, credentials, transcripts +├── logs/ +│ └── app.log # log applicatif global (démarrage, erreurs critiques) +└── sessions/ + └── {session_id}/ + ├── images/ # screenshots browser, images générées + ├── plans/ # fichiers de plan mode ({slug}.md ou plan.md) + ├── tools/ # fichiers téléchargés, outputs d'outils, metadata non-DB + └── session.log # log spécifique à cette session + └── permissions.json # Save permissions per tools during the session +``` + +### Principes + +- **Tout ce qui est propre à une session vit dans `sessions/{id}/`** — un seul `os.RemoveAll` suffit pour supprimer toutes les données physiques d'une session. +- **La DB reste la source de vérité pour les métadonnées** — les chemins physiques en sont déduits via les fonctions du package `runtimepath`, jamais hardcodés. +- **Le package `runtimepath` fournit les fonctions, l'application gère l'initialisation** — les packages internes prennent les chemins en entrée, ils ne font pas de découverte de répertoire eux-mêmes. +- **La DB SQLite passe à la racine** (`nexus.db` au lieu de `data/nexus.db`) — simplification sans impact fonctionnel. + +--- + +## Changements par couche + +### 1. `pkg/runtimepath` — nouvelles fonctions + +Ajouter les accesseurs pour les répertoires session-scoped : + +```go +// Répertoires globaux +func DBPath(root string) string { return Join(root, "nexus.db") } +func EncryptionKeyPath(root string) string { return Join(root, "secret.key") } +func AppLogPath(root string) string { return Join(root, "logs", "app.log") } + +// Répertoires par session +func SessionsDir(root string) string { return Join(root, "sessions") } +func SessionDir(root, sessionID string) string { + return filepath.Join(SessionsDir(root), sessionID) +} +func SessionImagesDir(root, sessionID string) string { + return filepath.Join(SessionDir(root, sessionID), "images") +} +func SessionPlansDir(root, sessionID string) string { + return filepath.Join(SessionDir(root, sessionID), "plans") +} +func SessionToolsDir(root, sessionID string) string { + return filepath.Join(SessionDir(root, sessionID), "tools") +} +func SessionLogPath(root, sessionID string) string { + return filepath.Join(SessionDir(root, sessionID), "session.log") +} +``` + +Les fonctions existantes (`PlansDir`, `StorageDir`, `BackendDBPath`) restent présentes pendant la période de migration puis sont dépréciées. + +### 2. `cmd/cli/appdir/appdir.go` — nouveau package (côté applicatif) + +Ce package est la **seule source de vérité côté application** pour les chemins. Il ne doit pas être importé par les packages internes. + +```go +package appdir + +// Root retourne le répertoire racine de l'application, résolu via NEXUS_RUNTIME_ROOT +// ou la convention plateforme (Linux/macOS : ~/.config/nexus-tui, Windows : %APPDATA%\nexus-tui). +func Root() string + +// EnsureAppDirs crée tous les répertoires applicatifs nécessaires au démarrage. +// Idempotent. À appeler une seule fois dans main(). +func EnsureAppDirs() error + +// EnsureSessionDir crée sessions/{id}/ et ses sous-répertoires (images, plans, tools). +// À appeler quand une nouvelle session démarre. +func EnsureSessionDir(sessionID string) error + +// DeleteSessionDir supprime récursivement sessions/{id}/. +// Utilisé par DeleteSession — un seul appel couvre tous les fichiers physiques. +func DeleteSessionDir(sessionID string) error + +// Accesseurs directs (délèguent à runtimepath) +func DBPath() string +func EncryptionKeyPath() string +func AppLogPath() string +func SessionDir(sessionID string) string +func SessionImagesDir(sessionID string) string +func SessionPlansDir(sessionID string) string +func SessionToolsDir(sessionID string) string +func SessionLogPath(sessionID string) string +``` + +### 3. Stockage des artifacts browser + +**Avant :** `storage/artifacts/browser/screenshots/{session_id}/{page_id}/{date}/{ts}-screenshot.png` + +**Après :** `sessions/{session_id}/images/{page_id}/{date}/{ts}-screenshot.png` + +La fonction `ScreenshotKey` dans `storage/keys.go` est mise à jour pour utiliser `appdir.SessionImagesDir(sessionID)` comme base. De même pour les downloads → `SessionToolsDir`. + +### 4. Fichiers de plan + +**Avant :** `plans/{random-slug}.md` (lien slug↔session_id uniquement en mémoire) + +**Après :** `sessions/{session_id}/plans/plan.md` (ou `sessions/{session_id}/plans/{slug}.md` si plusieurs plans par session) + +`GetPlanFilePath` dans `internal/modes/execution/plan.go` utilise `appdir.SessionPlansDir(sessionID)` au lieu de `planCache.GetDirectory()`. Le slug reste utile pour nommer les fichiers quand plusieurs plans coexistent dans une session, mais la session ID est désormais directement dans le chemin. + +### 5. Suppression d'une session + +**Avant :** +```go +// 1. Lister les artifacts par préfixe +store.List(ctx, {Prefix: "artifacts/browser/screenshots/" + id}) +// 2. Supprimer chaque fichier +for _, ref := range refs { store.Delete(ctx, ref.Key) } +// 3. Nettoyer les plans +os.Remove(GetPlanFilePath(sessionID, nil)) +execution.ClearState(sessionID) +execution.ClearPlanSlug(sessionID) +// 4. Supprimer la rangée DB +store.DeleteSession(sessionID) +``` + +**Après :** +```go +// 1. Supprimer tous les fichiers physiques d'un coup +appdir.DeleteSessionDir(string(sessionID)) // os.RemoveAll(sessions/{id}/) +// 2. Supprimer la rangée DB (cascade : transcripts, checkpoints, session_files) +store.DeleteSession(sessionID) +``` + +### 6. Logs par session + +À chaque démarrage de session, un `log.Logger` est créé pointant vers `sessions/{id}/session.log`. Les erreurs spécifiques à la session (tool failures, context errors, provider errors) y sont écrites en plus du log global. + +--- + +## Migration des données existantes + +Les installations existantes gardent leur répertoire `~/.config/nexus-cli/` intact. La migration n'est pas destructive : + +1. **Première utilisation** : si `~/.config/nexus-tui/` n'existe pas mais `~/.config/nexus-cli/` existe, afficher un message proposant la migration. +2. **Migration opt-in** : `nexus migrate` (ou un flag au démarrage) copie `nexus.db`, `secret.key`, `config.yaml` vers le nouveau répertoire. Les anciens artifacts restent dans l'ancien emplacement (on ne les déplace pas — trop risqué, trop lent). +3. **Période de cohabitation** : `NEXUS_RUNTIME_ROOT=~/.config/nexus-cli` permet de rester sur l'ancien chemin sans changement. + +--- + +## Phases d'implémentation + +| Phase | Contenu | Impact | +|-------|---------|--------| +| 1 | Ajouter les fonctions session-scoped à `pkg/runtimepath` | aucun — nouvelles fonctions | +| 2 | Créer `cmd/cli/appdir/appdir.go` avec `Root()`, `EnsureAppDirs()`, `EnsureSessionDir()`, `DeleteSessionDir()` | aucun — nouveau package | +| 3 | Renommer le répertoire racine : `nexus-cli` → `nexus-tui` dans `main.go` + valeur par défaut Windows | breaking pour les users existants → faire en dernier avec migration | +| 4 | Migrer le stockage des artifacts browser vers `sessions/{id}/images/` et `sessions/{id}/tools/` | modifier `storage/keys.go` + `storage/artifacts.go` | +| 5 | Migrer les plans vers `sessions/{id}/plans/` | modifier `internal/modes/execution/plan.go` + `cache.go` | +| 6 | Simplifier `DeleteSession` : remplacer par `appdir.DeleteSessionDir` + `store.DeleteSession` | remplace le code de nettoyage actuel | +| 7 | Ajouter le log par session | nouveau : `sessionLogger` dans `engine/session.go` ou `agent/runner.go` | +| 8 | Déprécier et supprimer les anciennes fonctions `runtimepath` (PlansDir, StorageDir, BackendDBPath legacy) | nettoyage | + +Les phases 1 et 2 peuvent être faites immédiatement. Les phases 3–5 sont le gros du travail mais sont indépendantes entre elles. Les phases 6–8 découlent naturellement. + +--- + +## Ce qui ne change pas + +- La structure de la base SQLite (`nexus.db`) — seul le chemin change. +- Le format des sessions, transcripts, metadata en DB. +- Le système de stockage S3 (aucun impact — le S3 store reste avec ses propres clés). +- L'interface `ArtifactStore` et la logique de GC. +- Le `runtimepath.EnvRuntimeRoot` comme mécanisme d'override. diff --git a/docs/issues/tui-roadmap.md b/docs/issues/tui-roadmap.md new file mode 100644 index 0000000..543305c --- /dev/null +++ b/docs/issues/tui-roadmap.md @@ -0,0 +1,224 @@ +# TUI Roadmap + +This note tracks the current UX progress of the Nexus CLI TUI and the next interaction work. + +## Completed + +### 1. Welcome wordmark cleanup +- Removed the extra leading bullet from the welcome screen wordmark. +- Status: done + +### 2. Footer simplification +- Removed `ctrl+e` select mode from the visible happy path. +- Simplified the default footer actions. +- Tool navigation is no longer advertised as `tab chat/tools` in the primary footer flow. +- Status: done + +### 3. Working status lane above composer +- Moved the active `working` indicator out of the header. +- Added a status lane directly above the composer for runtime visibility. +- The lane now focuses on runtime state only: `working`, `failed`, or `ready`. +- Status: done + +### 4. Primary chat layout polish +- The app now uses near-full-width layout with small left/right margins instead of a narrow centered column. +- User messages use an inline blue `● >` marker. +- Assistant messages use an orange `●` marker. +- Intermediate assistant segments created around tool calls no longer show false `done` states. +- Final assistant metadata is attached only to the true end of the turn. +- Status: done + +### 5. Tool rendering baseline +- Added richer tool summaries and previews for core tools. +- Kept completed tools visually more neutral so green is reserved for actual turn completion. +- Added a right-side details pane for selected tools. +- Status: done + +### 6. Shared markdown renderer +- Switched from raw environment-configured glamour usage to a shared markdown helper in `internal/tui/common/markdown.go`. +- Added cached renderers by width and per-renderer locking. +- Markdown headings no longer show visible `##` / `###` prefixes in the main chat renderer. +- Status: done + +### 7. Config/credentials isolation (CLI vs backend) +- CLI sets `NEXUS_RUNTIME_ROOT` → `~/.config/nexus-cli`; backend stays on `~/.config/nexus`. +- `LoadInto()` uses `runtimepath.ResolveRoot("")` instead of a hardcoded path. +- `ParseModelIdentifier("")` returns an empty `ModelIdentifier{}` instead of the Anthropic SDK default. +- No provider configured at startup → welcome screen shows `ctrl+p` hint instead of auto-opening settings. +- Status: done + +### 8. Credentials in SQLite DB +- API keys, model selection, Ollama URL, search keys stored in scoped SQLite DB keys. +- `SetModel` / `SaveProviderField` persist to DB; `loadCredsIntoConfig` restores on restart. +- Status: done + +### 9. Clipboard paste in secret fields +- `ctrl+v` pastes from clipboard in config panel and search panel. +- `ctrl+r` toggles reveal/hide (was previously bound to `ctrl+v` by mistake). +- Status: done + +### 10. Model picker — configured-only filtering +- Model picker only shows providers that have credentials configured. +- Ollama is probed at startup in the background; models are cached in the DB. +- Ollama endpoint is configurable in the provider settings panel. +- Saving a new Ollama URL triggers a re-probe automatically. +- Embedding models (bert, *-embed) are filtered out. +- Status: done + +### 11. Mouse-first selection and copy +- Mouse event routing, drag-to-copy, persistent colored selection after release. +- Double-click word, triple-click line selection. +- Auto-scroll while dragging at viewport edges. +- `ctrl+shift+c` copy shortcut; right-click copy attempt. +- Accurate clipboard-availability notice when Linux clipboard backends are missing. +- Remaining work: refine copy semantics for visual markers vs plain content. +- Status: in progress + +### 12. Clickable tool rows and richer interactions +- Tool rows can be clicked to select; expand and details have explicit click targets. +- Thinking blocks can be expanded or collapsed with the mouse. +- Remaining work: smoother IDE-like interactions around the side pane. +- Status: in progress + +### 13. Commands / settings panel +- `ctrl+p` settings hub with nested sections: commands, providers, models, tools, MCP, skills. +- Sections load live data from the current workspace/runtime. +- Remaining work: deepen each section into richer management views. +- Status: in progress + +### 14. Model picker freeze fix (this session) +- `SetModel` was doing synchronous SQLite I/O on the BubbleTea event-loop goroutine → blocked all input. +- Fix: DB persistence moved to a goroutine inside `SetModel`. +- `ctrl+c` / `ctrl+q` were silently swallowed by overlay-state handlers (stateModelSelect, stateCommands, etc.) that all end with `return true, nil`. +- Fix: global quit check added at the top of `handleKey`, before all state-specific blocks. +- Status: done + +--- + +## In Progress / Next + +### 15. Tool row compression (high priority) +**Problem**: rows are verbose and noisy. +- Current: `► ⊞ ✓ Task Create done Tool task_create completed (49ms)` +- Target: `✓ TaskCreate #1780… (49ms)` + +**Changes**: +- Single status icon (`●` pending / `✓` done / `✗` error) — remove the triple-icon prefix `► ⊞ ✓`. +- Remove redundant text: "done", "Tool", "completed" — the icon + duration is enough. +- Show the first relevant argument (truncated) in the row: file path for Write/Read/Edit, command for Bash, task title for TaskCreate. +- Duration shown only when > 0. + +**Grouping** (consecutive calls of the same tool type): +- **Grouped** (output is uniform per call): `Read`, `Write`, `TaskCreate`, `TaskUpdate`, `ListDirectory`. +- **Not grouped** (each call has unique and important output): `Bash`, `Edit`, `WebFetch`, `WebSearch`. +- A group collapses into one row: `✓ Read (4×) main.go, config.go, … (total Xms)`. +- Selecting the group in the right panel lists all individual calls with their outputs. + +**Thinking blocks**: +- Replace "click to expand" with "ctrl+t to toggle" (TUI is keyboard-driven, not click-driven). +- Add a subtle left border or background tint to distinguish from regular text. + +Status: planned + +--- + +### 16. Plan mode state tracking + enter/exit tool rows +**Problem**: `enter_plan_mode` and `exit_plan_mode` appear as tool rows in the chat — they are state transitions, not user-visible actions. + +**Changes**: +- Track plan mode state in the TUI (increment/decrement counter based on intercepted tool progress messages for `enter_plan_mode` / `exit_plan_mode`). +- When plan mode is active, show a `◈ Plan Mode` status pill in the header (next to the model pill) or as a banner between the header and chat area. +- Suppress `enter_plan_mode` / `exit_plan_mode` tool rows from the chat area entirely. + +Status: planned + +--- + +### 17. Task list display (replaces task tool rows) +**Problem**: `task_create` × N and `task_update` × N appear as individual rows with no structure. The user can't see the plan progress at a glance. + +**Design**: +- When the first `task_create` tool call completes in a turn, open a "Plan" panel inside the chat — a structured task list with the task titles. +- Each task starts with `○` (open). +- When a `task_update` with `status=complete` arrives for a task, its row updates to `✓`. +- While a task is actively being executed (a tool call group runs after a specific task), it shows a spinner `●`. +- The task list is shown as a chat block, not as individual tool rows. +- `task_create` and `task_update` tool rows are suppressed from the main tool row stream. + +Status: planned + +--- + +### 18. Plan file overlay (plan mode review flow) +**Background**: When Claude is in plan mode, a system prompt forces it to write a detailed markdown plan file before proceeding. This overlay intercepts that file and presents it for review. + +**Plan file location** (finalized): +``` +{working_dir}/.nexus/plans/{short_session_id}/{workspace_slug}_{YYYYMMDD-HHMMSS}.md +``` +- `.nexus/plans/` → hidden project-scoped folder, unambiguous TUI detection pattern. +- `{short_session_id}/` subfolder → groups all revisions of a session together. +- `{workspace_slug}_{YYYYMMDD-HHMMSS}.md` → each review revision gets a new timestamped file. +- Multiple revisions per session are supported (Review → Claude rewrites → new file → overlay again). + +**DB keys**: +- `plan_status:{sessionID}` → `pending` | `reviewing` | `validated` +- Latest plan file = most recent `.md` in the session subfolder (sorted by filename/mtime). +- On session resume: if `plan_status` is `pending` or `reviewing`, re-show the overlay. + +**Trigger**: A `write` tool call completes AND the written path matches `**/.nexus/plans/**/*.md`. + - No plan-mode-active check needed — the path pattern is unambiguous. + +**Flow**: +1. Read the written file content. +2. Send a `planReviewMsg{content, filePath, sessionID}` to the TUI. +3. Show a full-screen overlay (similar to the permission dialog but taller): + - Top: rendered markdown content of the plan (scrollable). + - Bottom: editable comment area + two action buttons. + - `[V] Validate` — accept the plan as-is. + - `[R] Review` — submit comments back to Claude. +4. If **Validate**: send a follow-up message to Claude ("Plan validated. Exit plan mode and begin execution."). + - Claude exits plan mode automatically and starts creating tasks. + - DB: `plan_status:{sessionID}` → `validated`. +5. If **Review**: send a follow-up message ("Here are my review comments: [comments]. Please revise the plan."). + - DB: `plan_status:{sessionID}` → `reviewing`. + - The overlay closes; Claude revises and writes a new plan file → triggers the overlay again. + +Status: planned + +--- + +### 19. Text overflow with sidebar open (bug) +**Problem**: When the right-side details panel is open, some chat text is truncated at the right edge instead of wrapping. +**Likely cause**: `chatW` calculation in `viewChat()` does not propagate correctly to all text renderers when the sidebar is open. +Status: planned (low effort) + +--- + +### 20. Context percentage and model capacity visibility +- Once model context capacity is reliably available, show `31% context` in the footer or header. +- Status: planned + +--- + +### 21. Manual compaction trigger +- A dedicated "Compact Context" action (settings hub + optional `ctrl+l`). +- Do not fake this with a normal prompt — it must be a real engine operation once exposed. +- Status: planned + +--- + +## Implementation Order + +1. Tool row compression (15) — most visible, standalone change +2. Plan mode state tracking (16) — prerequisite for 17 and 18 +3. Task list display (17) — depends on 16 +4. Plan file overlay (18) — depends on 16; needs plan file pattern decision +5. Text overflow fix (19) — quick bug fix, any time +6. Context percentage (20) — needs upstream data + +## Reference + +- Crush (`/home/amiche/Projects/AI/ai/nexus-product/helps/crush`) remains the primary reference for tool row style, thinking blocks, and animation patterns. +- Nexus intentionally diverges from Crush on markdown heading presentation and chat chrome. +- `AGENTS.md` stays focused on engineering rules; roadmap items belong here. diff --git a/docs/storage.md b/docs/storage.md new file mode 100644 index 0000000..89528f8 --- /dev/null +++ b/docs/storage.md @@ -0,0 +1,175 @@ +# Storage & Session Artifacts + +Reference for tool developers who need to persist files produced during agent execution. + +--- + +## Directory layout + +All data lives under `~/.config/nexus-cli/` (or `$NEXUS_RUNTIME_ROOT`). + +``` +~/.config/nexus-cli/ +├── nexus.db ← SQLite: sessions, transcripts, credentials +├── secret.key ← AES-256 encryption key (0600) +├── logs/ +│ └── app.log +├── documents/ ← user-uploaded PDFs and docs (global, persistent) +├── rag/ ← RAG-indexed documents (global, persistent) +└── sessions/ + └── {session_id}/ + ├── screenshots/ ← browser screenshots + ├── plans/ ← plan-mode markdown files + ├── tools/ ← browser downloads + └── artifacts/ + ├── web/ ← web-scraped / fetched content + ├── images/ ← AI-generated images + └── audio/ ← TTS / STT audio files +``` + +**Rule of thumb:** +- Content produced **by the agent for a session** → `sessions/{id}/…` +- Content **uploaded intentionally by the user** as a knowledge base → `documents/` or `rag/` + +--- + +## Artifact store + +The `storage.ArtifactStore` interface is the single entry point for persisting binary files. It supports both local filesystem and S3 backends transparently. + +The store is injected into tools at construction time — never instantiate it directly inside a tool. + +### Key builders (`internal/storage/keys.go`) + +Each key builder returns a deterministic path string that encodes the session, type, and timestamp. Use these instead of building paths by hand. + +| Function | Output path | Use for | +|---|---|---| +| `ScreenshotKey(sessionID, pageID, now)` | `sessions/{id}/screenshots/{page}/{date}/{ts}-screenshot.png` | Browser screenshots | +| `DownloadKey(sessionID, pageID, filename, now)` | `sessions/{id}/tools/{page}/{date}/{ts}-{file}` | Browser downloads | +| `WebArtifactKey(sessionID, filename, now)` | `sessions/{id}/artifacts/web/{date}/{ts}-{file}` | Web-fetched content | +| `GeneratedImageKey(sessionID, filename, now)` | `sessions/{id}/artifacts/images/{date}/{ts}-{file}` | AI-generated images | +| `AudioKey(sessionID, filename, now)` | `sessions/{id}/artifacts/audio/{date}/{ts}-{file}` | TTS / STT audio | +| `PDFKey(title, now)` | `documents/{date}/{ts}-{title}.pdf` | Global PDF documents | +| `DocumentKey(filename, now)` | `documents/{date}/{ts}-{file}` | Global user documents | + +### Store helpers (`internal/storage/artifacts.go`) + +Higher-level wrappers that pick the right key and call `store.Put` for you: + +```go +// Browser screenshots — called by the browser tool internally. +StoreScreenshotRef(ctx, store, data, sessionID, pageID string) (ArtifactRef, error) + +// Web-fetched binary content — called by the fetch service internally. +StoreWebArtifactRef(ctx, store, data []byte, sessionID, filename, contentType string) (ArtifactRef, error) + +// AI-generated image — call this from your image-generation tool. +StoreGeneratedImageRef(ctx, store, data []byte, sessionID, filename, contentType string) (ArtifactRef, error) + +// TTS/STT audio — call this from your audio tool. +StoreAudioRef(ctx, store, data []byte, sessionID, filename, contentType string) (ArtifactRef, error) + +// Global PDFs (not session-scoped). +StorePDFRef(ctx, store, data []byte, title string) (ArtifactRef, error) +``` + +Pass `nil` as `store` and the default process-wide store is used automatically. + +### Example: image generation tool + +```go +func (t *ImageGenTool) Execute(ctx context.Context, input Input) (Output, error) { + imageData, err := t.provider.Generate(ctx, input.Prompt) + if err != nil { + return Output{}, err + } + ref, err := storage.StoreGeneratedImageRef( + ctx, + t.artifactStore, // injected at construction + imageData, + string(t.sessionID), // from tool context + "generated.png", + "image/png", + ) + if err != nil { + return Output{}, err + } + return Output{URL: ref.URL}, nil +} +``` + +### Example: audio (TTS) tool + +```go +ref, err := storage.StoreAudioRef(ctx, store, audioBytes, sessionID, "speech.mp3", "audio/mpeg") +``` + +--- + +## Adding a new artifact type + +1. Add a key builder in `internal/storage/keys.go`: + ```go + func MyTypeKey(sessionID, filename string, now time.Time) string { + // Layout: sessions/{sessionID}/artifacts/mytype/{date}/{ts}-{file} + parts := []string{"sessions", sanitizePathSegment(sessionID), "artifacts", "mytype", ...} + ... + } + ``` + +2. Add a store helper in `internal/storage/artifacts.go`: + ```go + func StoreMyTypeRef(ctx context.Context, store ArtifactStore, data []byte, sessionID, filename, contentType string) (ArtifactRef, error) { + key := MyTypeKey(sessionID, filename, time.Now().UTC()) + return store.Put(ctx, key, data, contentType) + } + ``` + +3. Add a `SessionMyTypeDir` function in `pkg/runtimepath/runtimepath.go` and expose it in `cmd/cli/appdir/appdir.go`. + +4. Add the new directory to `appdir.EnsureSessionDir` so it's created when a session opens. + +No changes to the deletion logic — `appdir.DeleteSessionDir` removes the entire `sessions/{id}/` tree. + +--- + +## Session lifecycle + +``` +Session open / resume + └── appdir.EnsureSessionDir(id) ← creates all subdirs + +Agent runs + └── tools write artifacts via store helpers + +Session deleted (user presses 'd' in session browser) + ├── store.DeleteSession(id) ← removes DB rows (cascade) + └── appdir.DeleteSessionDir(id) ← os.RemoveAll(sessions/{id}/) + covers: screenshots, plans, tools, artifacts/web, artifacts/images, artifacts/audio +``` + +For S3 storage, `client.DeleteSession` additionally calls `store.List("sessions/{id}") + store.Delete` for each key, since `os.RemoveAll` only works on the local filesystem. + +--- + +## Path helpers + +Access paths from application code via `cmd/cli/appdir`: + +```go +appdir.Root() // ~/.config/nexus-cli/ +appdir.SessionDir(id) // sessions/{id}/ +appdir.SessionScreenshotsDir(id) // sessions/{id}/screenshots/ +appdir.SessionPlansDir(id) // sessions/{id}/plans/ +appdir.SessionToolsDir(id) // sessions/{id}/tools/ +appdir.SessionArtifactsDir(id) // sessions/{id}/artifacts/ +appdir.SessionArtifactsWebDir(id) // sessions/{id}/artifacts/web/ +appdir.SessionArtifactsImagesDir(id) // sessions/{id}/artifacts/images/ +appdir.SessionArtifactsAudioDir(id) // sessions/{id}/artifacts/audio/ +appdir.SessionLogPath(id) // sessions/{id}/session.log +``` + +Internal packages use `pkg/runtimepath` directly (same functions, with an explicit `root` param). + +> **Note:** `appdir` is application-level — only `cmd/cli` imports it. Internal packages (`internal/…`) receive paths as explicit parameters and do not import `appdir`. diff --git a/docs/tui-roadmap.md b/docs/tui-roadmap.md deleted file mode 100644 index 10ae810..0000000 --- a/docs/tui-roadmap.md +++ /dev/null @@ -1,128 +0,0 @@ -# TUI Roadmap - -This note tracks the current UX progress of the Nexus CLI TUI and the next interaction work. - -## Completed - -### 1. Welcome wordmark cleanup -- Removed the extra leading bullet from the welcome screen wordmark. -- Status: done - -### 2. Footer simplification -- Removed `ctrl+e` select mode from the visible happy path. -- Simplified the default footer actions. -- Tool navigation is no longer advertised as `tab chat/tools` in the primary footer flow. -- Status: done - -### 3. Working status lane above composer -- Moved the active `working` indicator out of the header. -- Added a status lane directly above the composer for runtime visibility. -- The lane now focuses on runtime state only: `working`, `failed`, or `ready`. -- Status: done - -### 4. Primary chat layout polish -- The app now uses near-full-width layout with small left/right margins instead of a narrow centered column. -- User messages use an inline blue `● >` marker. -- Assistant messages use an orange `●` marker. -- Intermediate assistant segments created around tool calls no longer show false `done` states. -- Final assistant metadata is attached only to the true end of the turn. -- Status: done - -### 5. Tool rendering baseline -- Added richer tool summaries and previews for core tools. -- Kept completed tools visually more neutral so green is reserved for actual turn completion. -- Added a right-side details pane for selected tools. -- Status: done - -### 6. Shared markdown renderer -- Switched from raw environment-configured glamour usage to a shared markdown helper in `internal/tui/common/markdown.go`. -- Added cached renderers by width and per-renderer locking, following the same structural idea as Crush. -- Kept a Nexus-specific style decision: markdown headings no longer show visible `##` / `###` prefixes in the main chat renderer. -- Status: done - -## Partially Done - -### 7. Footer token/context usage -- The footer now shows cumulative token usage for the current observed session in the TUI. -- Per-turn token usage is rendered on the final assistant meta line instead of duplicating it in multiple places. -- Remaining work: - - expose reliable model context-window capacity - - add a context percentage once that data is available -- Status: in progress - -### 8. Commands / settings reorganization -- The footer has already been simplified and older noise removed. -- `ctrl+p` is now a true settings hub with nested sections for commands, providers, models, tools, MCP, and skills. -- Generic slash commands are no longer advertised there; slash input is now reserved for skills in the chat composer. -- The `Tools`, `MCP`, and `Skills` sections now load live data from the current workspace/runtime instead of showing only static placeholder copy. -- Status: done - -## Next Priorities - -### 9. Mouse-first selection and copy -- Implemented: - - mouse event routing in the main model - - drag-to-copy text selection in chat - - copy on mouse release - - persistent colored selection after mouse release - - double-click word selection - - triple-click line selection - - auto-scroll while dragging at viewport edges - - `ctrl+shift+c` copy shortcut - - right-click copy attempt when the terminal forwards the event - - accurate clipboard-availability notice when Linux clipboard backends are missing - - `ctrl+shift+c` copy shortcut - - right-click copy attempt when the terminal forwards the event - - accurate clipboard-availability notice when Linux clipboard backends are missing -- Remaining work: - - refine copy semantics for visual chat markers versus plain content where needed -- Status: in progress - -### 10. Clickable tool rows and richer interactions -- Implemented: - - tool rows can now be clicked - - clicking a tool row selects it - - explicit click targets exist for expand and details - - thinking blocks can be expanded or collapsed directly with the mouse -- Remaining work: - - smoother IDE-like interactions around the side pane -- Status: in progress - -### 11. Commands / settings panel expansion -- Completed: - - skills - - tools - - MCP - - model/provider settings - - session actions -- Remaining work: - - deepen each section into richer management views instead of simple searchable lists -- Status: in progress - -### 11b. Manual compaction trigger -- A true manual compact action is still missing. -- The runtime currently auto-compacts, but the TUI/SDK surface does not yet expose a dedicated manual compaction API. -- Do not fake this with a normal prompt command; it should be a real engine operation once exposed. -- Candidate UX later: - - Settings / Commands entry: `Compact Context` - - optional shortcut such as `ctrl+l` once the runtime hook exists -- Status: planned - -### 12. Context percentage and model capacity visibility -- Once model context capacity is reliably available in TUI state, show clear session usage such as: - - `12.4k total` - - `31% context` -- Status: planned - -## Recommended Implementation Order - -1. Mouse-first selection and copy behavior -2. Clickable tool rows and detail interactions -3. Commands/settings reorganization -4. Context-window percentage and model-capacity display - -## Notes - -- Crush remains the right reference for markdown renderer structure, mouse selection, and interaction polish. -- Nexus intentionally diverges from Crush on some visual choices, especially markdown heading presentation and chat chrome. -- `AGENTS.md` should stay focused on engineering rules; roadmap items belong in docs like this file. diff --git a/examples/france_real_estate_2026/data.json b/examples/france_real_estate_2026/data.json new file mode 100644 index 0000000..ccc5f18 --- /dev/null +++ b/examples/france_real_estate_2026/data.json @@ -0,0 +1,102 @@ +{ + "prix_moyen_par_region": { + "source": "INSEE - Données 4ème trimestre 2025", + "evolution_nationale": { + "trimestrielle": "+0,2%", + "annuelle": "+0,1%", + "periode": "T1 2026" + }, + "appartements": { + "evolution_annuelle": "+0,6%", + "evolution_trimestrielle": "+0,1%" + }, + "maisons": { + "evolution_annuelle": "-0,2%", + "evolution_trimestrielle": "stable" + }, + "regions": { + "Île-de-France": { + "evolution_trimestrielle": "positive", + "statut": "en hausse" + }, + "Province": { + "evolution_trimestrielle": "stable", + "statut": "stable" + } + } + }, + "taux_credit_immobilier": { + "source": "Banque de France - Taux d'usure Q1 2025", + "taux_moyens_pratiques": { + "pret_fixe_moins_10_ans": "3,46%", + "pret_fixe_10_20_ans": "4,35%", + "pret_fixe_plus_20_ans": "4,25%", + "pret_variable": "4,40%", + "pret_relais": "4,98%" + }, + "taux_usure": { + "pret_fixe_moins_10_ans": "4,61%", + "pret_fixe_10_20_ans": "5,80%", + "pret_fixe_plus_20_ans": "5,67%", + "pret_variable": "5,87%", + "pret_relais": "6,64%" + } + }, + "rendement_locatif_par_ville": { + "source": "DVF 2025 - Calculs sur prix réels de transaction", + "unites": "rendement brut annuel", + "villes": { + "Mulhouse": "8,6%", + "prix_moyen_m2": "1 180 €", + "profil": "haut rendement, risque élevé" + }, + "Saint-Étienne": { + "rendement": "7,8%", + "prix_moyen_m2": "1 120 €", + "profil": "haut rendement, image difficile" + }, + "Le Mans": { + "rendement": "6,8%", + "prix_moyen_m2": "non spécifié", + "profil": "équilibre rendement/risque" + }, + "Limoges": { + "rendement": "6,5%", + "prix_moyen_m2": "non spécifié", + "profil": "équilibre rendement/risque" + }, + "Toulouse": { + "rendement": "4,0%", + "prix_moyen_m2": "non spécifié", + "profil": "meilleur compromis risque/rendement" + }, + "Rennes": { + "rendement": "4,0%", + "prix_moyen_m2": "non spécifié", + "profil": "meilleur compromis risque/rendement" + }, + "Lyon": { + "rendement": "3,4%", + "prix_moyen_m2": "non spécifié", + "profil": "valeur refuge, demande solide" + }, + "Paris": { + "rendement": "3,2%", + "prix_moyen_m2": "9 870 €", + "profil": "valeur refuge, rareté foncière" + } + }, + "tendances_marche": { + "reprise_progressive": "Oui", + "transactions": "en augmentation", + "prix": "quasi stables à l'échelle nationale", + "demande_locative": "solide dans les grandes villes", + "liquidite": "variable selon les villes" + }, + "risques": { + "vacance_locative": "élevée dans les villes à haut rendement", + "depreciation_capital": "possible dans les marchés fragilisés", + "fiscalite": "réduit le rendement net de 2-3 points", + "taux_interet": "stabilisation à la baisse depuis mi-2024" + } +} \ No newline at end of file diff --git a/examples/france_real_estate_2026/report.md b/examples/france_real_estate_2026/report.md new file mode 100644 index 0000000..aafd6fe --- /dev/null +++ b/examples/france_real_estate_2026/report.md @@ -0,0 +1,275 @@ +# Étude de marché immobilier résidentiel France 2026 + +## Résumé exécutif + +Le marché immobilier français présente en 2026 des signes de reprise progressive après la période de contraction observée en 2023-2024. Au premier trimestre 2026, les prix des logements anciens affichent une hausse de +0,2% à l'échelle nationale, avec une évolution annuelle limitée à +0,1%. Cette reprise est principalement portée par l'Île-de-France, tandis que les prix en province restent stables. + +Le contexte de taux d'intérêt relativement bas (moyenne de 3,46% à 4,35% selon la durée du prêt) et la reprise des transactions créent des conditions favorables pour l'investissement immobilier. Toutefois, les écarts de rendement locatif entre les villes restent considérables, allant de 3,2% à Paris à 8,6% à Mulhouse, reflétant une segmentation profonde du marché. + +## Méthodologie + +Cette étude s'appuie sur des données officielles et reconnues pour la période allant du 4ème trimestre 2025 au 1er trimestre 2026 : + +### Sources de données +- **INSEE** : Indices des prix des logements anciens +- **Banque de France** : Taux d'usure et taux moyens pratiqués +- **Notaires de France** : Prix médians au m² et tendances du marché +- **DVF (Demande de Valeur Foncière)** : Prix réels de transaction pour le calcul des rendements + +### Période d'analyse +- Données les plus récentes disponibles : T1 2026 +- Comparaisons annuelles : T1 2025 vs T1 2026 +- Évolutions trimestrielles : T4 2025 vs T1 2026 + +### Méthodes de calcul +- **Rendement locatif brut** : (Loyer mensuel × 12) ÷ Prix d'achat +- **Prix médians** : Utilisation des médianes pour éviter l'influence des valeurs extrêmes +- **Taux d'intérêt** : Taux moyens pratiqués par les établissements de crédit + +## 1. Données collectées + +### 1.1 Prix moyens et évolution + +#### Évolution nationale (T1 2026) +| Indicateur | Trimestriel | Annuel | +|-----------|------------|--------| +| Ensemble | +0,2% | +0,1% | +| Appartements | +0,1% | +0,6% | +| Maisons | Stable | -0,2% | + +#### Évolution par zone géographique +- **Île-de-France** : Hausse portant la reprise nationale +- **Province** : Prix stables sur le trimestre + +#### Contexte historique récent +- T4 2025 : +0,3% (trimestriel), +1,0% (annuel) +- T3 2025 : -0,1% (trimestriel), +0,6% (annuel) +- Tendance : Stabilisation après la période de contraction 2023-2024 + +### 1.2 Taux de crédit immobilier + +#### Taux moyens pratiqués (Q1 2025) +| Type de prêt | Taux moyen | Taux usure | +|-------------|------------|------------| +| Prêt fixe < 10 ans | 3,46% | 4,61% | +| Prêt fixe 10-20 ans | 4,35% | 5,80% | +| Prêt fixe > 20 ans | 4,25% | 5,67% | +| Prêt variable | 4,40% | 5,87% | +| Prêt relais | 4,98% | 6,64% | + +#### Contexte des taux +- Stabilisation à la baisse depuis mi-2024 +- Amélioration mécanique des rendements locatifs +- Taux moyens historiquement bas comparés aux dernières années + +### 1.3 Rendements locatifs par ville + +#### Classement des villes par rendement brut (2026) +| Ville | Rendement brut | Prix moyen au m² | Profil | +|-------|----------------|------------------|---------| +| Mulhouse | 8,6% | 1 180 € | Haut rendement, risque élevé | +| Saint-Étienne | 7,8% | 1 120 € | Haut rendement, image difficile | +| Le Mans | 6,8% | N/A | Équilibre rendement/risque | +| Limoges | 6,5% | N/A | Équilibre rendement/risque | +| Toulouse | 4,0% | N/A | Meilleur compromis | +| Rennes | 4,0% | N/A | Meilleur compromis | +| Lyon | 3,4% | N/A | Valeur refuge | +| Paris | 3,2% | 9 870 € | Valeur refuge | + +#### Analyse des écarts +- Écart maximal : 5,4 points entre Mulhouse et Paris +- Rendement moyen : environ 5,5% pour l'ensemble des villes étudiées +- Corrélation inverse entre niveau de prix et rendement + +### 1.4 Principales tendances du marché + +#### Tendances positives +- Reprise progressive des transactions +- Stabilisation des prix à l'échelle nationale +- Baisse des taux d'intérêt depuis mi-2024 +- Demande locative solide dans les grandes villes + +#### Tendances structurelles +- Segmentation accrue entre villes +- Importance croissante de la liquidité +- Prime de risque pour les villes à haut rendement +- Valorisation des villes avec bassins d'emploi dynamiques + +## 2. Analyse + +### 2.1 Régions les plus intéressantes pour investir + +#### Catégorisation des opportunités + +**Villes à haut rendement (6,5-8,6%)** +- **Mulhouse (8,6%)** : Rendement le plus élevé, mais risque important + - Prix très bas (1 180 €/m²) + - Vacance locative élevée + - Marché peu liquide à la revente + - Profil : Investisseurs avertis acceptant le risque + +- **Saint-Étienne (7,8%)** : Haut rendement avec potentiel étudiant + - Prix abordables (1 120 €/m²) + - Présence universitaire (Jean-Monnet) + - Image difficile mais demande locative ouvrière + - Profil : Investisseurs locaux ou gestion active + +- **Le Mans (6,8%) et Limoges (6,5%)** : Équilibre optimal + - Marchés plus stables + - Risque de vacance modéré + - Liquidité correcte + - Profil : Premiers investissements ou patrimoine diversifié + +**Villes à compromis optimal (4,0%)** +- **Toulouse et Rennes** : Meilleur rapport risque/rendement + - Démographie en hausse + - Bassins d'emploi dynamiques (tech, aérospatial, pharmaceutique) + - Tension locative réelle + - Plusieurs milliers de transactions annuelles + - Liquidité élevée à la revente + - Profil : Investisseurs cherchant l'équilibre + +**Villes valeur refuge (3,2-3,4%)** +- **Paris (3,2%)** : Valeur refuge par excellence + - Prix très élevés (9 870 €/m²) + - Rareté foncière structurelle + - Liquidité maximale + - Demande locative structurellement excédentaire + - Profil : Préservation du capital à long terme + +- **Lyon (3,4%)** : Valeur refuge régionale + - Demande locative très solide + - Taux de vacance parmi les plus bas de France + - Bassin économique diversifié + - Profil : Sécurité avec rendement correct + +### 2.2 Tableaux comparatifs + +#### Tableau 1 : Comparaison des rendements et risques +| Ville | Rendement brut | Niveau de risque | Liquidité | Potentiel de croissance | Profil investisseur | +|-------|----------------|-----------------|-----------|------------------------|-------------------| +| Mulhouse | 8,6% | Élevé | Faible | Limité | Averti | +| Saint-Étienne | 7,8% | Élevé | Moyenne | Moyen | Local | +| Le Mans | 6,8% | Moyen | Bonne | Moyen | Équilibré | +| Limoges | 6,5% | Moyen | Bonne | Moyen | Équilibré | +| Toulouse | 4,0% | Faible | Très bonne | Élevé | Équilibré | +| Rennes | 4,0% | Faible | Très bonne | Élevé | Équilibré | +| Lyon | 3,4% | Très faible | Excellente | Bon | Sécurité | +| Paris | 3,2% | Très faible | Excellente | Limité | Sécurité | + +#### Tableau 2 : Analyse coûts/bénéfices +| Catégorie | Avantages | Inconvénients | Recommandation | +|-----------|-----------|---------------|----------------| +| Haut rendement | + Rendement élevé
+ Prix d'entrée bas | - Risque élevé
- Vacance importante
- Faible liquidité | Pour investisseurs expérimentés | +| Compromis optimal | + Équilibre risque/rendement
+ Bonne liquidité
+ Potentiel de croissance | - Rendement moyen | Pour la majorité des investisseurs | +| Valeur refuge | + Sécurité maximale
+ Liquidité parfaite
+ Préservation du capital | - Rendement faible
+ Prix d'entrée élevé | Pour patrimoine sécurisé | + +### 2.3 Identification des risques du marché + +#### Risques principaux +1. **Vacance locative** + - Particulièrement élevée dans les villes à haut rendement + - Impact direct sur la rentabilité nette + - Nécessité d'une gestion active + +2. **Dépréciation du capital** + - Risque dans les marchés fragilisés + - Villes avec démographie stagnante ou en déclin + - Dépendance à des employeurs locaux importants + +3. **Fiscalité** + - Réduction du rendement net de 2-3 points + - Fiscalité sur les revenus locatifs (30-45% selon tranche) + - Taxe foncière et charges non récupérables + +4. **Liquidité** + - Variable selon les villes + - Risque de ne pas pouvoir revendre rapidement + - Impact sur la flexibilité du portefeuille + +#### Risques secondaires +- **Taux d'intérêt** : Remontée possible affectant les rendements +- **Réglementation** : Évolutions fiscales ou législatives +- **Conjuncture** : Ralentissement économique affectant la demande locative +- **Inflation** : Érosion du rendement réel + +## 3. Recommandations d'investissement + +### 3.1 Stratégies par profil d'investisseur + +#### Investisseurs recherche de rendement +**Profil** : Tolérance au risque élevée, recherche de cashflow +**Villes recommandées** : Mulhouse, Saint-Étienne +**Stratégie** : +- Investissements de taille réduite pour diversifier le risque +- Gestion active des biens (sélection rigoureuse des locataires) +- Prévision d'une vacance locative de 10-15% +- Horizon d'investissement : 5-8 ans + +#### Investisseurs équilibrés +**Profil** : Recherche d'équilibre entre rendement et sécurité +**Villes recommandées** : Toulouse, Rennes, Le Mans, Limoges +**Stratégie** : +- Investissements moyens (100-200k€) +- Gestion déléguée possible +- Prévision d'une vacance locative de 5-8% +- Horizon d'investissement : 8-12 ans + +#### Investisseurs sécurité +**Profil** : Préservation du capital, tolérance au risque faible +**Villes recommandées** : Paris, Lyon +**Stratégie** : +- Investissements importants (300k€ et plus) +- Biens de qualité dans emplacements premium +- Faible vacance locative (2-4%) +- Horizon d'investissement : 15+ ans + +### 3.2 Recommandations opérationnelles + +#### Due diligence +- **Analyse approfondie du quartier** : Évolution démographique, projets urbains +- **Vérification des données DVF** : Prix réels de transaction dans le secteur +- **Étude de la demande locative** : Taux de vacance, profils des locataires +- **Calcul du rendement net** : Intégration de toutes les charges et fiscalité + +#### Gestion +- **Sélection rigoureuse des locataires** : Vérification des revenus et garanties +- **Suivi régulier du marché** : Surveillance des prix et loyers +- **Entretien préventif** : Préservation de la valeur du bien +- **Diversification géographique** : Répartition des risques + +### 3.3 Timing et opportunités + +#### Contexte actuel favorable +- Taux d'intérêt relativement bas +- Stabilisation des prix +- Reprise des transactions +- Demande locative solide + +#### Signaux de vigilance +- Remontée des taux d'intérêt +- Ralentissement économique +- Évolutions réglementaires défavorables +- Sursignaux inflationnistes + +## 4. Conclusion + +Le marché immobilier français en 2026 offre des opportunités diversifiées adaptées à différents profils d'investisseurs. La stabilisation des prix et des taux d'intérêt crée un contexte favorable pour l'investissement, mais nécessite une approche segmentée. + +**Points clés à retenir** : +1. **Segmentation profonde** : Le marché n'est pas homogène, chaque ville présente ses propres caractéristiques +2. **Prime de risque** : Les hauts rendements compensent des risques plus élevés +3. **Importance de la liquidité** : Capacité à revendre est un critère essentiel +4. **Approche personnalisée** : La stratégie dépend du profil et des objectifs de l'investisseur + +**Perspectives 2026-2027** : +- Poursuite de la stabilisation des prix +- Maintien de taux d'intérêt bas +- Consolidation de la reprise des transactions +- Renforcement de la demande locative dans les villes dynamiques + +Cette étude met en évidence qu'il n'existe pas de "meilleure" ville universelle, mais des villes adaptées à des stratégies d'investissement spécifiques. La clé du succès résidera dans l'adéquation entre le choix d'investissement et les objectifs personnels de chaque investisseur. + +--- + +**Avertissement** : Cette étude s'appuie sur des données disponibles au T1 2026. Les marchés immobiliers étant par nature cycliques et locaux, une analyse approfondie de chaque projet spécifique est indispensable avant toute décision d'investissement. Les rendements mentionnés sont bruts et doivent être ajustés en fonction de la situation fiscale personnelle de chaque investisseur. \ No newline at end of file diff --git a/examples/france_real_estate_2026/sources.md b/examples/france_real_estate_2026/sources.md new file mode 100644 index 0000000..1bfa2bb --- /dev/null +++ b/examples/france_real_estate_2026/sources.md @@ -0,0 +1,103 @@ +# Sources de l'étude de marché immobilier France 2026 + +## Sources officielles + +### INSEE (Institut National de la Statistique et des Études Économiques) +1. **"Au premier trimestre 2026, les prix des logements anciens sont en hausse (+0,2 %)"** + - URL: https://www.insee.fr/fr/statistiques/8995299 + - Date: T1 2026 + - Contenu: Évolution des prix des logements anciens en France, données par trimestre + +2. **"Au quatrième trimestre 2025, les prix des logements anciens sont en hausse (+0,5 %)"** + - URL: https://www.insee.fr/fr/statistiques/8745266 + - Date: T4 2025 + - Contenu: Tendances du marché immobilier pour le 4ème trimestre 2025 + +### Banque de France +3. **"Taux d'usure - 2025-Q1"** + - URL: https://www.banque-france.fr/fr/statistiques/taux-et-cours/taux-dusure-2025-q1 + - Date: 27 Décembre 2024 + - Contenu: Taux d'usure et taux effectifs moyens pratiqués par les établissements de crédit + +4. **"Panorama des crédits à l'habitat des ménages – Octobre 2025"** + - URL: https://www.banque-france.fr/fr/publications-et-statistiques/statistiques/panorama-des-credits-lhabitat-des-menages-octobre-2025 + - Date: 7 Octobre 2025 + - Contenu: Statistiques sur les crédits immobiliers en France + +### Notaires de France +5. **"Note de conjoncture immobilière sur le 4è trimestre 2025"** + - URL: https://contribution-www.notaires.fr/en/node/39020 + - Date: 27 Avril 2026 + - Contenu: Chiffres clés du marché immobilier, indices et cartes des prix au m² + +## Sources spécialisées + +### DVF Explorer +6. **"Investissement locatif 2026 : les villes où le rendement est le meilleur"** + - URL: https://dvfexplorer.fr/blog/investissement-locatif-2026-villes + - Date: 20 Mars 2026 + - Contenu: Comparatif des rendements locatifs dans 10 grandes villes françaises + +### Meilleurtaux +7. **"Immobilier locatif : les rendements ville par ville en 2026"** + - URL: https://www.meilleurtaux.com/credit-immobilier/actualites/2026-fevrier/immobilier-locatif-les-rendements-ville-par-ville-en-2026.html + - Date: 7 Février 2026 + - Contenu: Analyse des rendements locatifs par ville pour 2026 + +### MoneyVox +8. **"Où investir en 2026 ? Le classement des villes françaises par rendement locatif"** + - URL: https://app.moneyvox.fr/immobilier/actualites/107420/ou-investir-en-2026-le-classement-des-villes-francaises-par-rendement-locatif + - Date: Non spécifiée + - Contenu: Classement des villes françaises par rendement locatif + +### Foncia +9. **"Le rendement locatif par ville en 2026"** + - URL: https://actus.foncia.com/investissement/rendement/rendement-locatif-par-ville + - Date: Non spécifiée + - Contenu: Analyse des écarts de rendement brut entre les villes françaises + +### Autres sources +10. **"France's Residential Property Market Analysis 2026"** + - URL: https://www.globalpropertyguide.com/europe/france/price-history + - Date: Mars 2026 + - Contenu: Analyse du marché résidentiel français + +11. **"Best French Cities for Property Investment in 2025: High-Yield Opportunities"** + - URL: https://www.monchasseurimmo.com/en/real-etate-news/best-french-cities-for-property-investment-in-2025-high-yield-opportunities + - Date: 20 Décembre 2025 + - Contenu: Guide des meilleures villes pour l'investissement immobilier + +## Données utilisées + +### Données de prix +- Source principale: INSEE et Notaires de France +- Période: 4ème trimestre 2025 - 1er trimestre 2026 +- Couverture: France entière (hors Mayotte) +- Type: Prix médians au m² pour appartements et maisons anciennes + +### Données de crédit +- Source principale: Banque de France +- Période: 1er trimestre 2025 +- Couverture: France entière +- Type: Taux moyens pratiqués et taux d'usure par catégorie de prêt + +### Données de rendement locatif +- Source principale: DVF (Demande de Valeur Foncière) - données 2025 +- Couverture: 10 grandes villes françaises +- Type: Rendements bruts calculés sur prix réels de transaction +- Méthodologie: Rendement brut = (loyer mensuel × 12) ÷ prix d'achat + +## Limites des données + +1. **Données régionales**: Informations limitées sur les prix par région spécifique +2. **Période d'analyse**: Données principalement concentrées sur 2025 et début 2026 +3. **Couverture géographique**: Rendements locatifs disponibles uniquement pour les grandes villes +4. **Données manquantes**: Prix moyens au m² non disponibles pour toutes les villes étudiées +5. **Rendements nets**: Calculs basés sur des rendements bruts, les rendements nets varient selon la fiscalité personnelle + +## Méthodologie + +- **Prix**: Utilisation des prix médians pour éviter l'influence des valeurs extrêmes +- **Rendements**: Calculs basés sur les prix de transaction réels (DVF) et non sur les prix d'annonce +- **Taux**: Taux moyens pratiqués issus de la Banque de France +- **Période**: Données les plus récentes disponibles au moment de l'étude (T1 2026) \ No newline at end of file diff --git a/examples/france_real_estate_2026/summary.txt b/examples/france_real_estate_2026/summary.txt new file mode 100644 index 0000000..7dab3db --- /dev/null +++ b/examples/france_real_estate_2026/summary.txt @@ -0,0 +1,74 @@ +ÉTUDE DE MARCHÉ IMMOBILIER RÉSIDENTIEL FRANCE 2026 +==================================================== + +RÉSUMÉ EXÉCUTIF +----------------- +Le marché immobilier français montre des signes de reprise progressive en 2026, avec des prix quasi stables à l'échelle nationale (+0,1% sur un an au T1 2026). La reprise est portée par l'Île-de-France, tandis que les prix en province restent stables. + +POINTS CLÉS +----------- +• Prix nationaux : +0,2% au T1 2026 (trimestriel), +0,1% sur un an +• Appartements : +0,6% sur un an, Maisons : -0,2% sur un an +• Taux crédit moyen : 3,46% à 4,35% selon la durée +• Rendements locatifs : de 3,2% (Paris) à 8,6% (Mulhouse) + +RÉGIONS LES PLUS INTÉRESSANTES +------------------------------- +1. VILLES À HAUT RENDEMENT (6,5-8,6%) + - Mulhouse (8,6%) - Haut rendement, risque élevé + - Saint-Étienne (7,8%) - Haut rendement, image difficile + - Le Mans (6,8%) - Bon équilibre rendement/risque + - Limoges (6,5%) - Bon équilibre rendement/risque + +2. VILLES À COMPROMIS OPTIMAL (4,0%) + - Toulouse - Meilleur rapport risque/rendement + - Rennes - Meilleur rapport risque/rendement + • Démographie en hausse + • Bassins d'emploi dynamiques + • Tension locative réelle + +3. VILLES VALEUR REFUGE (3,2-3,4%) + - Paris (3,2%) - Rareté foncière, liquidité élevée + - Lyon (3,4%) - Demande locative très solide + +RECOMMANDATIONS D'INVESTISSEMENT +--------------------------------- +• INVESTISSEURS RENDEMENT : Mulhouse, Saint-Étienne + - Accepter le risque pour un rendement élevé + - Gestion active nécessaire + +• INVESTISSEURS ÉQUILIBRÉS : Toulouse, Rennes + - Meilleur compromis prix/croissance/rendement + - Marchés liquides et dynamiques + +• INVESTISSEURS SÉCURITÉ : Paris, Lyon + - Préservation du capital à long terme + - Rendement faible mais sécurité élevée + +PRINCIPAUX RISQUES +------------------ +• Vacance locative élevée dans les villes à haut rendement +• Dépréciation possible du capital dans les marchés fragilisés +• Fiscalité réduit le rendement net de 2-3 points +• Liquidité variable selon les villes + +TENDANCES 2026 +--------------- +• Reprise progressive des transactions +• Stabilisation des prix à l'échelle nationale +• Baisse des taux d'intérêt depuis mi-2024 +• Demande locative solide dans les grandes villes + +DONNÉES MANQUANTES +------------------ +• Prix détaillés par région +• Rendements pour les villes moyennes +• Évolutions démographiques récentes +• Données sur les nouveaux programmes immobiliers + +MÉTHODOLOGIE +------------ +Données INSEE, Banque de France et Notaires pour T1 2026. +Rendements calculés sur prix réels de transaction (DVF). + +Pour plus de détails, consulter report.md, data.json et sources.md. \ No newline at end of file diff --git a/go.mod b/go.mod index da633cb..24cb2df 100644 --- a/go.mod +++ b/go.mod @@ -72,8 +72,10 @@ require ( github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/chewxy/math32 v1.10.1 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/coder/hnsw v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -87,6 +89,7 @@ require ( github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect + github.com/google/renameio v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect @@ -123,6 +126,8 @@ require ( github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect + github.com/viterin/partial v1.1.0 // indirect + github.com/viterin/vek v0.4.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/ysmood/fetchup v0.2.3 // indirect github.com/ysmood/goob v0.4.0 // indirect diff --git a/go.sum b/go.sum index 413b444..c526c7d 100644 --- a/go.sum +++ b/go.sum @@ -86,10 +86,14 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/chewxy/math32 v1.10.1 h1:LFpeY0SLJXeaiej/eIp2L40VYfscTvKh/FSEZ68uMkU= +github.com/chewxy/math32 v1.10.1/go.mod h1:dOB2rcuFrCn6UHrze36WSLVPKtzPMRAQvBvUwkSsLqs= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/coder/hnsw v0.6.1 h1:Dv76pjiFkgMYFqnTCOehJXd06irm2PRwcP/jMMPCyO0= +github.com/coder/hnsw v0.6.1/go.mod h1:wvRc/vZNkK50HFcagwnc/ep/u29Mg2uLlPmc8SD7eEQ= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -138,6 +142,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/renameio v1.0.1 h1:Lh/jXZmvZxb0BBeSY5VKEfidcbcbenKjZFzM/q0fSeU= +github.com/google/renameio v1.0.1/go.mod h1:t/HQoYBZSsWSNK35C6CO/TpPLDVWvxOHboWUAweKUpk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -252,6 +258,10 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= +github.com/viterin/partial v1.1.0 h1:iH1l1xqBlapXsYzADS1dcbizg3iQUKTU1rbwkHv/80E= +github.com/viterin/partial v1.1.0/go.mod h1:oKGAo7/wylWkJTLrWX8n+f4aDPtQMQ6VG4dd2qur5QA= +github.com/viterin/vek v0.4.2 h1:Vyv04UjQT6gcjEFX82AS9ocgNbAJqsHviheIBdPlv5U= +github.com/viterin/vek v0.4.2/go.mod h1:A4JRAe8OvbhdzBL5ofzjBS0J29FyUrf95tQogvtHHUc= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= diff --git a/grpc b/grpc new file mode 100755 index 0000000..eaed960 Binary files /dev/null and b/grpc differ diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 46541bb..68227d2 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -246,6 +246,32 @@ func TestAsyncAgentManager_AgentCancellation(t *testing.T) { assert.Greater(t, len(events), 0, "Should receive at least some events") } +func TestAsyncAgentManager_CloseAllAgentsRemovesTrackedAgents(t *testing.T) { + manager := NewAsyncAgentManager() + defer manager.Shutdown() + + configs := []*RunConfig{ + {AgentType: AgentTypeExplore, Task: "Long-running task A", MaxTurns: 10, Context: context.Background()}, + {AgentType: AgentTypeExplore, Task: "Long-running task B", MaxTurns: 10, Context: context.Background()}, + } + + agents := make([]*AsyncAgent, 0, len(configs)) + for _, cfg := range configs { + agent, err := manager.StartAgent(cfg) + require.NoError(t, err) + agents = append(agents, agent) + } + + closed := manager.CloseAllAgents() + assert.Equal(t, len(agents), closed) + assert.Empty(t, manager.ListAgents()) + + for _, agent := range agents { + agent.Wait() + assert.True(t, agent.IsComplete()) + } +} + // TestAsyncAgentManager_ConcurrentAgents tests multiple concurrent agents func TestAsyncAgentManager_ConcurrentAgents(t *testing.T) { manager := NewAsyncAgentManager() diff --git a/internal/agent/async.go b/internal/agent/async.go index 7a8fc96..4f9c715 100644 --- a/internal/agent/async.go +++ b/internal/agent/async.go @@ -7,6 +7,8 @@ import ( "reflect" "sync" "time" + + "github.com/EngineerProjects/nexus-engine/internal/types" ) // --------------------------------------------------------------------------- @@ -110,6 +112,10 @@ type AsyncAgent struct { // Mirrors Codex's agent_role in CollabAgentRef. Role string + // SessionID is the engine session ID used by this agent's run. Set after + // the run completes and exposes the session for resumption via resume_agent. + SessionID types.SessionID + // Current status Status AgentStatus @@ -375,6 +381,11 @@ func (m *AsyncAgentManager) runAgent(agent *AsyncAgent) { // Create a config with progress callback and message injection. config := *agent.Config + // Replace the caller-supplied context (typically the parent session's turn + // context, which gets canceled when that turn ends) with the async agent's + // own independent context. Without this, the sub-agent's API calls and + // permission prompts fail as soon as the parent turn finishes. + config.Context = agent.Ctx // Wire ContinuationMessage to drain pending inter-agent messages. // If no message is queued, fall back to the existing callback or the default. @@ -389,25 +400,23 @@ func (m *AsyncAgentManager) runAgent(agent *AsyncAgent) { return "" // runner uses its own default } - config.Callback = func(turn int, output string) { + config.Callback = func(turn int, output string, toolUses int) { agent.progressMu.Lock() agent.CurrentTurn = turn agent.CurrentOutput = output + agent.ToolUses = toolUses - // Calculate progress percentage maxTurns := config.MaxTurns if maxTurns == 0 { maxTurns = DefaultMaxTurns } percentComplete := float64(turn) / float64(maxTurns) * 100 - agent.progressMu.Unlock() - // Send progress event progress := &AgentProgress{ CurrentTurn: turn, MaxTurns: maxTurns, - ToolUses: agent.ToolUses, + ToolUses: toolUses, Output: output, PercentComplete: percentComplete, } @@ -417,6 +426,18 @@ func (m *AsyncAgentManager) runAgent(agent *AsyncAgent) { // Run the agent result, err := RunAgent(&config) + // Sync final counts from result so GetProgress() is accurate after completion. + if result != nil { + agent.progressMu.Lock() + agent.ToolUses = result.ToolUses + agent.progressMu.Unlock() + if result.SessionID != "" { + agent.stateMu.Lock() + agent.SessionID = result.SessionID + agent.stateMu.Unlock() + } + } + endTime := time.Now() agent.stateMu.Lock() agent.EndTime = endTime @@ -438,6 +459,11 @@ func (m *AsyncAgentManager) runAgent(agent *AsyncAgent) { if finalStatus == AgentStatusCompleted { m.emitEvent(agent, AgentEventCompleted, finalResult, nil, nil) } else { + slog.Error("async agent failed", + "agent_id", agent.ID, + "agent_type", agent.Config.AgentType, + "error", finalErr, + ) m.emitEvent(agent, AgentEventFailed, nil, nil, finalErr) } } @@ -535,6 +561,10 @@ func (m *AsyncAgentManager) Shutdown() { // goroutines transition out of AgentStatusRunning. m.agentsWg.Wait() + // Release memory held by completed/failed/cancelled agents now that all + // goroutines are done and no new ones can start (shutdown=true). + m.Cleanup() + // Stop event dispatcher and wait for event workers. m.dispatcherCancel() m.workersWg.Wait() @@ -593,6 +623,24 @@ func (m *AsyncAgentManager) CloseAgent(agentID string) error { return nil } +// CloseAllAgents terminates every tracked async agent and removes them from the registry. +func (m *AsyncAgentManager) CloseAllAgents() int { + m.agentsMu.RLock() + ids := make([]string, 0, len(m.agents)) + for id := range m.agents { + ids = append(ids, id) + } + m.agentsMu.RUnlock() + + closed := 0 + for _, id := range ids { + if err := m.CloseAgent(id); err == nil { + closed++ + } + } + return closed +} + // emitEvent emits an event for an agent func (m *AsyncAgentManager) emitEvent(agent *AsyncAgent, eventType AgentEventType, result *RunResult, progress *AgentProgress, err error) { // Snapshot volatile state under stateMu to avoid data races. diff --git a/internal/agent/runner.go b/internal/agent/runner.go index a7c4ac7..e8fcbae 100644 --- a/internal/agent/runner.go +++ b/internal/agent/runner.go @@ -14,6 +14,12 @@ import ( ) // RunResult is the result of running an agent +// SourceRef records a resource consulted by the sub-agent during execution. +type SourceRef struct { + Type string `json:"type"` // "file", "url", "search", "glob", "grep" + Value string `json:"value"` // path, URL, or query string +} + type RunResult struct { // AgentType is the type of agent AgentType string `json:"agentType"` @@ -30,9 +36,18 @@ type RunResult struct { // ToolUses is the number of tool uses ToolUses int `json:"toolUses"` + // Sources lists every file, URL, and search query the agent consulted. + // Collected automatically from tool calls — the parent receives this + // without relying on the sub-agent to format it in its output. + Sources []SourceRef `json:"sources,omitempty"` + // WorktreePath is the worktree directory (for isolation mode) WorktreePath string `json:"worktreePath,omitempty"` + // SessionID is the engine session ID for this run. Callers can pass it to + // RunConfig.ResumeFromSessionID to continue from where this run left off. + SessionID types.SessionID `json:"session_id,omitempty"` + // Error is the error if failed Error string `json:"error,omitempty"` } @@ -73,8 +88,8 @@ type RunConfig struct { // Context is the parent context Context context.Context - // Callback is called on each turn completion - Callback func(turn int, output string) + // Callback is called on each turn completion with cumulative tool use count. + Callback func(turn int, output string, toolUses int) // ForkFromMessages is message context inherited from parent ForkFromMessages []types.Message @@ -110,6 +125,16 @@ type RunConfig struct { // GoalSessionID is the key used to look up the goal in GoalStore. // Usually set to types.ToolContext.SessionID by the caller. GoalSessionID string + + // PermissionMode, when non-empty, overrides the session's permission mode. + // Set to types.PermissionModeBypass for headless background agents so they + // can execute tools without waiting for interactive prompts. + PermissionMode types.PermissionMode + + // ResumeFromSessionID, when set, opens an existing persisted session instead + // of creating a new one. The first message sent to the resumed session is + // config.Task, continuing where the previous run left off. + ResumeFromSessionID types.SessionID } // RunAgent runs an agent and returns the result @@ -159,28 +184,44 @@ func RunAgent(config *RunConfig) (*RunResult, error) { ctx = context.Background() } - // Create session + // Create or restore session. var session *engine.Session var err error - subagentMetadata := &types.SessionMetadata{ - Status: types.SessionStatusActive, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Additional: map[string]any{"tool_surface_profile": tool.ToolSurfaceProfileSubagent}, - SchemaVersion: types.SessionMetadataSchemaVersion, - } - if len(config.ForkFromMessages) > 0 { - inheritedMessages := append([]types.Message(nil), config.ForkFromMessages...) - session, err = runtimeEngine.NewSessionFromState(ctx, "", subagentMetadata, inheritedMessages) + if config.ResumeFromSessionID != "" { + session, err = runtimeEngine.OpenSession(ctx, config.ResumeFromSessionID) + if err != nil { + return &RunResult{ + AgentType: config.AgentType, + Success: false, + Error: fmt.Sprintf("failed to restore session %s: %v", config.ResumeFromSessionID, err), + }, nil + } } else { - session, err = runtimeEngine.NewSessionFromState(ctx, "", subagentMetadata, nil) + subagentMetadata := &types.SessionMetadata{ + Status: types.SessionStatusActive, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Additional: map[string]any{"tool_surface_profile": tool.ToolSurfaceProfileSubagent}, + SchemaVersion: types.SessionMetadataSchemaVersion, + } + if len(config.ForkFromMessages) > 0 { + inheritedMessages := append([]types.Message(nil), config.ForkFromMessages...) + session, err = runtimeEngine.NewSessionFromState(ctx, "", subagentMetadata, inheritedMessages) + } else { + session, err = runtimeEngine.NewSessionFromState(ctx, "", subagentMetadata, nil) + } + if err != nil { + return &RunResult{ + AgentType: config.AgentType, + Success: false, + Error: fmt.Sprintf("failed to create session: %v", err), + }, nil + } } - if err != nil { - return &RunResult{ - AgentType: config.AgentType, - Success: false, - Error: fmt.Sprintf("failed to create session: %v", err), - }, nil + + // Apply permission mode override (e.g. bypass for headless background agents). + if config.PermissionMode != "" { + session.SetPermissionMode(config.PermissionMode) } // Give the agent its own system prompt identity so it does not inherit the @@ -254,6 +295,8 @@ func RunAgent(config *RunConfig) (*RunResult, error) { var lastOutput strings.Builder turns := 0 totalToolUses := 0 + var sources []SourceRef + seenSources := make(map[string]bool) for turn := 1; turn <= maxTurns; turn++ { var msg string @@ -272,12 +315,25 @@ func RunAgent(config *RunConfig) (*RunResult, error) { Output: lastOutput.String(), Turns: turns, ToolUses: totalToolUses, + Sources: sources, + SessionID: session.GetSessionID(), Error: err.Error(), }, nil } turns++ totalToolUses += len(resp.GetLastToolResults()) + // Collect sources from every tool call in this turn. + for _, tu := range resp.ToolUses { + for _, ref := range extractSources(tu) { + key := ref.Type + ":" + ref.Value + if !seenSources[key] { + seenSources[key] = true + sources = append(sources, ref) + } + } + } + // Collect this turn's output. lastOutput.Reset() if lastMsg, hasMsg := resp.GetLastAssistantMessage(); hasMsg { @@ -289,7 +345,7 @@ func RunAgent(config *RunConfig) (*RunResult, error) { } if config.Callback != nil { - config.Callback(turn, lastOutput.String()) + config.Callback(turn, lastOutput.String(), totalToolUses) } // Check stop condition; if true the agent considers itself done. @@ -311,6 +367,8 @@ func RunAgent(config *RunConfig) (*RunResult, error) { Output: lastOutput.String(), Turns: turns, ToolUses: totalToolUses, + Sources: sources, + SessionID: session.GetSessionID(), Error: ctx.Err().Error(), }, nil default: @@ -323,9 +381,55 @@ func RunAgent(config *RunConfig) (*RunResult, error) { Output: lastOutput.String(), Turns: turns, ToolUses: totalToolUses, + Sources: sources, + SessionID: session.GetSessionID(), }, nil } +// extractSources inspects a single tool call and returns any source references +// (files read, URLs fetched, search queries). Deduplication happens in the caller. +func extractSources(tu types.ToolUseContent) []SourceRef { + str := func(key string) string { + if v, ok := tu.Input[key].(string); ok { + return strings.TrimSpace(v) + } + return "" + } + + switch tu.Name { + case "read_file", "write_file", "edit_file": + if p := str("path"); p != "" { + return []SourceRef{{Type: "file", Value: p}} + } + case "glob": + if p := str("pattern"); p != "" { + return []SourceRef{{Type: "glob", Value: p}} + } + case "grep": + refs := []SourceRef{} + if p := str("pattern"); p != "" { + refs = append(refs, SourceRef{Type: "grep", Value: p}) + } + if p := str("path"); p != "" { + refs = append(refs, SourceRef{Type: "file", Value: p}) + } + return refs + case "web_search", "wikipedia", "scholarly_search", "langsearch": + if q := str("query"); q != "" { + return []SourceRef{{Type: "search", Value: q}} + } + case "web_fetch", "web_crawl", "web_map": + if u := str("url"); u != "" { + return []SourceRef{{Type: "url", Value: u}} + } + case "browser_navigate", "browser_open": + if u := str("url"); u != "" { + return []SourceRef{{Type: "url", Value: u}} + } + } + return nil +} + // RunForkedAgent runs an agent in fork mode (like OpenClaude's forkSubagent) func RunForkedAgent(config *RunConfig) (*RunResult, error) { return RunAgent(config) diff --git a/internal/auth/loader.go b/internal/auth/loader.go index 4d0a997..b3e0ffc 100644 --- a/internal/auth/loader.go +++ b/internal/auth/loader.go @@ -3,10 +3,10 @@ package auth import ( "context" "os" - "path/filepath" "github.com/EngineerProjects/nexus-engine/internal/auth/store" "github.com/EngineerProjects/nexus-engine/internal/auth/types" + "github.com/EngineerProjects/nexus-engine/pkg/runtimepath" ) // ============================================================================ @@ -150,6 +150,5 @@ func DefaultConfigPath() string { return path } - homeDir, _ := os.UserHomeDir() - return filepath.Join(homeDir, ".nexus", "auth.json") + return runtimepath.Join("", "auth.json") } diff --git a/internal/db/credentials.go b/internal/db/credentials.go index cce0936..26f11a5 100644 --- a/internal/db/credentials.go +++ b/internal/db/credentials.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" + "github.com/EngineerProjects/nexus-engine/pkg/runtimepath" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -89,19 +90,32 @@ func (db *DB) ListCredentialKeys(ctx context.Context) ([]string, error) { // ─── Encryption helpers ──────────────────────────────────────────────────────── -const keyFile = ".nexus_secret" - -// loadOrCreateEncryptionKey returns the 32-byte AES key from ~/.nexus_secret, -// creating and writing it with mode 0600 on first use. +// loadOrCreateEncryptionKey returns the 32-byte AES key from +// ~/.config/nexus/secret.key, creating and writing it with mode 0600 on first use. +// Falls back to migrating the legacy ~/.nexus_secret key on first run. func loadOrCreateEncryptionKey() ([]byte, error) { path, err := encryptionKeyPath() if err != nil { return nil, err } + data, err := os.ReadFile(path) if err == nil && len(data) == 32 { return data, nil } + + // Migrate legacy key from ~/.nexus_secret if it exists. + if home, herr := os.UserHomeDir(); herr == nil { + legacy := filepath.Join(home, ".nexus_secret") + if legacyData, lerr := os.ReadFile(legacy); lerr == nil && len(legacyData) == 32 { + if merr := os.MkdirAll(filepath.Dir(path), 0o700); merr == nil { + if werr := os.WriteFile(path, legacyData, 0o600); werr == nil { + return legacyData, nil + } + } + } + } + // Generate a new key. key := make([]byte, 32) if _, err := io.ReadFull(rand.Reader, key); err != nil { @@ -117,11 +131,7 @@ func loadOrCreateEncryptionKey() ([]byte, error) { } func encryptionKeyPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) - } - return filepath.Join(home, keyFile), nil + return runtimepath.Join("", "secret.key"), nil } // encryptAESGCM encrypts plaintext with AES-256-GCM and returns a base64 string diff --git a/internal/db/db.go b/internal/db/db.go index 07c30a3..75c1e7e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -116,6 +116,7 @@ func (db *DB) Ping(ctx context.Context) error { } // Close closes the underlying database handle. +// For SQLite, PRAGMA optimize is run first to update the query planner statistics. func (db *DB) Close() error { if db == nil || db.gormDB == nil { return nil @@ -124,6 +125,9 @@ func (db *DB) Close() error { if err != nil { return err } + if db.driver == DriverSQLite { + _, _ = sqlDB.Exec("PRAGMA optimize") + } return sqlDB.Close() } @@ -142,6 +146,10 @@ func (db *DB) configure(ctx context.Context, cfg Config) error { "PRAGMA foreign_keys = ON", "PRAGMA journal_mode = WAL", "PRAGMA synchronous = NORMAL", + "PRAGMA cache_size = -20000", // 20 MB page cache (default ~2 MB) + "PRAGMA mmap_size = 134217728", // 128 MB memory-mapped I/O + "PRAGMA temp_store = MEMORY", // temp tables in RAM, never disk + "PRAGMA wal_autocheckpoint = 1000", // checkpoint every 1 000 WAL pages } if cfg.BusyTimeoutMS > 0 { pragmaStatements = append(pragmaStatements, fmt.Sprintf("PRAGMA busy_timeout = %d", cfg.BusyTimeoutMS)) diff --git a/internal/db/mailbox.go b/internal/db/mailbox.go index d4f11e1..7fed2e2 100644 --- a/internal/db/mailbox.go +++ b/internal/db/mailbox.go @@ -3,6 +3,7 @@ package db import ( "context" "errors" + "fmt" "time" "gorm.io/gorm" @@ -97,19 +98,23 @@ func (db *DB) GetMessageThread(ctx context.Context, rootID string) ([]GMailboxMe // GetTeamAgents returns the distinct to_agent values that have received // messages tagged with teamID. Used for broadcast expansion. func (db *DB) GetTeamAgents(ctx context.Context, teamID string) ([]string, error) { - var rows []GMailboxMessage - err := db.gormDB.WithContext(ctx). - Select("DISTINCT to_agent"). - Where("team_id = ?", teamID). - Find(&rows).Error + rows, err := db.SQL().QueryContext(ctx, + `SELECT DISTINCT to_agent FROM mailbox_messages WHERE team_id = ? ORDER BY to_agent`, + teamID, + ) if err != nil { - return nil, err + return nil, fmt.Errorf("get team agents: %w", err) } - agents := make([]string, 0, len(rows)) - for _, r := range rows { - agents = append(agents, r.ToAgent) + defer rows.Close() + var agents []string + for rows.Next() { + var agent string + if err := rows.Scan(&agent); err != nil { + return nil, fmt.Errorf("scan team agent: %w", err) + } + agents = append(agents, agent) } - return agents, nil + return agents, rows.Err() } // DeleteMessage removes a message record permanently. diff --git a/internal/db/migrations_sqlite_core.go b/internal/db/migrations_sqlite_core.go index a2268ad..3512d41 100644 --- a/internal/db/migrations_sqlite_core.go +++ b/internal/db/migrations_sqlite_core.go @@ -1,6 +1,9 @@ package db -import "context" +import ( + "context" + "log" +) func sqliteCoreMigrations() []schemaMigration { return []schemaMigration{ @@ -37,6 +40,29 @@ func sqliteCoreMigrations() []schemaMigration { Scope: migrationScopeCoreSQLite, Run: migrateSQLiteMailboxMessages, }, + { + ID: "20260607_007_session_files", + Scope: migrationScopeCoreSQLite, + Run: migrateSQLiteSessionFiles, + }, + { + // Unique constraint on session_files.tool_use_id prevents duplicate records + // from concurrent live-recording and backfill goroutines. + // Composite indexes on mailbox_messages cover the two hottest query shapes: + // GetUnreadMessages (to_agent + read_at IS NULL + created_at ASC) + // GetMessageHistory (to_agent + created_at DESC) + ID: "20260607_008_indexes_and_constraints", + Scope: migrationScopeCoreSQLite, + Run: migrateSQLiteIndexesAndConstraints, + }, + { + // FTS5 virtual table for O(log n) transcript full-text search. + // INSERT/DELETE triggers keep it in sync with session_transcript_entries, + // including rows removed by ON DELETE CASCADE from session_metadata. + ID: "20260607_009_transcript_fts5", + Scope: migrationScopeCoreSQLite, + Run: migrateSQLiteTranscriptFTS5, + }, } } @@ -95,9 +121,9 @@ func migrateSQLiteVectorFTS5(ctx context.Context, db *DB) error { } for _, stmt := range statements { if err := db.gormDB.WithContext(ctx).Exec(stmt).Error; err != nil { - // FTS5 is an enhancement; a failure here must not block startup. - // Log and continue rather than returning an error. - _ = err + // FTS5 is an enhancement; a failure must not block startup. + // Hybrid search degrades to LIKE scan when FTS5 is unavailable. + log.Printf("[db] fts5 vector migration warning (hybrid search may be degraded): %v", err) } } return nil @@ -135,3 +161,98 @@ func migrateSQLiteAgentProfiles(ctx context.Context, db *DB) error { func migrateSQLiteMailboxMessages(ctx context.Context, db *DB) error { return db.gormDB.WithContext(ctx).AutoMigrate(&GMailboxMessage{}) } + +func migrateSQLiteIndexesAndConstraints(ctx context.Context, db *DB) error { + statements := []string{ + // Deduplicate any existing session_files rows before adding the unique index + // (duplicates can occur if a live-recording goroutine races with backfill). + `DELETE FROM session_files + WHERE id NOT IN ( + SELECT MIN(id) FROM session_files + WHERE tool_use_id != '' + GROUP BY tool_use_id + ) AND tool_use_id != ''`, + // One row per tool_use_id (skips rows where tool_use_id is empty). + `CREATE UNIQUE INDEX IF NOT EXISTS idx_session_files_tool_use_unique + ON session_files(tool_use_id) WHERE tool_use_id != ''`, + // Partial covering index: to_agent + created_at on unread messages only. + // Covers WHERE to_agent = ? AND read_at IS NULL ORDER BY created_at ASC. + `CREATE INDEX IF NOT EXISTS idx_mailbox_to_agent_unread + ON mailbox_messages(to_agent, created_at ASC) WHERE read_at IS NULL`, + // Covering index for GetMessageHistory: to_agent + newest-first ordering. + `CREATE INDEX IF NOT EXISTS idx_mailbox_to_agent_history + ON mailbox_messages(to_agent, created_at DESC)`, + } + for _, stmt := range statements { + if err := db.gormDB.WithContext(ctx).Exec(stmt).Error; err != nil { + return err + } + } + return nil +} + +func migrateSQLiteTranscriptFTS5(ctx context.Context, db *DB) error { + statements := []string{ + // FTS5 index over raw transcript JSON — enables MATCH queries replacing LIKE full scans. + // session_id is stored but not tokenized (used only for grouping in DISTINCT queries). + `CREATE VIRTUAL TABLE IF NOT EXISTS session_transcript_fts USING fts5( + session_id UNINDEXED, + entry_json, + tokenize = 'unicode61 remove_diacritics 1' + )`, + // Backfill from any existing transcript entries. + `INSERT OR IGNORE INTO session_transcript_fts(rowid, session_id, entry_json) + SELECT rowid, session_id, entry_json FROM session_transcript_entries`, + // Trigger: keep FTS5 in sync on insert. + `CREATE TRIGGER IF NOT EXISTS trg_transcript_fts_insert + AFTER INSERT ON session_transcript_entries BEGIN + INSERT OR REPLACE INTO session_transcript_fts(rowid, session_id, entry_json) + VALUES (new.rowid, new.session_id, new.entry_json); + END`, + // Trigger: keep FTS5 in sync on delete (also fires for ON DELETE CASCADE rows). + `CREATE TRIGGER IF NOT EXISTS trg_transcript_fts_delete + AFTER DELETE ON session_transcript_entries BEGIN + INSERT INTO session_transcript_fts(session_transcript_fts, rowid) + VALUES ('delete', old.rowid); + END`, + } + for _, stmt := range statements { + if err := db.gormDB.WithContext(ctx).Exec(stmt).Error; err != nil { + // FTS5 is an enhancement; a failure must not block startup. + // Transcript full-text search degrades to LIKE scan when FTS5 is unavailable. + log.Printf("[db] fts5 transcript migration warning (search may be degraded): %v", err) + } + } + return nil +} + +func migrateSQLiteSessionFiles(ctx context.Context, db *DB) error { + statements := []string{ + // tool_use_id links back to the ToolResultContent in session_transcript_entries + // so the full metadata (structured_patch, git_diff, content, …) can be + // retrieved without scanning the entire transcript JSON. + `CREATE TABLE IF NOT EXISTS session_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + tool_use_id TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + operation TEXT NOT NULL, + timestamp_unix INTEGER NOT NULL, + lines_added INTEGER NOT NULL DEFAULT 0, + lines_removed INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_session_files_session + ON session_files(session_id, timestamp_unix)`, + `CREATE INDEX IF NOT EXISTS idx_session_files_path + ON session_files(file_path)`, + `CREATE INDEX IF NOT EXISTS idx_session_files_tool_use + ON session_files(tool_use_id)`, + } + for _, stmt := range statements { + if err := db.gormDB.WithContext(ctx).Exec(stmt).Error; err != nil { + return err + } + } + return nil +} diff --git a/internal/db/session_files.go b/internal/db/session_files.go new file mode 100644 index 0000000..6aaf5a8 --- /dev/null +++ b/internal/db/session_files.go @@ -0,0 +1,115 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// SessionFile represents one file operation recorded against a session. +// ToolUseID is the tool_use_id from the transcript — use it to look up the +// full ToolResultContent.Metadata (structured_patch, git_diff, content, …) +// in session_transcript_entries without scanning the whole transcript. +type SessionFile struct { + ID int64 + SessionID string + ToolUseID string // foreign key into session_transcript_entries JSON + FilePath string + Operation string // "create" | "update" | "edit" | "patch" + TimestampUnix int64 + LinesAdded int + LinesRemoved int +} + +// UpsertSessionFile records a file operation for a session. +// When tool_use_id is non-empty, INSERT OR IGNORE silently skips duplicates +// (the unique index on tool_use_id WHERE tool_use_id != ” enforces this). +func (db *DB) UpsertSessionFile(ctx context.Context, sf SessionFile) error { + if sf.TimestampUnix == 0 { + sf.TimestampUnix = time.Now().Unix() + } + _, err := db.SQL().ExecContext(ctx, ` + INSERT OR IGNORE INTO session_files + (session_id, tool_use_id, file_path, operation, timestamp_unix, lines_added, lines_removed) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + sf.SessionID, sf.ToolUseID, sf.FilePath, sf.Operation, + sf.TimestampUnix, sf.LinesAdded, sf.LinesRemoved, + ) + if err != nil { + return fmt.Errorf("upsert session file: %w", err) + } + return nil +} + +// GetSessionFiles returns all file operations recorded for a session, +// ordered by timestamp ascending. +func (db *DB) GetSessionFiles(ctx context.Context, sessionID string) ([]SessionFile, error) { + rows, err := db.SQL().QueryContext(ctx, ` + SELECT id, session_id, tool_use_id, file_path, operation, timestamp_unix, lines_added, lines_removed + FROM session_files + WHERE session_id = ? + ORDER BY timestamp_unix ASC, id ASC`, + sessionID, + ) + if err != nil { + return nil, fmt.Errorf("get session files: %w", err) + } + defer rows.Close() + + var files []SessionFile + for rows.Next() { + var f SessionFile + if err := rows.Scan( + &f.ID, &f.SessionID, &f.ToolUseID, &f.FilePath, &f.Operation, + &f.TimestampUnix, &f.LinesAdded, &f.LinesRemoved, + ); err != nil { + return nil, fmt.Errorf("scan session file: %w", err) + } + files = append(files, f) + } + return files, rows.Err() +} + +// GetFileSessions returns all sessions that touched a given file path, +// ordered by most recent first. +func (db *DB) GetFileSessions(ctx context.Context, filePath string) ([]SessionFile, error) { + rows, err := db.SQL().QueryContext(ctx, ` + SELECT id, session_id, tool_use_id, file_path, operation, timestamp_unix, lines_added, lines_removed + FROM session_files + WHERE file_path = ? + ORDER BY timestamp_unix DESC, id DESC`, + filePath, + ) + if err != nil { + return nil, fmt.Errorf("get file sessions: %w", err) + } + defer rows.Close() + + var files []SessionFile + for rows.Next() { + var f SessionFile + if err := rows.Scan( + &f.ID, &f.SessionID, &f.ToolUseID, &f.FilePath, &f.Operation, + &f.TimestampUnix, &f.LinesAdded, &f.LinesRemoved, + ); err != nil { + return nil, fmt.Errorf("scan file session: %w", err) + } + files = append(files, f) + } + return files, rows.Err() +} + +// HasSessionFileEntry returns true if session_files already has at least one +// row for the given session. Used to detect whether backfill is needed. +func (db *DB) HasSessionFileEntry(ctx context.Context, sessionID string) (bool, error) { + var exists bool + err := db.SQL().QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM session_files WHERE session_id = ? LIMIT 1)`, + sessionID, + ).Scan(&exists) + if err != nil && err != sql.ErrNoRows { + return false, err + } + return exists, nil +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 049365c..046c41e 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -1,6 +1,8 @@ package engine import ( + "context" + "fmt" "os" "github.com/EngineerProjects/nexus-engine/internal/execution" @@ -21,6 +23,13 @@ type SessionStore interface { SaveSessionState(sessionID types.SessionID, metadata *types.SessionMetadata, previousMessages []types.Message, currentMessages []types.Message) error } +// sessionRestorer is an optional capability of a SessionStore implementation +// that supports loading a previously persisted session back into memory. +// state.Store satisfies this interface; a nil or stub store does not. +type sessionRestorer interface { + RestoreSessionState(sessionID types.SessionID) (*types.SessionMetadata, []types.Message, error) +} + // Engine orchestrates query sessions. type Engine struct { apiClient *providers.Client @@ -132,6 +141,21 @@ func (e *Engine) SetAPIClient(apiClient *providers.Client) { } } +// OpenSession loads a previously persisted session by ID so it can receive new +// turns. Returns an error if the store does not support session restoration or +// if the session is not found. +func (e *Engine) OpenSession(ctx context.Context, sessionID types.SessionID) (*Session, error) { + r, ok := e.sessionStore.(sessionRestorer) + if !ok { + return nil, fmt.Errorf("session store does not support restoration") + } + meta, msgs, err := r.RestoreSessionState(sessionID) + if err != nil { + return nil, fmt.Errorf("failed to restore session %s: %w", sessionID, err) + } + return e.NewSessionFromState(ctx, sessionID, meta, msgs) +} + // HookRegistry returns the engine's hook registry for external hook registration. func (e *Engine) HookRegistry() *hooks.Registry { return e.hookRegistry diff --git a/internal/engine/session.go b/internal/engine/session.go index af3c4d5..3283451 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -325,6 +325,11 @@ func (s *Session) Close() error { return s.persistSessionState(messages) } +// GetSessionID returns the stable identifier for this session. +func (s *Session) GetSessionID() types.SessionID { + return s.state.SessionID +} + func (s *Session) enforceMaxTurns() error { if s == nil || s.config == nil { return nil diff --git a/internal/memory/manager.go b/internal/memory/manager.go index 68e6f21..2579e50 100644 --- a/internal/memory/manager.go +++ b/internal/memory/manager.go @@ -3,10 +3,11 @@ package memory import ( "fmt" "os" - "path/filepath" "sort" "strings" "time" + + "github.com/EngineerProjects/nexus-engine/pkg/runtimepath" ) // ============================================================================ @@ -283,13 +284,9 @@ func getBaseMemoryPath() (string, error) { return path, nil } - // Default to ~/.nexus/memory - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("get home dir: %w", err) - } - - return filepath.Join(homeDir, ".nexus", "memory"), nil + // Default to /memory — honours NEXUS_RUNTIME_ROOT so the CLI + // (nexus-cli) and the product backend (nexus) stay isolated automatically. + return runtimepath.Join("", "memory"), nil } // EnsureDirectory ensures the memory directory exists diff --git a/internal/modes/execution/plan.go b/internal/modes/execution/plan.go index 5f912de..1da5710 100644 --- a/internal/modes/execution/plan.go +++ b/internal/modes/execution/plan.go @@ -57,10 +57,12 @@ func ClearPlanSlug(sessionID types.SessionID) { planCache.ClearSlug(sessionID) } func ClearAllPlanSlugs() { planCache.ClearAllSlugs() } // GetPlanFilePath returns the full path to the plan file for a session. -// When agentID is non-nil the path includes the agent identifier, allowing +// Plans are stored under sessions/{sessionID}/plans/ so that deleting a session +// directory removes all its plans in one shot. +// When agentID is non-nil the filename includes the agent identifier, allowing // separate plan files per sub-agent within the same session. func GetPlanFilePath(sessionID types.SessionID, agentID *types.AgentID) string { - dir := planCache.GetDirectory() + dir := runtimepath.SessionPlansDir("", string(sessionID)) slug := planCache.GetSlug(sessionID) if agentID != nil { return filepath.Join(dir, slug+"-"+string(*agentID)+".md") @@ -68,8 +70,17 @@ func GetPlanFilePath(sessionID types.SessionID, agentID *types.AgentID) string { return filepath.Join(dir, slug+".md") } -// GetDisplayPath returns a user-friendly relative path to the plan file. -func GetDisplayPath(planFilePath string) string { return planCache.GetDisplayPath(planFilePath) } +// GetDisplayPath returns a user-friendly tilde-prefixed path to the plan file. +func GetDisplayPath(planFilePath string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return planFilePath + } + if rel, err := filepath.Rel(home, planFilePath); err == nil && !filepath.IsAbs(rel) { + return "~/" + rel + } + return planFilePath +} // PlanExists checks if a plan file exists for the session. func PlanExists(sessionID types.SessionID, agentID *types.AgentID) bool { diff --git a/internal/permissions/integration.go b/internal/permissions/integration.go index ef99316..c0513c1 100644 --- a/internal/permissions/integration.go +++ b/internal/permissions/integration.go @@ -2,12 +2,18 @@ package permissions import ( "context" + "encoding/json" "fmt" + "os" + "path/filepath" + "sync" automode "github.com/EngineerProjects/nexus-engine/internal/permissions/auto" "github.com/EngineerProjects/nexus-engine/internal/providers" tool "github.com/EngineerProjects/nexus-engine/internal/tools/registry" "github.com/EngineerProjects/nexus-engine/internal/types" + "github.com/EngineerProjects/nexus-engine/internal/utils" + "github.com/EngineerProjects/nexus-engine/pkg/runtimepath" ) // Integrator integrates permission checking with tool execution. @@ -18,12 +24,16 @@ type Integrator struct { engine *Engine promptFn types.PromptFn + + mu sync.RWMutex + sessionTools map[types.SessionID]map[string]bool } // NewIntegrator creates a new permission integrator. func NewIntegrator(engine *Engine) *Integrator { return &Integrator{ - engine: engine, + engine: engine, + sessionTools: make(map[types.SessionID]map[string]bool), } } @@ -61,6 +71,45 @@ func (i *Integrator) ResolverWithContext( if requestSessionID == "" { requestSessionID = sessionID } + + if requestSessionID != "" { + // 1. Fast path: check in-memory map + i.mu.RLock() + hasSession := i.sessionTools != nil && i.sessionTools[requestSessionID] != nil + var allowed bool + if hasSession { + allowed = i.sessionTools[requestSessionID][toolName] + } + i.mu.RUnlock() + + // 2. Slow path: if session is not in memory, try to load from disk + if !hasSession { + i.mu.Lock() + // Double-check inside lock + if i.sessionTools == nil { + i.sessionTools = make(map[types.SessionID]map[string]bool) + } + if i.sessionTools[requestSessionID] == nil { + sessionDir := runtimepath.SessionDir("", string(requestSessionID)) + filePath := filepath.Join(sessionDir, "permissions.json") + loadedMap := make(map[string]bool) + if data, err := os.ReadFile(filePath); err == nil { + _ = json.Unmarshal(data, &loadedMap) + } + i.sessionTools[requestSessionID] = loadedMap + } + allowed = i.sessionTools[requestSessionID][toolName] + i.mu.Unlock() + } + + if allowed { + return types.AllowWithInputAndDecisionReason("auto-approved for session", utils.CloneInput(toolInput), &types.PermissionDecisionReason{ + Type: types.PermissionDecisionReasonMode, + Source: "session", + Reason: "auto-approved for session", + }) + } + } requestTurnID := request.TurnID if requestTurnID == "" { requestTurnID = turnID @@ -176,11 +225,44 @@ func (i *Integrator) ResolverWithContext( }) } - if approved, ok := response.Value.(bool); ok && approved { - return types.AllowWithInputAndDecisionReason("user approved", result.UpdatedInput, &types.PermissionDecisionReason{ + var approved bool + var always bool + if b, ok := response.Value.(bool); ok { + approved = b + } else if s, ok := response.Value.(string); ok { + if s == "always" { + approved = true + always = true + } + } + + if approved { + reason := "user approved" + if always && requestSessionID != "" { + i.mu.Lock() + if i.sessionTools == nil { + i.sessionTools = make(map[types.SessionID]map[string]bool) + } + if i.sessionTools[requestSessionID] == nil { + i.sessionTools[requestSessionID] = make(map[string]bool) + } + i.sessionTools[requestSessionID][toolName] = true + + // Save to disk + sessionDir := runtimepath.SessionDir("", string(requestSessionID)) + filePath := filepath.Join(sessionDir, "permissions.json") + if err := os.MkdirAll(sessionDir, 0700); err == nil { + if data, err := json.Marshal(i.sessionTools[requestSessionID]); err == nil { + _ = os.WriteFile(filePath, data, 0600) + } + } + i.mu.Unlock() + reason = "always approved for session" + } + return types.AllowWithInputAndDecisionReason(reason, result.UpdatedInput, &types.PermissionDecisionReason{ Type: types.PermissionDecisionReasonPrompt, Source: "prompt", - Reason: "user approved", + Reason: reason, }) } diff --git a/internal/permissions/integration_test.go b/internal/permissions/integration_test.go index ac973db..a0dbcd0 100644 --- a/internal/permissions/integration_test.go +++ b/internal/permissions/integration_test.go @@ -2,9 +2,13 @@ package permissions import ( "context" + "encoding/json" + "os" + "path/filepath" "testing" "github.com/EngineerProjects/nexus-engine/internal/types" + "github.com/EngineerProjects/nexus-engine/pkg/runtimepath" ) // ─── mock classifier for e2e tests ─────────────────────────────────────────── @@ -19,6 +23,7 @@ func (c *e2eClassifier) Classify(_ context.Context, _ string, _ map[string]any) } func TestResolverUsesPromptFnApproval(t *testing.T) { + t.Setenv("NEXUS_RUNTIME_ROOT", t.TempDir()) engine := NewEngine() if err := engine.AddRule(PermissionRule{ Value: PermissionRuleValue{ToolName: "bash", RuleContent: "echo *"}, @@ -72,6 +77,7 @@ func TestResolverUsesPromptFnApproval(t *testing.T) { // TestIntegratorAutoModeClassifierAllows verifies the full path: // engine + auto-mode + mock classifier → allow decision. func TestIntegratorAutoModeClassifierAllows(t *testing.T) { + t.Setenv("NEXUS_RUNTIME_ROOT", t.TempDir()) engine := NewEngine() engine.SetClassifier(&e2eClassifier{allowed: true, reason: "safe operation"}) @@ -97,6 +103,7 @@ func TestIntegratorAutoModeClassifierAllows(t *testing.T) { // TestIntegratorAutoModeClassifierDenies verifies the full path: // engine + auto-mode + mock classifier → deny decision. func TestIntegratorAutoModeClassifierDenies(t *testing.T) { + t.Setenv("NEXUS_RUNTIME_ROOT", t.TempDir()) engine := NewEngine() engine.SetClassifier(&e2eClassifier{allowed: false, reason: "dangerous command"}) @@ -122,6 +129,7 @@ func TestIntegratorAutoModeClassifierDenies(t *testing.T) { // TestIntegratorDenyRuleTakesPrecedenceOverAutoMode verifies that an explicit // deny rule fires before the auto-mode classifier is consulted. func TestIntegratorDenyRuleTakesPrecedenceOverAutoMode(t *testing.T) { + t.Setenv("NEXUS_RUNTIME_ROOT", t.TempDir()) engine := NewEngine() // Classifier would allow, but deny rule should win. engine.SetClassifier(&e2eClassifier{allowed: true, reason: "would allow"}) @@ -155,6 +163,7 @@ func TestIntegratorDenyRuleTakesPrecedenceOverAutoMode(t *testing.T) { } func TestResolverUsesPromptFnDenial(t *testing.T) { + t.Setenv("NEXUS_RUNTIME_ROOT", t.TempDir()) engine := NewEngine() if err := engine.AddRule(PermissionRule{ Value: PermissionRuleValue{ToolName: "bash", RuleContent: "echo *"}, @@ -190,3 +199,137 @@ func TestResolverUsesPromptFnDenial(t *testing.T) { t.Fatalf("expected prompt decision reason, got %#v", result.DecisionReason) } } + +func TestResolverSessionAutoApproval(t *testing.T) { + t.Setenv("NEXUS_RUNTIME_ROOT", t.TempDir()) + engine := NewEngine() + if err := engine.AddRule(PermissionRule{ + Value: PermissionRuleValue{ToolName: "bash", RuleContent: "echo *"}, + Behavior: types.PermissionBehaviorAsk, + Priority: 100, + Reason: "echo commands require approval in this test", + Source: types.PermissionSourceStatic, + }); err != nil { + t.Fatalf("failed to add permission rule: %v", err) + } + + integrator := NewIntegrator(engine) + promptCalls := 0 + integrator.SetPromptFn(func(ctx context.Context, request types.PromptRequest) (types.PromptResponse, error) { + promptCalls++ + return types.PromptResponse{Value: "always"}, nil + }) + + resolver := integrator.Resolver("session-1", "turn-1", types.PermissionModeOnRequest) + + // First call should call promptFn and return "always", which should allow it and remember it for session-1. + result1 := resolver.ResolvePermission(context.Background(), types.GlobalToolPermissionRequest( + "bash", + map[string]any{"command": "echo first"}, + "tool-1", + "session-1", + "turn-1", + types.PermissionModeOnRequest, + "", + nil, + )) + + if promptCalls != 1 { + t.Fatalf("expected promptFn to be called once, got %d", promptCalls) + } + if !result1.IsAllowed() { + t.Fatalf("expected prompt approval to allow tool use, got %#v", result1) + } + + // Second call with same session-1 and same tool "bash" should NOT trigger a prompt. + result2 := resolver.ResolvePermission(context.Background(), types.GlobalToolPermissionRequest( + "bash", + map[string]any{"command": "echo second"}, + "tool-2", + "session-1", + "turn-2", + types.PermissionModeOnRequest, + "", + nil, + )) + + if promptCalls != 1 { + t.Fatalf("expected promptFn NOT to be called again, got %d calls total", promptCalls) + } + if !result2.IsAllowed() { + t.Fatalf("expected auto-approval for session to allow tool use, got %#v", result2) + } + if result2.DecisionReason == nil || result2.DecisionReason.Source != "session" { + t.Fatalf("expected session decision reason, got %#v", result2.DecisionReason) + } + + // Third call with DIFFERENT session "session-2" should trigger the prompt. + result3 := resolver.ResolvePermission(context.Background(), types.GlobalToolPermissionRequest( + "bash", + map[string]any{"command": "echo third"}, + "tool-3", + "session-2", + "turn-3", + types.PermissionModeOnRequest, + "", + nil, + )) + + if promptCalls != 2 { + t.Fatalf("expected promptFn to be called again for session-2, got %d calls total", promptCalls) + } + if !result3.IsAllowed() { + t.Fatalf("expected prompt approval to allow tool use, got %#v", result3) + } +} + +func TestResolverSessionAutoApprovalPersistence(t *testing.T) { + tempRoot := t.TempDir() + t.Setenv("NEXUS_RUNTIME_ROOT", tempRoot) + + engine := NewEngine() + integrator := NewIntegrator(engine) + + sessionID := types.SessionID("session-persistent") + sessionDir := runtimepath.SessionDir(tempRoot, string(sessionID)) + if err := os.MkdirAll(sessionDir, 0700); err != nil { + t.Fatalf("failed to create session dir: %v", err) + } + + mockPerms := map[string]bool{"bash": true} + data, err := json.Marshal(mockPerms) + if err != nil { + t.Fatalf("failed to marshal mock perms: %v", err) + } + if err := os.WriteFile(filepath.Join(sessionDir, "permissions.json"), data, 0600); err != nil { + t.Fatalf("failed to write permissions.json: %v", err) + } + + promptCalled := false + integrator.SetPromptFn(func(ctx context.Context, request types.PromptRequest) (types.PromptResponse, error) { + promptCalled = true + return types.PromptResponse{Value: false}, nil + }) + + resolver := integrator.Resolver(sessionID, "turn-1", types.PermissionModeOnRequest) + result := resolver.ResolvePermission(context.Background(), types.GlobalToolPermissionRequest( + "bash", + map[string]any{"command": "echo hi"}, + "tool-1", + sessionID, + "turn-1", + types.PermissionModeOnRequest, + "", + nil, + )) + + if promptCalled { + t.Fatal("expected promptFn NOT to be called because permissions were pre-loaded from permissions.json") + } + if !result.IsAllowed() { + t.Fatalf("expected tool use to be allowed from disk persistence, got %#v", result) + } + if result.DecisionReason == nil || result.DecisionReason.Source != "session" { + t.Fatalf("expected session decision reason, got %#v", result.DecisionReason) + } +} diff --git a/internal/providers/adapter.go b/internal/providers/adapter.go index 461c566..acf2e54 100644 --- a/internal/providers/adapter.go +++ b/internal/providers/adapter.go @@ -49,7 +49,9 @@ type providerAdapter interface { // mirrors exactly the branches of the former switch statements in client.go. func adapterForProvider(p types.APIProvider) providerAdapter { switch p { - case types.APIProviderOpenAI, types.APIProviderZAi, types.APIProviderMiniMax, types.APIProviderOpenRouter, types.APIProviderMistral, types.APIProviderDeepSeek, types.APIProviderOpenCode: + case types.APIProviderZAi, "zai", "z.ai": + return zAiAdapter{} + case types.APIProviderOpenAI, types.APIProviderMiniMax, types.APIProviderOpenRouter, types.APIProviderMistral, types.APIProviderDeepSeek, types.APIProviderOpenCode: return openAICompatAdapter{} case types.APIProviderCodex: return codexAdapter{} @@ -115,7 +117,21 @@ func (foundryAdapter) applyAuthHeaders(c *Client, req *http.Request) { } // --------------------------------------------------------------------------- -// OpenAI-compatible /chat/completions (openai, z-ai, minimax, openrouter, mistral) +// z-ai (api.z.ai) — OpenAI-compat body format but x-api-key auth header. +// The api.z.ai endpoint uses Anthropic-style authentication (x-api-key) +// while the request/response body follows the OpenAI /chat/completions format. +// --------------------------------------------------------------------------- + +type zAiAdapter struct{ openAICompatAdapter } + +func (zAiAdapter) applyAuthHeaders(c *Client, req *http.Request) { + if c.apiKey != "" { + req.Header.Set("x-api-key", c.apiKey) + } +} + +// --------------------------------------------------------------------------- +// OpenAI-compatible /chat/completions (openai, minimax, openrouter, mistral) // --------------------------------------------------------------------------- type openAICompatAdapter struct{} diff --git a/internal/providers/client.go b/internal/providers/client.go index 661443e..1dde0c1 100644 --- a/internal/providers/client.go +++ b/internal/providers/client.go @@ -159,16 +159,7 @@ func NewFallbackClient(ctx context.Context, provider types.APIProvider) (*Client func newClientWithConfig(apiKey string, config *Config) *Client { baseURL := config.GetBaseURL() if baseURL == "" { - switch config.Provider { - case types.APIProviderOpenAI: - baseURL = "https://api.openai.com/v1" - case types.APIProviderCodex: - baseURL = "https://chatgpt.com/backend-api/codex" - case types.APIProviderOllama: - baseURL = "http://localhost:11434" - default: - baseURL = "https://api.anthropic.com" - } + baseURL = "https://api.anthropic.com" } var httpClient *http.Client diff --git a/internal/providers/client_openai.go b/internal/providers/client_openai.go index 1118009..48eb0cb 100644 --- a/internal/providers/client_openai.go +++ b/internal/providers/client_openai.go @@ -62,12 +62,80 @@ func (c *Client) buildOpenAIMessages(req types.APIRequest) []map[string]any { "content": systemPrompt, }) } - for _, message := range req.Messages { + // Strip orphaned tool results before conversion to avoid invalid_request_message_order + // from OpenAI-compat APIs (z-ai/GLM, OpenAI, etc.). + sanitized := sanitizeToolResultOrphans(req.Messages) + for _, message := range sanitized { messages = append(messages, openAIMessageParts(message)...) } return messages } +// sanitizeToolResultOrphans removes tool_result blocks from user messages when +// the referenced tool_use_id does not exist in any preceding assistant message. +// This prevents "Unexpected tool call id X in tool results" (invalid_request_message_order) +// from OpenAI-compatible APIs when parallel or background agents cause message +// history desynchronization. +func sanitizeToolResultOrphans(messages []types.Message) []types.Message { + // Build the set of all tool_use IDs seen in assistant messages. + knownToolUseIDs := make(map[string]struct{}, 16) + for _, m := range messages { + if m.Role != types.RoleAssistant { + continue + } + for _, block := range m.Content { + if tu, ok := block.(types.ToolUseContent); ok { + knownToolUseIDs[tu.ID] = struct{}{} + } + } + } + + // If no assistant tool_calls exist in this conversation, there's nothing to + // be orphaned from — pass through unchanged to avoid stripping valid results. + if len(knownToolUseIDs) == 0 { + return messages + } + + result := make([]types.Message, 0, len(messages)) + for _, m := range messages { + if m.Role != types.RoleUser { + result = append(result, m) + continue + } + // Filter out tool_result blocks whose IDs are not in knownToolUseIDs. + hasOrphan := false + for _, block := range m.Content { + if tr, ok := block.(types.ToolResultContent); ok { + if _, known := knownToolUseIDs[tr.ToolUseID]; !known { + hasOrphan = true + break + } + } + } + if !hasOrphan { + result = append(result, m) + continue + } + // Rebuild message content without orphaned tool results. + clean := make([]types.ContentBlock, 0, len(m.Content)) + for _, block := range m.Content { + if tr, ok := block.(types.ToolResultContent); ok { + if _, known := knownToolUseIDs[tr.ToolUseID]; !known { + continue // drop orphan + } + } + clean = append(clean, block) + } + if len(clean) > 0 { + cleaned := m + cleaned.Content = clean + result = append(result, cleaned) + } + // If all content was orphaned tool results, the message is dropped entirely. + } + return result +} + func openAIMessageParts(message types.Message) []map[string]any { switch message.Role { case types.RoleUser: diff --git a/internal/providers/config.go b/internal/providers/config.go index 78da4cc..38a9690 100644 --- a/internal/providers/config.go +++ b/internal/providers/config.go @@ -318,7 +318,7 @@ func (c *Config) BuildAuthHeaders() map[string]string { case types.APIProviderZAi: if c.APIKey != "" { - headers["Authorization"] = "Bearer " + c.APIKey + headers["x-api-key"] = c.APIKey } headers["Content-Type"] = "application/json" headers["Accept-Language"] = "en-US,en" diff --git a/internal/providers/providers_test.go b/internal/providers/providers_test.go index ce09b9a..7bfe964 100644 --- a/internal/providers/providers_test.go +++ b/internal/providers/providers_test.go @@ -36,7 +36,7 @@ func TestProviderAdapterDispatch(t *testing.T) { {"anthropic", types.APIProviderAnthropic, "claude-x", "/v1/messages", [2]string{"x-api-key", "k"}, "messages"}, {"foundry", types.APIProviderFoundry, "claude-x", "/v1/messages", [2]string{"api-key", "k"}, "messages"}, {"openai", types.APIProviderOpenAI, "gpt", "/chat/completions", [2]string{"Authorization", "Bearer k"}, "messages"}, - {"zai", types.APIProviderZAi, "glm", "/chat/completions", [2]string{"Authorization", "Bearer k"}, "messages"}, + {"zai", types.APIProviderZAi, "glm", "/chat/completions", [2]string{"x-api-key", "k"}, "messages"}, {"minimax", types.APIProviderMiniMax, "mm", "/chat/completions", [2]string{"Authorization", "Bearer k"}, "messages"}, {"openrouter", types.APIProviderOpenRouter, "or", "/chat/completions", [2]string{"Authorization", "Bearer k"}, "messages"}, {"mistral", types.APIProviderMistral, "mi", "/chat/completions", [2]string{"Authorization", "Bearer k"}, "messages"}, diff --git a/internal/rag/embedder/embedder.go b/internal/rag/embedder/embedder.go index 51327ca..68f842a 100644 --- a/internal/rag/embedder/embedder.go +++ b/internal/rag/embedder/embedder.go @@ -140,8 +140,20 @@ func (e *Embedder) embedOpenAI(ctx context.Context, texts []string) ([][]float32 out := make([][]float32, len(result.Data)) for i, d := range result.Data { + if len(d.Embedding) == 0 { + return nil, fmt.Errorf("embedding %d from API is empty", i) + } out[i] = d.Embedding } + // Validate that all embeddings share the same dimension (catches misconfigured models). + if len(out) > 0 { + dim := len(out[0]) + for i, v := range out { + if len(v) != dim { + return nil, fmt.Errorf("embedding dimension mismatch: expected %d, got %d at index %d", dim, len(v), i) + } + } + } return out, nil } @@ -201,6 +213,18 @@ func (e *Embedder) embedOllama(ctx context.Context, texts []string) ([][]float32 if len(result.Embeddings) != len(texts) { return nil, fmt.Errorf("expected %d embeddings from ollama, got %d", len(texts), len(result.Embeddings)) } + // Validate that all embeddings share the same dimension. + if len(result.Embeddings) > 0 { + dim := len(result.Embeddings[0]) + if dim == 0 { + return nil, fmt.Errorf("ollama returned empty embedding vector") + } + for i, v := range result.Embeddings { + if len(v) != dim { + return nil, fmt.Errorf("ollama embedding dimension mismatch: expected %d, got %d at index %d", dim, len(v), i) + } + } + } return result.Embeddings, nil } diff --git a/internal/runtime/state/sqlite_backend.go b/internal/runtime/state/sqlite_backend.go index 24a7c0e..519faf8 100644 --- a/internal/runtime/state/sqlite_backend.go +++ b/internal/runtime/state/sqlite_backend.go @@ -11,6 +11,12 @@ import ( "github.com/EngineerProjects/nexus-engine/internal/types" ) +// dbCtx returns a context with a timeout for short DB operations. +// This is a pragmatic guard until the Backend interface accepts ctx directly (see L-A in audit). +func dbCtx(timeout time.Duration) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), timeout) +} + // SQLiteBackend persists session state in a shared application database. type SQLiteBackend struct { db *dbpkg.DB @@ -96,27 +102,18 @@ func (b *SQLiteBackend) LoadSession(sessionID types.SessionID) (*types.SessionMe } func (b *SQLiteBackend) DeleteSession(sessionID types.SessionID) error { - tx, err := b.db.SQL().BeginTx(context.Background(), nil) - if err != nil { - return fmt.Errorf("failed to begin delete transaction: %w", err) - } - defer func() { - _ = tx.Rollback() - }() - - statements := []string{ - `DELETE FROM session_transcript_entries WHERE session_id = ?`, - `DELETE FROM session_checkpoints WHERE session_id = ?`, + // A single DELETE on the parent row is sufficient: FOREIGN KEY ON DELETE CASCADE + // propagates to session_transcript_entries, session_checkpoints, and session_files. + // The trg_transcript_fts_delete trigger keeps session_transcript_fts in sync for + // every cascade-deleted transcript row. + ctx, cancel := dbCtx(10 * time.Second) + defer cancel() + _, err := b.db.SQL().ExecContext(ctx, `DELETE FROM session_metadata WHERE session_id = ?`, - } - for _, statement := range statements { - if _, err := tx.Exec(statement, sessionID.String()); err != nil { - return fmt.Errorf("failed to delete session %s: %w", sessionID, err) - } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit delete session %s: %w", sessionID, err) + sessionID.String(), + ) + if err != nil { + return fmt.Errorf("delete session %s: %w", sessionID, err) } return nil } @@ -148,7 +145,9 @@ func (b *SQLiteBackend) AppendTranscriptEntries(sessionID types.SessionID, entri if len(entries) == 0 { return nil } - tx, err := b.db.SQL().BeginTx(context.Background(), nil) + ctx, cancel := dbCtx(30 * time.Second) + defer cancel() + tx, err := b.db.SQL().BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transcript append transaction: %w", err) } @@ -170,7 +169,9 @@ func (b *SQLiteBackend) AppendTranscriptEntries(sessionID types.SessionID, entri } func (b *SQLiteBackend) ReplaceTranscript(sessionID types.SessionID, entries []types.TranscriptEntry) error { - tx, err := b.db.SQL().BeginTx(context.Background(), nil) + ctx, cancel := dbCtx(30 * time.Second) + defer cancel() + tx, err := b.db.SQL().BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transcript replace transaction: %w", err) } @@ -191,32 +192,44 @@ func (b *SQLiteBackend) ReplaceTranscript(sessionID types.SessionID, entries []t } // SearchTranscriptsByContent returns IDs of sessions whose stored transcript -// JSON contains needle. Uses a SQL LIKE to avoid loading full transcripts. +// JSON contains needle. Uses the session_transcript_fts FTS5 index when available +// (O(log n)); falls back to a LIKE full scan on older databases. func (b *SQLiteBackend) SearchTranscriptsByContent(needle string, limit int) ([]types.SessionID, error) { + scan := func(rows *sql.Rows) ([]types.SessionID, error) { + defer rows.Close() + var ids []types.SessionID + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan session id: %w", err) + } + ids = append(ids, types.SessionID(id)) + } + return ids, rows.Err() + } + + // Try FTS5 first — MATCH is word-token based, fast on large datasets. + ftsQuery := `SELECT DISTINCT session_id FROM session_transcript_fts WHERE entry_json MATCH ?` + if limit > 0 { + ftsQuery += fmt.Sprintf(" LIMIT %d", limit) + } + if rows, err := b.db.SQL().Query(ftsQuery, needle); err == nil { + if ids, err := scan(rows); err == nil { + return ids, nil + } + } + + // Fallback: LIKE scan (O(n)) for databases without the FTS5 table. pattern := "%" + needle + "%" - var query string - var args []any + likeQuery := `SELECT DISTINCT session_id FROM session_transcript_entries WHERE entry_json LIKE ?` if limit > 0 { - query = `SELECT DISTINCT session_id FROM session_transcript_entries WHERE entry_json LIKE ? LIMIT ?` - args = []any{pattern, limit} - } else { - query = `SELECT DISTINCT session_id FROM session_transcript_entries WHERE entry_json LIKE ?` - args = []any{pattern} + likeQuery += fmt.Sprintf(" LIMIT %d", limit) } - rows, err := b.db.SQL().Query(query, args...) + rows, err := b.db.SQL().Query(likeQuery, pattern) if err != nil { return nil, fmt.Errorf("search transcripts by content: %w", err) } - defer rows.Close() - var ids []types.SessionID - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, fmt.Errorf("scan session id: %w", err) - } - ids = append(ids, types.SessionID(id)) - } - return ids, rows.Err() + return scan(rows) } func (b *SQLiteBackend) LoadTranscript(sessionID types.SessionID) ([]types.TranscriptEntry, error) { diff --git a/internal/runtime/state/state_test.go b/internal/runtime/state/state_test.go index 5e2b594..49f2e11 100644 --- a/internal/runtime/state/state_test.go +++ b/internal/runtime/state/state_test.go @@ -206,3 +206,79 @@ func TestRestoreSessionStateFailsOnMalformedTranscriptEntry(t *testing.T) { t.Fatalf("expected ErrMalformedTranscriptEntry, got %v", err) } } + +func TestRestoreSessionStateWithCheckpointMismatchFallback(t *testing.T) { + store, err := NewStoreWithBackend(NewMemoryBackend()) + if err != nil { + t.Fatalf("NewStoreWithBackend failed: %v", err) + } + + sessionID := types.SessionID("session-fallback-mismatch") + createdAt := time.Unix(1700000000, 0).UTC() + updatedAt := createdAt.Add(2 * time.Minute) + metadata := &types.SessionMetadata{ + ID: sessionID, + Status: types.SessionStatusActive, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + TotalTurns: 1, + TotalTokens: 42, + Additional: map[string]any{ + "canonical_transcript": map[string]any{ + "message_count": 2, + "turn_count": 1, + "tool_results": 0, + "first_user_message": "hello", + }, + }, + } + + msg1 := types.UserMessage("msg-1", "hello") + msg1.Metadata = &types.MessageMetadata{TurnID: "turn-1"} + msg2 := types.AssistantMessage("msg-2", []types.ContentBlock{ + types.TextContent{Text: "world"}, + }) + msg2.Metadata = &types.MessageMetadata{TurnID: "turn-1"} + messages := []types.Message{msg1, msg2} + + // Save the session and transcript first + if err := store.SaveSession(sessionID, metadata); err != nil { + t.Fatalf("SaveSession failed: %v", err) + } + if err := store.SaveCanonicalMessages(sessionID, messages); err != nil { + t.Fatalf("SaveCanonicalMessages failed: %v", err) + } + + // Save a checkpoint with a completely wrong MessagesHash, but matching metadata summary + checkpoint := &Checkpoint{ + SessionID: sessionID, + TurnNumber: 1, + MessagesHash: "wrong-hash-should-fallback", + Timestamp: time.Now().Unix(), + Metadata: map[string]any{ + "status": "active", + "canonical_transcript": map[string]any{ + "message_count": 2, + "turn_count": 1, + "tool_results": 0, + "first_user_message": "hello", + }, + }, + } + if err := store.SaveCheckpoint(sessionID, checkpoint); err != nil { + t.Fatalf("SaveCheckpoint failed: %v", err) + } + + // Restoring should succeed thanks to our fallback verification logic! + restoredMetadata, restoredMessages, err := store.RestoreSessionState(sessionID) + if err != nil { + t.Fatalf("RestoreSessionState failed despite matching summary: %v", err) + } + + if len(restoredMessages) != 2 { + t.Fatalf("expected 2 restored messages, got %d", len(restoredMessages)) + } + if restoredMetadata.ID != sessionID { + t.Fatalf("expected restored metadata ID %q, got %q", sessionID, restoredMetadata.ID) + } +} diff --git a/internal/runtime/state/store.go b/internal/runtime/state/store.go index e5c1e43..ca5fef8 100644 --- a/internal/runtime/state/store.go +++ b/internal/runtime/state/store.go @@ -2,6 +2,7 @@ package state import ( "fmt" + "log/slog" "sync" "time" @@ -257,11 +258,53 @@ func checkpointMatchesMessages(checkpoint *Checkpoint, messages []types.Message) return false, err } if checkpoint.MessagesHash != "" && checkpoint.MessagesHash != messagesHash { + // Fallback 1: check if legacy hash matches + legacyHash, err := types.LegacyCanonicalTranscriptHash(messages) + if err == nil && checkpoint.MessagesHash == legacyHash { + return true, nil + } + + // Fallback 2: compare logical transcript summaries + expectedSummary := checkpointCanonicalSummary(checkpoint) + if expectedSummary != nil { + currentSummary := canonicalTranscriptSummary(messages) + if summariesMatch(expectedSummary, currentSummary) { + slog.Warn("Session checkpoint hash mismatch detected, but transcript summary matches. Proceeding.", + "session_id", checkpoint.SessionID, + "expected_hash", checkpoint.MessagesHash, + "computed_hash", messagesHash) + return true, nil + } + } return false, nil } return true, nil } +func summariesMatch(a, b map[string]any) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + keys := []string{"message_count", "turn_count", "tool_results", "first_user_message"} + for _, k := range keys { + va := a[k] + vb := b[k] + + // Normalize numeric types (json unmarshals as float64, while in-memory uses int) + if ka, ok := va.(float64); ok { + va = int(ka) + } + if kb, ok := vb.(float64); ok { + vb = int(kb) + } + + if va != vb { + return false + } + } + return true +} + func applyCheckpointMetadata(metadata *types.SessionMetadata, checkpoint *Checkpoint) { if metadata == nil || checkpoint == nil { return @@ -284,14 +327,22 @@ func (s *Store) GetSessionInfo(sessionID types.SessionID) (*SessionInfo, error) return nil, err } - return &SessionInfo{ + info := &SessionInfo{ ID: metadata.ID, Status: metadata.Status, CreatedAt: metadata.CreatedAt.Unix(), UpdatedAt: metadata.UpdatedAt.Unix(), TotalTurns: metadata.TotalTurns, TotalTokens: metadata.TotalTokens, - }, nil + } + if metadata.Additional != nil { + if ct, ok := metadata.Additional["canonical_transcript"].(map[string]any); ok { + if v, ok := ct["first_user_message"].(string); ok { + info.Preview = v + } + } + } + return info, nil } // GetAllSessionsInfo gets information about all sessions diff --git a/internal/runtime/state/sync.go b/internal/runtime/state/sync.go index 50da8e8..40b145e 100644 --- a/internal/runtime/state/sync.go +++ b/internal/runtime/state/sync.go @@ -1,6 +1,7 @@ package state import ( + "strings" "time" "github.com/EngineerProjects/nexus-engine/internal/types" @@ -65,6 +66,35 @@ func countCanonicalTranscriptToolResults(messages []types.Message) int { return types.CountToolResultMessages(canonicalTranscriptMessages(messages)) } +// firstUserMessagePreview returns a trimmed preview of the first user text +// message in the canonical transcript (at most 120 runes). +func firstUserMessagePreview(messages []types.Message) string { + for _, msg := range messages { + if msg.Role != types.RoleUser { + continue + } + for _, block := range msg.Content { + if tc, ok := block.(types.TextContent); ok { + text := strings.TrimSpace(tc.Text) + if text == "" { + continue + } + // Trim to first line and cap at 120 runes. + if idx := strings.IndexByte(text, '\n'); idx >= 0 { + text = text[:idx] + } + r := []rune(text) + if len(r) > 120 { + r = r[:120] + text = string(r) + "…" + } + return text + } + } + } + return "" +} + // canonicalTranscriptSummary derives the metadata snapshot stored alongside a // persisted session so restore/list operations can inspect transcript shape // without reparsing the whole history at each call site. @@ -74,6 +104,9 @@ func canonicalTranscriptSummary(messages []types.Message) map[string]any { "turn_count": countCanonicalTranscriptTurns(messages), "tool_results": countCanonicalTranscriptToolResults(messages), } + if preview := firstUserMessagePreview(messages); preview != "" { + summary["first_user_message"] = preview + } if compaction := lastCompactionMetadata(messages); compaction != nil { summary["last_compaction_kind"] = compaction.Kind summary["last_compaction_target_tokens"] = compaction.TargetTokens diff --git a/internal/runtime/state/types.go b/internal/runtime/state/types.go index 4d62a3b..f2ae0c6 100644 --- a/internal/runtime/state/types.go +++ b/internal/runtime/state/types.go @@ -19,4 +19,7 @@ type SessionInfo struct { UpdatedAt int64 `json:"updated_at"` TotalTurns int `json:"total_turns"` TotalTokens int `json:"total_tokens"` + // Preview is the first user message text, truncated to ~120 runes. + // Empty for sessions saved before this field was introduced. + Preview string `json:"preview,omitempty"` } diff --git a/internal/runtime/tasks/manager.go b/internal/runtime/tasks/manager.go index 1962916..30fdfa9 100644 --- a/internal/runtime/tasks/manager.go +++ b/internal/runtime/tasks/manager.go @@ -143,6 +143,14 @@ func (m *Manager) CreateAgentTask(ctx context.Context, prompt string, tools []to if err != nil { return nil, fmt.Errorf("failed to create agent session: %w", err) } + // sessionOwned tracks whether the goroutine has taken ownership of the session. + // If we return an error before launching the goroutine, we must close the session. + sessionOwned := false + defer func() { + if !sessionOwned { + _ = session.Close() + } + }() if err := session.RegisterTools(tools); err != nil { return nil, fmt.Errorf("failed to register tools: %w", err) } @@ -160,6 +168,7 @@ func (m *Manager) CreateAgentTask(ctx context.Context, prompt string, tools []to } m.tasks[taskID] = task m.persistTasksLocked() + sessionOwned = true go m.runAgentTask(ctx, task, session) return cloneTask(task), nil } diff --git a/internal/storage/artifacts.go b/internal/storage/artifacts.go index 0d1ffeb..d59a92d 100644 --- a/internal/storage/artifacts.go +++ b/internal/storage/artifacts.go @@ -176,14 +176,8 @@ func StoreScreenshotRef(ctx context.Context, store ArtifactStore, data []byte, s return ArtifactRef{}, err } } - return store.PutArtifact(ctx, ArtifactPutRequest{ - Namespace: NamespaceBrowserScreenshots, - Filename: "screenshot.png", - SessionID: sessionID, - PageID: pageID, - ContentType: "image/png", - Timestamp: time.Now().UTC(), - }, data) + key := ScreenshotKey(sessionID, pageID, time.Now().UTC()) + return store.Put(ctx, key, data, "image/png") } func StoreDocumentRef(ctx context.Context, store ArtifactStore, data []byte, filename string) (ArtifactRef, error) { @@ -218,7 +212,8 @@ func StoreRAGDocumentRef(ctx context.Context, store ArtifactStore, data []byte, }, data) } -func StoreWebArtifactRef(ctx context.Context, store ArtifactStore, data []byte, filename string, contentType string) (ArtifactRef, error) { +// StoreWebArtifactRef persists web-fetched content under the session's artifacts/web/ dir. +func StoreWebArtifactRef(ctx context.Context, store ArtifactStore, data []byte, sessionID, filename, contentType string) (ArtifactRef, error) { if store == nil { var err error store, err = DefaultArtifactStore() @@ -229,12 +224,40 @@ func StoreWebArtifactRef(ctx context.Context, store ArtifactStore, data []byte, if contentType == "" { contentType = DetectContentType(filename) } - return store.PutArtifact(ctx, ArtifactPutRequest{ - Namespace: NamespaceWebArtifacts, - Filename: filename, - ContentType: contentType, - Timestamp: time.Now().UTC(), - }, data) + key := WebArtifactKey(sessionID, filename, time.Now().UTC()) + return store.Put(ctx, key, data, contentType) +} + +// StoreGeneratedImageRef persists an AI-generated image under the session's artifacts/images/ dir. +func StoreGeneratedImageRef(ctx context.Context, store ArtifactStore, data []byte, sessionID, filename, contentType string) (ArtifactRef, error) { + if store == nil { + var err error + store, err = DefaultArtifactStore() + if err != nil { + return ArtifactRef{}, err + } + } + if contentType == "" { + contentType = DetectContentType(filename) + } + key := GeneratedImageKey(sessionID, filename, time.Now().UTC()) + return store.Put(ctx, key, data, contentType) +} + +// StoreAudioRef persists a TTS/STT audio file under the session's artifacts/audio/ dir. +func StoreAudioRef(ctx context.Context, store ArtifactStore, data []byte, sessionID, filename, contentType string) (ArtifactRef, error) { + if store == nil { + var err error + store, err = DefaultArtifactStore() + if err != nil { + return ArtifactRef{}, err + } + } + if contentType == "" { + contentType = DetectContentType(filename) + } + key := AudioKey(sessionID, filename, time.Now().UTC()) + return store.Put(ctx, key, data, contentType) } func copyRefURL(ref ArtifactRef) (string, error) { diff --git a/internal/storage/keys.go b/internal/storage/keys.go index c29893c..fe0fe32 100644 --- a/internal/storage/keys.go +++ b/internal/storage/keys.go @@ -39,26 +39,76 @@ func PDFKey(title string, now time.Time) string { }) } +// ScreenshotKey builds a session-scoped storage key for a browser screenshot. +// Layout: sessions/{sessionID}/screenshots/{pageID}/{date}/{timestamp}-screenshot.png func ScreenshotKey(sessionID, pageID string, now time.Time) string { - return BuildArtifactKey(ArtifactPutRequest{ - Namespace: NamespaceBrowserScreenshots, - Filename: "screenshot.png", - SessionID: sessionID, - PageID: pageID, - ContentType: "image/png", - Timestamp: now, - }) + now = now.UTC() + if now.IsZero() { + now = time.Now().UTC() + } + datePrefix := fmt.Sprintf("%04d/%02d/%02d", now.Year(), now.Month(), now.Day()) + parts := []string{"sessions", sanitizePathSegment(sessionID), "screenshots"} + if pageID != "" { + parts = append(parts, sanitizePathSegment(pageID)) + } + parts = append(parts, datePrefix) + return path.Join(append(parts, fmt.Sprintf("%d-screenshot.png", now.UnixNano()))...) } +// DownloadKey builds a session-scoped storage key for a browser download. +// Layout: sessions/{sessionID}/tools/{pageID}/{date}/{timestamp}-{filename} func DownloadKey(sessionID, pageID, filename string, now time.Time) string { - return BuildArtifactKey(ArtifactPutRequest{ - Namespace: NamespaceBrowserDownloads, - Filename: filename, - SessionID: sessionID, - PageID: pageID, - ContentType: DetectContentType(filename), - Timestamp: now, - }) + now = now.UTC() + if now.IsZero() { + now = time.Now().UTC() + } + datePrefix := fmt.Sprintf("%04d/%02d/%02d", now.Year(), now.Month(), now.Day()) + filename = normalizeArtifactFilename(filename, DetectContentType(filename), "download") + parts := []string{"sessions", sanitizePathSegment(sessionID), "tools"} + if pageID != "" { + parts = append(parts, sanitizePathSegment(pageID)) + } + parts = append(parts, datePrefix) + return path.Join(append(parts, fmt.Sprintf("%d-%s", now.UnixNano(), filename))...) +} + +// WebArtifactKey builds a session-scoped key for web-fetched content. +// Layout: sessions/{sessionID}/artifacts/web/{date}/{timestamp}-{filename} +func WebArtifactKey(sessionID, filename string, now time.Time) string { + now = now.UTC() + if now.IsZero() { + now = time.Now().UTC() + } + datePrefix := fmt.Sprintf("%04d/%02d/%02d", now.Year(), now.Month(), now.Day()) + filename = normalizeArtifactFilename(filename, DetectContentType(filename), "fetched") + parts := []string{"sessions", sanitizePathSegment(sessionID), "artifacts", "web", datePrefix} + return path.Join(append(parts, fmt.Sprintf("%d-%s", now.UnixNano(), filename))...) +} + +// GeneratedImageKey builds a session-scoped key for an AI-generated image. +// Layout: sessions/{sessionID}/artifacts/images/{date}/{timestamp}-{filename} +func GeneratedImageKey(sessionID, filename string, now time.Time) string { + now = now.UTC() + if now.IsZero() { + now = time.Now().UTC() + } + datePrefix := fmt.Sprintf("%04d/%02d/%02d", now.Year(), now.Month(), now.Day()) + filename = normalizeArtifactFilename(filename, DetectContentType(filename), "image") + parts := []string{"sessions", sanitizePathSegment(sessionID), "artifacts", "images", datePrefix} + return path.Join(append(parts, fmt.Sprintf("%d-%s", now.UnixNano(), filename))...) +} + +// AudioKey builds a session-scoped key for a TTS/STT audio file. +// Layout: sessions/{sessionID}/artifacts/audio/{date}/{timestamp}-{filename} +func AudioKey(sessionID, filename string, now time.Time) string { + now = now.UTC() + if now.IsZero() { + now = time.Now().UTC() + } + datePrefix := fmt.Sprintf("%04d/%02d/%02d", now.Year(), now.Month(), now.Day()) + filename = normalizeArtifactFilename(filename, DetectContentType(filename), "audio") + parts := []string{"sessions", sanitizePathSegment(sessionID), "artifacts", "audio", datePrefix} + return path.Join(append(parts, fmt.Sprintf("%d-%s", now.UnixNano(), filename))...) } func DocumentKey(filename string, now time.Time) string { diff --git a/internal/storage/metadata.go b/internal/storage/metadata.go index 036d0d9..46fc722 100644 --- a/internal/storage/metadata.go +++ b/internal/storage/metadata.go @@ -108,6 +108,13 @@ func inferMetadataForDirectPut(key string, contentType string, body []byte) Arti func inferNamespaceFromKey(key string) string { cleaned := strings.Trim(strings.TrimSpace(key), "/") switch { + // Session-scoped keys: sessions/{id}/{type}/… → namespace is sessions/{id}/{type} + case strings.HasPrefix(cleaned, "sessions/"): + parts := strings.SplitN(cleaned, "/", 4) + if len(parts) >= 3 { + return strings.Join(parts[:3], "/") + } + return "sessions" case strings.HasPrefix(cleaned, string(NamespaceBrowserScreenshots)+"/"): return string(NamespaceBrowserScreenshots) case strings.HasPrefix(cleaned, string(NamespaceBrowserDownloads)+"/"): diff --git a/internal/storage/reaper.go b/internal/storage/reaper.go index e580b8d..c43d4d1 100644 --- a/internal/storage/reaper.go +++ b/internal/storage/reaper.go @@ -22,12 +22,11 @@ type Reaper struct { func DefaultReaperConfig() ReaperConfig { return ReaperConfig{ Interval: 1 * time.Hour, - Namespaces: []ArtifactNamespace{ - NamespaceWebArtifacts, - NamespaceBrowserScreenshots, - NamespaceBrowserDownloads, - }, - Limit: 512, + // Only namespaces with globally-shared, time-expiring content need GC. + // Session-scoped content (screenshots, downloads, web artifacts) is cleaned + // up by DeleteSessionDir when a session is deleted — no reaper needed. + Namespaces: []ArtifactNamespace{}, + Limit: 512, } } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index d3ff0ef..c1a7f0d 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -30,8 +30,13 @@ const ( const DefaultS3Region = "us-east-1" +// DefaultLocalPath returns the root directory for the local artifact store. +// Using the runtime root directly (not a storage/ subdirectory) so that +// session-scoped keys (sessions/{id}/images/, sessions/{id}/tools/, …) land +// at the same level as plans and logs — all under ~/.config/nexus-cli/. +// Non-session namespaces (documents/, rag/, artifacts/web/) are unaffected. func DefaultLocalPath() string { - return runtimepath.StorageDir("") + return runtimepath.ResolveRoot("") } type Config struct { diff --git a/internal/tools/special/agents/agent_tool.go b/internal/tools/special/agents/agent_tool.go index fdc88f4..8d2550d 100644 --- a/internal/tools/special/agents/agent_tool.go +++ b/internal/tools/special/agents/agent_tool.go @@ -11,6 +11,7 @@ import ( "github.com/EngineerProjects/nexus-engine/internal/engine" "github.com/EngineerProjects/nexus-engine/internal/runtime/tasks" tool "github.com/EngineerProjects/nexus-engine/internal/tools/registry" + "github.com/EngineerProjects/nexus-engine/internal/tools/schema" worktreeTool "github.com/EngineerProjects/nexus-engine/internal/tools/special/worktree" "github.com/EngineerProjects/nexus-engine/internal/types" ) @@ -116,6 +117,47 @@ func (t *AgentTool) Definition() tool.Definition { "is_stateful": false, "subagent_type": "agent", }, + InputSchema: schema.FromMap(map[string]any{ + "type": "object", + "properties": map[string]any{ + "type": map[string]any{ + "type": "string", + "description": "Agent type to run. Must be one of the built-in types.", + "enum": []string{"general-purpose", "explore", "browse", "plan", "verify"}, + }, + "task": map[string]any{ + "type": "string", + "description": "The self-contained task description for the agent. Include exact file paths, goals, constraints, and what to return.", + }, + "maxTurns": map[string]any{ + "type": "integer", + "description": "Maximum autonomous turns. Default: 10 for sub-agents.", + "minimum": 1, + "maximum": 200, + }, + "run_in_background": map[string]any{ + "type": "boolean", + "description": "When true, the agent runs asynchronously and returns a task ID immediately. Use TaskGet to poll status.", + "default": false, + }, + "fork": map[string]any{ + "type": "boolean", + "description": "When true, the sub-agent inherits the parent session's message history.", + "default": false, + }, + "isolation": map[string]any{ + "type": "string", + "description": "Set to 'worktree' to run the agent in an isolated git worktree. Changes are isolated until merged.", + "enum": []string{"worktree"}, + }, + "tools": map[string]any{ + "type": "array", + "description": "Optional list of tool name patterns to restrict the sub-agent's tool surface.", + "items": map[string]any{"type": "string"}, + }, + }, + "required": []string{"type", "task"}, + }), } } @@ -136,9 +178,16 @@ func (t *AgentTool) Call( agentType := "" task := "" + // Accept both "type" and "agent_type" — the LLM sometimes uses the spawn_agent + // convention. Priority: "type" > "agent_type" to keep backwards compat. if v, ok := parsedInput["type"].(string); ok { agentType = v } + if agentType == "" { + if v, ok := parsedInput["agent_type"].(string); ok { + agentType = v + } + } if v, ok := parsedInput["task"].(string); ok { task = v } @@ -147,9 +196,17 @@ func (t *AgentTool) Call( } if agentType == "" { + available := t.listAvailableAgents() + typeList := "" + for _, a := range available { + typeList += fmt.Sprintf(" - %s: %s\n", a["type"], a["whenToUse"]) + } return tool.CallResult{ - Data: map[string]any{"error": "type is required"}, - Content: "Error: type is required", + Data: map[string]any{ + "error": "type is required", + "available": available, + }, + Content: fmt.Sprintf("Error: 'type' field is required. Available agent types:\n%s", typeList), }, nil } if task == "" { @@ -236,16 +293,20 @@ func (t *AgentTool) Call( Content: fmt.Sprintf("Agent failed (worktree): %s\n\nError: %s", result.Output, result.Error), }, nil } + wtData := map[string]any{ + "agentType": agentType, + "task": task, + "turns": result.Turns, + "toolUses": result.ToolUses, + "success": result.Success, + "isolation": "worktree", + "worktreePath": result.WorktreePath, + } + if len(result.Sources) > 0 { + wtData["sources"] = result.Sources + } return tool.CallResult{ - Data: map[string]any{ - "agentType": agentType, - "task": task, - "turns": result.Turns, - "toolUses": result.ToolUses, - "success": result.Success, - "isolation": "worktree", - "worktreePath": result.WorktreePath, - }, + Data: wtData, Content: result.Output + "\n\nWorktree created at: " + result.WorktreePath + "\nUse ExitWorktree to merge or discard changes.", }, nil } @@ -267,17 +328,18 @@ func (t *AgentTool) Call( Content: fmt.Sprintf("Fork agent failed: %s\n\nError: %s", result.Output, result.Error), }, nil } - return tool.CallResult{ - Data: map[string]any{ - "agentType": agentType, - "task": task, - "turns": result.Turns, - "toolUses": result.ToolUses, - "success": result.Success, - "fork": true, - }, - Content: result.Output, - }, nil + forkData := map[string]any{ + "agentType": agentType, + "task": task, + "turns": result.Turns, + "toolUses": result.ToolUses, + "success": result.Success, + "fork": true, + } + if len(result.Sources) > 0 { + forkData["sources"] = result.Sources + } + return tool.CallResult{Data: forkData, Content: result.Output}, nil } result := t.runAgent(ctx, agentType, task, maxTurns, allowedTools, agentEventFn) @@ -287,16 +349,17 @@ func (t *AgentTool) Call( Content: fmt.Sprintf("Agent failed: %s\n\nError: %s", result.Output, result.Error), }, nil } - return tool.CallResult{ - Data: map[string]any{ - "agentType": agentType, - "task": task, - "turns": result.Turns, - "toolUses": result.ToolUses, - "success": result.Success, - }, - Content: result.Output, - }, nil + data := map[string]any{ + "agentType": agentType, + "task": task, + "turns": result.Turns, + "toolUses": result.ToolUses, + "success": result.Success, + } + if len(result.Sources) > 0 { + data["sources"] = result.Sources + } + return tool.CallResult{Data: data, Content: result.Output}, nil } // runAgent executes the agent synchronously. diff --git a/internal/tools/special/agents/resume_agent.go b/internal/tools/special/agents/resume_agent.go new file mode 100644 index 0000000..581d631 --- /dev/null +++ b/internal/tools/special/agents/resume_agent.go @@ -0,0 +1,223 @@ +package agents + +import ( + "context" + "fmt" + "strings" + "time" + + coreagent "github.com/EngineerProjects/nexus-engine/internal/agent" + "github.com/EngineerProjects/nexus-engine/internal/engine" + tool "github.com/EngineerProjects/nexus-engine/internal/tools/registry" + "github.com/EngineerProjects/nexus-engine/internal/tools/schema" + "github.com/EngineerProjects/nexus-engine/internal/types" +) + +const resumeAgentName = "resume_agent" +const resumeAgentSearchHint = "resume a completed sub-agent session with a new task" +const resumeAgentDescription = `Resume a previously completed sub-agent session and send it a new task. + +The resumed agent picks up with its full conversation history intact — it remembers everything it learned, all files it read, and all work it did. Use this to continue a long investigation, request a follow-up analysis, or ask for revisions without restarting from scratch. + +## How to get a session_id +Call ` + "`wait_agent`" + ` after ` + "`spawn_agent`" + ` completes — the result contains a ` + "`session_id`" + ` field. +You can also supply ` + "`agent_id`" + ` (the ID returned by ` + "`spawn_agent`" + `) directly if the agent is still in the registry. + +## When to use +- Ask a research agent for more details or a different angle after seeing its first result +- Request revisions from a writing agent without restating all the context +- Send a follow-up implementation task to a coding agent that already read the codebase +- Chain multi-phase work: plan → implement → test → fix, each as a resume + +## Parameters +- ` + "`session_id`" + `: session ID from ` + "`wait_agent`" + ` result (preferred) +- ` + "`agent_id`" + `: fallback — the agent_id from ` + "`spawn_agent`" + ` (only works if agent is still registered) +- ` + "`task`" + `: new instruction to send to the resumed agent +- ` + "`max_turns`" + `: max autonomous turns for this continuation (default: 10) +- ` + "`async`" + `: if true, run in the background and return an agent_id (like spawn_agent)` + +// ResumeAgentTool resumes a persisted sub-agent session with a new task. +type ResumeAgentTool struct { + manager *coreagent.AsyncAgentManager + eng *engine.Engine + tools []tool.Tool + reg *coreagent.AgentRegistry +} + +func NewResumeAgentTool(eng *engine.Engine, tools []tool.Tool, reg *coreagent.AgentRegistry) *ResumeAgentTool { + return &ResumeAgentTool{ + manager: coreagent.GetDefaultAsyncManager(), + eng: eng, + tools: tools, + reg: reg, + } +} + +func (t *ResumeAgentTool) Definition() tool.Definition { + return tool.Definition{ + Name: resumeAgentName, + DisplayName: "ResumeAgent", + SearchHint: resumeAgentSearchHint, + Description: resumeAgentDescription, + Category: "agents", + InputSchema: schema.FromMap(map[string]any{ + "type": "object", + "properties": map[string]any{ + "session_id": map[string]any{ + "type": "string", + "description": "Session ID from a previous wait_agent result. Preferred over agent_id.", + }, + "agent_id": map[string]any{ + "type": "string", + "description": "Agent ID from spawn_agent. Used to look up the session_id automatically. Only works while the agent is still registered (before close_agent).", + }, + "task": map[string]any{ + "type": "string", + "description": "New task or instruction to send to the resumed agent. The agent sees its full prior history before this message.", + }, + "max_turns": map[string]any{ + "type": "integer", + "description": "Maximum autonomous turns for this continuation. Default: 10.", + "minimum": 1, + "maximum": 50, + }, + "async": map[string]any{ + "type": "boolean", + "description": "If true, run the resumed agent in the background and return an agent_id immediately (like spawn_agent). Default: false (blocks until done).", + }, + }, + "oneOf": []any{ + map[string]any{"required": []string{"session_id", "task"}}, + map[string]any{"required": []string{"agent_id", "task"}}, + }, + }), + IsReadOnly: false, + IsConcurrencySafe: true, + RequiresPermission: false, + } +} + +func (t *ResumeAgentTool) IsEnabled() bool { return true } +func (t *ResumeAgentTool) IsReadOnly(_ map[string]any) bool { return false } +func (t *ResumeAgentTool) IsConcurrencySafe(_ map[string]any) bool { return true } +func (t *ResumeAgentTool) FormatResult(data any) string { return fmt.Sprintf("%v", data) } +func (t *ResumeAgentTool) BackfillInput(_ context.Context, in map[string]any) map[string]any { + return in +} +func (t *ResumeAgentTool) ValidateInput(_ context.Context, in map[string]any) (map[string]any, error) { + sid, _ := in["session_id"].(string) + aid, _ := in["agent_id"].(string) + task, _ := in["task"].(string) + if strings.TrimSpace(sid) == "" && strings.TrimSpace(aid) == "" { + return nil, fmt.Errorf("one of session_id or agent_id is required") + } + if strings.TrimSpace(task) == "" { + return nil, fmt.Errorf("task is required") + } + return in, nil +} +func (t *ResumeAgentTool) CheckPermissions(_ context.Context, in map[string]any, _ tool.ToolUseContext) types.PermissionResult { + return types.Passthrough(in) +} +func (t *ResumeAgentTool) Description(_ context.Context) (string, error) { + return resumeAgentDescription, nil +} + +func (t *ResumeAgentTool) Call( + ctx context.Context, + input tool.CallInput, + _ types.CanUseToolFn, +) (tool.CallResult, error) { + sessionID, _ := input.Parsed["session_id"].(string) + sessionID = strings.TrimSpace(sessionID) + agentID, _ := input.Parsed["agent_id"].(string) + agentID = strings.TrimSpace(agentID) + task, _ := input.Parsed["task"].(string) + task = strings.TrimSpace(task) + runAsync, _ := input.Parsed["async"].(bool) + maxTurns := 10 + if v, ok := input.Parsed["max_turns"].(float64); ok && v >= 1 { + maxTurns = int(v) + } + + if task == "" { + return tool.NewErrorResult(fmt.Errorf("task is required")), nil + } + + // Resolve session_id — either supplied directly or looked up via agent_id. + if sessionID == "" { + if agentID == "" { + return tool.NewErrorResult(fmt.Errorf("one of session_id or agent_id is required")), nil + } + ag, err := t.manager.GetAgent(agentID) + if err != nil { + return tool.NewErrorResult(fmt.Errorf("agent not found: %s — use the session_id from wait_agent instead", agentID)), nil + } + if ag.SessionID == "" { + return tool.NewErrorResult(fmt.Errorf("agent %s has no session_id yet — wait for it to complete first, then use the session_id from wait_agent", agentID)), nil + } + sessionID = string(ag.SessionID) + } + + config := &coreagent.RunConfig{ + AgentType: "general-purpose", + Task: task, + Tools: t.tools, + Engine: t.eng, + MaxTurns: maxTurns, + Context: ctx, + Registry: t.reg, + ResumeFromSessionID: types.SessionID(sessionID), + PermissionMode: types.PermissionModeBypass, + } + + if runAsync { + ag, err := t.manager.StartAgent(config) + if err != nil { + return tool.NewErrorResult(fmt.Errorf("resume_agent (async) failed: %w", err)), nil + } + resp := map[string]any{ + "agent_id": ag.ID, + "status": ag.CollabStatus(), + "session_id": sessionID, + "message": fmt.Sprintf("Resumed agent '%s' running in background. Use wait_agent('%s') to get the result.", ag.ID, ag.ID), + } + res := tool.NewJSONResult(resp) + res.Content = fmt.Sprintf("Resumed agent spawned: %s (session: %s)", ag.ID, sessionID) + return res, nil + } + + // Synchronous: block until the resumed run completes. + startTime := time.Now() + result, err := coreagent.RunAgent(config) + elapsed := time.Since(startTime) + if err != nil { + return tool.NewErrorResult(fmt.Errorf("resume_agent failed: %w", err)), nil + } + if !result.Success { + return tool.NewErrorResult(fmt.Errorf("resume_agent errored: %s", result.Error)), nil + } + + resp := map[string]any{ + "status": "completed", + "output": result.Output, + "turns": result.Turns, + "tool_uses": result.ToolUses, + "elapsed_seconds": elapsed.Seconds(), + "session_id": string(result.SessionID), + } + if len(result.Sources) > 0 { + resp["sources"] = result.Sources + } + res := tool.NewJSONResult(resp) + + summary := fmt.Sprintf("Resumed agent completed in %.1fs (%d turns):\n%s", elapsed.Seconds(), result.Turns, result.Output) + if len(result.Sources) > 0 { + summary += fmt.Sprintf("\n\nSources consulted (%d):", len(result.Sources)) + for _, s := range result.Sources { + summary += fmt.Sprintf("\n [%s] %s", s.Type, s.Value) + } + } + res.Content = summary + return res, nil +} diff --git a/internal/tools/special/agents/spawn_agent.go b/internal/tools/special/agents/spawn_agent.go index f716d92..7ba1f9d 100644 --- a/internal/tools/special/agents/spawn_agent.go +++ b/internal/tools/special/agents/spawn_agent.go @@ -159,6 +159,10 @@ func (t *SpawnAgentTool) Call( Registry: t.reg, Nickname: nickname, Role: role, + // Background agents run in a goroutine that outlives the parent turn; + // bypass permissions so tools are not stuck waiting for an interactive + // prompt after the parent's turn context has been canceled. + PermissionMode: types.PermissionModeBypass, } ag, err := t.manager.StartAgent(config) diff --git a/internal/tools/special/agents/wait_agent.go b/internal/tools/special/agents/wait_agent.go index b2002b5..607869a 100644 --- a/internal/tools/special/agents/wait_agent.go +++ b/internal/tools/special/agents/wait_agent.go @@ -24,6 +24,7 @@ The result includes: - ` + "`output`" + `: the agent's final output text - ` + "`turns`" + `: number of turns taken - ` + "`elapsed_seconds`" + `: wall-clock time the agent ran +- ` + "`session_id`" + `: persisted session ID — pass to ` + "`resume_agent`" + ` to continue from where the agent left off Mirrors Codex's CollabAgentTool = "wait" + CollabWaitingBeginEvent / CollabWaitingEndEvent.` @@ -103,7 +104,12 @@ func (t *WaitAgentTool) Call( ag, err := t.manager.GetAgent(agentID) if err != nil { - return tool.NewErrorResult(fmt.Errorf("agent not found: %s", agentID)), nil + // Help the LLM distinguish agent_id (from spawn_agent) from tool_use_id. + hint := "" + if len(agentID) > 10 && (agentID[8] == '-' || agentID[4] == '-') { + hint = " (this looks like a tool_use_id — use the agent_id returned by spawn_agent instead)" + } + return tool.NewErrorResult(fmt.Errorf("agent not found: %s%s", agentID, hint)), nil } callID := input.ToolContextValue().ToolUseID @@ -176,6 +182,12 @@ func buildWaitResult(ag *coreagent.AsyncAgent) map[string]any { resp["output"] = ag.Result.Output resp["turns"] = ag.Result.Turns resp["tool_uses"] = ag.Result.ToolUses + if len(ag.Result.Sources) > 0 { + resp["sources"] = ag.Result.Sources + } + } + if ag.SessionID != "" { + resp["session_id"] = ag.SessionID } if ag.Error != nil { resp["error"] = ag.Error.Error() @@ -191,7 +203,14 @@ func formatWaitSummary(ag *coreagent.AsyncAgent) string { if ag.Result != nil { output = ag.Result.Output } - return fmt.Sprintf("Agent %s completed in %.1fs:\n%s", ag.ID, ag.GetDuration().Seconds(), output) + summary := fmt.Sprintf("Agent %s completed in %.1fs:\n%s", ag.ID, ag.GetDuration().Seconds(), output) + if ag.Result != nil && len(ag.Result.Sources) > 0 { + summary += fmt.Sprintf("\n\nSources consulted (%d):", len(ag.Result.Sources)) + for _, s := range ag.Result.Sources { + summary += fmt.Sprintf("\n [%s] %s", s.Type, s.Value) + } + } + return summary case "errored": errMsg := "" if ag.Error != nil { diff --git a/internal/tui/app/actions.go b/internal/tui/app/actions.go new file mode 100644 index 0000000..e93c381 --- /dev/null +++ b/internal/tui/app/actions.go @@ -0,0 +1,239 @@ +package app + +import ( + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/EngineerProjects/nexus-engine/internal/tui" + "github.com/EngineerProjects/nexus-engine/internal/tui/components" + clipboard "github.com/atotto/clipboard" +) + +// ─── Clipboard ─────────────────────────────────────────────────────────────── + +// copyToClipboard copies text using OSC 52 (tea.SetClipboard) and the native +// clipboard (atotto/clipboard), then shows a transient notice in the footer. +func (m *Model) copyToClipboard(text, notice string) tea.Cmd { + m.copyNotice = copyNoticeForCapability(notice) + return tea.Sequence( + tea.SetClipboard(text), + func() tea.Msg { + _ = clipboard.WriteAll(text) + return nil + }, + tea.Tick(2*time.Second, func(time.Time) tea.Msg { + return clearCopyNoticeMsg{} + }), + ) +} + +func copyNoticeForCapability(success string) string { + return clipboardNotice(success, nativeClipboardLikelyAvailable(), terminalClipboardLikelyAvailable()) +} + +func clipboardNotice(success string, nativeAvailable, terminalAvailable bool) string { + switch { + case nativeAvailable: + return success + case terminalAvailable: + return success + " (terminal clipboard requested)" + default: + return "Clipboard unavailable: install wl-clipboard or xclip" + } +} + +func nativeClipboardLikelyAvailable() bool { + switch runtime.GOOS { + case "windows", "darwin": + return true + } + for _, name := range []string{"wl-copy", "xclip", "xsel", "pbcopy", "clip.exe", "powershell.exe"} { + if _, err := exec.LookPath(name); err == nil { + return true + } + } + return false +} + +func terminalClipboardLikelyAvailable() bool { + term := strings.TrimSpace(os.Getenv("TERM")) + return term != "" && term != "dumb" +} + +// ─── Workspace commands ─────────────────────────────────────────────────────── + +func boolCmd(ok bool) tea.Cmd { + if ok { + return func() tea.Msg { return nil } + } + return nil +} + +func (m *Model) syncComposerAssist() { + if m.completions.IsOpen() { + m.skillCompletions.Close() + return + } + skills := m.loadSkillCatalog() + m.skillCompletions.Sync(skills, m.input.Value()) +} + +func (m *Model) loadSkillCatalog() []tui.SkillInfo { + if !m.skillCatalogLoaded { + m.skillCatalog = m.workspace.LoadSkills(m.ctx) + m.skillCatalogLoaded = true + } + return m.skillCatalog +} + +func (m Model) loadSessions() tea.Cmd { + return func() tea.Msg { m.workspace.ListSessions(m.ctx); return nil } +} + +func (m Model) listModels() tea.Cmd { + return func() tea.Msg { m.workspace.ListModels(m.ctx); return nil } +} + +func (m Model) createSession() tea.Cmd { + return func() tea.Msg { m.workspace.CreateSession(m.ctx); return nil } +} + +func (m Model) loadSession(id string) tea.Cmd { + return func() tea.Msg { m.workspace.LoadSession(m.ctx, id); return nil } +} + +func (m Model) loadProviderConfig() tea.Cmd { + return func() tea.Msg { + providers := m.workspace.LoadProviderConfig(m.ctx) + return providerConfigLoadedMsg{providers: providers} + } +} + +func (m Model) loadSearchConfig() tea.Cmd { + return func() tea.Msg { + cfg := m.workspace.LoadSearchConfig(m.ctx) + return searchConfigLoadedMsg{config: cfg} + } +} + +func (m *Model) refreshSettingsHubData() { + m.commands.SetSectionItems("commands", buildCommandSettingsItems(m.chat.VerboseInterim())) + m.commands.SetSectionItems("tools", buildToolSettingsItems(m.workspace.LoadToolCatalog(m.ctx))) + m.commands.SetSectionItems("mcp", buildMCPSettingsItems(m.workspace.LoadMCPServers(m.ctx))) + m.skillCatalog = m.workspace.LoadSkills(m.ctx) + m.skillCatalogLoaded = true + m.commands.SetSectionItems("skills", buildSkillSettingsItems(m.skillCatalog)) +} + +func buildCommandSettingsItems(verboseInterim bool) []components.PaletteItem { + verboseDesc := "Currently off · Keep assistant step narration compact between tools" + if verboseInterim { + verboseDesc = "Currently on · Show full assistant step narration between tools" + } + return []components.PaletteItem{ + {Kind: components.PaletteActionKind, ID: "new-session", Name: "New Session", Shortcut: "ctrl+n", Desc: "Start a fresh conversation"}, + {Kind: components.PaletteActionKind, ID: "sessions", Name: "Sessions", Shortcut: "ctrl+s", Desc: "Browse and resume past sessions"}, + {Kind: components.PaletteActionKind, ID: "copy-msg", Name: "Copy Last Message", Shortcut: "ctrl+u", Desc: "Copy your last message to clipboard"}, + {Kind: components.PaletteActionKind, ID: "toggle-verbose-steps", Name: "Verbose Agent Steps", Desc: verboseDesc}, + {Kind: components.PaletteActionKind, ID: "quit", Name: "Quit", Shortcut: "ctrl+c", Desc: "Exit Nexus"}, + } +} + +func buildToolSettingsItems(items []tui.ToolInfo) []components.PaletteItem { + if len(items) == 0 { + return []components.PaletteItem{{ + Kind: components.PaletteInfoKind, + ID: "tools-empty", + Name: "No tools found", + Desc: "The current runtime did not expose any tools", + }} + } + result := make([]components.PaletteItem, 0, len(items)) + for _, item := range items { + desc := strings.TrimSpace(item.Description) + if category := strings.TrimSpace(item.Category); category != "" { + if desc != "" { + desc = category + " · " + desc + } else { + desc = category + } + } + result = append(result, components.PaletteItem{ + Kind: components.PaletteInfoKind, + ID: "tool-" + item.Name, + Name: item.Name, + Desc: desc, + }) + } + return result +} + +func buildMCPSettingsItems(items []tui.MCPServerInfo) []components.PaletteItem { + if len(items) == 0 { + return []components.PaletteItem{{ + Kind: components.PaletteInfoKind, + ID: "mcp-empty", + Name: "No MCP servers configured", + Desc: "Add MCP servers in config to expose them here", + }} + } + result := make([]components.PaletteItem, 0, len(items)) + for _, item := range items { + desc := item.Status + " · " + strconv.Itoa(item.ToolsRegistered) + " tools" + if item.Error != "" { + desc += " · " + item.Error + } + result = append(result, components.PaletteItem{ + Kind: components.PaletteInfoKind, + ID: "mcp-" + item.Name, + Name: item.Name, + Desc: desc, + }) + } + return result +} + +func buildSkillSettingsItems(items []tui.SkillInfo) []components.PaletteItem { + if len(items) == 0 { + return []components.PaletteItem{{ + Kind: components.PaletteInfoKind, + ID: "skills-empty", + Name: "No skills found", + Desc: "Add bundled, repo, or user skills to invoke them with /skill", + }} + } + result := make([]components.PaletteItem, 0, len(items)) + for _, item := range items { + desc := strings.TrimSpace(item.Description) + if desc == "" { + desc = strings.TrimSpace(item.WhenToUse) + } + if source := strings.TrimSpace(item.Source); source != "" { + if desc != "" { + desc = source + " · " + desc + } else { + desc = source + } + } + result = append(result, components.PaletteItem{ + Kind: components.PaletteInfoKind, + ID: "skill-" + item.Name, + Name: "/" + item.Name, + Desc: desc, + }) + } + return result +} + +func (m Model) deleteSession(id string) tea.Cmd { + return func() tea.Msg { + err := m.workspace.DeleteSession(m.ctx, id) + m.workspace.ListSessions(m.ctx) + return sessionDeleteResultMsg{id: id, err: err} + } +} diff --git a/internal/tui/app/keys.go b/internal/tui/app/keys.go new file mode 100644 index 0000000..94d7511 --- /dev/null +++ b/internal/tui/app/keys.go @@ -0,0 +1,725 @@ +package app + +import ( + "strings" + + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" + "github.com/EngineerProjects/nexus-engine/internal/tui" + "github.com/EngineerProjects/nexus-engine/internal/tui/common" + "github.com/EngineerProjects/nexus-engine/internal/tui/components" + clipboard "github.com/atotto/clipboard" +) + +// pendingSubmitMsg is used to queue a prompt while session creation is pending. +// openSettingsMsg triggers the provider settings panel — sent on first run when +// no provider is configured. +type openSettingsMsg struct{} + +type pendingSubmitMsg struct{ prompt string } + +// clearCopyNoticeMsg clears the transient "Copied!" footer message. +type clearCopyNoticeMsg struct{} + +// cfgSaveResultMsg is sent after attempting to save a provider credential. +type cfgSaveResultMsg struct{ err error } + +// providerConfigLoadedMsg carries a refreshed provider list. +type providerConfigLoadedMsg struct{ providers []tui.ProviderStatus } + +type sessionDeleteResultMsg struct { + id string + err error +} + +// searchConfigLoadedMsg carries the refreshed search configuration. +type searchConfigLoadedMsg struct{ config tui.SearchConfig } + +// searchKeySaveResultMsg is sent after attempting to save a search provider key. +type searchKeySaveResultMsg struct{ err error } + +// searchModeSaveResultMsg is sent after attempting to save the search mode. +type searchModeSaveResultMsg struct{ err error } + +// handleKey processes a keypress. Returns (consumed, cmd): +// - consumed=true → key was handled; do NOT forward to textarea +// - consumed=false → key was not handled; forward to textarea for normal input +func (m *Model) handleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { + k := msg.String() + stroke := msg.Keystroke() + + if stroke == "ctrl+shift+c" { + if text := m.chat.SelectedText(); text != "" { + return true, m.copyToClipboard(text, "Selection copied") + } + return true, nil + } + + // ── Global quit/cancel — always handled before any overlay state ───── + // These must come first so ctrl+c / ctrl+q work even when a picker or + // panel is open (those state blocks all do "return true, nil" and would + // swallow the key otherwise). + switch k { + case "ctrl+c": + if m.busy { + m.workspace.Cancel() + m.cancelling = true + return true, nil + } + m.cancel() + return true, tea.Quit + case "ctrl+q": + m.cancel() + return true, tea.Quit + } + + // ── Permission dialog (all keys consumed) ──────────────────────────── + if m.state == statePermission && m.permission.HasPending() { + // Scroll keys are handled by the dialog itself first. + if m.permission.HandleKey(k) { + return true, nil + } + switch { + case k == "y" || k == "Y": + m.permission.Resolve(true, false) + m.state = stateChat + case k == "n" || k == "N" || k == "esc": + m.permission.Resolve(false, true) + m.state = stateChat + case k == "a" || k == "A": + m.permission.Resolve("always", false) + m.state = stateChat + default: + m.permInput += k + } + return true, nil + } + + // ── Model selection (all keys consumed) ───────────────────────────── + if m.state == stateModelSelect { + switch k { + case "esc", "ctrl+m": + if m.returnState == stateCommands { + m.refreshSettingsHubData() + m.state = stateCommands + m.commands.Open("") + } else { + m.state = m.prevChatState() + } + case "left": + // Navigate back to the settings hub if that's where we came from, + // otherwise close to the chat/welcome state. + if m.returnState == stateCommands { + m.refreshSettingsHubData() + m.state = stateCommands + m.commands.Open("") + } else { + m.state = m.prevChatState() + } + case "up": + m.modelSelect.Up() + case "down": + m.modelSelect.Down() + case "enter": + if sel := m.modelSelect.Selected(); sel != nil { + m.workspace.SetModel(sel.Provider, sel.Identifier) + m.state = m.prevChatState() + } + case "backspace": + m.modelSelect.DeleteFilter() + default: + if len(k) == 1 { + m.modelSelect.TypeFilter(k) + } + } + return true, nil + } + + // ── Settings hub (all keys consumed) ──────────────────────────────── + if m.state == stateCommands { + switch k { + case "esc", "ctrl+p": + if !m.commands.Back() { + m.state = m.prevChatState() + } + case "left": + if !m.commands.Back() { + m.state = m.prevChatState() + } + case "up": + m.commands.Up() + case "down": + m.commands.Down() + case "enter": + return true, m.activateSettingsSelection() + case "backspace": + m.commands.DeleteFilter() + default: + if len(k) == 1 { + m.commands.TypeFilter(k) + } + } + return true, nil + } + + // ── Provider config panel (all keys consumed) ─────────────────────── + if m.state == stateProviderConfig { + cp := m.configPanel + if cp.IsEditing() { + switch k { + case "esc": + m.state = m.prevChatState() + case "left": + cp.ExitEdit() + // Reload provider status after editing. + return true, m.loadProviderConfig() + case "up": + cp.Up() + case "down": + cp.Down() + case "tab": + cp.Down() + case "enter": + draft, _, fieldKey := cp.CurrentFieldDraft() + if strings.TrimSpace(draft) == "" { + return true, nil + } + providerID := cp.EditedProviderID() + return true, func() tea.Msg { + err := m.workspace.SaveProviderField(m.ctx, providerID, fieldKey, strings.TrimSpace(draft)) + if err != nil { + return cfgSaveResultMsg{err: err} + } + return cfgSaveResultMsg{} + } + case "backspace": + cp.DeleteChar() + case "ctrl+v": + if text, err := clipboard.ReadAll(); err == nil && text != "" { + cp.TypeString(text) + } + return true, nil + case "ctrl+r": + cp.ToggleReveal() + default: + if len(k) == 1 { + cp.TypeChar(k) + } + } + } else { + switch k { + case "esc", "ctrl+,": + if m.returnState == stateCommands { + m.refreshSettingsHubData() + m.state = stateCommands + m.commands.Open("") + } else { + m.state = m.prevChatState() + } + case "up": + cp.Up() + case "down": + cp.Down() + case "enter": + cp.EnterEdit() + case "backspace": + cp.DeleteFilter() + default: + if len(k) == 1 { + cp.TypeFilter(k) + } + } + } + return true, nil + } + + // ── Search config panel (all keys consumed) ────────────────────────── + if m.state == stateSearchConfig { + sp := m.searchPanel + switch { + case sp.IsEditingKey(): + switch k { + case "esc": + m.state = m.prevChatState() + case "left": + sp.ExitKeyEdit() + case "enter": + draft, dbKey := sp.CurrentDraft() + if strings.TrimSpace(draft) == "" { + sp.ExitKeyEdit() + return true, nil + } + return true, func() tea.Msg { + err := m.workspace.SaveSearchKey(m.ctx, dbKey, strings.TrimSpace(draft)) + return searchKeySaveResultMsg{err: err} + } + case "backspace": + sp.DeleteChar() + case "ctrl+v": + if text, err := clipboard.ReadAll(); err == nil && text != "" { + sp.TypeString(text) + } + return true, nil + case "ctrl+r": + sp.ToggleReveal() + default: + if len(k) == 1 { + sp.TypeChar(k) + } + } + case sp.IsEditingMode(): + switch k { + case "esc": + m.state = m.prevChatState() + case "left": + sp.ExitModeEdit() + case "up": + sp.Up() + case "down": + sp.Down() + case "enter": + chosen := sp.ConfirmMode() + if chosen != "" { + return true, func() tea.Msg { + err := m.workspace.SaveSearchMode(m.ctx, chosen) + return searchModeSaveResultMsg{err: err} + } + } + } + default: + switch k { + case "esc": + if m.returnState == stateCommands { + m.refreshSettingsHubData() + m.state = stateCommands + m.commands.Open("") + } else { + m.state = m.prevChatState() + } + case "up": + sp.Up() + case "down": + sp.Down() + case "enter": + sp.EnterList() + } + } + return true, nil + } + + // ── Session browser (all keys consumed) ───────────────────────────── + if m.state == stateSessions { + switch k { + case "esc", "ctrl+s": + m.state = m.prevChatState() + case "up": + m.sessions.Up() + case "down": + m.sessions.Down() + case "enter": + id := m.sessions.Selected() + if id != "" { + m.state = stateChat + return true, m.loadSession(id) + } + case "d", "delete": + id := m.sessions.DeleteSelected() + if id != "" { + if id == m.activeSession { + m.activeSession = "" + m.lastTurnErr = "" + m.lastErr = nil + m.busy = false + m.chat.Clear() + m.state = stateWelcome + } + return true, m.deleteSession(id) + } + case "backspace": + m.sessions.DeleteFilter() + default: + if len(k) == 1 { + m.sessions.TypeFilter(k) + } + } + return true, nil + } + + // ── Global shortcuts (always consumed) ────────────────────────────── + // Note: ctrl+c and ctrl+q are already handled at the top of handleKey. + switch k { + case "ctrl+p": + if m.state != stateCommands { + m.refreshSettingsHubData() + m.commands.Open("") + m.state = stateCommands + } + return true, nil + case "ctrl+,": + if m.state != stateProviderConfig { + m.state = stateProviderConfig + return true, m.loadProviderConfig() + } + return true, nil + case "ctrl+s": + if m.state == stateChat || m.state == stateWelcome { + m.state = stateSessions + return true, m.loadSessions() + } + case "ctrl+n": + return true, m.createSession() + case "ctrl+m": + if m.state != stateModelSelect { + m.returnState = m.prevChatState() + m.state = stateModelSelect + m.modelSelect.ClearFilter() + return true, m.listModels() + } + case "tab": + // Tab toggles between editor focus (typing) and main focus (scrolling). + if m.state == stateChat { + if m.focus == uiFocusEditor { + m.focus = uiFocusMain + m.input.Blur() + } else { + m.focus = uiFocusEditor + return true, m.input.Focus() + } + return true, nil + } + case "ctrl+o": + if m.state == stateChat || m.state == stateWelcome { + opened := m.chat.ToggleDetails() + *m = m.relayout() + // Auto-switch focus so arrow keys scroll the sidebar immediately + // without requiring an extra Tab press. + if opened { + m.focus = uiFocusMain + m.input.Blur() + } else if m.focus == uiFocusMain { + m.focus = uiFocusEditor + return true, m.input.Focus() + } + return true, boolCmd(opened) + } + return false, nil + case "esc": + if m.busy && (m.state == stateChat || m.state == stateWelcome) { + m.workspace.Cancel() + m.cancelling = true + return true, nil + } + } + + // ── Chat / welcome: dispatch by focus state (crush pattern) ────────── + if m.state == stateChat || m.state == stateWelcome { + + // When focus is on the chat list, arrow keys scroll rather than move cursor. + if m.focus == uiFocusMain { + switch k { + case "up": + if m.chat.DetailsOpen() { + m.chat.DetailScrollUp(3) + } else { + m.chat.ScrollUp(3) + } + return true, nil + case "down": + if m.chat.DetailsOpen() { + m.chat.DetailScrollDown(3) + } else { + m.chat.ScrollDown(3) + } + return true, nil + case "pgup": + if m.chat.DetailsOpen() { + m.chat.DetailPageUp() + } else { + m.chat.PageUp() + } + return true, nil + case "pgdown": + if m.chat.DetailsOpen() { + m.chat.DetailPageDown() + } else { + m.chat.PageDown() + } + return true, nil + case "home": + if m.chat.DetailsOpen() { + m.chat.DetailGotoTop() + } else { + m.chat.GotoTop() + } + return true, nil + case "end": + if m.chat.DetailsOpen() { + m.chat.DetailGotoBottom() + } else { + m.chat.GotoBottom() + } + return true, nil + case "n": + return true, boolCmd(m.chat.SelectNextTool()) + case "p": + return true, boolCmd(m.chat.SelectPrevTool()) + case "space": + return true, boolCmd(m.chat.ToggleSelectedToolExpanded()) + case "o", "enter", "right": + opened := m.chat.ToggleDetails() + *m = m.relayout() + return true, boolCmd(opened) + case "left", "esc": + if m.chat.DetailsOpen() { + m.chat.CloseDetails() + *m = m.relayout() + // Return focus to editor when sidebar closes. + m.focus = uiFocusEditor + return true, m.input.Focus() + } + if !m.busy { + m.state = stateWelcome + } + return true, nil + } + m.focus = uiFocusEditor + return true, m.input.Focus() + } + // ── Editor focus (default) ──────────────────────────────────────── + + // Slash-skill suggestions intercept keys while open. + if m.skillCompletions.IsOpen() { + switch k { + case "esc": + m.skillCompletions.Close() + return true, nil + case "up": + m.skillCompletions.Up() + return true, nil + case "down": + m.skillCompletions.Down() + return true, nil + case "enter", "tab": + if sel := m.skillCompletions.Selected(); sel != "" { + m.input.SetValue(sel + " ") + m.input.CursorEnd() + m.skillCompletions.Close() + *m = m.resizeInput() + return true, nil + } + m.skillCompletions.Close() + return false, nil + default: + return false, nil + } + } + + // File completions popup intercepts keys while open. + if m.completions.IsOpen() { + switch k { + case "esc": + m.completions.Close() + case "up": + m.completions.Up() + case "down": + m.completions.Down() + case "enter", "tab": + if sel := m.completions.Selected(); sel != "" { + query := m.completions.Query() + val := m.input.Value() + atIdx := strings.LastIndex(val, "@"+query) + if atIdx >= 0 { + m.input.SetValue(val[:atIdx] + sel + val[atIdx+len("@"+query):]) + } + m.completions.Close() + } + case "backspace": + m.completions.Backspace() + default: + if len(k) == 1 && k != "@" { + m.completions.TypeChar(k) + } else { + m.completions.Close() + return false, nil + } + } + return true, nil + } + + switch k { + case "esc": + if !m.busy { + m.state = stateWelcome + return true, nil + } + + case "/": + // Slash is reserved for skills. Let the textarea receive it directly. + return false, nil + + case "@": + // Open completions AND let textarea receive @ to show it in the input. + // Only trigger in chat/welcome state AND when a model is configured. + // Never in config panels where @ might be part of a credential or email. + if (m.state == stateChat || m.state == stateWelcome) && m.workspace.ModelString() != "" { + m.completions.Open(m.workspace.WorkingDir()) + } + // Fall through to textarea (consumed=false) so @ appears in input. + return false, nil + + case "enter": + text := strings.TrimSpace(m.input.Value()) + if text == "" || m.busy { + return true, nil + } + if m.activeSession == "" { + return true, tea.Batch(m.createSession(), func() tea.Msg { + return pendingSubmitMsg{prompt: text} + }) + } + atts := m.attachments.List() + _ = atts + m.attachments.Reset() + m.input.Reset() + *m = m.resizeInput() + m.chat.AddUserMessage(text) + m.workspace.Submit(m.ctx, text) + m.syncComposerAssist() + return true, nil + + case "shift+enter", "alt+enter": + // crush uses InsertRune('\n') directly — more reliable than Update(msg). + m.input.InsertRune('\n') + return true, nil + + case "ctrl+t": + // Toggle thinking block collapse on the most recent assistant message. + m.chat.ToggleThinking() + return true, nil + + case "ctrl+u": + // Copy last user message to clipboard. + text := m.chat.GetLastUserText() + if text != "" { + return true, m.copyToClipboard(text, "Message copied") + } + return true, nil + + case "ctrl+a": + return true, nil + + case "pgup": + m.chat.PageUp() + return true, nil + case "pgdown": + m.chat.PageDown() + return true, nil + case "home": + m.chat.GotoTop() + return true, nil + case "end": + m.chat.GotoBottom() + return true, nil + } + } + + // Key was not handled — forward to the textarea. + return false, nil +} + +func (m *Model) activateSettingsSelection() tea.Cmd { + sel := m.commands.Selected() + if sel == nil { + return nil + } + switch sel.Kind { + case components.PaletteSectionKind: + m.commands.OpenSection(sel.ID) + return nil + case components.PaletteRouteKind: + switch sel.ID { + case "providers": + m.returnState = stateCommands + m.state = stateProviderConfig + return m.loadProviderConfig() + case "models": + m.returnState = stateCommands + m.state = stateModelSelect + m.modelSelect.ClearFilter() + return m.listModels() + case "search": + m.returnState = stateCommands + m.state = stateSearchConfig + return m.loadSearchConfig() + } + case components.PaletteActionKind: + cmd := m.executeCommand(sel.ID) + if m.state == stateCommands { + m.state = m.prevChatState() + } + return cmd + case components.PaletteInfoKind: + if strings.HasPrefix(sel.Name, "/") { + return m.insertSkillIntoComposer(sel.Name) + } + return nil + } + return nil +} + +func (m *Model) insertSkillIntoComposer(skill string) tea.Cmd { + m.state = m.prevChatState() + m.focus = uiFocusEditor + m.input.SetValue(skill + " ") + m.input.CursorEnd() + *m = m.resizeInput() + return m.input.Focus() +} + +func (m *Model) executeCommand(id string) tea.Cmd { + switch id { + case "new-session": + return m.createSession() + case "sessions": + m.state = stateSessions + return m.loadSessions() + case "model": + m.returnState = stateCommands + m.state = stateModelSelect + m.modelSelect.ClearFilter() + return m.listModels() + case "thinking": + m.chat.ToggleThinking() + return nil + case "copy-msg": + text := m.chat.GetLastUserText() + if text != "" { + return m.copyToClipboard(text, "Message copied") + } + return nil + case "toggle-verbose-steps": + m.chat.SetVerboseInterim(!m.chat.VerboseInterim()) + m.refreshSettingsHubData() + return nil + case "provider-config": + m.state = stateProviderConfig + return m.loadProviderConfig() + case "quit": + m.cancel() + return tea.Quit + default: + return nil + } +} + +func editorPrompt(styles common.Styles) func(textarea.PromptInfo) string { + return func(info textarea.PromptInfo) string { + if info.LineNumber == 0 { + if info.Focused { + return styles.InputPrompt.Render("> ") + } + return styles.InputHint.Render("> ") + } + return " " + } +} diff --git a/internal/tui/app/layout.go b/internal/tui/app/layout.go new file mode 100644 index 0000000..20e9c2b --- /dev/null +++ b/internal/tui/app/layout.go @@ -0,0 +1,340 @@ +package app + +import ( + "fmt" + "strconv" + "strings" + + "charm.land/lipgloss/v2" + "github.com/EngineerProjects/nexus-engine/internal/tui/common" +) + +type chatLayout struct { + contentW int + contentX int + chatX int + chatY int + chatW int + chatH int + detailX int + detailY int + detailW int + detailH int + inputX int + inputY int + inputW int + inputH int + popupX int + popupY int + popupW int + popupH int +} + +func (m Model) currentChatLayout() chatLayout { + contentW := m.contentWidth() + contentX := max(0, (m.width-contentW)/2) + chatY := headerHeight + contentTopGap + chatW := contentW + + inputW := max(12, contentW-2) + inputX := max(0, (m.width-inputW)/2) + + var ( + detailX, detailY, detailW, detailH int + popupW, popupH int + statusH, inputH, chatH int + ) + + if m.chat.DetailsOpen() && contentW >= 110 { + paneW := max(36, contentW/3) + chatW = max(40, contentW-paneW-1) + detailW = contentW - chatW - 1 + + leftStatus := m.statusLineFor(chatW) + leftInput := m.inputViewFor(chatW) + statusH = lipgloss.Height(leftStatus) + inputH = lipgloss.Height(leftInput) + chatH = m.height - headerHeight - contentTopGap - footerHeight - statusH - inputH + detailH = max(1, chatH) + statusH + inputH + + detailX = contentX + chatW + 1 + detailY = chatY + inputX = contentX + inputW = max(12, chatW-2) + } else { + statusView := m.statusLine() + inputView := m.inputView() + statusH = lipgloss.Height(statusView) + inputH = lipgloss.Height(inputView) + chatH = m.height - headerHeight - contentTopGap - footerHeight - statusH - inputH + } + + inputY := chatY + max(1, chatH) + statusH + + popupX := inputX + if m.skillCompletions.IsOpen() { + popupW = m.skillCompletions.Width(max(24, contentW-4)) + popupH = m.skillCompletions.Height(max(24, contentW-4)) + } + + return chatLayout{ + contentW: contentW, + contentX: contentX, + chatX: contentX, + chatY: chatY, + chatW: chatW, + chatH: max(1, chatH), + detailX: detailX, + detailY: detailY, + detailW: detailW, + detailH: detailH, + inputX: inputX, + inputY: inputY, + inputW: inputW, + inputH: inputH, + popupX: popupX, + popupY: inputY, + popupW: popupW, + popupH: popupH, + } +} + +func (m Model) header() string { + contentW := m.contentWidth() + logo := m.styles.Logo.Render("NEXUS") + model := m.styles.HeaderPill.Render(m.workspace.ModelString()) + left := lipgloss.JoinHorizontal(lipgloss.Center, logo, " ", model) + var modeBadge string + switch m.chat.ExecutionMode() { + case "plan": + modeBadge = lipgloss.NewStyle(). + Foreground(common.ColorPrimary). + Bold(true). + Render(" ◈ plan") + case "pair_programming": + modeBadge = lipgloss.NewStyle(). + Foreground(common.ColorSecondary). + Bold(true). + Render(" ◎ pair") + default: + modeBadge = lipgloss.NewStyle(). + Foreground(common.ColorMuted). + Render(" ● execute") + } + left = lipgloss.JoinHorizontal(lipgloss.Center, left, modeBadge) + + var right string + if m.focus == uiFocusMain && m.state == stateChat { + right = lipgloss.JoinHorizontal( + lipgloss.Center, + m.styles.HeaderPillActive.Render("tools"), + " ", + m.styles.HeaderID.Render("n/p navigate · space expand · o details"), + ) + } else if m.activeSession != "" { + right = lipgloss.JoinHorizontal( + lipgloss.Center, + m.styles.HeaderPillReady.Render("● live"), + " ", + m.styles.HeaderPill.Render(common.ShortID(m.activeSession)), + ) + } else { + right = m.styles.HeaderPillReady.Render("ready") + } + + gap := contentW - lipgloss.Width(left) - lipgloss.Width(right) - m.styles.HeaderBar.GetHorizontalFrameSize() + if gap < 1 { + gap = 1 + } + content := m.styles.HeaderBar.Width(contentW).Render(left + strings.Repeat(" ", gap) + right) + return common.CenterHorizontally(content, m.width) +} + +func (m Model) statusLineFor(w int) string { + var line string + switch { + case m.busy && m.cancelling: + line = m.styles.Footer.Width(w).Render(m.styles.HeaderPillBusy.Render(m.spinner.View() + " interrupting…")) + case m.busy: + line = m.styles.Footer.Width(w).Render(m.styles.HeaderPillBusy.Render(m.spinner.View() + " working")) + case m.lastTurnErr != "": + line = m.styles.Footer.Width(w).Render(m.styles.ToolError.Render("failed") + " " + m.styles.Desc.Render(truncateStatus(m.lastTurnErr, max(12, w/2)))) + default: + line = m.styles.Footer.Width(w).Render(m.styles.Desc.Render("ready")) + } + return line +} + +func (m Model) statusLine() string { + return common.CenterHorizontally(m.statusLineFor(m.contentWidth()), m.width) +} + +func (m Model) tokenSummary() string { + total := m.sessionInputTokens + m.sessionOutputTokens + if total <= 0 { + return "" + } + parts := []string{formatTokenCount(total) + " total"} + if m.sessionInputTokens > 0 { + parts = append(parts, "in "+formatTokenCount(m.sessionInputTokens)) + } + if m.sessionOutputTokens > 0 { + parts = append(parts, "out "+formatTokenCount(m.sessionOutputTokens)) + } + return m.styles.Desc.Render(strings.Join(parts, " · ")) +} + +func truncateStatus(s string, maxLen int) string { + r := []rune(strings.TrimSpace(s)) + if len(r) <= maxLen { + return string(r) + } + if maxLen <= 1 { + return string(r[:1]) + } + return string(r[:maxLen-1]) + "…" +} + +func formatTokenCount(n int) string { + switch { + case n >= 1_000_000: + return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000), ".0"), ".") + "M" + case n >= 1_000: + return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000), ".0"), ".") + "k" + default: + return strconv.Itoa(n) + } +} + +func (m Model) contentWidth() int { + if m.width <= 4 { + return m.width + } + return m.width - 4 +} + +func (m Model) footer() string { + contentW := m.contentWidth() + if m.copyNotice != "" { + return common.CenterHorizontally(m.styles.ToolDone.Width(contentW).Render("✓ "+m.copyNotice), m.width) + } + + var leftItems []string + if m.focus == uiFocusMain && m.state == stateChat { + leftItems = []string{ + m.styles.Key.Render("↑↓") + " " + m.styles.Desc.Render("scroll"), + m.styles.Key.Render("n/p") + " " + m.styles.Desc.Render("tools"), + m.styles.Key.Render("space") + " " + m.styles.Desc.Render("preview"), + m.styles.Key.Render("ctrl+o") + " " + m.styles.Desc.Render("details"), + m.styles.Key.Render("tab") + " " + m.styles.Desc.Render("focus"), + m.styles.Key.Render("ctrl+p") + " " + m.styles.Desc.Render("settings"), + } + } else { + leftItems = []string{ + m.styles.Key.Render("tab") + " " + m.styles.Desc.Render("tools"), + m.styles.Key.Render("ctrl+p") + " " + m.styles.Desc.Render("settings"), + m.styles.Key.Render("ctrl+n") + " " + m.styles.Desc.Render("new"), + m.styles.Key.Render("ctrl+s") + " " + m.styles.Desc.Render("sessions"), + m.styles.Key.Render("ctrl+c") + " " + m.styles.Desc.Render("cancel/quit"), + } + } + left := strings.Join(leftItems, " ") + right := m.tokenSummary() + var line string + if right == "" { + line = m.styles.Footer.Width(contentW).Render(left) + } else { + gap := contentW - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 2 { + gap = 2 + } + line = m.styles.Footer.Width(contentW).Render(left + strings.Repeat(" ", gap) + right) + } + return common.CenterHorizontally(line, m.width) +} + +// inputViewFor renders the input box at a given outer width (without global centering). +func (m Model) inputViewFor(w int) string { + inner := m.input.View() + if attView := m.attachments.View(max(20, w-4)); attView != "" { + inner = attView + "\n" + inner + } + box := m.styles.InputBorder.Width(max(12, w-2)).Render(inner) + boxW := lipgloss.Width(box) + + // Only show popups in chat/welcome states. + if m.state == stateChat || m.state == stateWelcome { + if m.skillCompletions.IsOpen() { + popup := m.skillCompletions.View(max(24, w-4)) + return lipgloss.NewStyle().Width(boxW).Render(popup) + "\n" + box + } + if m.completions.IsOpen() { + popup := m.completions.View(max(20, w-4)) + return lipgloss.NewStyle().Width(boxW).Render(popup) + "\n" + box + } + } + return box +} + +func (m Model) inputView() string { + return common.CenterHorizontally(m.inputViewFor(m.contentWidth()), m.width) +} + +func (m Model) relayout() Model { + contentW := m.contentWidth() + chatW := contentW + inputW := contentW - 4 + if m.chat.DetailsOpen() && contentW >= 110 { + paneW := max(36, contentW/3) + chatW = max(40, contentW-paneW-1) + inputW = chatW - 4 + } + if inputW < 10 { + inputW = 10 + } + m.input.SetWidth(inputW) + m.sessions.SetSize(m.width, m.height) + m.permission.SetSize(m.width, m.height) + m.modelSelect.SetSize(m.width, m.height) + m.commands.SetSize(m.width, m.height) + m.configPanel.SetSize(m.width, m.height) + m.chat.SetSize(chatW, max(1, m.height-headerHeight-contentTopGap-footerHeight-statusHeight-inputMinH-inputPadding)) + return m +} + +func (m Model) resizeInput() Model { + // Count visual rows, not just explicit newlines. + // Text that wraps due to line width doesn't insert \n into the value. + contentW := m.contentWidth() + inputW := contentW - 4 // matches SetWidth in relayout + if m.chat.DetailsOpen() && contentW >= 110 { + paneW := max(36, contentW/3) + chatW := max(40, contentW-paneW-1) + inputW = chatW - 4 + } + promptW := 4 // matches SetPromptFunc(4, ...) + textW := max(1, inputW-promptW) + + visualLines := 0 + for _, line := range strings.Split(m.input.Value(), "\n") { + runes := []rune(line) + if len(runes) == 0 { + visualLines++ + } else { + visualLines += (len(runes) + textW - 1) / textW + } + } + if visualLines < 1 { + visualLines = 1 + } + h := common.Clamp(visualLines, inputMinH, inputMaxH) + m.input.SetHeight(h) + return m +} + +func (m Model) prevChatState() uiState { + if m.activeSession != "" { + return stateChat + } + return stateWelcome +} diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index 7146f17..23f2ebd 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -5,13 +5,6 @@ package app import ( "context" - "fmt" - "os" - "os/exec" - "runtime" - "strconv" - "strings" - "time" "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textarea" @@ -20,7 +13,6 @@ import ( "github.com/EngineerProjects/nexus-engine/internal/tui" "github.com/EngineerProjects/nexus-engine/internal/tui/common" "github.com/EngineerProjects/nexus-engine/internal/tui/components" - clipboard "github.com/atotto/clipboard" ) type uiState uint8 @@ -33,6 +25,7 @@ const ( stateModelSelect stateCommands stateProviderConfig + stateSearchConfig ) // uiFocus mirrors crush's focus model: editor has the cursor / main lets @@ -45,12 +38,13 @@ const ( ) const ( - headerHeight = 1 - footerHeight = 1 - statusHeight = 1 - inputMinH = 1 - inputMaxH = 10 - inputPadding = 1 + headerHeight = 1 + contentTopGap = 1 + footerHeight = 1 + statusHeight = 1 + inputMinH = 1 + inputMaxH = 10 + inputPadding = 1 ) // Model is the top-level BubbleTea model for nexus-engine's TUI. @@ -72,6 +66,7 @@ type Model struct { modelSelect *components.ModelPicker commands *components.CommandPalette configPanel *components.ConfigPanel + searchPanel *components.SearchPanel completions *components.FileCompletions skillCompletions *components.SkillCompletions attachments *components.Attachments @@ -80,7 +75,9 @@ type Model struct { focus uiFocus busy bool + cancelling bool // true between ESC press and TurnDoneMsg arrival activeSession string + pendingPrompt string // queued when Enter is pressed before session creation completes lastErr error permInput string copyNotice string // transient "Copied!" message shown in footer @@ -133,6 +130,7 @@ func New(ws tui.Workspace, ctx context.Context) Model { modelSelect: components.NewModelPicker(styles), commands: components.NewCommandPalette(styles), configPanel: components.NewConfigPanel(styles), + searchPanel: components.NewSearchPanel(styles), completions: components.NewFileCompletions(styles, ws.WorkingDir()), skillCompletions: components.NewSkillCompletions(styles), attachments: components.NewAttachments(styles), @@ -156,6 +154,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case openSettingsMsg: + m.returnState = stateWelcome + m.state = stateProviderConfig + return m, m.loadProviderConfig() + case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -191,7 +194,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.spinner.Tick) case tui.TurnDoneMsg: + wasCancelling := m.cancelling m.busy = false + m.cancelling = false m.lastInputTokens = msg.InputTokens m.lastOutputTokens = msg.OutputTokens m.lastStopReason = msg.StopReason @@ -199,7 +204,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sessionOutputTokens += msg.OutputTokens m.lastTurnErr = "" m.chat.FinishAssistantMessage(msg.InputTokens, msg.OutputTokens, msg.StopReason) - if msg.Err != nil { + if msg.Err != nil && !wasCancelling && + msg.Err != context.Canceled && msg.Err != context.DeadlineExceeded { m.lastTurnErr = msg.Err.Error() m.chat.AddError(msg.Err) } @@ -214,6 +220,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sessions.SetSessions(msg.Sessions) } + case pendingSubmitMsg: + if m.activeSession != "" { + m.chat.AddUserMessage(msg.prompt) + m.workspace.Submit(m.ctx, msg.prompt) + } else { + m.pendingPrompt = msg.prompt + } + case tui.SessionCreatedMsg: if msg.Err != nil { m.lastErr = msg.Err @@ -224,24 +238,64 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sessionInputTokens = 0 m.sessionOutputTokens = 0 m.chat.Clear() - m.sessionInputTokens = 0 - m.sessionOutputTokens = 0 m.chat.AddSystem("New session · " + common.ShortID(msg.ID)) + if prompt := m.pendingPrompt; prompt != "" { + m.pendingPrompt = "" + m.chat.AddUserMessage(prompt) + m.workspace.Submit(m.ctx, prompt) + } cmds = append(cmds, m.input.Focus()) // v2: Focus() returns a Cmd } case tui.SessionLoadedMsg: if msg.Err != nil { m.lastErr = msg.Err + m.lastTurnErr = "session load failed: " + msg.Err.Error() + m.state = stateChat } else { m.activeSession = msg.ID m.state = stateChat m.focus = uiFocusEditor m.chat.Clear() - m.chat.AddSystem("Resumed session · " + common.ShortID(msg.ID)) + for _, entry := range msg.History { + switch entry.Role { + case "user": + m.chat.AddUserMessage(entry.Text) + case "assistant": + m.chat.StartAssistantMessage() + if entry.Thinking != "" { + m.chat.AppendChunk(entry.Thinking, true) + } + if entry.Text != "" { + m.chat.AppendChunk(entry.Text, false) + } + for _, tool := range entry.Tools { + // Start from the persisted TUI metadata (content, + // execution_duration_ms, lines_added, exit_code, …). + // Always inject tool_input so detail renderers can + // access file paths, commands, etc. + meta := make(map[string]any, len(tool.Metadata)+1) + for k, v := range tool.Metadata { + meta[k] = v + } + meta["tool_input"] = tool.Input + m.chat.AddToolProgress(tool.ID, tool.Name, "completed", "", meta) + } + m.chat.FinishAssistantMessage(entry.InputTokens, entry.OutputTokens, entry.StopReason) + } + } + m.chat.AddSystem("↑ session resumed · " + common.ShortID(msg.ID)) cmds = append(cmds, m.input.Focus()) // v2: Focus() returns a Cmd } + case sessionDeleteResultMsg: + if msg.err != nil { + m.lastErr = msg.err + m.lastTurnErr = "session delete failed: " + msg.err.Error() + } else { + m.lastTurnErr = "" + } + case tui.ModelListMsg: if msg.Err == nil { m.modelSelect.SetModels(msg.Models) @@ -266,20 +320,58 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.configPanel.SetSaved() } + case searchConfigLoadedMsg: + m.searchPanel.SetConfig(msg.config) + + case searchKeySaveResultMsg: + if msg.err != nil { + m.searchPanel.SetError(msg.err.Error()) + } else { + m.searchPanel.SetKeySaved() + } + + case searchModeSaveResultMsg: + if msg.err != nil { + m.searchPanel.SetError(msg.err.Error()) + } else { + m.searchPanel.SetModeSaved() + } + // v2 uses KeyPressMsg instead of KeyMsg case tea.KeyPressMsg: + // Defensive: ensure completions are closed if we are not in a chat-related state. + // This handles cases where a state transition happened (e.g. to config) while + // a popup was open. + if m.state != stateChat && m.state != stateWelcome { + if m.completions.IsOpen() { + m.completions.Close() + } + if m.skillCompletions.IsOpen() { + m.skillCompletions.Close() + } + } + consumed, cmd := m.handleKey(msg) if cmd != nil { cmds = append(cmds, cmd) } // Non-consumed keys flow to the textarea so regular characters, // backspace, and cursor movement work normally. - if !consumed && (m.state == stateChat || m.state == stateWelcome) { + // ONLY forward if in chat/welcome state AND a model is configured. + // If no model is configured, the user MUST use global shortcuts (ctrl+p, etc.) + // to set one up before they can type anything. + canType := (m.state == stateChat || m.state == stateWelcome) && m.workspace.ModelString() != "" + if !consumed && canType { newInput, inputCmd := m.input.Update(msg) m.input = newInput cmds = append(cmds, inputCmd) m = m.resizeInput() m.syncComposerAssist() + } else if !consumed && m.state == stateWelcome && m.workspace.ModelString() == "" { + // Explicitly close completions if user tries to type in empty welcome state. + if m.completions.IsOpen() { + m.completions.Close() + } } return m, tea.Batch(cmds...) @@ -344,1341 +436,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) m = m.resizeInput() m.syncComposerAssist() - } - - return m, tea.Batch(cmds...) -} - -// View returns a tea.View (v2 API — not a string). -func (m Model) View() tea.View { - if m.width == 0 { - return tea.NewView("") - } - - var content string - switch m.state { - case stateWelcome: - content = m.viewWelcome() - case stateSessions: - content = m.viewSessions() - case stateModelSelect: - content = m.viewModelSelect() - case stateCommands: - content = m.viewCommands() - case stateProviderConfig: - content = m.viewProviderConfig() - case stateChat, statePermission: - content = m.viewChat() - default: - content = m.viewChat() - } - - v := tea.NewView(content) - v.AltScreen = true - v.MouseMode = tea.MouseModeCellMotion - return v -} - -type chatLayout struct { - contentW int - contentX int - chatX int - chatY int - chatW int - chatH int - detailX int - detailY int - detailW int - detailH int - inputX int - inputY int - inputW int - inputH int - popupX int - popupY int - popupW int - popupH int -} - -func (m Model) currentChatLayout() chatLayout { - contentW := m.contentWidth() - contentX := max(0, (m.width-contentW)/2) - chatY := headerHeight - chatW := contentW - - inputW := max(12, contentW-2) - inputX := max(0, (m.width-inputW)/2) - - var ( - detailX, detailY, detailW, detailH int - popupW, popupH int - statusH, inputH, chatH int - ) - - if m.chat.DetailsOpen() && contentW >= 110 { - paneW := max(36, contentW/3) - chatW = max(40, contentW-paneW-1) - detailW = contentW - chatW - 1 - - leftStatus := m.statusLineFor(chatW) - leftInput := m.inputViewFor(chatW) - statusH = lipgloss.Height(leftStatus) - inputH = lipgloss.Height(leftInput) - chatH = m.height - headerHeight - footerHeight - statusH - inputH - detailH = max(1, chatH) + statusH + inputH - - detailX = contentX + chatW + 1 - detailY = chatY - inputX = contentX - inputW = max(12, chatW-2) } else { - statusView := m.statusLine() - inputView := m.inputView() - statusH = lipgloss.Height(statusView) - inputH = lipgloss.Height(inputView) - chatH = m.height - headerHeight - footerHeight - statusH - inputH - } - - inputY := chatY + max(1, chatH) + statusH - - popupX := inputX - if m.skillCompletions.IsOpen() { - popupW = m.skillCompletions.Width(max(24, contentW-4)) - popupH = m.skillCompletions.Height(max(24, contentW-4)) - } - - return chatLayout{ - contentW: contentW, - contentX: contentX, - chatX: contentX, - chatY: chatY, - chatW: chatW, - chatH: max(1, chatH), - detailX: detailX, - detailY: detailY, - detailW: detailW, - detailH: detailH, - inputX: inputX, - inputY: inputY, - inputW: inputW, - inputH: inputH, - popupX: popupX, - popupY: inputY, - popupW: popupW, - popupH: popupH, - } -} - -func pointInRect(x, y, rx, ry, rw, rh int) bool { - return x >= rx && x < rx+rw && y >= ry && y < ry+rh -} - -func clampMouse(v, lo, hi int) int { - if v < lo { - return lo - } - if v > hi { - return hi - } - return v -} - -func (m *Model) handleMouseClick(msg tea.MouseClickMsg) tea.Cmd { - if m.state != stateChat { - return nil - } - layout := m.currentChatLayout() - if m.skillCompletions.IsOpen() && pointInRect(msg.X, msg.Y, layout.popupX, layout.popupY, layout.popupW, layout.popupH) { - if msg.Button == tea.MouseLeft { - row := msg.Y - layout.popupY - 1 - if sel := m.skillCompletions.ClickRow(row); sel != "" { - m.input.SetValue(sel + " ") - m.input.CursorEnd() - m.skillCompletions.Close() - m.focus = uiFocusEditor - *m = m.resizeInput() - return m.input.Focus() - } - } - return nil - } - if pointInRect(msg.X, msg.Y, layout.inputX, layout.inputY+layout.popupH, layout.inputW, layout.inputH-layout.popupH) { - m.focus = uiFocusEditor - return m.input.Focus() - } - if !pointInRect(msg.X, msg.Y, layout.chatX, layout.chatY, layout.chatW, layout.chatH) { - return nil - } - if msg.Button == tea.MouseRight { - if text := m.chat.SelectedText(); text != "" { - return m.copyToClipboard(text, "Selection copied") - } - return nil - } - if msg.Button != tea.MouseLeft { - return nil - } - m.focus = uiFocusMain - m.input.Blur() - m.chat.HandleMouseDown(msg.X-layout.chatX, msg.Y-layout.chatY) - return nil -} - -func (m *Model) handleMouseMotion(msg tea.MouseMotionMsg) bool { - if m.state != stateChat || !m.chat.HasMouseCapture() { - return false - } - layout := m.currentChatLayout() - relX := msg.X - layout.chatX - relY := msg.Y - layout.chatY - return m.chat.HandleMouseDrag(relX, relY) -} - -func (m *Model) handleMouseRelease(msg tea.MouseReleaseMsg) tea.Cmd { - if m.state != stateChat || !m.chat.HasMouseCapture() { - return nil - } - layout := m.currentChatLayout() - relX := msg.X - layout.chatX - relY := msg.Y - layout.chatY - if text := m.chat.HandleMouseUp(relX, relY); text != "" { - return m.copyToClipboard(text, "Selection copied") - } - return nil -} - -// ─── Key handling ───────────────────────────────────────────────────────────── - -// handleKey processes a keypress. Returns (consumed, cmd): -// - consumed=true → key was handled; do NOT forward to textarea -// - consumed=false → key was not handled; forward to textarea for normal input -func (m *Model) handleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { - k := msg.String() - stroke := msg.Keystroke() - - if stroke == "ctrl+shift+c" { - if text := m.chat.SelectedText(); text != "" { - return true, m.copyToClipboard(text, "Selection copied") - } - return true, nil - } - - // ── Permission dialog (all keys consumed) ──────────────────────────── - if m.state == statePermission && m.permission.HasPending() { - // Scroll keys are handled by the dialog itself first. - if m.permission.HandleKey(k) { - return true, nil - } - switch { - case k == "y" || k == "Y": - m.permission.Resolve(true, false) - m.state = stateChat - case k == "n" || k == "N" || k == "esc": - m.permission.Resolve(false, true) - m.state = stateChat - case k == "a" || k == "A": - m.permission.Resolve("always", false) - m.state = stateChat - default: - m.permInput += k - } - return true, nil - } - - // ── Model selection (all keys consumed) ───────────────────────────── - if m.state == stateModelSelect { - switch k { - case "esc", "ctrl+m": - if m.returnState == stateCommands { - m.refreshSettingsHubData() - m.state = stateCommands - m.commands.Open("") - } else { - m.state = m.prevChatState() - } - case "left": - // Navigate back to the settings hub if that's where we came from, - // otherwise close to the chat/welcome state. - if m.returnState == stateCommands { - m.refreshSettingsHubData() - m.state = stateCommands - m.commands.Open("") - } else { - m.state = m.prevChatState() - } - case "up": - m.modelSelect.Up() - case "down": - m.modelSelect.Down() - case "enter": - if sel := m.modelSelect.Selected(); sel != nil { - m.workspace.SetModel(sel.Provider, sel.Identifier) - m.state = m.prevChatState() - } - case "backspace": - m.modelSelect.DeleteFilter() - default: - if len(k) == 1 { - m.modelSelect.TypeFilter(k) - } - } - return true, nil - } - - // ── Settings hub (all keys consumed) ──────────────────────────────── - if m.state == stateCommands { - switch k { - case "esc", "ctrl+p": - if !m.commands.Back() { - m.state = m.prevChatState() - } - case "left": - if !m.commands.Back() { - m.state = m.prevChatState() - } - case "up": - m.commands.Up() - case "down": - m.commands.Down() - case "enter": - return true, m.activateSettingsSelection() - case "backspace": - m.commands.DeleteFilter() - default: - if len(k) == 1 { - m.commands.TypeFilter(k) - } - } - return true, nil - } - - // ── Provider config panel (all keys consumed) ─────────────────────── - if m.state == stateProviderConfig { - cp := m.configPanel - if cp.IsEditing() { - switch k { - case "esc": - m.state = m.prevChatState() - case "left": - cp.ExitEdit() - // Reload provider status after editing. - return true, m.loadProviderConfig() - case "up": - cp.Up() - case "down": - cp.Down() - case "tab": - cp.Down() - case "enter": - draft, _, fieldKey := cp.CurrentFieldDraft() - if strings.TrimSpace(draft) == "" { - return true, nil - } - providerID := cp.EditedProviderID() - return true, func() tea.Msg { - err := m.workspace.SaveProviderField(m.ctx, providerID, fieldKey, strings.TrimSpace(draft)) - if err != nil { - return cfgSaveResultMsg{err: err} - } - return cfgSaveResultMsg{} - } - case "backspace": - cp.DeleteChar() - case "ctrl+v": - cp.ToggleReveal() - default: - if len(k) == 1 { - cp.TypeChar(k) - } - } - } else { - switch k { - case "esc", "ctrl+,": - if m.returnState == stateCommands { - m.refreshSettingsHubData() - m.state = stateCommands - m.commands.Open("") - } else { - m.state = m.prevChatState() - } - case "up": - cp.Up() - case "down": - cp.Down() - case "enter": - cp.EnterEdit() - case "backspace": - cp.DeleteFilter() - default: - if len(k) == 1 { - cp.TypeFilter(k) - } - } - } - return true, nil - } - - // ── Session browser (all keys consumed) ───────────────────────────── - if m.state == stateSessions { - switch k { - case "esc", "ctrl+s": - m.state = m.prevChatState() - case "up": - m.sessions.Up() - case "down": - m.sessions.Down() - case "enter": - id := m.sessions.Selected() - if id != "" { - m.state = stateChat - return true, m.loadSession(id) - } - case "d", "delete": - id := m.sessions.DeleteSelected() - if id != "" { - return true, m.deleteSession(id) - } - case "backspace": - m.sessions.DeleteFilter() - default: - if len(k) == 1 { - m.sessions.TypeFilter(k) - } - } - return true, nil - } - - // ── Global shortcuts (always consumed) ────────────────────────────── - switch k { - case "ctrl+c": - if m.busy { - m.workspace.Cancel() - return true, nil - } - m.cancel() - return true, tea.Quit - case "ctrl+q": - m.cancel() - return true, tea.Quit - case "ctrl+p": - if m.state != stateCommands { - m.refreshSettingsHubData() - m.commands.Open("") - m.state = stateCommands - } - return true, nil - case "ctrl+,": - if m.state != stateProviderConfig { - m.state = stateProviderConfig - return true, m.loadProviderConfig() - } - return true, nil - case "ctrl+s": - if m.state == stateChat || m.state == stateWelcome { - m.state = stateSessions - return true, m.loadSessions() - } - case "ctrl+n": - return true, m.createSession() - case "ctrl+m": - if m.state != stateModelSelect { - m.returnState = m.prevChatState() - m.state = stateModelSelect - m.modelSelect.ClearFilter() - return true, m.listModels() - } - case "tab": - // Tab toggles between editor focus (typing) and main focus (scrolling). - if m.state == stateChat { - if m.focus == uiFocusEditor { - m.focus = uiFocusMain - m.input.Blur() - } else { - m.focus = uiFocusEditor - return true, m.input.Focus() - } - return true, nil - } - case "ctrl+o": - if m.state == stateChat || m.state == stateWelcome { - opened := m.chat.ToggleDetails() - *m = m.relayout() - return true, boolCmd(opened) - } - return false, nil - case "esc": - if m.busy && (m.state == stateChat || m.state == stateWelcome) { - m.workspace.Cancel() - return true, nil - } - } - - // ── Chat / welcome: dispatch by focus state (crush pattern) ────────── - if m.state == stateChat || m.state == stateWelcome { - - // When focus is on the chat list, arrow keys scroll rather than move cursor. - if m.focus == uiFocusMain { - switch k { - case "up": - if m.chat.DetailsOpen() { - m.chat.DetailScrollUp(3) - } else { - m.chat.ScrollUp(3) - } - return true, nil - case "down": - if m.chat.DetailsOpen() { - m.chat.DetailScrollDown(3) - } else { - m.chat.ScrollDown(3) - } - return true, nil - case "pgup": - if m.chat.DetailsOpen() { - m.chat.DetailPageUp() - } else { - m.chat.PageUp() - } - return true, nil - case "pgdown": - if m.chat.DetailsOpen() { - m.chat.DetailPageDown() - } else { - m.chat.PageDown() - } - return true, nil - case "home": - if m.chat.DetailsOpen() { - m.chat.DetailGotoTop() - } else { - m.chat.GotoTop() - } - return true, nil - case "end": - if m.chat.DetailsOpen() { - m.chat.DetailGotoBottom() - } else { - m.chat.GotoBottom() - } - return true, nil - case "n": - return true, boolCmd(m.chat.SelectNextTool()) - case "p": - return true, boolCmd(m.chat.SelectPrevTool()) - case "space": - return true, boolCmd(m.chat.ToggleSelectedToolExpanded()) - case "o", "enter", "right": - opened := m.chat.ToggleDetails() - *m = m.relayout() - return true, boolCmd(opened) - case "left", "esc": - if m.chat.DetailsOpen() { - m.chat.CloseDetails() - *m = m.relayout() - return true, nil - } - if !m.busy { - m.state = stateWelcome - } - return true, nil - } - m.focus = uiFocusEditor - return true, m.input.Focus() - } - // ── Editor focus (default) ──────────────────────────────────────── - - // Slash-skill suggestions intercept keys while open. - if m.skillCompletions.IsOpen() { - switch k { - case "esc": - m.skillCompletions.Close() - return true, nil - case "up": - m.skillCompletions.Up() - return true, nil - case "down": - m.skillCompletions.Down() - return true, nil - case "enter", "tab": - if sel := m.skillCompletions.Selected(); sel != "" { - m.input.SetValue(sel + " ") - m.input.CursorEnd() - m.skillCompletions.Close() - *m = m.resizeInput() - return true, nil - } - m.skillCompletions.Close() - return false, nil - default: - return false, nil - } - } - - // File completions popup intercepts keys while open. + // Ensure completions are closed if we are not in a chat state. + // This is defensive against state transitions while a popup was open. if m.completions.IsOpen() { - switch k { - case "esc": - m.completions.Close() - case "up": - m.completions.Up() - case "down": - m.completions.Down() - case "enter", "tab": - if sel := m.completions.Selected(); sel != "" { - query := m.completions.Query() - val := m.input.Value() - atIdx := strings.LastIndex(val, "@"+query) - if atIdx >= 0 { - m.input.SetValue(val[:atIdx] + sel + val[atIdx+len("@"+query):]) - } - m.completions.Close() - } - case "backspace": - m.completions.Backspace() - default: - if len(k) == 1 && k != "@" { - m.completions.TypeChar(k) - } else { - m.completions.Close() - return false, nil - } - } - return true, nil + m.completions.Close() } - - switch k { - case "esc": - if !m.busy { - m.state = stateWelcome - return true, nil - } - - case "/": - // Slash is reserved for skills. Let the textarea receive it directly. - return false, nil - - case "@": - // Open completions AND let textarea receive @ to show it in the input. - m.completions.Open(m.workspace.WorkingDir()) - // Fall through to textarea (consumed=false) so @ appears in input. - return false, nil - - case "enter": - text := strings.TrimSpace(m.input.Value()) - if text == "" || m.busy { - return true, nil - } - if m.activeSession == "" { - return true, tea.Batch(m.createSession(), func() tea.Msg { - return pendingSubmitMsg{prompt: text} - }) - } - atts := m.attachments.List() - _ = atts - m.attachments.Reset() - m.input.Reset() - *m = m.resizeInput() - m.chat.AddUserMessage(text) - m.workspace.Submit(m.ctx, text) - m.syncComposerAssist() - return true, nil - - case "shift+enter", "alt+enter": - // crush uses InsertRune('\n') directly — more reliable than Update(msg). - m.input.InsertRune('\n') - return true, nil - - case "ctrl+t": - // Toggle thinking block collapse on the most recent assistant message. - m.chat.ToggleThinking() - return true, nil - - case "ctrl+u": - // Copy last user message to clipboard. - text := m.chat.GetLastUserText() - if text != "" { - return true, m.copyToClipboard(text, "Message copied") - } - return true, nil - - case "ctrl+a": - return true, nil - - case "pgup": - m.chat.PageUp() - return true, nil - case "pgdown": - m.chat.PageDown() - return true, nil - case "home": - m.chat.GotoTop() - return true, nil - case "end": - m.chat.GotoBottom() - return true, nil - } - } - - // Key was not handled — forward to the textarea. - return false, nil -} - -func (m *Model) activateSettingsSelection() tea.Cmd { - sel := m.commands.Selected() - if sel == nil { - return nil - } - switch sel.Kind { - case components.PaletteSectionKind: - m.commands.OpenSection(sel.ID) - return nil - case components.PaletteRouteKind: - switch sel.ID { - case "providers": - m.returnState = stateCommands - m.state = stateProviderConfig - return m.loadProviderConfig() - case "models": - m.returnState = stateCommands - m.state = stateModelSelect - m.modelSelect.ClearFilter() - return m.listModels() - } - case components.PaletteActionKind: - cmd := m.executeCommand(sel.ID) - if m.state == stateCommands { - m.state = m.prevChatState() - } - return cmd - case components.PaletteInfoKind: - if strings.HasPrefix(sel.Name, "/") { - return m.insertSkillIntoComposer(sel.Name) - } - return nil - } - return nil -} - -func (m *Model) insertSkillIntoComposer(skill string) tea.Cmd { - m.state = m.prevChatState() - m.focus = uiFocusEditor - m.input.SetValue(skill + " ") - m.input.CursorEnd() - *m = m.resizeInput() - return m.input.Focus() -} - -func (m *Model) executeCommand(id string) tea.Cmd { - switch id { - case "new-session": - return m.createSession() - case "sessions": - m.state = stateSessions - return m.loadSessions() - case "model": - m.returnState = stateCommands - m.state = stateModelSelect - m.modelSelect.ClearFilter() - return m.listModels() - case "thinking": - m.chat.ToggleThinking() - return nil - case "copy-msg": - text := m.chat.GetLastUserText() - if text != "" { - return m.copyToClipboard(text, "Message copied") - } - return nil - case "toggle-verbose-steps": - m.chat.SetVerboseInterim(!m.chat.VerboseInterim()) - m.refreshSettingsHubData() - return nil - case "provider-config": - m.state = stateProviderConfig - return m.loadProviderConfig() - case "quit": - m.cancel() - return tea.Quit - default: - return nil - } -} - -// pendingSubmitMsg is used to queue a prompt while session creation is pending. -type pendingSubmitMsg struct{ prompt string } - -// clearCopyNoticeMsg clears the transient "Copied!" footer message. -type clearCopyNoticeMsg struct{} - -// cfgSaveResultMsg is sent after attempting to save a provider credential. -type cfgSaveResultMsg struct{ err error } - -// providerConfigLoadedMsg carries a refreshed provider list. -type providerConfigLoadedMsg struct{ providers []tui.ProviderStatus } - -func editorPrompt(styles common.Styles) func(textarea.PromptInfo) string { - return func(info textarea.PromptInfo) string { - if info.LineNumber == 0 { - if info.Focused { - return styles.InputPrompt.Render("> ") - } - return styles.InputHint.Render("> ") - } - return " " - } -} - -// ─── Views ──────────────────────────────────────────────────────────────────── - -func (m Model) viewWelcome() string { - // Braille logo rendered in orange primary colour. - logoArt := lipgloss.NewStyle().Foreground(common.ColorPrimary).Render(common.NexusLogo) - - wordmark := m.styles.Logo.Render("NEXUS") - tagline := m.styles.HeaderModel.Render("One runtime. Any LLM. Any language.") - - hint := strings.Join([]string{ - m.styles.Key.Render("ctrl+n") + " " + m.styles.Desc.Render("new session"), - m.styles.Key.Render("ctrl+s") + " " + m.styles.Desc.Render("sessions"), - m.styles.Key.Render("ctrl+q") + " " + m.styles.Desc.Render("quit"), - }, " ") - - contentW := m.contentWidth() - body := lipgloss.NewStyle(). - Width(contentW). - Height(m.height-2). - Align(lipgloss.Center, lipgloss.Center). - Render(logoArt + "\n" + wordmark + "\n\n" + tagline + "\n\n" + hint) - - return m.header() + "\n" + common.CenterHorizontally(body, m.width) -} - -func (m Model) viewChat() string { - contentW := m.contentWidth() - - // ── Sidebar-open: join left column (chat+status+input) with sidebar ─── - if m.chat.DetailsOpen() && contentW >= 110 { - paneW := max(36, contentW/3) - chatW := max(40, contentW-paneW-1) - detailW := contentW - chatW - 1 - - leftStatus := m.statusLineFor(chatW) - leftInput := m.inputViewFor(chatW) - statusH := lipgloss.Height(leftStatus) - inputH := lipgloss.Height(leftInput) - chatH := m.height - headerHeight - footerHeight - statusH - inputH - - m.chat.SetSize(chatW, max(1, chatH)) - leftContent := strings.Join([]string{m.chat.View(), leftStatus, leftInput}, "\n") - sideH := lipgloss.Height(leftContent) - - detailView := m.chat.DetailView(detailW, sideH) - body := lipgloss.JoinHorizontal(lipgloss.Top, leftContent, " ", detailView) - body = common.CenterHorizontally(lipgloss.NewStyle().Width(contentW).Render(body), m.width) - - base := strings.Join([]string{m.header(), body, m.footer()}, "\n") - if m.state == statePermission && m.permission.HasPending() { - return common.OverlayOn(base, m.permission.View(), m.width, m.height) - } - return base - } - - // ── Normal layout ───────────────────────────────────────────────────── - inputView := m.inputView() - statusView := m.statusLine() - chatH := m.height - headerHeight - footerHeight - lipgloss.Height(statusView) - lipgloss.Height(inputView) - m.chat.SetSize(contentW, max(1, chatH)) - body := common.CenterHorizontally(lipgloss.NewStyle().Width(contentW).Render(m.chat.View()), m.width) - - base := strings.Join([]string{m.header(), body, statusView, inputView, m.footer()}, "\n") - if m.state == statePermission && m.permission.HasPending() { - return common.OverlayOn(base, m.permission.View(), m.width, m.height) - } - return base -} -func (m Model) viewSessions() string { - m.sessions.SetSize(m.width, m.height) - overlay := m.sessions.Centered() - var backdrop string - if m.activeSession != "" { - backdrop = m.viewChat() - } else { - backdrop = m.viewWelcome() - } - return common.OverlayOn(backdrop, overlay, m.width, m.height) -} - -func (m Model) viewModelSelect() string { - m.modelSelect.SetSize(m.width, m.height) - overlay := m.modelSelect.Centered() - var backdrop string - if m.activeSession != "" { - backdrop = m.viewChat() - } else { - backdrop = m.viewWelcome() - } - return common.OverlayOn(backdrop, overlay, m.width, m.height) -} - -func (m Model) viewCommands() string { - m.commands.SetSize(m.width, m.height) - overlay := m.commands.Centered() - var backdrop string - if m.activeSession != "" { - backdrop = m.viewChat() - } else { - backdrop = m.viewWelcome() - } - return common.OverlayOn(backdrop, overlay, m.width, m.height) -} - -func (m Model) viewProviderConfig() string { - m.configPanel.SetSize(m.width, m.height) - overlay := m.configPanel.Centered() - var backdrop string - if m.activeSession != "" { - backdrop = m.viewChat() - } else { - backdrop = m.viewWelcome() - } - return common.OverlayOn(backdrop, overlay, m.width, m.height) -} - -func (m Model) header() string { - contentW := m.contentWidth() - logo := m.styles.Logo.Render("NEXUS") - model := m.styles.HeaderPill.Render(m.workspace.ModelString()) - left := lipgloss.JoinHorizontal(lipgloss.Center, logo, " ", model) - - var right string - if m.focus == uiFocusMain && m.state == stateChat { - right = lipgloss.JoinHorizontal( - lipgloss.Center, - m.styles.HeaderPillActive.Render("tools"), - " ", - m.styles.HeaderID.Render("n/p navigate · space expand · o details"), - ) - } else if m.activeSession != "" { - right = lipgloss.JoinHorizontal( - lipgloss.Center, - m.styles.HeaderPillReady.Render("● live"), - " ", - m.styles.HeaderPill.Render(common.ShortID(m.activeSession)), - ) - } else { - right = m.styles.HeaderPillReady.Render("ready") - } - - gap := contentW - lipgloss.Width(left) - lipgloss.Width(right) - m.styles.HeaderBar.GetHorizontalFrameSize() - if gap < 1 { - gap = 1 - } - content := m.styles.HeaderBar.Width(contentW).Render(left + strings.Repeat(" ", gap) + right) - return common.CenterHorizontally(content, m.width) -} - -func (m Model) statusLineFor(w int) string { - var line string - switch { - case m.busy: - line = m.styles.Footer.Width(w).Render(m.styles.HeaderPillBusy.Render(m.spinner.View() + " working")) - case m.lastTurnErr != "": - line = m.styles.Footer.Width(w).Render(m.styles.ToolError.Render("failed") + " " + m.styles.Desc.Render(truncateStatus(m.lastTurnErr, max(12, w/2)))) - default: - line = m.styles.Footer.Width(w).Render(m.styles.Desc.Render("ready")) - } - return line -} - -func (m Model) statusLine() string { - return common.CenterHorizontally(m.statusLineFor(m.contentWidth()), m.width) -} - -func (m Model) tokenSummary() string { - total := m.sessionInputTokens + m.sessionOutputTokens - if total <= 0 { - return "" - } - parts := []string{formatTokenCount(total) + " total"} - if m.sessionInputTokens > 0 { - parts = append(parts, "in "+formatTokenCount(m.sessionInputTokens)) - } - if m.sessionOutputTokens > 0 { - parts = append(parts, "out "+formatTokenCount(m.sessionOutputTokens)) - } - return m.styles.Desc.Render(strings.Join(parts, " · ")) -} - -func truncateStatus(s string, maxLen int) string { - r := []rune(strings.TrimSpace(s)) - if len(r) <= maxLen { - return string(r) - } - if maxLen <= 1 { - return string(r[:1]) - } - return string(r[:maxLen-1]) + "…" -} - -func formatTokenCount(n int) string { - switch { - case n >= 1_000_000: - return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000), ".0"), ".") + "M" - case n >= 1_000: - return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000), ".0"), ".") + "k" - default: - return strconv.Itoa(n) - } -} - -func (m Model) contentWidth() int { - if m.width <= 4 { - return m.width - } - return m.width - 4 -} - -func (m Model) footer() string { - contentW := m.contentWidth() - if m.copyNotice != "" { - return common.CenterHorizontally(m.styles.ToolDone.Width(contentW).Render("✓ "+m.copyNotice), m.width) - } - - var leftItems []string - if m.focus == uiFocusMain && m.state == stateChat { - leftItems = []string{ - m.styles.Key.Render("↑↓") + " " + m.styles.Desc.Render("scroll"), - m.styles.Key.Render("n/p") + " " + m.styles.Desc.Render("tools"), - m.styles.Key.Render("space") + " " + m.styles.Desc.Render("preview"), - m.styles.Key.Render("ctrl+o") + " " + m.styles.Desc.Render("details"), - m.styles.Key.Render("tab") + " " + m.styles.Desc.Render("focus"), - m.styles.Key.Render("ctrl+p") + " " + m.styles.Desc.Render("settings"), - } - } else { - leftItems = []string{ - m.styles.Key.Render("tab") + " " + m.styles.Desc.Render("tools"), - m.styles.Key.Render("ctrl+p") + " " + m.styles.Desc.Render("settings"), - m.styles.Key.Render("ctrl+n") + " " + m.styles.Desc.Render("new"), - m.styles.Key.Render("ctrl+s") + " " + m.styles.Desc.Render("sessions"), - m.styles.Key.Render("ctrl+c") + " " + m.styles.Desc.Render("cancel/quit"), - } - } - left := strings.Join(leftItems, " ") - right := m.tokenSummary() - var line string - if right == "" { - line = m.styles.Footer.Width(contentW).Render(left) - } else { - gap := contentW - lipgloss.Width(left) - lipgloss.Width(right) - if gap < 2 { - gap = 2 - } - line = m.styles.Footer.Width(contentW).Render(left + strings.Repeat(" ", gap) + right) - } - return common.CenterHorizontally(line, m.width) -} - -// inputViewFor renders the input box at a given outer width (without global centering). -func (m Model) inputViewFor(w int) string { - inner := m.input.View() - if attView := m.attachments.View(max(20, w-4)); attView != "" { - inner = attView + "\n" + inner - } - box := m.styles.InputBorder.Width(max(12, w-2)).Render(inner) - boxW := lipgloss.Width(box) - if m.skillCompletions.IsOpen() { - popup := m.skillCompletions.View(max(24, w-4)) - return lipgloss.NewStyle().Width(boxW).Render(popup) + "\n" + box - } - if m.completions.IsOpen() { - popup := m.completions.View(max(20, w-4)) - return lipgloss.NewStyle().Width(boxW).Render(popup) + "\n" + box - } - return box -} - -func (m Model) inputView() string { - return common.CenterHorizontally(m.inputViewFor(m.contentWidth()), m.width) -} - -// ─── Layout ─────────────────────────────────────────────────────────────────── - -func (m Model) relayout() Model { - contentW := m.contentWidth() - chatW := contentW - inputW := contentW - 4 - if m.chat.DetailsOpen() && contentW >= 110 { - paneW := max(36, contentW/3) - chatW = max(40, contentW-paneW-1) - inputW = chatW - 4 - } - if inputW < 10 { - inputW = 10 - } - m.input.SetWidth(inputW) - m.sessions.SetSize(m.width, m.height) - m.permission.SetSize(m.width, m.height) - m.modelSelect.SetSize(m.width, m.height) - m.commands.SetSize(m.width, m.height) - m.configPanel.SetSize(m.width, m.height) - m.chat.SetSize(chatW, max(1, m.height-headerHeight-footerHeight-statusHeight-inputMinH-inputPadding)) - return m -} - -func (m Model) resizeInput() Model { - // Count visual rows, not just explicit newlines. - // Text that wraps due to line width doesn't insert \n into the value. - contentW := m.contentWidth() - inputW := contentW - 4 // matches SetWidth in relayout - if m.chat.DetailsOpen() && contentW >= 110 { - paneW := max(36, contentW/3) - chatW := max(40, contentW-paneW-1) - inputW = chatW - 4 - } - promptW := 4 // matches SetPromptFunc(4, ...) - textW := max(1, inputW-promptW) - - visualLines := 0 - for _, line := range strings.Split(m.input.Value(), "\n") { - runes := []rune(line) - if len(runes) == 0 { - visualLines++ - } else { - visualLines += (len(runes) + textW - 1) / textW - } - } - if visualLines < 1 { - visualLines = 1 - } - h := common.Clamp(visualLines, inputMinH, inputMaxH) - m.input.SetHeight(h) - return m -} - -func (m Model) prevChatState() uiState { - if m.activeSession != "" { - return stateChat - } - return stateWelcome -} - -// ─── Clipboard ─────────────────────────────────────────────────────────────── - -// copyToClipboard copies text using OSC 52 (tea.SetClipboard) and the native -// clipboard (atotto/clipboard), then shows a transient notice in the footer. -// This mirrors crush's CopyToClipboard approach, but avoids claiming success -// when the local session has no actual clipboard backend. -func (m *Model) copyToClipboard(text, notice string) tea.Cmd { - m.copyNotice = copyNoticeForCapability(notice) - return tea.Sequence( - tea.SetClipboard(text), - func() tea.Msg { - _ = clipboard.WriteAll(text) - return nil - }, - tea.Tick(2*time.Second, func(time.Time) tea.Msg { - return clearCopyNoticeMsg{} - }), - ) -} - -func copyNoticeForCapability(success string) string { - return clipboardNotice(success, nativeClipboardLikelyAvailable(), terminalClipboardLikelyAvailable()) -} - -func clipboardNotice(success string, nativeAvailable, terminalAvailable bool) string { - switch { - case nativeAvailable: - return success - case terminalAvailable: - return success + " (terminal clipboard requested)" - default: - return "Clipboard unavailable: install wl-clipboard or xclip" - } -} - -func nativeClipboardLikelyAvailable() bool { - switch runtime.GOOS { - case "windows", "darwin": - return true - } - for _, name := range []string{"wl-copy", "xclip", "xsel", "pbcopy", "clip.exe", "powershell.exe"} { - if _, err := exec.LookPath(name); err == nil { - return true - } - } - return false -} - -func terminalClipboardLikelyAvailable() bool { - term := strings.TrimSpace(os.Getenv("TERM")) - return term != "" && term != "dumb" -} - -// ─── Workspace commands ─────────────────────────────────────────────────────── - -func boolCmd(ok bool) tea.Cmd { - if ok { - return func() tea.Msg { return nil } - } - return nil -} - -func (m *Model) syncComposerAssist() { - if m.completions.IsOpen() { - m.skillCompletions.Close() - return - } - skills := m.loadSkillCatalog() - m.skillCompletions.Sync(skills, m.input.Value()) -} - -func (m *Model) loadSkillCatalog() []tui.SkillInfo { - if !m.skillCatalogLoaded { - m.skillCatalog = m.workspace.LoadSkills(m.ctx) - m.skillCatalogLoaded = true - } - return m.skillCatalog -} - -func (m Model) loadSessions() tea.Cmd { - return func() tea.Msg { m.workspace.ListSessions(m.ctx); return nil } -} - -func (m Model) listModels() tea.Cmd { - return func() tea.Msg { m.workspace.ListModels(m.ctx); return nil } -} - -func (m Model) createSession() tea.Cmd { - return func() tea.Msg { m.workspace.CreateSession(m.ctx); return nil } -} - -func (m Model) loadSession(id string) tea.Cmd { - return func() tea.Msg { m.workspace.LoadSession(m.ctx, id); return nil } -} - -func (m Model) loadProviderConfig() tea.Cmd { - return func() tea.Msg { - providers := m.workspace.LoadProviderConfig(m.ctx) - return providerConfigLoadedMsg{providers: providers} - } -} - -func (m *Model) refreshSettingsHubData() { - m.commands.SetSectionItems("commands", buildCommandSettingsItems(m.chat.VerboseInterim())) - m.commands.SetSectionItems("tools", buildToolSettingsItems(m.workspace.LoadToolCatalog(m.ctx))) - m.commands.SetSectionItems("mcp", buildMCPSettingsItems(m.workspace.LoadMCPServers(m.ctx))) - m.skillCatalog = m.workspace.LoadSkills(m.ctx) - m.skillCatalogLoaded = true - m.commands.SetSectionItems("skills", buildSkillSettingsItems(m.skillCatalog)) -} - -func buildCommandSettingsItems(verboseInterim bool) []components.PaletteItem { - verboseDesc := "Currently off · Keep assistant step narration compact between tools" - if verboseInterim { - verboseDesc = "Currently on · Show full assistant step narration between tools" - } - return []components.PaletteItem{ - {Kind: components.PaletteActionKind, ID: "new-session", Name: "New Session", Shortcut: "ctrl+n", Desc: "Start a fresh conversation"}, - {Kind: components.PaletteActionKind, ID: "sessions", Name: "Sessions", Shortcut: "ctrl+s", Desc: "Browse and resume past sessions"}, - {Kind: components.PaletteActionKind, ID: "copy-msg", Name: "Copy Last Message", Shortcut: "ctrl+u", Desc: "Copy your last message to clipboard"}, - {Kind: components.PaletteActionKind, ID: "toggle-verbose-steps", Name: "Verbose Agent Steps", Desc: verboseDesc}, - {Kind: components.PaletteActionKind, ID: "quit", Name: "Quit", Shortcut: "ctrl+c", Desc: "Exit Nexus"}, - } -} - -func buildToolSettingsItems(items []tui.ToolInfo) []components.PaletteItem { - if len(items) == 0 { - return []components.PaletteItem{{ - Kind: components.PaletteInfoKind, - ID: "tools-empty", - Name: "No tools found", - Desc: "The current runtime did not expose any tools", - }} - } - result := make([]components.PaletteItem, 0, len(items)) - for _, item := range items { - desc := strings.TrimSpace(item.Description) - if category := strings.TrimSpace(item.Category); category != "" { - if desc != "" { - desc = category + " · " + desc - } else { - desc = category - } - } - result = append(result, components.PaletteItem{ - Kind: components.PaletteInfoKind, - ID: "tool-" + item.Name, - Name: item.Name, - Desc: desc, - }) - } - return result -} - -func buildMCPSettingsItems(items []tui.MCPServerInfo) []components.PaletteItem { - if len(items) == 0 { - return []components.PaletteItem{{ - Kind: components.PaletteInfoKind, - ID: "mcp-empty", - Name: "No MCP servers configured", - Desc: "Add MCP servers in config to expose them here", - }} - } - result := make([]components.PaletteItem, 0, len(items)) - for _, item := range items { - desc := item.Status + " · " + strconv.Itoa(item.ToolsRegistered) + " tools" - if item.Error != "" { - desc += " · " + item.Error - } - result = append(result, components.PaletteItem{ - Kind: components.PaletteInfoKind, - ID: "mcp-" + item.Name, - Name: item.Name, - Desc: desc, - }) - } - return result -} - -func buildSkillSettingsItems(items []tui.SkillInfo) []components.PaletteItem { - if len(items) == 0 { - return []components.PaletteItem{{ - Kind: components.PaletteInfoKind, - ID: "skills-empty", - Name: "No skills found", - Desc: "Add bundled, repo, or user skills to invoke them with /skill", - }} - } - result := make([]components.PaletteItem, 0, len(items)) - for _, item := range items { - desc := strings.TrimSpace(item.Description) - if desc == "" { - desc = strings.TrimSpace(item.WhenToUse) - } - if source := strings.TrimSpace(item.Source); source != "" { - if desc != "" { - desc = source + " · " + desc - } else { - desc = source - } + if m.skillCompletions.IsOpen() { + m.skillCompletions.Close() } - result = append(result, components.PaletteItem{ - Kind: components.PaletteInfoKind, - ID: "skill-" + item.Name, - Name: "/" + item.Name, - Desc: desc, - }) } - return result -} -func (m Model) deleteSession(id string) tea.Cmd { - return func() tea.Msg { - _ = m.workspace.DeleteSession(m.ctx, id) - m.workspace.ListSessions(m.ctx) - return nil - } + return m, tea.Batch(cmds...) } // ─── Utilities ──────────────────────────────────────────────────────────────── diff --git a/internal/tui/app/model_test.go b/internal/tui/app/model_test.go index 63a0b32..aa72334 100644 --- a/internal/tui/app/model_test.go +++ b/internal/tui/app/model_test.go @@ -8,10 +8,26 @@ import ( tea "charm.land/bubbletea/v2" "github.com/EngineerProjects/nexus-engine/internal/tui" "github.com/EngineerProjects/nexus-engine/internal/tui/common" + "github.com/charmbracelet/x/ansi" ) type mockWorkspace struct{} +type trackingWorkspace struct { + mockWorkspace + deleteCalls []string + listCalls int +} + +func (w *trackingWorkspace) DeleteSession(_ context.Context, id string) error { + w.deleteCalls = append(w.deleteCalls, id) + return nil +} + +func (w *trackingWorkspace) ListSessions(context.Context) { + w.listCalls++ +} + func (mockWorkspace) ListSessions(context.Context) {} func (mockWorkspace) CreateSession(context.Context) {} func (mockWorkspace) LoadSession(context.Context, string) {} @@ -45,6 +61,11 @@ func (mockWorkspace) SaveProviderField(context.Context, string, string, string) func (mockWorkspace) DeleteProviderField(context.Context, string, string) error { return nil } +func (mockWorkspace) LoadSearchConfig(context.Context) tui.SearchConfig { + return tui.SearchConfig{Mode: "auto"} +} +func (mockWorkspace) SaveSearchKey(context.Context, string, string) error { return nil } +func (mockWorkspace) SaveSearchMode(context.Context, string) error { return nil } func TestModelRelayoutPropagatesChildSizes(t *testing.T) { m := New(mockWorkspace{}, context.Background()) @@ -57,8 +78,8 @@ func TestModelRelayoutPropagatesChildSizes(t *testing.T) { if cw != 76 { t.Fatalf("expected chat width 76, got %d", cw) } - if ch != 19 { - t.Fatalf("expected chat height 19, got %d", ch) + if ch != 18 { + t.Fatalf("expected chat height 18, got %d", ch) } sw, sh := m.sessions.Size() if sw != 80 { @@ -159,6 +180,111 @@ func TestModelFooterSimplifiesPrimaryActions(t *testing.T) { } } +func TestModelViewChatIncludesSpacingBelowHeader(t *testing.T) { + m := New(mockWorkspace{}, context.Background()) + m.width = 120 + m.height = 30 + m.activeSession = "session-123" + m.state = stateChat + m = m.relayout() + m.chat.AddUserMessage("hello") + plain := ansi.Strip(m.viewChat()) + if !strings.Contains(plain, "\n\n ● > hello") { + t.Fatalf("expected blank line between header and first message, got %q", plain) + } +} + +func TestModelSessionDeleteKeyDispatchesDelete(t *testing.T) { + ws := &trackingWorkspace{} + m := New(ws, context.Background()) + m.state = stateSessions + m.sessions.SetSessions([]tui.SessionInfo{{ID: "sess-1", ShortID: "sess-1"}}) + + consumed, cmd := m.handleKey(tea.KeyPressMsg{Text: "d"}) + if !consumed { + t.Fatal("expected d to be handled in sessions view") + } + if cmd == nil { + t.Fatal("expected delete command") + } + msg := cmd() + result, ok := msg.(sessionDeleteResultMsg) + if !ok { + t.Fatalf("expected sessionDeleteResultMsg, got %T", msg) + } + if result.err != nil { + t.Fatalf("unexpected delete error: %v", result.err) + } + if len(ws.deleteCalls) != 1 || ws.deleteCalls[0] != "sess-1" { + t.Fatalf("expected delete of sess-1, got %#v", ws.deleteCalls) + } + if ws.listCalls != 1 { + t.Fatalf("expected session list refresh, got %d", ws.listCalls) + } +} + +func TestModelCtrlSOpensSessionsAndLoadsList(t *testing.T) { + ws := &trackingWorkspace{} + m := New(ws, context.Background()) + m.state = stateChat + + consumed, cmd := m.handleKey(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + if !consumed { + t.Fatal("expected ctrl+s to be handled") + } + if got := m.state; got != stateSessions { + t.Fatalf("expected sessions state, got %v", got) + } + if cmd == nil { + t.Fatal("expected session browser load command") + } + if msg := cmd(); msg != nil { + t.Fatalf("expected nil message from async load cmd, got %T", msg) + } + if ws.listCalls != 1 { + t.Fatalf("expected one session list refresh, got %d", ws.listCalls) + } +} + +func TestModelDeletingActiveSessionResetsChatState(t *testing.T) { + ws := &trackingWorkspace{} + m := New(ws, context.Background()) + m.state = stateSessions + m.activeSession = "sess-1" + m.busy = true + m.lastErr = context.Canceled + m.lastTurnErr = "boom" + m.chat.AddUserMessage("hello") + m.sessions.SetSessions([]tui.SessionInfo{{ID: "sess-1", ShortID: "sess-1"}}) + + consumed, cmd := m.handleKey(tea.KeyPressMsg{Text: "d"}) + if !consumed { + t.Fatal("expected d to be handled in sessions view") + } + if got := m.state; got != stateWelcome { + t.Fatalf("expected welcome state after deleting active session, got %v", got) + } + if m.activeSession != "" { + t.Fatalf("expected active session to be cleared, got %q", m.activeSession) + } + if m.busy { + t.Fatal("expected busy flag to be cleared") + } + if m.lastErr != nil { + t.Fatalf("expected lastErr to be cleared, got %v", m.lastErr) + } + if m.lastTurnErr != "" { + t.Fatalf("expected lastTurnErr to be cleared, got %q", m.lastTurnErr) + } + if cmd == nil { + t.Fatal("expected delete command") + } + _ = cmd() + if len(ws.deleteCalls) != 1 || ws.deleteCalls[0] != "sess-1" { + t.Fatalf("expected delete of sess-1, got %#v", ws.deleteCalls) + } +} + func TestModelViewChatIncludesStatusLine(t *testing.T) { m := New(mockWorkspace{}, context.Background()) m.width = 120 diff --git a/internal/tui/app/mouse.go b/internal/tui/app/mouse.go new file mode 100644 index 0000000..09aaf8d --- /dev/null +++ b/internal/tui/app/mouse.go @@ -0,0 +1,83 @@ +package app + +import ( + tea "charm.land/bubbletea/v2" +) + +func pointInRect(x, y, rx, ry, rw, rh int) bool { + return x >= rx && x < rx+rw && y >= ry && y < ry+rh +} + +func clampMouse(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func (m *Model) handleMouseClick(msg tea.MouseClickMsg) tea.Cmd { + if m.state != stateChat { + return nil + } + layout := m.currentChatLayout() + if m.skillCompletions.IsOpen() && pointInRect(msg.X, msg.Y, layout.popupX, layout.popupY, layout.popupW, layout.popupH) { + if msg.Button == tea.MouseLeft { + row := msg.Y - layout.popupY - 1 + if sel := m.skillCompletions.ClickRow(row); sel != "" { + m.input.SetValue(sel + " ") + m.input.CursorEnd() + m.skillCompletions.Close() + m.focus = uiFocusEditor + *m = m.resizeInput() + return m.input.Focus() + } + } + return nil + } + if pointInRect(msg.X, msg.Y, layout.inputX, layout.inputY+layout.popupH, layout.inputW, layout.inputH-layout.popupH) { + m.focus = uiFocusEditor + return m.input.Focus() + } + if !pointInRect(msg.X, msg.Y, layout.chatX, layout.chatY, layout.chatW, layout.chatH) { + return nil + } + if msg.Button == tea.MouseRight { + if text := m.chat.SelectedText(); text != "" { + return m.copyToClipboard(text, "Selection copied") + } + return nil + } + if msg.Button != tea.MouseLeft { + return nil + } + m.focus = uiFocusMain + m.input.Blur() + m.chat.HandleMouseDown(msg.X-layout.chatX, msg.Y-layout.chatY) + return nil +} + +func (m *Model) handleMouseMotion(msg tea.MouseMotionMsg) bool { + if m.state != stateChat || !m.chat.HasMouseCapture() { + return false + } + layout := m.currentChatLayout() + relX := msg.X - layout.chatX + relY := msg.Y - layout.chatY + return m.chat.HandleMouseDrag(relX, relY) +} + +func (m *Model) handleMouseRelease(msg tea.MouseReleaseMsg) tea.Cmd { + if m.state != stateChat || !m.chat.HasMouseCapture() { + return nil + } + layout := m.currentChatLayout() + relX := msg.X - layout.chatX + relY := msg.Y - layout.chatY + if text := m.chat.HandleMouseUp(relX, relY); text != "" { + return m.copyToClipboard(text, "Selection copied") + } + return nil +} diff --git a/internal/tui/app/view.go b/internal/tui/app/view.go new file mode 100644 index 0000000..00bc283 --- /dev/null +++ b/internal/tui/app/view.go @@ -0,0 +1,177 @@ +package app + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/EngineerProjects/nexus-engine/internal/tui/common" +) + +// View returns a tea.View (v2 API — not a string). +func (m Model) View() tea.View { + if m.width == 0 { + return tea.NewView("") + } + + var content string + switch m.state { + case stateWelcome: + content = m.viewWelcome() + case stateSessions: + content = m.viewSessions() + case stateModelSelect: + content = m.viewModelSelect() + case stateCommands: + content = m.viewCommands() + case stateProviderConfig: + content = m.viewProviderConfig() + case stateSearchConfig: + content = m.viewSearchConfig() + case stateChat, statePermission: + content = m.viewChat() + default: + content = m.viewChat() + } + + v := tea.NewView(content) + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + return v +} + +// ─── Views ──────────────────────────────────────────────────────────────────── + +func (m Model) viewWelcome() string { + // Braille logo rendered in orange primary colour. + logoArt := lipgloss.NewStyle().Foreground(common.ColorPrimary).Render(common.NexusLogo) + + wordmark := m.styles.Logo.Render("NEXUS") + tagline := m.styles.HeaderModel.Render("One runtime. Any LLM. Any language.") + + hint := strings.Join([]string{ + m.styles.Key.Render("ctrl+n") + " " + m.styles.Desc.Render("new session"), + m.styles.Key.Render("ctrl+s") + " " + m.styles.Desc.Render("sessions"), + m.styles.Key.Render("ctrl+q") + " " + m.styles.Desc.Render("quit"), + }, " ") + + extra := "" + if m.workspace.ModelString() == "" { + extra = "\n\n" + m.styles.MsgTimestamp.Render("No provider configured — press ") + + m.styles.Key.Render("ctrl+p") + + m.styles.MsgTimestamp.Render(" to set one up") + } + + contentW := m.contentWidth() + body := lipgloss.NewStyle(). + Width(contentW). + Height(m.height-2). + Align(lipgloss.Center, lipgloss.Center). + Render(logoArt + "\n" + wordmark + "\n\n" + tagline + "\n\n" + hint + extra) + + return m.header() + "\n\n" + common.CenterHorizontally(body, m.width) +} + +func (m Model) viewChat() string { + contentW := m.contentWidth() + + // ── Sidebar-open: join left column (chat+status+input) with sidebar ─── + if m.chat.DetailsOpen() && contentW >= 110 { + paneW := max(36, contentW/3) + chatW := max(40, contentW-paneW-1) + detailW := contentW - chatW - 1 + + leftStatus := m.statusLineFor(chatW) + leftInput := m.inputViewFor(chatW) + statusH := lipgloss.Height(leftStatus) + inputH := lipgloss.Height(leftInput) + chatH := m.height - headerHeight - contentTopGap - footerHeight - statusH - inputH + + m.chat.SetSize(chatW, max(1, chatH)) + leftContent := strings.Join([]string{m.chat.View(), leftStatus, leftInput}, "\n") + sideH := lipgloss.Height(leftContent) + + detailView := m.chat.DetailView(detailW, sideH) + body := lipgloss.JoinHorizontal(lipgloss.Top, leftContent, " ", detailView) + body = common.CenterHorizontally(lipgloss.NewStyle().Width(contentW).Render(body), m.width) + + base := strings.Join([]string{m.header(), "", body, m.footer()}, "\n") + if m.state == statePermission && m.permission.HasPending() { + return common.OverlayOn(base, m.permission.View(), m.width, m.height) + } + return base + } + + // ── Normal layout ───────────────────────────────────────────────────── + inputView := m.inputView() + statusView := m.statusLine() + chatH := m.height - headerHeight - contentTopGap - footerHeight - lipgloss.Height(statusView) - lipgloss.Height(inputView) + m.chat.SetSize(contentW, max(1, chatH)) + body := common.CenterHorizontally(lipgloss.NewStyle().Width(contentW).Render(m.chat.View()), m.width) + + base := strings.Join([]string{m.header(), "", body, statusView, inputView, m.footer()}, "\n") + if m.state == statePermission && m.permission.HasPending() { + return common.OverlayOn(base, m.permission.View(), m.width, m.height) + } + return base +} + +func (m Model) viewSessions() string { + m.sessions.SetSize(m.width, m.height) + overlay := m.sessions.Centered() + var backdrop string + if m.activeSession != "" { + backdrop = m.viewChat() + } else { + backdrop = m.viewWelcome() + } + return common.OverlayOn(backdrop, overlay, m.width, m.height) +} + +func (m Model) viewModelSelect() string { + m.modelSelect.SetSize(m.width, m.height) + overlay := m.modelSelect.Centered() + var backdrop string + if m.activeSession != "" { + backdrop = m.viewChat() + } else { + backdrop = m.viewWelcome() + } + return common.OverlayOn(backdrop, overlay, m.width, m.height) +} + +func (m Model) viewCommands() string { + m.commands.SetSize(m.width, m.height) + overlay := m.commands.Centered() + var backdrop string + if m.activeSession != "" { + backdrop = m.viewChat() + } else { + backdrop = m.viewWelcome() + } + return common.OverlayOn(backdrop, overlay, m.width, m.height) +} + +func (m Model) viewProviderConfig() string { + m.configPanel.SetSize(m.width, m.height) + overlay := m.configPanel.Centered() + var backdrop string + if m.activeSession != "" { + backdrop = m.viewChat() + } else { + backdrop = m.viewWelcome() + } + return common.OverlayOn(backdrop, overlay, m.width, m.height) +} + +func (m Model) viewSearchConfig() string { + m.searchPanel.SetSize(m.width, m.height) + overlay := m.searchPanel.Centered() + var backdrop string + if m.activeSession != "" { + backdrop = m.viewChat() + } else { + backdrop = m.viewWelcome() + } + return common.OverlayOn(backdrop, overlay, m.width, m.height) +} diff --git a/internal/tui/components/chat.go b/internal/tui/components/chat.go index ac097fe..679452e 100644 --- a/internal/tui/components/chat.go +++ b/internal/tui/components/chat.go @@ -1,21 +1,13 @@ package components import ( - "encoding/json" - "fmt" - "os" - "path/filepath" "strings" "time" "unicode" - "github.com/EngineerProjects/nexus-engine/internal/tui/common" - "github.com/muesli/reflow/wrap" - "charm.land/bubbles/v2/viewport" "charm.land/glamour/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" + "github.com/EngineerProjects/nexus-engine/internal/tui/common" ) var toolIcons = map[string]string{ @@ -46,2358 +38,637 @@ func toolIconFor(name string) string { return "◆" } -type msgItem interface { - render(c *Chat, width int) string - isFinished() bool - invalidate() -} - -const thinkTailLines = 4 -const interimNarrationLines = 2 +type Chat struct { + styles common.Styles + viewport *viewport.Model + renderer *glamour.TermRenderer + detail *viewport.Model + messages []msgItem + width int + height int + follow bool -type thinkingBlock struct { - content string - streaming bool - startedAt time.Time - finishedAt time.Time - collapsed bool + selectedTool int + detailOpen bool + planDepth int // incremented by enter_plan_mode, decremented by exit_plan_mode + pairDepth int // incremented by enter_pair_programming_mode, decremented by exit_pair_programming_mode - cacheWidth int - cacheRender string + renderedContent string + renderedLines []string + plainContent string + plainLines []string + toolRegions []toolRegion + thinkingRegions []thinkingRegion + selection mouseSelection + verboseInterim bool + detailKey string + detailToolID string // ID of tool currently rendered in the detail sidebar } -func newThinkingBlock() *thinkingBlock { - return &thinkingBlock{ - streaming: true, - collapsed: true, - startedAt: time.Now(), +func NewChat(styles common.Styles, width, height int) *Chat { + vp := viewport.New(viewport.WithWidth(width), viewport.WithHeight(height)) + vp.SetContent("") + detail := viewport.New(viewport.WithWidth(width), viewport.WithHeight(height)) + detail.SetContent("") + r := common.MarkdownRenderer(width) + return &Chat{ + styles: styles, + viewport: &vp, + renderer: r, + detail: &detail, + follow: true, + width: width, + height: height, + selectedTool: -1, } } -func (tb *thinkingBlock) append(text string) { - tb.content += text - tb.cacheWidth = 0 -} - -func (tb *thinkingBlock) finish() { - tb.streaming = false - tb.finishedAt = time.Now() - tb.cacheWidth = 0 -} - -func (tb *thinkingBlock) toggle() { - tb.collapsed = !tb.collapsed - tb.cacheWidth = 0 -} - -func (tb *thinkingBlock) render(styles common.Styles, width int) string { - if !tb.streaming && tb.cacheWidth == width && tb.cacheRender != "" { - return tb.cacheRender - } - - innerW := width - 6 - if innerW < 10 { - innerW = 10 - } - - lines := strings.Split(strings.TrimRight(tb.content, "\n"), "\n") - var shownLines []string - var hiddenCount int - - if tb.collapsed && len(lines) > thinkTailLines { - hiddenCount = len(lines) - thinkTailLines - shownLines = lines[len(lines)-thinkTailLines:] - } else { - shownLines = lines +func (c *Chat) SetSize(width, height int) { + if c.width == width && c.height == height { + return } - - var inner strings.Builder - if hiddenCount > 0 { - inner.WriteString(styles.MsgTimestamp.Render(fmt.Sprintf("… %d lines hidden", hiddenCount))) - inner.WriteString("\n") + c.width = width + c.height = height + c.viewport.SetWidth(width) + c.viewport.SetHeight(height) + if r := common.MarkdownRenderer(width); r != nil { + c.renderer = r } - for i, line := range shownLines { - inner.WriteString(styles.MsgTimestamp.Render(wrap.String(line, innerW))) - if i < len(shownLines)-1 { - inner.WriteString("\n") - } + for _, m := range c.messages { + m.invalidate() } + c.refresh() +} - boxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(common.ColorBorder). - Padding(0, 1). - Width(width - 2) - - box := boxStyle.Render(inner.String()) - - var footParts []string - if tb.streaming { - footParts = append(footParts, styles.MsgTimestamp.Render("thinking…")) - } else { - dur := tb.finishedAt.Sub(tb.startedAt).Round(100 * time.Millisecond) - footParts = append(footParts, - styles.MsgTimestamp.Render(fmt.Sprintf("Thought for %.1fs", dur.Seconds()))) - if tb.collapsed { - footParts = append(footParts, styles.Desc.Render("click to expand")) - } else { - footParts = append(footParts, styles.Desc.Render("click to collapse")) - } +func (c *Chat) SetVerboseInterim(v bool) { + if c.verboseInterim == v { + return } - foot := " " + strings.Join(footParts, " ") - - result := box + "\n" + foot - if !tb.streaming { - tb.cacheWidth = width - tb.cacheRender = result + c.verboseInterim = v + for _, m := range c.messages { + m.invalidate() } - return result -} - -type assistantItem struct { - thinking *thinkingBlock - content string - streaming bool - startedAt time.Time - finishedAt time.Time - showLabel bool - showMeta bool - inputTokens int - outputTokens int - stopReason string - - contentCacheWidth int - contentCacheRender string + c.refresh() } -func newAssistantItem() *assistantItem { - return &assistantItem{streaming: true, showLabel: true, startedAt: time.Now()} +func (c *Chat) VerboseInterim() bool { + return c.verboseInterim } -func newContinuationItem(startedAt time.Time) *assistantItem { - if startedAt.IsZero() { - startedAt = time.Now() - } - return &assistantItem{streaming: true, showLabel: false, startedAt: startedAt} -} +func (c *Chat) PlanMode() bool { return c.planDepth > 0 } +func (c *Chat) PairMode() bool { return c.pairDepth > 0 } -func (a *assistantItem) appendThinking(text string) { - if text == "" { - return +// ExecutionMode returns "plan", "pair_programming", or "execute" (default). +func (c *Chat) ExecutionMode() string { + if c.planDepth > 0 { + return "plan" } - if a.thinking == nil { - a.thinking = newThinkingBlock() + if c.pairDepth > 0 { + return "pair_programming" } - a.thinking.append(text) - a.contentCacheWidth = 0 + return "execute" } -func (a *assistantItem) appendContent(text string) { - if a.thinking != nil && a.thinking.streaming { - a.thinking.finish() - } - a.content += text - a.contentCacheWidth = 0 +func (c *Chat) AddUserMessage(text string) { + c.sealActiveAssistant() + c.selection.clear() + c.messages = append(c.messages, &userItem{content: text, timestamp: time.Now()}) + c.refresh() } -func (a *assistantItem) finish(inputTokens, outputTokens int, stopReason string, showMeta bool) { - a.streaming = false - a.finishedAt = time.Now() - a.showMeta = showMeta - a.inputTokens = inputTokens - a.outputTokens = outputTokens - a.stopReason = stopReason - if a.thinking != nil && a.thinking.streaming { - a.thinking.finish() - } - a.contentCacheWidth = 0 +func (c *Chat) StartAssistantMessage() { + c.sealActiveAssistant() + c.selection.clear() + c.messages = append(c.messages, newAssistantItem()) + c.refresh() } -func (a *assistantItem) isFinished() bool { return !a.streaming } -func (a *assistantItem) invalidate() { a.contentCacheWidth = 0 } - -func (a *assistantItem) render(c *Chat, width int) string { - var sb strings.Builder - if a.showLabel { - sb.WriteString(c.styles.AssistantMarker.Render("●")) - sb.WriteString("\n") - } - if a.thinking != nil && strings.TrimSpace(a.thinking.content) != "" { - sb.WriteString(a.thinking.render(c.styles, width)) - if a.content != "" { - sb.WriteString("\n\n") - } else { - sb.WriteString("\n") - } - } - if a.content != "" { - var rendered string - if !a.streaming && a.contentCacheWidth == width && a.contentCacheRender != "" { - rendered = a.contentCacheRender - } else { - if !a.streaming && !a.showMeta && !c.verboseInterim { - rendered = renderCompactAssistantNarration(c.styles, a.content, width) +func (c *Chat) AppendChunk(text string, isThinking bool) { + for i := len(c.messages) - 1; i >= 0; i-- { + if a, ok := c.messages[i].(*assistantItem); ok && a.streaming { + if isThinking { + a.appendThinking(text) } else { - var err error - mu := common.LockMarkdownRenderer(c.renderer) - mu.Lock() - rendered, err = c.renderer.Render(a.content) - mu.Unlock() - if err != nil { - rendered = a.content - } - rendered = strings.TrimRight(rendered, "\n") - } - if !a.streaming { - a.contentCacheWidth = width - a.contentCacheRender = rendered + a.appendContent(text) } + c.refresh() + return } - sb.WriteString(rendered) - } else if a.streaming { - sb.WriteString(c.styles.MsgTimestamp.Render("…")) } - if meta := a.metaLine(c.styles, width); meta != "" { - if sb.Len() > 0 { - sb.WriteString("\n\n") + isContinuation := false + continuationStart := time.Time{} + for i := len(c.messages) - 1; i >= 0; i-- { + if _, ok := c.messages[i].(*userItem); ok { + break + } + if a, ok := c.messages[i].(*assistantItem); ok { + isContinuation = true + continuationStart = a.startedAt + break } - sb.WriteString(meta) } - return sb.String() + if isContinuation { + c.messages = append(c.messages, newContinuationItem(continuationStart)) + } else { + c.messages = append(c.messages, newAssistantItem()) + } + c.AppendChunk(text, isThinking) } -func renderCompactAssistantNarration(styles common.Styles, content string, width int) string { - innerW := max(20, width-2) - normalized := strings.Join(strings.Fields(strings.TrimSpace(content)), " ") - if normalized == "" { - return "" - } - wrapped := strings.TrimSpace(wrap.String(normalized, innerW)) - lines := strings.Split(wrapped, "\n") - if len(lines) > interimNarrationLines { - lines = lines[:interimNarrationLines] - last := []rune(strings.TrimRight(lines[len(lines)-1], " ")) - if len(last) >= innerW { - last = last[:innerW-1] +func (c *Chat) FinishAssistantMessage(inputTokens, outputTokens int, stopReason string) { + for i := len(c.messages) - 1; i >= 0; i-- { + if a, ok := c.messages[i].(*assistantItem); ok && a.streaming { + a.finish(inputTokens, outputTokens, stopReason, true) + c.refresh() + return } - lines[len(lines)-1] = strings.TrimRight(string(last), " ") + "…" - } - for i, line := range lines { - lines[i] = styles.InterimAssistant.Render(line) } - return strings.Join(lines, "\n") } -func (a *assistantItem) metaLine(styles common.Styles, width int) string { - if a.streaming || a.finishedAt.IsZero() || !a.showMeta { - return "" - } - left := styles.ToolDone.Render("done") - if !a.startedAt.IsZero() { - left += styles.TurnMeta.Render(" · " + formatDuration(a.finishedAt.Sub(a.startedAt))) - } - turnTokens := a.inputTokens + a.outputTokens - if turnTokens <= 0 { - return left - } - right := styles.TurnMeta.Render(compactTokenCount(turnTokens) + " tok") - sepLen := width - lipgloss.Width(left) - lipgloss.Width(right) - 2 - if sepLen < 3 { - sepLen = 3 +func (c *Chat) AddToolProgress(toolUseID, toolName, status, label string, metadata map[string]any) { + // Plan mode / Pair programming mode intercepts. + if status == "completed" { + switch toolName { + case "enter_plan_mode": + c.planDepth++ + case "exit_plan_mode": + c.planDepth = max(0, c.planDepth-1) + case "enter_pair_programming_mode": + c.pairDepth++ + case "exit_pair_programming_mode": + c.pairDepth = max(0, c.pairDepth-1) + } } - sep := styles.TurnMeta.Render(strings.Repeat("·", sepLen)) - return left + " " + sep + " " + right -} -type userItem struct { - content string - timestamp time.Time - cacheW int - cacheR string -} + // Update existing tool item if we find it. + for _, m := range c.messages { + if t, ok := m.(*toolItem); ok && t.id == toolUseID { + t.status = status + t.label = label + for k, v := range metadata { + t.metadata[k] = v + } + if t.isDone() { + t.finishedAt = time.Now() + if isAutoExpandTool(t.name) && !t.expanded { + t.expanded = true + } + } + t.invalidate() + c.refresh() + return + } + } -func (u *userItem) isFinished() bool { return true } -func (u *userItem) invalidate() { u.cacheW = 0 } + // Not found: seal active assistant item (if any) so the tool row starts cleanly. + c.sealActiveAssistant() + c.selection.clear() -func (u *userItem) render(c *Chat, width int) string { - if u.cacheW == width && u.cacheR != "" { - return u.cacheR - } - _ = u.timestamp - prefix := "● > " - bodyWidth := max(12, width-lipgloss.Width(prefix)) - wrapped := strings.Split(wrap.String(u.content, bodyWidth), "\n") - if len(wrapped) == 0 { - wrapped = []string{""} + // Append as a new toolItem. + tool := newToolItem(toolUseID, toolName, status, label, metadata) + if tool.isDone() && isAutoExpandTool(toolName) { + tool.expanded = true } - wrapped[0] = c.styles.UserMarker.Render(prefix) + c.styles.UserMsg.Render(wrapped[0]) - for i := 1; i < len(wrapped); i++ { - wrapped[i] = strings.Repeat(" ", lipgloss.Width(prefix)) + c.styles.UserMsg.Render(wrapped[i]) - } - r := strings.Join(wrapped, "\n") - u.cacheW = width - u.cacheR = r - return r -} + c.messages = append(c.messages, tool) -type toolItem struct { - id string - name string - status string - label string - metadata map[string]any - expanded bool - startedAt time.Time - finishedAt time.Time + // If this new tool starts, automatically select it. + c.selectedTool = len(c.messages) - 1 + if isAutoExpandTool(toolName) { + c.detailOpen = true + } - cacheW int - cacheR string + c.refresh() } -func newToolItem(id, name, status, label string, metadata map[string]any) *toolItem { - return &toolItem{ - id: id, - name: name, - status: status, - label: label, - metadata: cloneMap(metadata), - startedAt: time.Now(), +func (c *Chat) sealActiveAssistant() { + for i := len(c.messages) - 1; i >= 0; i-- { + a, ok := c.messages[i].(*assistantItem) + if !ok || !a.streaming { + continue + } + hasThinking := a.thinking != nil && strings.TrimSpace(a.thinking.content) != "" + if a.content == "" && !hasThinking { + c.messages = append(c.messages[:i], c.messages[i+1:]...) + } else { + a.finish(0, 0, "", false) + } + return } } -func (t *toolItem) isDone() bool { - return t.status == "completed" || t.status == "failed" || t.status == "done" || t.status == "error" +func (c *Chat) AddError(err error) { + c.sealActiveAssistant() + c.selection.clear() + c.messages = append(c.messages, &errorItem{content: err.Error()}) + c.refresh() } -func (t *toolItem) isFinished() bool { return t.isDone() } -func (t *toolItem) invalidate() { t.cacheW = 0; t.cacheR = "" } - -func (t *toolItem) render(c *Chat, width int) string { - return t.renderSelected(c, width, false) +func (c *Chat) AddSystem(text string) { + c.sealActiveAssistant() + c.selection.clear() + c.messages = append(c.messages, &systemItem{content: text}) + c.refresh() } -func (t *toolItem) expanderSymbol() string { - if !t.supportsPreview() { - return " " - } - if t.expanded { - return "▾" - } - return "▸" +func (c *Chat) Clear() { + c.messages = nil + c.selectedTool = -1 + c.detailOpen = false + c.planDepth = 0 + c.pairDepth = 0 + c.detailKey = "" + c.detailToolID = "" + c.selection.clear() + c.viewport.SetContent("") + c.detail.SetContent("") + c.refresh() } -func (t *toolItem) detailsSymbol(selected, detailsOpen bool) string { - if selected && detailsOpen { - return "⊟" +func (c *Chat) GetLastAssistantText() string { + var parts []string + inCurrentTurn := false + for i := len(c.messages) - 1; i >= 0; i-- { + switch m := c.messages[i].(type) { + case *userItem: + if inCurrentTurn { + goto done + } + case *assistantItem: + inCurrentTurn = true + if m.content != "" { + parts = append([]string{m.content}, parts...) + } + } } - return "⊞" +done: + return strings.Join(parts, "\n\n") } -func (t *toolItem) renderSelected(c *Chat, width int, selected bool) string { - if t.isDone() && !selected && !t.expanded && t.cacheW == width && t.cacheR != "" { - return t.cacheR - } - - icon := t.renderIcon(c.styles) - nameStyle := t.renderNameStyle(c.styles) - summary := truncate(t.summaryText(), max(12, width-34)) - expander := c.styles.MsgTimestamp.Render(t.expanderSymbol()) - details := c.styles.MsgTimestamp.Render(t.detailsSymbol(selected, c.detailOpen && selected)) - status := c.styles.MsgTimestamp.Render(t.statusLabel()) - - parts := []string{expander, details, icon, nameStyle.Render(toolDisplayName(t.name)), status} - if summary != "" { - parts = append(parts, c.styles.MsgTimestamp.Render(summary)) - } - if dur := t.durationText(); dur != "" { - parts = append(parts, c.styles.MsgTimestamp.Render("("+dur+")")) - } - - line := strings.Join(parts, " ") - if selected { - line = lipgloss.NewStyle().Foreground(common.ColorText).Background(lipgloss.Color("#1F2937")).Render(line) - } - - if !t.expanded { - if t.isDone() && !selected { - t.cacheW = width - t.cacheR = line +func (c *Chat) GetLastUserText() string { + for i := len(c.messages) - 1; i >= 0; i-- { + if user, ok := c.messages[i].(*userItem); ok { + return user.content } - return line } - - preview := t.inlinePreview(c, width) - if preview == "" { - preview = c.styles.MsgTimestamp.Render("No preview available.") - } - result := line + "\n" + indentBlock(preview, " ") - if t.isDone() && !selected { - t.cacheW = width - t.cacheR = result - } - return result + return "" } -func (t *toolItem) renderIcon(styles common.Styles) string { - switch { - case t.status == "completed" || t.status == "done": - return styles.MsgTimestamp.Render("✓") - case t.status == "failed" || t.status == "error": - return styles.ToolError.Render("✗") - default: - return styles.ToolProgress.Render(toolIconFor(t.name)) +func (c *Chat) ToggleThinking() { + for i := len(c.messages) - 1; i >= 0; i-- { + if assistant, ok := c.messages[i].(*assistantItem); ok { + if assistant.thinking != nil { + assistant.thinking.toggle() + assistant.invalidate() + c.refresh() + } + break + } } } -func (t *toolItem) renderNameStyle(styles common.Styles) lipgloss.Style { - switch { - case t.status == "completed" || t.status == "done": - return styles.UserMsg - case t.status == "failed" || t.status == "error": - return styles.ToolError - default: - return styles.ToolProgress +func (c *Chat) HasThinking() bool { + for _, m := range c.messages { + if assistant, ok := m.(*assistantItem); ok && assistant.thinking != nil { + return true + } } + return false } -func (t *toolItem) durationText() string { - if !t.isDone() || t.finishedAt.IsZero() { - if ms, ok := intFromMap(t.metadata, "execution_duration_ms"); ok && ms > 0 { - return formatDuration(time.Duration(ms) * time.Millisecond) +func (c *Chat) HasTools() bool { + for _, m := range c.messages { + if _, ok := m.(*toolItem); ok { + return true } - return "" } - return formatDuration(t.finishedAt.Sub(t.startedAt)) + return false +} + +func (c *Chat) HasSelectedTool() bool { + return c.selectedToolIndex() >= 0 } -func (t *toolItem) toolInput() map[string]any { - return normalizeMap(t.metadata["tool_input"]) +func (c *Chat) DetailsOpen() bool { + return c.detailOpen } -func (t *toolItem) supportsPreview() bool { - switch t.name { - case "read_file", "write_file", "edit_file", "apply_patch", "bash", "web_search", "web_fetch", "spawn_agent", "wait_agent", "close_agent", "send_agent_message": +func (c *Chat) ToggleSelectedToolExpanded() bool { + if tool := c.selectedToolItem(); tool != nil && tool.supportsPreview() { + tool.expanded = !tool.expanded + tool.invalidate() + c.refresh() return true - default: - return strings.TrimSpace(t.resultContent()) != "" } + return false } -func (t *toolItem) statusLabel() string { - switch t.status { - case "completed", "done": - return "done" - case "failed", "error": - return "failed" - case "running", "started": - return "running" - default: - return t.status - } -} - -func (t *toolItem) summaryText() string { - input := t.toolInput() - switch t.name { - case "read_file": - path := compactPath(stringFromMap(input, "file_path")) - if path == "" { - path = t.label - } - return path - case "write_file", "edit_file": - path := compactPath(stringFromMap(input, "file_path")) - parts := make([]string, 0, 3) - if path != "" { - parts = append(parts, path) - } - if kind := stringFromMap(t.metadata, "type"); kind != "" { - parts = append(parts, kind) - } - if stats := t.changeStatsText(); stats != "" { - parts = append(parts, stats) - } - return strings.Join(parts, " · ") - case "apply_patch": - if stats := t.changeStatsText(); stats != "" { - return "patch · " + stats - } - if patch := stringFromMap(input, "patch"); patch != "" { - return firstLine(strings.TrimSpace(patch)) - } - if content := stringFromMap(t.metadata, "content"); content != "" { - return firstLine(content) - } - case "bash": - cmd := strings.TrimSpace(stringFromMap(input, "command")) - if cmd == "" { - cmd = strings.TrimSpace(stringFromMap(t.metadata, "description")) - } - return cmd - case "web_search": - query := strings.TrimSpace(stringFromMap(input, "query")) - if query == "" { - query = strings.TrimSpace(stringFromMap(t.metadata, "query")) - } - if count, ok := intFromMap(t.metadata, "result_count"); ok && count > 0 { - return fmt.Sprintf("%s · %d results", query, count) - } - return query - case "web_fetch": - summary := strings.TrimSpace(stringFromMap(input, "url")) - if summary == "" { - summary = strings.TrimSpace(stringFromMap(t.metadata, "url")) - } - if title := strings.TrimSpace(stringFromMap(t.metadata, "title")); title != "" { - summary = title - } - if code, ok := intFromMap(t.metadata, "code"); ok && code > 0 { - return fmt.Sprintf("%s · %d", compactPath(summary), code) - } - return compactPath(summary) - case "spawn_agent": - prompt := strings.TrimSpace(stringFromMap(input, "prompt")) - nickname := strings.TrimSpace(stringFromMap(input, "nickname")) - if nickname != "" { - return nickname + " · " + prompt - } - return prompt - case "wait_agent", "close_agent", "send_agent_message": - agentID := strings.TrimSpace(stringFromMap(input, "agent_id")) - if agentID != "" { - return agentID - } - } - if t.label != "" && t.label != t.status { - return strings.TrimSpace(t.label) - } - if msg := strings.TrimSpace(stringFromMap(t.metadata, "content")); msg != "" { - return firstLine(msg) - } - return "" -} - -func (t *toolItem) inlinePreview(c *Chat, width int) string { - bodyWidth := max(20, width-4) - switch t.name { - case "list_directory", "glob": - return renderDirListing(c.styles, t.resultContent(), bodyWidth, inlinePreviewLines) - case "read_file": - path := prettyPath(stringFromMap(t.toolInput(), "file_path")) - return renderFilePanel(c.styles, path, t.resultContent(), bodyWidth, inlinePreviewLines) - case "write_file": - path := prettyPath(stringFromMap(t.toolInput(), "file_path")) - return renderFilePanel(c.styles, path, t.writeContent(), bodyWidth, inlinePreviewLines) - case "edit_file", "apply_patch": - if diff := t.unifiedDiff(); diff != "" { - return renderColoredDiff(c.styles, diff, bodyWidth, inlinePreviewLines) - } - return "" - case "bash": - return renderBashInline(c.styles, stringFromMap(t.toolInput(), "command"), t.commandOutput(), bodyWidth, inlinePreviewLines) - case "web_search": - return renderContentPanel(c.styles, "results", t.resultContent(), bodyWidth, 8, contentFlavorMarkdown) - case "web_fetch": - return renderContentPanel(c.styles, "page", t.resultContent(), bodyWidth, 8, contentFlavorMarkdown) - case "spawn_agent", "wait_agent", "close_agent", "send_agent_message": - return renderContentPanel(c.styles, "agent", t.agentDetails(), bodyWidth, 8, contentFlavorPlain) - default: - return renderContentPanel(c.styles, toolDisplayName(t.name), t.resultContent(), bodyWidth, 8, contentFlavorPlain) - } -} - -func (t *toolItem) detailView(c *Chat, width, height int) string { - return c.renderToolDetail(t, width, height) -} - -func (t *toolItem) metaSummary() string { - var lines []string - if dur := t.durationText(); dur != "" { - lines = append(lines, "duration: "+dur) - } - if code, ok := intFromMap(t.metadata, "exit_code"); ok { - lines = append(lines, fmt.Sprintf("exit code: %d", code)) - } - if cwd := stringFromMap(t.metadata, "cwd"); cwd != "" { - lines = append(lines, "cwd: "+cwd) - } - if taskID := stringFromMap(t.metadata, "task_id"); taskID != "" { - lines = append(lines, "task: "+taskID) - } - if stats := t.changeStatsText(); stats != "" { - lines = append(lines, "changes: "+stats) - } - if t.name == "web_search" { - if provider := stringFromMap(t.metadata, "provider"); provider != "" { - lines = append(lines, "provider: "+provider) - } - if count, ok := intFromMap(t.metadata, "result_count"); ok { - lines = append(lines, fmt.Sprintf("results: %d", count)) - } - } - if t.name == "web_fetch" { - if code, ok := intFromMap(t.metadata, "code"); ok && code > 0 { - lines = append(lines, fmt.Sprintf("status: %d", code)) - } - if mode := stringFromMap(t.metadata, "mode"); mode != "" { - lines = append(lines, "mode: "+mode) - } - if rawURL := stringFromMap(t.metadata, "url"); rawURL != "" { - lines = append(lines, "url: "+rawURL) - } - } - return strings.Join(lines, "\n") -} - -func (t *toolItem) detailBody(c *Chat, width int) string { - switch t.name { - case "list_directory", "glob": - return renderDirListing(c.styles, t.resultContent(), width, 0) - case "read_file": - path := stringFromMap(t.toolInput(), "file_path") - if flavorForPath(path) == contentFlavorCode { - return renderCodeBody(c.styles, path, t.resultContent(), width, 0, 0) - } - return renderContentBody(c.styles, t.resultContent(), width, flavorForPath(path)) - case "write_file": - path := stringFromMap(t.toolInput(), "file_path") - content := t.writeContent() - if flavorForPath(path) == contentFlavorCode { - return renderCodeBody(c.styles, path, content, width, 0, 0) - } - return renderContentBody(c.styles, content, width, flavorForPath(path)) - case "edit_file", "apply_patch": - path := stringFromMap(t.toolInput(), "file_path") - if diff := t.unifiedDiff(); diff != "" { - label := "patch" - if path != "" { - label = prettyPath(path) - } - label = ansi.Truncate(label, width, "…") - return c.styles.Key.Render(label) + "\n\n" + renderColoredDiff(c.styles, diff, width, 0) - } - return "" - case "bash": - return renderBashDetails(c.styles, stringFromMap(t.toolInput(), "command"), t.commandOutput(), width) - case "web_search": - return renderWebSearchDetails(c.styles, t.summaryText(), t.resultContent(), width) - case "web_fetch": - return renderWebFetchDetails(c.styles, t.summaryText(), t.resultContent(), width) - case "spawn_agent", "wait_agent", "close_agent", "send_agent_message": - return renderContentBody(c.styles, t.agentDetails(), width, contentFlavorPlain) - default: - return renderContentBody(c.styles, t.resultContent(), width, contentFlavorPlain) - } -} - -type contentFlavor uint8 - -const ( - contentFlavorPlain contentFlavor = iota - contentFlavorMarkdown - contentFlavorCode - contentFlavorDiff -) - -func flavorForPath(path string) contentFlavor { - switch strings.ToLower(filepath.Ext(path)) { - case ".md", ".markdown", ".mdx": - return contentFlavorMarkdown - case ".go", ".js", ".jsx", ".ts", ".tsx", ".json", ".yaml", ".yml", ".toml", ".sh", ".bash", ".zsh", ".py", ".rb", ".rs", ".java", ".kt", ".swift", ".css", ".scss", ".html", ".sql", ".proto": - return contentFlavorCode - default: - return contentFlavorPlain - } -} - -func (t *toolItem) detailCacheKey(width, height int, body string) string { - return fmt.Sprintf("%s|%s|%d|%d|%s|%s", t.id, t.status, width, height, t.summaryText(), body) -} - -func (t *toolItem) changeStatsText() string { - added, addedOK := intFromMap(t.metadata, "lines_added") - removed, removedOK := intFromMap(t.metadata, "lines_removed") - if !addedOK && !removedOK { - return "" - } - return fmt.Sprintf("+%d -%d", added, removed) -} - -func renderDiffSections(styles common.Styles, title, body string, width int) string { - parts := make([]string, 0, 2) - if title = strings.TrimSpace(title); title != "" { - parts = append(parts, styles.Key.Render(title)) - } - parts = append(parts, renderDiffBody(styles, body, width, 0)) - return strings.Join(parts, "\n\n") -} - -func renderBashDetails(styles common.Styles, cmd, output string, width int) string { - sections := make([]string, 0, 2) - if cmd = strings.TrimSpace(cmd); cmd != "" { - sections = append(sections, styles.Key.Render("command")+"\n"+renderPlainBody("$ "+cmd, width)) - } - if output = strings.TrimSpace(output); output != "" { - sections = append(sections, styles.Key.Render("output")+"\n"+renderPlainBody(output, width)) - } - return strings.Join(sections, "\n\n") -} - -func renderWebSearchDetails(styles common.Styles, summary, body string, width int) string { - query, results := parseWebSearchContent(body) - sections := make([]string, 0, 2) - if strings.TrimSpace(query) == "" { - query = summary - } - if query = strings.TrimSpace(query); query != "" { - sections = append(sections, styles.Key.Render("query")+"\n"+renderContentBody(styles, query, width, contentFlavorPlain)) - } - if len(results) == 0 { - sections = append(sections, renderContentBody(styles, body, width, contentFlavorMarkdown)) - return strings.Join(sections, "\n\n") - } - sep := styles.MsgTimestamp.Render(strings.Repeat("─", max(4, width))) - cards := make([]string, 0, len(results)) - for i, result := range results { - cards = append(cards, renderSearchResultCard(styles, i+1, result, width)) - } - sections = append(sections, strings.Join(cards, "\n"+sep+"\n")) - return strings.Join(sections, "\n\n") -} - -func renderWebFetchDetails(styles common.Styles, summary, body string, width int) string { - meta, extracted := splitWebFetchContent(body) - sections := make([]string, 0, 3) - if summary = strings.TrimSpace(summary); summary != "" { - sections = append(sections, styles.Key.Render("page")+"\n"+renderContentBody(styles, summary, width, contentFlavorPlain)) - } - if meta = strings.TrimSpace(meta); meta != "" { - sections = append(sections, styles.Key.Render("fetch")+"\n"+renderContentBody(styles, meta, width, contentFlavorPlain)) - } - if extracted = strings.TrimSpace(extracted); extracted != "" { - sections = append(sections, styles.Key.Render("result")+"\n"+renderContentBody(styles, extracted, width, contentFlavorMarkdown)) - } - if len(sections) == 0 { - return renderContentBody(styles, body, width, contentFlavorMarkdown) - } - return strings.Join(sections, "\n\n") -} - -type webSearchResult struct { - Title string - URL string - Description string - Source string -} - -func parseWebSearchContent(body string) (string, []webSearchResult) { - lines := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") - var query string - results := make([]webSearchResult, 0) - var current *webSearchResult - for _, raw := range lines { - line := strings.TrimSpace(raw) - if line == "" { - continue - } - if strings.HasPrefix(line, "Query: ") { - query = strings.TrimSpace(strings.TrimPrefix(line, "Query: ")) - continue - } - if idx := leadingNumberedItem(line); idx >= 0 { - if current != nil { - results = append(results, *current) - } - current = &webSearchResult{Title: strings.TrimSpace(line[idx:])} - continue - } - if current == nil { - continue - } - if current.URL == "" && strings.HasPrefix(line, "http") { - current.URL = line - continue - } - if strings.HasPrefix(line, "Source: ") { - current.Source = strings.TrimSpace(strings.TrimPrefix(line, "Source: ")) - continue - } - if current.Description == "" { - current.Description = line - } else { - current.Description += "\n" + line - } - } - if current != nil { - results = append(results, *current) - } - return query, results -} - -func renderSearchResultCard(styles common.Styles, index int, result webSearchResult, width int) string { - linkStyle := lipgloss.NewStyle().Foreground(common.ColorBlue) - parts := []string{styles.AssistantLabel.Render(fmt.Sprintf("%d. %s", index, result.Title))} - if result.URL != "" { - // Truncate long URLs so they don't character-wrap across lines. - url := ansi.Truncate(result.URL, width, "…") - parts = append(parts, linkStyle.Render(url)) - } - if result.Description != "" { - parts = append(parts, renderContentBody(styles, result.Description, width, contentFlavorMarkdown)) - } - if result.Source != "" { - parts = append(parts, styles.MsgTimestamp.Render("source: "+result.Source)) - } - return strings.Join(parts, "\n") -} - -func leadingNumberedItem(line string) int { - dot := strings.Index(line, ".") - if dot <= 0 { - return -1 - } - for _, r := range line[:dot] { - if r < '0' || r > '9' { - return -1 - } - } - return dot + 1 -} - -func splitWebFetchContent(body string) (string, string) { - normalized := strings.ReplaceAll(body, "\r\n", "\n") - parts := strings.SplitN(normalized, "Result:\n", 2) - if len(parts) != 2 { - return "", normalized - } - return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) -} - -func (t *toolItem) resultContent() string { - if content := stringFromMap(t.metadata, "content"); content != "" { - return content - } - return "" -} - -func (t *toolItem) diffPreview() string { - if patch := nestedString(t.metadata["git_diff"], "patch", "Patch"); patch != "" { - return patch - } - if structured := prettyJSON(t.metadata["structured_patch"]); structured != "" { - return structured - } - if original := stringFromMap(t.metadata, "original_file"); original != "" { - if content := stringFromMap(t.metadata, "content"); content != "" { - return "--- before\n" + original + "\n\n+++ after\n" + content - } - } - return "" -} - -// writeContent returns the actual file content from the tool input (not the result). -// write_file stores the content-to-write in its input, not in the result metadata. -func (t *toolItem) writeContent() string { - return stringFromMap(t.toolInput(), "content") -} - -// unifiedDiff returns a proper unified-diff string from the best available source. -func (t *toolItem) unifiedDiff() string { - // Structured patch from result metadata (JSON hunk array from the engine) - if sp := t.metadata["structured_patch"]; sp != nil { - if diff := structuredPatchToUnified(sp); diff != "" { - return diff - } - } - // Git diff attached to metadata - if patch := nestedString(t.metadata["git_diff"], "patch", "Patch"); patch != "" { - return patch - } - // Patch field in tool input (apply_patch, edit_file with patch arg) - if patch := stringFromMap(t.toolInput(), "patch"); strings.TrimSpace(patch) != "" { - return patch - } - // Compute from old/new content fields - if old := stringFromMap(t.toolInput(), "old_content"); old != "" { - return buildSimpleDiff(old, stringFromMap(t.toolInput(), "new_content")) - } - return "" -} - -// structuredPatchToUnified converts the engine's JSON hunk array to a unified diff. -func structuredPatchToUnified(raw any) string { - b, err := json.Marshal(raw) - if err != nil { - return "" - } - var hunks []struct { - OldStart int `json:"oldStart"` - OldLines int `json:"oldLines"` - NewStart int `json:"newStart"` - NewLines int `json:"newLines"` - Lines []string `json:"lines"` - } - if err := json.Unmarshal(b, &hunks); err != nil || len(hunks) == 0 { - return "" - } - var sb strings.Builder - for _, h := range hunks { - fmt.Fprintf(&sb, "@@ -%d,%d +%d,%d @@\n", h.OldStart, h.OldLines, h.NewStart, h.NewLines) - for _, ln := range h.Lines { - sb.WriteString(ln + "\n") - } - } - return strings.TrimRight(sb.String(), "\n") -} - -func (t *toolItem) commandOutput() string { - stdout := stringFromMap(t.metadata, "stdout") - stderr := stringFromMap(t.metadata, "stderr") - if stdout == "" && stderr == "" { - if content := t.resultContent(); content != "" { - return content - } - return t.label - } - if stdout != "" && stderr != "" { - return stdout + "\n\n[stderr]\n" + stderr - } - if stdout != "" { - return stdout - } - return stderr -} - -func (t *toolItem) agentDetails() string { - input := t.toolInput() - var parts []string - if nickname := stringFromMap(input, "nickname"); nickname != "" { - parts = append(parts, "nickname: "+nickname) - } - if role := stringFromMap(input, "role"); role != "" { - parts = append(parts, "role: "+role) - } - if agentID := stringFromMap(input, "agent_id"); agentID != "" { - parts = append(parts, "agent: "+agentID) - } - if prompt := stringFromMap(input, "prompt"); prompt != "" { - parts = append(parts, "prompt:\n"+prompt) - } - if msg := stringFromMap(input, "message"); msg != "" { - parts = append(parts, "message:\n"+msg) - } - if content := t.resultContent(); content != "" { - parts = append(parts, content) - } - return strings.Join(parts, "\n\n") -} - -type systemItem struct{ content string } - -func (s *systemItem) isFinished() bool { return true } -func (s *systemItem) invalidate() {} -func (s *systemItem) render(c *Chat, _ int) string { - return c.styles.MsgTimestamp.Render("─ " + s.content) -} - -type errorItem struct{ content string } - -func (e *errorItem) isFinished() bool { return true } -func (e *errorItem) invalidate() {} -func (e *errorItem) render(c *Chat, _ int) string { - return c.styles.ToolError.Render("✗ " + e.content) -} - -type toolRegion struct { - startLine int - endLine int - msgIndex int - expanderStart int - expanderEnd int - detailStart int - detailEnd int -} - -type thinkingRegion struct { - startLine int - endLine int - msgIndex int -} - -type Chat struct { - styles common.Styles - viewport *viewport.Model - renderer *glamour.TermRenderer - detail *viewport.Model - messages []msgItem - width int - height int - follow bool - - selectedTool int - detailOpen bool - - renderedContent string - renderedLines []string - plainContent string - plainLines []string - toolRegions []toolRegion - thinkingRegions []thinkingRegion - selection mouseSelection - verboseInterim bool - detailKey string -} - -func NewChat(styles common.Styles, width, height int) *Chat { - vp := viewport.New(viewport.WithWidth(width), viewport.WithHeight(height)) - vp.SetContent("") - detail := viewport.New(viewport.WithWidth(width), viewport.WithHeight(height)) - detail.SetContent("") - r := common.MarkdownRenderer(width) - return &Chat{ - styles: styles, - viewport: &vp, - renderer: r, - detail: &detail, - follow: true, - width: width, - height: height, - selectedTool: -1, - } -} - -func (c *Chat) SetSize(width, height int) { - c.width = width - c.height = height - c.viewport.SetWidth(width) - c.viewport.SetHeight(height) - c.detailKey = "" - if r := common.MarkdownRenderer(width); r != nil { - c.renderer = r - } - for _, m := range c.messages { - m.invalidate() - } - c.refresh() -} - -func (c *Chat) SetVerboseInterim(v bool) { - if c.verboseInterim == v { - return - } - c.verboseInterim = v - for _, m := range c.messages { - m.invalidate() - } - c.refresh() -} - -func (c *Chat) VerboseInterim() bool { - return c.verboseInterim -} - -func (c *Chat) AddUserMessage(text string) { - c.messages = append(c.messages, &userItem{content: text, timestamp: time.Now()}) - c.refresh() -} - -func (c *Chat) StartAssistantMessage() { - c.messages = append(c.messages, newAssistantItem()) - c.refresh() -} - -func (c *Chat) AppendChunk(text string, isThinking bool) { - if text == "" { - return - } - for i := len(c.messages) - 1; i >= 0; i-- { - if a, ok := c.messages[i].(*assistantItem); ok && a.streaming { - if isThinking { - a.appendThinking(text) - } else { - a.appendContent(text) - } - c.refresh() - return - } - } - isContinuation := false - continuationStart := time.Time{} - for i := len(c.messages) - 1; i >= 0; i-- { - if _, ok := c.messages[i].(*userItem); ok { - break - } - if a, ok := c.messages[i].(*assistantItem); ok { - isContinuation = true - continuationStart = a.startedAt - break - } - } - if isContinuation { - c.messages = append(c.messages, newContinuationItem(continuationStart)) - } else { - c.messages = append(c.messages, newAssistantItem()) - } - c.AppendChunk(text, isThinking) -} - -func (c *Chat) FinishAssistantMessage(inputTokens, outputTokens int, stopReason string) { - for i := len(c.messages) - 1; i >= 0; i-- { - if a, ok := c.messages[i].(*assistantItem); ok && a.streaming { - a.finish(inputTokens, outputTokens, stopReason, true) - c.refresh() - return - } - } -} - -func (c *Chat) AddToolProgress(toolUseID, toolName, status, label string, metadata map[string]any) { - if toolUseID != "" { - for i := len(c.messages) - 1; i >= 0; i-- { - if t, ok := c.messages[i].(*toolItem); ok && t.id == toolUseID { - t.status = status - t.label = label - if len(metadata) > 0 { - t.metadata = cloneMap(metadata) - } - if t.isDone() { - t.finishedAt = time.Now() - if isAutoExpandTool(t.name) && !t.expanded { - t.expanded = true - } - } - t.invalidate() - c.refresh() - return - } - } - } else { - for i := len(c.messages) - 1; i >= 0; i-- { - if t, ok := c.messages[i].(*toolItem); ok && t.name == toolName && !t.isDone() { - t.status = status - t.label = label - if len(metadata) > 0 { - t.metadata = cloneMap(metadata) - } - if t.isDone() { - t.finishedAt = time.Now() - if isAutoExpandTool(t.name) && !t.expanded { - t.expanded = true - } - } - t.invalidate() - c.refresh() - return - } - } - } - - c.sealActiveAssistant() - c.messages = append(c.messages, newToolItem(toolUseID, toolName, status, label, metadata)) - c.selectedTool = len(c.messages) - 1 - if isAutoExpandTool(toolName) { - c.detailOpen = true - } - c.refresh() -} - -func (c *Chat) sealActiveAssistant() { - for i := len(c.messages) - 1; i >= 0; i-- { - a, ok := c.messages[i].(*assistantItem) - if !ok || !a.streaming { - continue - } - hasThinking := a.thinking != nil && strings.TrimSpace(a.thinking.content) != "" - if a.content == "" && !hasThinking { - c.messages = append(c.messages[:i], c.messages[i+1:]...) - } else { - a.finish(0, 0, "", false) - } - return - } -} - -func (c *Chat) AddError(err error) { - c.messages = append(c.messages, &errorItem{content: err.Error()}) - c.refresh() -} - -func (c *Chat) AddSystem(text string) { - c.messages = append(c.messages, &systemItem{content: text}) - c.refresh() -} - -func (c *Chat) Clear() { - c.messages = c.messages[:0] - c.selectedTool = -1 - c.detailOpen = false - c.refresh() -} - -func (c *Chat) GetLastAssistantText() string { - var parts []string - inCurrentTurn := false - for i := len(c.messages) - 1; i >= 0; i-- { - switch m := c.messages[i].(type) { - case *userItem: - if inCurrentTurn { - goto done - } - case *assistantItem: - inCurrentTurn = true - if m.content != "" { - parts = append([]string{m.content}, parts...) - } - } - } -done: - return strings.Join(parts, "\n\n") -} - -func (c *Chat) GetLastUserText() string { - for i := len(c.messages) - 1; i >= 0; i-- { - if u, ok := c.messages[i].(*userItem); ok { - return u.content - } - } - return "" -} - -func (c *Chat) ToggleThinking() { - for i := len(c.messages) - 1; i >= 0; i-- { - if a, ok := c.messages[i].(*assistantItem); ok { - if a.thinking != nil { - a.thinking.toggle() - c.refresh() - } - return - } - } -} - -func (c *Chat) HasThinking() bool { - for i := len(c.messages) - 1; i >= 0; i-- { - if a, ok := c.messages[i].(*assistantItem); ok { - return a.thinking != nil - } - } - return false -} - -func (c *Chat) HasTools() bool { - for _, item := range c.messages { - if _, ok := item.(*toolItem); ok { - return true - } - } - return false -} - -func (c *Chat) HasSelectedTool() bool { - return c.selectedToolIndex() >= 0 -} - -func (c *Chat) DetailsOpen() bool { - return c.detailOpen && c.HasSelectedTool() -} - -func (c *Chat) ToggleSelectedToolExpanded() bool { - if tool := c.selectedToolItem(); tool != nil { - tool.expanded = !tool.expanded - tool.invalidate() - c.refresh() - return true - } - return false -} - -func (c *Chat) ToggleDetails() bool { - if !c.HasSelectedTool() { - return false - } - c.detailOpen = !c.detailOpen - c.refresh() - return true -} - -func (c *Chat) CloseDetails() { - if c.detailOpen { - c.detailOpen = false - c.refresh() - } -} - -func (c *Chat) SelectNextTool() bool { - for i, start := range c.toolIndices() { - if start > c.selectedToolIndex() { - c.selectedTool = start - c.refresh() - _ = i - return true - } - } - indices := c.toolIndices() - if len(indices) > 0 && c.selectedToolIndex() < 0 { - c.selectedTool = indices[0] - c.refresh() - return true - } - return false -} - -func (c *Chat) SelectPrevTool() bool { - indices := c.toolIndices() - for i := len(indices) - 1; i >= 0; i-- { - if indices[i] < c.selectedToolIndex() { - c.selectedTool = indices[i] - c.refresh() - return true - } - } - if len(indices) > 0 && c.selectedToolIndex() < 0 { - c.selectedTool = indices[len(indices)-1] - c.refresh() - return true - } - return false -} - -func (c *Chat) HandleMouseDown(x, y int) bool { - line := c.viewport.YOffset() + clampInt(y, 0, max(0, c.height-1)) - if line < 0 || line >= len(c.plainLines) { - return false - } - clicks := c.selection.begin(line, max(0, x), time.Now()) - switch clicks { - case 2: - c.selectWordAt(line, max(0, x)) - case 3: - c.selectLineAt(line) - } - c.refresh() - return true -} - -func (c *Chat) HandleMouseDrag(x, y int) bool { - if !c.selection.dragging { - return false - } - if len(c.plainLines) == 0 { - return false - } - if y < 0 { - c.ScrollUp(1) - y = 0 - } else if y >= c.height { - c.ScrollDown(1) - y = max(0, c.height-1) - } - line := c.viewport.YOffset() + clampInt(y, 0, max(0, c.height-1)) - line = clampInt(line, 0, len(c.plainLines)-1) - c.selection.update(line, max(0, x)) - c.refresh() - return true -} - -func (c *Chat) HandleMouseUp(x, y int) string { - if !c.selection.dragging { - return "" - } - _ = c.HandleMouseDrag(x, y) - wasMoved := c.selection.finish() - text := "" - if wasMoved { - text = c.selectedText() - c.refresh() - } else { - line := c.selection.startLine - c.selection.clear() - if idx := c.thinkingIndexAtLine(line); idx >= 0 { - c.handleThinkingLineClick(idx) - } else if idx := c.toolIndexAtLine(line); idx >= 0 { - c.handleToolLineClick(idx, max(0, x), line) - } else { - c.refresh() - } - } - return text -} - -func (c *Chat) HasMouseCapture() bool { - return c.selection.dragging -} - -func (c *Chat) handleToolLineClick(msgIndex, x, line int) { - if msgIndex < 0 || msgIndex >= len(c.messages) { - return - } - tool, ok := c.messages[msgIndex].(*toolItem) - if !ok { - return - } - for _, region := range c.toolRegions { - if region.msgIndex != msgIndex { - continue - } - if line == region.startLine && x >= region.expanderStart && x < region.expanderEnd && tool.supportsPreview() { - tool.expanded = !tool.expanded - tool.invalidate() - c.selectedTool = msgIndex - c.refresh() - return - } - if line == region.startLine && x >= region.detailStart && x < region.detailEnd { - c.selectedTool = msgIndex - c.detailOpen = !(c.selectedTool == msgIndex && c.detailOpen) - c.refresh() - return - } - break - } - c.selectedTool = msgIndex - c.refresh() -} - -func (c *Chat) handleThinkingLineClick(msgIndex int) { - if msgIndex < 0 || msgIndex >= len(c.messages) { - return - } - assistant, ok := c.messages[msgIndex].(*assistantItem) - if !ok || assistant.thinking == nil { - return - } - assistant.thinking.toggle() - assistant.invalidate() - c.refresh() -} - -func (c *Chat) thinkingIndexAtLine(line int) int { - for _, region := range c.thinkingRegions { - if line >= region.startLine && line <= region.endLine { - return region.msgIndex - } - } - return -1 -} - -func (c *Chat) toolIndexAtLine(line int) int { - for _, region := range c.toolRegions { - if line >= region.startLine && line <= region.endLine { - return region.msgIndex - } - } - return -1 -} - -func (c *Chat) selectWordAt(line, col int) { - if line < 0 || line >= len(c.plainLines) { - return - } - runes := []rune(c.plainLines[line]) - if len(runes) == 0 { - c.selection.setRange(line, 0, line, 0) - return - } - col = clampInt(col, 0, len(runes)-1) - isWord := func(r rune) bool { - return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' - } - if !isWord(runes[col]) { - for col < len(runes) && !isWord(runes[col]) { - col++ - } - if col >= len(runes) { - c.selection.setRange(line, 0, line, len(runes)) - return - } - } - start := col - for start > 0 && isWord(runes[start-1]) { - start-- - } - end := col - for end < len(runes) && isWord(runes[end]) { - end++ - } - c.selection.setRange(line, start, line, end) -} - -func (c *Chat) selectLineAt(line int) { - if line < 0 || line >= len(c.plainLines) { - return - } - runes := []rune(c.plainLines[line]) - c.selection.setRange(line, 0, line, len(runes)) -} - -func (c *Chat) HasSelection() bool { - return c.selection.hasSelection() -} - -func (c *Chat) SelectedText() string { - return c.selectedText() -} - -func (c *Chat) selectedText() string { - if len(c.plainLines) == 0 { - return "" - } - startLn, startCo, endLn, endCo := c.selectionRange() - if startLn < 0 || endLn < 0 { - return "" - } - var parts []string - for line := startLn; line <= endLn; line++ { - current := c.plainLines[line] - runes := []rune(current) - lineStart := 0 - lineEnd := len(runes) - if line == startLn { - lineStart = clampInt(startCo, 0, len(runes)) - } - if line == endLn { - lineEnd = clampInt(endCo, 0, len(runes)) - } - if line == startLn && line == endLn && lineEnd < lineStart { - lineStart, lineEnd = lineEnd, lineStart - } - if lineEnd < lineStart { - lineEnd = lineStart - } - parts = append(parts, normalizeCopiedLine(string(runes[lineStart:lineEnd]))) - } - joined := strings.Join(parts, "\n") - joined = strings.Trim(joined, "\n") - return joined -} - -func normalizeCopiedLine(line string) string { - switch { - case strings.HasPrefix(line, "● > "): - return strings.TrimPrefix(line, "● > ") - case strings.HasPrefix(line, "● "): - return strings.TrimPrefix(line, "● ") - case line == "●": - return "" - case strings.HasPrefix(line, "─ "): - return strings.TrimPrefix(line, "─ ") - case strings.HasPrefix(line, "✗ "): - return strings.TrimPrefix(line, "✗ ") - default: - return line - } -} - -func (c *Chat) selectionRange() (int, int, int, int) { - return c.selection.rangeOrInvalid() -} - -func clampInt(v, lo, hi int) int { - if v < lo { - return lo - } - if v > hi { - return hi - } - return v -} - -func (c *Chat) DetailView(width, height int) string { - tool := c.selectedToolItem() - if tool == nil { - return "" - } - return tool.detailView(c, width, height) -} - -func (c *Chat) renderToolDetail(t *toolItem, width, height int) string { - if width < 20 || height < 6 { - return "" - } - innerW := max(10, width-4) - clampLines := func(s string) string { - lines := strings.Split(s, "\n") - for i, l := range lines { - lines[i] = ansi.Truncate(l, innerW, "…") - } - return strings.Join(lines, "\n") - } - sections := []string{ - ansi.Truncate(c.styles.AssistantLabel.Render(toolDisplayName(t.name)), innerW, "…"), - ansi.Truncate(c.styles.MsgTimestamp.Render(strings.ToUpper(t.status)), innerW, "…"), - } - if summary := strings.TrimSpace(t.summaryText()); summary != "" { - sections = append(sections, clampLines(wrap.String(summary, innerW))) - } - if meta := strings.TrimSpace(t.metaSummary()); meta != "" { - sections = append(sections, clampLines(wrap.String(meta, innerW))) - } - header := strings.Join(sections, "\n\n") - body := strings.TrimSpace(t.detailBody(c, innerW)) - if body == "" { - body = c.styles.MsgTimestamp.Render("No output") - } - bodyH := max(3, height-lipgloss.Height(header)-4) - key := t.detailCacheKey(innerW, bodyH, body) - sizeChanged := c.detail.Width() != innerW || c.detail.Height() != bodyH - if c.detail.Width() != innerW { - c.detail.SetWidth(innerW) - } - if c.detail.Height() != bodyH { - c.detail.SetHeight(bodyH) - } - if c.detailKey != key || sizeChanged { - yOffset := c.detail.YOffset() - c.detail.SetContent(body) - if c.detailKey != key { - c.detail.GotoTop() - } else { - c.detail.SetYOffset(yOffset) - } - c.detailKey = key - } - content := header + "\n\n" + c.detail.View() - return lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(common.ColorBorder). - Padding(0, 1). - Width(width). - Height(height). - Render(content) -} - -func (c *Chat) ScrollUp(n int) { c.follow = false; c.viewport.ScrollUp(n) } -func (c *Chat) ScrollDown(n int) { c.viewport.ScrollDown(n); c.follow = c.viewport.AtBottom() } -func (c *Chat) PageUp() { c.follow = false; c.viewport.HalfPageUp() } -func (c *Chat) PageDown() { c.viewport.HalfPageDown(); c.follow = c.viewport.AtBottom() } -func (c *Chat) GotoTop() { c.follow = false; c.viewport.GotoTop() } -func (c *Chat) GotoBottom() { c.follow = true; c.viewport.GotoBottom() } -func (c *Chat) DetailScrollUp(n int) { - if c.detail != nil { - c.detail.ScrollUp(n) - } -} -func (c *Chat) DetailScrollDown(n int) { - if c.detail != nil { - c.detail.ScrollDown(n) - } -} -func (c *Chat) DetailPageUp() { - if c.detail != nil { - c.detail.HalfPageUp() - } -} -func (c *Chat) DetailPageDown() { - if c.detail != nil { - c.detail.HalfPageDown() - } -} -func (c *Chat) DetailGotoTop() { - if c.detail != nil { - c.detail.GotoTop() - } -} -func (c *Chat) DetailGotoBottom() { - if c.detail != nil { - c.detail.GotoBottom() - } -} -func (c *Chat) View() string { return c.viewport.View() } - -func (c *Chat) selectedToolIndex() int { - if c.selectedTool < 0 || c.selectedTool >= len(c.messages) { - return -1 - } - if _, ok := c.messages[c.selectedTool].(*toolItem); !ok { - return -1 - } - return c.selectedTool -} - -func (c *Chat) selectedToolItem() *toolItem { - if idx := c.selectedToolIndex(); idx >= 0 { - if tool, ok := c.messages[idx].(*toolItem); ok { - return tool - } +func (c *Chat) ToggleDetails() bool { + if !c.HasSelectedTool() { + return false } - return nil + c.detailOpen = !c.detailOpen + c.refresh() + return true } -func (c *Chat) toolIndices() []int { - indices := make([]int, 0) - for i, item := range c.messages { - if _, ok := item.(*toolItem); ok { - indices = append(indices, i) - } +func (c *Chat) CloseDetails() { + if c.detailOpen { + c.detailOpen = false + c.refresh() } - return indices } -func (c *Chat) refresh() { - var sb strings.Builder - var plainSB strings.Builder - lastWasTool := false - wroteAny := false - line := 0 - toolRegions := make([]toolRegion, 0) - thinkingRegions := make([]thinkingRegion, 0) - for i, item := range c.messages { - var rendered string - if tool, ok := item.(*toolItem); ok { - rendered = tool.renderSelected(c, c.width, i == c.selectedToolIndex()) - } else { - rendered = item.render(c, c.width) - } - if rendered == "" { - continue - } - plainRendered := ansi.Strip(rendered) - if wroteAny { - _, currIsTool := item.(*toolItem) - if lastWasTool && currIsTool { - sb.WriteString("\n") - plainSB.WriteString("\n") - line += 1 - } else { - sb.WriteString("\n\n") - plainSB.WriteString("\n\n") - line += 2 - } - } - startLine := line - sb.WriteString(rendered) - plainSB.WriteString(plainRendered) - height := max(1, lipgloss.Height(plainRendered)) - if _, ok := item.(*toolItem); ok { - toolRegions = append(toolRegions, toolRegion{startLine: startLine, endLine: startLine + height - 1, msgIndex: i, expanderStart: 0, expanderEnd: 1, detailStart: 2, detailEnd: 3}) - } - if assistant, ok := item.(*assistantItem); ok && assistant.thinking != nil && strings.TrimSpace(assistant.thinking.content) != "" { - thinkingStart := startLine - if assistant.showLabel { - thinkingStart++ - } - thinkingRendered := assistant.thinking.render(c.styles, c.width) - thinkingHeight := max(1, lipgloss.Height(ansi.Strip(thinkingRendered))) - thinkingRegions = append(thinkingRegions, thinkingRegion{startLine: thinkingStart, endLine: thinkingStart + thinkingHeight - 1, msgIndex: i}) - } - line += height - _, lastWasTool = item.(*toolItem) - wroteAny = true - } - content := sb.String() - plain := plainSB.String() - c.renderedContent = content - if content == "" { - c.renderedLines = nil - } else { - c.renderedLines = strings.Split(content, "\n") - } - c.plainContent = plain - if plain == "" { - c.plainLines = nil - } else { - c.plainLines = strings.Split(plain, "\n") - } - c.toolRegions = toolRegions - c.thinkingRegions = thinkingRegions - if c.selection.hasSelection() { - c.viewport.SetContent(c.highlightedSelectionContent()) - } else { - c.viewport.SetContent(content) - } - if c.follow { - c.viewport.GotoBottom() - } -} -func (c *Chat) highlightedSelectionContent() string { - if len(c.renderedLines) == 0 { - return c.renderedContent - } - startLn, startCo, endLn, endCo := c.selectionRange() - if startLn < 0 || endLn < 0 { - return c.renderedContent - } - lines := make([]string, len(c.renderedLines)) - copy(lines, c.renderedLines) - for line := startLn; line <= endLn; line++ { - renderedLine := lines[line] - lineWidth := ansi.StringWidth(renderedLine) - lineStart := 0 - lineEnd := lineWidth - if line == startLn { - lineStart = clampInt(startCo, 0, lineWidth) - } - if line == endLn { - lineEnd = clampInt(endCo, 0, lineWidth) - } - if line == startLn && line == endLn && lineEnd < lineStart { - lineStart, lineEnd = lineEnd, lineStart - } - if lineEnd < lineStart { - lineEnd = lineStart - } - before := ansi.Cut(renderedLine, 0, lineStart) - middle := ansi.Cut(renderedLine, lineStart, lineEnd) - after := ansi.Cut(renderedLine, lineEnd, lineWidth) - if middle == "" && lineStart < lineWidth { - middle = ansi.Cut(renderedLine, lineStart, lineStart+1) - after = ansi.Cut(renderedLine, lineStart+1, lineWidth) +func (c *Chat) SelectNextTool() bool { + indices := c.toolIndices() + for _, idx := range indices { + if idx > c.selectedToolIndex() { + c.selectedTool = idx + c.refresh() + return true } - lines[line] = before + applySelectionStyle(middle, c.styles.Selection) + after - } - return strings.Join(lines, "\n") -} - -func headerLine(style lipgloss.Style, width int) string { - return style.Render(strings.Repeat("─", max(0, width))) -} - -func compactTokenCount(n int) string { - switch { - case n >= 1_000_000: - return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000), ".0"), ".") + "M" - case n >= 1_000: - return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000), ".0"), ".") + "k" - default: - return fmt.Sprintf("%d", n) } -} - -func truncate(s string, maxLen int) string { - if maxLen <= 0 || len([]rune(s)) <= maxLen { - return s - } - r := []rune(s) - return string(r[:maxLen-1]) + "…" -} - -func (c *Chat) Size() (int, int) { return c.width, c.height } - -// inlinePreviewLines is the default number of lines shown in collapsed inline previews. -const inlinePreviewLines = 10 - -// previewTruncFmt is the footer shown when a preview is truncated. -const previewTruncFmt = "… (%d lines hidden) [enter for full view]" - -// isAutoExpandTool returns true for tools whose inline preview should open by default. -func isAutoExpandTool(name string) bool { - switch name { - case "write_file", "edit_file", "apply_patch", "bash", "spawn_agent": + if len(indices) > 0 && c.selectedToolIndex() < 0 { + c.selectedTool = indices[0] + c.refresh() return true } - return false -} - -// ── Directory listing ───────────────────────────────────────────────────────── - -type dirEntry struct { - Name string `json:"name"` - IsDirectory bool `json:"is_directory"` - IsFile bool `json:"is_file"` - IsSymlink bool `json:"is_symlink"` - SizeBytes int64 `json:"size_bytes"` - Mode string `json:"mode"` -} - -type dirListing struct { - Path string `json:"path"` - Entries []dirEntry `json:"entries"` - Count int `json:"count"` - Truncated bool `json:"truncated"` -} - -func parseDirListing(content string) (dirListing, bool) { - content = strings.TrimSpace(content) - if !strings.HasPrefix(content, "{") { - return dirListing{}, false - } - var dl dirListing - if err := json.Unmarshal([]byte(content), &dl); err != nil { - return dirListing{}, false - } - return dl, true -} - -func humanSize(bytes int64) string { - const ( - kb = 1024 - mb = 1024 * kb - gb = 1024 * mb - ) - switch { - case bytes >= gb: - return fmt.Sprintf("%.1f GB", float64(bytes)/float64(gb)) - case bytes >= mb: - return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb)) - case bytes >= kb: - return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb)) - default: - return fmt.Sprintf("%d B", bytes) - } -} - -func shortHomePath(path string) string { - if path == "" { - return "" - } - if home, err := os.UserHomeDir(); err == nil && home != "" && strings.HasPrefix(path, home) { - return "~" + path[len(home):] - } - return prettyPath(path) -} - -// renderDirListing renders a list_directory JSON result as a human-readable tree. -// maxLines=0 means no truncation (detail sidebar). -func renderDirListing(styles common.Styles, content string, width, maxLines int) string { - dl, ok := parseDirListing(content) - if !ok { - return renderPlainBody(content, width) - } - - // Header: path + count - headerPath := styles.Key.Render(shortHomePath(dl.Path)) - count := fmt.Sprintf("(%d entries)", dl.Count) - if dl.Truncated { - count += " [truncated]" - } - headerCount := styles.MsgTimestamp.Render(count) - lines := []string{headerPath + " " + headerCount, ""} - - // Compute max visible name width for alignment. - maxNameW := 0 - for _, e := range dl.Entries { - n := e.Name - if e.IsDirectory { - n += "/" - } - if w := len("/ ") + len(n); w > maxNameW { - maxNameW = w - } - } - sizeColW := 8 - nameColW := max(16, min(maxNameW, width-sizeColW-2)) - - for _, e := range dl.Entries { - displayName := e.Name - if e.IsDirectory { - displayName += "/" - } - - var nameRendered string - switch { - case e.IsSymlink: - nameRendered = lipgloss.NewStyle().Foreground(common.ColorYellow).Render("→ " + displayName) - case e.IsDirectory: - nameRendered = lipgloss.NewStyle().Foreground(common.ColorBlue).Bold(true).Render("/ " + displayName) - default: - nameRendered = styles.PermBody.Render("· " + displayName) - } - - // Truncate very long names to stay within nameColW. - visibleNameW := lipgloss.Width(nameRendered) - if visibleNameW > nameColW { - // Re-render with truncated display name. - truncated := ansi.Truncate(displayName, nameColW-3, "…") - switch { - case e.IsSymlink: - nameRendered = lipgloss.NewStyle().Foreground(common.ColorYellow).Render("→ " + truncated) - case e.IsDirectory: - nameRendered = lipgloss.NewStyle().Foreground(common.ColorBlue).Bold(true).Render("/ " + truncated) - default: - nameRendered = styles.PermBody.Render("· " + truncated) - } - visibleNameW = lipgloss.Width(nameRendered) - } - - var sizeStr string - if !e.IsDirectory { - sizeStr = humanSize(e.SizeBytes) - } - sizeRendered := styles.MsgTimestamp.Render(fmt.Sprintf("%*s", sizeColW, sizeStr)) - - gap := max(1, nameColW-visibleNameW+1) - lines = append(lines, nameRendered+strings.Repeat(" ", gap)+sizeRendered) - } - - if maxLines > 0 && len(lines) > maxLines { - hidden := len(lines) - maxLines - lines = lines[:maxLines] - lines = append(lines, styles.ToolTruncation.Render(fmt.Sprintf(previewTruncFmt, hidden))) - } - - return strings.Join(lines, "\n") + return false } -// renderCodeBody renders source code with line numbers and syntax highlighting. -// maxLines=0 means no truncation (used by the detail sidebar). -func renderCodeBody(styles common.Styles, path, body string, width, maxLines, offset int) string { - if strings.TrimSpace(body) == "" { - return "" +func (c *Chat) SelectPrevTool() bool { + indices := c.toolIndices() + for i := len(indices) - 1; i >= 0; i-- { + if indices[i] < c.selectedToolIndex() { + c.selectedTool = indices[i] + c.refresh() + return true + } } - body = strings.ReplaceAll(body, "\r\n", "\n") - allLines := strings.Split(body, "\n") - - display := allLines - hidden := 0 - if maxLines > 0 && len(allLines) > maxLines { - hidden = len(allLines) - maxLines - display = allLines[:maxLines] + if len(indices) > 0 && c.selectedToolIndex() < 0 { + c.selectedTool = indices[len(indices)-1] + c.refresh() + return true } + return false +} - highlighted := common.SyntaxHighlight(strings.Join(display, "\n"), path) - hlLines := strings.Split(highlighted, "\n") - // Chroma may trim a trailing newline, causing one fewer output line when the - // last display line is blank. Pad or truncate to always match len(display). - for len(hlLines) < len(display) { - hlLines = append(hlLines, "") +func (c *Chat) HandleMouseDown(x, y int) bool { + line := c.viewport.YOffset() + clampInt(y, 0, max(0, c.height-1)) + if line < 0 || line >= len(c.plainLines) { + return false } - if len(hlLines) > len(display) { - hlLines = hlLines[:len(display)] + clicks := c.selection.begin(line, max(0, x), time.Now()) + switch clicks { + case 2: + c.selectWordAt(line, max(0, x)) + case 3: + c.selectLineAt(line) } + c.refresh() + return true +} - maxNum := len(display) + offset - numWidth := len(fmt.Sprintf("%d", maxNum)) - if numWidth < 1 { - numWidth = 1 +func (c *Chat) HandleMouseDrag(x, y int) bool { + if !c.selection.dragging { + return false } - numFmt := fmt.Sprintf("%%%dd ", numWidth) - - numColW := numWidth + 1 // digits + trailing space - contentW := max(1, width-numColW) - indent := strings.Repeat(" ", numColW) - - out := make([]string, 0, len(hlLines)+1) - for i, ln := range hlLines { - lineNum := styles.ToolLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset)) - if maxLines > 0 { - // Inline preview: truncate to keep fixed height. - out = append(out, lineNum+ansi.Truncate(ln, contentW, "…")) - } else { - // Detail sidebar: soft-wrap with continuation indent. - parts := strings.Split(wrap.String(ln, contentW), "\n") - out = append(out, lineNum+parts[0]) - for _, cont := range parts[1:] { - out = append(out, indent+cont) - } - } + if len(c.plainLines) == 0 { + return false } - if hidden > 0 { - out = append(out, styles.ToolTruncation.Render(fmt.Sprintf(previewTruncFmt, hidden))) + if y < 0 { + c.ScrollUp(1) + y = 0 + } else if y >= c.height { + c.ScrollDown(1) + y = max(0, c.height-1) } - return strings.Join(out, "\n") + line := c.viewport.YOffset() + clampInt(y, 0, max(0, c.height-1)) + line = clampInt(line, 0, len(c.plainLines)-1) + c.selection.update(line, max(0, x)) + c.refresh() + return true } -// renderDiffBody renders a unified diff with +/- line coloring. -// maxLines=0 means no truncation (used by the detail sidebar). -func renderDiffBody(styles common.Styles, body string, width, maxLines int) string { - if strings.TrimSpace(body) == "" { +func (c *Chat) HandleMouseUp(x, y int) string { + if !c.selection.dragging { return "" } - body = strings.ReplaceAll(body, "\r\n", "\n") - allLines := strings.Split(body, "\n") - - display := allLines - hidden := 0 - if maxLines > 0 && len(allLines) > maxLines { - hidden = len(allLines) - maxLines - display = allLines[:maxLines] - } - - out := make([]string, 0, len(display)+1) - for _, ln := range display { - switch { - case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++"): - out = append(out, styles.ToolDiffAdd.Render(ln)) - case strings.HasPrefix(ln, "-") && !strings.HasPrefix(ln, "---"): - out = append(out, styles.ToolDiffDel.Render(ln)) - case strings.HasPrefix(ln, "@@"): - out = append(out, styles.ToolDiffHunk.Render(ln)) - default: - out = append(out, common.Escape(ln)) + _ = c.HandleMouseDrag(x, y) + wasMoved := c.selection.finish() + text := "" + if wasMoved { + text = c.selectedText() + c.refresh() + } else { + line := c.selection.startLine + c.selection.clear() + if idx := c.thinkingIndexAtLine(line); idx >= 0 { + c.handleThinkingLineClick(idx) + } else if idx := c.toolIndexAtLine(line); idx >= 0 { + c.handleToolLineClick(idx, max(0, x), line) + } else { + c.refresh() } } - if hidden > 0 { - out = append(out, styles.ToolTruncation.Render(fmt.Sprintf(previewTruncFmt, hidden))) - } - return strings.Join(out, "\n") + return text } -// renderColoredDiff renders a unified diff with full-row colored backgrounds -// (green for added, red for deleted). maxLines=0 means no truncation. -// Reuses renderPermDiffLines so both places stay visually identical. -func renderColoredDiff(styles common.Styles, diffBody string, width, maxLines int) string { - lines := renderPermDiffLines(styles, diffBody, width) - if maxLines > 0 && len(lines) > maxLines { - hidden := len(lines) - maxLines - lines = append(lines[:maxLines], styles.ToolTruncation.Render(fmt.Sprintf(previewTruncFmt, hidden))) - } - return strings.Join(lines, "\n") +func (c *Chat) HasMouseCapture() bool { + return c.selection.dragging } -// panelBox wraps rendered content in the standard rounded border box used for inline previews. -func panelBox(styles common.Styles, content string, width int) string { - if strings.TrimSpace(content) == "" { - content = styles.MsgTimestamp.Render("No output") +func (c *Chat) handleToolLineClick(msgIndex, x, line int) { + if msgIndex < 0 || msgIndex >= len(c.messages) { + return } - return lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(common.ColorBorder). - Padding(0, 1). - Width(width). - Render(content) -} - -// renderCodePanel renders a file's content as a code block for inline preview (no border). -func renderCodePanel(styles common.Styles, path, body string, width, maxLines, offset int) string { - return renderCodeBody(styles, path, body, width, maxLines, offset) -} - -// renderDiffPanel renders a unified diff for inline preview (no border). -func renderDiffPanel(styles common.Styles, body string, width, maxLines int) string { - return renderDiffBody(styles, body, width, maxLines) -} - -// renderBashInline renders a bash inline preview: a dim `$ cmd` prompt line -// followed by the command output, all truncated to maxLines (no border). -func renderBashInline(styles common.Styles, cmd, output string, width, maxLines int) string { - var lines []string - if cmd = strings.TrimSpace(cmd); cmd != "" { - cmdOneLine := strings.ReplaceAll(cmd, "\n", "; ") - lines = append(lines, styles.MsgTimestamp.Render("$ "+ansi.Truncate(cmdOneLine, max(1, width-2), "…"))) + tool, ok := c.messages[msgIndex].(*toolItem) + if !ok { + return } - if output = strings.TrimSpace(output); output != "" { - for _, ln := range strings.Split(strings.ReplaceAll(output, "\r\n", "\n"), "\n") { - lines = append(lines, wrap.String(common.Escape(ln), width)) + for _, region := range c.toolRegions { + if region.msgIndex != msgIndex { + continue } + if line == region.startLine && x >= region.expanderStart && x < region.expanderEnd && tool.supportsPreview() { + tool.expanded = !tool.expanded + tool.invalidate() + c.selectedTool = msgIndex + c.refresh() + return + } + if line == region.startLine && x >= region.detailStart && x < region.detailEnd { + c.selectedTool = msgIndex + c.detailOpen = !(c.selectedTool == msgIndex && c.detailOpen) + c.refresh() + return + } + break } - hidden := 0 - if maxLines > 0 && len(lines) > maxLines { - hidden = len(lines) - maxLines - lines = lines[:maxLines] - } - if hidden > 0 { - lines = append(lines, styles.ToolTruncation.Render(fmt.Sprintf(previewTruncFmt, hidden))) - } - return strings.Join(lines, "\n") -} - -// renderFilePanel picks the right panel renderer based on the file extension. -func renderFilePanel(styles common.Styles, path, body string, width, maxLines int) string { - switch flavorForPath(path) { - case contentFlavorCode: - return renderCodePanel(styles, path, body, width, maxLines, 0) - default: - return renderContentPanel(styles, "", body, width, maxLines, flavorForPath(path)) - } + c.selectedTool = msgIndex + c.refresh() } -func renderContentPanel(styles common.Styles, title, body string, width, maxLines int, flavor contentFlavor) string { - panelBody := renderContentBody(styles, body, width, flavor) - if maxLines > 0 { - lines := strings.Split(panelBody, "\n") - if len(lines) > maxLines { - hidden := len(lines) - maxLines - lines = append(lines[:maxLines], styles.ToolTruncation.Render(fmt.Sprintf(previewTruncFmt, hidden))) - } - panelBody = strings.Join(lines, "\n") +func (c *Chat) handleThinkingLineClick(msgIndex int) { + if msgIndex < 0 || msgIndex >= len(c.messages) { + return } - if title != "" { - panelBody = styles.Key.Render(title) + "\n" + panelBody + assistant, ok := c.messages[msgIndex].(*assistantItem) + if !ok || assistant.thinking == nil { + return } - return panelBody + assistant.thinking.toggle() + assistant.invalidate() + c.refresh() } -func renderContentBody(styles common.Styles, body string, width int, flavor contentFlavor) string { - if strings.TrimSpace(body) == "" { - return "" - } - switch flavor { - case contentFlavorMarkdown: - if rendered := renderMarkdownBody(body, width); rendered != "" { - return rendered +func (c *Chat) thinkingIndexAtLine(line int) int { + for _, region := range c.thinkingRegions { + if line >= region.startLine && line <= region.endLine { + return region.msgIndex } } - return renderPlainBody(body, width) + return -1 } -func renderMarkdownBody(body string, width int) string { - renderer := common.MarkdownRenderer(width) - if renderer == nil { - return "" - } - mu := common.LockMarkdownRenderer(renderer) - mu.Lock() - defer mu.Unlock() - rendered, err := renderer.Render(strings.ReplaceAll(body, "\r\n", "\n")) - if err != nil { - return "" +func (c *Chat) toolIndexAtLine(line int) int { + for _, region := range c.toolRegions { + if line >= region.startLine && line <= region.endLine { + return region.msgIndex + } } - return strings.TrimRight(rendered, "\n") + return -1 } -func renderPlainBody(body string, width int) string { - rawLines := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") - wrapped := make([]string, 0, len(rawLines)) - innerW := max(16, width) - for _, line := range rawLines { - wrapped = append(wrapped, wrap.String(common.Escape(line), innerW)) +func (c *Chat) selectWordAt(line, col int) { + if line < 0 || line >= len(c.plainLines) { + return } - return strings.Join(wrapped, "\n") -} - -func cloneMap(in map[string]any) map[string]any { - if len(in) == 0 { - return nil + runes := []rune(c.plainLines[line]) + if len(runes) == 0 { + c.selection.setRange(line, 0, line, 0) + return } - out := make(map[string]any, len(in)) - for k, v := range in { - out[k] = v + col = clampInt(col, 0, len(runes)-1) + isWord := func(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' } - return out -} - -func normalizeMap(v any) map[string]any { - switch m := v.(type) { - case map[string]any: - return m - case nil: - return nil - default: - b, err := json.Marshal(v) - if err != nil { - return nil + if !isWord(runes[col]) { + for col < len(runes) && !isWord(runes[col]) { + col++ } - var out map[string]any - if err := json.Unmarshal(b, &out); err != nil { - return nil + if col >= len(runes) { + c.selection.setRange(line, 0, line, len(runes)) + return } - return out } -} - -func nestedString(v any, keys ...string) string { - m := normalizeMap(v) - for _, key := range keys { - if s, ok := stringAny(m[key]); ok { - return s - } + start := col + for start > 0 && isWord(runes[start-1]) { + start-- } - return "" + end := col + for end < len(runes) && isWord(runes[end]) { + end++ + } + c.selection.setRange(line, start, line, end) } -func stringFromMap(m map[string]any, key string) string { - if m == nil { - return "" +func (c *Chat) selectLineAt(line int) { + if line < 0 || line >= len(c.plainLines) { + return } - s, _ := stringAny(m[key]) - return s + runes := []rune(c.plainLines[line]) + c.selection.setRange(line, 0, line, len(runes)) } -func intFromMap(m map[string]any, key string) (int, bool) { - if m == nil { - return 0, false - } - switch v := m[key].(type) { - case int: - return v, true - case int32: - return int(v), true - case int64: - return int(v), true - case float64: - return int(v), true - case float32: - return int(v), true - case json.Number: - i, err := v.Int64() - return int(i), err == nil - default: - return 0, false - } +func (c *Chat) HasSelection() bool { + return c.selection.hasSelection() } -func stringAny(v any) (string, bool) { - switch x := v.(type) { - case string: - return x, true - case json.Number: - return x.String(), true - case fmt.Stringer: - return x.String(), true - case nil: - return "", false - default: - return fmt.Sprintf("%v", x), true - } +func (c *Chat) SelectedText() string { + return c.selectedText() } -func prettyPath(path string) string { - if path == "" { +func (c *Chat) selectedText() string { + if len(c.plainLines) == 0 { return "" } - clean := filepath.Clean(path) - return strings.ReplaceAll(clean, "\\", "/") -} - -func compactPath(path string) string { - pretty := prettyPath(path) - if pretty == "" { + startLn, startCo, endLn, endCo := c.selectionRange() + if startLn < 0 || endLn < 0 { return "" } - base := filepath.Base(pretty) - if base == "." || base == "/" { - return pretty + var parts []string + for line := startLn; line <= endLn; line++ { + current := c.plainLines[line] + runes := []rune(current) + lineStart := 0 + lineEnd := len(runes) + if line == startLn { + lineStart = clampInt(startCo, 0, len(runes)) + } + if line == endLn { + lineEnd = clampInt(endCo, 0, len(runes)) + } + if line == startLn && line == endLn && lineEnd < lineStart { + lineStart, lineEnd = lineEnd, lineStart + } + if lineEnd < lineStart { + lineEnd = lineStart + } + parts = append(parts, normalizeCopiedLine(string(runes[lineStart:lineEnd]))) } - return base + joined := strings.Join(parts, "\n") + joined = strings.Trim(joined, "\n") + return joined } -func prettyJSON(v any) string { - if v == nil { - return "" - } - b, err := json.MarshalIndent(v, "", " ") - if err != nil { +func normalizeCopiedLine(line string) string { + switch { + case strings.HasPrefix(line, "● > "): + return strings.TrimPrefix(line, "● > ") + case strings.HasPrefix(line, "● "): + return strings.TrimPrefix(line, "● ") + case line == "●": return "" + case strings.HasPrefix(line, "─ "): + return strings.TrimPrefix(line, "─ ") + case strings.HasPrefix(line, "✗ "): + return strings.TrimPrefix(line, "✗ ") + default: + return line } - return string(b) } -func indentBlock(s, indent string) string { - lines := strings.Split(s, "\n") - for i, line := range lines { - lines[i] = indent + line - } - return strings.Join(lines, "\n") +func (c *Chat) selectionRange() (int, int, int, int) { + return c.selection.rangeOrInvalid() } -func formatDuration(d time.Duration) string { - if d < time.Second { - return fmt.Sprintf("%dms", d.Milliseconds()) +func (c *Chat) Size() (int, int) { return c.width, c.height } + +const inlinePreviewLines = 10 +const previewTruncFmt = "… (%d lines hidden) [enter for full view]" + +func isAutoExpandTool(name string) bool { + switch name { + case "write_file", "edit_file", "apply_patch", "bash", "spawn_agent": + return true } - return fmt.Sprintf("%.1fs", d.Seconds()) + return false } -func toolDisplayName(name string) string { - name = strings.ReplaceAll(name, "_", " ") - name = strings.ReplaceAll(name, "-", " ") - parts := strings.Fields(name) - for i, part := range parts { - if part == "api" || part == "mcp" { - parts[i] = strings.ToUpper(part) - continue - } - parts[i] = strings.ToUpper(part[:1]) + part[1:] +func isGroupableTool(name string) bool { + switch name { + case "read_file", "write_file", "list_directory", "glob": + return true } - return strings.Join(parts, " ") + return false } -func firstLine(s string) string { - s = strings.TrimSpace(s) - if s == "" { - return "" +func clampInt(v, lo, hi int) int { + if v < lo { + return lo } - if idx := strings.IndexByte(s, '\n'); idx >= 0 { - return s[:idx] + if v > hi { + return hi } - return s + return v } diff --git a/internal/tui/components/chat_assistant.go b/internal/tui/components/chat_assistant.go new file mode 100644 index 0000000..338c391 --- /dev/null +++ b/internal/tui/components/chat_assistant.go @@ -0,0 +1,268 @@ +package components + +import ( + "fmt" + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/EngineerProjects/nexus-engine/internal/tui/common" + "github.com/muesli/reflow/wrap" +) + +const thinkTailLines = 4 +const interimNarrationLines = 2 + +type thinkingBlock struct { + content string + streaming bool + startedAt time.Time + finishedAt time.Time + collapsed bool + + cacheWidth int + cacheRender string +} + +func newThinkingBlock() *thinkingBlock { + return &thinkingBlock{ + streaming: true, + collapsed: true, + startedAt: time.Now(), + } +} + +func (tb *thinkingBlock) append(text string) { + tb.content += text + tb.cacheWidth = 0 +} + +func (tb *thinkingBlock) finish() { + tb.streaming = false + tb.finishedAt = time.Now() + tb.cacheWidth = 0 +} + +func (tb *thinkingBlock) toggle() { + tb.collapsed = !tb.collapsed + tb.cacheWidth = 0 +} + +func (tb *thinkingBlock) render(styles common.Styles, width int) string { + if !tb.streaming && tb.cacheWidth == width && tb.cacheRender != "" { + return tb.cacheRender + } + + innerW := width - 6 + if innerW < 10 { + innerW = 10 + } + + lines := strings.Split(strings.TrimRight(tb.content, "\n"), "\n") + var shownLines []string + var hiddenCount int + + if tb.collapsed && len(lines) > thinkTailLines { + hiddenCount = len(lines) - thinkTailLines + shownLines = lines[len(lines)-thinkTailLines:] + } else { + shownLines = lines + } + + var inner strings.Builder + if hiddenCount > 0 { + inner.WriteString(styles.MsgTimestamp.Render(fmt.Sprintf("… %d lines hidden", hiddenCount))) + inner.WriteString("\n") + } + for i, line := range shownLines { + inner.WriteString(styles.MsgTimestamp.Render(wrap.String(line, innerW))) + if i < len(shownLines)-1 { + inner.WriteString("\n") + } + } + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(common.ColorBorder). + Padding(0, 1). + Width(width - 2) + + box := boxStyle.Render(inner.String()) + + var footParts []string + if tb.streaming { + footParts = append(footParts, styles.MsgTimestamp.Render("thinking…")) + } else { + dur := tb.finishedAt.Sub(tb.startedAt).Round(100 * time.Millisecond) + footParts = append(footParts, + styles.MsgTimestamp.Render(fmt.Sprintf("Thought for %.1fs", dur.Seconds()))) + if tb.collapsed { + footParts = append(footParts, styles.Desc.Render("ctrl+t to expand")) + } else { + footParts = append(footParts, styles.Desc.Render("ctrl+t to collapse")) + } + } + foot := " " + strings.Join(footParts, " ") + + result := box + "\n" + foot + if !tb.streaming { + tb.cacheWidth = width + tb.cacheRender = result + } + return result +} + +type assistantItem struct { + thinking *thinkingBlock + content string + streaming bool + startedAt time.Time + finishedAt time.Time + showLabel bool + showMeta bool + inputTokens int + outputTokens int + stopReason string + + contentCacheWidth int + contentCacheRender string +} + +func newAssistantItem() *assistantItem { + return &assistantItem{streaming: true, showLabel: true, startedAt: time.Now()} +} + +func newContinuationItem(startedAt time.Time) *assistantItem { + if startedAt.IsZero() { + startedAt = time.Now() + } + return &assistantItem{streaming: true, showLabel: false, startedAt: startedAt} +} + +func (a *assistantItem) appendThinking(text string) { + if text == "" { + return + } + if a.thinking == nil { + a.thinking = newThinkingBlock() + } + a.thinking.append(text) + a.contentCacheWidth = 0 +} + +func (a *assistantItem) appendContent(text string) { + if a.thinking != nil && a.thinking.streaming { + a.thinking.finish() + } + a.content += text + a.contentCacheWidth = 0 +} + +func (a *assistantItem) finish(inputTokens, outputTokens int, stopReason string, showMeta bool) { + a.streaming = false + a.finishedAt = time.Now() + a.showMeta = showMeta + a.inputTokens = inputTokens + a.outputTokens = outputTokens + a.stopReason = stopReason + if a.thinking != nil && a.thinking.streaming { + a.thinking.finish() + } + a.contentCacheWidth = 0 +} + +func (a *assistantItem) isFinished() bool { return !a.streaming } +func (a *assistantItem) invalidate() { a.contentCacheWidth = 0 } + +func (a *assistantItem) render(c *Chat, width int) string { + var sb strings.Builder + if a.showLabel { + sb.WriteString(c.styles.AssistantMarker.Render("●")) + sb.WriteString("\n") + } + if a.thinking != nil && strings.TrimSpace(a.thinking.content) != "" { + sb.WriteString(a.thinking.render(c.styles, width)) + if a.content != "" { + sb.WriteString("\n\n") + } else { + sb.WriteString("\n") + } + } + if a.content != "" { + var rendered string + if !a.streaming && a.contentCacheWidth == width && a.contentCacheRender != "" { + rendered = a.contentCacheRender + } else { + if !a.streaming && !a.showMeta && !c.verboseInterim { + rendered = renderCompactAssistantNarration(c.styles, a.content, width) + } else { + var err error + mu := common.LockMarkdownRenderer(c.renderer) + mu.Lock() + rendered, err = c.renderer.Render(a.content) + mu.Unlock() + if err != nil { + rendered = a.content + } + rendered = strings.TrimRight(rendered, "\n") + } + if !a.streaming { + a.contentCacheWidth = width + a.contentCacheRender = rendered + } + } + sb.WriteString(rendered) + } else if a.streaming { + sb.WriteString(c.styles.MsgTimestamp.Render("…")) + } + if meta := a.metaLine(c.styles, width); meta != "" { + if sb.Len() > 0 { + sb.WriteString("\n\n") + } + sb.WriteString(meta) + } + return sb.String() +} + +func renderCompactAssistantNarration(styles common.Styles, content string, width int) string { + innerW := max(20, width-2) + normalized := strings.Join(strings.Fields(strings.TrimSpace(content)), " ") + if normalized == "" { + return "" + } + wrapped := strings.TrimSpace(wrap.String(normalized, innerW)) + lines := strings.Split(wrapped, "\n") + if len(lines) > interimNarrationLines { + lines = lines[:interimNarrationLines] + last := []rune(strings.TrimRight(lines[len(lines)-1], " ")) + if len(last) >= innerW { + last = last[:innerW-1] + } + lines[len(lines)-1] = strings.TrimRight(string(last), " ") + "…" + } + for i, line := range lines { + lines[i] = styles.InterimAssistant.Render(line) + } + return strings.Join(lines, "\n") +} + +func (a *assistantItem) metaLine(styles common.Styles, width int) string { + if a.streaming || a.finishedAt.IsZero() || !a.showMeta { + return "" + } + left := styles.ToolDone.Render("done") + if !a.startedAt.IsZero() { + left += styles.TurnMeta.Render(" · " + formatDuration(a.finishedAt.Sub(a.startedAt))) + } + turnTokens := a.inputTokens + a.outputTokens + if turnTokens <= 0 { + return left + } + right := styles.TurnMeta.Render(compactTokenCount(turnTokens) + " tok") + sepLen := width - lipgloss.Width(left) - lipgloss.Width(right) - 2 + if sepLen < 3 { + sepLen = 3 + } + sep := styles.TurnMeta.Render(strings.Repeat("·", sepLen)) + return left + " " + sep + " " + right +} diff --git a/internal/tui/components/chat_helpers.go b/internal/tui/components/chat_helpers.go new file mode 100644 index 0000000..08845d2 --- /dev/null +++ b/internal/tui/components/chat_helpers.go @@ -0,0 +1,92 @@ +package components + +import ( + "fmt" + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/EngineerProjects/nexus-engine/internal/tui/common" +) + +func compactTokenCount(n int) string { + switch { + case n >= 1_000_000: + return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000), ".0"), ".") + "M" + case n >= 1_000: + return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000), ".0"), ".") + "k" + default: + return fmt.Sprintf("%d", n) + } +} + +func headerLine(style lipgloss.Style, width int) string { + return style.Render(strings.Repeat("─", max(0, width))) +} + +func truncate(s string, maxLen int) string { + if maxLen <= 0 || len([]rune(s)) <= maxLen { + return s + } + r := []rune(s) + return string(r[:maxLen-1]) + "…" +} + +// renderToolGroup renders N consecutive completed same-name tool calls as one row. +// Format: " ✓ ToolName (N×) path1, path2, … (total Xms)" +func renderToolGroup(c *Chat, tools []*toolItem, width int, selected bool) string { + icon := c.styles.MsgTimestamp.Render("✓") + nameStyle := c.styles.UserMsg + name := toolDisplayName(tools[0].name) + count := c.styles.MsgTimestamp.Render(fmt.Sprintf("(%d×)", len(tools))) + + // Collect per-call summaries (file paths, etc.). + var summaries []string + for _, t := range tools { + if s := t.summaryText(); s != "" { + summaries = append(summaries, s) + } + } + var summaryStr string + if len(summaries) > 0 { + const maxShow = 3 + shown := summaries + rest := 0 + if len(summaries) > maxShow { + shown = summaries[:maxShow] + rest = len(summaries) - maxShow + } + summaryStr = strings.Join(shown, ", ") + if rest > 0 { + summaryStr += fmt.Sprintf(", +%d", rest) + } + } + + // Total wall-clock duration. + var totalDur time.Duration + for _, t := range tools { + if !t.finishedAt.IsZero() && !t.startedAt.IsZero() { + totalDur += t.finishedAt.Sub(t.startedAt) + } + } + durStr := "" + if totalDur > 0 { + durStr = "(" + formatDuration(totalDur) + ")" + } + + // The leading space keeps column alignment with single-tool rows (expander position). + parts := []string{" ", icon, nameStyle.Render(name), count} + if summaryStr != "" { + maxSum := max(12, width-len(name)-30) + parts = append(parts, c.styles.MsgTimestamp.Render(truncate(summaryStr, maxSum))) + } + if durStr != "" { + parts = append(parts, c.styles.MsgTimestamp.Render(durStr)) + } + + line := strings.Join(parts, " ") + if selected { + line = lipgloss.NewStyle().Foreground(common.ColorText).Background(lipgloss.Color("#1F2937")).Render(line) + } + return line +} diff --git a/internal/tui/components/chat_items.go b/internal/tui/components/chat_items.go new file mode 100644 index 0000000..bbd0f16 --- /dev/null +++ b/internal/tui/components/chat_items.go @@ -0,0 +1,78 @@ +package components + +import ( + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/muesli/reflow/wrap" +) + +type msgItem interface { + render(c *Chat, width int) string + isFinished() bool + invalidate() +} + +type userItem struct { + content string + timestamp time.Time + cacheW int + cacheR string +} + +func (u *userItem) isFinished() bool { return true } +func (u *userItem) invalidate() { u.cacheW = 0 } + +func (u *userItem) render(c *Chat, width int) string { + if u.cacheW == width && u.cacheR != "" { + return u.cacheR + } + _ = u.timestamp + prefix := "● > " + bodyWidth := max(12, width-lipgloss.Width(prefix)) + wrapped := strings.Split(wrap.String(u.content, bodyWidth), "\n") + if len(wrapped) == 0 { + wrapped = []string{""} + } + wrapped[0] = c.styles.UserMarker.Render(prefix) + c.styles.UserMsg.Render(wrapped[0]) + for i := 1; i < len(wrapped); i++ { + wrapped[i] = strings.Repeat(" ", lipgloss.Width(prefix)) + c.styles.UserMsg.Render(wrapped[i]) + } + r := strings.Join(wrapped, "\n") + u.cacheW = width + u.cacheR = r + return r +} + +type systemItem struct{ content string } + +func (s *systemItem) isFinished() bool { return true } +func (s *systemItem) invalidate() {} +func (s *systemItem) render(c *Chat, _ int) string { + return c.styles.MsgTimestamp.Render("─ " + s.content) +} + +type errorItem struct{ content string } + +func (e *errorItem) isFinished() bool { return true } +func (e *errorItem) invalidate() {} +func (e *errorItem) render(c *Chat, _ int) string { + return c.styles.ToolError.Render("✗ " + e.content) +} + +type toolRegion struct { + startLine int + endLine int + msgIndex int + expanderStart int + expanderEnd int + detailStart int + detailEnd int +} + +type thinkingRegion struct { + startLine int + endLine int + msgIndex int +} diff --git a/internal/tui/components/chat_test.go b/internal/tui/components/chat_test.go index 0d6529c..f23f1e7 100644 --- a/internal/tui/components/chat_test.go +++ b/internal/tui/components/chat_test.go @@ -219,22 +219,22 @@ func TestChatThinkingLineClickTogglesCollapse(t *testing.T) { } } -func TestThinkingBlockRenderShowsMouseHint(t *testing.T) { +func TestThinkingBlockRenderShowsKeyHint(t *testing.T) { tb := newThinkingBlock() for i := 0; i < 12; i++ { tb.append("line\n") } tb.finish() rendered := tb.render(common.DefaultStyles(), 50) - if !strings.Contains(rendered, "click to expand") { - t.Fatalf("expected mouse hint in thinking footer, got %q", rendered) + if !strings.Contains(rendered, "ctrl+t to expand") { + t.Fatalf("expected keyboard hint in thinking footer, got %q", rendered) } - if strings.Contains(rendered, "ctrl+t") { - t.Fatalf("expected ctrl+t hint to be removed from visible footer, got %q", rendered) + if strings.Contains(rendered, "click to") { + t.Fatalf("expected old mouse hint to be removed from footer, got %q", rendered) } } -func TestChatToolDetailsZoneTogglesDetails(t *testing.T) { +func TestChatToolSelectThenDetailsViaToggle(t *testing.T) { c := NewChat(common.DefaultStyles(), 80, 20) c.AddToolProgress("tool-1", "read_file", "completed", "done", map[string]any{ "tool_input": map[string]any{"file_path": "/tmp/a.txt"}, @@ -243,15 +243,14 @@ func TestChatToolDetailsZoneTogglesDetails(t *testing.T) { if len(c.toolRegions) == 0 { t.Fatalf("expected tool regions to be populated") } - region := c.toolRegions[0] - if !c.HandleMouseDown(region.detailStart, region.startLine) { - t.Fatalf("expected mouse down on tool detail zone to be handled") - } - if got := c.HandleMouseUp(region.detailStart, region.startLine); got != "" { - t.Fatalf("expected click on tool detail zone not to copy text, got %q", got) + // Verify a tool is selected after AddToolProgress. + if !c.HasSelectedTool() { + t.Fatalf("expected a tool to be selected after AddToolProgress") } + // Opening the detail pane is now keyboard-driven (ToggleDetails). + c.ToggleDetails() if !c.DetailsOpen() { - t.Fatalf("expected details pane to open from detail click zone") + t.Fatalf("expected details pane to open via ToggleDetails") } } diff --git a/internal/tui/components/chat_tool_details.go b/internal/tui/components/chat_tool_details.go new file mode 100644 index 0000000..c9ea65e --- /dev/null +++ b/internal/tui/components/chat_tool_details.go @@ -0,0 +1,1023 @@ +package components + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/EngineerProjects/nexus-engine/internal/tui/common" + "github.com/charmbracelet/x/ansi" + "github.com/muesli/reflow/wrap" +) + +type contentFlavor int + +const ( + contentFlavorPlain contentFlavor = iota + contentFlavorMarkdown + contentFlavorCode +) + +func (t *toolItem) inlinePreview(c *Chat, width int) string { + input := t.toolInput() + switch t.name { + case "read_file": + body := stringFromMap(t.metadata, "content") + if body == "" { + body = stringFromMap(t.metadata, "result") + } + start, clean := parseReadContent(body) + return renderCodePanel(c.styles, stringFromMap(input, "file_path"), clean, width-4, inlinePreviewLines, start) + case "write_file": + return renderFilePanel(c.styles, stringFromMap(input, "file_path"), stringFromMap(input, "content"), width-4, inlinePreviewLines) + case "edit_file", "apply_patch": + diff := t.unifiedDiff() + if diff == "" { + diff = stringFromMap(t.metadata, "diff") + } + if diff != "" { + return renderColoredDiff(c.styles, diff, width-4, inlinePreviewLines) + } + return renderFilePanel(c.styles, stringFromMap(input, "file_path"), stringFromMap(input, "content"), width-4, inlinePreviewLines) + case "bash": + return renderBashInline(c.styles, stringFromMap(input, "command"), stringFromMap(t.metadata, "content"), width-4, inlinePreviewLines) + case "web_search": + return renderContentPanel(c.styles, "", stringFromMap(t.metadata, "content"), width-4, inlinePreviewLines, contentFlavorPlain) + case "web_fetch": + return renderContentPanel(c.styles, "", stringFromMap(t.metadata, "content"), width-4, inlinePreviewLines, contentFlavorPlain) + } + if res := t.resultContent(); res != "" { + return renderPlainBody(res, width-4) + } + return "" +} + +func (t *toolItem) detailView(c *Chat, width, height int) string { + return c.renderToolDetail(t, width, height) +} + +func (t *toolItem) metaSummary() string { + input := t.toolInput() + var parts []string + if workDir := stringFromMap(t.metadata, "working_directory"); workDir != "" { + parts = append(parts, "Directory: "+prettyPath(workDir)) + } + switch t.name { + case "read_file", "write_file", "edit_file": + if path := stringFromMap(input, "file_path"); path != "" { + parts = append(parts, "Path: "+prettyPath(path)) + } + case "apply_patch": + if path := stringFromMap(input, "file_path"); path != "" { + parts = append(parts, "Path: "+prettyPath(path)) + } + case "web_search": + if q := stringFromMap(input, "query"); q != "" { + parts = append(parts, "Query: "+q) + } + case "web_fetch": + if url := stringFromMap(input, "url"); url != "" { + parts = append(parts, "URL: "+url) + } + case "spawn_agent": + if nickname := stringFromMap(input, "nickname"); nickname != "" { + parts = append(parts, "Nickname: "+nickname) + } + if id := stringFromMap(t.metadata, "agent_id"); id != "" { + parts = append(parts, "Agent ID: "+id) + } + case "wait_agent", "close_agent", "send_agent_message": + if id := stringFromMap(input, "agent_id"); id != "" { + parts = append(parts, "Agent ID: "+id) + } + } + return strings.Join(parts, "\n") +} + +func (t *toolItem) detailBody(c *Chat, width int) string { + if t.isDone() && t.detailCacheW == width && t.detailCacheR != "" { + return t.detailCacheR + } + var res string + switch t.name { + case "list_directory", "glob": + res = renderDirListing(c.styles, t.resultContent(), width, 0) + case "read_file": + path := stringFromMap(t.toolInput(), "file_path") + startLine, cleanBody := parseReadContent(t.resultContent()) + if flavorForPath(path) == contentFlavorCode { + res = renderCodeBody(c.styles, path, cleanBody, width, 0, startLine) + } else { + res = renderContentBody(c.styles, cleanBody, width, flavorForPath(path)) + } + case "write_file": + path := stringFromMap(t.toolInput(), "file_path") + content := t.writeContent() + if flavorForPath(path) == contentFlavorCode { + res = renderCodeBody(c.styles, path, content, width, 0, 0) + } else { + res = renderContentBody(c.styles, content, width, flavorForPath(path)) + } + case "edit_file", "apply_patch": + path := stringFromMap(t.toolInput(), "file_path") + if diff := t.unifiedDiff(); diff != "" { + label := "patch" + if path != "" { + label = prettyPath(path) + } + label = ansi.Truncate(label, width, "…") + res = c.styles.Key.Render(label) + "\n\n" + renderColoredDiff(c.styles, diff, width, 0) + } + case "bash": + res = renderBashDetails(c.styles, stringFromMap(t.toolInput(), "command"), t.commandOutput(), width) + case "web_search": + res = renderWebSearchDetails(c.styles, t.summaryText(), t.resultContent(), width) + case "web_fetch": + res = renderWebFetchDetails(c.styles, t.summaryText(), t.resultContent(), width) + case "spawn_agent", "wait_agent", "close_agent", "send_agent_message": + res = renderContentBody(c.styles, t.agentDetails(), width, contentFlavorPlain) + default: + res = renderContentBody(c.styles, t.resultContent(), width, contentFlavorPlain) + } + if t.isDone() { + t.detailCacheW = width + t.detailCacheR = res + } + return res +} + +func flavorForPath(path string) contentFlavor { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".md", ".markdown": + return contentFlavorMarkdown + case ".txt", ".log", "": + return contentFlavorPlain + default: + return contentFlavorCode + } +} + +func (t *toolItem) detailCacheKey(width, height int, body string) string { + return fmt.Sprintf("%d-%d-%d", width, height, len(body)) +} + +func (t *toolItem) changeStatsText() string { + added, ok1 := intFromMap(t.metadata, "lines_added") + deleted, ok2 := intFromMap(t.metadata, "lines_removed") + if !ok1 && !ok2 { + return "" + } + parts := make([]string, 0, 2) + if added > 0 { + parts = append(parts, fmt.Sprintf("+%d", added)) + } + if deleted > 0 { + parts = append(parts, fmt.Sprintf("-%d", deleted)) + } + return strings.Join(parts, " ") +} + +func renderDiffSections(styles common.Styles, title, body string, width int) string { + out := styles.Key.Render(title) + if strings.TrimSpace(body) != "" { + out += "\n\n" + renderDiffBody(styles, body, width, 0) + } + return out +} + +func renderBashDetails(styles common.Styles, cmd, output string, width int) string { + var sections []string + if cmd = strings.TrimSpace(cmd); cmd != "" { + sections = append(sections, styles.Key.Render("Command")+"\n\n"+renderCodeBody(styles, "command.sh", cmd, width, 0, 0)) + } + if output = strings.TrimSpace(output); output != "" { + sections = append(sections, styles.Key.Render("Output")+"\n\n"+renderPlainBody(output, width)) + } + return strings.Join(sections, "\n\n") +} + +func renderWebSearchDetails(styles common.Styles, summary, body string, width int) string { + brief, cards := parseWebSearchContent(body) + if brief == "" { + brief = summary + } + var out strings.Builder + if brief != "" { + out.WriteString(renderMarkdownBody(brief, width)) + } + for i, card := range cards { + if out.Len() > 0 { + out.WriteString("\n\n") + } + out.WriteString(renderSearchResultCard(styles, i+1, card, width)) + } + if out.Len() == 0 { + return renderPlainBody(body, width) + } + return out.String() +} + +func renderWebFetchDetails(styles common.Styles, summary, body string, width int) string { + brief, content := splitWebFetchContent(body) + if brief == "" { + brief = summary + } + var out strings.Builder + if brief != "" { + out.WriteString(renderMarkdownBody(brief, width)) + } + if content != "" { + if out.Len() > 0 { + out.WriteString("\n\n") + } + out.WriteString(styles.MsgTimestamp.Render(headerLine(styles.Footer, width)) + "\n\n") + out.WriteString(renderMarkdownBody(content, width)) + } + if out.Len() == 0 { + return renderPlainBody(body, width) + } + return out.String() +} + +type webSearchResult struct { + Title string `json:"title"` + URL string `json:"url"` + Description string `json:"description"` +} + +func parseWebSearchContent(body string) (string, []webSearchResult) { + body = strings.ReplaceAll(body, "\r\n", "\n") + parts := strings.SplitN(body, "\n\nJSON Results:\n", 2) + if len(parts) < 2 { + return "", nil + } + var results []webSearchResult + if err := json.Unmarshal([]byte(parts[1]), &results); err != nil { + return parts[0], nil + } + return parts[0], results +} + +func renderSearchResultCard(styles common.Styles, index int, result webSearchResult, width int) string { + w := max(12, width-2) + card := fmt.Sprintf("[%d] %s\n%s\n\n%s", index, result.Title, styles.HeaderID.Render(result.URL), result.Description) + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(common.ColorBorder). + Padding(0, 1). + Width(w). + Render(card) +} + +func leadingNumberedItem(line string) int { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "[") { + return -1 + } + idx := strings.Index(line, "]") + if idx <= 1 { + return -1 + } + val, err := strconv.Atoi(line[1:idx]) + if err != nil { + return -1 + } + return val +} + +func splitWebFetchContent(body string) (string, string) { + body = strings.ReplaceAll(body, "\r\n", "\n") + parts := strings.SplitN(body, "\n\nMarkdown Content:\n", 2) + if len(parts) < 2 { + return "", body + } + return parts[0], parts[1] +} + +func (t *toolItem) resultContent() string { + if res, ok := stringAny(t.metadata["result"]); ok && res != "" { + return res + } + if content, ok := stringAny(t.metadata["content"]); ok && content != "" { + return content + } + return "" +} + +func (t *toolItem) diffPreview() string { + var sb strings.Builder + if path := stringFromMap(t.toolInput(), "file_path"); path != "" { + sb.WriteString(compactPath(path) + " ") + } + if stats := t.changeStatsText(); stats != "" { + sb.WriteString(stats) + } + return sb.String() +} + +func (t *toolItem) writeContent() string { + return stringFromMap(t.toolInput(), "content") +} + +func (t *toolItem) unifiedDiff() string { + meta := t.metadata + if patch, ok := meta["patch"].(string); ok && patch != "" { + return patch + } + if diff, ok := meta["diff"].(string); ok && diff != "" { + return diff + } + hunksVal, ok := meta["hunks"] + if !ok { + return "" + } + b, err := json.Marshal(hunksVal) + if err != nil { + return "" + } + var hunks []struct { + Header string `json:"header"` + Lines string `json:"lines"` + } + if err := json.Unmarshal(b, &hunks); err != nil { + return "" + } + var sb strings.Builder + for i, hunk := range hunks { + sb.WriteString(hunk.Header + "\n" + hunk.Lines) + if i < len(hunks)-1 { + sb.WriteString("\n") + } + } + return sb.String() +} + +func (t *toolItem) commandOutput() string { + var sb strings.Builder + input := t.toolInput() + if cmd := stringFromMap(input, "command"); cmd != "" { + sb.WriteString(cmd + "\n") + } + if output := stringFromMap(t.metadata, "content"); output != "" { + sb.WriteString(output) + } else if result := stringFromMap(t.metadata, "result"); result != "" { + sb.WriteString(result) + } + return sb.String() +} + +func (t *toolItem) agentDetails() string { + input := t.toolInput() + var sb strings.Builder + if prompt := stringFromMap(input, "prompt"); prompt != "" { + sb.WriteString("Prompt:\n" + prompt) + } + res := t.resultContent() + if res != "" { + if sb.Len() > 0 { + sb.WriteString("\n\n") + } + sb.WriteString("Result:\n" + res) + } + return sb.String() +} + +func (c *Chat) DetailView(width, height int) string { + tool := c.selectedToolItem() + if tool == nil { + return "" + } + return tool.detailView(c, width, height) +} + +func (c *Chat) renderToolDetail(t *toolItem, width, height int) string { + if width < 20 || height < 6 { + return "" + } + innerW := max(10, width-4) + clampLines := func(s string) string { + lines := strings.Split(s, "\n") + for i, l := range lines { + lines[i] = ansi.Truncate(l, innerW, "…") + } + return strings.Join(lines, "\n") + } + sections := []string{ + ansi.Truncate(c.styles.AssistantLabel.Render(toolDisplayName(t.name)), innerW, "…"), + ansi.Truncate(c.styles.MsgTimestamp.Render(strings.ToUpper(t.status)), innerW, "…"), + } + if summary := strings.TrimSpace(t.summaryText()); summary != "" { + sections = append(sections, clampLines(wrap.String(summary, innerW))) + } + if meta := strings.TrimSpace(t.metaSummary()); meta != "" { + sections = append(sections, clampLines(wrap.String(meta, innerW))) + } + header := strings.Join(sections, "\n\n") + body := strings.TrimSpace(t.detailBody(c, innerW)) + if body == "" { + body = c.styles.MsgTimestamp.Render("No output") + } + bodyH := max(3, height-lipgloss.Height(header)-4) + key := t.detailCacheKey(innerW, bodyH, body) + + // Apply dimension changes first so SetContent lays out correctly. + sizeChanged := c.detail.Width() != innerW || c.detail.Height() != bodyH + if c.detail.Width() != innerW { + c.detail.SetWidth(innerW) + } + if c.detail.Height() != bodyH { + c.detail.SetHeight(bodyH) + } + + switch { + case c.detailToolID != t.id: + // Different tool selected: reset to top. + c.detail.SetContent(body) + c.detail.GotoTop() + c.detailKey = key + c.detailToolID = t.id + + case c.detailKey != key: + // Same tool, content grew (streaming) or size changed: preserve scroll position. + yOffset := c.detail.YOffset() + c.detail.SetContent(body) + c.detail.SetYOffset(yOffset) + c.detailKey = key + + case sizeChanged: + // Dimensions changed but content is identical: re-layout without losing position. + yOffset := c.detail.YOffset() + c.detail.SetContent(body) + c.detail.SetYOffset(yOffset) + } + + content := header + "\n\n" + c.detail.View() + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(common.ColorBorder). + Padding(0, 1). + Width(width). + Height(height). + Render(content) +} + +// ── Directory listing ───────────────────────────────────────────────────────── + +type dirEntry struct { + Name string `json:"name"` + IsDirectory bool `json:"is_directory"` + IsFile bool `json:"is_file"` + IsSymlink bool `json:"is_symlink"` + SizeBytes int64 `json:"size_bytes"` + Mode string `json:"mode"` +} + +type dirListing struct { + Path string `json:"path"` + Entries []dirEntry `json:"entries"` + Count int `json:"count"` + Truncated bool `json:"truncated"` +} + +func parseDirListing(content string) (dirListing, bool) { + content = strings.TrimSpace(content) + if !strings.HasPrefix(content, "{") { + return dirListing{}, false + } + var dl dirListing + if err := json.Unmarshal([]byte(content), &dl); err != nil { + return dirListing{}, false + } + return dl, true +} + +func humanSize(bytes int64) string { + const ( + kb = 1024 + mb = 1024 * kb + gb = 1024 * mb + ) + switch { + case bytes >= gb: + return fmt.Sprintf("%.1f GB", float64(bytes)/float64(gb)) + case bytes >= mb: + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb)) + case bytes >= kb: + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb)) + default: + return fmt.Sprintf("%d B", bytes) + } +} + +func shortHomePath(path string) string { + if path == "" { + return "" + } + if home, err := os.UserHomeDir(); err == nil && home != "" && strings.HasPrefix(path, home) { + return "~" + path[len(home):] + } + return prettyPath(path) +} + +// renderDirListing renders a list_directory JSON result as a human-readable tree. +// maxLines=0 means no truncation (detail sidebar). +func renderDirListing(styles common.Styles, content string, width, maxLines int) string { + dl, ok := parseDirListing(content) + if !ok { + return renderPlainBody(content, width) + } + + // Header: path + count + headerPath := styles.Key.Render(shortHomePath(dl.Path)) + count := fmt.Sprintf("(%d entries)", dl.Count) + if dl.Truncated { + count += " [truncated]" + } + headerCount := styles.MsgTimestamp.Render(count) + lines := []string{headerPath + " " + headerCount, ""} + + // Compute max visible name width for alignment. + maxNameW := 0 + for _, e := range dl.Entries { + n := e.Name + if e.IsDirectory { + n += "/" + } + if w := len("/ ") + len(n); w > maxNameW { + maxNameW = w + } + } + sizeColW := 8 + nameColW := max(16, min(maxNameW, width-sizeColW-2)) + + for _, e := range dl.Entries { + displayName := e.Name + if e.IsDirectory { + displayName += "/" + } + + var nameRendered string + switch { + case e.IsSymlink: + nameRendered = lipgloss.NewStyle().Foreground(common.ColorYellow).Render("→ " + displayName) + case e.IsDirectory: + nameRendered = lipgloss.NewStyle().Foreground(common.ColorBlue).Bold(true).Render("/ " + displayName) + default: + nameRendered = styles.PermBody.Render("· " + displayName) + } + + // Truncate very long names to stay within nameColW. + visibleNameW := lipgloss.Width(nameRendered) + if visibleNameW > nameColW { + // Re-render with truncated display name. + truncated := ansi.Truncate(displayName, nameColW-3, "…") + switch { + case e.IsSymlink: + nameRendered = lipgloss.NewStyle().Foreground(common.ColorYellow).Render("→ " + truncated) + case e.IsDirectory: + nameRendered = lipgloss.NewStyle().Foreground(common.ColorBlue).Bold(true).Render("/ " + truncated) + default: + nameRendered = styles.PermBody.Render("· " + truncated) + } + visibleNameW = lipgloss.Width(nameRendered) + } + + var sizeStr string + if !e.IsDirectory { + sizeStr = humanSize(e.SizeBytes) + } + sizeRendered := styles.MsgTimestamp.Render(fmt.Sprintf("%*s", sizeColW, sizeStr)) + + gap := max(1, nameColW-visibleNameW+1) + lines = append(lines, nameRendered+strings.Repeat(" ", gap)+sizeRendered) + } + + if maxLines > 0 && len(lines) > maxLines { + hidden := len(lines) - maxLines + lines = lines[:maxLines] + lines = append(lines, styles.ToolTruncation.Render(fmt.Sprintf(previewTruncFmt, hidden))) + } + + return strings.Join(lines, "\n") +} + +// renderCodeBody renders source code with line numbers and syntax highlighting. +// maxLines=0 means no truncation (used by the detail sidebar). +func renderCodeBody(styles common.Styles, path, body string, width, maxLines, offset int) string { + if strings.TrimSpace(body) == "" { + return "" + } + body = strings.ReplaceAll(body, "\r\n", "\n") + allLines := strings.Split(body, "\n") + + display := allLines + hidden := 0 + if maxLines > 0 && len(allLines) > maxLines { + hidden = len(allLines) - maxLines + display = allLines[:maxLines] + } + + highlighted := common.SyntaxHighlight(strings.Join(display, "\n"), path) + hlLines := strings.Split(highlighted, "\n") + // Chroma may trim a trailing newline, causing one fewer output line when the + // last display line is blank. Pad or truncate to always match len(display). + for len(hlLines) < len(display) { + hlLines = append(hlLines, "") + } + if len(hlLines) > len(display) { + hlLines = hlLines[:len(display)] + } + + maxNum := len(display) + offset + numWidth := len(fmt.Sprintf("%d", maxNum)) + if numWidth < 1 { + numWidth = 1 + } + numFmt := fmt.Sprintf("%%%dd ", numWidth) + + numColW := numWidth + 1 // digits + trailing space + contentW := max(1, width-numColW) + indent := strings.Repeat(" ", numColW) + + out := make([]string, 0, len(hlLines)+1) + for i, ln := range hlLines { + lineNum := styles.ToolLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset)) + if maxLines > 0 { + // Inline preview: truncate to keep fixed height. + out = append(out, lineNum+ansi.Truncate(ln, contentW, "…")) + } else { + // Detail sidebar: soft-wrap with continuation indent. + parts := strings.Split(wrap.String(ln, contentW), "\n") + out = append(out, lineNum+parts[0]) + for _, cont := range parts[1:] { + out = append(out, indent+cont) + } + } + } + if hidden > 0 { + out = append(out, styles.ToolTruncation.Render(fmt.Sprintf(previewTruncFmt, hidden))) + } + return strings.Join(out, "\n") +} + +// renderDiffBody renders a unified diff with +/- line coloring. +// maxLines=0 means no truncation (used by the detail sidebar). +func renderDiffBody(styles common.Styles, body string, width, maxLines int) string { + if strings.TrimSpace(body) == "" { + return "" + } + body = strings.ReplaceAll(body, "\r\n", "\n") + allLines := strings.Split(body, "\n") + + display := allLines + hidden := 0 + if maxLines > 0 && len(allLines) > maxLines { + hidden = len(allLines) - maxLines + display = allLines[:maxLines] + } + + out := make([]string, 0, len(display)+1) + for _, ln := range display { + switch { + case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++"): + out = append(out, styles.ToolDiffAdd.Render(ln)) + case strings.HasPrefix(ln, "-") && !strings.HasPrefix(ln, "---"): + out = append(out, styles.ToolDiffDel.Render(ln)) + case strings.HasPrefix(ln, "@@"): + out = append(out, styles.ToolDiffHunk.Render(ln)) + default: + out = append(out, common.Escape(ln)) + } + } + if hidden > 0 { + out = append(out, styles.ToolTruncation.Render(fmt.Sprintf(previewTruncFmt, hidden))) + } + return strings.Join(out, "\n") +} + +// renderColoredDiff renders a unified diff with full-row colored backgrounds +// (green for added, red for deleted). maxLines=0 means no truncation. +// Reuses renderPermDiffLines so both places stay visually identical. +func renderColoredDiff(styles common.Styles, diffBody string, width, maxLines int) string { + lines := renderPermDiffLines(styles, diffBody, width) + if maxLines > 0 && len(lines) > maxLines { + hidden := len(lines) - maxLines + lines = append(lines[:maxLines], styles.ToolTruncation.Render(fmt.Sprintf(previewTruncFmt, hidden))) + } + return strings.Join(lines, "\n") +} + +// panelBox wraps rendered content in the standard rounded border box used for inline previews. +func panelBox(styles common.Styles, content string, width int) string { + if strings.TrimSpace(content) == "" { + content = styles.MsgTimestamp.Render("No output") + } + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(common.ColorBorder). + Padding(0, 1). + Width(width). + Render(content) +} + +// renderCodePanel renders a file's content as a code block for inline preview (no border). +func renderCodePanel(styles common.Styles, path, body string, width, maxLines, offset int) string { + return renderCodeBody(styles, path, body, width, maxLines, offset) +} + +// renderDiffPanel renders a unified diff for inline preview (no border). +func renderDiffPanel(styles common.Styles, body string, width, maxLines int) string { + return renderDiffBody(styles, body, width, maxLines) +} + +// renderBashInline renders a bash inline preview: a dim `$ cmd` prompt line +// followed by the command output, all truncated to maxLines (no border). +func renderBashInline(styles common.Styles, cmd, output string, width, maxLines int) string { + var lines []string + if cmd = strings.TrimSpace(cmd); cmd != "" { + cmdOneLine := strings.ReplaceAll(cmd, "\n", "; ") + lines = append(lines, styles.MsgTimestamp.Render("$ "+ansi.Truncate(cmdOneLine, max(1, width-2), "…"))) + } + if output = strings.TrimSpace(output); output != "" { + for _, ln := range strings.Split(strings.ReplaceAll(output, "\r\n", "\n"), "\n") { + lines = append(lines, wrap.String(common.Escape(ln), width)) + } + } + hidden := 0 + if maxLines > 0 && len(lines) > maxLines { + hidden = len(lines) - maxLines + lines = lines[:maxLines] + } + if hidden > 0 { + lines = append(lines, styles.ToolTruncation.Render(fmt.Sprintf(previewTruncFmt, hidden))) + } + return strings.Join(lines, "\n") +} + +// renderFilePanel picks the right panel renderer based on the file extension. +func renderFilePanel(styles common.Styles, path, body string, width, maxLines int) string { + switch flavorForPath(path) { + case contentFlavorCode: + return renderCodePanel(styles, path, body, width, maxLines, 0) + default: + return renderContentPanel(styles, "", body, width, maxLines, flavorForPath(path)) + } +} + +func renderContentPanel(styles common.Styles, title, body string, width, maxLines int, flavor contentFlavor) string { + panelBody := renderContentBody(styles, body, width, flavor) + if maxLines > 0 { + lines := strings.Split(panelBody, "\n") + if len(lines) > maxLines { + hidden := len(lines) - maxLines + lines = append(lines[:maxLines], styles.ToolTruncation.Render(fmt.Sprintf(previewTruncFmt, hidden))) + } + panelBody = strings.Join(lines, "\n") + } + if title != "" { + panelBody = styles.Key.Render(title) + "\n" + panelBody + } + return panelBody +} + +// parseReadContent strips the "File:/Lines:" header and N→ line-number prefixes +// produced by the read_file tool's FormatTextWithLineNumbers, returning the +// 0-based start line and the clean file content ready for rendering. +func parseReadContent(body string) (startLine int, clean string) { + body = strings.ReplaceAll(body, "\r\n", "\n") + + // Locate the blank line that separates the header block from the content. + sep := strings.Index(body, "\n\n") + if sep < 0 { + return 0, body + } + + // Parse "Lines: N-M of T" from the header to recover the start offset. + for _, line := range strings.SplitN(body[:sep], "\n", -1) { + if strings.HasPrefix(line, "Lines: ") { + var start, end int + if _, err := fmt.Sscanf(line, "Lines: %d-%d", &start, &end); err == nil { + startLine = start + } + break + } + } + + // Strip the N→ prefix (possibly padded with spaces) from every content line. + // The separator is the Unicode right arrow U+2192 used by AddLineNumbers. + rawLines := strings.Split(body[sep+2:], "\n") + out := make([]string, 0, len(rawLines)) + const arrow = "→" + for _, line := range rawLines { + if idx := strings.Index(line, arrow); idx >= 0 { + prefix := line[:idx] + if strings.TrimLeft(prefix, " \t0123456789") == "" { + line = line[idx+len(arrow):] + } + } + out = append(out, line) + } + clean = strings.TrimRight(strings.Join(out, "\n"), "\n") + return +} + +func renderContentBody(styles common.Styles, body string, width int, flavor contentFlavor) string { + if strings.TrimSpace(body) == "" { + return "" + } + switch flavor { + case contentFlavorMarkdown: + if rendered := renderMarkdownBody(body, width); rendered != "" { + return rendered + } + } + return renderPlainBody(body, width) +} + +func renderMarkdownBody(body string, width int) string { + renderer := common.MarkdownRenderer(width) + if renderer == nil { + return "" + } + mu := common.LockMarkdownRenderer(renderer) + mu.Lock() + defer mu.Unlock() + rendered, err := renderer.Render(strings.ReplaceAll(body, "\r\n", "\n")) + if err != nil { + return "" + } + return strings.TrimRight(rendered, "\n") +} + +func renderPlainBody(body string, width int) string { + rawLines := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") + wrapped := make([]string, 0, len(rawLines)) + innerW := max(16, width) + for _, line := range rawLines { + wrapped = append(wrapped, wrap.String(common.Escape(line), innerW)) + } + return strings.Join(wrapped, "\n") +} + +func cloneMap(in map[string]any) map[string]any { + if len(in) == 0 { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func normalizeMap(v any) map[string]any { + switch m := v.(type) { + case map[string]any: + return m + case nil: + return nil + default: + b, err := json.Marshal(v) + if err != nil { + return nil + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + return nil + } + return out + } +} + +func nestedString(v any, keys ...string) string { + m := normalizeMap(v) + for _, key := range keys { + if s, ok := stringAny(m[key]); ok { + return s + } + } + return "" +} + +func stringFromMap(m map[string]any, key string) string { + if m == nil { + return "" + } + s, _ := stringAny(m[key]) + return s +} + +func intFromMap(m map[string]any, key string) (int, bool) { + if m == nil { + return 0, false + } + switch v := m[key].(type) { + case int: + return v, true + case int32: + return int(v), true + case int64: + return int(v), true + case float64: + return int(v), true + case float32: + return int(v), true + case json.Number: + i, err := v.Int64() + return int(i), err == nil + default: + return 0, false + } +} + +func stringAny(v any) (string, bool) { + switch x := v.(type) { + case string: + return x, true + case json.Number: + return x.String(), true + case fmt.Stringer: + return x.String(), true + case nil: + return "", false + default: + return fmt.Sprintf("%v", x), true + } +} + +func prettyPath(path string) string { + if path == "" { + return "" + } + clean := filepath.Clean(path) + return strings.ReplaceAll(clean, "\\", "/") +} + +func compactPath(path string) string { + pretty := prettyPath(path) + if pretty == "" { + return "" + } + base := filepath.Base(pretty) + if base == "." || base == "/" { + return pretty + } + return base +} + +func prettyJSON(v any) string { + if v == nil { + return "" + } + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return "" + } + return string(b) +} + +func indentBlock(s, indent string) string { + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = indent + line + } + return strings.Join(lines, "\n") +} + +func formatDuration(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + return fmt.Sprintf("%.1fs", d.Seconds()) +} + +func toolDisplayName(name string) string { + name = strings.ReplaceAll(name, "_", " ") + name = strings.ReplaceAll(name, "-", " ") + parts := strings.Fields(name) + for i, part := range parts { + if part == "api" || part == "mcp" { + parts[i] = strings.ToUpper(part) + continue + } + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + return strings.Join(parts, " ") +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + if idx := strings.IndexByte(s, '\n'); idx >= 0 { + return s[:idx] + } + return s +} diff --git a/internal/tui/components/chat_tools.go b/internal/tui/components/chat_tools.go new file mode 100644 index 0000000..ba8e935 --- /dev/null +++ b/internal/tui/components/chat_tools.go @@ -0,0 +1,250 @@ +package components + +import ( + "fmt" + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/EngineerProjects/nexus-engine/internal/tui/common" +) + +type toolItem struct { + id string + name string + status string + label string + metadata map[string]any + expanded bool + startedAt time.Time + finishedAt time.Time + + cacheW int + cacheR string + + detailCacheW int + detailCacheR string +} + +func newToolItem(id, name, status, label string, metadata map[string]any) *toolItem { + return &toolItem{ + id: id, + name: name, + status: status, + label: label, + metadata: cloneMap(metadata), + startedAt: time.Now(), + } +} + +func (t *toolItem) isDone() bool { + return t.status == "completed" || t.status == "failed" || t.status == "done" || t.status == "error" +} + +func (t *toolItem) isFinished() bool { return t.isDone() } +func (t *toolItem) invalidate() { t.cacheW = 0; t.cacheR = ""; t.detailCacheW = 0; t.detailCacheR = "" } + +func (t *toolItem) render(c *Chat, width int) string { + return t.renderSelected(c, width, false) +} + +func (t *toolItem) expanderSymbol() string { + if !t.supportsPreview() { + return " " + } + if t.expanded { + return "▾" + } + return "▸" +} + +func (t *toolItem) detailsSymbol(selected, detailsOpen bool) string { + if selected && detailsOpen { + return "⊟" + } + return "⊞" +} + +func (t *toolItem) renderSelected(c *Chat, width int, selected bool) string { + if t.isDone() && !selected && !t.expanded && t.cacheW == width && t.cacheR != "" { + return t.cacheR + } + + icon := t.renderIcon(c.styles) + nameStyle := t.renderNameStyle(c.styles) + summary := truncate(t.summaryText(), max(12, width-34)) + expander := c.styles.MsgTimestamp.Render(t.expanderSymbol()) + + // Format: ▸ ✓ ToolName summary (Xms) + // No status label (redundant with icon), no details symbol (open via keyboard/o). + parts := []string{expander, icon, nameStyle.Render(toolDisplayName(t.name))} + if summary != "" { + parts = append(parts, c.styles.MsgTimestamp.Render(summary)) + } + if dur := t.durationText(); dur != "" { + parts = append(parts, c.styles.MsgTimestamp.Render("("+dur+")")) + } + + line := strings.Join(parts, " ") + if selected { + line = lipgloss.NewStyle().Foreground(common.ColorText).Background(lipgloss.Color("#1F2937")).Render(line) + } + + if !t.expanded { + if t.isDone() && !selected { + t.cacheW = width + t.cacheR = line + } + return line + } + + preview := t.inlinePreview(c, width) + if preview == "" { + preview = c.styles.MsgTimestamp.Render("No preview available.") + } + result := line + "\n" + indentBlock(preview, " ") + if t.isDone() && !selected { + t.cacheW = width + t.cacheR = result + } + return result +} + +func (t *toolItem) renderIcon(styles common.Styles) string { + switch { + case t.status == "completed" || t.status == "done": + return styles.MsgTimestamp.Render("✓") + case t.status == "failed" || t.status == "error": + return styles.ToolError.Render("✗") + default: + return styles.ToolProgress.Render(toolIconFor(t.name)) + } +} + +func (t *toolItem) renderNameStyle(styles common.Styles) lipgloss.Style { + switch { + case t.status == "completed" || t.status == "done": + return styles.UserMsg + case t.status == "failed" || t.status == "error": + return styles.ToolError + default: + return styles.ToolProgress + } +} + +func (t *toolItem) durationText() string { + if !t.isDone() || t.finishedAt.IsZero() { + if ms, ok := intFromMap(t.metadata, "execution_duration_ms"); ok && ms > 0 { + return formatDuration(time.Duration(ms) * time.Millisecond) + } + return "" + } + return formatDuration(t.finishedAt.Sub(t.startedAt)) +} + +func (t *toolItem) toolInput() map[string]any { + return normalizeMap(t.metadata["tool_input"]) +} + +func (t *toolItem) supportsPreview() bool { + switch t.name { + case "read_file", "write_file", "edit_file", "apply_patch", "bash", "web_search", "web_fetch", "spawn_agent", "wait_agent", "close_agent", "send_agent_message": + return true + default: + return strings.TrimSpace(t.resultContent()) != "" + } +} + +func (t *toolItem) statusLabel() string { + switch t.status { + case "completed", "done": + return "done" + case "failed", "error": + return "failed" + case "running", "started": + return "running" + default: + return t.status + } +} + +func (t *toolItem) summaryText() string { + input := t.toolInput() + switch t.name { + case "read_file": + path := compactPath(stringFromMap(input, "file_path")) + if path == "" { + path = t.label + } + return path + case "write_file", "edit_file": + path := compactPath(stringFromMap(input, "file_path")) + parts := make([]string, 0, 3) + if path != "" { + parts = append(parts, path) + } + if kind := stringFromMap(t.metadata, "type"); kind != "" { + parts = append(parts, kind) + } + if stats := t.changeStatsText(); stats != "" { + parts = append(parts, stats) + } + return strings.Join(parts, " · ") + case "apply_patch": + if stats := t.changeStatsText(); stats != "" { + return "patch · " + stats + } + if patch := stringFromMap(input, "patch"); patch != "" { + return firstLine(strings.TrimSpace(patch)) + } + if content := stringFromMap(t.metadata, "content"); content != "" { + return firstLine(content) + } + case "bash": + cmd := strings.TrimSpace(stringFromMap(input, "command")) + if cmd == "" { + cmd = strings.TrimSpace(stringFromMap(t.metadata, "description")) + } + return cmd + case "web_search": + query := strings.TrimSpace(stringFromMap(input, "query")) + if query == "" { + query = strings.TrimSpace(stringFromMap(t.metadata, "query")) + } + if count, ok := intFromMap(t.metadata, "result_count"); ok && count > 0 { + return fmt.Sprintf("%s · %d results", query, count) + } + return query + case "web_fetch": + summary := strings.TrimSpace(stringFromMap(input, "url")) + if summary == "" { + summary = strings.TrimSpace(stringFromMap(t.metadata, "url")) + } + if title := strings.TrimSpace(stringFromMap(t.metadata, "title")); title != "" { + summary = title + } + if code, ok := intFromMap(t.metadata, "code"); ok && code > 0 { + return fmt.Sprintf("%s · %d", compactPath(summary), code) + } + return compactPath(summary) + case "spawn_agent": + prompt := strings.TrimSpace(stringFromMap(input, "prompt")) + nickname := strings.TrimSpace(stringFromMap(input, "nickname")) + if nickname != "" { + return nickname + " · " + prompt + } + return prompt + case "wait_agent", "close_agent", "send_agent_message": + agentID := strings.TrimSpace(stringFromMap(input, "agent_id")) + if agentID != "" { + return agentID + } + } + if t.label != "" && t.label != t.status { + return strings.TrimSpace(t.label) + } + if msg := strings.TrimSpace(stringFromMap(t.metadata, "content")); msg != "" { + return firstLine(msg) + } + return "" +} diff --git a/internal/tui/components/chat_view.go b/internal/tui/components/chat_view.go new file mode 100644 index 0000000..bd66ef0 --- /dev/null +++ b/internal/tui/components/chat_view.go @@ -0,0 +1,269 @@ +package components + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +func (c *Chat) ScrollUp(n int) { c.follow = false; c.viewport.ScrollUp(n) } +func (c *Chat) ScrollDown(n int) { c.viewport.ScrollDown(n); c.follow = c.viewport.AtBottom() } +func (c *Chat) PageUp() { c.follow = false; c.viewport.HalfPageUp() } +func (c *Chat) PageDown() { c.viewport.HalfPageDown(); c.follow = c.viewport.AtBottom() } +func (c *Chat) GotoTop() { c.follow = false; c.viewport.GotoTop() } +func (c *Chat) GotoBottom() { c.follow = true; c.viewport.GotoBottom() } + +func (c *Chat) DetailScrollUp(n int) { + if c.detail != nil { + c.detail.ScrollUp(n) + } +} + +func (c *Chat) DetailScrollDown(n int) { + if c.detail != nil { + c.detail.ScrollDown(n) + } +} + +func (c *Chat) DetailPageUp() { + if c.detail != nil { + c.detail.HalfPageUp() + } +} + +func (c *Chat) DetailPageDown() { + if c.detail != nil { + c.detail.HalfPageDown() + } +} + +func (c *Chat) DetailGotoTop() { + if c.detail != nil { + c.detail.GotoTop() + } +} + +func (c *Chat) DetailGotoBottom() { + if c.detail != nil { + c.detail.GotoBottom() + } +} + +func (c *Chat) View() string { return c.viewport.View() } + +func (c *Chat) selectedToolIndex() int { + if c.selectedTool < 0 || c.selectedTool >= len(c.messages) { + return -1 + } + if _, ok := c.messages[c.selectedTool].(*toolItem); !ok { + return -1 + } + return c.selectedTool +} + +func (c *Chat) selectedToolItem() *toolItem { + if idx := c.selectedToolIndex(); idx >= 0 { + if tool, ok := c.messages[idx].(*toolItem); ok { + return tool + } + } + return nil +} + +func (c *Chat) toolIndices() []int { + indices := make([]int, 0) + i := 0 + for i < len(c.messages) { + tool, ok := c.messages[i].(*toolItem) + if !ok { + i++ + continue + } + if isGroupableTool(tool.name) && tool.isDone() { + // Scan ahead for the full group. + j := i + 1 + for j < len(c.messages) { + next, ok2 := c.messages[j].(*toolItem) + if !ok2 || next.name != tool.name || !next.isDone() { + break + } + j++ + } + // The selection point for the whole group is its last item. + indices = append(indices, j-1) + i = j + } else { + indices = append(indices, i) + i++ + } + } + return indices +} + +func (c *Chat) refresh() { + var sb strings.Builder + var plainSB strings.Builder + lastWasTool := false + wroteAny := false + line := 0 + toolRegions := make([]toolRegion, 0) + thinkingRegions := make([]thinkingRegion, 0) + mi := 0 + for mi < len(c.messages) { + item := c.messages[mi] + + // ── Group detection: consecutive completed same-name groupable tools ── + var rendered string + var isTool bool + var regionMsgIndex int + + if tool, ok := item.(*toolItem); ok && isGroupableTool(tool.name) && tool.isDone() { + j := mi + 1 + for j < len(c.messages) { + next, ok2 := c.messages[j].(*toolItem) + if !ok2 || next.name != tool.name || !next.isDone() { + break + } + j++ + } + if j-mi >= 2 { + // Render the group as one summary row. + groupItems := make([]*toolItem, j-mi) + for k := mi; k < j; k++ { + groupItems[k-mi] = c.messages[k].(*toolItem) + } + lastIdx := j - 1 + selectedInGroup := c.selectedTool >= mi && c.selectedTool <= lastIdx + rendered = renderToolGroup(c, groupItems, c.width, selectedInGroup) + isTool = true + regionMsgIndex = lastIdx // selection lands on last item in the group + mi = j + } else { + // Only one item — render normally. + rendered = tool.renderSelected(c, c.width, mi == c.selectedToolIndex()) + isTool = true + regionMsgIndex = mi + mi++ + } + } else if tool, ok := item.(*toolItem); ok { + rendered = tool.renderSelected(c, c.width, mi == c.selectedToolIndex()) + isTool = true + regionMsgIndex = mi + mi++ + } else { + rendered = item.render(c, c.width) + isTool = false + regionMsgIndex = mi + mi++ + } + + if rendered == "" { + continue + } + plainRendered := ansi.Strip(rendered) + if wroteAny { + if lastWasTool && isTool { + sb.WriteString("\n") + plainSB.WriteString("\n") + line++ + } else { + sb.WriteString("\n\n") + plainSB.WriteString("\n\n") + line += 2 + } + } + startLine := line + sb.WriteString(rendered) + plainSB.WriteString(plainRendered) + height := max(1, lipgloss.Height(plainRendered)) + if isTool { + // expanderStart/End at col 0-1 (the ▸/▾ symbol). Detail click disabled + // (right panel is opened with keyboard or by selecting the tool). + toolRegions = append(toolRegions, toolRegion{ + startLine: startLine, + endLine: startLine + height - 1, + msgIndex: regionMsgIndex, + expanderStart: 0, + expanderEnd: 1, + detailStart: 0, + detailEnd: 0, + }) + } + if assistant, ok := item.(*assistantItem); ok && assistant.thinking != nil && strings.TrimSpace(assistant.thinking.content) != "" { + thinkingStart := startLine + if assistant.showLabel { + thinkingStart++ + } + thinkingRendered := assistant.thinking.render(c.styles, c.width) + thinkingHeight := max(1, lipgloss.Height(ansi.Strip(thinkingRendered))) + thinkingRegions = append(thinkingRegions, thinkingRegion{startLine: thinkingStart, endLine: thinkingStart + thinkingHeight - 1, msgIndex: regionMsgIndex}) + } + line += height + lastWasTool = isTool + wroteAny = true + } + content := sb.String() + plain := plainSB.String() + c.renderedContent = content + if content == "" { + c.renderedLines = nil + } else { + c.renderedLines = strings.Split(content, "\n") + } + c.plainContent = plain + if plain == "" { + c.plainLines = nil + } else { + c.plainLines = strings.Split(plain, "\n") + } + c.toolRegions = toolRegions + c.thinkingRegions = thinkingRegions + if c.selection.hasSelection() { + c.viewport.SetContent(c.highlightedSelectionContent()) + } else { + c.viewport.SetContent(content) + } + if c.follow { + c.viewport.GotoBottom() + } +} + +func (c *Chat) highlightedSelectionContent() string { + if len(c.renderedLines) == 0 { + return c.renderedContent + } + startLn, startCo, endLn, endCo := c.selectionRange() + if startLn < 0 || endLn < 0 { + return c.renderedContent + } + lines := make([]string, len(c.renderedLines)) + copy(lines, c.renderedLines) + for line := startLn; line <= endLn; line++ { + renderedLine := lines[line] + lineWidth := ansi.StringWidth(renderedLine) + lineStart := 0 + lineEnd := lineWidth + if line == startLn { + lineStart = clampInt(startCo, 0, lineWidth) + } + if line == endLn { + lineEnd = clampInt(endCo, 0, lineWidth) + } + if line == startLn && line == endLn && lineEnd < lineStart { + lineStart, lineEnd = lineEnd, lineStart + } + if lineEnd < lineStart { + lineEnd = lineStart + } + before := ansi.Cut(renderedLine, 0, lineStart) + middle := ansi.Cut(renderedLine, lineStart, lineEnd) + after := ansi.Cut(renderedLine, lineEnd, lineWidth) + if middle == "" && lineStart < lineWidth { + middle = ansi.Cut(renderedLine, lineStart, lineStart+1) + after = ansi.Cut(renderedLine, lineStart+1, lineWidth) + } + lines[line] = before + applySelectionStyle(middle, c.styles.Selection) + after + } + return strings.Join(lines, "\n") +} diff --git a/internal/tui/components/command_palette.go b/internal/tui/components/command_palette.go index 58d9ed3..b386c30 100644 --- a/internal/tui/components/command_palette.go +++ b/internal/tui/components/command_palette.go @@ -64,6 +64,7 @@ func defaultPaletteRootItems() []PaletteItem { {Kind: PaletteSectionKind, ID: "commands", Name: "Commands", Desc: "Shortcuts, sessions, copy actions, and app controls"}, {Kind: PaletteRouteKind, ID: "providers", Name: "Providers", Shortcut: "ctrl+,", Desc: "Configure API keys and provider credentials"}, {Kind: PaletteRouteKind, ID: "models", Name: "Models", Shortcut: "ctrl+m", Desc: "Switch the active AI model"}, + {Kind: PaletteRouteKind, ID: "search", Name: "Web Search", Desc: "Configure web search providers and API keys"}, {Kind: PaletteSectionKind, ID: "tools", Name: "Tools", Desc: "Current tool UX and future browser entry point"}, {Kind: PaletteSectionKind, ID: "mcp", Name: "MCP", Desc: "Server usage notes and future management surface"}, {Kind: PaletteSectionKind, ID: "skills", Name: "Skills", Desc: "Slash-skill workflow and future skill discovery"}, diff --git a/internal/tui/components/config_panel.go b/internal/tui/components/config_panel.go index a3c5081..f4a7e1c 100644 --- a/internal/tui/components/config_panel.go +++ b/internal/tui/components/config_panel.go @@ -110,6 +110,15 @@ func (p *ConfigPanel) TypeChar(ch string) { p.statusMsg = "" } +// TypeString appends an arbitrary string to the active field draft (used for clipboard paste). +func (p *ConfigPanel) TypeString(s string) { + if !p.editing || p.fieldCursor >= len(p.inputs) { + return + } + p.inputs[p.fieldCursor].draft += s + p.statusMsg = "" +} + // DeleteChar removes the last character from the active field draft. func (p *ConfigPanel) DeleteChar() { if !p.editing || p.fieldCursor >= len(p.inputs) { @@ -276,9 +285,9 @@ func (p *ConfigPanel) viewEdit(w, innerW int) string { cursor := lipgloss.NewStyle().Foreground(common.ColorPrimary).Render("█") valLine = " ▶ " + lipgloss.NewStyle().Foreground(common.ColorText).Render(display) + cursor if inp.field.Secret { - revealHint := "ctrl+v: reveal" + revealHint := "ctrl+r: reveal" if p.showSecret { - revealHint = "ctrl+v: hide" + revealHint = "ctrl+r: hide" } valLine += " " + p.styles.MsgTimestamp.Render(revealHint) } @@ -314,7 +323,7 @@ func (p *ConfigPanel) viewEdit(w, innerW int) string { statusLine = " " + st.Render(p.statusMsg) } - hint := p.styles.Footer.Render(" enter: save ↑↓ switch field ← back esc: close") + hint := p.styles.Footer.Render(" enter: save ctrl+v: paste ctrl+r: reveal ↑↓ switch field ← back esc: close") parts := []string{title, sep, ""} parts = append(parts, rows...) diff --git a/internal/tui/components/search_panel.go b/internal/tui/components/search_panel.go new file mode 100644 index 0000000..b51b73f --- /dev/null +++ b/internal/tui/components/search_panel.go @@ -0,0 +1,432 @@ +package components + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/EngineerProjects/nexus-engine/internal/tui" + "github.com/EngineerProjects/nexus-engine/internal/tui/common" +) + +var searchModes = []struct { + ID string + Label string + Desc string +}{ + {"auto", "Auto", "Try configured providers in priority order"}, + {"tavily", "Tavily", "AI-optimised search (requires API key)"}, + {"exa", "Exa", "Neural search engine (requires API key)"}, + {"jina", "Jina AI", "Reader-based web retrieval (requires API key)"}, + {"langsearch", "LangSearch", "Free AI-optimised search (requires API key)"}, + {"searxng", "SearXNG", "Self-hosted meta-search (no key needed)"}, + {"ddg", "DuckDuckGo", "Privacy-friendly fallback (no key needed)"}, +} + +// SearchPanel is the web-search configuration overlay. +// It has three modes: +// - list mode: all providers + active mode row +// - key-edit mode: typing an API key for one provider +// - mode-select mode: choosing the active provider mode +type SearchPanel struct { + styles common.Styles + config tui.SearchConfig + + cursor int // 0 = mode row, 1..N = providers + + // key-edit mode + editingKey bool + editEntry tui.SearchKeyStatus + draft string + showSecret bool + + // mode-select mode + editingMode bool + modeCursor int + + statusMsg string + width, height int +} + +func NewSearchPanel(styles common.Styles) *SearchPanel { + return &SearchPanel{styles: styles} +} + +func (p *SearchPanel) SetSize(w, h int) { p.width = w; p.height = h } + +func (p *SearchPanel) SetConfig(cfg tui.SearchConfig) { + p.config = cfg + mode := cfg.Mode + if mode == "" { + mode = "auto" + } + for i, m := range searchModes { + if m.ID == mode { + p.modeCursor = i + break + } + } +} + +func (p *SearchPanel) Up() { + if p.editingKey { + return + } + if p.editingMode { + if p.modeCursor > 0 { + p.modeCursor-- + } + return + } + if p.cursor > 0 { + p.cursor-- + } +} + +func (p *SearchPanel) Down() { + if p.editingKey { + return + } + if p.editingMode { + if p.modeCursor < len(searchModes)-1 { + p.modeCursor++ + } + return + } + if p.cursor < len(p.config.Providers) { + p.cursor++ + } +} + +// EnterList opens the editor for the currently selected entry. +// Returns (openedMode, openedKey, saveKey) — the model acts on these signals. +func (p *SearchPanel) EnterList() (openedMode, openedKey bool) { + if p.cursor == 0 { + // Mode row — open mode selector + p.editingMode = true + p.statusMsg = "" + return true, false + } + idx := p.cursor - 1 + if idx < 0 || idx >= len(p.config.Providers) { + return false, false + } + prov := p.config.Providers[idx] + if !prov.NeedsKey { + // Truly no configuration needed (e.g. DuckDuckGo). + return false, false + } + p.editEntry = prov + p.draft = "" + // URL fields are not secrets — reveal by default. + p.showSecret = prov.FieldLabel == "" + p.statusMsg = "" + p.editingKey = true + return false, true +} + +// ConfirmMode saves the selected mode and exits mode-select. +// Returns the chosen mode ID so the model can persist it. +func (p *SearchPanel) ConfirmMode() string { + if p.modeCursor < len(searchModes) { + chosen := searchModes[p.modeCursor].ID + p.config.Mode = chosen + p.editingMode = false + p.statusMsg = "" + return chosen + } + p.editingMode = false + return "" +} + +// ExitKeyEdit closes key-edit mode without saving. +func (p *SearchPanel) ExitKeyEdit() { + p.editingKey = false + p.statusMsg = "" +} + +// ExitModeEdit closes mode-select mode without saving. +func (p *SearchPanel) ExitModeEdit() { + p.editingMode = false + p.statusMsg = "" +} + +// TypeChar appends a character in key-edit mode. +func (p *SearchPanel) TypeChar(ch string) { + if !p.editingKey { + return + } + p.draft += ch + p.statusMsg = "" +} + +// TypeString appends an arbitrary string in key-edit mode (used for clipboard paste). +func (p *SearchPanel) TypeString(s string) { + if !p.editingKey { + return + } + p.draft += s + p.statusMsg = "" +} + +// DeleteChar removes the last character in key-edit mode. +func (p *SearchPanel) DeleteChar() { + if !p.editingKey || len(p.draft) == 0 { + return + } + p.draft = p.draft[:len(p.draft)-1] + p.statusMsg = "" +} + +func (p *SearchPanel) ToggleReveal() { p.showSecret = !p.showSecret } + +// CurrentDraft returns the current key draft and the DB key to store it under. +func (p *SearchPanel) CurrentDraft() (draft, dbKey string) { + return p.draft, p.editEntry.DBKey +} + +func (p *SearchPanel) IsEditingKey() bool { return p.editingKey } +func (p *SearchPanel) IsEditingMode() bool { return p.editingMode } + +// SetKeySaved marks the provider as configured and clears the editor. +func (p *SearchPanel) SetKeySaved() { + for i, pv := range p.config.Providers { + if pv.ID == p.editEntry.ID { + p.config.Providers[i].IsSet = true + break + } + } + p.editEntry.IsSet = true + p.draft = "" + p.editingKey = false + p.statusMsg = "✓ Saved" +} + +func (p *SearchPanel) SetModeSaved() { p.statusMsg = "✓ Mode saved" } +func (p *SearchPanel) SetError(msg string) { p.statusMsg = "✗ " + msg } +func (p *SearchPanel) ClearStatus() { p.statusMsg = "" } + +// ─── View ───────────────────────────────────────────────────────────────────── + +func (p *SearchPanel) View() string { + w := common.Clamp(p.width*4/5, 56, 90) + innerW := w - 4 + + switch { + case p.editingKey: + return p.viewKeyEdit(w, innerW) + case p.editingMode: + return p.viewModeEdit(w, innerW) + default: + return p.viewList(w, innerW) + } +} + +func (p *SearchPanel) viewList(w, innerW int) string { + title := p.styles.BrowserTitle.Render(" Web Search Providers") + sep := p.styles.MsgTimestamp.Render(strings.Repeat("─", innerW)) + + var rows []string + + // ── Mode row (cursor = 0) ────────────────────────────────────────────── + activeMode := p.config.Mode + if activeMode == "" { + activeMode = "auto" + } + modeStatus := p.styles.MsgTimestamp.Render("mode: " + activeMode) + modeLabel := "Search Mode" + + if p.cursor == 0 { + ind := lipgloss.NewStyle().Bold(true).Foreground(common.ColorPrimary).Render("▶ ") + name := lipgloss.NewStyle().Bold(true).Foreground(common.ColorPrimary).Render(modeLabel) + left := " " + ind + name + pad := max(1, innerW-lipgloss.Width(left)-lipgloss.Width(modeStatus)-2) + rows = append(rows, p.styles.BrowserSelected.Width(innerW).Render( + left+strings.Repeat(" ", pad)+modeStatus, + )) + } else { + left := " " + lipgloss.NewStyle().Foreground(common.ColorText).Render(modeLabel) + pad := max(1, innerW-lipgloss.Width(left)-lipgloss.Width(modeStatus)-2) + rows = append(rows, p.styles.BrowserItem.Width(innerW).Render( + left+strings.Repeat(" ", pad)+modeStatus, + )) + } + + // ── Provider rows ────────────────────────────────────────────────────── + for i, pv := range p.config.Providers { + cur := i + 1 + statusStr := p.statusTag(pv) + + var row string + if p.cursor == cur { + ind := lipgloss.NewStyle().Bold(true).Foreground(common.ColorPrimary).Render("▶ ") + name := lipgloss.NewStyle().Bold(true).Foreground(common.ColorPrimary).Render(pv.DisplayName) + left := " " + ind + name + if pv.Description != "" { + maxD := innerW - lipgloss.Width(left) - lipgloss.Width(statusStr) - 10 + d := pv.Description + if len(d) > maxD && maxD > 4 { + d = d[:maxD-1] + "…" + } + left += " " + p.styles.MsgTimestamp.Render(d) + } + pad := max(1, innerW-lipgloss.Width(left)-lipgloss.Width(statusStr)-2) + row = p.styles.BrowserSelected.Width(innerW).Render( + left + strings.Repeat(" ", pad) + statusStr, + ) + } else { + left := " " + lipgloss.NewStyle().Foreground(common.ColorText).Render(pv.DisplayName) + if pv.Description != "" { + maxD := innerW - lipgloss.Width(left) - lipgloss.Width(statusStr) - 10 + d := pv.Description + if len(d) > maxD && maxD > 4 { + d = d[:maxD-1] + "…" + } + left += " " + p.styles.MsgTimestamp.Render(d) + } + pad := max(1, innerW-lipgloss.Width(left)-lipgloss.Width(statusStr)-2) + row = p.styles.BrowserItem.Width(innerW).Render( + left + strings.Repeat(" ", pad) + statusStr, + ) + } + rows = append(rows, row) + } + + hint := p.styles.Footer.Render(" enter: configure ↑↓ navigate esc: close") + parts := []string{title, sep, ""} + parts = append(parts, rows...) + parts = append(parts, "", sep) + if line := p.statusLine(); line != "" { + parts = append(parts, line) + } + parts = append(parts, hint) + return p.styles.BrowserBorder.Width(w).Render(strings.Join(parts, "\n")) +} + +func (p *SearchPanel) viewKeyEdit(w, innerW int) string { + title := p.styles.BrowserTitle.Render( + " " + lipgloss.NewStyle().Bold(true).Foreground(common.ColorPrimary).Render(p.editEntry.DisplayName), + ) + sep := p.styles.MsgTimestamp.Render(strings.Repeat("─", innerW)) + + fieldLabel := p.editEntry.FieldLabel + if fieldLabel == "" { + fieldLabel = "API Key" + } + isURL := p.editEntry.FieldLabel != "" // URL fields have an explicit label + labelLine := " " + lipgloss.NewStyle().Bold(true).Foreground(common.ColorPrimary).Render(fieldLabel) + if p.editEntry.EnvVar != "" { + labelLine += " " + p.styles.MsgTimestamp.Render("("+p.editEntry.EnvVar+")") + } + if p.editEntry.IsSet && p.draft == "" { + labelLine += " " + lipgloss.NewStyle().Foreground(common.ColorGreen).Render("✓ set") + } + + display := p.draft + if !isURL && !p.showSecret && display != "" { + // Mask secrets (API keys), but not URLs. + display = strings.Repeat("•", len(display)) + } + if display == "" && p.editEntry.IsSet { + display = p.styles.MsgTimestamp.Render("(keep existing — type to replace)") + } + cur := lipgloss.NewStyle().Foreground(common.ColorPrimary).Render("█") + + var valLine string + if isURL { + valLine = " ▶ " + lipgloss.NewStyle().Foreground(common.ColorText).Render(display) + cur + } else { + revealHint := "ctrl+r: reveal" + if p.showSecret { + revealHint = "ctrl+r: hide" + } + valLine = " ▶ " + lipgloss.NewStyle().Foreground(common.ColorText).Render(display) + cur + + " " + p.styles.MsgTimestamp.Render(revealHint) + } + + hintText := " enter: save ctrl+v: paste ← back esc: close" + if !isURL { + hintText = " enter: save ctrl+v: paste ctrl+r: reveal ← back esc: close" + } + hint := p.styles.Footer.Render(hintText) + parts := []string{title, sep, "", labelLine, valLine, "", sep} + if line := p.statusLine(); line != "" { + parts = append(parts, line) + } + parts = append(parts, hint) + return p.styles.BrowserBorder.Width(w).Render(strings.Join(parts, "\n")) +} + +func (p *SearchPanel) viewModeEdit(w, innerW int) string { + title := p.styles.BrowserTitle.Render(" Select Search Mode") + sep := p.styles.MsgTimestamp.Render(strings.Repeat("─", innerW)) + + var rows []string + for i, m := range searchModes { + selected := i == p.modeCursor + isActive := m.ID == p.config.Mode || (p.config.Mode == "" && m.ID == "auto") + + var row string + if selected { + ind := lipgloss.NewStyle().Bold(true).Foreground(common.ColorPrimary).Render("▶ ") + name := lipgloss.NewStyle().Bold(true).Foreground(common.ColorPrimary).Render(m.Label) + left := " " + ind + name + if m.Desc != "" { + left += " " + p.styles.MsgTimestamp.Render(m.Desc) + } + suffix := "" + if isActive { + suffix = " " + lipgloss.NewStyle().Foreground(common.ColorGreen).Render("✓") + } + row = p.styles.BrowserSelected.Width(innerW).Render(left + suffix) + } else { + left := " " + lipgloss.NewStyle().Foreground(common.ColorText).Render(m.Label) + if m.Desc != "" { + maxD := innerW - lipgloss.Width(left) - 12 + d := m.Desc + if len(d) > maxD && maxD > 4 { + d = d[:maxD-1] + "…" + } + left += " " + p.styles.MsgTimestamp.Render(d) + } + suffix := "" + if isActive { + suffix = " " + lipgloss.NewStyle().Foreground(common.ColorGreen).Render("✓") + } + row = p.styles.BrowserItem.Width(innerW).Render(left + suffix) + } + rows = append(rows, row) + } + + hint := p.styles.Footer.Render(" enter: select ↑↓ navigate ← back esc: close") + parts := []string{title, sep, ""} + parts = append(parts, rows...) + parts = append(parts, "", sep, hint) + return p.styles.BrowserBorder.Width(w).Render(strings.Join(parts, "\n")) +} + +func (p *SearchPanel) statusTag(pv tui.SearchKeyStatus) string { + if !pv.NeedsKey { + return p.styles.MsgTimestamp.Render("─ no config needed") + } + if pv.IsSet { + return lipgloss.NewStyle().Foreground(common.ColorGreen).Render("✓ configured") + } + return lipgloss.NewStyle().Foreground(common.ColorRed).Render("✗ not configured") +} + +func (p *SearchPanel) statusLine() string { + if p.statusMsg == "" { + return "" + } + var st lipgloss.Style + if strings.HasPrefix(p.statusMsg, "✓") { + st = lipgloss.NewStyle().Foreground(common.ColorGreen).Bold(true) + } else { + st = lipgloss.NewStyle().Foreground(common.ColorRed).Bold(true) + } + return " " + st.Render(p.statusMsg) +} + +func (p *SearchPanel) Centered() string { + return common.CenterHorizontally(p.View(), p.width) +} diff --git a/internal/tui/components/session_list.go b/internal/tui/components/session_list.go index 52384ab..a88b855 100644 --- a/internal/tui/components/session_list.go +++ b/internal/tui/components/session_list.go @@ -23,7 +23,9 @@ func NewSessionList(styles common.Styles) *SessionList { return &SessionList{ styles: styles, list: common.NewListState(func(sess tui.SessionInfo, needle string) bool { - return strings.Contains(strings.ToLower(sess.ShortID), needle) + needle = strings.ToLower(needle) + return strings.Contains(strings.ToLower(sess.ShortID), needle) || + strings.Contains(strings.ToLower(sess.Preview), needle) }), editing: true, } @@ -101,14 +103,31 @@ func (s *SessionList) View() string { for i := start; i < end; i++ { sess := filtered[i] age := formatAge(sess.UpdatedAt) - info := fmt.Sprintf("%s · %s · %d turns", sess.ShortID, age, sess.Turns) - if len(info) > w-4 { - info = info[:w-4] + meta := fmt.Sprintf("%s · %s · %d turns", sess.ShortID, age, sess.Turns) + if len(meta) > w-4 { + meta = meta[:w-4] + } + // Use preview (first user message line) as the primary title. + // Fall back to ShortID when no preview is stored yet. + title := sess.Preview + if title == "" { + title = sess.ShortID + } else { + maxTitle := w - 6 + if maxTitle < 0 { + maxTitle = 0 + } + r := []rune(title) + if len(r) > maxTitle { + title = string(r[:maxTitle]) + "…" + } } if i == cursor { - rows = append(rows, s.styles.BrowserSelected.Width(w-2).Render("▶ "+info)) + line := "▶ " + title + "\n " + meta + rows = append(rows, s.styles.BrowserSelected.Width(w-2).Render(line)) } else { - rows = append(rows, s.styles.BrowserItem.Width(w-2).Render(" "+info)) + line := " " + title + "\n " + s.styles.Desc.Render(meta) + rows = append(rows, s.styles.BrowserItem.Width(w-2).Render(line)) } } diff --git a/internal/tui/components/testdata/chat/collapsed_thinking.golden b/internal/tui/components/testdata/chat/collapsed_thinking.golden index f8804cb..e9c163f 100644 --- a/internal/tui/components/testdata/chat/collapsed_thinking.golden +++ b/internal/tui/components/testdata/chat/collapsed_thinking.golden @@ -6,6 +6,6 @@ │ line 11 │ │ line 12 │ ╰────────────────────────────────────────────────────────╯ - Thought for 1.5s click to expand + Thought for 1.5s ctrl+t to expand Final answer. \ No newline at end of file diff --git a/internal/tui/components/testdata/chat/turn_with_tool.golden b/internal/tui/components/testdata/chat/turn_with_tool.golden index b47c38a..b89e481 100644 --- a/internal/tui/components/testdata/chat/turn_with_tool.golden +++ b/internal/tui/components/testdata/chat/turn_with_tool.golden @@ -3,6 +3,6 @@ ● I will inspect the workspace. -▸ ⊞ ✓ Bash done ls -la (500ms) +▸ ✓ Bash ls -la (500ms) The workspace contains 3 files. \ No newline at end of file diff --git a/internal/tui/workspace.go b/internal/tui/workspace.go index 1e40f6a..5b45dc1 100644 --- a/internal/tui/workspace.go +++ b/internal/tui/workspace.go @@ -80,10 +80,33 @@ type SessionCreatedMsg struct { Err error } +// HistoryTool is one completed tool call embedded in a HistoryEntry. +// Metadata is the full TUI metadata map (content, execution_duration_ms, etc.) +// stored alongside the tool result in the transcript. +// Input is the tool's input map (stored separately in ToolUseContent). +type HistoryTool struct { + ID string + Name string + Input map[string]any + Metadata map[string]any // includes "content", "execution_duration_ms", etc. +} + +// HistoryEntry is one message in a replayed session transcript. +type HistoryEntry struct { + Role string // "user" | "assistant" + Text string // combined text content + Thinking string // thinking block content (assistant only) + Tools []HistoryTool + InputTokens int + OutputTokens int + StopReason string +} + // SessionLoadedMsg signals a session was loaded successfully. type SessionLoadedMsg struct { - ID string - Err error + ID string + History []HistoryEntry + Err error } // ErrMsg wraps an error to display in the UI. @@ -112,6 +135,26 @@ type ModelChangedMsg struct { Model string } +// ─── Search config types ────────────────────────────────────────────────────── + +// SearchKeyStatus describes one web-search provider in the TUI. +type SearchKeyStatus struct { + ID string // "tavily", "exa", "jina", "langsearch", "searxng", "ddg" + DisplayName string + Description string + EnvVar string // e.g. "TAVILY_API_KEY" + DBKey string // credential DB key + NeedsKey bool // false for DDG (truly no config), true for all others + FieldLabel string // label shown in the edit dialog; defaults to "API Key" + IsSet bool // a value is currently stored in the DB +} + +// SearchConfig is the TUI's view of the web search configuration. +type SearchConfig struct { + Mode string // "auto", "tavily", "exa", etc. + Providers []SearchKeyStatus +} + // ─── Provider config types ──────────────────────────────────────────────────── // ProviderFieldStatus describes one credential field for a provider. @@ -143,6 +186,8 @@ type SessionInfo struct { CreatedAt time.Time Turns int Tokens int + // Preview is the first user message, truncated — empty for old sessions. + Preview string } // ToolInfo is the TUI's lightweight view of one registered tool. @@ -231,4 +276,15 @@ type Workspace interface { // DeleteProviderField removes a credential field for a provider. DeleteProviderField(ctx context.Context, providerID, fieldKey string) error + + // ── Search configuration ────────────────────────────────────────────── + + // LoadSearchConfig returns the current web search provider configuration. + LoadSearchConfig(ctx context.Context) SearchConfig + + // SaveSearchKey persists an API key for a search provider under dbKey. + SaveSearchKey(ctx context.Context, dbKey, value string) error + + // SaveSearchMode sets the active web search provider mode (e.g. "auto", "tavily"). + SaveSearchMode(ctx context.Context, mode string) error } diff --git a/internal/types/message.go b/internal/types/message.go index 96f2b7b..0cecd58 100644 --- a/internal/types/message.go +++ b/internal/types/message.go @@ -626,7 +626,9 @@ func CanonicalMessagesFromTranscriptEntries(entries []TranscriptEntry) []Message return messages } -func CanonicalTranscriptHash(messages []Message) (string, error) { +// LegacyCanonicalTranscriptHash calculates the hash of the raw marshaled entries +// without recursively normalizing nested objects to maps. +func LegacyCanonicalTranscriptHash(messages []Message) (string, error) { entries := CanonicalTranscriptEntriesFromMessages(messages, "") payload, err := json.Marshal(entries) if err != nil { @@ -636,6 +638,31 @@ func CanonicalTranscriptHash(messages []Message) (string, error) { return hex.EncodeToString(hash[:]), nil } +// CanonicalTranscriptHash computes a stable, normalized SHA-256 hash of the +// canonical transcript entries. It round-trips the JSON payload through a generic +// unmarshaling step to convert any nested structures (such as GitDiff) to generic +// maps, ensuring key ordering is consistently alphabetical. +func CanonicalTranscriptHash(messages []Message) (string, error) { + entries := CanonicalTranscriptEntriesFromMessages(messages, "") + payload, err := json.Marshal(entries) + if err != nil { + return "", fmt.Errorf("failed to marshal canonical transcript entries: %w", err) + } + + var raw any + if err := json.Unmarshal(payload, &raw); err != nil { + return "", fmt.Errorf("failed to unmarshal canonical transcript entries for normalization: %w", err) + } + + normalizedPayload, err := json.Marshal(raw) + if err != nil { + return "", fmt.Errorf("failed to marshal normalized canonical transcript entries: %w", err) + } + + hash := sha256.Sum256(normalizedPayload) + return hex.EncodeToString(hash[:]), nil +} + func HasCompactionMetadata(message Message) bool { return message.Metadata != nil && message.Metadata.Compaction != nil } @@ -984,6 +1011,11 @@ func ValidateCompactionBoundary(messages []Message) error { return err } if metadata.PreservedTailHash != actualHash { + // Fallback: check if the legacy hash matches + legacyHash, err := LegacyCanonicalTranscriptHash(preserved) + if err == nil && metadata.PreservedTailHash == legacyHash { + return nil + } return fmt.Errorf("compaction boundary preserved tail hash mismatch") } if err := validatePreservedTailToolResults(preserved); err != nil { diff --git a/internal/vector/config.go b/internal/vector/config.go index d7e62c4..0e94f40 100644 --- a/internal/vector/config.go +++ b/internal/vector/config.go @@ -17,6 +17,7 @@ const ( BackendQdrant Backend = "qdrant" BackendChroma Backend = "chroma" BackendMemory Backend = "memory" // in-process, for tests and dev + BackendHNSW Backend = "hnsw" // embedded HNSW, no CGO, no external service ) // Config describes how to open a vector store. @@ -71,6 +72,11 @@ type Config struct { // Defaults to "default_tenant" / "default_database". ChromaTenant string ChromaDatabase string + + // HNSWDir is the directory where HNSW index files are stored. + // Each namespace gets its own pair of files (.hnsw + .meta.json). + // Defaults to /data/hnsw when not set. + HNSWDir string } // NewStore creates and returns a ready-to-use Store from cfg. @@ -143,6 +149,13 @@ func NewStore(ctx context.Context, cfg Config) (Store, error) { Database: database, }), nil + case BackendHNSW: + dir := strings.TrimSpace(cfg.HNSWDir) + if dir == "" { + return nil, fmt.Errorf("vector.NewStore: HNSWDir is required for hnsw backend") + } + return NewHNSWStore(dir) + default: return nil, fmt.Errorf("vector.NewStore: unknown backend %q", cfg.Backend) } diff --git a/internal/vector/hnsw_store.go b/internal/vector/hnsw_store.go new file mode 100644 index 0000000..ae5ea30 --- /dev/null +++ b/internal/vector/hnsw_store.go @@ -0,0 +1,342 @@ +package vector + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/coder/hnsw" +) + +// HNSWStore is an embedded, persistent vector store backed by HNSW graphs. +// +// Each namespace gets two files in dir: +// - .hnsw — binary HNSW index (vectors + graph topology) +// - .meta.json — text and metadata per key +// +// Search is O(log n) via HNSW. No external service or CGO required. +// Designed as the default RAG backend for the Nexus CLI. +type HNSWStore struct { + dir string + mu sync.RWMutex + ns map[string]*hnswNamespace +} + +type hnswNamespace struct { + mu sync.RWMutex + graph *hnsw.SavedGraph[string] + meta map[string]hnswMeta + metaPath string +} + +// hnswMeta stores everything except the vector itself (the graph holds that). +type hnswMeta struct { + Text string `json:"t"` + Metadata map[string]string `json:"m,omitempty"` +} + +// NewHNSWStore creates an HNSWStore that persists files in dir. +// The directory is created if it does not exist. +func NewHNSWStore(dir string) (*HNSWStore, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create hnsw store dir: %w", err) + } + return &HNSWStore{ + dir: dir, + ns: make(map[string]*hnswNamespace), + }, nil +} + +// Close releases in-memory graphs. All data is already persisted to disk. +func (s *HNSWStore) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + s.ns = make(map[string]*hnswNamespace) + return nil +} + +// namespace returns (or lazily loads) the hnswNamespace for name. +func (s *HNSWStore) namespace(name string) (*hnswNamespace, error) { + s.mu.RLock() + n, ok := s.ns[name] + s.mu.RUnlock() + if ok { + return n, nil + } + + s.mu.Lock() + defer s.mu.Unlock() + if n, ok = s.ns[name]; ok { + return n, nil + } + + slug := sanitizeNamespace(name) + graphPath := filepath.Join(s.dir, slug+".hnsw") + metaPath := filepath.Join(s.dir, slug+".meta.json") + + g, err := hnsw.LoadSavedGraph[string](graphPath) + if err != nil { + return nil, fmt.Errorf("load hnsw graph %q: %w", name, err) + } + // Higher efSearch for better recall on CLI-scale corpora. + if g.Len() == 0 { + g.EfSearch = 50 + } + + meta := make(map[string]hnswMeta) + if data, err := os.ReadFile(metaPath); err == nil { + if unmarshalErr := json.Unmarshal(data, &meta); unmarshalErr != nil { + log.Printf("[vector/hnsw] metadata unmarshal warning for namespace %q: %v", name, unmarshalErr) + } + } + + n = &hnswNamespace{graph: g, meta: meta, metaPath: metaPath} + s.ns[name] = n + return n, nil +} + +func (n *hnswNamespace) saveMeta() error { + data, err := json.Marshal(n.meta) + if err != nil { + return err + } + tmp := n.metaPath + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, n.metaPath) +} + +// Upsert inserts or replaces records. Saves index and metadata atomically after each namespace batch. +func (s *HNSWStore) Upsert(ctx context.Context, records []Record) error { + byNS := make(map[string][]Record, 1) + for _, r := range records { + if r.Namespace == "" { + return fmt.Errorf("vector namespace is required") + } + if r.Key == "" { + return fmt.Errorf("vector key is required") + } + if len(r.Vector) == 0 { + return fmt.Errorf("vector values are required for key %q", r.Key) + } + byNS[r.Namespace] = append(byNS[r.Namespace], r) + } + + for nsName, recs := range byNS { + n, err := s.namespace(nsName) + if err != nil { + return err + } + n.mu.Lock() + for _, r := range recs { + n.graph.Add(hnsw.MakeNode(r.Key, r.Vector)) + n.meta[r.Key] = hnswMeta{Text: r.Text, Metadata: r.Metadata} + } + saveErr := n.graph.Save() + metaErr := n.saveMeta() + n.mu.Unlock() + if err := errors.Join(saveErr, metaErr); err != nil { + return fmt.Errorf("persist hnsw namespace %q: %w", nsName, err) + } + } + return nil +} + +// Search performs HNSW ANN search (O(log n)) over the namespace. +// When query.HybridWeight > 0 and query.QueryText is set, keyword scores +// are blended with vector scores using linear interpolation. +func (s *HNSWStore) Search(ctx context.Context, query Query) ([]SearchResult, error) { + if query.Namespace == "" { + return nil, fmt.Errorf("vector query namespace is required") + } + if len(query.Vector) == 0 { + return nil, fmt.Errorf("vector query values are required") + } + topK := query.TopK + if topK <= 0 { + topK = 5 + } + + n, err := s.namespace(query.Namespace) + if err != nil { + return nil, err + } + + n.mu.RLock() + defer n.mu.RUnlock() + + if n.graph.Len() == 0 { + return nil, nil + } + + nodes := n.graph.Search(query.Vector, topK) + results := make([]SearchResult, 0, len(nodes)) + for _, node := range nodes { + m := n.meta[node.Key] + r := Record{ + Namespace: query.Namespace, + Key: node.Key, + Text: m.Text, + Vector: node.Value, + Metadata: m.Metadata, + } + if len(query.Filter) > 0 && !matchesFilter(r, query.Filter) { + continue + } + // coder/hnsw uses cosine distance (0 = identical, 2 = opposite). + // Convert to cosine similarity (1 = identical) to match the other backends. + dist := hnsw.CosineDistance(query.Vector, node.Value) + results = append(results, SearchResult{Record: r, Score: 1 - dist}) + } + + if query.HybridWeight > 0 && strings.TrimSpace(query.QueryText) != "" && len(results) > 0 { + results = hnswBlendKeyword(results, query.QueryText, query.HybridWeight) + // Re-sort after blending: keyword scores change the ranking. + sort.Slice(results, func(i, j int) bool { + return results[i].Score > results[j].Score + }) + } + + return results, nil +} + +// Get retrieves records by key (without their vectors — use Search for ANN retrieval). +// If keys is nil or empty, all records in the namespace are returned. +func (s *HNSWStore) Get(ctx context.Context, namespace string, keys []string) ([]Record, error) { + n, err := s.namespace(namespace) + if err != nil { + return nil, err + } + + n.mu.RLock() + defer n.mu.RUnlock() + + if len(keys) == 0 { + records := make([]Record, 0, len(n.meta)) + for key, m := range n.meta { + records = append(records, Record{ + Namespace: namespace, + Key: key, + Text: m.Text, + Metadata: m.Metadata, + }) + } + return records, nil + } + + records := make([]Record, 0, len(keys)) + for _, key := range keys { + if m, ok := n.meta[key]; ok { + records = append(records, Record{ + Namespace: namespace, + Key: key, + Text: m.Text, + Metadata: m.Metadata, + }) + } + } + return records, nil +} + +// HasNamespace reports whether the namespace contains at least one record. +func (s *HNSWStore) HasNamespace(ctx context.Context, namespace string) (bool, error) { + n, err := s.namespace(namespace) + if err != nil { + return false, err + } + n.mu.RLock() + defer n.mu.RUnlock() + return n.graph.Len() > 0, nil +} + +// DeleteNamespace removes all records for a namespace and its index files. +func (s *HNSWStore) DeleteNamespace(ctx context.Context, namespace string) error { + s.mu.Lock() + delete(s.ns, namespace) + s.mu.Unlock() + + slug := sanitizeNamespace(namespace) + _ = os.Remove(filepath.Join(s.dir, slug+".hnsw")) + _ = os.Remove(filepath.Join(s.dir, slug+".meta.json")) + return nil +} + +// DeleteKeys removes specific records within a namespace. +func (s *HNSWStore) DeleteKeys(ctx context.Context, namespace string, keys []string) error { + if len(keys) == 0 { + return nil + } + n, err := s.namespace(namespace) + if err != nil { + return err + } + + n.mu.Lock() + for _, key := range keys { + n.graph.Delete(key) + delete(n.meta, key) + } + saveErr := n.graph.Save() + metaErr := n.saveMeta() + n.mu.Unlock() + + if err := errors.Join(saveErr, metaErr); err != nil { + return fmt.Errorf("persist hnsw namespace %q after delete: %w", namespace, err) + } + return nil +} + +// hnswBlendKeyword blends HNSW cosine scores with a simple keyword presence score. +// This replaces FTS5 BM25 (unavailable in the standalone HNSW backend). +// Score per result = (1-hw)*vector_score + hw*keyword_score +// where keyword_score = fraction of query tokens found in the result text. +func hnswBlendKeyword(results []SearchResult, queryText string, hw float32) []SearchResult { + tokens := strings.Fields(strings.ToLower(queryText)) + if len(tokens) == 0 { + return results + } + for i := range results { + text := strings.ToLower(results[i].Record.Text) + var hits float32 + for _, tok := range tokens { + if strings.Contains(text, tok) { + hits++ + } + } + kwScore := hits / float32(len(tokens)) + results[i].Score = (1-hw)*results[i].Score + hw*kwScore + } + return results +} + +// sanitizeNamespace converts a namespace string into a safe filename component. +func sanitizeNamespace(ns string) string { + var sb strings.Builder + for _, r := range ns { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-': + sb.WriteRune(r) + default: + sb.WriteByte('_') + } + } + s := sb.String() + if len(s) > 200 { + s = s[:200] + } + if s == "" { + s = "default" + } + return s +} + +// Ensure HNSWStore implements Store at compile time. +var _ Store = (*HNSWStore)(nil) diff --git a/internal/vector/sqlite_store.go b/internal/vector/sqlite_store.go index 187db12..53e379e 100644 --- a/internal/vector/sqlite_store.go +++ b/internal/vector/sqlite_store.go @@ -7,6 +7,7 @@ import ( "encoding/binary" "encoding/json" "fmt" + "log" "sort" "strings" @@ -290,7 +291,11 @@ func (s *SQLiteStore) Get(ctx context.Context, namespace string, keys []string) return nil, fmt.Errorf("scan vector record: %w", err) } var meta map[string]string - _ = json.Unmarshal([]byte(metaJSON), &meta) + if metaJSON != "" && metaJSON != "{}" { + if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil { + log.Printf("[vector/sqlite] metadata unmarshal warning for key %q in namespace %q: %v", key, namespace, err) + } + } results = append(results, Record{ Namespace: namespace, Key: key, @@ -306,13 +311,13 @@ func (s *SQLiteStore) Get(ctx context.Context, namespace string, keys []string) // HasNamespace reports whether at least one record exists in the namespace. func (s *SQLiteStore) HasNamespace(ctx context.Context, namespace string) (bool, error) { - var count int + var exists bool err := s.db.SQL().QueryRowContext(ctx, - `SELECT COUNT(*) FROM vector_records WHERE namespace = ? LIMIT 1`, namespace).Scan(&count) + `SELECT EXISTS(SELECT 1 FROM vector_records WHERE namespace = ? LIMIT 1)`, namespace).Scan(&exists) if err != nil { return false, fmt.Errorf("has namespace: %w", err) } - return count > 0, nil + return exists, nil } // DeleteNamespace removes all records for a namespace. diff --git a/internal/vector/vector_test.go b/internal/vector/vector_test.go index 3baae02..d80e508 100644 --- a/internal/vector/vector_test.go +++ b/internal/vector/vector_test.go @@ -2,10 +2,11 @@ package vector import ( "context" - dbpkg "github.com/EngineerProjects/nexus-engine/internal/db" "os" "path/filepath" "testing" + + dbpkg "github.com/EngineerProjects/nexus-engine/internal/db" ) func TestMemoryStoreSearchRanksByCosineSimilarity(t *testing.T) { @@ -629,3 +630,112 @@ func TestSQLiteStore_HybridSearch_FilterWithHybrid(t *testing.T) { } } } + +func TestHNSWStore_UpsertSearchPersistence(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + + store, err := NewHNSWStore(dir) + if err != nil { + t.Fatalf("NewHNSWStore: %v", err) + } + + records := []Record{ + {Namespace: "ns", Key: "a", Text: "apple", Vector: []float32{1, 0, 0}}, + {Namespace: "ns", Key: "b", Text: "banana", Vector: []float32{0, 1, 0}}, + {Namespace: "ns", Key: "c", Text: "cherry", Vector: []float32{0, 0, 1}}, + } + if err := store.Upsert(ctx, records); err != nil { + t.Fatalf("Upsert: %v", err) + } + + // Vector [1,0,0] should rank "a" first. + results, err := store.Search(ctx, Query{Namespace: "ns", Vector: []float32{1, 0, 0}, TopK: 1}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(results) == 0 || results[0].Record.Key != "a" { + t.Fatalf("expected key=a as top result, got %+v", results) + } + if results[0].Record.Text != "apple" { + t.Fatalf("expected text=apple, got %q", results[0].Record.Text) + } + + // Score should be ~1.0 for identical direction. + if results[0].Score < 0.99 { + t.Fatalf("expected score ~1, got %f", results[0].Score) + } + + // HasNamespace + has, err := store.HasNamespace(ctx, "ns") + if err != nil || !has { + t.Fatalf("HasNamespace: has=%v err=%v", has, err) + } + + // --- Persistence: reload from disk --- + store2, err := NewHNSWStore(dir) + if err != nil { + t.Fatalf("reload NewHNSWStore: %v", err) + } + results2, err := store2.Search(ctx, Query{Namespace: "ns", Vector: []float32{0, 1, 0}, TopK: 1}) + if err != nil { + t.Fatalf("Search after reload: %v", err) + } + if len(results2) == 0 || results2[0].Record.Key != "b" { + t.Fatalf("expected key=b after reload, got %+v", results2) + } + + // DeleteKeys + if err := store2.DeleteKeys(ctx, "ns", []string{"a"}); err != nil { + t.Fatalf("DeleteKeys: %v", err) + } + got, err := store2.Get(ctx, "ns", []string{"a"}) + if err != nil { + t.Fatalf("Get after delete: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected key=a deleted, still got %+v", got) + } + + // DeleteNamespace + if err := store2.DeleteNamespace(ctx, "ns"); err != nil { + t.Fatalf("DeleteNamespace: %v", err) + } + has, err = store2.HasNamespace(ctx, "ns") + if err != nil || has { + t.Fatalf("after DeleteNamespace: has=%v err=%v", has, err) + } +} + +func TestHNSWStore_HybridKeywordBlend(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + + store, err := NewHNSWStore(dir) + if err != nil { + t.Fatalf("NewHNSWStore: %v", err) + } + _ = store.Upsert(ctx, []Record{ + {Namespace: "k", Key: "x", Text: "the quick brown fox", Vector: []float32{1, 0}}, + {Namespace: "k", Key: "y", Text: "lazy dog sleeps", Vector: []float32{1, 0}}, // same vector direction + }) + + // Without hybrid both should have the same vector score. + // With hybrid=1 "fox" should rank "x" higher. + results, err := store.Search(ctx, Query{ + Namespace: "k", + Vector: []float32{1, 0}, + TopK: 2, + HybridWeight: 1.0, + QueryText: "fox", + }) + if err != nil { + t.Fatalf("hybrid search: %v", err) + } + if len(results) < 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + if results[0].Record.Key != "x" { + t.Fatalf("expected 'x' to rank first with keyword 'fox', got %+v", results) + } +} diff --git a/internal/web/browser/downloads.go b/internal/web/browser/downloads.go index b50a7f8..1903ef9 100644 --- a/internal/web/browser/downloads.go +++ b/internal/web/browser/downloads.go @@ -92,13 +92,8 @@ func (m *RodManager) persistDownload(ctx context.Context, session *sessionState, entry.ErrorText = err.Error() return entry } - ref, err := m.config.ArtifactStore.PutArtifact(ctx, storage.ArtifactPutRequest{ - Namespace: storage.NamespaceBrowserDownloads, - Filename: filename, - SessionID: string(session.id), - PageID: entry.PageID, - ContentType: storage.DetectContentType(filename), - }, body) + key := storage.DownloadKey(string(session.id), entry.PageID, filename, time.Now().UTC()) + ref, err := m.config.ArtifactStore.Put(ctx, key, body, storage.DetectContentType(filename)) if err != nil { entry.ErrorText = err.Error() return entry diff --git a/internal/web/fetch/browser.go b/internal/web/fetch/browser.go index 2e7a2d7..54d739c 100644 --- a/internal/web/fetch/browser.go +++ b/internal/web/fetch/browser.go @@ -28,7 +28,7 @@ func (s *Service) Fetch(ctx context.Context, request Request) (FetchedContent, e case webcore.RenderModeBrowser: fetched, err = s.fetchViaBrowser(ctx, plan) case webcore.RenderModeHTTP: - fetched, err = s.fetchViaHTTP(ctx, plan.NormalizedURL) + fetched, err = s.fetchViaHTTP(ctx, plan.NormalizedURL, string(plan.Request.SessionID)) default: return FetchedContent{}, fmt.Errorf("unsupported fetch mode %q", plan.Mode) } @@ -46,7 +46,7 @@ func (s *Service) fetchAuto(ctx context.Context, plan *fetchPlan) (FetchedConten if cachedMode, ok := s.decisionCache.Get(decisionCacheKey(plan.NormalizedURL)); ok { switch cachedMode { case webcore.RenderModeHTTP: - httpFetched, err := s.fetchViaHTTP(ctx, plan.NormalizedURL) + httpFetched, err := s.fetchViaHTTP(ctx, plan.NormalizedURL, string(plan.Request.SessionID)) if err == nil { httpFetched.Mode = webcore.RenderModeHTTP } @@ -61,7 +61,7 @@ func (s *Service) fetchAuto(ctx context.Context, plan *fetchPlan) (FetchedConten } } - httpFetched, err := s.fetchViaHTTP(ctx, plan.NormalizedURL) + httpFetched, err := s.fetchViaHTTP(ctx, plan.NormalizedURL, string(plan.Request.SessionID)) if err != nil { return FetchedContent{}, err } diff --git a/internal/web/fetch/http.go b/internal/web/fetch/http.go index e44c727..5a6e031 100644 --- a/internal/web/fetch/http.go +++ b/internal/web/fetch/http.go @@ -39,7 +39,7 @@ func DefaultHTTPClient(resolver webcore.HostResolver) *http.Client { } // fetchViaHTTP keeps the fast path lean and cache-backed for the common case of static or server-rendered pages. -func (s *Service) fetchViaHTTP(ctx context.Context, urlStr string) (FetchedContent, error) { +func (s *Service) fetchViaHTTP(ctx context.Context, urlStr string, sessionID string) (FetchedContent, error) { if entry, ok := s.cache.Get(urlStr); ok { return FetchedContent{ Content: entry.Content, @@ -97,7 +97,7 @@ func (s *Service) fetchViaHTTP(ctx context.Context, urlStr string) (FetchedConte browserRecommended = shouldRecommendBrowser(string(body)) content = HTMLToMarkdown(content, finalURL) } else if !isTextualContentType(contentType) { - persistedPath, persistedSize, err = s.persistArtifact(requestCtx, finalURL, contentType, body) + persistedPath, persistedSize, err = s.persistArtifact(requestCtx, sessionID, finalURL, contentType, body) if err != nil { return FetchedContent{}, err } diff --git a/internal/web/fetch/persist.go b/internal/web/fetch/persist.go index 2653dc9..4bc31cb 100644 --- a/internal/web/fetch/persist.go +++ b/internal/web/fetch/persist.go @@ -50,16 +50,12 @@ func inferArtifactFilename(rawURL string, contentType string) string { } } -func (s *Service) persistArtifact(ctx context.Context, finalURL string, contentType string, body []byte) (string, int, error) { +func (s *Service) persistArtifact(ctx context.Context, sessionID, finalURL, contentType string, body []byte) (string, int, error) { if s.artifactStore == nil || len(body) == 0 { return "", 0, nil } filename := inferArtifactFilename(finalURL, contentType) - ref, err := s.artifactStore.PutArtifact(ctx, storage.ArtifactPutRequest{ - Namespace: storage.NamespaceWebArtifacts, - Filename: filename, - ContentType: contentType, - }, body) + ref, err := storage.StoreWebArtifactRef(ctx, s.artifactStore, body, sessionID, filename, contentType) if err != nil { return "", 0, fmt.Errorf("persist fetched artifact: %w", err) } diff --git a/internal/web/search/providers/registry.go b/internal/web/search/providers/registry.go index dd81416..87af732 100644 --- a/internal/web/search/providers/registry.go +++ b/internal/web/search/providers/registry.go @@ -2,6 +2,7 @@ package providers import ( "fmt" + "log" "os" "sort" "strings" @@ -66,7 +67,7 @@ func RunSearch(input SearchInput, chain []SearchProvider, mode ProviderMode) (Pr output, err := provider.Search(input) if err == nil { if mode == ProviderModeAuto && len(output.Hits) == 0 && shouldFallbackOnEmpty(provider) && i < len(chain)-1 { - fmt.Fprintf(os.Stderr, "[web-search] %s returned 0 hits, trying next provider...\n", provider.Name()) + log.Printf("[web-search] %s returned 0 hits, trying next provider...", provider.Name()) continue } return output, nil @@ -81,7 +82,7 @@ func RunSearch(input SearchInput, chain []SearchProvider, mode ProviderMode) (Pr // In auto mode, try next provider if i < len(chain)-1 { - fmt.Fprintf(os.Stderr, "[web-search] %s failed: %v, trying next provider...\n", provider.Name(), err) + log.Printf("[web-search] %s failed: %v, trying next provider...", provider.Name(), err) } } diff --git a/pkg/config/config.go b/pkg/config/config.go index d779d21..c2f166b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -141,7 +141,7 @@ func DefaultConfig() Config { return Config{ RuntimeRoot: "", Cwd: ".", - Model: "glm-4.5", + Model: "", MaxTokens: 4096, Temperature: 0.7, MCPEnabled: true, @@ -194,12 +194,16 @@ func LoadInto(config *Config) error { loadEnvFile("../.env") //nolint:errcheck // best-effort .env loading v := viper.New() - v.SetConfigName(".nexus") v.SetConfigType("yaml") - v.AddConfigPath("$HOME") - v.AddConfigPath(".") v.SetEnvPrefix("NEXUS") + // Primary location: derived from NEXUS_RUNTIME_ROOT (or ~/.config/nexus by default). + // CLI sets NEXUS_RUNTIME_ROOT=~/.config/nexus-cli before calling Load(). + v.SetConfigName("config") + v.AddConfigPath(runtimepath.ResolveRoot("")) + // Fallback: legacy ~/.nexus.yaml for migration + v.AddConfigPath("$HOME") + v.BindEnv("runtime_root", runtimepath.EnvRuntimeRoot) v.BindEnv("cwd", "NEXUS_CWD") v.BindEnv("model", "NEXUS_MODEL") @@ -269,7 +273,16 @@ func LoadInto(config *Config) error { v.SetDefault("enable_api_keys", true) if err := v.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + // Primary config not found — try legacy ~/.nexus.yaml as a one-time fallback. + if home, herr := os.UserHomeDir(); herr == nil { + legacy := filepath.Join(home, ".nexus.yaml") + if _, serr := os.Stat(legacy); serr == nil { + v.SetConfigFile(legacy) + _ = v.ReadInConfig() //nolint:errcheck // best-effort legacy read + } + } + } else { return fmt.Errorf("failed to read config: %w", err) } } @@ -400,10 +413,12 @@ func DetectProviderFromModel(model string) sdk.APIProvider { } // ParseModelIdentifier normalizes optional provider prefixes like "openai:gpt-4o". +// Returns an empty ModelIdentifier when raw is empty — callers that need a +// concrete default should apply sdk.DefaultClientConfig().Model themselves. func ParseModelIdentifier(raw string) sdk.ModelIdentifier { value := strings.TrimSpace(raw) if value == "" { - return sdk.DefaultClientConfig().Model + return sdk.ModelIdentifier{} } provider := sdk.APIProviderAnthropic diff --git a/pkg/config/persist.go b/pkg/config/persist.go index 5ae9fad..fcf28ef 100644 --- a/pkg/config/persist.go +++ b/pkg/config/persist.go @@ -5,15 +5,12 @@ import ( "os" "path/filepath" + "github.com/EngineerProjects/nexus-engine/pkg/runtimepath" "gopkg.in/yaml.v3" ) func DefaultConfigPath() string { - home, err := os.UserHomeDir() - if err != nil || home == "" { - return ".nexus.yaml" - } - return filepath.Join(home, ".nexus.yaml") + return runtimepath.Join("", "config.yaml") } func Save(config Config) error { diff --git a/pkg/config/provider_catalog.go b/pkg/config/provider_catalog.go index 9469f60..69ce8d3 100644 --- a/pkg/config/provider_catalog.go +++ b/pkg/config/provider_catalog.go @@ -312,6 +312,13 @@ func setupFieldsForProvider(provider sdk.APIProvider) []ProviderSetupField { Required: true, }, ) + case sdk.APIProviderOllama: + fields = append(fields, ProviderSetupField{ + Key: "provider_base_url", + Label: "Ollama endpoint", + Description: "Leave blank for http://localhost:11434", + EnvVar: "OLLAMA_HOST", + }) case sdk.APIProviderFoundry: fields = append(fields, ProviderSetupField{ @@ -335,7 +342,7 @@ func setupFieldsForProvider(provider sdk.APIProvider) []ProviderSetupField { func setupHintForProvider(provider sdk.APIProvider) string { switch provider { case sdk.APIProviderOllama: - return "Uses the default local Ollama endpoint at http://localhost:11434." + return "No API key required. Leave endpoint blank to use http://localhost:11434." case sdk.APIProviderBedrock: return "Requires AWS credentials in your environment or profile in addition to the region." case sdk.APIProviderVertex: diff --git a/pkg/runtimepath/runtimepath.go b/pkg/runtimepath/runtimepath.go index 70337bd..f205cc6 100644 --- a/pkg/runtimepath/runtimepath.go +++ b/pkg/runtimepath/runtimepath.go @@ -68,6 +68,8 @@ func TmpDir(root string) string { return Join(root, "tmp") } func BackendDBPath(root string) string { return Join(root, "data", "nexus.db") } +func HNSWDataDir(root string) string { return Join(root, "data", "hnsw") } + func SessionStoreDir(root string) string { return Join(root, "data", "sessions") } func PlansDir(root string) string { return Join(root, "plans") } @@ -83,3 +85,65 @@ func ElectronSessionDataDir(root string) string { return Join(root, "electron", func ElectronLogsDir(root string) string { return Join(root, "electron", "logs") } func ElectronCrashDumpsDir(root string) string { return Join(root, "electron", "crash-dumps") } + +// ─── Session-scoped directories ─────────────────────────────────────────────── +// +// All per-session physical data lives under sessions/{session_id}/. Deleting a +// session requires only os.RemoveAll(SessionDir(root, id)) for filesystem data +// and store.DeleteSession(id) for the database — nothing else. +// +// Layout: +// sessions/{id}/ +// ├── screenshots/ ← browser screenshots +// ├── plans/ ← plan-mode markdown files +// ├── tools/ ← browser downloads +// └── artifacts/ +// ├── web/ ← web-scraped content +// ├── images/ ← AI-generated images (DALL-E, Stable Diffusion, …) +// └── audio/ ← TTS/STT audio files + +func SessionsDir(root string) string { return Join(root, "sessions") } + +func SessionDir(root, sessionID string) string { + return filepath.Join(ResolveRoot(root), "sessions", sessionID) +} + +// SessionScreenshotsDir holds browser screenshots. +func SessionScreenshotsDir(root, sessionID string) string { + return filepath.Join(ResolveRoot(root), "sessions", sessionID, "screenshots") +} + +// SessionPlansDir holds plan-mode markdown files for the session. +func SessionPlansDir(root, sessionID string) string { + return filepath.Join(ResolveRoot(root), "sessions", sessionID, "plans") +} + +// SessionToolsDir holds browser downloads and tool-produced output files. +func SessionToolsDir(root, sessionID string) string { + return filepath.Join(ResolveRoot(root), "sessions", sessionID, "tools") +} + +// SessionLogPath is the per-session log file for errors and diagnostics. +func SessionLogPath(root, sessionID string) string { + return filepath.Join(ResolveRoot(root), "sessions", sessionID, "session.log") +} + +// SessionArtifactsDir is the parent for all agent-produced artifacts. +func SessionArtifactsDir(root, sessionID string) string { + return filepath.Join(ResolveRoot(root), "sessions", sessionID, "artifacts") +} + +// SessionArtifactsWebDir holds web-scraped/fetched content. +func SessionArtifactsWebDir(root, sessionID string) string { + return filepath.Join(ResolveRoot(root), "sessions", sessionID, "artifacts", "web") +} + +// SessionArtifactsImagesDir holds AI-generated images (not browser screenshots). +func SessionArtifactsImagesDir(root, sessionID string) string { + return filepath.Join(ResolveRoot(root), "sessions", sessionID, "artifacts", "images") +} + +// SessionArtifactsAudioDir holds TTS output and STT input audio files. +func SessionArtifactsAudioDir(root, sessionID string) string { + return filepath.Join(ResolveRoot(root), "sessions", sessionID, "artifacts", "audio") +} diff --git a/pkg/sdk/client.go b/pkg/sdk/client.go index 73c9d6b..56e0ed9 100644 --- a/pkg/sdk/client.go +++ b/pkg/sdk/client.go @@ -8,6 +8,7 @@ import ( "os" "strings" "sync" + "time" coreagent "github.com/EngineerProjects/nexus-engine/internal/agent" "github.com/EngineerProjects/nexus-engine/internal/engine" @@ -152,12 +153,17 @@ func NewClient(config *ClientConfig) (*Client, error) { return nil, fmt.Errorf("failed to register agent tool: %w", err) } - // spawn_agent needs the live engine instance — registered here, not in builtin.go. + // spawn_agent and resume_agent need the live engine instance — registered here, not in builtin.go. // nil tools → sub-agent inherits all tools from the engine registry at call time. - spawnAgentTool := agentTool.NewSpawnAgentTool(queryEngine, nil, coreagent.NewAgentRegistry()) + agentRegistry := coreagent.NewAgentRegistry() + spawnAgentTool := agentTool.NewSpawnAgentTool(queryEngine, nil, agentRegistry) if err := reg.Register(spawnAgentTool); err != nil { return nil, fmt.Errorf("failed to register spawn_agent tool: %w", err) } + resumeAgentTool := agentTool.NewResumeAgentTool(queryEngine, nil, agentRegistry) + if err := reg.Register(resumeAgentTool); err != nil { + return nil, fmt.Errorf("failed to register resume_agent tool: %w", err) + } client := &Client{ queryEngine: queryEngine, @@ -307,12 +313,33 @@ func (c *Client) ListSessions() ([]*SessionInfo, error) { return c.store.GetAllSessionsInfo() } -// DeleteSession deletes a session. +// DeleteSession deletes a session and all associated artifacts from storage. func (c *Client) DeleteSession(sessionID SessionID) error { if c.store == nil { return fmt.Errorf("session persistence not enabled") } - return c.store.DeleteSession(sessionID) + if err := c.store.DeleteSession(sessionID); err != nil { + return err + } + if c.artifacts != nil { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + deleteSessionArtifacts(ctx, c.artifacts, string(sessionID)) + } + return nil +} + +// deleteSessionArtifacts removes all artifacts stored under sessions/{id}/. +// This handles S3 storage where os.RemoveAll is not available. +// Errors are intentionally ignored — artifact cleanup is best-effort. +func deleteSessionArtifacts(ctx context.Context, store ArtifactStore, sessionID string) { + refs, err := store.List(ctx, ArtifactListOptions{Prefix: "sessions/" + sessionID}) + if err != nil { + return + } + for _, ref := range refs { + _ = store.Delete(ctx, ref.Key) + } } // Close releases SDK-owned resources. Safe to call multiple times. diff --git a/pkg/sdk/client_config.go b/pkg/sdk/client_config.go index a1d94d6..7542262 100644 --- a/pkg/sdk/client_config.go +++ b/pkg/sdk/client_config.go @@ -5,7 +5,6 @@ import ( "time" "github.com/EngineerProjects/nexus-engine/internal/providers" - "github.com/EngineerProjects/nexus-engine/internal/storage" "github.com/EngineerProjects/nexus-engine/pkg/runtimepath" ) @@ -145,10 +144,9 @@ func DefaultClientConfig() *ClientConfig { StorageGCEnabled: true, StorageGCInterval: time.Hour, StorageGCLimit: 512, - StorageGCNamespaces: []string{ - string(storage.NamespaceWebArtifacts), - string(storage.NamespaceBrowserScreenshots), - string(storage.NamespaceBrowserDownloads), - }, + // Session-scoped artifacts are cleaned up via DeleteSessionDir on session + // deletion, not by periodic GC. Global namespaces with expiring content + // would go here if added in the future. + StorageGCNamespaces: []string{}, } } diff --git a/pkg/sdk/types.go b/pkg/sdk/types.go index a2b97e6..50c88ba 100644 --- a/pkg/sdk/types.go +++ b/pkg/sdk/types.go @@ -56,6 +56,7 @@ type ( ThinkingContent = types.ThinkingContent TranscriptEntry = types.TranscriptEntry TurnID = types.TurnID + ToolResultContent = types.ToolResultContent ToolUseContent = types.ToolUseContent Role = types.Role RuntimeEvent = types.RuntimeEvent diff --git a/pkg/vector/vector.go b/pkg/vector/vector.go index 3cf9e7b..9b83102 100644 --- a/pkg/vector/vector.go +++ b/pkg/vector/vector.go @@ -23,6 +23,7 @@ const ( BackendQdrant = internalvector.BackendQdrant BackendChroma = internalvector.BackendChroma BackendMemory = internalvector.BackendMemory + BackendHNSW = internalvector.BackendHNSW ) // DBHandle is the public vector-facing database descriptor. @@ -63,6 +64,9 @@ type Config struct { ChromaAPIKey string ChromaTenant string ChromaDatabase string + // HNSWDir is the directory for HNSW index files (BackendHNSW only). + // Defaults to /data/hnsw via pkg/config helpers. + HNSWDir string } func NewMemoryStore() *internalvector.MemoryStore { @@ -91,6 +95,7 @@ func NewStore(ctx context.Context, cfg Config) (Store, error) { ChromaAPIKey: cfg.ChromaAPIKey, ChromaTenant: cfg.ChromaTenant, ChromaDatabase: cfg.ChromaDatabase, + HNSWDir: cfg.HNSWDir, }) }