9048 lines
268 KiB
Go
9048 lines
268 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
goruntime "runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/autoresearch"
|
|
"reasonix/internal/billing"
|
|
"reasonix/internal/boot"
|
|
"reasonix/internal/botruntime"
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/control"
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/evidence"
|
|
"reasonix/internal/fileref"
|
|
fileenc "reasonix/internal/fileutil/encoding"
|
|
"reasonix/internal/i18n"
|
|
"reasonix/internal/mcpdiag"
|
|
"reasonix/internal/memory"
|
|
"reasonix/internal/notify"
|
|
"reasonix/internal/plugin"
|
|
"reasonix/internal/pluginpkg"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/skill"
|
|
"reasonix/internal/store"
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
// eventChannel is the Wails runtime event name the frontend subscribes to for the
|
|
// agent's typed event stream. One channel carries every event kind; the payload's
|
|
// `kind` field discriminates — the desktop analogue of the serve transport's SSE
|
|
// `data:` frames.
|
|
const eventChannel = "agent:event"
|
|
|
|
const singleInstanceIDPrefix = "com.reasonix.desktop"
|
|
|
|
// singleInstanceID is used by Wails to route a second desktop launch back to the
|
|
// running instance. It is stable for a given binary path, while allowing a dev
|
|
// build and an installed release at different paths to run side by side.
|
|
func singleInstanceID() string {
|
|
abs, err := os.Executable()
|
|
if err != nil {
|
|
return singleInstanceIDPrefix
|
|
}
|
|
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
|
abs = resolved
|
|
} else if fallback, err := filepath.Abs(abs); err == nil {
|
|
abs = fallback
|
|
}
|
|
sum := sha256.Sum256([]byte(abs))
|
|
return singleInstanceIDPrefix + "." + hex.EncodeToString(sum[:8])
|
|
}
|
|
|
|
// PromptHistoryEntry is one user prompt extracted from a session JSONL file.
|
|
// The frontend uses these for ↑/↓ prompt-history navigation.
|
|
type PromptHistoryEntry struct {
|
|
Text string `json:"text"`
|
|
At int64 `json:"at"` // unix ms
|
|
SessionPath string `json:"sessionPath"`
|
|
Turn int `json:"turn"`
|
|
}
|
|
|
|
// PromptHistoryResult is returned as one Wails value. It carries one loaded tape
|
|
// segment plus the cursor needed to keep walking toward older prompts.
|
|
type PromptHistoryResult struct {
|
|
Entries []PromptHistoryEntry `json:"entries"`
|
|
Nonce string `json:"nonce"`
|
|
OlderCursor string `json:"olderCursor,omitempty"`
|
|
HasOlder bool `json:"hasOlder"`
|
|
}
|
|
|
|
// App is the Wails-bound application object: the desktop frontend's command
|
|
// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS
|
|
// bindings. The app manages multiple WorkspaceTabs — each with its own controller
|
|
// scoped to a project workspace — and routes commands to the active tab. Events
|
|
// flow the other way: each tab's controller emits to a tabEventSink that
|
|
// forwards events tagged with tabId to the webview via runtime.EventsEmit.
|
|
type App struct {
|
|
ctx context.Context
|
|
|
|
// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read
|
|
// from bound methods. All bound methods that touch a controller use activeCtrl().
|
|
mu sync.RWMutex
|
|
tabs map[string]*WorkspaceTab
|
|
tabOrder []string
|
|
activeTabID string
|
|
readyHook func()
|
|
|
|
// tabsRestored is closed when restoreOrBuildTabs has finished populating
|
|
// a.tabs from desktop-tabs.json (or built the first-launch tab). Startup
|
|
// work that inspects "which sessions are open" or persists the tab list
|
|
// (recovery GC's DeleteSession does both) must wait on it: running against
|
|
// the pre-restore empty tab map would treat every saved tab's session as
|
|
// closed and could overwrite desktop-tabs.json with an empty snapshot.
|
|
tabsRestored chan struct{}
|
|
|
|
// projectTreeChangedHook is test-only: set once before any concurrency
|
|
// starts, then read lock-free from emitProjectTreeChanged (whose callers
|
|
// may or may not hold a.mu, so it cannot re-lock). Never write it after
|
|
// startup.
|
|
projectTreeChangedHook func()
|
|
|
|
// singleSurfaceMu serializes open/reuse plus visible-tab pruning for the
|
|
// one-conversation layout so overlapping navigation cannot remove the tab
|
|
// another navigation is still activating.
|
|
singleSurfaceMu sync.Mutex
|
|
|
|
// sessionRemovalMu serializes operations that remove visible or detached
|
|
// session bindings. Those operations may snapshot controllers before
|
|
// deletion; keep that snapshot outside a.mu, but do not let DeleteSession or
|
|
// topic/workspace removal trash the same files while it is in flight.
|
|
sessionRemovalMu sync.Mutex
|
|
|
|
// runtimeRebuildMu serializes controller rebuilds (build + swap) across
|
|
// rebuildSetting, SetModelForTab, and the deferred-rebuild retry loop. Two
|
|
// concurrent rebuilds of the same tab both pass the tab-identity check at
|
|
// swap time (the tab pointer is unchanged), so the loser's controller
|
|
// would replace the winner's and leak it without Close.
|
|
runtimeRebuildMu sync.Mutex
|
|
|
|
// tryRunMu guards tryRunCancel — the cancel handle for the single
|
|
// in-flight settings-page subagent try run (TrySubagentProfile /
|
|
// CancelTrySubagentProfile).
|
|
tryRunMu sync.Mutex
|
|
tryRunCancel context.CancelFunc
|
|
|
|
// deferredRebuild tracks tabs whose settings were saved but whose runtime
|
|
// could not refresh because the session lease was held by another process.
|
|
deferredRebuild deferredRebuildState
|
|
|
|
// detachedSessions keeps live session runtimes whose visible tab was closed.
|
|
// It is process-local by design: shutdown closes every detached controller.
|
|
detachedSessions map[string]*WorkspaceTab
|
|
|
|
// sharedHosts holds one *plugin.Host per workspace root, shared by all
|
|
// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are
|
|
// spawned once instead of N times. Lifecycle: first Acquire creates the
|
|
// host, last Release closes it.
|
|
sharedHosts map[string]*sharedPluginHost
|
|
sharedHostsMu sync.Mutex
|
|
|
|
// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.
|
|
tabsSaveMu sync.Mutex
|
|
tabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot
|
|
tabsLastWrittenVersion uint64 // protected by tabsSaveMu
|
|
|
|
forceQuit atomic.Bool
|
|
backgroundMaximised atomic.Bool
|
|
trayReady bool
|
|
tray *desktopTray
|
|
hangWatchdogMu sync.Mutex
|
|
hangWatchdogCancel context.CancelFunc
|
|
|
|
mediaTokens *mediaTokenStore
|
|
botInstalls map[string]*botInstallSession
|
|
botRuntime *desktopBotRuntime
|
|
// botBridge gives the embedded bot gateway a god view over desktop
|
|
// sessions (/desktop commands). Set once in NewApp before any tab exists,
|
|
// read-only afterwards, so tabEventSink.Emit reads it without a lock.
|
|
botBridge *botBridgeHub
|
|
|
|
metrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics
|
|
|
|
notificationSenderOnce sync.Once
|
|
notificationSender notify.Sender
|
|
|
|
runtimeEvents asyncRuntimeEmitter
|
|
|
|
// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It
|
|
// stores session order and per-session parsed entries only after that session is
|
|
// reached by ↑ navigation. See ScanPromptHistory.
|
|
promptHistoryMu sync.Mutex
|
|
promptHistoryTape *promptHistoryTape
|
|
|
|
skillRootsMu sync.Mutex
|
|
skillRootsCache skillRootsCache
|
|
|
|
heartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup
|
|
}
|
|
|
|
type skillRootsCache struct {
|
|
key string
|
|
at time.Time
|
|
roots []SkillRootView
|
|
}
|
|
|
|
// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.
|
|
type mediaTokenEntry struct {
|
|
absPath string
|
|
filename string
|
|
mime string
|
|
kind string
|
|
size int64
|
|
modTime time.Time
|
|
createdAt time.Time
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// mediaTokenStore manages temporary tokens that grant access to workspace files
|
|
// through the AssetServer middleware. Tokens expire after a fixed TTL and are
|
|
// capped at a maximum count; creating a new token evicts the oldest entry when
|
|
// the store is full.
|
|
type mediaTokenStore struct {
|
|
mu sync.Mutex
|
|
byTok map[string]*mediaTokenEntry
|
|
order []string // oldest first
|
|
maxN int
|
|
ttl time.Duration
|
|
}
|
|
|
|
const mediaTokenMax = 256
|
|
|
|
func newMediaTokenStore() *mediaTokenStore {
|
|
return &mediaTokenStore{
|
|
byTok: map[string]*mediaTokenEntry{},
|
|
maxN: mediaTokenMax,
|
|
ttl: 10 * time.Minute,
|
|
}
|
|
}
|
|
|
|
func (s *mediaTokenStore) cleanupLocked() {
|
|
now := time.Now()
|
|
for len(s.order) > 0 {
|
|
tok := s.order[0]
|
|
e := s.byTok[tok]
|
|
if e == nil {
|
|
s.order = s.order[1:]
|
|
continue
|
|
}
|
|
if !now.Before(e.expiresAt) {
|
|
delete(s.byTok, tok)
|
|
s.order = s.order[1:]
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
for len(s.order) > s.maxN {
|
|
oldest := s.order[0]
|
|
delete(s.byTok, oldest)
|
|
s.order = s.order[1:]
|
|
}
|
|
}
|
|
|
|
func (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.cleanupLocked()
|
|
|
|
tok := make([]byte, 16)
|
|
if _, err := rand.Read(tok); err != nil {
|
|
panic("crypto/rand.Read failed: " + err.Error())
|
|
}
|
|
token := hex.EncodeToString(tok)
|
|
|
|
now := time.Now()
|
|
s.byTok[token] = &mediaTokenEntry{
|
|
absPath: absPath,
|
|
filename: filename,
|
|
mime: mime,
|
|
kind: kind,
|
|
size: size,
|
|
modTime: modTime,
|
|
createdAt: now,
|
|
expiresAt: now.Add(s.ttl),
|
|
}
|
|
s.order = append(s.order, token)
|
|
|
|
// Trim oldest if the new token pushed us over the limit.
|
|
for len(s.order) > s.maxN {
|
|
oldest := s.order[0]
|
|
delete(s.byTok, oldest)
|
|
s.order = s.order[1:]
|
|
}
|
|
|
|
return token
|
|
}
|
|
|
|
func (s *mediaTokenStore) get(token string) *mediaTokenEntry {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
e := s.byTok[token]
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
if time.Now().After(e.expiresAt) {
|
|
delete(s.byTok, token)
|
|
return nil
|
|
}
|
|
return e
|
|
}
|
|
|
|
func (a *App) ensureMediaTokenStore() *mediaTokenStore {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.mediaTokens == nil {
|
|
a.mediaTokens = newMediaTokenStore()
|
|
}
|
|
return a.mediaTokens
|
|
}
|
|
|
|
// jsProfilingMiddleware opts every asset response into the JS Self-Profiling
|
|
// document policy so the frontend performance monitor can attach sampled stacks
|
|
// to long-task reports. Chromium WebViews (WebView2) honor it; WebKit ignores
|
|
// both the header and the API, so the frontend degrades to unattributed reports.
|
|
func (a *App) jsProfilingMiddleware() func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Document-Policy", "js-profiling")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// workspaceMediaMiddleware returns an HTTP middleware that intercepts
|
|
// /__reasonix_workspace_media/{token}/{filename} requests and serves the
|
|
// corresponding workspace file. All other paths pass through to the Wails
|
|
// default asset handler unchanged.
|
|
func (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
prefix := "/__reasonix_workspace_media/"
|
|
if !strings.HasPrefix(r.URL.Path, prefix) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
rest := strings.TrimPrefix(r.URL.Path, prefix)
|
|
parts := strings.SplitN(rest, "/", 2)
|
|
if len(parts) == 0 || parts[0] == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
token := parts[0]
|
|
|
|
entry := a.ensureMediaTokenStore().get(token)
|
|
if entry == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
f, err := os.Open(entry.absPath)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
w.Header().Set("Content-Type", entry.mime)
|
|
w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": entry.filename}))
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Cache-Control", "private, max-age=600")
|
|
http.ServeContent(w, r, entry.filename, entry.modTime, f)
|
|
})
|
|
}
|
|
}
|
|
|
|
// NewApp constructs the bound object. Tabs are restored in startup from the
|
|
// last session's desktop-tabs.json.
|
|
func NewApp() *App {
|
|
a := &App{
|
|
tabs: map[string]*WorkspaceTab{},
|
|
detachedSessions: map[string]*WorkspaceTab{},
|
|
mediaTokens: newMediaTokenStore(),
|
|
botInstalls: map[string]*botInstallSession{},
|
|
botRuntime: newDesktopBotRuntime(),
|
|
}
|
|
a.botBridge = a.newBotBridge()
|
|
return a
|
|
}
|
|
|
|
func (a *App) bootContext() context.Context {
|
|
if a.ctx != nil {
|
|
return a.ctx
|
|
}
|
|
return context.Background()
|
|
}
|
|
|
|
// Platform exposes the native OS to the frontend so chrome/layout affordances can
|
|
// stay platform-scoped instead of relying on browser user-agent guesses.
|
|
func (a *App) Platform() string {
|
|
return goruntime.GOOS
|
|
}
|
|
|
|
// startup runs once the webview process is up, before the frontend can issue any
|
|
// bound call. It captures the Wails context (needed for EventsEmit), then kicks
|
|
// off the initialization in a background goroutine so the webview loads immediately.
|
|
func (a *App) startup(ctx context.Context) {
|
|
a.ctx = ctx
|
|
installSystemQuitHook()
|
|
a.startTray()
|
|
a.enableDeferredRebuildRetry()
|
|
|
|
if cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != "dev" {
|
|
a.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
|
|
a.recordSettingsMetricsSnapshot(cfg)
|
|
}
|
|
a.startMainThreadWatchdog()
|
|
|
|
a.heartbeat = newHeartbeatEngine(a)
|
|
a.heartbeat.Start()
|
|
|
|
a.mu.Lock()
|
|
a.tabsRestored = make(chan struct{})
|
|
a.mu.Unlock()
|
|
go a.restoreOrBuildTabs()
|
|
a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
|
|
a.goSafe("sendStartupPing", a.sendStartupPing)
|
|
a.goSafe("flushMetrics", a.flushMetrics)
|
|
a.goSafe("flushPendingCrash", a.flushPendingCrash)
|
|
// After restoreOrBuildTabs is launched: the GC's first sweep waits on
|
|
// tabsRestored so it never observes the pre-restore empty tab map.
|
|
a.startRecoveryGC()
|
|
}
|
|
|
|
func (a *App) beforeClose(ctx context.Context) bool {
|
|
if a.forceQuit.Swap(false) || consumeSystemQuitRequested() {
|
|
return false
|
|
}
|
|
cfg, _, err := a.loadDesktopUserConfigForView()
|
|
if err != nil {
|
|
cfg = config.LoadForEdit(config.UserConfigPath())
|
|
}
|
|
if cfg.DesktopCloseBehavior() == "background" {
|
|
if !a.backgroundCloseHasRestorePath() {
|
|
return false
|
|
}
|
|
a.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))
|
|
a.saveWindowStateSync()
|
|
a.snapshotAllTabs()
|
|
hideForBackground(ctx)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
const backgroundCloseTrayReadyTimeout = 500 * time.Millisecond
|
|
|
|
func (a *App) backgroundCloseHasRestorePath() bool {
|
|
if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
|
|
return backgroundCloseHasRestorePathFor(goruntime.GOOS, false, false)
|
|
}
|
|
if !a.startTray() {
|
|
return false
|
|
}
|
|
return backgroundCloseHasRestorePathFor(goruntime.GOOS, true, a.waitForTrayReady(backgroundCloseTrayReadyTimeout))
|
|
}
|
|
|
|
func (a *App) waitForTrayReady(timeout time.Duration) bool {
|
|
if a.isTrayReady() {
|
|
return true
|
|
}
|
|
ready := a.trayReadySignal()
|
|
if ready == nil {
|
|
return false
|
|
}
|
|
if timeout <= 0 {
|
|
select {
|
|
case <-ready:
|
|
return a.isTrayReady()
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ready:
|
|
return a.isTrayReady()
|
|
case <-timer.C:
|
|
return a.isTrayReady()
|
|
}
|
|
}
|
|
|
|
func (a *App) isTrayReady() bool {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
return a.trayReady
|
|
}
|
|
|
|
func (a *App) trayReadySignal() <-chan struct{} {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.tray == nil {
|
|
return nil
|
|
}
|
|
return a.tray.ready
|
|
}
|
|
|
|
// markTabsRestored closes the tabsRestored gate exactly once. Safe when the
|
|
// channel was never created (tests that drive App without startup).
|
|
func (a *App) markTabsRestored() {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.tabsRestored == nil {
|
|
return
|
|
}
|
|
select {
|
|
case <-a.tabsRestored:
|
|
default:
|
|
close(a.tabsRestored)
|
|
}
|
|
}
|
|
|
|
// tabsRestoredSignal returns a channel closed once tab restore has completed.
|
|
// When startup never armed the gate (tests), it reports already-restored.
|
|
func (a *App) tabsRestoredSignal() <-chan struct{} {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.tabsRestored == nil {
|
|
closed := make(chan struct{})
|
|
close(closed)
|
|
return closed
|
|
}
|
|
return a.tabsRestored
|
|
}
|
|
|
|
func (a *App) showMainWindow() {
|
|
if a.ctx != nil {
|
|
showFromBackground(a.ctx, a.backgroundMaximised.Swap(false))
|
|
a.kickDeferredRebuildRetry()
|
|
}
|
|
}
|
|
|
|
func (a *App) secondInstanceLaunch() {
|
|
a.showMainWindow()
|
|
}
|
|
|
|
func (a *App) quitApp() {
|
|
if a.ctx == nil {
|
|
return
|
|
}
|
|
a.forceQuit.Store(true)
|
|
runtime.Quit(a.ctx)
|
|
}
|
|
|
|
func hideForBackground(ctx context.Context) {
|
|
if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
|
|
runtime.Hide(ctx)
|
|
return
|
|
}
|
|
runtime.WindowHide(ctx)
|
|
}
|
|
|
|
func showFromBackground(ctx context.Context, wasMaximised bool) {
|
|
if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
|
|
runtime.Show(ctx)
|
|
}
|
|
plan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)
|
|
if plan.maximiseBeforeShow {
|
|
runtime.WindowMaximise(ctx)
|
|
}
|
|
runtime.WindowShow(ctx)
|
|
if plan.unminimiseAfterShow {
|
|
runtime.WindowUnminimise(ctx)
|
|
}
|
|
}
|
|
|
|
func backgroundCloseUsesApplicationHide(goos string) bool {
|
|
return goos == "darwin"
|
|
}
|
|
|
|
func backgroundCloseHasRestorePathFor(goos string, trayStarted, trayReady bool) bool {
|
|
return backgroundCloseUsesApplicationHide(goos) || (trayStarted && trayReady)
|
|
}
|
|
|
|
type backgroundRestorePlan struct {
|
|
maximiseBeforeShow bool
|
|
unminimiseAfterShow bool
|
|
}
|
|
|
|
func backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {
|
|
if backgroundRestoreShouldMaximise(goos, wasMaximised) {
|
|
return backgroundRestorePlan{maximiseBeforeShow: true}
|
|
}
|
|
return backgroundRestorePlan{unminimiseAfterShow: true}
|
|
}
|
|
|
|
func backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {
|
|
return wasMaximised && !backgroundCloseUsesApplicationHide(goos)
|
|
}
|
|
|
|
// restoreOrBuildTabs restores the tabs from the last session, or creates a
|
|
// default Global tab on first launch.
|
|
func (a *App) restoreOrBuildTabs() {
|
|
defer a.recoverToPending("restoreOrBuildTabs")
|
|
// Unblock startup work gated on the restore (recovery GC) no matter how
|
|
// this returns — including the recover path above.
|
|
defer a.markTabsRestored()
|
|
// Reap any orphaned codegraph processes from a previous crash or older
|
|
// version that leaked them, so they don't accumulate across restarts.
|
|
a.reapOrphanCodeGraph()
|
|
ctx := a.ctx
|
|
ensureWorkspace()
|
|
|
|
// Run legacy config migration before the first config load so the
|
|
// freshly written config (including the user's default_model) is
|
|
// picked up by Load instead of falling back to built-in defaults.
|
|
_, _ = config.MigrateLegacyIfNeeded()
|
|
f := loadTabsFile()
|
|
_, _ = recoverLegacyProjectSidebarRoots(f)
|
|
_, _ = config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath())
|
|
_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))
|
|
|
|
// Load i18n from the first available config.
|
|
// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),
|
|
// so the user's language choice in desktop settings takes effect.
|
|
startupCfg, cfgErr := config.Load()
|
|
if cfgErr == nil {
|
|
cfg := startupCfg
|
|
lang := cfg.DesktopLanguage()
|
|
if lang == "" {
|
|
lang = cfg.Language
|
|
}
|
|
i18n.DetectLanguage(lang)
|
|
}
|
|
if cfgErr != nil || singleSurfaceLayoutStyle(startupCfg.DesktopLayoutStyle()) {
|
|
f = singleSurfaceTabsFile(f)
|
|
}
|
|
|
|
if len(f.Tabs) > 0 {
|
|
toBuild := make([]*WorkspaceTab, 0, len(f.Tabs))
|
|
for _, entry := range f.Tabs {
|
|
a.mu.Lock()
|
|
id := a.restoredTabIDLocked(entry.ID)
|
|
a.mu.Unlock()
|
|
|
|
var tab *WorkspaceTab
|
|
if entry.Scope == "project" {
|
|
tab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)
|
|
} else {
|
|
tab = a.createTabEntryWithID("global", globalTabWorkspaceRoot(), entry.TopicID, id)
|
|
}
|
|
tab.model = entry.Model
|
|
tab.effort = cloneStringPtr(entry.Effort)
|
|
tab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)
|
|
tab.mode = persistedTabMode(entry.Mode)
|
|
// Validate the persisted goal against the session's goal-state
|
|
// sidecar: a typed /new or /clear rotates the session through the
|
|
// controller without passing App.NewSession/ClearSession, so
|
|
// entry.Goal can be stale. Session rotation writes a stopped
|
|
// goal-state onto the fresh path; reading it here stops a restart
|
|
// from re-seeding the cleared goal into the rotated session. A
|
|
// session without a sidecar keeps the persisted goal (legacy).
|
|
tab.goal = runningTabSessionGoal(strings.TrimSpace(entry.SessionPath), strings.TrimSpace(entry.Goal))
|
|
tab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)
|
|
if tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {
|
|
tab.toolApprovalMode = control.ToolApprovalYolo
|
|
}
|
|
tab.SessionPath = strings.TrimSpace(entry.SessionPath)
|
|
tab.ReadOnly = entry.ReadOnly
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}
|
|
a.mu.Lock()
|
|
a.tabs[tab.ID] = tab
|
|
a.tabOrder = append(a.tabOrder, tab.ID)
|
|
a.mu.Unlock()
|
|
toBuild = append(toBuild, tab)
|
|
}
|
|
a.mu.Lock()
|
|
if _, ok := a.tabs[f.ActiveTab]; ok {
|
|
a.activeTabID = f.ActiveTab
|
|
} else {
|
|
ordered := a.orderedTabIDsLocked()
|
|
if len(ordered) > 0 {
|
|
a.activeTabID = ordered[0]
|
|
}
|
|
}
|
|
a.saveTabsLocked()
|
|
a.mu.Unlock()
|
|
for _, tab := range toBuild {
|
|
a.startTabControllerBuild(tab)
|
|
}
|
|
return
|
|
}
|
|
|
|
// First launch: create a default Global tab.
|
|
tab := a.createTabEntry("global", globalTabWorkspaceRoot(), "")
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}
|
|
tab.TopicTitle = "Global"
|
|
a.mu.Lock()
|
|
a.tabs[tab.ID] = tab
|
|
a.tabOrder = append(a.tabOrder, tab.ID)
|
|
a.activeTabID = tab.ID
|
|
a.mu.Unlock()
|
|
a.startTabControllerBuild(tab)
|
|
}
|
|
|
|
func (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {
|
|
return a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())
|
|
}
|
|
|
|
func desktopNewSessionDefaults() (string, string) {
|
|
cfg := config.LoadForEdit(config.UserConfigPath())
|
|
return strings.TrimSpace(cfg.DefaultModel), normalizeToolApprovalMode(cfg.DesktopDefaultToolApprovalMode())
|
|
}
|
|
|
|
func (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {
|
|
model, toolApprovalMode := desktopNewSessionDefaults()
|
|
return &WorkspaceTab{
|
|
ID: id,
|
|
Scope: scope,
|
|
WorkspaceRoot: workspaceRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),
|
|
model: model,
|
|
tokenMode: boot.TokenModeFull,
|
|
mode: tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo),
|
|
toolApprovalMode: toolApprovalMode,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
}
|
|
|
|
func (a *App) snapshotAllTabs() {
|
|
a.mu.RLock()
|
|
tabs := a.runtimeTabsLocked()
|
|
a.mu.RUnlock()
|
|
for _, t := range tabs {
|
|
if err := a.snapshotTab(t); err != nil {
|
|
slog.Warn("desktop: snapshot all tabs failed", "tab", t.ID, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.
|
|
func (a *App) shutdown(context.Context) {
|
|
a.stopDeferredRebuildRetry()
|
|
a.stopMainThreadWatchdog()
|
|
if a.heartbeat != nil {
|
|
a.heartbeat.Stop()
|
|
}
|
|
a.stopBotRuntime()
|
|
a.stopTray()
|
|
// Save window geometry synchronously from Go so it's persisted even if the
|
|
// frontend's beforeunload promise hasn't resolved yet.
|
|
a.saveWindowStateSync()
|
|
// Close every shared plugin host on exit, even if a tab cleanup panics.
|
|
defer a.closeAllSharedHosts()
|
|
|
|
a.mu.RLock()
|
|
tabs := a.runtimeTabsLocked()
|
|
type shutdownItem struct {
|
|
tab *WorkspaceTab
|
|
ctrl control.SessionAPI
|
|
}
|
|
items := make([]shutdownItem, 0, len(tabs))
|
|
for _, t := range tabs {
|
|
if t.Ctrl != nil {
|
|
items = append(items, shutdownItem{tab: t, ctrl: t.Ctrl})
|
|
}
|
|
}
|
|
a.mu.RUnlock()
|
|
for _, it := range items {
|
|
if err := a.snapshotTab(it.tab); err != nil {
|
|
slog.Warn("desktop: shutdown snapshot failed", "tab", it.tab.ID, "err", err)
|
|
}
|
|
it.ctrl.Close()
|
|
it.tab.releaseSessionLease()
|
|
}
|
|
}
|
|
|
|
// domReady is called (via OnDomReady) after the webview finishes loading its DOM
|
|
// but before the window is shown (StartHidden). It restores the saved window
|
|
// position and size, then calls WindowShow so the user never sees the default
|
|
// size/position flash.
|
|
func (a *App) domReady(_ context.Context) {
|
|
state, ok := loadWindowState()
|
|
if ok {
|
|
// Validate saved position against current screens. Wails v2 doesn't
|
|
// expose per-screen origin (x,y offsets) so we can only do a basic
|
|
// sanity check: ensure the window origin falls within a generous
|
|
// estimate of the screen area. If the user unplugged an external
|
|
// display, negative or out-of-bounds coordinates are caught here.
|
|
valid := state.X >= 0 && state.Y >= 0
|
|
if valid {
|
|
screens, err := runtime.ScreenGetAll(a.ctx)
|
|
if err == nil && len(screens) > 0 {
|
|
maxW, maxH := 0, 0
|
|
for _, sc := range screens {
|
|
if sc.Size.Width > maxW {
|
|
maxW = sc.Size.Width
|
|
}
|
|
if sc.Size.Height > maxH {
|
|
maxH = sc.Size.Height
|
|
}
|
|
}
|
|
if state.X > maxW*2 || state.Y > maxH*2 {
|
|
valid = false
|
|
}
|
|
}
|
|
}
|
|
if valid {
|
|
runtime.WindowSetPosition(a.ctx, state.X, state.Y)
|
|
} else {
|
|
runtime.WindowCenter(a.ctx)
|
|
}
|
|
} else {
|
|
runtime.WindowCenter(a.ctx)
|
|
}
|
|
|
|
if ok && state.Maximised {
|
|
runtime.WindowMaximise(a.ctx)
|
|
}
|
|
|
|
runtime.WindowShow(a.ctx)
|
|
}
|
|
|
|
// --- bound command surface (frontend → controller) ---
|
|
// Each method guards on a nil controller so a pre-startup or failed-build call is
|
|
// a no-op, never a panic.
|
|
|
|
// Submit runs raw user input as a turn; slash commands and @-references are
|
|
// resolved by the controller. Output arrives asynchronously on eventChannel.
|
|
func (a *App) Submit(input string) error {
|
|
return a.SubmitToTab("", input)
|
|
}
|
|
|
|
func (a *App) SubmitToTab(tabID, input string) error {
|
|
return a.submitToTab(tabID, input, false)
|
|
}
|
|
|
|
// beginTabTurn locks the tab's foreground-turn admission gate and reserves the
|
|
// event sink until TurnDone has completed all of its fan-out. The caller must
|
|
// call finishTabTurnStart exactly once after invoking the controller.
|
|
func (a *App) beginTabTurn(tabID string, reclaim bool) (*WorkspaceTab, control.SessionAPI, error) {
|
|
tab, ctrl := a.tabAndCtrlByID(tabID)
|
|
if a.tabIsReadOnly(tab) {
|
|
return nil, nil, readOnlyChannelErr()
|
|
}
|
|
if tab == nil || ctrl == nil {
|
|
return nil, nil, a.workspaceNotReadyErr(tab)
|
|
}
|
|
tab.turnStartMu.Lock()
|
|
if a.tabIsReadOnly(tab) {
|
|
tab.turnStartMu.Unlock()
|
|
return nil, nil, readOnlyChannelErr()
|
|
}
|
|
if reclaim && a.botBridge != nil {
|
|
a.botBridge.reclaimFromDesktop(tab.ID)
|
|
}
|
|
ctrl = a.controllerForTab(tab)
|
|
if ctrl == nil {
|
|
tab.turnStartMu.Unlock()
|
|
return nil, nil, a.workspaceNotReadyErr(tab)
|
|
}
|
|
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
|
tab.turnStartMu.Unlock()
|
|
return nil, nil, err
|
|
}
|
|
ctrl = a.controllerForTab(tab)
|
|
if ctrl == nil {
|
|
tab.turnStartMu.Unlock()
|
|
return nil, nil, a.workspaceNotReadyErr(tab)
|
|
}
|
|
if ctrl.RuntimeStatus().Running || (tab.sink != nil && !tab.sink.tryBeginTurn()) {
|
|
tab.turnStartMu.Unlock()
|
|
return nil, nil, control.ErrTurnRunning
|
|
}
|
|
return tab, ctrl, nil
|
|
}
|
|
|
|
func (a *App) finishTabTurnStart(tab *WorkspaceTab, ctrl control.SessionAPI) bool {
|
|
started := ctrl != nil && ctrl.RuntimeStatus().Running
|
|
if !started && tab != nil && tab.sink != nil {
|
|
tab.sink.cancelTurnStart()
|
|
}
|
|
if tab != nil {
|
|
tab.turnStartMu.Unlock()
|
|
}
|
|
return started
|
|
}
|
|
|
|
// submitToTab is the shared submit body. fromBridge marks submissions driven
|
|
// by the IM takeover bridge; local (frontend) submissions on a taken-over tab
|
|
// reclaim remote control first — typing locally is the grab-back gesture.
|
|
func (a *App) submitToTab(tabID, input string, fromBridge bool) error {
|
|
trimmed := strings.TrimSpace(input)
|
|
if trimmed == "/effort" || strings.HasPrefix(trimmed, "/effort ") {
|
|
tab, _ := a.tabAndCtrlByID(tabID)
|
|
if a.tabIsReadOnly(tab) {
|
|
return readOnlyChannelErr()
|
|
}
|
|
if tab == nil {
|
|
return a.workspaceNotReadyErr(tab)
|
|
}
|
|
tab.turnStartMu.Lock()
|
|
defer tab.turnStartMu.Unlock()
|
|
if !fromBridge && a.botBridge != nil {
|
|
a.botBridge.reclaimFromDesktop(tab.ID)
|
|
}
|
|
a.runEffortCommandForTab(tabID, trimmed)
|
|
return nil
|
|
}
|
|
tab, ctrl, err := a.beginTabTurn(tabID, !fromBridge)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.ensureTabTopicIndexedForUserTurn(tab)
|
|
ctrl.SubmitDisplay(input, input)
|
|
a.finishTabTurnStart(tab, ctrl)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) submitUserTurnToTabWithSink(tabID, input string, forwarder event.Sink) bool {
|
|
tab, ctrl, err := a.beginTabTurn(tabID, false)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
var generation uint64
|
|
if forwarder != nil {
|
|
generation = tab.sink.SetBotSink(forwarder)
|
|
}
|
|
a.ensureTabTopicIndexedForUserTurn(tab)
|
|
ctrl.SubmitUserTurn(input, input)
|
|
started := a.finishTabTurnStart(tab, ctrl)
|
|
if !started && forwarder != nil {
|
|
tab.sink.clearBotSink(generation)
|
|
}
|
|
return started
|
|
}
|
|
|
|
// RunShell executes a shell command directly (bypassing the model) and streams
|
|
// output as events on eventChannel.
|
|
func (a *App) RunShell(command string) error {
|
|
return a.RunShellForTab("", command)
|
|
}
|
|
|
|
func (a *App) RunShellForTab(tabID, command string) error {
|
|
tab, ctrl, err := a.beginTabTurn(tabID, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.ensureTabTopicIndexedForUserTurn(tab)
|
|
ctrl.RunShell(command)
|
|
a.finishTabTurnStart(tab, ctrl)
|
|
return nil
|
|
}
|
|
|
|
// SubmitDisplay runs input as a turn while recording a shorter UI-only display
|
|
// string for the saved desktop transcript. The model still receives input.
|
|
func (a *App) SubmitDisplay(display, input string) error {
|
|
return a.SubmitDisplayToTab("", display, input)
|
|
}
|
|
|
|
func (a *App) SubmitDisplayToTab(tabID, display, input string) error {
|
|
tab, ctrl, err := a.beginTabTurn(tabID, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.ensureTabTopicIndexedForUserTurn(tab)
|
|
ctrl.SubmitDisplay(display, input)
|
|
a.finishTabTurnStart(tab, ctrl)
|
|
return nil
|
|
}
|
|
|
|
// InvocationRequest is the Wails-bound form of a composer invocation entity.
|
|
type InvocationRequest struct {
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
Offset int `json:"offset"`
|
|
}
|
|
|
|
func (a *App) SubmitInvocationsToTab(tabID, display, input string, invocations []InvocationRequest) error {
|
|
tab, ctrl, err := a.beginTabTurn(tabID, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.ensureTabTopicIndexedForUserTurn(tab)
|
|
requests := make([]control.InvocationRequest, 0, len(invocations))
|
|
for _, invocation := range invocations {
|
|
requests = append(requests, control.InvocationRequest{
|
|
Name: invocation.Name, Kind: invocation.Kind, Offset: invocation.Offset,
|
|
})
|
|
}
|
|
ctrl.SubmitInvocationDisplay(display, input, requests)
|
|
a.finishTabTurnStart(tab, ctrl)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) SubmitEditedDisplayToTab(tabID, display, input, original string) error {
|
|
tab, ctrl, err := a.beginTabTurn(tabID, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.ensureTabTopicIndexedForUserTurn(tab)
|
|
ctrl.SubmitEditedDisplay(display, input, original)
|
|
a.finishTabTurnStart(tab, ctrl)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {
|
|
if ctrl == nil {
|
|
return
|
|
}
|
|
ctrl.SetDisplayRecorder(func(content, display string) {
|
|
dir := ctrl.SessionDir()
|
|
if dir == "" {
|
|
dir = config.SessionDir()
|
|
}
|
|
_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)
|
|
})
|
|
}
|
|
|
|
// Cancel aborts the in-flight turn.
|
|
func (a *App) Cancel() {
|
|
a.CancelTab("")
|
|
}
|
|
|
|
func (a *App) CancelTab(tabID string) {
|
|
if ctrl := a.ctrlByTabID(tabID); ctrl != nil {
|
|
ctrl.Cancel()
|
|
}
|
|
}
|
|
|
|
// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.
|
|
func (a *App) Steer(text string) error {
|
|
return a.SteerForTab("", text)
|
|
}
|
|
|
|
// SteerForTab sends mid-turn guidance to a specific tab's agent.
|
|
func (a *App) SteerForTab(tabID, text string) error {
|
|
tab, ctrl := a.tabAndCtrlByID(tabID)
|
|
if a.tabIsReadOnly(tab) {
|
|
return readOnlyChannelErr()
|
|
}
|
|
if ctrl == nil {
|
|
return a.workspaceNotReadyErr(tab)
|
|
}
|
|
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
|
return err
|
|
}
|
|
ctrl = a.controllerForTab(tab)
|
|
if ctrl == nil {
|
|
return a.workspaceNotReadyErr(tab)
|
|
}
|
|
ctrl.Steer(text)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) tabAndCtrlByID(tabID string) (*WorkspaceTab, control.SessionAPI) {
|
|
a.mu.RLock()
|
|
tab := a.tabByIDLocked(tabID)
|
|
if tab == nil {
|
|
a.mu.RUnlock()
|
|
return nil, nil
|
|
}
|
|
ctrl := tab.Ctrl
|
|
retryStartup := ctrl == nil && tab.StartupErrLeaseHeld
|
|
a.mu.RUnlock()
|
|
if retryStartup && a.tryRecoverStartupLeaseHeldTab(tab) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.tabs[tab.ID] != tab {
|
|
return nil, nil
|
|
}
|
|
return tab, tab.Ctrl
|
|
}
|
|
return tab, ctrl
|
|
}
|
|
|
|
// activeTabAndCtrl snapshots the active tab and its controller in one locked
|
|
// read, so callers never do a check-then-use on tab.Ctrl after the lock is
|
|
// released (a rebuild can swap the controller in between).
|
|
func (a *App) activeTabAndCtrl() (*WorkspaceTab, control.SessionAPI) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
tab := a.activeTabLocked()
|
|
if tab == nil {
|
|
return nil, nil
|
|
}
|
|
return tab, tab.Ctrl
|
|
}
|
|
|
|
func (a *App) controllerForTab(tab *WorkspaceTab) control.SessionAPI {
|
|
if tab == nil {
|
|
return nil
|
|
}
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if tab.ID != "" && a.tabs[tab.ID] != tab {
|
|
return nil
|
|
}
|
|
return tab.Ctrl
|
|
}
|
|
|
|
// currentSessionPathFor is the locked form of tab.currentSessionPath: it
|
|
// snapshots Ctrl/SessionPath under a.mu, then queries the controller off-lock.
|
|
// Use it on paths that do not otherwise hold a.mu.
|
|
func (a *App) currentSessionPathFor(tab *WorkspaceTab) string {
|
|
if tab == nil {
|
|
return ""
|
|
}
|
|
a.mu.RLock()
|
|
ctrl := tab.Ctrl
|
|
fallback := strings.TrimSpace(tab.SessionPath)
|
|
a.mu.RUnlock()
|
|
if ctrl != nil {
|
|
if path := strings.TrimSpace(ctrl.SessionPath()); path != "" {
|
|
return path
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// sessionDirForSnapshot mirrors tabSessionDir for callers that hold a
|
|
// tabRuntimeSnapshot instead of reading the live tab.
|
|
func sessionDirForSnapshot(s tabRuntimeSnapshot) string {
|
|
if s.workspaceRoot != "" {
|
|
return desktopSessionDir(s.workspaceRoot)
|
|
}
|
|
if s.ctrl != nil {
|
|
if dir := s.ctrl.SessionDir(); dir != "" {
|
|
return dir
|
|
}
|
|
}
|
|
return desktopSessionDir("")
|
|
}
|
|
|
|
func readOnlyChannelErr() error {
|
|
return fmt.Errorf("channel session is read-only")
|
|
}
|
|
|
|
func (a *App) snapshotTab(tab *WorkspaceTab) error {
|
|
if tab == nil {
|
|
return nil
|
|
}
|
|
a.mu.RLock()
|
|
readOnly := tab.ReadOnly
|
|
ctrl := tab.Ctrl
|
|
a.mu.RUnlock()
|
|
if readOnly || ctrl == nil {
|
|
return nil
|
|
}
|
|
return ctrl.Snapshot()
|
|
}
|
|
|
|
func (a *App) snapshotTabForAction(tab *WorkspaceTab, action string) error {
|
|
if err := a.snapshotTab(tab); err != nil {
|
|
a.reportTabSnapshotError(tab, action, err)
|
|
if strings.TrimSpace(action) == "" {
|
|
return fmt.Errorf("save current session: %w", err)
|
|
}
|
|
return fmt.Errorf("save current session before %s: %w", action, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) reportTabSnapshotError(tab *WorkspaceTab, action string, err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
tabID := ""
|
|
if tab != nil {
|
|
tabID = tab.ID
|
|
}
|
|
slog.Warn("desktop: session snapshot failed", "tab", tabID, "action", action, "err", err)
|
|
if tab == nil || tab.sink == nil {
|
|
return
|
|
}
|
|
// Autosave fires once per turn; on a persistently failing disk that would
|
|
// stream a chat warning after every turn. Rate-limit the user-facing
|
|
// notice per tab (the slog line above always records every failure). Saves
|
|
// triggered by an explicit action are one-shot and always surface.
|
|
if action == "autosave" {
|
|
tab.saveMu.Lock()
|
|
now := time.Now()
|
|
if !tab.lastAutosaveWarnAt.IsZero() && now.Sub(tab.lastAutosaveWarnAt) < autosaveWarnInterval {
|
|
tab.saveMu.Unlock()
|
|
return
|
|
}
|
|
tab.lastAutosaveWarnAt = now
|
|
tab.saveMu.Unlock()
|
|
}
|
|
prefix := "Session autosave failed"
|
|
if strings.TrimSpace(action) != "" && action != "autosave" {
|
|
prefix = "Session save failed before " + action
|
|
}
|
|
tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: prefix + ": " + err.Error()})
|
|
}
|
|
|
|
func (a *App) reconciledSessionPathForTab(tab *WorkspaceTab) string {
|
|
if tab == nil {
|
|
return ""
|
|
}
|
|
path, _ := a.reconcileTabWithPinnedSessionMeta(tab)
|
|
if ctrl := a.controllerForTab(tab); path == "" && ctrl != nil {
|
|
path = ctrl.SessionPath()
|
|
}
|
|
return path
|
|
}
|
|
|
|
func (a *App) ensureTabControllerWorkspace(tab *WorkspaceTab) error {
|
|
if tab == nil {
|
|
return nil
|
|
}
|
|
tab.reconcileMu.Lock()
|
|
defer tab.reconcileMu.Unlock()
|
|
|
|
a.mu.RLock()
|
|
current := a.tabs[tab.ID]
|
|
ctrl := tab.Ctrl
|
|
readOnly := tab.ReadOnly
|
|
a.mu.RUnlock()
|
|
if current != tab || ctrl == nil || readOnly {
|
|
return nil
|
|
}
|
|
if controllerHasActiveRuntimeWork(ctrl) {
|
|
return nil
|
|
}
|
|
path, hasBinding := a.reconcileTabWithPinnedSessionMeta(tab)
|
|
desiredRoot := strings.TrimSpace(tab.WorkspaceRoot)
|
|
ctrlRoot, rootOK := safeControllerWorkspaceRoot(ctrl)
|
|
ctrlDir, dirOK := safeControllerSessionDir(ctrl)
|
|
if !rootOK || !dirOK {
|
|
return nil
|
|
}
|
|
if !hasBinding {
|
|
if desiredRoot == "" || strings.TrimSpace(ctrlRoot) == "" || sameDesktopPath(ctrlRoot, desiredRoot) {
|
|
return nil
|
|
}
|
|
}
|
|
desiredDir := tabSessionDir(tab)
|
|
rootMatches := desiredRoot == "" || sameDesktopPath(ctrlRoot, desiredRoot)
|
|
dirMatches := desiredDir == "" || sameDesktopPath(ctrlDir, desiredDir)
|
|
if !dirMatches && path != "" {
|
|
if validPath, _, err := validateSessionPath(ctrlDir, path); err == nil && sessionRuntimeKey(validPath) == sessionRuntimeKey(path) {
|
|
dirMatches = true
|
|
}
|
|
}
|
|
if strings.TrimSpace(ctrlRoot) == "" && dirMatches {
|
|
rootMatches = true
|
|
}
|
|
if tab.Scope == "global" {
|
|
if strings.TrimSpace(ctrlRoot) == "" {
|
|
rootMatches = true
|
|
}
|
|
if sameDesktopPath(ctrlDir, config.SessionDir()) || sameDesktopPath(ctrlDir, desktopSessionDir(globalWorkspaceRoot())) {
|
|
dirMatches = true
|
|
}
|
|
}
|
|
sessionMatches := path == "" || sessionRuntimeKey(ctrl.SessionPath()) == sessionRuntimeKey(path)
|
|
if rootMatches && dirMatches && sessionMatches {
|
|
return nil
|
|
}
|
|
if err := ctrl.Snapshot(); err != nil {
|
|
return err
|
|
}
|
|
ctrl.Close()
|
|
|
|
a.mu.Lock()
|
|
var hostKey string
|
|
if current := a.tabs[tab.ID]; current == tab {
|
|
tab.Ctrl = nil
|
|
tab.Ready = false
|
|
clearTabStartupError(tab)
|
|
tab.ActivityStatus = ""
|
|
if tab.sink == nil {
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
|
|
}
|
|
hostKey = takeTabSharedHostKey(tab)
|
|
a.saveTabsLocked()
|
|
}
|
|
a.mu.Unlock()
|
|
if hostKey != "" {
|
|
a.releaseSharedHost(hostKey)
|
|
}
|
|
|
|
a.buildTabController(tab)
|
|
if tab.Ctrl == nil {
|
|
if tab.StartupErr != "" {
|
|
return fmt.Errorf("workspace failed to restart with corrected root: %s", tab.StartupErr)
|
|
}
|
|
return fmt.Errorf("workspace failed to restart with corrected root")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func safeControllerWorkspaceRoot(ctrl control.SessionAPI) (root string, ok bool) {
|
|
if ctrl == nil {
|
|
return "", false
|
|
}
|
|
defer func() {
|
|
if recover() != nil {
|
|
root = ""
|
|
ok = false
|
|
}
|
|
}()
|
|
return ctrl.WorkspaceRoot(), true
|
|
}
|
|
|
|
func safeControllerSessionDir(ctrl control.SessionAPI) (dir string, ok bool) {
|
|
if ctrl == nil {
|
|
return "", false
|
|
}
|
|
defer func() {
|
|
if recover() != nil {
|
|
dir = ""
|
|
ok = false
|
|
}
|
|
}()
|
|
return ctrl.SessionDir(), true
|
|
}
|
|
|
|
// Approve answers a pending approval_request by ID: allow runs the call, session
|
|
// also remembers the grant for the rest of the session.
|
|
func (a *App) Approve(id string, allow, session, persist bool) {
|
|
ctrl := a.ctrlByTabID("")
|
|
if ctrl != nil {
|
|
ctrl.Approve(id, allow, session, persist)
|
|
}
|
|
}
|
|
|
|
// ApproveTab is like Approve but scoped to a specific tab.
|
|
func (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {
|
|
ctrl := a.ctrlByTabID(tabID)
|
|
if ctrl != nil {
|
|
ctrl.Approve(id, allow, session, persist)
|
|
}
|
|
}
|
|
|
|
// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask
|
|
// prompt that is currently blocking its run loop. The frontend calls this once
|
|
// its event subscription is live (on load/reconnect) so a session that was
|
|
// already awaiting confirmation rebuilds its modal instead of showing a
|
|
// "waiting" status with no way to answer — and no way to stop.
|
|
func (a *App) ReplayPendingPrompts() {
|
|
a.mu.RLock()
|
|
tabs := a.runtimeTabsLocked()
|
|
ctrls := make([]control.SessionAPI, 0, len(tabs))
|
|
for _, t := range tabs {
|
|
if t.Ctrl != nil {
|
|
ctrls = append(ctrls, t.Ctrl)
|
|
}
|
|
}
|
|
a.mu.RUnlock()
|
|
for _, ctrl := range ctrls {
|
|
ctrl.ReplayPendingPrompts()
|
|
}
|
|
}
|
|
|
|
// SetPlanMode toggles the read-only plan axis while preserving the current
|
|
// tool-auto-approval axis.
|
|
func (a *App) SetPlanMode(on bool) {
|
|
a.setPlanModeForTab("", on)
|
|
}
|
|
|
|
func (a *App) setPlanModeForTab(tabID string, on bool) {
|
|
current := a.currentModeForTab(tabID)
|
|
a.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))
|
|
}
|
|
|
|
// SetMode applies a composer gating mode ("plan" | "yolo" | "plan-yolo" |
|
|
// anything else =
|
|
// normal) in one call, so a turn submitted right after the switch can't race a
|
|
// half-applied plan/tool-auto-approval pair.
|
|
func (a *App) SetMode(mode string) {
|
|
a.SetModeForTab("", mode)
|
|
}
|
|
|
|
func (a *App) SetModeForTab(tabID, mode string) {
|
|
normalized := normalizeTabMode(mode)
|
|
a.mu.Lock()
|
|
tab := a.tabByIDLocked(tabID)
|
|
if tab == nil {
|
|
a.mu.Unlock()
|
|
return
|
|
}
|
|
tab.mode = normalized
|
|
tab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)
|
|
if tabModeHasAutoApproveTools(normalized) {
|
|
tab.toolApprovalMode = control.ToolApprovalYolo
|
|
} else if tab.toolApprovalMode == control.ToolApprovalYolo {
|
|
tab.toolApprovalMode = control.ToolApprovalAsk
|
|
}
|
|
ctrl := tab.Ctrl
|
|
approvalMode := tab.toolApprovalMode
|
|
tabIDForSave := tab.ID
|
|
a.mu.Unlock()
|
|
applyTabModeToController(ctrl, normalized)
|
|
applyTabToolApprovalModeToController(ctrl, approvalMode)
|
|
a.mu.Lock()
|
|
if a.tabs[tabIDForSave] == tab {
|
|
a.saveTabsLocked()
|
|
}
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func applyTabModeToController(ctrl control.SessionAPI, mode string) {
|
|
if ctrl == nil {
|
|
return
|
|
}
|
|
switch normalizeTabMode(mode) {
|
|
case "plan":
|
|
ctrl.SetMode(true, false)
|
|
case "yolo":
|
|
ctrl.SetMode(false, true)
|
|
case "plan-yolo":
|
|
ctrl.SetMode(true, true)
|
|
default:
|
|
ctrl.SetMode(false, false)
|
|
}
|
|
}
|
|
|
|
func applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {
|
|
if ctrl == nil {
|
|
return
|
|
}
|
|
ctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))
|
|
}
|
|
|
|
func (a *App) currentModeForTab(tabID string) string {
|
|
a.mu.RLock()
|
|
tab := a.tabByIDLocked(tabID)
|
|
mode := "normal"
|
|
if tab != nil {
|
|
mode = currentTabMode(tab)
|
|
}
|
|
a.mu.RUnlock()
|
|
return mode
|
|
}
|
|
|
|
func normalizeCollaborationMode(mode string) string {
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case "plan":
|
|
return "plan"
|
|
case "goal":
|
|
return "goal"
|
|
default:
|
|
return "normal"
|
|
}
|
|
}
|
|
|
|
func (a *App) SetCollaborationMode(mode string) {
|
|
a.SetCollaborationModeForTab("", mode)
|
|
}
|
|
|
|
func (a *App) SetCollaborationModeForTab(tabID, mode string) {
|
|
mode = normalizeCollaborationMode(mode)
|
|
a.mu.Lock()
|
|
tab := a.tabByIDLocked(tabID)
|
|
if tab == nil {
|
|
a.mu.Unlock()
|
|
return
|
|
}
|
|
approvalMode := currentTabToolApprovalMode(tab)
|
|
switch mode {
|
|
case "plan":
|
|
tab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)
|
|
tab.goal = ""
|
|
case "goal":
|
|
tab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)
|
|
default:
|
|
tab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)
|
|
tab.goal = ""
|
|
}
|
|
ctrl := tab.Ctrl
|
|
goal := tab.goal
|
|
plan := tabModeHasPlan(tab.mode)
|
|
tabIDForSave := tab.ID
|
|
a.mu.Unlock()
|
|
if ctrl != nil {
|
|
ctrl.SetPlanMode(plan)
|
|
ctrl.SetGoal(goal)
|
|
}
|
|
a.mu.Lock()
|
|
if a.tabs[tabIDForSave] == tab {
|
|
a.saveTabsLocked()
|
|
}
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
// QuestionAnswer is the frontend's reply to one question in an ask_request.
|
|
type QuestionAnswer struct {
|
|
QuestionID string `json:"questionId"`
|
|
Selected []string `json:"selected"`
|
|
}
|
|
|
|
// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the
|
|
// user's selections per question.
|
|
func (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {
|
|
a.AnswerQuestionForTab("", id, answers)
|
|
}
|
|
|
|
func (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {
|
|
ctrl := a.ctrlByTabID(tabID)
|
|
if ctrl == nil {
|
|
return
|
|
}
|
|
out := make([]event.AskAnswer, len(answers))
|
|
for i, an := range answers {
|
|
out[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}
|
|
}
|
|
ctrl.AnswerQuestion(id, out)
|
|
}
|
|
|
|
// Compact runs a plain compaction pass (the "compact now" button). Focus-guided
|
|
// compaction goes through Submit("/compact <focus>") instead.
|
|
func (a *App) Compact() error {
|
|
return a.CompactForTab("")
|
|
}
|
|
|
|
// CompactForTab compacts the requested tab without depending on which tab is
|
|
// focused when the asynchronous frontend call reaches the backend.
|
|
func (a *App) CompactForTab(tabID string) error {
|
|
tab, ctrl := a.tabAndCtrlByID(tabID)
|
|
if a.tabIsReadOnly(tab) {
|
|
return readOnlyChannelErr()
|
|
}
|
|
if ctrl == nil {
|
|
return nil
|
|
}
|
|
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
|
return err
|
|
}
|
|
ctrl = a.controllerForTab(tab)
|
|
if ctrl == nil {
|
|
return nil
|
|
}
|
|
return ctrl.Compact(a.ctx, "")
|
|
}
|
|
|
|
// workspaceNotReadyErr names why a session action arrived before the tab's
|
|
// controller existed: still starting, or failed to start. Silently returning
|
|
// nil here swallowed the click with no feedback (#3938).
|
|
//
|
|
// This is the bound-method form: StartupErr is written under a.mu by the
|
|
// build goroutine while Submit-family calls race it, so read it under the
|
|
// lock. Callers must not hold a.mu.
|
|
func (a *App) workspaceNotReadyErr(tab *WorkspaceTab) error {
|
|
startupErr := ""
|
|
if tab != nil {
|
|
a.mu.RLock()
|
|
startupErr = tab.StartupErr
|
|
a.mu.RUnlock()
|
|
}
|
|
if strings.TrimSpace(startupErr) != "" {
|
|
return fmt.Errorf("workspace failed to start: %s", startupErr)
|
|
}
|
|
return fmt.Errorf("workspace is still starting")
|
|
}
|
|
|
|
// tabIsReadOnly reads tab.ReadOnly under a.mu; setTabReadOnly can flip it
|
|
// concurrently with Submit-family bound calls. Callers must not hold a.mu.
|
|
func (a *App) tabIsReadOnly(tab *WorkspaceTab) bool {
|
|
if tab == nil {
|
|
return false
|
|
}
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
return tab.ReadOnly
|
|
}
|
|
|
|
// NewSession snapshots the current conversation and rotates to a fresh one.
|
|
func (a *App) NewSession() error {
|
|
return a.NewSessionForTab("")
|
|
}
|
|
|
|
// NewSessionForTab snapshots and rotates the requested tab regardless of which
|
|
// tab becomes active while the Wails call is in flight.
|
|
func (a *App) NewSessionForTab(tabID string) error {
|
|
tab, ctrl := a.tabAndCtrlByID(tabID)
|
|
if a.tabIsReadOnly(tab) {
|
|
return readOnlyChannelErr()
|
|
}
|
|
if ctrl == nil {
|
|
return a.workspaceNotReadyErr(tab)
|
|
}
|
|
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
|
return err
|
|
}
|
|
ctrl = a.controllerForTab(tab)
|
|
if ctrl == nil {
|
|
return a.workspaceNotReadyErr(tab)
|
|
}
|
|
// Tab is already blank — just persist and skip the new-session dance.
|
|
if !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {
|
|
a.persistTabSessionPath(tab, ctrl.SessionPath())
|
|
return nil
|
|
}
|
|
|
|
if err := ctrl.NewSession(); err != nil {
|
|
return err
|
|
}
|
|
// The rotated session starts with zero spend: without this reset the tab
|
|
// telemetry keeps the previous session's totals and the status bar 会话费用
|
|
// silently turns into an all-sessions running total (#5850).
|
|
tab.resetTelemetry(ctrl.SessionPath())
|
|
// Mirror the controller: NewSession cleared the active goal, and the tab's
|
|
// persisted copy must follow — otherwise the next rebuild/restart would
|
|
// re-seed the old goal into the fresh session via SetGoal(tab.goal).
|
|
a.clearTabGoal(tab)
|
|
a.assignFreshSessionTopic(tab)
|
|
a.persistTabSessionPath(tab, ctrl.SessionPath())
|
|
a.invalidatePromptHistoryCache()
|
|
a.emitProjectTreeChanged()
|
|
return nil
|
|
}
|
|
|
|
func (a *App) assignFreshSessionTopic(tab *WorkspaceTab) {
|
|
if tab == nil {
|
|
return
|
|
}
|
|
topicID := newTopicID()
|
|
a.mu.Lock()
|
|
scope := tab.Scope
|
|
workspaceRoot := tab.WorkspaceRoot
|
|
tab.TopicID = topicID
|
|
tab.TopicTitle = defaultTopicTitle
|
|
if current := a.tabs[tab.ID]; current == tab {
|
|
a.saveTabsLocked()
|
|
}
|
|
a.mu.Unlock()
|
|
if strings.TrimSpace(scope) == "global" {
|
|
workspaceRoot = ""
|
|
} else {
|
|
workspaceRoot = normalizeProjectRoot(workspaceRoot)
|
|
}
|
|
// NewSession already rotated the runtime to a fresh session. If the sidebar
|
|
// topic index repair fails here, keep the session usable and let persisted
|
|
// session metadata repair the topic index later instead of surfacing a false
|
|
// "new session failed" error to the frontend.
|
|
_ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto)
|
|
_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
|
|
}
|
|
|
|
func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) {
|
|
if tab == nil {
|
|
return
|
|
}
|
|
topicID := newTopicID()
|
|
a.mu.Lock()
|
|
if strings.TrimSpace(tab.TopicID) != "" {
|
|
a.mu.Unlock()
|
|
return
|
|
}
|
|
scope := tab.Scope
|
|
workspaceRoot := tab.WorkspaceRoot
|
|
tab.TopicID = topicID
|
|
tab.TopicTitle = defaultTopicTitle
|
|
if current := a.tabs[tab.ID]; current == tab {
|
|
a.saveTabsLocked()
|
|
}
|
|
a.mu.Unlock()
|
|
if strings.TrimSpace(scope) == "global" {
|
|
scope = "global"
|
|
workspaceRoot = ""
|
|
} else {
|
|
scope = "project"
|
|
workspaceRoot = normalizeProjectRoot(workspaceRoot)
|
|
}
|
|
|
|
_ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto)
|
|
_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
|
|
a.persistTabSessionPath(tab, a.currentSessionPathFor(tab))
|
|
a.emitProjectTreeChanged()
|
|
}
|
|
|
|
func messagesHaveConversationContent(messages []provider.Message) bool {
|
|
for _, msg := range messages {
|
|
if msg.Role != provider.RoleSystem {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ClearSession discards the current conversation and rotates to a fresh unsaved one.
|
|
func (a *App) ClearSession() error {
|
|
return a.ClearSessionForTab("")
|
|
}
|
|
|
|
// ClearSessionForTab clears the requested tab regardless of later focus changes.
|
|
func (a *App) ClearSessionForTab(tabID string) error {
|
|
tab, ctrl := a.tabAndCtrlByID(tabID)
|
|
if a.tabIsReadOnly(tab) {
|
|
return readOnlyChannelErr()
|
|
}
|
|
if ctrl == nil {
|
|
return a.workspaceNotReadyErr(tab)
|
|
}
|
|
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
|
return err
|
|
}
|
|
ctrl = a.controllerForTab(tab)
|
|
if ctrl == nil {
|
|
return a.workspaceNotReadyErr(tab)
|
|
}
|
|
if controllerHasActiveRuntimeWork(ctrl) {
|
|
return a.clearActiveSessionRuntime(tab, ctrl)
|
|
}
|
|
if err := ctrl.ClearSession(); err != nil {
|
|
return err
|
|
}
|
|
if err := tab.ensureSessionLease(ctrl.SessionPath()); err != nil {
|
|
// Wails bridge return: a raw lease error would carry the session path
|
|
// and holder id across to the frontend.
|
|
return userFacingSessionLeaseError("", err)
|
|
}
|
|
tab.resetTelemetry(ctrl.SessionPath())
|
|
// Mirror the controller: ClearSession cleared the active goal.
|
|
a.clearTabGoal(tab)
|
|
a.persistTabSessionPath(tab, ctrl.SessionPath())
|
|
a.invalidatePromptHistoryCache()
|
|
return nil
|
|
}
|
|
|
|
// clearTabGoal drops the tab's persisted goal copy so rebuilds and restarts
|
|
// cannot re-seed a goal the controller has already cleared on session rotation.
|
|
func (a *App) clearTabGoal(tab *WorkspaceTab) {
|
|
if tab == nil {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
tab.goal = ""
|
|
if current := a.tabs[tab.ID]; current == tab {
|
|
a.saveTabsLocked()
|
|
}
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {
|
|
if tab == nil || oldCtrl == nil {
|
|
return fmt.Errorf("workspace is still starting")
|
|
}
|
|
// This is a build+swap of the tab's controller; serialize with the other
|
|
// rebuild paths (see runtimeRebuildMu) so a concurrent model/effort/settings
|
|
// rebuild cannot interleave a second swap. Lock order:
|
|
// runtimeRebuildMu → sessionRemovalMu (no path acquires them in reverse).
|
|
a.runtimeRebuildMu.Lock()
|
|
defer a.runtimeRebuildMu.Unlock()
|
|
// This path destroys the old session's files (removeDesktopSessionArtifacts);
|
|
// serialize with DeleteSession/TrashTopic/workspace removal so they never
|
|
// trash or restore the same files mid-clear.
|
|
a.sessionRemovalMu.Lock()
|
|
defer a.sessionRemovalMu.Unlock()
|
|
|
|
a.reconciledSessionPathForTab(tab)
|
|
oldPath := oldCtrl.SessionPath()
|
|
// Snapshot the tab profile under a.mu: bound methods write these fields
|
|
// under the lock while this rebuild runs off-lock.
|
|
snap := a.tabRuntimeSnapshot(tab)
|
|
oldSink := snap.sink
|
|
if oldSink != nil {
|
|
// Rebind under the runtime key, matching the id cloneDetachedRuntimeTab
|
|
// derives — a raw path here would hash to a different detached id on
|
|
// Windows where keys are case-folded.
|
|
oldSink.setBinding(detachedRuntimeTabID(sessionRuntimeKey(oldPath)), nil)
|
|
oldSink.clearContext()
|
|
}
|
|
if oldCtrl.RuntimeStatus().Cancellable {
|
|
oldCtrl.Cancel()
|
|
if err := waitControllerStopped(oldCtrl); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
destroy := oldCtrl.BeginDestroySession(oldPath)
|
|
destroys := []control.SessionDestroyHandle{destroy}
|
|
teardownTimedOut := waitDestroyHandles(destroys)
|
|
if teardownTimedOut {
|
|
if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
newSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
|
|
sharedHost := a.lookupSharedHost(snap.sharedHostKey)
|
|
newCtrl, err := boot.Build(a.bootContext(), boot.Options{
|
|
Model: snap.model,
|
|
RequireKey: false,
|
|
Sink: newSink,
|
|
WorkspaceRoot: snap.workspaceRoot,
|
|
SessionDir: sessionDirForSnapshot(snap),
|
|
EffortOverride: cloneStringPtr(snap.effort),
|
|
TokenMode: snap.currentTokenMode(),
|
|
SharedHost: sharedHost,
|
|
CleanupPendingReconciler: reconcileDesktopCleanupPending,
|
|
SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
|
|
OnSessionRecovered: a.handleTabSessionRecovered(tab),
|
|
})
|
|
if err != nil {
|
|
if teardownTimedOut {
|
|
// The old session was already marked cleanup-pending, so finish the
|
|
// destroy cleanup instead of re-exposing a runtime in teardown.
|
|
go delayedDesktopSessionCleanup(oldPath, destroys)
|
|
} else {
|
|
finishDestroyHandles(destroys)
|
|
}
|
|
if oldSink != nil {
|
|
oldSink.setBinding(tab.ID, nil)
|
|
oldSink.setContext(a.ctx)
|
|
}
|
|
return err
|
|
}
|
|
if teardownTimedOut {
|
|
go delayedDesktopSessionCleanup(oldPath, destroys)
|
|
} else {
|
|
if err := removeDesktopSessionArtifacts(oldPath); err != nil {
|
|
finishDestroyHandles(destroys)
|
|
newCtrl.Close()
|
|
return err
|
|
}
|
|
finishDestroyHandles(destroys)
|
|
}
|
|
a.bindControllerDisplayRecorder(newCtrl)
|
|
newCtrl.EnableInteractiveApproval()
|
|
applyTabModeToController(newCtrl, snap.mode)
|
|
applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
|
|
// Clearing the session clears the active goal too (same contract as
|
|
// Controller.ClearSession): the snapshot's goal belongs to the destroyed
|
|
// conversation and must not seed the replacement.
|
|
path := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())
|
|
if err := tab.ensureSessionLease(path); err != nil {
|
|
newCtrl.Close()
|
|
// Surfaces through ClearSession's Wails return; keep the holder's
|
|
// path/pid/writer id out of it.
|
|
return userFacingSessionLeaseError("", err)
|
|
}
|
|
newCtrl.SetSessionPath(path)
|
|
|
|
a.mu.Lock()
|
|
if current := a.tabs[tab.ID]; current != tab {
|
|
a.mu.Unlock()
|
|
// The old session is already destroyed either way; release what this
|
|
// clear acquired for the replaced tab (fresh controller and its
|
|
// lease) so neither leaks, and still finish the old runtime teardown.
|
|
newCtrl.Close()
|
|
tab.releaseSessionLease()
|
|
oldCtrl.CloseAfterDestroy()
|
|
a.emitProjectTreeChanged()
|
|
return fmt.Errorf("tab %q changed while clearing the session", tab.ID)
|
|
}
|
|
tab.Ctrl = newCtrl
|
|
tab.sink = newSink
|
|
tab.SessionPath = path
|
|
tab.Label = newCtrl.Label()
|
|
tab.Ready = true
|
|
clearTabStartupError(tab)
|
|
tab.goal = ""
|
|
// Supersede any in-flight startup build: the session it was resuming
|
|
// was just destroyed, and finishing later would pass the generation
|
|
// check and overwrite this controller.
|
|
a.supersedeTabBuildLocked(tab)
|
|
a.saveTabsLocked()
|
|
a.mu.Unlock()
|
|
// Same contract as ClearSession's non-running path: the replacement
|
|
// session starts with zero spend.
|
|
tab.resetTelemetry(path)
|
|
oldCtrl.CloseAfterDestroy()
|
|
a.emitProjectTreeChanged()
|
|
a.notifyTabRuntimeRebuilt(tab)
|
|
return nil
|
|
}
|
|
|
|
func removeDesktopSessionArtifacts(path string) error {
|
|
if strings.TrimSpace(path) == "" {
|
|
return nil
|
|
}
|
|
guard, err := acquireSessionRemovalGuard(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer guard.Release()
|
|
if err := invalidateTopicDirMarkers(filepath.Dir(path)); err != nil {
|
|
return err
|
|
}
|
|
defer invalidateTopicSessionIndexForPath(path)
|
|
for _, p := range sessionOwnedArtifactPaths(path) {
|
|
if strings.TrimSpace(p) == "" {
|
|
continue
|
|
}
|
|
if err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
}
|
|
if err := guard.RemoveSidecarsAndRelease(); err != nil {
|
|
return err
|
|
}
|
|
if err := removeSessionDisplay(filepath.Dir(path), path); err != nil {
|
|
return err
|
|
}
|
|
if err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {
|
|
return err
|
|
}
|
|
return agent.ClearCleanupPending(path)
|
|
}
|
|
|
|
// CheckpointMeta summarises one rewind point (a user turn) for the desktop.
|
|
type CheckpointMeta struct {
|
|
Turn int `json:"turn"`
|
|
Prompt string `json:"prompt"`
|
|
Files []string `json:"files"` // stable preview of cumulative files RestoreCode would affect from this turn
|
|
FileCount int `json:"fileCount"` // full cumulative file count, including entries omitted from Files
|
|
FilesTruncated bool `json:"filesTruncated,omitempty"`
|
|
TurnFileCount int `json:"turnFileCount"` // files changed during this turn only
|
|
Time int64 `json:"time"` // unix milliseconds
|
|
CanCode bool `json:"canCode"`
|
|
CanConversation bool `json:"canConversation"`
|
|
}
|
|
|
|
const checkpointFilePreviewLimit = 60
|
|
|
|
// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.
|
|
func (a *App) Checkpoints() []CheckpointMeta {
|
|
return a.CheckpointsForTab("")
|
|
}
|
|
|
|
func (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {
|
|
a.mu.RLock()
|
|
var ctrl control.SessionAPI
|
|
if tab := a.tabByIDLocked(tabID); tab != nil {
|
|
ctrl = tab.Ctrl
|
|
}
|
|
a.mu.RUnlock()
|
|
if ctrl == nil {
|
|
return []CheckpointMeta{}
|
|
}
|
|
metas := ctrl.Checkpoints()
|
|
out := make([]CheckpointMeta, 0, len(metas))
|
|
for _, m := range metas {
|
|
out = append(out, CheckpointMeta{
|
|
Turn: m.Turn,
|
|
Prompt: m.Prompt,
|
|
Files: m.Paths,
|
|
TurnFileCount: len(m.Paths),
|
|
Time: m.Time.UnixMilli(),
|
|
CanCode: len(m.Paths) > 0,
|
|
CanConversation: ctrl.CheckpointHasBoundary(m.Turn),
|
|
})
|
|
}
|
|
// RestoreCode(turn) reverts every file touched in this turn or any later one, so
|
|
// a turn can rewind code even when it changed no files itself — as long as a
|
|
// later turn did. Propagate CanCode backwards over the oldest-first list.
|
|
// Also propagate the cumulative unique file count so the UI shows how many
|
|
// files RestoreCode would actually affect from this turn.
|
|
hasCodeAfter := false
|
|
codeFileSet := make(map[string]bool, len(metas)*2)
|
|
codeFilePreview := []string{}
|
|
for i := len(out) - 1; i >= 0; i-- {
|
|
if len(out[i].Files) > 0 {
|
|
hasCodeAfter = true
|
|
}
|
|
for _, f := range out[i].Files {
|
|
if codeFileSet[f] {
|
|
continue
|
|
}
|
|
codeFileSet[f] = true
|
|
codeFilePreview = insertCheckpointFilePreview(codeFilePreview, f, checkpointFilePreviewLimit)
|
|
}
|
|
out[i].CanCode = hasCodeAfter
|
|
out[i].FileCount = len(codeFileSet)
|
|
out[i].Files = append([]string{}, codeFilePreview...)
|
|
out[i].FilesTruncated = out[i].FileCount > len(out[i].Files)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func insertCheckpointFilePreview(preview []string, path string, limit int) []string {
|
|
if limit <= 0 || path == "" {
|
|
return preview
|
|
}
|
|
idx := sort.SearchStrings(preview, path)
|
|
if idx < len(preview) && preview[idx] == path {
|
|
return preview
|
|
}
|
|
if len(preview) < limit {
|
|
preview = append(preview, "")
|
|
copy(preview[idx+1:], preview[idx:])
|
|
preview[idx] = path
|
|
return preview
|
|
}
|
|
if idx >= limit {
|
|
return preview
|
|
}
|
|
copy(preview[idx+1:], preview[idx:limit-1])
|
|
preview[idx] = path
|
|
return preview
|
|
}
|
|
|
|
// ToolResultForTab returns the full arguments and output for one tool call that
|
|
// were elided from the frontend's in-memory items[] for memory efficiency. The
|
|
// caller (frontend ToolCard) loads this on demand when the user expands a
|
|
// collapsed tool card. Returns nil when the tool ID is not found.
|
|
func (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {
|
|
a.mu.RLock()
|
|
var ctrl control.SessionAPI
|
|
if tab := a.tabByIDLocked(tabID); tab != nil {
|
|
ctrl = tab.Ctrl
|
|
}
|
|
a.mu.RUnlock()
|
|
if ctrl == nil {
|
|
return nil
|
|
}
|
|
return ctrl.ToolResult(toolID)
|
|
}
|
|
|
|
// Rewind restores the session to the start of turn. scope is "code",
|
|
// "conversation", or "both" (anything else is treated as "both"). The frontend
|
|
// re-reads History after this resolves.
|
|
func (a *App) Rewind(turn int, scope string) error {
|
|
return a.RewindForTab("", turn, scope)
|
|
}
|
|
|
|
// RewindForTab rewinds the requested tab instead of resolving the active tab at
|
|
// execution time, which may have changed after frontend confirmation.
|
|
func (a *App) RewindForTab(tabID string, turn int, scope string) error {
|
|
tab, ctrl := a.tabAndCtrlByID(tabID)
|
|
if a.tabIsReadOnly(tab) {
|
|
return readOnlyChannelErr()
|
|
}
|
|
if ctrl == nil {
|
|
return nil
|
|
}
|
|
s := control.RewindBoth
|
|
switch scope {
|
|
case "code":
|
|
s = control.RewindCode
|
|
case "conversation":
|
|
s = control.RewindConversation
|
|
}
|
|
return ctrl.Rewind(turn, s)
|
|
}
|
|
|
|
// Fork branches the conversation at the start of turn into a new session tab
|
|
// (preserving the current tab), keeping code intact, and switches to the new tab.
|
|
func (a *App) Fork(turn int) (TabMeta, error) {
|
|
return a.ForkForTab("", turn)
|
|
}
|
|
|
|
// ForkForTab forks the requested source tab even if focus changes before the
|
|
// backend begins processing the request. The fork becomes active only while the
|
|
// source tab still owns focus, so a later tab selection remains authoritative.
|
|
func (a *App) ForkForTab(tabID string, turn int) (TabMeta, error) {
|
|
sourceTab, ctrl := a.tabAndCtrlByID(tabID)
|
|
if sourceTab == nil || ctrl == nil {
|
|
return TabMeta{}, nil
|
|
}
|
|
if a.tabIsReadOnly(sourceTab) {
|
|
return TabMeta{}, readOnlyChannelErr()
|
|
}
|
|
|
|
if err := a.ensureTabControllerWorkspace(sourceTab); err != nil {
|
|
return TabMeta{}, err
|
|
}
|
|
a.mu.RLock()
|
|
if a.tabs[sourceTab.ID] != sourceTab || sourceTab.Ctrl == nil {
|
|
a.mu.RUnlock()
|
|
return TabMeta{}, nil
|
|
}
|
|
ctrl = sourceTab.Ctrl
|
|
scope := sourceTab.Scope
|
|
workspaceRoot := sourceTab.WorkspaceRoot
|
|
sourceTitle := sourceTab.TopicTitle
|
|
model := sourceTab.model
|
|
effort := cloneStringPtr(sourceTab.effort)
|
|
mode := currentTabMode(sourceTab)
|
|
toolApprovalMode := currentTabToolApprovalMode(sourceTab)
|
|
disabledMCP := cloneServerViewMap(sourceTab.disabledMCP)
|
|
mcpOrder := append([]string(nil), sourceTab.mcpOrder...)
|
|
a.mu.RUnlock()
|
|
|
|
newPath, err := ctrl.ForkSession(turn, "")
|
|
if err != nil {
|
|
return TabMeta{}, err
|
|
}
|
|
topicID := newTopicID()
|
|
topicTitle := forkTopicTitle(sourceTitle)
|
|
titleRoot := workspaceRoot
|
|
if scope == "global" {
|
|
titleRoot = ""
|
|
}
|
|
if err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {
|
|
return TabMeta{}, err
|
|
}
|
|
m, _ := agent.EnsureBranchMeta(newPath)
|
|
m.Scope = scope
|
|
m.WorkspaceRoot = workspaceRoot
|
|
m.TopicID = topicID
|
|
m.TopicTitle = topicTitle
|
|
if err := agent.SaveBranchMeta(newPath, m); err != nil {
|
|
return TabMeta{}, err
|
|
}
|
|
invalidateTopicSessionIndexForPath(newPath)
|
|
|
|
a.mu.Lock()
|
|
newTabID := a.newUniqueTabIDLocked()
|
|
tab := &WorkspaceTab{
|
|
ID: newTabID,
|
|
Scope: scope,
|
|
WorkspaceRoot: workspaceRoot,
|
|
TopicID: topicID,
|
|
TopicTitle: topicTitle,
|
|
SessionPath: newPath,
|
|
model: model,
|
|
effort: effort,
|
|
mode: mode,
|
|
toolApprovalMode: toolApprovalMode,
|
|
disabledMCP: disabledMCP,
|
|
mcpOrder: mcpOrder,
|
|
}
|
|
tab.sink = &tabEventSink{tabID: newTabID, app: a}
|
|
a.tabs[newTabID] = tab
|
|
a.tabOrder = append(a.tabOrder, newTabID)
|
|
activateFork := a.activeTabID == sourceTab.ID
|
|
if activateFork {
|
|
a.activeTabID = newTabID
|
|
}
|
|
a.saveTabsLocked()
|
|
meta := a.tabMeta(tab, activateFork)
|
|
a.mu.Unlock()
|
|
|
|
a.emitProjectTreeChanged()
|
|
a.startTabControllerBuild(tab)
|
|
return meta, nil
|
|
}
|
|
|
|
// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start
|
|
// of turn into one summary (Claude Code's "summarize from/up to here"), keeping
|
|
// code intact. The frontend re-reads History after this resolves.
|
|
func (a *App) SummarizeFrom(turn int) error {
|
|
return a.SummarizeFromForTab("", turn)
|
|
}
|
|
|
|
func (a *App) SummarizeFromForTab(tabID string, turn int) error {
|
|
tab, ctrl := a.tabAndCtrlByID(tabID)
|
|
if a.tabIsReadOnly(tab) {
|
|
return readOnlyChannelErr()
|
|
}
|
|
if ctrl == nil {
|
|
return nil
|
|
}
|
|
return ctrl.SummarizeFrom(a.ctx, turn)
|
|
}
|
|
|
|
func (a *App) SummarizeUpTo(turn int) error {
|
|
return a.SummarizeUpToForTab("", turn)
|
|
}
|
|
|
|
func (a *App) SummarizeUpToForTab(tabID string, turn int) error {
|
|
tab, ctrl := a.tabAndCtrlByID(tabID)
|
|
if a.tabIsReadOnly(tab) {
|
|
return readOnlyChannelErr()
|
|
}
|
|
if ctrl == nil {
|
|
return nil
|
|
}
|
|
return ctrl.SummarizeUpTo(a.ctx, turn)
|
|
}
|
|
|
|
// SessionMeta summarises one saved session for the history panel.
|
|
type SessionMeta struct {
|
|
Path string `json:"path"`
|
|
Preview string `json:"preview"` // first user message
|
|
Title string `json:"title,omitempty"` // user-chosen name, when set (overrides preview)
|
|
Turns int `json:"turns"`
|
|
CreatedAt int64 `json:"createdAt"` // unix milliseconds
|
|
LastActivityAt int64 `json:"lastActivityAt"` // unix milliseconds
|
|
ModTime int64 `json:"modTime"` // compatibility alias for lastActivityAt
|
|
DeletedAt int64 `json:"deletedAt,omitempty"`
|
|
Current bool `json:"current"`
|
|
Open bool `json:"open"`
|
|
Scope string `json:"scope,omitempty"`
|
|
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
|
|
TopicID string `json:"topicId,omitempty"`
|
|
TopicTitle string `json:"topicTitle,omitempty"`
|
|
Kind string `json:"kind,omitempty"` // "channel" for external IM transcripts
|
|
Channel string `json:"channel,omitempty"`
|
|
ChannelLabel string `json:"channelLabel,omitempty"`
|
|
RemoteID string `json:"remoteId,omitempty"`
|
|
ChatType string `json:"chatType,omitempty"`
|
|
UserID string `json:"userId,omitempty"`
|
|
ThreadID string `json:"threadId,omitempty"`
|
|
SessionSource string `json:"sessionSource,omitempty"`
|
|
Recovered bool `json:"recovered,omitempty"` // created by conflict recovery, including an adopted/continued branch
|
|
RecoveryCopy bool `json:"recoveryCopy,omitempty"` // actual branch content is unchanged and covered by its parent
|
|
}
|
|
|
|
type channelSessionRoute struct {
|
|
channel string
|
|
channelLabel string
|
|
remoteID string
|
|
chatType string
|
|
userID string
|
|
threadID string
|
|
sessionSource string
|
|
}
|
|
|
|
type WorkspaceMeta struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Current bool `json:"current"`
|
|
}
|
|
|
|
func controllerSessionDir(ctrl control.SessionAPI) string {
|
|
if ctrl != nil {
|
|
if dir := ctrl.SessionDir(); dir != "" {
|
|
return dir
|
|
}
|
|
}
|
|
return desktopSessionDir("")
|
|
}
|
|
|
|
func tabSessionDir(tab *WorkspaceTab) string {
|
|
if tab != nil {
|
|
if tab.WorkspaceRoot != "" {
|
|
return desktopSessionDir(tab.WorkspaceRoot)
|
|
}
|
|
if tab.Ctrl != nil {
|
|
if dir := tab.Ctrl.SessionDir(); dir != "" {
|
|
return dir
|
|
}
|
|
}
|
|
}
|
|
return desktopSessionDir("")
|
|
}
|
|
|
|
func tabRuntimeSessionDir(tab *WorkspaceTab) string {
|
|
if tab != nil && tab.Ctrl != nil {
|
|
if dir, ok := safeControllerSessionDir(tab.Ctrl); ok && strings.TrimSpace(dir) != "" {
|
|
if path := strings.TrimSpace(tab.currentSessionPath()); path != "" {
|
|
if _, _, err := validateSessionPath(dir, path); err == nil {
|
|
return dir
|
|
}
|
|
} else {
|
|
return dir
|
|
}
|
|
}
|
|
}
|
|
return tabSessionDir(tab)
|
|
}
|
|
|
|
func (a *App) activeSessionDir() string {
|
|
tab := a.activeTab()
|
|
if path, ok := a.reconcileTabWithPinnedSessionMeta(tab); ok && strings.TrimSpace(path) != "" {
|
|
return filepath.Dir(path)
|
|
}
|
|
if tab != nil && tab.Ctrl != nil {
|
|
return tabRuntimeSessionDir(tab)
|
|
}
|
|
return tabSessionDir(tab)
|
|
}
|
|
|
|
// ListSessions returns the saved sessions newest-first for the history panel,
|
|
// marking the one the current conversation is writing to and attaching any
|
|
// user-chosen titles.
|
|
func (a *App) ListSessions() []SessionMeta {
|
|
dir := a.activeSessionDir()
|
|
infos, err := agent.ListSessions(dir)
|
|
if err != nil {
|
|
return []SessionMeta{}
|
|
}
|
|
open := a.openSessionPaths(dir)
|
|
protectedDisplays := make(map[string]struct{}, len(open))
|
|
for path := range open {
|
|
if key := filepath.Base(path); store.IsSessionTranscriptName(key) {
|
|
protectedDisplays[key] = struct{}{}
|
|
}
|
|
}
|
|
_ = pruneSessionDisplays(dir, protectedDisplays)
|
|
titles := loadSessionTitles(dir)
|
|
channelRoutes := channelSessionRoutesForDir(dir)
|
|
active := a.activeSessionPath(dir)
|
|
out := make([]SessionMeta, 0, len(infos))
|
|
for _, s := range infos {
|
|
_, isOpen := open[s.Path]
|
|
title := strings.TrimSpace(s.CustomTitle)
|
|
if title == "" {
|
|
title = titles[filepath.Base(s.Path)]
|
|
}
|
|
meta := sessionMetaFromInfo(s, title, s.Path == active, isOpen, 0, dir)
|
|
if route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {
|
|
applyChannelSessionRoute(&meta, route)
|
|
}
|
|
out = append(out, meta)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ListTrashedSessions returns sessions that were moved to the local trash,
|
|
// newest-deleted first. These can be previewed, restored, or permanently purged.
|
|
func (a *App) ListTrashedSessions() []SessionMeta {
|
|
out := []SessionMeta{}
|
|
for _, dir := range a.knownSessionDirs() {
|
|
paths, err := listTrashedSessionFiles(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
titles := loadSessionTitles(dir)
|
|
for _, path := range paths {
|
|
infos, err := agent.ListSessions(filepath.Dir(path))
|
|
if err != nil || len(infos) == 0 {
|
|
continue
|
|
}
|
|
deletedAt := trashedSessionDeletedAt(path)
|
|
title := strings.TrimSpace(infos[0].CustomTitle)
|
|
if title == "" {
|
|
title = titles[filepath.Base(path)]
|
|
}
|
|
out = append(out, sessionMetaFromInfo(infos[0], title, false, false, deletedAt, dir))
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].DeletedAt == out[j].DeletedAt {
|
|
return out[i].LastActivityAt > out[j].LastActivityAt
|
|
}
|
|
return out[i].DeletedAt > out[j].DeletedAt
|
|
})
|
|
return out
|
|
}
|
|
|
|
func (a *App) trashedSessionDir(path string) (string, error) {
|
|
for _, dir := range a.knownSessionDirs() {
|
|
if _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {
|
|
return dir, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("trashed session path outside known session dirs: %s", path)
|
|
}
|
|
|
|
func (a *App) sessionDirForPath(path string) (string, string, error) {
|
|
for _, dir := range a.knownSessionDirs() {
|
|
sessionPath, _, err := validateSessionPath(dir, path)
|
|
if err == nil {
|
|
return dir, sessionPath, nil
|
|
}
|
|
}
|
|
return "", "", fmt.Errorf("session path outside known session dirs: %s", path)
|
|
}
|
|
|
|
func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64, parentDir string) SessionMeta {
|
|
return SessionMeta{
|
|
Path: s.Path,
|
|
Preview: s.Preview,
|
|
Title: title,
|
|
Turns: s.Turns,
|
|
CreatedAt: s.CreatedAt.UnixMilli(),
|
|
LastActivityAt: s.LastActivityAt.UnixMilli(),
|
|
ModTime: s.LastActivityAt.UnixMilli(),
|
|
DeletedAt: deletedAt,
|
|
Current: current,
|
|
Open: open,
|
|
Scope: s.Scope,
|
|
WorkspaceRoot: s.WorkspaceRoot,
|
|
TopicID: s.TopicID,
|
|
TopicTitle: s.TopicTitle,
|
|
Recovered: sessionInfoIsAutomaticRecovery(s),
|
|
RecoveryCopy: sessionInfoIsUnmodifiedRecoveryCopy(s, parentDir),
|
|
}
|
|
}
|
|
|
|
func applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {
|
|
if meta == nil {
|
|
return
|
|
}
|
|
meta.Kind = "channel"
|
|
meta.Channel = route.channel
|
|
meta.ChannelLabel = route.channelLabel
|
|
meta.RemoteID = route.remoteID
|
|
meta.ChatType = route.chatType
|
|
meta.UserID = route.userID
|
|
meta.ThreadID = route.threadID
|
|
meta.SessionSource = route.sessionSource
|
|
}
|
|
|
|
func channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {
|
|
userPath := config.UserConfigPath()
|
|
if strings.TrimSpace(userPath) == "" {
|
|
return nil
|
|
}
|
|
cfg := config.LoadForEdit(userPath)
|
|
out := map[string]channelSessionRoute{}
|
|
for _, conn := range cfg.Bot.Connections {
|
|
channel := strings.TrimSpace(conn.Provider)
|
|
if channel == "" {
|
|
continue
|
|
}
|
|
channelLabel := strings.TrimSpace(conn.Label)
|
|
if channelLabel == "" {
|
|
channelLabel = channelDisplayName(channel, conn.Domain)
|
|
}
|
|
for _, mapping := range conn.SessionMappings {
|
|
if strings.TrimSpace(mapping.SessionSource) != "auto" {
|
|
continue
|
|
}
|
|
sessionPath := botSessionPathTarget(mapping.SessionID)
|
|
if sessionPath == "" {
|
|
continue
|
|
}
|
|
validPath, _, err := validateSessionPath(dir, sessionPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
key := sessionRuntimeKey(validPath)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
out[key] = channelSessionRoute{
|
|
channel: channel,
|
|
channelLabel: channelLabel,
|
|
remoteID: strings.TrimSpace(mapping.RemoteID),
|
|
chatType: strings.TrimSpace(mapping.ChatType),
|
|
userID: strings.TrimSpace(mapping.UserID),
|
|
threadID: strings.TrimSpace(mapping.ThreadID),
|
|
sessionSource: strings.TrimSpace(mapping.SessionSource),
|
|
}
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func botSessionPathTarget(sessionID string) string {
|
|
sessionID = strings.TrimSpace(sessionID)
|
|
if sessionID == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(sessionID), "path:") {
|
|
return strings.TrimSpace(sessionID[5:])
|
|
}
|
|
if strings.HasSuffix(sessionID, ".jsonl") || strings.Contains(sessionID, "/") || strings.Contains(sessionID, `\`) || strings.HasPrefix(sessionID, "~") {
|
|
return sessionID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func channelDisplayName(provider, domain string) string {
|
|
provider = strings.TrimSpace(provider)
|
|
domain = strings.TrimSpace(domain)
|
|
switch provider {
|
|
case "feishu":
|
|
if strings.EqualFold(domain, "lark") {
|
|
return "Lark"
|
|
}
|
|
return "Feishu"
|
|
case "weixin":
|
|
return "WeChat"
|
|
case "qq":
|
|
return "QQ"
|
|
default:
|
|
return provider
|
|
}
|
|
}
|
|
|
|
// DeleteSession moves a saved session to the local trash. If the session still
|
|
// has an in-process runtime, the runtime is cancelled and removed first so
|
|
// autosave cannot recreate or append to the deleted file later.
|
|
func (a *App) DeleteSession(path string) error {
|
|
return friendlySessionFileError(a.deleteSession(path, false))
|
|
}
|
|
|
|
// DeleteRecoveryCopy is the guarded bulk-cleanup path. The frontend's copy
|
|
// marker is only a hint; the backend re-reads the branch and parent immediately
|
|
// before changing runtime state or moving any files.
|
|
func (a *App) DeleteRecoveryCopy(path string) error {
|
|
return friendlySessionFileError(a.deleteSession(path, true))
|
|
}
|
|
|
|
var errRecoveryCopyNotRedundant = errors.New("recovery session contains content not preserved by its parent")
|
|
|
|
func (a *App) deleteSession(path string, requireRedundantRecovery bool) error {
|
|
dir := a.activeSessionDir()
|
|
sessionPath, key, err := validateSessionPath(dir, path)
|
|
if err != nil {
|
|
var foundErr error
|
|
if dir, sessionPath, foundErr = a.sessionDirForPath(path); foundErr != nil {
|
|
return err
|
|
}
|
|
key = filepath.Base(sessionPath)
|
|
}
|
|
if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
|
|
return err
|
|
}
|
|
var fallback fallbackRuntimeTarget
|
|
if err := func() error {
|
|
a.sessionRemovalMu.Lock()
|
|
defer a.sessionRemovalMu.Unlock()
|
|
if requireRedundantRecovery && !agent.RecoveryBranchCoveredByParent(sessionPath, dir) {
|
|
return errRecoveryCopyNotRedundant
|
|
}
|
|
|
|
removed, nextFallback := a.removeSessionRuntimeBindings(dir, sessionPath)
|
|
fallback = nextFallback
|
|
if err := a.prepareRemovedSessionRuntimes(removed); err != nil {
|
|
a.closeRemovedSessionRuntimes(removed)
|
|
return err
|
|
}
|
|
closedRemoved := map[control.SessionAPI]bool{}
|
|
destroys := a.destroyHandlesForSession(dir, sessionPath, removed)
|
|
teardownTimedOut := waitDestroyHandles(destroys)
|
|
a.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)
|
|
if teardownTimedOut {
|
|
if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
|
|
a.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)
|
|
return err
|
|
}
|
|
go delayedDesktopSessionTrash(dir, sessionPath, key, destroys)
|
|
} else {
|
|
err = trashSessionArtifacts(dir, sessionPath, key)
|
|
finishDestroyHandles(destroys)
|
|
if err != nil {
|
|
a.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)
|
|
return err
|
|
}
|
|
}
|
|
a.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)
|
|
return nil
|
|
}(); err != nil {
|
|
return err
|
|
}
|
|
if err := botruntime.ForgetAutoSessionMappingsForPath(sessionPath); err != nil {
|
|
slog.Warn("desktop: failed to clear auto bot session mapping", "err", err)
|
|
}
|
|
if fallback.needs {
|
|
fallback = a.sessionDeleteFallbackTarget(fallback)
|
|
if err := a.openFallbackRuntime(fallback); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
a.emitProjectTreeChanged()
|
|
a.invalidatePromptHistoryCache()
|
|
return nil
|
|
}
|
|
|
|
type removedSessionRuntime struct {
|
|
tab *WorkspaceTab
|
|
ctrl control.SessionAPI
|
|
sink *tabEventSink
|
|
sessionDir string
|
|
sessionPath string
|
|
scope string
|
|
workspaceRoot string
|
|
topicID string
|
|
readOnly bool
|
|
}
|
|
|
|
type fallbackRuntimeTarget struct {
|
|
needs bool
|
|
scope string
|
|
workspaceRoot string
|
|
topicID string
|
|
}
|
|
|
|
func (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {
|
|
var removed []removedSessionRuntime
|
|
var fallback fallbackRuntimeTarget
|
|
|
|
a.mu.Lock()
|
|
for id, tab := range a.tabs {
|
|
if !tabMatchesSession(tab, dir, sessionPath) {
|
|
continue
|
|
}
|
|
if len(removed) == 0 {
|
|
fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}
|
|
}
|
|
removed = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))
|
|
a.markTabRemovedLocked(tab)
|
|
delete(a.tabs, id)
|
|
a.removeTabOrderLocked(id)
|
|
if a.activeTabID == id {
|
|
a.activeTabID = ""
|
|
}
|
|
}
|
|
for key, tab := range a.detachedSessions {
|
|
if !tabMatchesSession(tab, dir, sessionPath) {
|
|
continue
|
|
}
|
|
if len(removed) == 0 {
|
|
fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}
|
|
}
|
|
removed = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))
|
|
a.markTabRemovedLocked(tab)
|
|
delete(a.detachedSessions, key)
|
|
}
|
|
if a.activeTabID == "" && len(a.tabOrder) > 0 {
|
|
a.activeTabID = a.tabOrder[0]
|
|
}
|
|
fallback.needs = len(removed) > 0 && len(a.tabs) == 0
|
|
dir, entries, activeID, version := a.saveTabsCollectLocked()
|
|
a.mu.Unlock()
|
|
|
|
a.saveTabsWrite(dir, entries, activeID, version)
|
|
|
|
return removed, fallback
|
|
}
|
|
|
|
func (a *App) sessionDeleteFallbackTarget(target fallbackRuntimeTarget) fallbackRuntimeTarget {
|
|
topicID := strings.TrimSpace(target.topicID)
|
|
if topicID == "" {
|
|
return target
|
|
}
|
|
if path, _ := a.findTopicContentSessionForTarget(target.scope, target.workspaceRoot, topicID); path != "" {
|
|
return target
|
|
}
|
|
target.topicID = ""
|
|
return target
|
|
}
|
|
|
|
func (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {
|
|
var removed []removedSessionRuntime
|
|
var fallback fallbackRuntimeTarget
|
|
|
|
a.mu.Lock()
|
|
for id, tab := range a.tabs {
|
|
if tab == nil || tab.TopicID != topicID {
|
|
continue
|
|
}
|
|
sessionDir := tabRuntimeSessionDir(tab)
|
|
sessionPath := canonicalTabSessionPath(tab.currentSessionPath())
|
|
if len(removed) == 0 {
|
|
fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
|
|
}
|
|
removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))
|
|
a.markTabRemovedLocked(tab)
|
|
delete(a.tabs, id)
|
|
a.removeTabOrderLocked(id)
|
|
if a.activeTabID == id {
|
|
a.activeTabID = ""
|
|
}
|
|
}
|
|
for key, tab := range a.detachedSessions {
|
|
if tab == nil || tab.TopicID != topicID {
|
|
continue
|
|
}
|
|
sessionDir := tabRuntimeSessionDir(tab)
|
|
sessionPath := canonicalTabSessionPath(tab.currentSessionPath())
|
|
if len(removed) == 0 {
|
|
fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
|
|
}
|
|
removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))
|
|
a.markTabRemovedLocked(tab)
|
|
delete(a.detachedSessions, key)
|
|
}
|
|
if a.activeTabID == "" && len(a.tabOrder) > 0 {
|
|
a.activeTabID = a.tabOrder[0]
|
|
}
|
|
fallback.needs = len(removed) > 0 && len(a.tabs) == 0
|
|
dir, entries, activeID, version := a.saveTabsCollectLocked()
|
|
a.mu.Unlock()
|
|
|
|
a.saveTabsWrite(dir, entries, activeID, version)
|
|
|
|
return removed, fallback
|
|
}
|
|
|
|
func removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {
|
|
return removedSessionRuntime{
|
|
tab: tab,
|
|
ctrl: tab.Ctrl,
|
|
sink: tab.sink,
|
|
sessionDir: dir,
|
|
sessionPath: sessionPath,
|
|
scope: tab.Scope,
|
|
workspaceRoot: tab.WorkspaceRoot,
|
|
topicID: tab.TopicID,
|
|
readOnly: tab.ReadOnly,
|
|
}
|
|
}
|
|
|
|
func tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {
|
|
if tab == nil {
|
|
return false
|
|
}
|
|
currentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())
|
|
if err == nil && currentPath == sessionPath {
|
|
return true
|
|
}
|
|
if tabRuntimeSessionDir(tab) != dir {
|
|
return false
|
|
}
|
|
currentPath, _, err = validateSessionPath(dir, tab.currentSessionPath())
|
|
return err == nil && currentPath == sessionPath
|
|
}
|
|
|
|
func (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {
|
|
for _, item := range removed {
|
|
if item.sink != nil {
|
|
item.sink.clearContext()
|
|
}
|
|
if item.ctrl == nil {
|
|
continue
|
|
}
|
|
if item.ctrl.Running() {
|
|
item.ctrl.Cancel()
|
|
if err := waitControllerStopped(item.ctrl); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if item.readOnly {
|
|
continue
|
|
}
|
|
if err := item.ctrl.Snapshot(); err != nil {
|
|
if !errors.Is(err, agent.ErrSessionSnapshotConflict) {
|
|
return err
|
|
}
|
|
slog.Warn("desktop: skipping stale runtime snapshot before removing session",
|
|
"session", item.sessionPath, "err", err)
|
|
}
|
|
item.ctrl.SetSessionPath("")
|
|
a.quiesceTabAutosave(item.tab)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func waitControllerStopped(ctrl control.SessionAPI) error {
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for ctrl.Running() {
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("timed out waiting for cancelled session work to stop")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {
|
|
destroys := a.beginDestroySessionJobs(dir, sessionPath)
|
|
for _, item := range removed {
|
|
if item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {
|
|
continue
|
|
}
|
|
destroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))
|
|
}
|
|
return destroys
|
|
}
|
|
|
|
func waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {
|
|
timedOut := false
|
|
for _, destroy := range destroys {
|
|
if destroy.Wait != nil {
|
|
if destroy.Wait().HasTimedOut() {
|
|
timedOut = true
|
|
}
|
|
}
|
|
}
|
|
return timedOut
|
|
}
|
|
|
|
func waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {
|
|
for _, destroy := range destroys {
|
|
if destroy.WaitAll != nil {
|
|
destroy.WaitAll()
|
|
}
|
|
}
|
|
}
|
|
|
|
func finishDestroyHandles(destroys []control.SessionDestroyHandle) {
|
|
for _, destroy := range destroys {
|
|
if destroy.Finish != nil {
|
|
destroy.Finish()
|
|
}
|
|
}
|
|
}
|
|
|
|
func delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {
|
|
waitAllDestroyHandles(destroys)
|
|
if err := removeDesktopSessionArtifacts(path); err != nil {
|
|
slog.Warn("desktop: delayed session cleanup failed", "path", path, "err", err)
|
|
}
|
|
finishDestroyHandles(destroys)
|
|
}
|
|
|
|
func delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {
|
|
waitAllDestroyHandles(destroys)
|
|
if err := trashSessionArtifacts(dir, sessionPath, key); err != nil {
|
|
slog.Warn("desktop: delayed session trash failed", "path", sessionPath, "err", err)
|
|
}
|
|
finishDestroyHandles(destroys)
|
|
}
|
|
|
|
func (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {
|
|
a.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})
|
|
}
|
|
|
|
func (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {
|
|
releasedTabs := map[*WorkspaceTab]bool{}
|
|
for _, item := range removed {
|
|
if item.sessionDir != dir || item.sessionPath != sessionPath {
|
|
continue
|
|
}
|
|
a.closeRemovedSessionRuntime(item, closed, releasedTabs, true)
|
|
}
|
|
}
|
|
|
|
func (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {
|
|
releasedTabs := map[*WorkspaceTab]bool{}
|
|
for _, item := range removed {
|
|
a.closeRemovedSessionRuntime(item, closed, releasedTabs, false)
|
|
}
|
|
}
|
|
|
|
func (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {
|
|
releasedTabs := map[*WorkspaceTab]bool{}
|
|
for _, item := range removed {
|
|
a.closeRemovedSessionRuntime(item, closed, releasedTabs, true)
|
|
}
|
|
}
|
|
|
|
func (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {
|
|
if item.tab != nil {
|
|
if releasedTabs == nil || !releasedTabs[item.tab] {
|
|
if releasedTabs != nil {
|
|
releasedTabs[item.tab] = true
|
|
}
|
|
a.releaseTabSharedHost(item.tab)
|
|
item.tab.releaseSessionLease()
|
|
}
|
|
}
|
|
if item.ctrl == nil {
|
|
return
|
|
}
|
|
if closed == nil {
|
|
closed = map[control.SessionAPI]bool{}
|
|
}
|
|
if closed[item.ctrl] {
|
|
return
|
|
}
|
|
closed[item.ctrl] = true
|
|
if afterDestroy {
|
|
item.ctrl.CloseAfterDestroy()
|
|
return
|
|
}
|
|
item.ctrl.Close()
|
|
}
|
|
|
|
func (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {
|
|
scope := target.scope
|
|
root := target.workspaceRoot
|
|
topicID := strings.TrimSpace(target.topicID)
|
|
if scope == "global" {
|
|
root = ""
|
|
}
|
|
if topicID == "" {
|
|
return a.openTransientBlankRuntime(scope, root)
|
|
}
|
|
var err error
|
|
if a.singleSurfaceLayoutEnabled() {
|
|
_, err = a.ActivateTopic(scope, root, topicID, "")
|
|
} else if scope == "global" {
|
|
_, err = a.OpenGlobalTab(topicID)
|
|
} else {
|
|
_, err = a.OpenProjectTab(root, topicID)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (a *App) openTransientBlankRuntime(scope, workspaceRoot string) error {
|
|
scope = strings.TrimSpace(scope)
|
|
if scope != "project" {
|
|
scope = "global"
|
|
}
|
|
actualRoot := ""
|
|
if scope == "project" {
|
|
workspaceRoot = normalizeProjectRoot(workspaceRoot)
|
|
if workspaceRoot == "" {
|
|
return fmt.Errorf("workspaceRoot is required")
|
|
}
|
|
saveWorkspace(workspaceRoot)
|
|
a.registerProjectRoot(workspaceRoot)
|
|
actualRoot = workspaceRoot
|
|
} else {
|
|
actualRoot = globalWorkspaceRoot()
|
|
if err := os.MkdirAll(actualRoot, 0o755); err != nil {
|
|
return fmt.Errorf("create global workspace: %w", err)
|
|
}
|
|
}
|
|
|
|
model, toolApprovalMode := desktopNewSessionDefaults()
|
|
sessionPath, err := createEmptySessionFile(desktopSessionDir(actualRoot), model)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tab := &WorkspaceTab{
|
|
Scope: scope,
|
|
WorkspaceRoot: actualRoot,
|
|
TopicTitle: defaultTopicTitle,
|
|
SessionPath: sessionPath,
|
|
model: model,
|
|
tokenMode: boot.TokenModeFull,
|
|
mode: tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo),
|
|
toolApprovalMode: toolApprovalMode,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
a.mu.Lock()
|
|
tab.ID = a.newUniqueTabIDLocked()
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: a}
|
|
a.tabs[tab.ID] = tab
|
|
a.tabOrder = append(a.tabOrder, tab.ID)
|
|
a.activeTabID = tab.ID
|
|
a.saveTabsLocked()
|
|
a.mu.Unlock()
|
|
|
|
a.startTabControllerBuild(tab)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
var destroys []control.SessionDestroyHandle
|
|
for _, tab := range a.runtimeTabsLocked() {
|
|
if tab == nil || tab.Ctrl == nil || tabRuntimeSessionDir(tab) != dir {
|
|
continue
|
|
}
|
|
destroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))
|
|
}
|
|
return destroys
|
|
}
|
|
|
|
func (a *App) openSessionPaths(dir string) map[string]struct{} {
|
|
a.mu.RLock()
|
|
paths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))
|
|
for _, tab := range a.runtimeTabsLocked() {
|
|
if tab != nil {
|
|
paths = append(paths, tab.currentSessionPath())
|
|
}
|
|
}
|
|
a.mu.RUnlock()
|
|
|
|
out := make(map[string]struct{}, len(paths))
|
|
for _, path := range paths {
|
|
currentPath, _, err := validateSessionPath(dir, path)
|
|
if err == nil {
|
|
out[currentPath] = struct{}{}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *App) activeSessionPath(dir string) string {
|
|
a.mu.RLock()
|
|
var path string
|
|
if tab := a.tabs[a.activeTabID]; tab != nil {
|
|
path = tab.currentSessionPath()
|
|
}
|
|
a.mu.RUnlock()
|
|
currentPath, _, err := validateSessionPath(dir, path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return currentPath
|
|
}
|
|
|
|
// RestoreSession moves a trashed session back into the saved-session list.
|
|
func (a *App) RestoreSession(path string) error {
|
|
return friendlySessionFileError(a.restoreSession(path))
|
|
}
|
|
|
|
func (a *App) restoreSession(path string) error {
|
|
dir, err := a.trashedSessionDir(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, key, _, err := validateTrashedSessionPath(dir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// The destroying/open checks and the trash-entry move must not interleave
|
|
// with DeleteSession/TrashTopic trashing the same entry.
|
|
a.sessionRemovalMu.Lock()
|
|
defer a.sessionRemovalMu.Unlock()
|
|
target := filepath.Join(dir, key)
|
|
if a.sessionDestroying(dir, target) {
|
|
return fmt.Errorf("session cleanup is still in progress: %s", key)
|
|
}
|
|
if a.sessionOpen(dir, target) {
|
|
return fmt.Errorf("session is open: %s", key)
|
|
}
|
|
if err := restoreTrashedSessionFile(dir, path); err != nil {
|
|
return err
|
|
}
|
|
if err := restoreSessionTopicIndex(dir, target); err != nil {
|
|
return err
|
|
}
|
|
a.emitProjectTreeChanged()
|
|
a.invalidatePromptHistoryCache()
|
|
return nil
|
|
}
|
|
|
|
func (a *App) sessionDestroying(dir, sessionPath string) bool {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
for _, tab := range a.runtimeTabsLocked() {
|
|
if tab == nil || tab.Ctrl == nil || tabRuntimeSessionDir(tab) != dir {
|
|
continue
|
|
}
|
|
if tab.Ctrl.IsDestroyingSession(sessionPath) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *App) sessionOpen(dir, sessionPath string) bool {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
for _, tab := range a.runtimeTabsLocked() {
|
|
if tabMatchesSession(tab, dir, sessionPath) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// PurgeTrashedSession permanently removes a trashed session and its title/display
|
|
// sidecars.
|
|
func (a *App) PurgeTrashedSession(path string) error {
|
|
return friendlySessionFileError(a.purgeTrashedSession(path, false))
|
|
}
|
|
|
|
// PurgeRecoveryCopy is the guarded permanent-cleanup path. A trashed branch is
|
|
// rechecked against its live parent; missing, stale, or divergent data is kept.
|
|
func (a *App) PurgeRecoveryCopy(path string) error {
|
|
return friendlySessionFileError(a.purgeTrashedSession(path, true))
|
|
}
|
|
|
|
func (a *App) purgeTrashedSession(path string, requireRedundantRecovery bool) error {
|
|
dir, err := a.trashedSessionDir(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.sessionRemovalMu.Lock()
|
|
defer a.sessionRemovalMu.Unlock()
|
|
var parentGuard *agent.SessionRemovalGuard
|
|
if requireRedundantRecovery {
|
|
parentGuard, err = agent.TryAcquireRecoveryParentGuard(path, dir)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, agent.ErrRecoveryBranchNotCovered):
|
|
return errRecoveryCopyNotRedundant
|
|
case errors.Is(err, agent.ErrSessionLeaseHeld):
|
|
return errSessionBusyElsewhere
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
defer parentGuard.Release()
|
|
}
|
|
if err := purgeTrashedSessionFile(dir, path); err != nil {
|
|
return err
|
|
}
|
|
a.invalidatePromptHistoryCache()
|
|
return nil
|
|
}
|
|
|
|
// RenameSession sets a custom display name for a session (empty clears it back to
|
|
// the preview). The transcript file is unchanged; the canonical name lives in
|
|
// the branch meta sidecar, with the legacy .titles.json map kept as a
|
|
// compatibility write-through for older desktop data paths.
|
|
func (a *App) RenameSession(path, title string) error {
|
|
dir := a.activeSessionDir()
|
|
sessionPath, _, err := validateSessionPath(dir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := agent.RenameSession(sessionPath, title); err != nil {
|
|
return err
|
|
}
|
|
if err := setSessionTitle(dir, sessionPath, title); err != nil {
|
|
return err
|
|
}
|
|
a.invalidatePromptHistoryCache()
|
|
a.emitProjectTreeChanged()
|
|
return nil
|
|
}
|
|
|
|
// ResumeSession snapshots the current conversation, then loads the session at
|
|
// path and continues it on the active tab. The model and working folder are
|
|
// unchanged; only the transcript is swapped. Returns the resumed messages for
|
|
// the frontend to render.
|
|
func (a *App) ResumeSession(path string) ([]HistoryMessage, error) {
|
|
return a.ResumeSessionForTab("", path)
|
|
}
|
|
|
|
func (a *App) ResumeSessionPage(path string, limit int) (HistoryPage, error) {
|
|
return a.ResumeSessionPageForTab("", path, limit)
|
|
}
|
|
|
|
func (a *App) ResumeSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) {
|
|
tab, ctrl := a.tabAndCtrlByID(tabID)
|
|
if tab == nil || ctrl == nil {
|
|
return HistoryPage{}, fmt.Errorf("tab is not ready")
|
|
}
|
|
sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
|
|
if err != nil {
|
|
return HistoryPage{}, err
|
|
}
|
|
loaded, err := loadResumableSession(sessionPath)
|
|
if err != nil {
|
|
return HistoryPage{}, err
|
|
}
|
|
if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {
|
|
if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
|
|
return HistoryPage{}, err
|
|
}
|
|
}
|
|
a.setTabReadOnly(tab.ID, false)
|
|
return a.HistoryPageForTab(tab.ID, 0, limit), nil
|
|
}
|
|
|
|
// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session
|
|
// path is a runtime identity, so changing to a different path must replace the
|
|
// tab's controller binding rather than mutating the current controller in place.
|
|
func (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {
|
|
tab, ctrl := a.tabAndCtrlByID(tabID)
|
|
if tab == nil || ctrl == nil {
|
|
return []HistoryMessage{}, fmt.Errorf("tab is not ready")
|
|
}
|
|
sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
loaded, err := loadResumableSession(sessionPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {
|
|
a.setTabReadOnly(tab.ID, false)
|
|
return a.HistoryForTab(tabID), nil
|
|
}
|
|
|
|
if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
|
|
return nil, err
|
|
}
|
|
a.setTabReadOnly(tab.ID, false)
|
|
return a.HistoryForTab(tab.ID), nil
|
|
}
|
|
|
|
func (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {
|
|
tab, ctrl := a.tabAndCtrlByID(tabID)
|
|
if tab == nil || ctrl == nil {
|
|
return []HistoryMessage{}, fmt.Errorf("tab is not ready")
|
|
}
|
|
sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
loaded, err := loadResumableSession(sessionPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {
|
|
if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
a.setTabReadOnly(tab.ID, true)
|
|
return a.HistoryForTab(tab.ID), nil
|
|
}
|
|
|
|
func (a *App) OpenChannelSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) {
|
|
tab, ctrl := a.tabAndCtrlByID(tabID)
|
|
if tab == nil || ctrl == nil {
|
|
return HistoryPage{}, fmt.Errorf("tab is not ready")
|
|
}
|
|
sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
|
|
if err != nil {
|
|
return HistoryPage{}, err
|
|
}
|
|
loaded, err := loadResumableSession(sessionPath)
|
|
if err != nil {
|
|
return HistoryPage{}, err
|
|
}
|
|
if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {
|
|
if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
|
|
return HistoryPage{}, err
|
|
}
|
|
}
|
|
a.setTabReadOnly(tab.ID, true)
|
|
return a.HistoryPageForTab(tab.ID, 0, limit), nil
|
|
}
|
|
|
|
func (a *App) setTabReadOnly(tabID string, readOnly bool) {
|
|
a.mu.Lock()
|
|
if tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {
|
|
tab.ReadOnly = readOnly
|
|
a.saveTabsLocked()
|
|
}
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {
|
|
sessionPath = canonicalTabSessionPath(sessionPath)
|
|
if sessionPath == "" {
|
|
return fmt.Errorf("session path is required")
|
|
}
|
|
loaded, err := loadResumableSession(sessionPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)
|
|
}
|
|
|
|
func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {
|
|
if tab == nil {
|
|
return fmt.Errorf("tab is not ready")
|
|
}
|
|
sessionPath = canonicalTabSessionPath(sessionPath)
|
|
if sessionPath == "" {
|
|
return fmt.Errorf("session path is required")
|
|
}
|
|
if agent.IsCleanupPending(sessionPath) {
|
|
return fmt.Errorf("session is pending cleanup")
|
|
}
|
|
if loaded == nil {
|
|
var err error
|
|
loaded, err = loadResumableSession(sessionPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Session rebinding is a full detach/close/build/swap of the tab's
|
|
// controller — the same shape as rebuildSetting/SetModelForTab. Hold the
|
|
// shared rebuild mutex so a concurrent model/effort/settings rebuild of the
|
|
// same tab cannot interleave: without it both builds pass the swap-time
|
|
// identity check, the loser's controller leaks un-closed, and the old
|
|
// controller can be double-closed.
|
|
a.runtimeRebuildMu.Lock()
|
|
defer a.runtimeRebuildMu.Unlock()
|
|
|
|
// Validate the tab, compare session keys, invalidate any in-flight async
|
|
// build, and snapshot the controller in ONE a.mu critical section.
|
|
// runtimeRebuildMu does not cover startTabControllerBuild's goroutine, so
|
|
// observing ctrl == nil and bumping the generation later would leave a
|
|
// window where the async build passes its swap-time generation check,
|
|
// installs its controller after the observation, and the loaded build
|
|
// below silently overwrites it — leaking the runtime and its shared-host
|
|
// reference. Bump-before-snapshot makes the snapshot authoritative: after
|
|
// the bump the async build can only fall into its superseded branches,
|
|
// which release exactly what it acquired (abandonSupersededBuild).
|
|
a.mu.Lock()
|
|
if tab.removed || a.tabs[tab.ID] != tab {
|
|
a.mu.Unlock()
|
|
return fmt.Errorf("tab is not ready")
|
|
}
|
|
currentPath := ""
|
|
if tab.Ctrl != nil {
|
|
currentPath = strings.TrimSpace(tab.Ctrl.SessionPath())
|
|
}
|
|
if currentPath == "" {
|
|
currentPath = strings.TrimSpace(tab.SessionPath)
|
|
}
|
|
if sessionRuntimeKey(currentPath) == sessionRuntimeKey(sessionPath) {
|
|
// Same session: leave any in-flight build alone — resuming the
|
|
// session a build is already binding must stay a no-op.
|
|
a.mu.Unlock()
|
|
return nil
|
|
}
|
|
tab.buildGeneration++
|
|
if tab.buildCancel != nil {
|
|
tab.buildCancel()
|
|
tab.buildCancel = nil
|
|
}
|
|
ctrl := tab.Ctrl
|
|
a.mu.Unlock()
|
|
|
|
profile := loadTabSessionProfile(sessionPath)
|
|
|
|
if ctrl == nil {
|
|
a.mu.Lock()
|
|
tab.SessionPath = sessionPath
|
|
applyTabSessionProfile(tab, profile)
|
|
tab.Ready = false
|
|
clearTabStartupError(tab)
|
|
tab.ActivityStatus = ""
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
|
|
a.saveTabsLocked()
|
|
a.mu.Unlock()
|
|
a.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})
|
|
a.mu.RLock()
|
|
builtCtrl, startupErr := tab.Ctrl, tab.StartupErr
|
|
a.mu.RUnlock()
|
|
if builtCtrl == nil {
|
|
if startupErr != "" {
|
|
return fmt.Errorf("resume session: %s", startupErr)
|
|
}
|
|
return fmt.Errorf("resume session: controller was not built")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := a.snapshotTabForAction(tab, "switching sessions"); err != nil {
|
|
return err
|
|
}
|
|
if oldPath := a.reconciledSessionPathForTab(tab); oldPath != "" {
|
|
if err := a.saveTabSessionMeta(tab, oldPath); err != nil {
|
|
return fmt.Errorf("save current session metadata before switching sessions: %w", err)
|
|
}
|
|
}
|
|
if controllerHasActiveRuntimeWork(ctrl) {
|
|
if !a.detachRuntimeForReplacement(tab) {
|
|
return fmt.Errorf("current session runtime cannot be detached")
|
|
}
|
|
} else {
|
|
ctrl.Close()
|
|
tab.releaseSessionLease()
|
|
}
|
|
|
|
a.mu.Lock()
|
|
// The generation was already bumped (and any async build cancelled) in
|
|
// the validation section above; only retarget the tab here.
|
|
tab.Ctrl = nil
|
|
tab.SessionPath = sessionPath
|
|
applyTabSessionProfile(tab, profile)
|
|
tab.Ready = false
|
|
clearTabStartupError(tab)
|
|
tab.ActivityStatus = ""
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
|
|
a.saveTabsLocked()
|
|
a.mu.Unlock()
|
|
|
|
a.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})
|
|
a.mu.RLock()
|
|
builtCtrl, startupErr := tab.Ctrl, tab.StartupErr
|
|
a.mu.RUnlock()
|
|
if builtCtrl == nil {
|
|
if startupErr != "" {
|
|
return fmt.Errorf("resume session: %s", startupErr)
|
|
}
|
|
return fmt.Errorf("resume session: controller was not built")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadResumableSession(sessionPath string) (*agent.Session, error) {
|
|
if agent.IsCleanupPending(sessionPath) {
|
|
return nil, fmt.Errorf("session is pending cleanup")
|
|
}
|
|
return agent.LoadSession(sessionPath)
|
|
}
|
|
|
|
// PreviewSession reads a saved session for display only. It does not snapshot or
|
|
// swap the active controller, so the history drawer can call it while a turn runs.
|
|
func (a *App) PreviewSession(path string) ([]HistoryMessage, error) {
|
|
sessionDir, sessionPath, err := a.sessionDirForPath(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return previewSessionMessages(sessionDir, sessionPath)
|
|
}
|
|
|
|
// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next
|
|
// ScanPromptHistory call rebuilds session order and reloads sessions on demand.
|
|
// Called from every session-mutating path: NewSession, ClearSession,
|
|
// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.
|
|
func (a *App) invalidatePromptHistoryCache() {
|
|
a.promptHistoryMu.Lock()
|
|
a.promptHistoryTape = nil
|
|
a.promptHistoryMu.Unlock()
|
|
}
|
|
|
|
const (
|
|
promptHistoryPageLimit = 50
|
|
promptHistoryMaxPageLimit = 200
|
|
)
|
|
|
|
type promptHistoryRequest struct {
|
|
Nonce string `json:"nonce,omitempty"`
|
|
Cursor string `json:"cursor,omitempty"`
|
|
Limit int `json:"limit,omitempty"`
|
|
legacy bool
|
|
}
|
|
|
|
type promptHistoryCursor struct {
|
|
Nonce string `json:"n"`
|
|
Session int `json:"s"`
|
|
Offset int `json:"o"`
|
|
}
|
|
|
|
type promptHistoryTape struct {
|
|
nonce string
|
|
dir string
|
|
currentPath string
|
|
displays sessionDisplayMap
|
|
sessions []promptHistorySessionFile
|
|
loaded map[string][]PromptHistoryEntry
|
|
}
|
|
|
|
// ScanPromptHistory returns the next prompt-history tape segment. The request is
|
|
// a JSON string so the Wails binding stays one-argument while the protocol can
|
|
// carry a cursor and page limit. Older clients may still pass a bare nonce; that
|
|
// path keeps the old cache-hit behavior.
|
|
func (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {
|
|
req := parsePromptHistoryRequest(rawRequest)
|
|
dir := a.activeSessionDir()
|
|
sessionPath := a.activeSessionPath(dir)
|
|
|
|
a.promptHistoryMu.Lock()
|
|
tape, err := a.promptHistoryTapeForLocked(dir, sessionPath)
|
|
if err != nil {
|
|
a.promptHistoryMu.Unlock()
|
|
return PromptHistoryResult{}, err
|
|
}
|
|
if req.legacy && req.Nonce != "" && req.Nonce == tape.nonce {
|
|
a.promptHistoryMu.Unlock()
|
|
return PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil
|
|
}
|
|
result := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))
|
|
a.promptHistoryMu.Unlock()
|
|
return result, nil
|
|
}
|
|
|
|
func parsePromptHistoryRequest(raw string) promptHistoryRequest {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return promptHistoryRequest{}
|
|
}
|
|
if strings.HasPrefix(raw, "{") {
|
|
var req promptHistoryRequest
|
|
if err := json.Unmarshal([]byte(raw), &req); err == nil {
|
|
return req
|
|
}
|
|
}
|
|
return promptHistoryRequest{Nonce: raw, legacy: true}
|
|
}
|
|
|
|
func promptHistoryLimit(limit int) int {
|
|
if limit <= 0 {
|
|
return promptHistoryPageLimit
|
|
}
|
|
if limit > promptHistoryMaxPageLimit {
|
|
return promptHistoryMaxPageLimit
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {
|
|
currentPath := ""
|
|
if path, _, err := validateSessionPath(dir, sessionPath); err == nil {
|
|
currentPath = path
|
|
}
|
|
if a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {
|
|
return a.promptHistoryTape, nil
|
|
}
|
|
tape, err := newPromptHistoryTape(dir, currentPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a.promptHistoryTape = tape
|
|
return tape, nil
|
|
}
|
|
|
|
func (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {
|
|
tape, err := newPromptHistoryTape(dir, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return tape.readAll(), nil
|
|
}
|
|
|
|
func newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {
|
|
tape := &promptHistoryTape{
|
|
nonce: fmt.Sprintf("%d", time.Now().UnixNano()),
|
|
dir: dir,
|
|
currentPath: currentPath,
|
|
displays: loadSessionDisplays(dir),
|
|
loaded: map[string][]PromptHistoryEntry{},
|
|
}
|
|
sessions, err := promptHistorySessionFiles(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if currentPath != "" {
|
|
currentPath = filepath.Clean(currentPath)
|
|
currentSession := promptHistorySessionFile{}
|
|
currentIndex := -1
|
|
for i, session := range sessions {
|
|
if filepath.Clean(session.path) == currentPath {
|
|
currentSession = session
|
|
currentIndex = i
|
|
break
|
|
}
|
|
}
|
|
if currentIndex >= 0 {
|
|
sessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)
|
|
} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {
|
|
sessions = append([]promptHistorySessionFile{{
|
|
path: currentPath,
|
|
}}, sessions...)
|
|
}
|
|
}
|
|
tape.sessions = sessions
|
|
return tape, nil
|
|
}
|
|
|
|
func (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {
|
|
c := promptHistoryCursor{Nonce: t.nonce}
|
|
if decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {
|
|
c = decoded
|
|
}
|
|
if c.Session < 0 {
|
|
c.Session = 0
|
|
}
|
|
if c.Offset < 0 {
|
|
c.Offset = 0
|
|
}
|
|
|
|
out := make([]PromptHistoryEntry, 0, limit)
|
|
sessionIndex := c.Session
|
|
offset := c.Offset
|
|
for sessionIndex < len(t.sessions) && len(out) < limit {
|
|
entries, err := t.entriesForSession(sessionIndex)
|
|
if err != nil || offset >= len(entries) {
|
|
sessionIndex++
|
|
offset = 0
|
|
continue
|
|
}
|
|
|
|
end := min(len(entries), offset+limit-len(out))
|
|
out = append(out, entries[offset:end]...)
|
|
offset = end
|
|
if offset >= len(entries) && len(out) < limit {
|
|
sessionIndex++
|
|
offset = 0
|
|
}
|
|
}
|
|
|
|
if sessionIndex < len(t.sessions) {
|
|
if entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {
|
|
sessionIndex++
|
|
offset = 0
|
|
}
|
|
}
|
|
hasOlder := sessionIndex < len(t.sessions)
|
|
olderCursor := ""
|
|
if hasOlder {
|
|
olderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})
|
|
}
|
|
return PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}
|
|
}
|
|
|
|
func (t *promptHistoryTape) readAll() []PromptHistoryEntry {
|
|
out := []PromptHistoryEntry{}
|
|
cursor := ""
|
|
for {
|
|
page := t.readOlder(cursor, promptHistoryMaxPageLimit)
|
|
out = append(out, page.Entries...)
|
|
if !page.HasOlder || page.OlderCursor == "" {
|
|
return out
|
|
}
|
|
cursor = page.OlderCursor
|
|
}
|
|
}
|
|
|
|
func (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {
|
|
if index < 0 || index >= len(t.sessions) {
|
|
return nil, nil
|
|
}
|
|
path := t.sessions[index].path
|
|
if entries, ok := t.loaded[path]; ok {
|
|
return entries, nil
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.loaded[path] = nil
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
entries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))
|
|
if err != nil {
|
|
t.loaded[path] = nil
|
|
return nil, err
|
|
}
|
|
t.loaded[path] = entries
|
|
return entries, nil
|
|
}
|
|
|
|
func encodePromptHistoryCursor(cursor promptHistoryCursor) string {
|
|
b, err := json.Marshal(cursor)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
func decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {
|
|
if strings.TrimSpace(value) == "" {
|
|
return promptHistoryCursor{}, false
|
|
}
|
|
b, err := base64.RawURLEncoding.DecodeString(value)
|
|
if err != nil {
|
|
return promptHistoryCursor{}, false
|
|
}
|
|
var cursor promptHistoryCursor
|
|
if err := json.Unmarshal(b, &cursor); err != nil {
|
|
return promptHistoryCursor{}, false
|
|
}
|
|
return cursor, true
|
|
}
|
|
|
|
func scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {
|
|
entries, err := collectPromptHistoryEntries(path, info, resolveUserContent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sortPromptHistoryNewestFirst(entries)
|
|
return entries, nil
|
|
}
|
|
|
|
type promptHistorySessionFile struct {
|
|
path string
|
|
}
|
|
|
|
func promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {
|
|
infos, err := agent.ListSessionOrder(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sessions := make([]promptHistorySessionFile, 0, len(infos))
|
|
for _, info := range infos {
|
|
sessions = append(sessions, promptHistorySessionFile{path: info.Path})
|
|
}
|
|
return sessions, nil
|
|
}
|
|
|
|
func promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {
|
|
if a.At != b.At {
|
|
return a.At > b.At
|
|
}
|
|
if a.SessionPath != b.SessionPath {
|
|
return a.SessionPath > b.SessionPath
|
|
}
|
|
return a.Turn > b.Turn
|
|
}
|
|
|
|
func sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return promptHistoryEntryNewer(entries[i], entries[j])
|
|
})
|
|
}
|
|
|
|
func collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {
|
|
var out []PromptHistoryEntry
|
|
emit := func(entry PromptHistoryEntry) {
|
|
out = append(out, entry)
|
|
}
|
|
// Sessions with an event log must replay it: the .jsonl checkpoint stops
|
|
// gaining turns between checkpoints, so scanning it directly would freeze
|
|
// ↑-recall at each session's last checkpoint.
|
|
if handled, err := collectEventLogUserPrompts(path, info, resolveUserContent, emit); handled {
|
|
return out, err
|
|
}
|
|
err := collectJSONLUserPrompts(path, info, resolveUserContent, emit)
|
|
return out, err
|
|
}
|
|
|
|
func collectEventLogUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) (bool, error) {
|
|
logPath := store.SessionEventLog(path)
|
|
if logPath == "" {
|
|
return false, nil
|
|
}
|
|
if logInfo, err := os.Stat(logPath); err != nil || logInfo.IsDir() || logInfo.Size() == 0 {
|
|
return false, nil
|
|
}
|
|
users, err := agent.LoadSessionUserMessages(path)
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
fallbackAt := promptHistoryFallbackMillis(path, info)
|
|
turn := 0
|
|
for _, user := range users {
|
|
text := strings.TrimSpace(resolveUserContent(strings.TrimSpace(user.Text)))
|
|
if text == "" || control.IsSyntheticUserMessage(text) {
|
|
continue
|
|
}
|
|
at := fallbackAt
|
|
if !user.At.IsZero() {
|
|
at = user.At.UnixMilli()
|
|
}
|
|
emit(PromptHistoryEntry{
|
|
Text: text,
|
|
At: at,
|
|
SessionPath: path,
|
|
Turn: turn,
|
|
})
|
|
turn++
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
fallbackAt := promptHistoryFallbackMillis(path, info)
|
|
|
|
dec := json.NewDecoder(f)
|
|
turn := 0
|
|
for {
|
|
var rec previewEventRecord
|
|
if err := dec.Decode(&rec); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
return nil // partial results are better than none
|
|
}
|
|
// Format compatibility:
|
|
// 1) Legacy event format: {"kind":"user.message","text":"..."}
|
|
// 2) Early event format: {"type":"user.message","text":"..."}
|
|
// 3) Current provider.Message format: {"role":"user","content":"..."}
|
|
text := ""
|
|
kindOrType := strings.TrimSpace(rec.Kind)
|
|
if kindOrType == "" {
|
|
kindOrType = strings.TrimSpace(rec.Type)
|
|
}
|
|
if kindOrType == "user.message" {
|
|
text = strings.TrimSpace(rec.Text)
|
|
} else if strings.TrimSpace(rec.Role) == "user" {
|
|
text = strings.TrimSpace(rec.Content)
|
|
}
|
|
if text != "" {
|
|
text = resolveUserContent(text)
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
if control.IsSyntheticUserMessage(text) {
|
|
continue
|
|
}
|
|
at := fallbackAt
|
|
if eventAt, ok := promptHistoryEventMillis(rec); ok {
|
|
at = eventAt
|
|
}
|
|
entry := PromptHistoryEntry{
|
|
Text: text,
|
|
At: at,
|
|
SessionPath: path,
|
|
Turn: turn,
|
|
}
|
|
emit(entry)
|
|
turn++
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {
|
|
if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {
|
|
return meta.UpdatedAt.UnixMilli()
|
|
}
|
|
if info != nil {
|
|
return info.ModTime().UnixMilli()
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {
|
|
for _, raw := range []json.RawMessage{
|
|
rec.Time,
|
|
rec.Timestamp,
|
|
rec.CreatedAt,
|
|
rec.CreatedAtSnake,
|
|
rec.UpdatedAt,
|
|
rec.UpdatedAtSnake,
|
|
} {
|
|
if at, ok := parseJSONTimestampMillis(raw); ok {
|
|
return at, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {
|
|
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
|
return 0, false
|
|
}
|
|
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err == nil {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return 0, false
|
|
}
|
|
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
|
return normalizeTimestampMillis(n)
|
|
}
|
|
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
|
return normalizeTimestampMillisFloat(f)
|
|
}
|
|
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
|
return t.UnixMilli(), true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.UseNumber()
|
|
var n json.Number
|
|
if err := dec.Decode(&n); err != nil {
|
|
return 0, false
|
|
}
|
|
if i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {
|
|
return normalizeTimestampMillis(i)
|
|
}
|
|
if f, err := strconv.ParseFloat(n.String(), 64); err == nil {
|
|
return normalizeTimestampMillisFloat(f)
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func normalizeTimestampMillis(v int64) (int64, bool) {
|
|
if v <= 0 {
|
|
return 0, false
|
|
}
|
|
switch {
|
|
case v >= 1_000_000_000_000_000_000:
|
|
return v / 1_000_000, true // nanoseconds
|
|
case v >= 1_000_000_000_000_000:
|
|
return v / 1_000, true // microseconds
|
|
case v >= 100_000_000_000:
|
|
return v, true // milliseconds
|
|
case v >= 1_000_000_000:
|
|
return v * 1_000, true // seconds
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func normalizeTimestampMillisFloat(v float64) (int64, bool) {
|
|
if v <= 0 {
|
|
return 0, false
|
|
}
|
|
switch {
|
|
case v >= 1_000_000_000_000_000_000:
|
|
return int64(v / 1_000_000), true
|
|
case v >= 1_000_000_000_000_000:
|
|
return int64(v / 1_000), true
|
|
case v >= 100_000_000_000:
|
|
return int64(v), true
|
|
case v >= 1_000_000_000:
|
|
return int64(v * 1_000), true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab
|
|
// scoped to that folder. Returns the chosen path ("" if cancelled).
|
|
func (a *App) PickWorkspace() (string, error) {
|
|
if a.ctx == nil {
|
|
return "", nil
|
|
}
|
|
cur, _ := os.Getwd()
|
|
a.mu.RLock()
|
|
if tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != "" {
|
|
cur = tab.WorkspaceRoot
|
|
}
|
|
a.mu.RUnlock()
|
|
dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
|
|
Title: "Choose working folder",
|
|
DefaultDirectory: dialogDefaultDirectory(cur),
|
|
})
|
|
if err != nil || dir == "" {
|
|
return "", err
|
|
}
|
|
return a.SwitchWorkspace(dir)
|
|
}
|
|
|
|
func dialogDefaultDirectory(preferred string) string {
|
|
if dir := nearestExistingDirectory(preferred); dir != "" {
|
|
return dir
|
|
}
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
if dir := nearestExistingDirectory(cwd); dir != "" {
|
|
return dir
|
|
}
|
|
}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
if dir := nearestExistingDirectory(home); dir != "" {
|
|
return dir
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func nearestExistingDirectory(path string) string {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
if abs, err := filepath.Abs(path); err == nil {
|
|
path = abs
|
|
}
|
|
for {
|
|
info, err := os.Stat(path)
|
|
if err == nil {
|
|
if info.IsDir() {
|
|
return path
|
|
}
|
|
path = filepath.Dir(path)
|
|
continue
|
|
}
|
|
parent := filepath.Dir(path)
|
|
if parent == path {
|
|
return ""
|
|
}
|
|
path = parent
|
|
}
|
|
}
|
|
|
|
func (a *App) ListWorkspaces() []WorkspaceMeta {
|
|
migrateLegacyWorkspacesIntoProjects()
|
|
activeRoot := ""
|
|
cur, _ := os.Getwd()
|
|
a.mu.RLock()
|
|
if tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != "" {
|
|
activeRoot = normalizeProjectRoot(tab.WorkspaceRoot)
|
|
}
|
|
a.mu.RUnlock()
|
|
if activeRoot == "" {
|
|
activeRoot = normalizeProjectRoot(cur)
|
|
}
|
|
projects := loadProjectsFile().Projects
|
|
out := make([]WorkspaceMeta, 0, len(projects))
|
|
for _, project := range projects {
|
|
out = append(out, WorkspaceMeta{
|
|
Path: project.Root,
|
|
Name: projectDisplayName(project),
|
|
Current: activeRoot != "" && sameProjectRoot(project.Root, activeRoot),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *App) RemoveWorkspace(dir string) error {
|
|
if dir == "" {
|
|
return fmt.Errorf("workspace path is required")
|
|
}
|
|
dir = normalizeProjectRoot(dir)
|
|
|
|
var fallback *WorkspaceTab
|
|
// sessionRemovalMu covers every step that can still touch this workspace's
|
|
// session files: snapshotting, unlinking the tab/runtime bindings, and
|
|
// closing the unlinked runtimes (quiescing autosave). Once a runtime is
|
|
// unlinked from a.tabs/detachedSessions it is invisible to
|
|
// DeleteSession/TrashTopic/RestoreSession, so it must stop writing before
|
|
// the lock is released. Project bookkeeping, the fallback controller build,
|
|
// and notifications run after release.
|
|
if err := func() error {
|
|
a.sessionRemovalMu.Lock()
|
|
defer a.sessionRemovalMu.Unlock()
|
|
|
|
type workspaceTabCandidate struct {
|
|
id string
|
|
tab *WorkspaceTab
|
|
}
|
|
|
|
var closeTabs []*WorkspaceTab
|
|
var closeDetached []*WorkspaceTab
|
|
a.mu.Lock()
|
|
for _, tab := range a.tabs {
|
|
if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
|
|
a.mu.Unlock()
|
|
return fmt.Errorf("workspace has running sessions; stop them before removing")
|
|
}
|
|
}
|
|
for _, tab := range a.detachedSessions {
|
|
if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
|
|
a.mu.Unlock()
|
|
return fmt.Errorf("workspace has running sessions; stop them before removing")
|
|
}
|
|
}
|
|
candidates := make([]workspaceTabCandidate, 0)
|
|
for id, tab := range a.tabs {
|
|
if !tabInWorkspace(tab, dir) {
|
|
continue
|
|
}
|
|
candidates = append(candidates, workspaceTabCandidate{id: id, tab: tab})
|
|
}
|
|
a.mu.Unlock()
|
|
|
|
snapshotted := make(map[string]*WorkspaceTab, len(candidates))
|
|
for _, candidate := range candidates {
|
|
id, tab := candidate.id, candidate.tab
|
|
snapshotted[id] = tab
|
|
if err := a.snapshotTab(tab); err != nil {
|
|
slog.Warn("desktop: snapshot before removing workspace failed", "tab", id, "workspace", dir, "err", err)
|
|
return fmt.Errorf("save current session before removing workspace: %w", err)
|
|
}
|
|
}
|
|
|
|
a.mu.Lock()
|
|
for _, tab := range a.tabs {
|
|
if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
|
|
a.mu.Unlock()
|
|
return fmt.Errorf("workspace has running sessions; stop them before removing")
|
|
}
|
|
}
|
|
for _, tab := range a.detachedSessions {
|
|
if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
|
|
a.mu.Unlock()
|
|
return fmt.Errorf("workspace has running sessions; stop them before removing")
|
|
}
|
|
}
|
|
for id, tab := range a.tabs {
|
|
if tabInWorkspace(tab, dir) && snapshotted[id] != tab {
|
|
a.mu.Unlock()
|
|
return fmt.Errorf("workspace tabs changed while removing; retry")
|
|
}
|
|
}
|
|
for _, candidate := range candidates {
|
|
id, tab := candidate.id, candidate.tab
|
|
if tab == nil || a.tabs[id] != tab || !tabInWorkspace(tab, dir) {
|
|
continue
|
|
}
|
|
a.markTabRemovedLocked(tab)
|
|
closeTabs = append(closeTabs, tab)
|
|
delete(a.tabs, id)
|
|
a.removeTabOrderLocked(id)
|
|
if a.activeTabID == id {
|
|
a.activeTabID = ""
|
|
}
|
|
}
|
|
for key, tab := range a.detachedSessions {
|
|
if !tabInWorkspace(tab, dir) {
|
|
continue
|
|
}
|
|
closeDetached = append(closeDetached, tab)
|
|
delete(a.detachedSessions, key)
|
|
}
|
|
if len(a.tabs) == 0 {
|
|
fallback = a.createTabEntry("global", globalTabWorkspaceRoot(), "")
|
|
fallback.TopicTitle = "Global"
|
|
fallback.sink = &tabEventSink{tabID: fallback.ID, app: a, ctx: a.ctx}
|
|
a.tabs[fallback.ID] = fallback
|
|
a.tabOrder = append(a.tabOrder, fallback.ID)
|
|
a.activeTabID = fallback.ID
|
|
} else if a.activeTabID == "" {
|
|
if ordered := a.orderedTabIDsLocked(); len(ordered) > 0 {
|
|
a.activeTabID = ordered[0]
|
|
}
|
|
}
|
|
a.saveTabsLocked()
|
|
a.mu.Unlock()
|
|
|
|
for _, tab := range closeTabs {
|
|
a.closeTabRuntime(tab)
|
|
}
|
|
for _, tab := range closeDetached {
|
|
a.closeTabRuntime(tab)
|
|
}
|
|
return nil
|
|
}(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// The fallback tab is already linked into a.tabs; its controller build is
|
|
// asynchronous and does not touch removed session files, so it does not
|
|
// need the removal lock.
|
|
if fallback != nil {
|
|
a.startTabControllerBuild(fallback)
|
|
}
|
|
|
|
forgetWorkspace(dir)
|
|
if err := removeProject(dir); err != nil {
|
|
return err
|
|
}
|
|
// If the removed workspace was the active one, clear the pointer
|
|
// so we don't leave a stale reference to a deleted project.
|
|
if loadWorkspace() == dir {
|
|
if remaining := loadProjectsFile(); len(remaining.Projects) > 0 {
|
|
// Fall back to the first remaining project
|
|
saveWorkspace(remaining.Projects[0].Root)
|
|
} else {
|
|
// No projects left; clear the active pointer entirely
|
|
clearWorkspace()
|
|
}
|
|
}
|
|
a.emitProjectTreeChanged()
|
|
return nil
|
|
}
|
|
|
|
func migrateLegacyWorkspacesIntoProjects() {
|
|
legacy := loadWorkspaces()
|
|
if len(legacy) == 0 {
|
|
return
|
|
}
|
|
_ = updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
|
seen := make(map[string]bool, len(f.Projects)+len(legacy))
|
|
for _, p := range f.Projects {
|
|
seen[p.Root] = true
|
|
}
|
|
changed := false
|
|
for _, path := range legacy {
|
|
root := normalizeProjectRoot(path)
|
|
if root == "" || seen[root] {
|
|
continue
|
|
}
|
|
f.Projects = append(f.Projects, desktopProject{Root: root})
|
|
seen[root] = true
|
|
changed = true
|
|
}
|
|
return changed, nil
|
|
})
|
|
}
|
|
|
|
func workspaceName(path string) string {
|
|
name := filepath.Base(path)
|
|
if name == "." || name == string(filepath.Separator) || name == "" {
|
|
return path
|
|
}
|
|
return name
|
|
}
|
|
|
|
// tabWorkspaceNameForScope resolves the display name for a tab's workspace.
|
|
// Callers pass tab.Scope copied under a.mu instead of re-reading the tab.
|
|
func tabWorkspaceNameForScope(scope, cwd string) string {
|
|
if scope == "global" {
|
|
return globalProjectTitle()
|
|
}
|
|
return workspaceName(cwd)
|
|
}
|
|
|
|
func (a *App) SwitchWorkspace(dir string) (string, error) {
|
|
if dir == "" {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dir = home
|
|
}
|
|
if abs, err := filepath.Abs(dir); err == nil {
|
|
dir = abs
|
|
}
|
|
info, err := os.Stat(dir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !info.IsDir() {
|
|
return "", fmt.Errorf("%s is not a directory", dir)
|
|
}
|
|
saveWorkspace(dir)
|
|
|
|
// Open a registered topic so the new workspace appears in the project tree
|
|
// immediately instead of only existing as an in-memory tab.
|
|
topic, err := a.CreateTopic("project", dir, "")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var meta TabMeta
|
|
if a.singleSurfaceLayoutEnabled() {
|
|
meta, err = a.ActivateTopic("project", dir, topic.ID, "")
|
|
} else {
|
|
meta, err = a.OpenProjectTab(dir, topic.ID)
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return meta.WorkspaceRoot, nil
|
|
}
|
|
|
|
func (a *App) singleSurfaceLayoutEnabled() bool {
|
|
cfg, _, err := a.loadDesktopUserConfigForView()
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return singleSurfaceLayoutStyle(cfg.DesktopLayoutStyle())
|
|
}
|
|
|
|
// HistoryMessage is one prior turn, for the frontend to repopulate its transcript
|
|
// after a reload.
|
|
type HistoryMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
Detail string `json:"detail,omitempty"`
|
|
Code string `json:"code,omitempty"`
|
|
SubmitText string `json:"submitText,omitempty"`
|
|
CheckpointTurn *int `json:"checkpointTurn,omitempty"`
|
|
Reasoning string `json:"reasoning,omitempty"`
|
|
MemoryCitations []provider.MemoryCitation `json:"memoryCitations,omitempty"`
|
|
WorkDurationMs int64 `json:"workDurationMs,omitempty"`
|
|
Level string `json:"level,omitempty"`
|
|
ToolCalls []HistoryToolCall `json:"toolCalls,omitempty"`
|
|
ToolCallID string `json:"toolCallId,omitempty"`
|
|
ToolName string `json:"toolName,omitempty"`
|
|
ToolResultArchived bool `json:"toolResultArchived,omitempty"`
|
|
ToolResultError string `json:"toolResultError,omitempty"`
|
|
Pending bool `json:"pending,omitempty"`
|
|
Trigger string `json:"trigger,omitempty"`
|
|
Messages int `json:"messages,omitempty"`
|
|
Summary string `json:"summary,omitempty"`
|
|
Archive string `json:"archive,omitempty"`
|
|
}
|
|
|
|
type HistoryToolCall struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
Subject string `json:"subject,omitempty"`
|
|
Summary string `json:"summary,omitempty"`
|
|
Diff string `json:"diff,omitempty"`
|
|
Added int `json:"added,omitempty"`
|
|
Removed int `json:"removed,omitempty"`
|
|
ArgumentsArchived bool `json:"argumentsArchived,omitempty"`
|
|
}
|
|
|
|
const (
|
|
defaultHistoryPageTurns = 60
|
|
maxHistoryPageTurns = 200
|
|
)
|
|
|
|
type HistoryPage struct {
|
|
Messages []HistoryMessage `json:"messages"`
|
|
StartTurn int `json:"startTurn"`
|
|
EndTurn int `json:"endTurn"`
|
|
TotalTurns int `json:"totalTurns"`
|
|
HasOlder bool `json:"hasOlder"`
|
|
}
|
|
|
|
// History returns the session's message log.
|
|
func (a *App) History() []HistoryMessage {
|
|
return a.HistoryForTab("")
|
|
}
|
|
|
|
func (a *App) HistoryPage(beforeTurn, limit int) HistoryPage {
|
|
return a.HistoryPageForTab("", beforeTurn, limit)
|
|
}
|
|
|
|
func (a *App) HistoryPageForTab(tabID string, beforeTurn, limit int) HistoryPage {
|
|
a.mu.RLock()
|
|
tab := a.tabByIDLocked(tabID)
|
|
var ctrl control.SessionAPI
|
|
var sessionDir, sessionPath string
|
|
if tab != nil {
|
|
ctrl = tab.Ctrl
|
|
sessionDir = tabSessionDir(tab)
|
|
sessionPath = tab.currentSessionPath()
|
|
}
|
|
a.mu.RUnlock()
|
|
if ctrl == nil {
|
|
if strings.TrimSpace(sessionPath) == "" {
|
|
return HistoryPage{Messages: []HistoryMessage{}}
|
|
}
|
|
page, err := previewSessionPage(sessionDir, sessionPath, beforeTurn, limit)
|
|
if err != nil {
|
|
return HistoryPage{Messages: []HistoryMessage{}}
|
|
}
|
|
return page
|
|
}
|
|
msgs := ctrl.History()
|
|
dir := controllerSessionDir(ctrl)
|
|
path := ctrl.SessionPath()
|
|
return historyPageFromProviderMessages(
|
|
msgs,
|
|
sessionDisplayResolver(dir, path),
|
|
sessionPlannerDisplayTurns(dir, path),
|
|
ctrl.CheckpointTurnsByMessageIndex(),
|
|
beforeTurn,
|
|
limit,
|
|
)
|
|
}
|
|
|
|
func normalizeHistoryPageLimit(limit int) int {
|
|
if limit <= 0 {
|
|
return defaultHistoryPageTurns
|
|
}
|
|
if limit > maxHistoryPageTurns {
|
|
return maxHistoryPageTurns
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func historyPageFromMessages(messages []HistoryMessage, beforeTurn, limit int) HistoryPage {
|
|
limit = normalizeHistoryPageLimit(limit)
|
|
totalTurns := 0
|
|
for _, msg := range messages {
|
|
if msg.Role == "user" {
|
|
totalTurns++
|
|
}
|
|
}
|
|
if beforeTurn <= 0 || beforeTurn > totalTurns {
|
|
beforeTurn = totalTurns
|
|
}
|
|
startTurn := beforeTurn - limit
|
|
if startTurn < 0 {
|
|
startTurn = 0
|
|
}
|
|
page := HistoryPage{
|
|
StartTurn: startTurn,
|
|
EndTurn: beforeTurn,
|
|
TotalTurns: totalTurns,
|
|
HasOlder: startTurn > 0,
|
|
}
|
|
if len(messages) == 0 || startTurn >= beforeTurn {
|
|
page.Messages = []HistoryMessage{}
|
|
return page
|
|
}
|
|
page.Messages = historyMessagesForTurnRange(messages, startTurn, beforeTurn)
|
|
return page
|
|
}
|
|
|
|
func historyMessagesForTurnRange(messages []HistoryMessage, startTurn, endTurn int) []HistoryMessage {
|
|
out := make([]HistoryMessage, 0, len(messages))
|
|
turn := -1
|
|
for _, msg := range messages {
|
|
if msg.Role == "user" {
|
|
turn++
|
|
}
|
|
if turn < 0 {
|
|
if startTurn == 0 {
|
|
out = append(out, msg)
|
|
}
|
|
continue
|
|
}
|
|
if turn >= startTurn && turn < endTurn {
|
|
out = append(out, msg)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *App) HistoryForTab(tabID string) []HistoryMessage {
|
|
a.mu.RLock()
|
|
tab := a.tabByIDLocked(tabID)
|
|
var ctrl control.SessionAPI
|
|
var sessionDir, sessionPath string
|
|
if tab != nil {
|
|
ctrl = tab.Ctrl
|
|
sessionDir = tabSessionDir(tab)
|
|
sessionPath = tab.currentSessionPath()
|
|
}
|
|
a.mu.RUnlock()
|
|
if ctrl == nil {
|
|
if strings.TrimSpace(sessionPath) == "" {
|
|
return []HistoryMessage{}
|
|
}
|
|
messages, err := previewSessionMessages(sessionDir, sessionPath)
|
|
if err != nil {
|
|
return []HistoryMessage{}
|
|
}
|
|
return messages
|
|
}
|
|
msgs := ctrl.History()
|
|
dir := controllerSessionDir(ctrl)
|
|
path := ctrl.SessionPath()
|
|
return historyMessagesWithPlannerDisplays(
|
|
msgs,
|
|
sessionDisplayResolver(dir, path),
|
|
sessionPlannerDisplayTurns(dir, path),
|
|
ctrl.CheckpointTurnsByMessageIndex(),
|
|
)
|
|
}
|
|
|
|
func (a *App) HistoryCheckpointTurnsForTab(tabID string) []int {
|
|
a.mu.RLock()
|
|
tab := a.tabByIDLocked(tabID)
|
|
var ctrl control.SessionAPI
|
|
if tab != nil {
|
|
ctrl = tab.Ctrl
|
|
}
|
|
a.mu.RUnlock()
|
|
if ctrl == nil {
|
|
return []int{}
|
|
}
|
|
return historyCheckpointTurns(
|
|
ctrl.History(),
|
|
sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()),
|
|
ctrl.CheckpointTurnsByMessageIndex(),
|
|
)
|
|
}
|
|
|
|
func historyCheckpointTurns(msgs []provider.Message, resolveUserContent func(string) string, checkpointTurns map[int]int) []int {
|
|
out := make([]int, 0)
|
|
for index, msg := range msgs {
|
|
if msg.Role != provider.RoleUser {
|
|
continue
|
|
}
|
|
if _, isSteer := agent.SteerText(msg.Content); isSteer {
|
|
continue
|
|
}
|
|
if control.IsSyntheticUserMessage(resolveUserContent(msg.Content)) {
|
|
continue
|
|
}
|
|
turn, ok := checkpointTurns[index]
|
|
if !ok {
|
|
turn = -1
|
|
}
|
|
out = append(out, turn)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {
|
|
return historyMessagesWithPlannerDisplays(msgs, resolveUserContent, nil, nil)
|
|
}
|
|
|
|
func historyMessagesWithPlannerDisplays(msgs []provider.Message, resolveUserContent func(string) string, plannerTurns []plannerDisplayTurn, checkpointTurns map[int]int) []HistoryMessage {
|
|
replayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)
|
|
toolResults := historyToolResultsByID(msgs)
|
|
return historyMessagesWithPlannerDisplaysAndLookups(msgs, resolveUserContent, plannerTurns, checkpointTurns, replayedTodoArgs, toolResults)
|
|
}
|
|
|
|
func historyMessagesWithPlannerDisplaysAndLookups(
|
|
msgs []provider.Message,
|
|
resolveUserContent func(string) string,
|
|
plannerTurns []plannerDisplayTurn,
|
|
checkpointTurns map[int]int,
|
|
replayedTodoArgs map[string]string,
|
|
toolResults map[string]provider.Message,
|
|
) []HistoryMessage {
|
|
out := make([]HistoryMessage, 0, len(msgs))
|
|
plannerByUserHash := plannerTurnsByUserHash(plannerTurns)
|
|
for index, m := range msgs {
|
|
content := m.Content
|
|
var checkpointTurn *int
|
|
if m.Role == provider.RoleUser {
|
|
// Mid-turn steer messages are persisted in the session so they
|
|
// survive tab switches. They are surfaced as a notice (↪ text)
|
|
// — matching the live Steer event look — rather than as a
|
|
// regular user bubble or being filtered as synthetic (#4044).
|
|
// Check against the raw m.Content: resolveUserContent applies
|
|
// StripComposePrefixes which trims trailing whitespace.
|
|
if steerText, isSteer := agent.SteerText(m.Content); isSteer {
|
|
out = append(out, HistoryMessage{Role: "notice", Content: "↪ " + steerText})
|
|
continue
|
|
}
|
|
content = resolveUserContent(m.Content)
|
|
if control.IsSyntheticUserMessage(content) {
|
|
continue
|
|
}
|
|
if turn, ok := checkpointTurns[index]; ok {
|
|
turnCopy := turn
|
|
checkpointTurn = &turnCopy
|
|
}
|
|
}
|
|
reasoning := ""
|
|
if m.Role == provider.RoleAssistant {
|
|
reasoning = m.ReasoningContent
|
|
}
|
|
hm := HistoryMessage{Role: string(m.Role), Content: content, CheckpointTurn: checkpointTurn, Reasoning: reasoning, WorkDurationMs: m.WorkDurationMs}
|
|
if m.Role == provider.RoleAssistant && len(m.MemoryCitations) > 0 {
|
|
hm.MemoryCitations = append([]provider.MemoryCitation(nil), m.MemoryCitations...)
|
|
}
|
|
if m.Role == provider.RoleUser && content != m.Content {
|
|
if agent.ContainsMemoryCompilerExecution(m.Content) {
|
|
// Never expose the compiler contract itself. A safely unwrapped
|
|
// slash invocation is useful display metadata, though: it lets the
|
|
// frontend restore the selected skill/subagent in history and trash.
|
|
if replay := control.StripComposePrefixes(m.Content); strings.HasPrefix(strings.TrimSpace(replay), "/") && replay != content {
|
|
hm.SubmitText = replay
|
|
}
|
|
} else {
|
|
hm.SubmitText = m.Content
|
|
}
|
|
}
|
|
if m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {
|
|
hm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))
|
|
for i, tc := range m.ToolCalls {
|
|
args := tc.Arguments
|
|
if tc.Name == "todo_write" {
|
|
if replayed, ok := replayedTodoArgs[tc.ID]; ok {
|
|
args = replayed
|
|
}
|
|
}
|
|
hm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])
|
|
}
|
|
}
|
|
if m.Role == provider.RoleTool {
|
|
hm.ToolCallID = m.ToolCallID
|
|
hm.ToolName = m.Name
|
|
hm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != "")
|
|
}
|
|
out = append(out, hm)
|
|
if m.Role == provider.RoleUser {
|
|
if turns := plannerByUserHash[messageDisplayKey(m.Content)]; len(turns) > 0 {
|
|
out = append(out, cloneHistoryMessages(turns[0].Messages)...)
|
|
plannerByUserHash[messageDisplayKey(m.Content)] = turns[1:]
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func historyPageFromProviderMessages(
|
|
msgs []provider.Message,
|
|
resolveUserContent func(string) string,
|
|
plannerTurns []plannerDisplayTurn,
|
|
checkpointTurns map[int]int,
|
|
beforeTurn, limit int,
|
|
) HistoryPage {
|
|
limit = normalizeHistoryPageLimit(limit)
|
|
totalTurns := visibleHistoryUserTurns(msgs, resolveUserContent)
|
|
if beforeTurn <= 0 || beforeTurn > totalTurns {
|
|
beforeTurn = totalTurns
|
|
}
|
|
startTurn := beforeTurn - limit
|
|
if startTurn < 0 {
|
|
startTurn = 0
|
|
}
|
|
page := HistoryPage{
|
|
StartTurn: startTurn,
|
|
EndTurn: beforeTurn,
|
|
TotalTurns: totalTurns,
|
|
HasOlder: startTurn > 0,
|
|
}
|
|
if len(msgs) == 0 || startTurn >= beforeTurn {
|
|
page.Messages = []HistoryMessage{}
|
|
return page
|
|
}
|
|
pageMessages, originalIndexes := providerMessagesForVisibleTurnRange(msgs, resolveUserContent, startTurn, beforeTurn)
|
|
page.Messages = historyMessagesWithPlannerDisplaysAndLookups(
|
|
pageMessages,
|
|
resolveUserContent,
|
|
plannerTurns,
|
|
checkpointTurnsForProviderWindow(checkpointTurns, originalIndexes),
|
|
historyTodoArgsWithCompleteSteps(msgs),
|
|
historyToolResultsByID(msgs),
|
|
)
|
|
return page
|
|
}
|
|
|
|
func visibleHistoryUserTurns(msgs []provider.Message, resolveUserContent func(string) string) int {
|
|
total := 0
|
|
for _, msg := range msgs {
|
|
if isVisibleHistoryUser(msg, resolveUserContent) {
|
|
total++
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
func isVisibleHistoryUser(msg provider.Message, resolveUserContent func(string) string) bool {
|
|
if msg.Role != provider.RoleUser {
|
|
return false
|
|
}
|
|
if _, isSteer := agent.SteerText(msg.Content); isSteer {
|
|
return false
|
|
}
|
|
return !control.IsSyntheticUserMessage(resolveUserContent(msg.Content))
|
|
}
|
|
|
|
func providerMessagesForVisibleTurnRange(msgs []provider.Message, resolveUserContent func(string) string, startTurn, endTurn int) ([]provider.Message, []int) {
|
|
out := make([]provider.Message, 0, len(msgs))
|
|
indexes := make([]int, 0, len(msgs))
|
|
turn := -1
|
|
for index, msg := range msgs {
|
|
if isVisibleHistoryUser(msg, resolveUserContent) {
|
|
turn++
|
|
}
|
|
if turn < 0 {
|
|
if startTurn == 0 {
|
|
out = append(out, msg)
|
|
indexes = append(indexes, index)
|
|
}
|
|
continue
|
|
}
|
|
if turn >= startTurn && turn < endTurn {
|
|
out = append(out, msg)
|
|
indexes = append(indexes, index)
|
|
}
|
|
}
|
|
return out, indexes
|
|
}
|
|
|
|
func checkpointTurnsForProviderWindow(checkpointTurns map[int]int, originalIndexes []int) map[int]int {
|
|
if len(checkpointTurns) == 0 || len(originalIndexes) == 0 {
|
|
return nil
|
|
}
|
|
out := map[int]int{}
|
|
for pageIndex, originalIndex := range originalIndexes {
|
|
if turn, ok := checkpointTurns[originalIndex]; ok {
|
|
out[pageIndex] = turn
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func plannerTurnsByUserHash(turns []plannerDisplayTurn) map[string][]plannerDisplayTurn {
|
|
out := map[string][]plannerDisplayTurn{}
|
|
for _, turn := range turns {
|
|
if strings.TrimSpace(turn.UserHash) == "" || len(turn.Messages) == 0 {
|
|
continue
|
|
}
|
|
out[turn.UserHash] = append(out[turn.UserHash], turn)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneHistoryMessages(in []HistoryMessage) []HistoryMessage {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]HistoryMessage, len(in))
|
|
copy(out, in)
|
|
for i := range out {
|
|
if len(in[i].MemoryCitations) > 0 {
|
|
out[i].MemoryCitations = append([]provider.MemoryCitation(nil), in[i].MemoryCitations...)
|
|
}
|
|
if len(in[i].ToolCalls) > 0 {
|
|
out[i].ToolCalls = append([]HistoryToolCall(nil), in[i].ToolCalls...)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
const historyToolPreviewLimit = 2_000
|
|
|
|
func historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {
|
|
call := HistoryToolCall{
|
|
ID: tc.ID,
|
|
Name: tc.Name,
|
|
Subject: historyToolSubject(tc.Name, args),
|
|
Summary: historyToolSummary(tc.Name, args, result.Content),
|
|
Diff: tc.Diff,
|
|
Added: tc.Added,
|
|
Removed: tc.Removed,
|
|
}
|
|
if tc.Name == "todo_write" {
|
|
call.Arguments = args
|
|
return call
|
|
}
|
|
if tc.ID == "" {
|
|
call.Arguments = args
|
|
return call
|
|
}
|
|
if args != "" {
|
|
call.ArgumentsArchived = true
|
|
}
|
|
return call
|
|
}
|
|
|
|
func historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {
|
|
out := map[string]provider.Message{}
|
|
for _, msg := range msgs {
|
|
if msg.Role != provider.RoleTool || msg.ToolCallID == "" {
|
|
continue
|
|
}
|
|
out[msg.ToolCallID] = msg
|
|
}
|
|
return out
|
|
}
|
|
|
|
func historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {
|
|
if content == "" {
|
|
return "", false, ""
|
|
}
|
|
if !canArchive {
|
|
if historyToolResultFailed(content) {
|
|
return content, false, content
|
|
}
|
|
return content, false, ""
|
|
}
|
|
if historyToolResultFailed(content) {
|
|
display = clipHistoryToolPreview(strings.TrimSpace(content))
|
|
return display, display != content, display
|
|
}
|
|
return "", true, ""
|
|
}
|
|
|
|
func clipHistoryToolPreview(s string) string {
|
|
if len(s) <= historyToolPreviewLimit {
|
|
return s
|
|
}
|
|
return strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + "\n..."
|
|
}
|
|
|
|
func historyToolSubject(name, args string) string {
|
|
a := parseHistoryToolArgs(args)
|
|
var subject string
|
|
switch name {
|
|
case "bash":
|
|
subject = historyArgString(a, "command")
|
|
case "grep", "glob":
|
|
subject = firstNonEmpty(historyArgString(a, "pattern"), historyArgString(a, "path"))
|
|
case "web_fetch":
|
|
subject = historyArgString(a, "url")
|
|
case "task":
|
|
subject = firstNonEmpty(historyArgString(a, "description"), historyArgString(a, "prompt"))
|
|
case "run_skill":
|
|
subject = historyArgString(a, "name")
|
|
case "move_file":
|
|
src := historyArgString(a, "source_path")
|
|
dst := historyArgString(a, "destination_path")
|
|
if src != "" && dst != "" {
|
|
subject = src + " -> " + dst
|
|
} else {
|
|
subject = firstNonEmpty(src, dst)
|
|
}
|
|
case "remember":
|
|
subject = firstNonEmpty(historyArgString(a, "name"), historyArgString(a, "description"))
|
|
case "todo_write", "exit_plan_mode":
|
|
subject = ""
|
|
default:
|
|
subject = firstNonEmpty(historyArgString(a, "path"), historyArgString(a, "file_path"))
|
|
}
|
|
return clipSingleLine(subject, 240)
|
|
}
|
|
|
|
func historyToolSummary(name, args, output string) string {
|
|
if historyToolResultFailed(output) {
|
|
return ""
|
|
}
|
|
a := parseHistoryToolArgs(args)
|
|
switch name {
|
|
case "write_file":
|
|
if content := historyArgString(a, "content"); content != "" {
|
|
return fmt.Sprintf("%d lines", historyLineCount(content))
|
|
}
|
|
case "edit_file":
|
|
oldText := historyArgString(a, "old_string")
|
|
newText := historyArgString(a, "new_string")
|
|
if oldText != "" || newText != "" {
|
|
return fmt.Sprintf("%d -> %d lines", historyLineCount(oldText), historyLineCount(newText))
|
|
}
|
|
case "multi_edit":
|
|
if edits, ok := a["edits"].([]any); ok && len(edits) > 0 {
|
|
return fmt.Sprintf("%d edits", len(edits))
|
|
}
|
|
}
|
|
if output == "" {
|
|
return ""
|
|
}
|
|
switch name {
|
|
case "read_file":
|
|
if strings.HasPrefix(output, "(empty file)") {
|
|
return "empty file"
|
|
}
|
|
if arrows := strings.Count(output, "→"); arrows > 0 {
|
|
return fmt.Sprintf("%d lines", arrows)
|
|
}
|
|
return fmt.Sprintf("%d lines", historyLineCount(output))
|
|
case "grep":
|
|
return fmt.Sprintf("%d matches", historyNonEmptyLineCount(output))
|
|
case "glob":
|
|
return fmt.Sprintf("%d files", historyNonEmptyLineCount(output))
|
|
case "ls":
|
|
return fmt.Sprintf("%d entries", historyNonEmptyLineCount(output))
|
|
case "web_fetch":
|
|
return clipSingleLine(strings.SplitN(output, "\n", 2)[0], 80)
|
|
case "bash":
|
|
if strings.TrimSpace(output) == "" {
|
|
return "no output"
|
|
}
|
|
return fmt.Sprintf("%d lines", historyLineCount(output))
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func parseHistoryToolArgs(args string) map[string]any {
|
|
if args == "" {
|
|
return map[string]any{}
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal([]byte(args), &out); err != nil {
|
|
return map[string]any{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func historyArgString(args map[string]any, key string) string {
|
|
if v, ok := args[key].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func historyLineCount(s string) int {
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
s = strings.TrimSuffix(s, "\n")
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
return strings.Count(s, "\n") + 1
|
|
}
|
|
|
|
func historyNonEmptyLineCount(s string) int {
|
|
count := 0
|
|
for _, line := range strings.Split(s, "\n") {
|
|
if strings.TrimSpace(line) != "" {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func clipSingleLine(s string, max int) string {
|
|
s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
if max <= 3 {
|
|
return clipStringBytes(s, max)
|
|
}
|
|
return clipStringBytes(s, max-3) + "..."
|
|
}
|
|
|
|
func clipStringBytes(s string, max int) string {
|
|
if max <= 0 {
|
|
return ""
|
|
}
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
for max > 0 && !utf8.RuneStart(s[max]) {
|
|
max--
|
|
}
|
|
return s[:max]
|
|
}
|
|
|
|
func historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {
|
|
successful := successfulHistoryToolCallIDs(msgs)
|
|
out := map[string]string{}
|
|
var todos []evidence.TodoItem
|
|
latestTodoID := ""
|
|
for _, m := range msgs {
|
|
for _, tc := range m.ToolCalls {
|
|
if tc.ID == "" || !successful[tc.ID] {
|
|
continue
|
|
}
|
|
switch tc.Name {
|
|
case "todo_write":
|
|
rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)
|
|
if len(rec.Todos) == 0 {
|
|
continue
|
|
}
|
|
todos = append([]evidence.TodoItem(nil), rec.Todos...)
|
|
latestTodoID = tc.ID
|
|
if args, ok := todoArgsJSON(todos); ok {
|
|
out[latestTodoID] = args
|
|
}
|
|
case "complete_step":
|
|
if latestTodoID == "" || len(todos) == 0 {
|
|
continue
|
|
}
|
|
rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)
|
|
match, ok := evidence.MatchStep(rec.Step, todos)
|
|
if !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == "completed" {
|
|
continue
|
|
}
|
|
todos[match.Index-1].Status = "completed"
|
|
promoteNextHistoryTodo(todos)
|
|
if args, ok := todoArgsJSON(todos); ok {
|
|
out[latestTodoID] = args
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {
|
|
successful := map[string]bool{}
|
|
for _, msg := range msgs {
|
|
if msg.Role != provider.RoleTool || msg.ToolCallID == "" {
|
|
continue
|
|
}
|
|
if !historyToolResultFailed(msg.Content) {
|
|
successful[msg.ToolCallID] = true
|
|
}
|
|
}
|
|
return successful
|
|
}
|
|
|
|
func historyToolResultFailed(content string) bool {
|
|
content = strings.TrimSpace(content)
|
|
return strings.HasPrefix(content, "error:") ||
|
|
strings.HasPrefix(content, "blocked:") ||
|
|
strings.HasPrefix(content, "Error:") ||
|
|
strings.HasPrefix(content, "[error")
|
|
}
|
|
|
|
func todoArgsJSON(todos []evidence.TodoItem) (string, bool) {
|
|
b, err := json.Marshal(map[string]any{"todos": todos})
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return string(b), true
|
|
}
|
|
|
|
func promoteNextHistoryTodo(todos []evidence.TodoItem) {
|
|
for _, todo := range todos {
|
|
if todoStatusForHistory(todo.Status) == "in_progress" {
|
|
return
|
|
}
|
|
}
|
|
for i := range todos {
|
|
if todoStatusForHistory(todos[i].Status) == "pending" {
|
|
todos[i].Status = "in_progress"
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func todoStatusForHistory(status string) string {
|
|
status = strings.TrimSpace(status)
|
|
if status == "" {
|
|
return "pending"
|
|
}
|
|
return status
|
|
}
|
|
|
|
func previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {
|
|
sessionPath, _, err := validateSessionPath(sessionDir, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {
|
|
return out, err
|
|
}
|
|
loaded, err := agent.LoadSession(sessionPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return historyMessagesWithPlannerDisplays(
|
|
loaded.Snapshot(),
|
|
sessionDisplayResolver(sessionDir, sessionPath),
|
|
sessionPlannerDisplayTurns(sessionDir, sessionPath),
|
|
nil,
|
|
), nil
|
|
}
|
|
|
|
func previewSessionPage(sessionDir, path string, beforeTurn, limit int) (HistoryPage, error) {
|
|
sessionPath, _, err := validateSessionPath(sessionDir, path)
|
|
if err != nil {
|
|
return HistoryPage{}, err
|
|
}
|
|
if out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {
|
|
if err != nil {
|
|
return HistoryPage{}, err
|
|
}
|
|
return historyPageFromMessages(out, beforeTurn, limit), nil
|
|
}
|
|
loaded, err := agent.LoadSession(sessionPath)
|
|
if err != nil {
|
|
return HistoryPage{}, err
|
|
}
|
|
return historyPageFromProviderMessages(
|
|
loaded.Snapshot(),
|
|
sessionDisplayResolver(sessionDir, sessionPath),
|
|
sessionPlannerDisplayTurns(sessionDir, sessionPath),
|
|
nil,
|
|
beforeTurn,
|
|
limit,
|
|
), nil
|
|
}
|
|
|
|
type previewEventRecord struct {
|
|
Kind string `json:"kind"`
|
|
Type string `json:"type"`
|
|
Role string `json:"role"`
|
|
Time json.RawMessage `json:"time"`
|
|
Timestamp json.RawMessage `json:"timestamp"`
|
|
CreatedAt json.RawMessage `json:"createdAt"`
|
|
CreatedAtSnake json.RawMessage `json:"created_at"`
|
|
UpdatedAt json.RawMessage `json:"updatedAt"`
|
|
UpdatedAtSnake json.RawMessage `json:"updated_at"`
|
|
Text string `json:"text"`
|
|
Detail string `json:"detail"`
|
|
Code string `json:"code"`
|
|
Content string `json:"content"`
|
|
Reasoning string `json:"reasoning"`
|
|
ReasoningContent string `json:"reasoningContent"`
|
|
MemoryCitations []provider.MemoryCitation `json:"memoryCitations"`
|
|
Level string `json:"level"`
|
|
ToolCalls []previewToolCall `json:"toolCalls"`
|
|
CallID string `json:"callId"`
|
|
ToolCallID string `json:"toolCallId"`
|
|
ToolName string `json:"toolName"`
|
|
Name string `json:"name"`
|
|
Output string `json:"output"`
|
|
Compaction *previewCompaction `json:"compaction"`
|
|
Trigger string `json:"trigger"`
|
|
Messages int `json:"messages"`
|
|
Summary string `json:"summary"`
|
|
Archive string `json:"archive"`
|
|
}
|
|
|
|
type previewToolCall struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
Function struct {
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
} `json:"function"`
|
|
}
|
|
|
|
type previewCompaction struct {
|
|
Trigger string `json:"trigger"`
|
|
Messages int `json:"messages"`
|
|
Summary string `json:"summary"`
|
|
Archive string `json:"archive"`
|
|
}
|
|
|
|
func previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
defer f.Close()
|
|
|
|
dec := json.NewDecoder(f)
|
|
out := []HistoryMessage{}
|
|
toolName := map[string]string{}
|
|
sawEvent := false
|
|
for {
|
|
var rec previewEventRecord
|
|
if err := dec.Decode(&rec); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if sawEvent {
|
|
return out, true, nil
|
|
}
|
|
return nil, false, nil
|
|
}
|
|
eventName := strings.TrimSpace(rec.Kind)
|
|
if eventName == "" {
|
|
eventName = strings.TrimSpace(rec.Type)
|
|
}
|
|
if eventName == "" {
|
|
continue
|
|
}
|
|
sawEvent = true
|
|
switch eventName {
|
|
case "user.message":
|
|
if rec.Text != "" {
|
|
out = append(out, HistoryMessage{Role: "user", Content: rec.Text})
|
|
}
|
|
case "model.final":
|
|
hm := HistoryMessage{Role: "assistant", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}
|
|
if len(rec.MemoryCitations) > 0 {
|
|
hm.MemoryCitations = append([]provider.MemoryCitation(nil), rec.MemoryCitations...)
|
|
}
|
|
for _, tc := range rec.ToolCalls {
|
|
id := tc.ID
|
|
name := firstNonEmpty(tc.Name, tc.Function.Name)
|
|
args := firstNonEmpty(tc.Arguments, tc.Function.Arguments)
|
|
hm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))
|
|
if id != "" {
|
|
toolName[id] = name
|
|
}
|
|
}
|
|
out = append(out, hm)
|
|
case "tool.result":
|
|
callID := firstNonEmpty(rec.CallID, rec.ToolCallID)
|
|
content := firstNonEmpty(rec.Output, rec.Content)
|
|
display, archived, errPreview := historyToolResultContent(content, callID != "")
|
|
if len(out) > 0 && callID != "" {
|
|
updateHistoryToolCallSummary(out, callID, content)
|
|
}
|
|
out = append(out, HistoryMessage{
|
|
Role: "tool",
|
|
ToolCallID: callID,
|
|
ToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),
|
|
Content: display,
|
|
ToolResultArchived: archived,
|
|
ToolResultError: errPreview,
|
|
})
|
|
case "phase":
|
|
out = append(out, HistoryMessage{Role: "phase", Content: firstNonEmpty(rec.Text, rec.Content)})
|
|
case "notice":
|
|
level := rec.Level
|
|
if level != "warn" {
|
|
level = "info"
|
|
}
|
|
out = append(out, HistoryMessage{Role: "notice", Level: level, Content: firstNonEmpty(rec.Text, rec.Content), Detail: rec.Detail, Code: rec.Code})
|
|
case "compaction_started":
|
|
c := rec.compactionPayload()
|
|
out = append(out, HistoryMessage{Role: "compaction", Pending: true, Trigger: c.Trigger})
|
|
case "compaction_done":
|
|
c := rec.compactionPayload()
|
|
out = append(out, HistoryMessage{
|
|
Role: "compaction",
|
|
Trigger: c.Trigger,
|
|
Messages: c.Messages,
|
|
Summary: c.Summary,
|
|
Archive: c.Archive,
|
|
})
|
|
}
|
|
}
|
|
return out, sawEvent, nil
|
|
}
|
|
|
|
func (r previewEventRecord) compactionPayload() previewCompaction {
|
|
if r.Compaction != nil {
|
|
return *r.Compaction
|
|
}
|
|
return previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}
|
|
}
|
|
|
|
func updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {
|
|
if callID == "" {
|
|
return
|
|
}
|
|
for i := len(out) - 1; i >= 0; i-- {
|
|
for j := range out[i].ToolCalls {
|
|
call := &out[i].ToolCalls[j]
|
|
if call.ID != callID {
|
|
continue
|
|
}
|
|
if call.Summary == "" {
|
|
call.Summary = historyToolSummary(call.Name, call.Arguments, output)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used
|
|
// and Window both zero means no context-window data yet.
|
|
type ContextInfo struct {
|
|
Used int `json:"used"`
|
|
Window int `json:"window"`
|
|
SessionTokens int `json:"sessionTokens"`
|
|
CompactRatio float64 `json:"compactRatio,omitempty"`
|
|
SessionCost float64 `json:"sessionCost,omitempty"`
|
|
SessionCurrency string `json:"sessionCurrency,omitempty"`
|
|
CacheHitTokens int `json:"cacheHitTokens,omitempty"`
|
|
CacheMissTokens int `json:"cacheMissTokens,omitempty"`
|
|
Sources map[string]usageSourceStats `json:"sources,omitempty"`
|
|
}
|
|
|
|
// ContextUsage returns the latest context-window gauge numbers.
|
|
func (a *App) ContextUsage() ContextInfo {
|
|
return a.ContextUsageForTab("")
|
|
}
|
|
|
|
func (a *App) ContextUsageForTab(tabID string) ContextInfo {
|
|
a.mu.RLock()
|
|
tab := a.tabByIDLocked(tabID)
|
|
var ctrl control.SessionAPI
|
|
if tab != nil {
|
|
ctrl = tab.Ctrl
|
|
}
|
|
a.mu.RUnlock()
|
|
|
|
var info ContextInfo
|
|
if tab != nil {
|
|
// Re-key first: a controller-side rotation (typed /new) may have
|
|
// swapped sessions without the App noticing, and the stale totals
|
|
// would otherwise be reported — and then persisted — under the new
|
|
// session (#5850).
|
|
if ctrl != nil {
|
|
if sp := ctrl.SessionPath(); sp != "" {
|
|
tab.syncTelemetryToSession(sp)
|
|
}
|
|
}
|
|
snap := tab.telemetrySnapshot()
|
|
info.SessionTokens = snap.Usage.TotalTokens
|
|
info.SessionCost = snap.Usage.SessionCost
|
|
info.SessionCurrency = snap.Usage.SessionCurrency
|
|
info.CacheHitTokens = snap.Usage.CacheHitTokens
|
|
info.CacheMissTokens = snap.Usage.CacheMissTokens
|
|
info.Sources = snap.Usage.Sources
|
|
}
|
|
if ctrl == nil {
|
|
return info
|
|
}
|
|
used, window := ctrl.ContextSnapshot()
|
|
info.Used = used
|
|
info.Window = window
|
|
info.CompactRatio = ctrl.CompactRatio()
|
|
return info
|
|
}
|
|
|
|
// BalanceInfo is the wallet-balance readout for the status bar. Available is true
|
|
// only when a balance was fetched; Display is the formatted amount (e.g. "¥110.00")
|
|
// and is "" when the active provider declares no balance_url — the frontend then
|
|
// omits the readout. Err carries a fetch failure for an optional tooltip.
|
|
type BalanceInfo struct {
|
|
Available bool `json:"available"`
|
|
Display string `json:"display"`
|
|
Err string `json:"err,omitempty"`
|
|
}
|
|
|
|
// Balance queries the active provider's wallet balance (a network call). It
|
|
// returns an empty (unavailable) readout when no provider balance_url is set, the
|
|
// controller is down, or the fetch fails — so the status bar simply shows nothing
|
|
// rather than an error.
|
|
func (a *App) Balance() BalanceInfo {
|
|
return a.BalanceForTab("")
|
|
}
|
|
|
|
func (a *App) BalanceForTab(tabID string) BalanceInfo {
|
|
ctrl := a.ctrlByTabID(tabID)
|
|
if ctrl == nil {
|
|
return BalanceInfo{}
|
|
}
|
|
b, err := ctrl.Balance(a.ctx)
|
|
if err != nil {
|
|
return BalanceInfo{Err: err.Error()}
|
|
}
|
|
if b == nil {
|
|
return BalanceInfo{} // provider declares no balance endpoint
|
|
}
|
|
return BalanceInfo{Available: true, Display: b.Display()}
|
|
}
|
|
|
|
// JobView is one running background job (bash/task started with
|
|
// run_in_background) for the status-bar indicator.
|
|
type JobView struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Label string `json:"label"`
|
|
Status string `json:"status"`
|
|
StartedAt int64 `json:"startedAt"`
|
|
}
|
|
|
|
// Jobs returns the still-running background jobs for the status bar. It refreshes
|
|
// on demand (mount, turn end, and on each notice the frontend receives).
|
|
func (a *App) Jobs() []JobView {
|
|
out := []JobView{}
|
|
ctrl := a.ctrlByTabID("")
|
|
return a.jobsForCtrl(ctrl, out)
|
|
}
|
|
|
|
func (a *App) JobsForTab(tabID string) []JobView {
|
|
out := []JobView{}
|
|
ctrl := a.ctrlByTabID(tabID)
|
|
return a.jobsForCtrl(ctrl, out)
|
|
}
|
|
|
|
func (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {
|
|
if ctrl == nil {
|
|
return out
|
|
}
|
|
for _, v := range ctrl.Jobs() {
|
|
out = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Meta describes the session for the frontend's header and status line.
|
|
type Meta struct {
|
|
Label string `json:"label"`
|
|
Ready bool `json:"ready"`
|
|
StartupErr string `json:"startupErr,omitempty"`
|
|
EventChannel string `json:"eventChannel"`
|
|
Cwd string `json:"cwd"`
|
|
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
|
|
WorkspaceName string `json:"workspaceName,omitempty"`
|
|
WorkspacePath string `json:"workspacePath,omitempty"`
|
|
GitBranch string `json:"gitBranch,omitempty"`
|
|
ImageInputEnabled bool `json:"imageInputEnabled"`
|
|
AutoApproveTools bool `json:"autoApproveTools"`
|
|
Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
|
|
CollaborationMode string `json:"collaborationMode"`
|
|
ToolApprovalMode string `json:"toolApprovalMode"`
|
|
TokenMode string `json:"tokenMode"`
|
|
Goal string `json:"goal,omitempty"`
|
|
GoalStatus string `json:"goalStatus,omitempty"`
|
|
AutoResearch *AutoResearchCompactView `json:"autoResearch,omitempty"`
|
|
}
|
|
|
|
type AutoResearchCompactView struct {
|
|
TaskID string `json:"taskId"`
|
|
Status string `json:"status"`
|
|
Iteration int `json:"iteration"`
|
|
PivotRequired bool `json:"pivotRequired"`
|
|
StaleCount int `json:"staleCount"`
|
|
}
|
|
|
|
type AutoResearchCriterionView struct {
|
|
ID string `json:"id"`
|
|
Description string `json:"description"`
|
|
Required bool `json:"required"`
|
|
EvidenceCount int `json:"evidenceCount"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type AutoResearchStatusView struct {
|
|
TaskID string `json:"taskId"`
|
|
Goal string `json:"goal"`
|
|
Status string `json:"status"`
|
|
Iteration int `json:"iteration"`
|
|
CurrentDirection string `json:"currentDirection"`
|
|
StaleCount int `json:"staleCount"`
|
|
PivotCount int `json:"pivotCount"`
|
|
PivotRequired bool `json:"pivotRequired"`
|
|
LastHeartbeatAt string `json:"lastHeartbeatAt"`
|
|
FindingCount int `json:"findingCount"`
|
|
OpenCriteria []AutoResearchCriterionView `json:"openCriteria"`
|
|
Blocker string `json:"blocker"`
|
|
TaskPath string `json:"taskPath"`
|
|
NextRequiredAction string `json:"nextRequiredAction"`
|
|
}
|
|
|
|
type AutoResearchFindingView struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Summary string `json:"summary"`
|
|
Source string `json:"source"`
|
|
Command string `json:"command,omitempty"`
|
|
Paths []string `json:"paths,omitempty"`
|
|
Accepted bool `json:"accepted"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
type AutoResearchEvidenceView struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Summary string `json:"summary"`
|
|
Source string `json:"source"`
|
|
Command string `json:"command,omitempty"`
|
|
Paths []string `json:"paths,omitempty"`
|
|
Accepted bool `json:"accepted"`
|
|
}
|
|
|
|
// Meta reports the model label, readiness, any startup error, the working
|
|
// directory (for the status line), and the runtime event channel the frontend
|
|
// subscribes to.
|
|
func (a *App) Meta() Meta {
|
|
return a.MetaForTab("")
|
|
}
|
|
|
|
func (a *App) imageInputEnabledForTab(tabID string) bool {
|
|
a.mu.RLock()
|
|
tab := a.tabByIDLocked(tabID)
|
|
var ref, root string
|
|
if tab != nil {
|
|
ref = tab.model
|
|
root = tab.WorkspaceRoot
|
|
}
|
|
a.mu.RUnlock()
|
|
if tab == nil {
|
|
return false
|
|
}
|
|
cfg, err := config.LoadForRoot(root)
|
|
if err == nil && ref == "" {
|
|
ref = cfg.DefaultModel
|
|
}
|
|
if err != nil || ref == "" {
|
|
return false
|
|
}
|
|
entry, ok := cfg.ResolveModel(ref)
|
|
return ok && config.EffectiveVision(entry)
|
|
}
|
|
|
|
func (a *App) MetaForTab(tabID string) Meta {
|
|
a.mu.RLock()
|
|
tab := a.tabByIDLocked(tabID)
|
|
snap := snapshotTabRuntimeLocked(tab)
|
|
a.mu.RUnlock()
|
|
if tab == nil {
|
|
return Meta{EventChannel: eventChannel}
|
|
}
|
|
cwd := snap.workspaceRoot
|
|
if cwd == "" {
|
|
cwd, _ = os.Getwd()
|
|
}
|
|
autoApproveTools := snap.ctrl != nil && snap.ctrl.AutoApproveTools()
|
|
collaborationMode := snap.collaborationMode()
|
|
toolApprovalMode := snap.currentToolApprovalMode()
|
|
tokenMode := snap.currentTokenMode()
|
|
goal := snap.currentGoal()
|
|
goalStatus := snap.currentGoalStatus()
|
|
return Meta{
|
|
Label: snap.label,
|
|
Ready: snap.ready,
|
|
StartupErr: snap.startupErr,
|
|
EventChannel: eventChannel,
|
|
Cwd: cwd,
|
|
WorkspaceRoot: cwd,
|
|
WorkspaceName: tabWorkspaceNameForScope(snap.scope, cwd),
|
|
WorkspacePath: cwd,
|
|
GitBranch: workspaceGitBranchForMeta(cwd),
|
|
ImageInputEnabled: a.imageInputEnabledForTab(tabID),
|
|
AutoApproveTools: autoApproveTools,
|
|
Bypass: autoApproveTools,
|
|
CollaborationMode: collaborationMode,
|
|
ToolApprovalMode: toolApprovalMode,
|
|
TokenMode: tokenMode,
|
|
Goal: goal,
|
|
GoalStatus: goalStatus,
|
|
AutoResearch: compactAutoResearchFromController(snap.ctrl),
|
|
}
|
|
}
|
|
|
|
func compactAutoResearchFromController(ctrl control.SessionAPI) *AutoResearchCompactView {
|
|
if ctrl == nil {
|
|
return nil
|
|
}
|
|
summary, ok := ctrl.AutoResearchSummary()
|
|
if !ok || summary == nil || summary.TaskID == "" {
|
|
return nil
|
|
}
|
|
return &AutoResearchCompactView{
|
|
TaskID: summary.TaskID,
|
|
Status: summary.Status,
|
|
Iteration: summary.Iteration,
|
|
PivotRequired: summary.PivotRequired,
|
|
StaleCount: summary.StaleCount,
|
|
}
|
|
}
|
|
|
|
func compactAutoResearch(tab *WorkspaceTab) *AutoResearchCompactView {
|
|
if tab == nil || tab.Ctrl == nil {
|
|
return nil
|
|
}
|
|
summary, ok := tab.Ctrl.AutoResearchSummary()
|
|
if !ok || summary == nil || summary.TaskID == "" {
|
|
return nil
|
|
}
|
|
return &AutoResearchCompactView{
|
|
TaskID: summary.TaskID,
|
|
Status: summary.Status,
|
|
Iteration: summary.Iteration,
|
|
PivotRequired: summary.PivotRequired,
|
|
StaleCount: summary.StaleCount,
|
|
}
|
|
}
|
|
|
|
func autoResearchStatusView(summary *autoresearch.Summary) AutoResearchStatusView {
|
|
if summary == nil {
|
|
return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}}
|
|
}
|
|
open := make([]AutoResearchCriterionView, 0, len(summary.OpenCriteria))
|
|
for _, criterion := range summary.OpenCriteria {
|
|
open = append(open, AutoResearchCriterionView{
|
|
ID: criterion.ID,
|
|
Description: criterion.Description,
|
|
Required: criterion.Required,
|
|
EvidenceCount: criterion.EvidenceCount,
|
|
Status: criterion.Status,
|
|
})
|
|
}
|
|
return AutoResearchStatusView{
|
|
TaskID: summary.TaskID,
|
|
Goal: summary.Goal,
|
|
Status: summary.Status,
|
|
Iteration: summary.Iteration,
|
|
CurrentDirection: summary.CurrentDirection,
|
|
StaleCount: summary.StaleCount,
|
|
PivotCount: summary.PivotCount,
|
|
PivotRequired: summary.PivotRequired,
|
|
LastHeartbeatAt: summary.LastHeartbeatAt.Format(time.RFC3339),
|
|
FindingCount: summary.FindingCount,
|
|
OpenCriteria: open,
|
|
Blocker: summary.Blocker,
|
|
TaskPath: summary.TaskPath,
|
|
NextRequiredAction: summary.NextRequiredAction,
|
|
}
|
|
}
|
|
|
|
func (a *App) AutoResearchCurrent() AutoResearchStatusView {
|
|
return a.AutoResearchStatus("")
|
|
}
|
|
|
|
func (a *App) AutoResearchStatus(tabID string) AutoResearchStatusView {
|
|
ctrl := a.ctrlByTabID(tabID)
|
|
if ctrl == nil {
|
|
return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}}
|
|
}
|
|
summary, ok := ctrl.AutoResearchSummary()
|
|
if !ok {
|
|
return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}}
|
|
}
|
|
return autoResearchStatusView(summary)
|
|
}
|
|
|
|
func (a *App) AutoResearchList(tabID string) []AutoResearchStatusView {
|
|
ctrl := a.ctrlByTabID(tabID)
|
|
if ctrl == nil {
|
|
return []AutoResearchStatusView{}
|
|
}
|
|
summaries, ok := ctrl.AutoResearchList()
|
|
if !ok {
|
|
return []AutoResearchStatusView{}
|
|
}
|
|
out := make([]AutoResearchStatusView, 0, len(summaries))
|
|
for i := range summaries {
|
|
out = append(out, autoResearchStatusView(&summaries[i]))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *App) AutoResearchFindings(tabID string, limit int) []AutoResearchFindingView {
|
|
ctrl := a.ctrlByTabID(tabID)
|
|
if ctrl == nil {
|
|
return []AutoResearchFindingView{}
|
|
}
|
|
findings, ok := ctrl.AutoResearchFindings(limit)
|
|
if !ok {
|
|
return []AutoResearchFindingView{}
|
|
}
|
|
out := make([]AutoResearchFindingView, 0, len(findings))
|
|
for _, finding := range findings {
|
|
out = append(out, AutoResearchFindingView{
|
|
ID: finding.ID,
|
|
Kind: finding.Kind,
|
|
Summary: finding.Summary,
|
|
Source: finding.Source,
|
|
Command: finding.Command,
|
|
Paths: append([]string(nil), finding.Paths...),
|
|
Accepted: finding.Accepted,
|
|
CreatedAt: finding.CreatedAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *App) AutoResearchOpenTask(tabID string) error {
|
|
status := a.AutoResearchStatus(tabID)
|
|
if strings.TrimSpace(status.TaskPath) == "" {
|
|
return os.ErrInvalid
|
|
}
|
|
return a.RevealPath(status.TaskPath)
|
|
}
|
|
|
|
func (a *App) AutoResearchRecordEvidence(tabID, criterionID string, input AutoResearchEvidenceView) error {
|
|
ctrl := a.ctrlByTabID(tabID)
|
|
if ctrl == nil {
|
|
return os.ErrInvalid
|
|
}
|
|
return ctrl.RecordAutoResearchEvidence(criterionID, control.AutoResearchEvidenceInput{
|
|
ID: input.ID,
|
|
Kind: input.Kind,
|
|
Summary: input.Summary,
|
|
Source: input.Source,
|
|
Command: input.Command,
|
|
Paths: append([]string(nil), input.Paths...),
|
|
Accepted: input.Accepted,
|
|
})
|
|
}
|
|
|
|
func (a *App) SetGoal(goal string) {
|
|
a.SetGoalForTab("", goal)
|
|
}
|
|
|
|
func (a *App) SetGoalForTab(tabID, goal string) {
|
|
goal = strings.TrimSpace(goal)
|
|
a.mu.Lock()
|
|
tab := a.tabByIDLocked(tabID)
|
|
if tab == nil {
|
|
a.mu.Unlock()
|
|
return
|
|
}
|
|
tab.goal = goal
|
|
if goal != "" {
|
|
tab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)
|
|
}
|
|
ctrl := tab.Ctrl
|
|
plan := tabModeHasPlan(tab.mode)
|
|
tabIDForSave := tab.ID
|
|
a.mu.Unlock()
|
|
if ctrl != nil {
|
|
ctrl.SetPlanMode(plan)
|
|
ctrl.SetGoal(goal)
|
|
}
|
|
a.mu.Lock()
|
|
if a.tabs[tabIDForSave] == tab {
|
|
a.saveTabsLocked()
|
|
}
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func (a *App) ClearGoal() {
|
|
a.SetGoal("")
|
|
}
|
|
|
|
func (a *App) ClearGoalForTab(tabID string) {
|
|
a.SetGoalForTab(tabID, "")
|
|
}
|
|
|
|
// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:
|
|
// approval-gated tool calls run without asking, while ask questions and plan
|
|
// approvals still wait for the user. Runtime-only — not written to config.
|
|
func (a *App) SetAutoApproveTools(on bool) {
|
|
if on {
|
|
a.SetToolApprovalModeForTab("", control.ToolApprovalYolo)
|
|
return
|
|
}
|
|
a.SetToolApprovalModeForTab("", control.ToolApprovalAsk)
|
|
}
|
|
|
|
// SetBypass is the legacy Wails binding for SetAutoApproveTools.
|
|
func (a *App) SetBypass(on bool) {
|
|
a.SetAutoApproveTools(on)
|
|
}
|
|
|
|
func (a *App) SetToolApprovalMode(mode string) {
|
|
a.SetToolApprovalModeForTab("", mode)
|
|
}
|
|
|
|
func (a *App) SetToolApprovalModeForTab(tabID, mode string) {
|
|
mode = normalizeToolApprovalMode(mode)
|
|
a.mu.Lock()
|
|
tab := a.tabByIDLocked(tabID)
|
|
if tab == nil {
|
|
a.mu.Unlock()
|
|
return
|
|
}
|
|
tab.toolApprovalMode = mode
|
|
tab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)
|
|
ctrl := tab.Ctrl
|
|
tabIDForSave := tab.ID
|
|
a.mu.Unlock()
|
|
applyTabToolApprovalModeToController(ctrl, mode)
|
|
a.mu.Lock()
|
|
if a.tabs[tabIDForSave] == tab {
|
|
a.saveTabsLocked()
|
|
}
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
// CommandInfo describes one available slash command for the composer's "/" menu.
|
|
type CommandInfo struct {
|
|
Name string `json:"name"` // without the leading slash
|
|
Description string `json:"description"`
|
|
Hint string `json:"hint,omitempty"` // argument hint, if any
|
|
Kind string `json:"kind"` // "builtin" | "custom" | "mcp" | "skill" | "subagent"
|
|
Group string `json:"group,omitempty"` // menu group; older frontends can ignore it
|
|
Plugin string `json:"plugin,omitempty"`
|
|
Color string `json:"color,omitempty"`
|
|
}
|
|
|
|
// Commands lists the slash commands available this session — built-in actions,
|
|
// custom commands (.reasonix/commands), and MCP prompts — for the composer's "/"
|
|
// autocomplete menu.
|
|
func (a *App) Commands() []CommandInfo {
|
|
out := []CommandInfo{
|
|
{Name: "new", Description: i18n.M.CmdNew, Kind: "builtin", Group: "actions"},
|
|
{Name: "clear", Description: i18n.M.CmdClear, Kind: "builtin", Group: "actions"},
|
|
{Name: "compact", Description: i18n.M.CmdCompact, Kind: "builtin", Group: "actions"},
|
|
{Name: "model", Description: i18n.M.CmdModel, Kind: "builtin", Group: "actions"},
|
|
{Name: "provider", Description: i18n.M.CmdProvider, Kind: "builtin", Group: "management"},
|
|
{Name: "effort", Description: i18n.M.CmdEffort, Kind: "builtin", Group: "actions"},
|
|
{Name: "memory", Description: i18n.M.CmdMemory, Kind: "builtin", Group: "management"},
|
|
{Name: "migrate", Description: i18n.M.CmdMigrate, Kind: "builtin", Group: "management"},
|
|
{Name: "goal", Description: i18n.M.CmdGoal, Kind: "builtin", Group: "actions"},
|
|
{Name: "remember", Description: i18n.M.CmdRemember, Kind: "builtin", Group: "management"},
|
|
{Name: "mcp", Description: i18n.M.CmdMcp, Kind: "builtin", Group: "integrations"},
|
|
{Name: "hooks", Description: i18n.M.CmdHooks, Kind: "builtin", Group: "management"},
|
|
{Name: "plugins", Description: i18n.M.CmdPlugins, Kind: "builtin", Group: "integrations"},
|
|
{Name: "theme", Description: i18n.M.CmdTheme, Kind: "builtin", Group: "management"},
|
|
{Name: "skill", Description: i18n.M.CmdSkill, Kind: "builtin", Group: "skills"},
|
|
{Name: "reload-cmd", Description: i18n.M.CmdReloadCmd, Kind: "builtin", Group: "management"},
|
|
}
|
|
a.mu.RLock()
|
|
ctrl := a.activeCtrlLocked()
|
|
a.mu.RUnlock()
|
|
if ctrl == nil {
|
|
return out
|
|
}
|
|
// Skills are invocable as slash commands (the model runs inline ones; subagent ones
|
|
// run isolated). Listing them here is what surfaces /init, /explore, … in the
|
|
// composer's slash menu; selecting one submits its displayed slash name, which the controller
|
|
// resolves via RunSkill.
|
|
for _, s := range ctrl.SlashSkills() {
|
|
kind := "skill"
|
|
if s.RunAs == skill.RunSubagent {
|
|
kind = "subagent"
|
|
}
|
|
group := "skills"
|
|
if kind == "subagent" {
|
|
group = "subagents"
|
|
}
|
|
out = append(out, CommandInfo{Name: s.SlashName(), Description: s.Description, Kind: kind, Group: group, Plugin: s.Plugin, Color: s.Color})
|
|
}
|
|
for _, c := range ctrl.Commands() {
|
|
if c.Hidden {
|
|
continue
|
|
}
|
|
out = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: "custom", Group: "skills", Plugin: c.Plugin})
|
|
}
|
|
if h := ctrl.Host(); h != nil {
|
|
for _, p := range h.Prompts() {
|
|
out = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: "mcp", Group: "integrations"})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// SlashArgItem is one sub-command / argument suggestion for the composer's slash
|
|
// menu (the part after the command word). Mirrors the CLI's arg completion via
|
|
// the shared control.SlashArgItems, so desktop and CLI offer the same hints.
|
|
type SlashArgItem struct {
|
|
Label string `json:"label"`
|
|
Insert string `json:"insert"`
|
|
Hint string `json:"hint"`
|
|
Descend bool `json:"descend"`
|
|
}
|
|
|
|
// SlashArgsResult carries the suggestions plus the byte offset in the input where
|
|
// the current token begins, so the composer replaces just that token.
|
|
type SlashArgsResult struct {
|
|
Items []SlashArgItem `json:"items"`
|
|
From int `json:"from"`
|
|
}
|
|
|
|
// SlashArgs completes the arguments of a management slash command (/mcp, /model,
|
|
// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty
|
|
// Items means the input has no structured arguments to complete.
|
|
func (a *App) SlashArgs(input string) SlashArgsResult {
|
|
a.mu.RLock()
|
|
ctrl := a.activeCtrlLocked()
|
|
model := ""
|
|
if tab := a.activeTabLocked(); tab != nil {
|
|
model = tab.model
|
|
}
|
|
a.mu.RUnlock()
|
|
if ctrl == nil {
|
|
return SlashArgsResult{Items: []SlashArgItem{}}
|
|
}
|
|
data := control.ArgData{
|
|
Skills: ctrl.Skills(),
|
|
DisabledSkills: ctrl.DisabledSkills(),
|
|
ConfiguredMCP: ctrl.ConfiguredMCPNames(),
|
|
DisconnectedMCP: ctrl.DisconnectedMCPNames(),
|
|
CurrentModel: model,
|
|
}
|
|
if names, err := pluginpkg.InstalledNames(config.ReasonixHomeDir()); err == nil {
|
|
data.PluginNames = names
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, m := range a.Models() {
|
|
data.ModelRefs = append(data.ModelRefs, m.Ref)
|
|
if m.Provider != "" && !seen[m.Provider] {
|
|
seen[m.Provider] = true
|
|
data.ProviderNames = append(data.ProviderNames, m.Provider)
|
|
}
|
|
if m.Current {
|
|
data.CurrentProvider = m.Provider
|
|
}
|
|
}
|
|
if h := ctrl.Host(); h != nil {
|
|
data.ServerNames = h.ServerNames()
|
|
}
|
|
items, from := control.SlashArgItems(input, data)
|
|
// Non-nil so it serializes as a JSON array, never null — the frontend filters
|
|
// over it directly.
|
|
out := SlashArgsResult{Items: []SlashArgItem{}, From: from}
|
|
for _, it := range items {
|
|
out.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP
|
|
// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.
|
|
type CapabilitiesView struct {
|
|
Servers []ServerView `json:"servers"`
|
|
Skills []SkillView `json:"skills"`
|
|
SkillRoots []SkillRootView `json:"skillRoots"`
|
|
Plugins []PluginView `json:"plugins"`
|
|
}
|
|
|
|
// SkillsSettingsView is the skills management page's data, split from MCP
|
|
// status so opening MCP settings does not scan skill roots.
|
|
type SkillsSettingsView struct {
|
|
Skills []SkillView `json:"skills"`
|
|
SkillRoots []SkillRootView `json:"skillRoots"`
|
|
}
|
|
|
|
// ServerView is one MCP server for the drawer. Status is "connected" (with
|
|
// tool/prompt/resource counts), "deferred" (enabled but idle), "failed" (with
|
|
// the connection error), "initializing" (background startup in progress), or
|
|
// "disabled".
|
|
type ServerView struct {
|
|
Name string `json:"name"`
|
|
Transport string `json:"transport"`
|
|
Status string `json:"status"`
|
|
StartIntent string `json:"startIntent,omitempty"`
|
|
RuntimeState string `json:"runtimeState,omitempty"`
|
|
BuiltIn bool `json:"builtIn,omitempty"`
|
|
Configured bool `json:"configured,omitempty"`
|
|
AutoStart bool `json:"autoStart"`
|
|
Tier string `json:"tier,omitempty"`
|
|
Command string `json:"command,omitempty"`
|
|
Args []string `json:"args,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
EnvKeys []string `json:"envKeys,omitempty"`
|
|
HeaderKeys []string `json:"headerKeys,omitempty"`
|
|
Tools int `json:"tools"`
|
|
Prompts int `json:"prompts"`
|
|
Resources int `json:"resources"`
|
|
HasTools bool `json:"hasTools,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
ToolList []ToolView `json:"toolList,omitempty"`
|
|
TrustedReadOnlyTools []string `json:"trustedReadOnlyTools,omitempty"`
|
|
AuthStatus string `json:"authStatus,omitempty"`
|
|
AuthURL string `json:"authUrl,omitempty"`
|
|
AuthConfigured bool `json:"authConfigured,omitempty"`
|
|
ManagedByPlugin string `json:"managedByPlugin,omitempty"`
|
|
}
|
|
|
|
type ToolView struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
ReadOnlyHint bool `json:"readOnlyHint,omitempty"`
|
|
}
|
|
|
|
// SkillView is one discoverable skill for the drawer. Also backs the
|
|
// Subagents settings surface: the frontend filters this same list to
|
|
// RunAs=="subagent" rather than calling a second, redundant endpoint.
|
|
type SkillView struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Scope string `json:"scope"`
|
|
RunAs string `json:"runAs"`
|
|
Enabled bool `json:"enabled"`
|
|
Plugin string `json:"plugin,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
Effort string `json:"effort,omitempty"`
|
|
AllowedTools []string `json:"allowedTools,omitempty"`
|
|
Color string `json:"color,omitempty"`
|
|
// Invocation is the user-facing slash name; InvocationMode preserves the
|
|
// frontmatter policy used by the subagent profile editor.
|
|
Invocation string `json:"invocation,omitempty"`
|
|
InvocationMode string `json:"invocationMode,omitempty"`
|
|
// Body is the skill's full markdown body (post-frontmatter) — the
|
|
// subagent profile editor pre-fills its system-prompt field from this.
|
|
Body string `json:"body,omitempty"`
|
|
// ConfiguredModel/ConfiguredEffort are the per-name overrides from
|
|
// cfg.Agent.SubagentModels/SubagentEfforts (internal/boot's
|
|
// subagentModelRef/subagentEffortRef read the same map at dispatch time).
|
|
// This is the only lever for a built-in subagent's model/effort, since
|
|
// built-ins have no editable frontmatter file to carry Model/Effort.
|
|
ConfiguredModel string `json:"configuredModel,omitempty"`
|
|
ConfiguredEffort string `json:"configuredEffort,omitempty"`
|
|
}
|
|
|
|
type SkillRootSkillView struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Scope string `json:"scope"`
|
|
RunAs string `json:"runAs"`
|
|
Plugin string `json:"plugin,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
Effort string `json:"effort,omitempty"`
|
|
AllowedTools []string `json:"allowedTools,omitempty"`
|
|
Color string `json:"color,omitempty"`
|
|
Invocation string `json:"invocation,omitempty"`
|
|
}
|
|
|
|
// SkillRootView is one skill discovery root for the drawer's Sources section.
|
|
type SkillRootView struct {
|
|
Dir string `json:"dir"`
|
|
Scope string `json:"scope"`
|
|
Priority int `json:"priority"`
|
|
Status string `json:"status"`
|
|
Configured bool `json:"configured"`
|
|
Removable bool `json:"removable"`
|
|
Skills int `json:"skills"`
|
|
SkillItems []SkillRootSkillView `json:"skillItems,omitempty"`
|
|
Warning string `json:"warning,omitempty"`
|
|
}
|
|
|
|
// Capabilities projects the session's MCP servers (connected + failed) and skills
|
|
// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.
|
|
func (a *App) Capabilities() CapabilitiesView {
|
|
skills := a.SkillsSettings()
|
|
return CapabilitiesView{
|
|
Servers: a.MCPServers(),
|
|
Skills: skills.Skills,
|
|
SkillRoots: skills.SkillRoots,
|
|
Plugins: a.Plugins(),
|
|
}
|
|
}
|
|
|
|
// MCPServers returns only MCP server status for settings pages that do not need
|
|
// skill discovery.
|
|
func (a *App) MCPServers() []ServerView {
|
|
return a.mcpServersView()
|
|
}
|
|
|
|
// SkillsSettings returns the skills management snapshot without MCP status.
|
|
func (a *App) SkillsSettings() SkillsSettingsView {
|
|
out := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}
|
|
a.mu.RLock()
|
|
tab := a.activeTabLocked()
|
|
var ctrl control.SessionAPI
|
|
if tab != nil {
|
|
ctrl = tab.Ctrl
|
|
}
|
|
a.mu.RUnlock()
|
|
if ctrl == nil {
|
|
return out
|
|
}
|
|
|
|
disabled := map[string]bool{}
|
|
var configuredModels, configuredEfforts map[string]string
|
|
if cfg, err := config.Load(); err == nil {
|
|
for _, name := range cfg.Skills.DisabledSkills {
|
|
if key := config.SkillNameKey(name); key != "" {
|
|
disabled[key] = true
|
|
}
|
|
}
|
|
configuredModels = cfg.Agent.SubagentModels
|
|
configuredEfforts = cfg.Agent.SubagentEfforts
|
|
}
|
|
for _, s := range ctrl.AllSkills() {
|
|
view := SkillView{
|
|
Name: s.Name, Description: s.Description,
|
|
Scope: string(s.Scope), RunAs: string(s.RunAs),
|
|
Enabled: !disabled[config.SkillNameKey(s.Name)],
|
|
Plugin: s.Plugin,
|
|
Model: s.Model,
|
|
Effort: s.Effort,
|
|
AllowedTools: append([]string{}, s.AllowedTools...),
|
|
Color: s.Color,
|
|
Invocation: "/" + s.SlashName(),
|
|
InvocationMode: s.Invocation,
|
|
ConfiguredModel: subagentOverrideFor(configuredModels, s.Name),
|
|
ConfiguredEffort: subagentOverrideFor(configuredEfforts, s.Name),
|
|
}
|
|
// Body feeds only the Subagents editor's prompt prefill. Inline skills
|
|
// fold references/ into Body at load time (hundreds of KB for a rich
|
|
// skill library), and every Capabilities/Settings fetch would ship all
|
|
// of it across the JSON bridge for nothing.
|
|
if s.RunAs == skill.RunSubagent {
|
|
view.Body = s.Body
|
|
}
|
|
out.Skills = append(out.Skills, view)
|
|
}
|
|
out.SkillRoots = a.cachedSkillRootsView()
|
|
return out
|
|
}
|
|
|
|
// subagentOverrideFor resolves a per-name subagent override with the same
|
|
// underscore/hyphen alias fallback the runtime dispatch uses
|
|
// (boot.SubagentModelKeys) — an exact-key read would show a legacy
|
|
// `security_review` config entry as "inherit default" while it still won at
|
|
// dispatch time.
|
|
func subagentOverrideFor(overrides map[string]string, name string) string {
|
|
for _, key := range boot.SubagentModelKeys(name) {
|
|
if v := strings.TrimSpace(overrides[key]); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// AvailableSubagentTools lists the tool names a subagent profile's
|
|
// "available tools" picker may offer. Scoped to compile-time builtins for
|
|
// v1 — MCP/plugin tools are per-session/per-connection and would need a new
|
|
// live-registry accessor on control.Capabilities to enumerate safely; a
|
|
// profile's allowed-tools already degrades gracefully (FilterRegistry drops
|
|
// unknown names silently) if extended to MCP names by hand later. Tools that
|
|
// are always excluded from every subagent regardless of an explicit
|
|
// allowlist (agent.AlwaysHiddenSubagentTools) are left out entirely — they'd
|
|
// be a selectable no-op otherwise.
|
|
func (a *App) AvailableSubagentTools() []ToolView {
|
|
hidden := map[string]bool{}
|
|
for _, name := range agent.AlwaysHiddenSubagentTools() {
|
|
hidden[name] = true
|
|
}
|
|
entries := tool.BuiltinContractEntries()
|
|
out := make([]ToolView, 0, len(entries))
|
|
for _, e := range entries {
|
|
if hidden[e.Name] {
|
|
continue
|
|
}
|
|
out = append(out, ToolView{Name: e.Name, Description: e.Description, ReadOnlyHint: e.ReadOnly})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
func (a *App) mcpServersView() []ServerView {
|
|
out := []ServerView{}
|
|
a.mu.RLock()
|
|
tab := a.activeTabLocked()
|
|
if tab == nil {
|
|
a.mu.RUnlock()
|
|
return out
|
|
}
|
|
ctrl := tab.Ctrl
|
|
disabled := make(map[string]ServerView, len(tab.disabledMCP))
|
|
for name, s := range tab.disabledMCP {
|
|
disabled[name] = s
|
|
}
|
|
order := append([]string(nil), tab.mcpOrder...)
|
|
workspaceRoot := tab.WorkspaceRoot
|
|
tabID := tab.ID
|
|
a.mu.RUnlock()
|
|
if ctrl == nil {
|
|
return out
|
|
}
|
|
seen := map[string]bool{}
|
|
connected := map[string]bool{}
|
|
retainedDisabled := map[string]ServerView{}
|
|
configured := map[string]config.PluginEntry{}
|
|
managedByPlugin := map[string]string{}
|
|
var configuredEntries []config.PluginEntry
|
|
if cfg, err := config.LoadForRoot(workspaceRoot); err == nil {
|
|
configuredEntries = append(configuredEntries, cfg.Plugins...)
|
|
for _, p := range configuredEntries {
|
|
configured[p.Name] = p
|
|
if owner, ok := cfg.PluginPackageOwner(p.Name); ok {
|
|
managedByPlugin[p.Name] = owner
|
|
}
|
|
}
|
|
}
|
|
if h := ctrl.Host(); h != nil {
|
|
for _, s := range h.Servers() {
|
|
if disabledView, ok := disabled[s.Name]; ok {
|
|
disabledView.Status = "disabled"
|
|
disabledView.RuntimeState = "idle"
|
|
disabledView.StartIntent = "off"
|
|
disabledView.Error = ""
|
|
if p, ok := configured[s.Name]; ok {
|
|
disabledView = withPluginConfig(disabledView, p)
|
|
}
|
|
out = append(out, disabledView)
|
|
retainedDisabled[s.Name] = disabledView
|
|
seen[s.Name] = true
|
|
delete(disabled, s.Name)
|
|
continue
|
|
}
|
|
seen[s.Name] = true
|
|
connected[s.Name] = true
|
|
view := ServerView{
|
|
Name: s.Name, Transport: s.Transport, Status: "connected", RuntimeState: "ready",
|
|
Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
|
|
HasTools: s.HasTools,
|
|
ToolList: pluginToolsToView(s.ToolList),
|
|
}
|
|
if p, ok := configured[s.Name]; ok {
|
|
view = withPluginConfig(view, p)
|
|
}
|
|
out = append(out, view)
|
|
}
|
|
for _, f := range h.Failures() {
|
|
seen[f.Name] = true
|
|
view := ServerView{
|
|
Name: f.Name, Transport: f.Transport, Status: "failed", RuntimeState: "issue", Error: f.Error,
|
|
}
|
|
if p, ok := configured[f.Name]; ok {
|
|
view = withPluginConfig(view, p)
|
|
}
|
|
out = append(out, view)
|
|
}
|
|
for _, name := range h.ConnectingServers() {
|
|
if seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
view := ServerView{Name: name, Status: "initializing", RuntimeState: "connecting"}
|
|
if p, ok := configured[name]; ok {
|
|
view = withPluginConfig(view, p)
|
|
}
|
|
out = append(out, view)
|
|
}
|
|
}
|
|
// Configured servers that are neither connected, connecting, nor failed are
|
|
// idle: disabled/off or automatic background startup waiting for its next kick.
|
|
if len(configuredEntries) > 0 {
|
|
for _, p := range configuredEntries {
|
|
if seen[p.Name] {
|
|
continue
|
|
}
|
|
if s, ok := disabled[p.Name]; ok {
|
|
s.Status = "disabled"
|
|
s.RuntimeState = "idle"
|
|
s.StartIntent = "off"
|
|
s = withPluginConfig(s, p)
|
|
s.Error = ""
|
|
out = append(out, s)
|
|
retainedDisabled[p.Name] = s
|
|
seen[p.Name] = true
|
|
delete(disabled, p.Name)
|
|
continue
|
|
}
|
|
status := "disabled"
|
|
startIntent := "off"
|
|
if p.ShouldAutoStart() {
|
|
status = "deferred"
|
|
startIntent = mcpStartIntent(p)
|
|
}
|
|
out = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: "idle"}, p))
|
|
seen[p.Name] = true
|
|
}
|
|
}
|
|
out = orderServerViews(out, order)
|
|
for i := range out {
|
|
out[i].ManagedByPlugin = managedByPlugin[out[i].Name]
|
|
}
|
|
|
|
a.mu.Lock()
|
|
if tab, ok := a.tabs[tabID]; ok {
|
|
for name := range connected {
|
|
delete(retainedDisabled, name)
|
|
}
|
|
tab.disabledMCP = retainedDisabled
|
|
tab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)
|
|
}
|
|
a.mu.Unlock()
|
|
return out
|
|
}
|
|
|
|
func mcpStartIntent(p config.PluginEntry) string {
|
|
if !p.ShouldAutoStart() {
|
|
return "off"
|
|
}
|
|
return "automatic"
|
|
}
|
|
|
|
func mcpRuntimeState(status string) string {
|
|
switch status {
|
|
case "connected":
|
|
return "ready"
|
|
case "initializing":
|
|
return "connecting"
|
|
case "failed":
|
|
return "issue"
|
|
default:
|
|
return "idle"
|
|
}
|
|
}
|
|
|
|
func withPluginConfig(v ServerView, p config.PluginEntry) ServerView {
|
|
tt := p.Type
|
|
if tt == "" {
|
|
tt = "stdio"
|
|
}
|
|
v.Transport = tt
|
|
v.Configured = true
|
|
v.AutoStart = p.ShouldAutoStart()
|
|
v.Tier = p.ResolvedTier()
|
|
if v.StartIntent == "" {
|
|
v.StartIntent = mcpStartIntent(p)
|
|
}
|
|
if v.Status == "disabled" {
|
|
v.StartIntent = "off"
|
|
}
|
|
if v.RuntimeState == "" {
|
|
v.RuntimeState = mcpRuntimeState(v.Status)
|
|
}
|
|
v.Command = p.Command
|
|
v.Args = append([]string(nil), p.Args...)
|
|
v.URL = p.URL
|
|
v.TrustedReadOnlyTools = uniqueStrings(p.TrustedReadOnlyTools)
|
|
v.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)
|
|
v.EnvKeys = nil
|
|
v.HeaderKeys = nil
|
|
if len(p.Env) > 0 {
|
|
v.EnvKeys = make([]string, 0, len(p.Env))
|
|
for k := range p.Env {
|
|
v.EnvKeys = append(v.EnvKeys, k)
|
|
}
|
|
sort.Strings(v.EnvKeys)
|
|
}
|
|
if len(p.Headers) > 0 {
|
|
v.HeaderKeys = make([]string, 0, len(p.Headers))
|
|
for k := range p.Headers {
|
|
v.HeaderKeys = append(v.HeaderKeys, k)
|
|
}
|
|
sort.Strings(v.HeaderKeys)
|
|
}
|
|
auth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)
|
|
v.AuthStatus = auth.Status
|
|
v.AuthURL = auth.URL
|
|
return v
|
|
}
|
|
|
|
const skillRootsCacheTTL = 10 * time.Second
|
|
|
|
func (a *App) cachedSkillRootsView() []SkillRootView {
|
|
cwd, _ := os.Getwd()
|
|
cfg, _ := config.Load()
|
|
userCfg := config.LoadForEdit(config.UserConfigPath())
|
|
key := skillRootsCacheKey(cwd, cfg, userCfg)
|
|
|
|
now := time.Now()
|
|
a.skillRootsMu.Lock()
|
|
if a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {
|
|
roots := cloneSkillRootViews(a.skillRootsCache.roots)
|
|
a.skillRootsMu.Unlock()
|
|
return roots
|
|
}
|
|
a.skillRootsMu.Unlock()
|
|
|
|
roots := skillRootsViewFrom(cwd, cfg, userCfg)
|
|
|
|
a.skillRootsMu.Lock()
|
|
a.skillRootsCache = skillRootsCache{
|
|
key: key,
|
|
at: now,
|
|
roots: cloneSkillRootViews(roots),
|
|
}
|
|
a.skillRootsMu.Unlock()
|
|
return roots
|
|
}
|
|
|
|
func (a *App) invalidateSkillRootsCache() {
|
|
a.skillRootsMu.Lock()
|
|
a.skillRootsCache = skillRootsCache{}
|
|
a.skillRootsMu.Unlock()
|
|
}
|
|
|
|
func skillRootsView() []SkillRootView {
|
|
cwd, _ := os.Getwd()
|
|
cfg, _ := config.Load()
|
|
userCfg := config.LoadForEdit(config.UserConfigPath())
|
|
return skillRootsViewFrom(cwd, cfg, userCfg)
|
|
}
|
|
|
|
func skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {
|
|
var custom []string
|
|
var excluded []string
|
|
maxDepth := 3
|
|
if cfg != nil {
|
|
custom = cfg.SkillCustomPaths()
|
|
excluded = cfg.SkillExcludedPaths()
|
|
maxDepth = cfg.SkillMaxDepth()
|
|
}
|
|
var pluginPaths map[string][]string
|
|
if cfg != nil {
|
|
pluginPaths = cfg.PluginPackageSkillOwners()
|
|
}
|
|
st := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, PluginPaths: pluginPaths, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})
|
|
counts := map[string]int{}
|
|
skillItems := map[string][]SkillRootSkillView{}
|
|
roots := st.Roots()
|
|
for _, sk := range st.SlashList() {
|
|
root := skillDisplayRoot(sk, roots)
|
|
counts[root]++
|
|
skillItems[root] = append(skillItems[root], SkillRootSkillView{
|
|
Name: sk.Name,
|
|
Description: sk.Description,
|
|
Scope: string(sk.Scope),
|
|
RunAs: string(sk.RunAs),
|
|
Plugin: sk.Plugin,
|
|
Model: sk.Model,
|
|
Effort: sk.Effort,
|
|
AllowedTools: append([]string{}, sk.AllowedTools...),
|
|
Color: sk.Color,
|
|
Invocation: "/" + sk.SlashName(),
|
|
})
|
|
}
|
|
for root := range skillItems {
|
|
sort.Slice(skillItems[root], func(i, j int) bool {
|
|
return skillItems[root][i].Invocation < skillItems[root][j].Invocation
|
|
})
|
|
}
|
|
userConfigured := map[string]bool{}
|
|
if userCfg != nil {
|
|
for _, p := range userCfg.Skills.Paths {
|
|
userConfigured[config.CanonicalSkillPath(p)] = true
|
|
}
|
|
}
|
|
out := []SkillRootView{}
|
|
seenRoots := map[string]int{}
|
|
for _, r := range roots {
|
|
dir := config.CanonicalSkillPath(r.Dir)
|
|
view := SkillRootView{
|
|
Dir: r.Dir,
|
|
Scope: string(r.Scope),
|
|
Priority: r.Priority + 1,
|
|
Status: string(r.Status),
|
|
Configured: r.Scope == skill.ScopeCustom && userConfigured[dir],
|
|
Removable: true,
|
|
Skills: counts[dir],
|
|
SkillItems: skillItems[dir],
|
|
}
|
|
if idx, ok := seenRoots[dir]; ok {
|
|
out[idx] = mergeDuplicateSkillRootView(out[idx], view)
|
|
continue
|
|
}
|
|
seenRoots[dir] = len(out)
|
|
out = append(out, view)
|
|
}
|
|
if userCfg != nil {
|
|
for _, p := range userCfg.Skills.Paths {
|
|
if rootActive(out, p) {
|
|
continue
|
|
}
|
|
out = append(out, SkillRootView{
|
|
Dir: p,
|
|
Scope: string(skill.ScopeCustom),
|
|
Status: "inactive",
|
|
Configured: true,
|
|
Removable: true,
|
|
Warning: "configured in user config but not active in this workspace; project [skills].paths may override it",
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {
|
|
existing.Configured = existing.Configured || duplicate.Configured
|
|
existing.Removable = existing.Removable || duplicate.Removable
|
|
if existing.Status != "ok" && duplicate.Status == "ok" {
|
|
existing.Status = duplicate.Status
|
|
}
|
|
if existing.Skills == 0 && duplicate.Skills > 0 {
|
|
existing.Skills = duplicate.Skills
|
|
existing.SkillItems = duplicate.SkillItems
|
|
}
|
|
if existing.Warning == "" {
|
|
existing.Warning = duplicate.Warning
|
|
}
|
|
return existing
|
|
}
|
|
|
|
func skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {
|
|
type cacheKey struct {
|
|
CWD string `json:"cwd"`
|
|
Custom []string `json:"custom"`
|
|
Plugins []string `json:"plugins"`
|
|
Excluded []string `json:"excluded"`
|
|
MaxDepth int `json:"maxDepth"`
|
|
UserPaths []string `json:"userPaths"`
|
|
}
|
|
key := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}
|
|
if cfg != nil {
|
|
key.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())
|
|
for path, owners := range cfg.PluginPackageSkillOwners() {
|
|
for _, owner := range owners {
|
|
key.Plugins = append(key.Plugins, config.CanonicalSkillPath(path)+"\x00"+owner)
|
|
}
|
|
}
|
|
sort.Strings(key.Plugins)
|
|
key.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())
|
|
key.MaxDepth = cfg.SkillMaxDepth()
|
|
}
|
|
if userCfg != nil {
|
|
key.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)
|
|
}
|
|
b, err := json.Marshal(key)
|
|
if err != nil {
|
|
return fmt.Sprintf("%s|%v|%v|%v|%d|%v", key.CWD, key.Custom, key.Plugins, key.Excluded, key.MaxDepth, key.UserPaths)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func canonicalSkillPaths(paths []string) []string {
|
|
out := make([]string, 0, len(paths))
|
|
for _, p := range paths {
|
|
out = append(out, config.CanonicalSkillPath(p))
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func cloneSkillRootViews(in []SkillRootView) []SkillRootView {
|
|
out := make([]SkillRootView, len(in))
|
|
for i, r := range in {
|
|
out[i] = r
|
|
out[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func rootActive(roots []SkillRootView, path string) bool {
|
|
want := config.CanonicalSkillPath(path)
|
|
for _, r := range roots {
|
|
if config.CanonicalSkillPath(r.Dir) == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// PickSkillFolder opens a directory picker for adding custom skill roots. It only
|
|
// returns a path; AddSkillPath performs normalization and writes config.
|
|
func (a *App) PickSkillFolder() (string, error) {
|
|
if a.ctx == nil {
|
|
return "", nil
|
|
}
|
|
cur, _ := os.Getwd()
|
|
dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
|
|
Title: "Choose skills folder",
|
|
DefaultDirectory: dialogDefaultDirectory(cur),
|
|
})
|
|
if err != nil || dir == "" {
|
|
return "", err
|
|
}
|
|
return normalizeSkillPath(dir), nil
|
|
}
|
|
|
|
// PickPluginFolder opens a directory picker for choosing a local plugin package
|
|
// source. It returns the selected directory path; plugin install/plan performs
|
|
// manifest validation and decides whether to copy or link the package.
|
|
func (a *App) PickPluginFolder() (string, error) {
|
|
if a.ctx == nil {
|
|
return "", nil
|
|
}
|
|
cur := a.activeWorkspaceRoot()
|
|
if strings.TrimSpace(cur) == "" {
|
|
cur, _ = os.Getwd()
|
|
}
|
|
dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
|
|
Title: "Choose plugin folder",
|
|
DefaultDirectory: dialogDefaultDirectory(cur),
|
|
})
|
|
if err != nil || dir == "" {
|
|
return "", err
|
|
}
|
|
return filepath.Clean(dir), nil
|
|
}
|
|
|
|
// AddSkillPath adds a custom skill root to the user config and rebuilds the
|
|
// controller so the skills index and slash menu reflect it immediately.
|
|
func (a *App) AddSkillPath(path string) error {
|
|
path = normalizeSkillPath(path)
|
|
workspaceRoot := a.activeWorkspaceRoot()
|
|
err := a.applyConfigChange(func(c *config.Config) error {
|
|
if isConventionSkillRoot(path, workspaceRoot) {
|
|
return c.RestoreSkillPath(path)
|
|
}
|
|
return c.AddSkillPath(path)
|
|
})
|
|
if err == nil {
|
|
a.invalidateSkillRootsCache()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// RemoveSkillPath removes a skill source from the user config and rebuilds. For
|
|
// convention roots, it records a pseudo-delete in excluded_paths.
|
|
func (a *App) RemoveSkillPath(path string) error {
|
|
path = normalizeSkillPath(path)
|
|
err := a.applyConfigChange(func(c *config.Config) error {
|
|
removed, err := c.RemoveSkillPath(path)
|
|
if err != nil || removed {
|
|
return err
|
|
}
|
|
return c.ExcludeSkillPath(path)
|
|
})
|
|
if err == nil {
|
|
a.invalidateSkillRootsCache()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// RefreshSkills rebuilds the controller without changing config, reloading skill
|
|
// discovery, the system prompt index, and slash completions.
|
|
func (a *App) RefreshSkills() error {
|
|
a.invalidateSkillRootsCache()
|
|
if err := a.rebuild(); err != nil {
|
|
// The skill cache is already invalidated; refresh the runtime once the
|
|
// other window releases the session lease.
|
|
if _, ok := a.deferredRebuildWarning("skills", err); ok {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReloadCommands rescans command directories and hot-swaps without restarting
|
|
// the controller — no MCP disconnect, no hook rerun.
|
|
func (a *App) ReloadCommands() error {
|
|
if a.ctx == nil {
|
|
return nil
|
|
}
|
|
_, ctrl := a.activeTabAndCtrl()
|
|
if ctrl == nil {
|
|
return fmt.Errorf("no active session")
|
|
}
|
|
if ctrl.Running() {
|
|
return fmt.Errorf("wait for the current turn to finish, then retry")
|
|
}
|
|
return ctrl.ReloadCommands(a.ctx)
|
|
}
|
|
|
|
// SetSkillEnabled persists a skill toggle and rebuilds the controller so the
|
|
// prompt index, slash menu, and skill tools reflect it immediately.
|
|
func (a *App) SetSkillEnabled(name string, enabled bool) error {
|
|
err := a.applyConfigChange(func(c *config.Config) error {
|
|
return c.SetSkillEnabled(name, enabled)
|
|
})
|
|
if err == nil {
|
|
a.invalidateSkillRootsCache()
|
|
}
|
|
return err
|
|
}
|
|
|
|
func normalizeSkillPath(path string) string {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
if path == "~" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
if path == "~" {
|
|
path = home
|
|
} else {
|
|
path = filepath.Join(home, path[2:])
|
|
}
|
|
}
|
|
}
|
|
if abs, err := filepath.Abs(path); err == nil {
|
|
path = abs
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return filepath.Clean(path)
|
|
}
|
|
if info.Mode().IsRegular() {
|
|
if filepath.Base(path) == skill.SkillFile {
|
|
return filepath.Clean(filepath.Dir(filepath.Dir(path)))
|
|
}
|
|
return filepath.Clean(filepath.Dir(path))
|
|
}
|
|
if info.IsDir() {
|
|
if _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {
|
|
return filepath.Clean(filepath.Dir(path))
|
|
}
|
|
}
|
|
return filepath.Clean(path)
|
|
}
|
|
|
|
func isConventionSkillRoot(path, workspaceRoot string) bool {
|
|
want := config.CanonicalSkillPath(path)
|
|
if want == "" {
|
|
return false
|
|
}
|
|
bases := []string{workspaceRoot}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
bases = append(bases, home)
|
|
}
|
|
for _, base := range bases {
|
|
base = strings.TrimSpace(base)
|
|
if base == "" {
|
|
continue
|
|
}
|
|
for _, dir := range config.ConventionDirs {
|
|
if want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func skillRootPath(path string) string {
|
|
if filepath.Base(path) == skill.SkillFile {
|
|
return filepath.Dir(path)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {
|
|
cleanPath := filepath.Clean(sk.Path)
|
|
for _, r := range roots {
|
|
if r.Scope != sk.Scope {
|
|
continue
|
|
}
|
|
cleanRoot := filepath.Clean(r.Dir)
|
|
prefix := cleanRoot + string(filepath.Separator)
|
|
if cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {
|
|
return config.CanonicalSkillPath(r.Dir)
|
|
}
|
|
}
|
|
return config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))
|
|
}
|
|
|
|
// MCPServerInput is the drawer's "add server" form. Transport is "stdio" (Command
|
|
// + Args + Env) or "http"/"sse" (URL). Mirrors config.PluginEntry's writable shape.
|
|
type MCPServerInput struct {
|
|
Name string `json:"name"`
|
|
Transport string `json:"transport"`
|
|
Command string `json:"command"`
|
|
Args []string `json:"args"`
|
|
URL string `json:"url"`
|
|
Env map[string]string `json:"env"`
|
|
Headers map[string]string `json:"headers"`
|
|
TrustedReadOnlyTools []string `json:"trustedReadOnlyTools"`
|
|
}
|
|
|
|
// AddMCPServer connects a server live and persists it to config (Customize → MCP →
|
|
// Add). Returns the number of tools it exposed.
|
|
func (a *App) AddMCPServer(in MCPServerInput) (int, error) {
|
|
ctrl := a.activeCtrl()
|
|
if ctrl == nil {
|
|
return 0, fmt.Errorf("no active session")
|
|
}
|
|
if controllerHasActiveRuntimeWork(ctrl) {
|
|
return 0, rebuildControllerActiveWorkError("MCP server")
|
|
}
|
|
entry := config.PluginEntry{
|
|
Name: in.Name,
|
|
Type: normalizeMCPTransport(in.Transport),
|
|
Command: in.Command,
|
|
Args: in.Args,
|
|
URL: in.URL,
|
|
Env: in.Env,
|
|
Headers: in.Headers,
|
|
TrustedReadOnlyTools: uniqueStrings(in.TrustedReadOnlyTools),
|
|
}
|
|
entry, _ = config.NormalizePluginCommandLine(entry)
|
|
if err := a.saveDesktopMCPServer(entry); err != nil {
|
|
return 0, err
|
|
}
|
|
return ctrl.ConnectMCPServer(entry)
|
|
}
|
|
|
|
// UpdateMCPServer edits a persisted external MCP server. The name is the stable
|
|
// identity; callers must remove + add if they want to rename a server.
|
|
func (a *App) UpdateMCPServer(name string, in MCPServerInput) error {
|
|
ctrl := a.activeCtrl()
|
|
if ctrl == nil {
|
|
return fmt.Errorf("no active session")
|
|
}
|
|
if controllerHasActiveRuntimeWork(ctrl) {
|
|
return rebuildControllerActiveWorkError("MCP server")
|
|
}
|
|
if strings.TrimSpace(in.Name) != "" && strings.TrimSpace(in.Name) != name {
|
|
return fmt.Errorf("renaming MCP servers is not supported; remove and add a new server")
|
|
}
|
|
updated, found, err := a.desktopMCPServerForEdit(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("no configured MCP server named %q", name)
|
|
}
|
|
updated.Type = normalizeMCPTransport(in.Transport)
|
|
updated.Command = strings.TrimSpace(in.Command)
|
|
updated.Args = append([]string(nil), in.Args...)
|
|
updated.URL = strings.TrimSpace(in.URL)
|
|
updated.Tier = ""
|
|
if in.Env != nil {
|
|
updated.Env = in.Env
|
|
}
|
|
if in.Headers != nil {
|
|
updated.Headers = in.Headers
|
|
}
|
|
if in.TrustedReadOnlyTools != nil {
|
|
updated.TrustedReadOnlyTools = uniqueStrings(in.TrustedReadOnlyTools)
|
|
}
|
|
updated, _ = config.NormalizePluginCommandLine(updated)
|
|
if updated.Type == "stdio" {
|
|
updated.URL = ""
|
|
} else {
|
|
updated.Command = ""
|
|
updated.Args = nil
|
|
}
|
|
if err := a.saveDesktopMCPServer(updated); err != nil {
|
|
return err
|
|
}
|
|
|
|
a.mu.RLock()
|
|
tab := a.activeTabLocked()
|
|
sessionDisabled := false
|
|
if tab != nil {
|
|
_, sessionDisabled = tab.disabledMCP[name]
|
|
}
|
|
a.mu.RUnlock()
|
|
wasConnected := mcpConnected(ctrl, name)
|
|
if wasConnected {
|
|
ctrl.DisconnectMCPServer(name)
|
|
}
|
|
if !sessionDisabled {
|
|
if _, err := ctrl.ConnectMCPServer(updated); err != nil {
|
|
recordMCPFailure(ctrl, updated, err)
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).
|
|
func (a *App) RemoveMCPServer(name string) error {
|
|
tab, ctrl := a.activeTabAndCtrl()
|
|
if tab == nil || ctrl == nil {
|
|
return fmt.Errorf("no active session")
|
|
}
|
|
if controllerHasActiveRuntimeWork(ctrl) {
|
|
return rebuildControllerActiveWorkError("MCP server")
|
|
}
|
|
if err := ensureMCPServerDirectlyWritable(tab.WorkspaceRoot, name); err != nil {
|
|
return err
|
|
}
|
|
removed, err := a.removeDesktopMCPServer(tab.WorkspaceRoot, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !removed {
|
|
return fmt.Errorf("no removable MCP server named %q", name)
|
|
}
|
|
ctrl.DisconnectMCPServer(name)
|
|
if h := ctrl.Host(); h != nil {
|
|
h.ClearFailure(name)
|
|
}
|
|
a.mu.Lock()
|
|
delete(tab.disabledMCP, name)
|
|
tab.mcpOrder = removeServerOrder(tab.mcpOrder, name)
|
|
a.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// ReconnectMCPServer disconnects the server if it is already connected (to force
|
|
// a fresh handshake and tool re-registration), then reconnects. Failures are
|
|
// recorded on the Host so the UI can render them.
|
|
func (a *App) ReconnectMCPServer(name string) error {
|
|
tab, ctrl := a.activeTabAndCtrl()
|
|
if tab == nil || ctrl == nil {
|
|
return fmt.Errorf("no active session")
|
|
}
|
|
if controllerHasActiveRuntimeWork(ctrl) {
|
|
return rebuildControllerActiveWorkError("MCP server")
|
|
}
|
|
ctrl.DisconnectMCPServer(name)
|
|
if h := ctrl.Host(); h != nil {
|
|
h.ClearFailure(name)
|
|
}
|
|
_, err := a.connectConfiguredMCPServerForTab(tab, name)
|
|
if err != nil {
|
|
if plugin.IsServerAlreadyConnected(err) {
|
|
a.mu.Lock()
|
|
delete(tab.disabledMCP, name)
|
|
a.mu.Unlock()
|
|
return nil
|
|
}
|
|
entry := config.PluginEntry{Name: name}
|
|
if p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {
|
|
entry = p
|
|
}
|
|
recordMCPFailure(ctrl, entry, err)
|
|
return err
|
|
}
|
|
a.mu.Lock()
|
|
delete(tab.disabledMCP, name)
|
|
a.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// ClearMCPServerAuthentication removes local auth-like config for one MCP and
|
|
// clears the current session's cached connection failure. It does not remove the
|
|
// server itself or try to sign the user out of the third-party browser session.
|
|
func (a *App) ClearMCPServerAuthentication(name string) error {
|
|
tab, ctrl := a.activeTabAndCtrl()
|
|
if tab == nil || ctrl == nil {
|
|
return fmt.Errorf("no active session")
|
|
}
|
|
if controllerHasActiveRuntimeWork(ctrl) {
|
|
return rebuildControllerActiveWorkError("MCP server")
|
|
}
|
|
if err := ensureMCPServerDirectlyWritable(tab.WorkspaceRoot, name); err != nil {
|
|
return err
|
|
}
|
|
if _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {
|
|
return err
|
|
}
|
|
ctrl.DisconnectMCPServer(name)
|
|
if h := ctrl.Host(); h != nil {
|
|
h.ClearFailure(name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) updateMCPServerTrustedReadOnlyTools(name string, update func([]string) []string) error {
|
|
ctrl := a.activeCtrl()
|
|
if ctrl == nil {
|
|
return fmt.Errorf("no active session")
|
|
}
|
|
if controllerHasActiveRuntimeWork(ctrl) {
|
|
return rebuildControllerActiveWorkError("MCP server")
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return fmt.Errorf("MCP server name is required")
|
|
}
|
|
updated, found, err := a.desktopMCPServerForEdit(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("no configured MCP server named %q", name)
|
|
}
|
|
trusted := uniqueStrings(updated.TrustedReadOnlyTools)
|
|
next := uniqueStrings(update(trusted))
|
|
if sameStringList(trusted, next) {
|
|
updated.TrustedReadOnlyTools = trusted
|
|
return nil
|
|
}
|
|
updated.TrustedReadOnlyTools = next
|
|
if err := a.saveDesktopMCPServer(updated); err != nil {
|
|
return err
|
|
}
|
|
|
|
a.mu.RLock()
|
|
tab := a.activeTabLocked()
|
|
sessionDisabled := false
|
|
if tab != nil {
|
|
_, sessionDisabled = tab.disabledMCP[name]
|
|
}
|
|
a.mu.RUnlock()
|
|
if !mcpConnected(ctrl, name) {
|
|
return nil
|
|
}
|
|
ctrl.DisconnectMCPServer(name)
|
|
if h := ctrl.Host(); h != nil {
|
|
h.ClearFailure(name)
|
|
}
|
|
if !sessionDisabled {
|
|
if _, err := ctrl.ConnectMCPServer(updated); err != nil {
|
|
recordMCPFailure(ctrl, updated, err)
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TrustMCPServerTool marks one raw MCP tool name as trusted read-only and
|
|
// refreshes the live connection so plan mode can use the updated trust boundary.
|
|
func (a *App) TrustMCPServerTool(name, toolName string) error {
|
|
toolName = strings.TrimSpace(toolName)
|
|
if toolName == "" {
|
|
return fmt.Errorf("MCP tool name is required")
|
|
}
|
|
return a.updateMCPServerTrustedReadOnlyTools(name, func(trusted []string) []string {
|
|
return append(trusted, toolName)
|
|
})
|
|
}
|
|
|
|
// TrustMCPServerTools marks multiple raw MCP tool names as trusted read-only in
|
|
// one config write and one live reconnect.
|
|
func (a *App) TrustMCPServerTools(name string, toolNames []string) error {
|
|
if len(uniqueStrings(toolNames)) == 0 {
|
|
return fmt.Errorf("at least one MCP tool name is required")
|
|
}
|
|
return a.updateMCPServerTrustedReadOnlyTools(name, func(trusted []string) []string {
|
|
return append(trusted, toolNames...)
|
|
})
|
|
}
|
|
|
|
// UntrustMCPServerTool removes one raw MCP tool name from the trusted read-only
|
|
// list and refreshes the live connection.
|
|
func (a *App) UntrustMCPServerTool(name, toolName string) error {
|
|
toolName = strings.TrimSpace(toolName)
|
|
if toolName == "" {
|
|
return fmt.Errorf("MCP tool name is required")
|
|
}
|
|
return a.updateMCPServerTrustedReadOnlyTools(name, func(trusted []string) []string {
|
|
return removeString(trusted, toolName)
|
|
})
|
|
}
|
|
|
|
// SetMCPServerEnabled is the connector toggle: on reconnects a configured server
|
|
// for this session, off disconnects it (config untouched either way — like Claude
|
|
// Code's per-conversation enable/disable, it resets on the next session start).
|
|
func (a *App) SetMCPServerEnabled(name string, enabled bool) error {
|
|
a.mu.RLock()
|
|
tab := a.activeTabLocked()
|
|
var ctrl control.SessionAPI
|
|
hostKey := ""
|
|
if tab != nil {
|
|
ctrl = tab.Ctrl
|
|
hostKey = tab.SharedHostKey
|
|
}
|
|
a.mu.RUnlock()
|
|
if tab == nil || ctrl == nil {
|
|
return fmt.Errorf("no active session")
|
|
}
|
|
if controllerHasActiveRuntimeWork(ctrl) {
|
|
return rebuildControllerActiveWorkError("MCP server")
|
|
}
|
|
configuredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if enabled {
|
|
_, err := a.connectConfiguredMCPServerForTab(tab, name)
|
|
if err == nil {
|
|
a.mu.Lock()
|
|
delete(tab.disabledMCP, name)
|
|
a.mu.Unlock()
|
|
}
|
|
return err
|
|
}
|
|
if s, ok := findMCPServerView(ctrl, name); ok {
|
|
s.Status = "disabled"
|
|
s.Error = ""
|
|
a.mu.Lock()
|
|
if tab.disabledMCP == nil {
|
|
tab.disabledMCP = map[string]ServerView{}
|
|
}
|
|
tab.disabledMCP[name] = s
|
|
tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})
|
|
a.mu.Unlock()
|
|
} else if hasConfiguredEntry {
|
|
s := withPluginConfig(ServerView{Name: name, Status: "disabled"}, configuredEntry)
|
|
a.mu.Lock()
|
|
if tab.disabledMCP == nil {
|
|
tab.disabledMCP = map[string]ServerView{}
|
|
}
|
|
tab.disabledMCP[name] = s
|
|
tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})
|
|
a.mu.Unlock()
|
|
}
|
|
if hostKey != "" {
|
|
ctrl.UnregisterMCPServerTools(name)
|
|
} else {
|
|
ctrl.DisconnectMCPServer(name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {
|
|
a.mu.RLock()
|
|
var ctrl control.SessionAPI
|
|
root := ""
|
|
if tab != nil {
|
|
ctrl = tab.Ctrl
|
|
root = tab.WorkspaceRoot
|
|
}
|
|
a.mu.RUnlock()
|
|
if ctrl == nil {
|
|
return 0, fmt.Errorf("no active session")
|
|
}
|
|
cfg, err := config.LoadForRoot(root)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
for _, p := range cfg.Plugins {
|
|
if p.Name == name {
|
|
return ctrl.ConnectMCPServer(p)
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("no configured MCP server named %q", name)
|
|
}
|
|
|
|
// SetMCPServerTier is kept for old desktop bindings. New config writes drop the
|
|
// retired tier field.
|
|
func (a *App) SetMCPServerTier(name, tier string) error {
|
|
tier = normalizeMCPTier(tier)
|
|
tab, ctrl := a.activeTabAndCtrl()
|
|
if tab != nil && controllerHasActiveRuntimeWork(ctrl) {
|
|
return rebuildControllerActiveWorkError("MCP server")
|
|
}
|
|
updated, found, err := a.desktopMCPServerForEdit(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("no configured MCP server named %q", name)
|
|
}
|
|
updated.Tier = tier
|
|
if !updated.ShouldAutoStart() {
|
|
on := true
|
|
updated.AutoStart = &on
|
|
}
|
|
if err := a.saveDesktopMCPServer(updated); err != nil {
|
|
return err
|
|
}
|
|
if tab != nil && ctrl != nil && !mcpConnected(ctrl, name) {
|
|
if _, err := ctrl.ConnectMCPServer(updated); err != nil {
|
|
recordMCPFailure(ctrl, updated, err)
|
|
return nil
|
|
}
|
|
a.mu.Lock()
|
|
delete(tab.disabledMCP, name)
|
|
a.mu.Unlock()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {
|
|
// Read-only lookup of the entry to edit; loads credentials because callers
|
|
// hand the entry to ConnectMCPServer, which resolves env-based secrets.
|
|
// The actual config write goes through saveDesktopMCPServer under the
|
|
// config edit lock.
|
|
cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
|
|
if err != nil {
|
|
return config.PluginEntry{}, false, err
|
|
}
|
|
if p, ok := findPluginEntry(cfg.Plugins, name); ok {
|
|
return p, true, nil
|
|
}
|
|
if merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {
|
|
if p, ok := findPluginEntry(merged.Plugins, name); ok {
|
|
return p, true, nil
|
|
}
|
|
}
|
|
return config.PluginEntry{}, false, nil
|
|
}
|
|
|
|
func (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {
|
|
root := a.activeWorkspaceRoot()
|
|
if err := ensureMCPServerDirectlyWritable(root, entry.Name); err != nil {
|
|
return err
|
|
}
|
|
if a.desktopMCPServerOwnedByProjectMCPJSON(root, entry.Name) {
|
|
_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(root), entry)
|
|
return err
|
|
}
|
|
// Lock only the user-config load-modify-save; the project-override cleanup
|
|
// below writes the project config, which this lock does not cover.
|
|
if err := func() error {
|
|
unlock := config.LockUserConfigEdits()
|
|
defer unlock()
|
|
cfg, path, err := a.loadDesktopUserConfigForEdit()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := cfg.UpsertPlugin(entry); err != nil {
|
|
return err
|
|
}
|
|
return cfg.SaveTo(path)
|
|
}(); err != nil {
|
|
return err
|
|
}
|
|
_, err := a.removeProjectMCPOverride(root, entry.Name)
|
|
return err
|
|
}
|
|
|
|
func ensureMCPServerDirectlyWritable(root, name string) error {
|
|
cfg, err := config.LoadForRoot(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if owner, ok := cfg.PluginPackageOwner(name); ok {
|
|
return fmt.Errorf("MCP server %q is managed by plugin %q; disable or remove the plugin instead", name, owner)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) removeDesktopMCPServer(root, name string) (bool, error) {
|
|
return config.RemovePluginFromSourcesForRoot(root, name)
|
|
}
|
|
|
|
func (a *App) removeProjectMCPOverride(root, name string) (bool, error) {
|
|
path := projectConfigPathForRoot(root)
|
|
userPath := config.UserConfigPath()
|
|
if path == "" || sameConfigPath(path, userPath) {
|
|
return false, nil
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
cfg := config.LoadForEdit(path)
|
|
if !cfg.RemovePlugin(name) {
|
|
return false, nil
|
|
}
|
|
if err := cfg.SaveTo(path); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (a *App) desktopMCPServerOwnedByProjectMCPJSON(root, name string) bool {
|
|
if strings.TrimSpace(name) == "" {
|
|
return false
|
|
}
|
|
// Read-only ownership check: only looks for the name in the user config's
|
|
// plugin list, so no credentials and no config edit lock are needed.
|
|
cfg, _, err := a.loadDesktopUserConfigForView()
|
|
if err == nil {
|
|
if _, ok := findPluginEntry(cfg.Plugins, name); ok {
|
|
return false
|
|
}
|
|
}
|
|
projectCfg := config.LoadForEdit(projectConfigPathForRoot(root))
|
|
if _, ok := findPluginEntry(projectCfg.Plugins, name); ok {
|
|
return false
|
|
}
|
|
_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(root), name)
|
|
return err == nil && ok
|
|
}
|
|
|
|
func projectMCPJSONPathForRoot(root string) string {
|
|
if strings.TrimSpace(root) == "" || root == "." {
|
|
return ".mcp.json"
|
|
}
|
|
return filepath.Join(root, ".mcp.json")
|
|
}
|
|
|
|
func findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {
|
|
for _, p := range entries {
|
|
if p.Name == name {
|
|
return p, true
|
|
}
|
|
}
|
|
return config.PluginEntry{}, false
|
|
}
|
|
|
|
func normalizeMCPTier(tier string) string {
|
|
switch strings.ToLower(strings.TrimSpace(tier)) {
|
|
case "eager":
|
|
return "eager"
|
|
case "background", "lazy":
|
|
return "background"
|
|
case "":
|
|
return "background"
|
|
default:
|
|
return "background"
|
|
}
|
|
}
|
|
|
|
func normalizeMCPTransport(transport string) string {
|
|
switch strings.ToLower(strings.TrimSpace(transport)) {
|
|
case "http", "streamable-http":
|
|
return "http"
|
|
case "sse":
|
|
return "sse"
|
|
default:
|
|
return "stdio"
|
|
}
|
|
}
|
|
|
|
func mcpConnected(ctrl control.SessionAPI, name string) bool {
|
|
if ctrl == nil || ctrl.Host() == nil {
|
|
return false
|
|
}
|
|
for _, s := range ctrl.Host().Servers() {
|
|
if s.Name == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func mcpFailed(ctrl control.SessionAPI, name string) bool {
|
|
if ctrl == nil || ctrl.Host() == nil {
|
|
return false
|
|
}
|
|
for _, f := range ctrl.Host().Failures() {
|
|
if f.Name == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {
|
|
if ctrl == nil || ctrl.Host() == nil || err == nil {
|
|
return
|
|
}
|
|
exp := e.ExpandedPlugin()
|
|
ctrl.Host().RecordFailure(plugin.Spec{
|
|
Name: exp.Name,
|
|
Type: exp.Type,
|
|
Command: exp.Command,
|
|
Args: exp.Args,
|
|
Env: exp.Env,
|
|
URL: exp.URL,
|
|
Headers: exp.Headers,
|
|
}, err)
|
|
}
|
|
|
|
func findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {
|
|
if ctrl == nil || ctrl.Host() == nil {
|
|
return ServerView{}, false
|
|
}
|
|
for _, s := range ctrl.Host().Servers() {
|
|
if s.Name == name {
|
|
return ServerView{
|
|
Name: s.Name, Transport: s.Transport, Status: "connected",
|
|
Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
|
|
HasTools: s.HasTools,
|
|
ToolList: pluginToolsToView(s.ToolList),
|
|
}, true
|
|
}
|
|
}
|
|
for _, f := range ctrl.Host().Failures() {
|
|
if f.Name == name {
|
|
return ServerView{Name: f.Name, Transport: f.Transport, Status: "failed", Error: f.Error}, true
|
|
}
|
|
}
|
|
return ServerView{}, false
|
|
}
|
|
|
|
func pluginToolsToView(tools []plugin.ToolInfo) []ToolView {
|
|
if len(tools) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]ToolView, 0, len(tools))
|
|
for _, t := range tools {
|
|
out = append(out, ToolView{Name: t.Name, Description: t.Description, ReadOnlyHint: t.ReadOnlyHint})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sameStringList(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func orderServerViews(servers []ServerView, order []string) []ServerView {
|
|
pos := make(map[string]int, len(order))
|
|
for i, name := range order {
|
|
pos[name] = i
|
|
}
|
|
sort.SliceStable(servers, func(i, j int) bool {
|
|
pi, iok := pos[servers[i].Name]
|
|
pj, jok := pos[servers[j].Name]
|
|
switch {
|
|
case iok && jok:
|
|
return pi < pj
|
|
case iok:
|
|
return true
|
|
case jok:
|
|
return false
|
|
default:
|
|
return false
|
|
}
|
|
})
|
|
return servers
|
|
}
|
|
|
|
func mergeServerOrder(order []string, servers []ServerView) []string {
|
|
seen := make(map[string]bool, len(order)+len(servers))
|
|
next := make([]string, 0, len(order)+len(servers))
|
|
for _, name := range order {
|
|
if name == "" || seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
next = append(next, name)
|
|
}
|
|
for _, s := range servers {
|
|
if s.Name == "" || seen[s.Name] {
|
|
continue
|
|
}
|
|
seen[s.Name] = true
|
|
next = append(next, s.Name)
|
|
}
|
|
return next
|
|
}
|
|
|
|
func removeServerOrder(order []string, name string) []string {
|
|
if name == "" || len(order) == 0 {
|
|
return order
|
|
}
|
|
next := order[:0]
|
|
for _, n := range order {
|
|
if n != name {
|
|
next = append(next, n)
|
|
}
|
|
}
|
|
return next
|
|
}
|
|
|
|
// ModelInfo is one (provider, model) the bottom switcher can pick. Ref ("provider/
|
|
// model") is what SetModel takes; Provider/Model are for display.
|
|
type ModelInfo struct {
|
|
Ref string `json:"ref"`
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
Current bool `json:"current"`
|
|
}
|
|
|
|
type EffortInfo struct {
|
|
Supported bool `json:"supported"`
|
|
Current string `json:"current"`
|
|
Default string `json:"default"`
|
|
Levels []string `json:"levels"`
|
|
}
|
|
|
|
// Models flattens the configured providers into their (provider, model) pairs —
|
|
// the switcher's options — marking the active one. A vendor with a `models` list
|
|
// yields one entry per model, all sharing the same endpoint/key. Unconfigured
|
|
// providers are skipped. Result is non-nil: the frontend reads .length, so a nil
|
|
// slice (JSON null) would crash the switcher on an empty list.
|
|
func (a *App) Models() []ModelInfo {
|
|
return a.ModelsForTab("")
|
|
}
|
|
|
|
func (a *App) ModelsForTab(tabID string) []ModelInfo {
|
|
a.mu.RLock()
|
|
curModel := ""
|
|
workspaceRoot := ""
|
|
if tab := a.tabByIDLocked(tabID); tab != nil {
|
|
curModel = tab.model
|
|
workspaceRoot = tab.WorkspaceRoot
|
|
}
|
|
a.mu.RUnlock()
|
|
cfg, err := config.LoadForRoot(workspaceRoot)
|
|
if err != nil {
|
|
return []ModelInfo{}
|
|
}
|
|
if entry, ok := cfg.ResolveModel(curModel); ok {
|
|
curModel = entry.Name + "/" + entry.Model
|
|
}
|
|
access := providerAccessSet(cfg.Desktop.ProviderAccess)
|
|
out := []ModelInfo{}
|
|
for i := range cfg.Providers {
|
|
p := &cfg.Providers[i]
|
|
if !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {
|
|
continue
|
|
}
|
|
for _, m := range p.ChatModelList() {
|
|
ref := p.Name + "/" + m
|
|
out = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func modelProviderAccessAllowed(access map[string]bool, name string) bool {
|
|
if len(access) == 0 {
|
|
return true
|
|
}
|
|
return access[strings.TrimSpace(name)]
|
|
}
|
|
|
|
func controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {
|
|
if ctrl == nil {
|
|
return false
|
|
}
|
|
status := ctrl.RuntimeStatus()
|
|
return status.Running || status.PendingPrompt || status.BackgroundJobs > 0
|
|
}
|
|
|
|
// rebuildBusyError reports a rebuild rejected because the controller still has
|
|
// a running turn, pending prompt, or background jobs. Typed so the
|
|
// deferred-rebuild retry loop can keep waiting instead of giving up.
|
|
type rebuildBusyError struct{ setting string }
|
|
|
|
func (e *rebuildBusyError) Error() string {
|
|
return fmt.Sprintf("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s", e.setting)
|
|
}
|
|
|
|
func rebuildControllerActiveWorkError(setting string) error {
|
|
return &rebuildBusyError{setting: setting}
|
|
}
|
|
|
|
type sessionLeaseBusyError struct {
|
|
setting string
|
|
err error
|
|
}
|
|
|
|
func (e *sessionLeaseBusyError) Error() string {
|
|
// The raw SessionLeaseError text carries the session path and the
|
|
// holder's host-pid-writer id; every user-facing surface must render
|
|
// this wrapper instead. An empty setting means the failure gated opening
|
|
// the session itself (startup bind), not changing a setting on it.
|
|
setting := strings.TrimSpace(e.setting)
|
|
if setting == "" {
|
|
return "this session is already open in another Reasonix window or still running in the background; close the other window or open a copy"
|
|
}
|
|
return fmt.Sprintf("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing %s", setting)
|
|
}
|
|
|
|
func (e *sessionLeaseBusyError) Unwrap() error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.err
|
|
}
|
|
|
|
func userFacingSessionLeaseError(setting string, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
|
return &sessionLeaseBusyError{setting: setting, err: err}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// sessionPathAfterSnapshot returns where a controller rebuild should keep
|
|
// persisting after the old controller was snapshotted. Snapshotting is not
|
|
// path-neutral: a snapshot conflict can recover by retargeting the controller
|
|
// (and the tab's session lease, via handleTabSessionRecovered) to a recovery
|
|
// branch, so a prevPath captured before the snapshot may be stale. Reusing the
|
|
// stale path would bind the rebuilt controller — carrying the just-recovered
|
|
// transcript — back to the original file, turning every later save into a new
|
|
// conflict that derives yet another recovery branch. Falls back to fallback
|
|
// when the controller is gone or persistence is disabled (empty SessionPath).
|
|
func sessionPathAfterSnapshot(ctrl control.SessionAPI, fallback string) string {
|
|
if ctrl == nil {
|
|
return fallback
|
|
}
|
|
if path := strings.TrimSpace(ctrl.SessionPath()); path != "" {
|
|
return path
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func (a *App) ensureTabSessionLeaseForRebuild(tab *WorkspaceTab, path, setting string) error {
|
|
if err := tab.ensureSessionLease(path); err != nil {
|
|
if a.canReclaimCurrentProcessSessionLease(tab, path, err) {
|
|
if lease, reclaimErr := agent.TryReclaimCurrentProcessSessionLease(path); reclaimErr == nil {
|
|
tab.adoptSessionLease(lease)
|
|
return nil
|
|
} else {
|
|
err = reclaimErr
|
|
}
|
|
}
|
|
return userFacingSessionLeaseError(setting, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) canReclaimCurrentProcessSessionLease(tab *WorkspaceTab, path string, err error) bool {
|
|
key := sessionRuntimeKey(path)
|
|
if tab == nil || key == "" || !errors.Is(err, agent.ErrSessionLeaseHeld) {
|
|
return false
|
|
}
|
|
var leaseErr *agent.SessionLeaseError
|
|
if !errors.As(err, &leaseErr) || leaseErr == nil {
|
|
return false
|
|
}
|
|
// A readable info naming a foreign runtime is respected here; reclaim
|
|
// would refuse it anyway. A nil Info (lease.json deleted by the user,
|
|
// quarantined by AV, or torn by a crash) must still attempt the reclaim:
|
|
// the OS lock is the arbiter there, and refusing on missing metadata
|
|
// wedges a session nobody actually holds as permanently busy.
|
|
if leaseErr.Info != nil &&
|
|
(leaseErr.Info.PID != os.Getpid() || leaseErr.Info.WriterID != agent.SessionWriterID()) {
|
|
return false
|
|
}
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
for _, candidate := range a.runtimeTabsLocked() {
|
|
if candidate == nil || candidate == tab {
|
|
continue
|
|
}
|
|
if candidate.sessionLeaseRuntimeKey() == key {
|
|
return false
|
|
}
|
|
if candidate.Ctrl != nil && sessionRuntimeKey(candidate.currentSessionPath()) == key {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// SetModel switches the active model and carries the current conversation into the
|
|
// new model's session, so the chat continues seamlessly and subsequent turns use
|
|
// the new model. No-op if name is already active or the controller is down.
|
|
func (a *App) SetModel(name string) error {
|
|
return a.SetModelForTab("", name)
|
|
}
|
|
|
|
func (a *App) SetModelForTab(tabID, name string) error {
|
|
if a.ctx == nil || name == "" {
|
|
return nil
|
|
}
|
|
tab := a.tabByID(tabID)
|
|
if tab == nil {
|
|
return nil
|
|
}
|
|
a.mu.RLock()
|
|
currentModel := tab.model
|
|
a.mu.RUnlock()
|
|
if name == currentModel {
|
|
return nil
|
|
}
|
|
// Same build+swap shape as rebuildSetting; hold the same lock so a settings
|
|
// rebuild (manual or from the deferred-rebuild retry loop) and a model
|
|
// switch cannot interleave on one tab.
|
|
a.runtimeRebuildMu.Lock()
|
|
defer a.runtimeRebuildMu.Unlock()
|
|
prevPath := a.reconciledSessionPathForTab(tab)
|
|
if prevPath == "" {
|
|
prevPath = a.currentSessionPathFor(tab)
|
|
}
|
|
if a.controllerForTab(tab) == nil && prevPath != "" {
|
|
a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
|
|
}
|
|
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
|
|
return rebuildControllerActiveWorkError("model")
|
|
}
|
|
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
|
return err
|
|
}
|
|
prevPath = a.reconciledSessionPathForTab(tab)
|
|
if prevPath == "" {
|
|
prevPath = a.currentSessionPathFor(tab)
|
|
}
|
|
if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
|
|
prevPath = a.reconciledSessionPathForTab(tab)
|
|
if prevPath == "" {
|
|
prevPath = a.currentSessionPathFor(tab)
|
|
}
|
|
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
|
|
return rebuildControllerActiveWorkError("model")
|
|
}
|
|
}
|
|
// Snapshot the tab profile under a.mu: SetModeForTab/SetGoalForTab and the
|
|
// event sink write these fields under the lock while this rebuild runs
|
|
// off-lock.
|
|
snap := a.tabRuntimeSnapshot(tab)
|
|
cfg, err := config.LoadForRoot(snap.workspaceRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entry, ok := cfg.ResolveModel(name)
|
|
if !ok {
|
|
return fmt.Errorf("unknown model %q", name)
|
|
}
|
|
if !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {
|
|
return fmt.Errorf("model %q is not available because provider %q is not added", name, entry.Name)
|
|
}
|
|
name = entry.Name + "/" + entry.Model
|
|
effortOverride := cloneStringPtr(snap.effort)
|
|
if effortOverride != nil {
|
|
normalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))
|
|
if err != nil {
|
|
effortOverride = nil
|
|
} else {
|
|
effortOverride = &normalized
|
|
}
|
|
}
|
|
|
|
var carried []provider.Message
|
|
oldCtrl := a.controllerForTab(tab)
|
|
if oldCtrl != nil {
|
|
if prevPath == "" {
|
|
prevPath = oldCtrl.SessionPath()
|
|
}
|
|
if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "model"); err != nil {
|
|
return err
|
|
}
|
|
if err := a.snapshotTabForAction(tab, "changing model"); err != nil {
|
|
return err
|
|
}
|
|
prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
|
|
carried = oldCtrl.History()
|
|
}
|
|
|
|
// Preserve the shared plugin host across controller rebuilds — the tab
|
|
// stays in the same workspace root, so MCP processes must not be restarted.
|
|
sharedHost := a.lookupSharedHost(snap.sharedHostKey)
|
|
|
|
newCtrl, err := boot.Build(a.bootContext(), boot.Options{
|
|
Model: name,
|
|
RequireKey: false,
|
|
Sink: snap.sink,
|
|
WorkspaceRoot: snap.workspaceRoot,
|
|
SessionDir: sessionDirForSnapshot(snap),
|
|
EffortOverride: cloneStringPtr(effortOverride),
|
|
TokenMode: snap.currentTokenMode(),
|
|
SharedHost: sharedHost,
|
|
CleanupPendingReconciler: reconcileDesktopCleanupPending,
|
|
SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
|
|
OnSessionRecovered: a.handleTabSessionRecovered(tab),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.bindControllerDisplayRecorder(newCtrl)
|
|
newCtrl.EnableInteractiveApproval()
|
|
applyTabModeToController(newCtrl, snap.mode)
|
|
applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
|
|
newCtrl.SetGoal(snap.goal)
|
|
|
|
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
|
|
if err := a.ensureTabSessionLeaseForRebuild(tab, path, "model"); err != nil {
|
|
newCtrl.Close()
|
|
return err
|
|
}
|
|
resumeWithFreshSystemPrompt(newCtrl, carried, path)
|
|
a.mu.Lock()
|
|
if current := a.tabs[tab.ID]; current != tab {
|
|
// The tab was closed/replaced while we built the new controller off-lock;
|
|
// adopting it now would leak the runtime onto an orphaned tab and pin the
|
|
// session lease forever.
|
|
a.mu.Unlock()
|
|
newCtrl.Close()
|
|
tab.releaseSessionLease()
|
|
return fmt.Errorf("tab %q changed while switching model; retry", tab.ID)
|
|
}
|
|
tab.Ctrl = newCtrl
|
|
tab.model = name
|
|
tab.effort = cloneStringPtr(effortOverride)
|
|
tab.Label = newCtrl.Label()
|
|
// Supersede any in-flight startup build: it would otherwise finish later,
|
|
// overwrite this controller, and release/steal the tab's session lease.
|
|
a.supersedeTabBuildLocked(tab)
|
|
a.saveTabsLocked()
|
|
a.mu.Unlock()
|
|
if oldCtrl != nil {
|
|
oldCtrl.Close()
|
|
}
|
|
// The runtime now reflects the on-disk config; drop any deferred refresh.
|
|
a.clearDeferredRebuild(tab.ID)
|
|
a.persistTabSessionPath(tab, path)
|
|
a.notifyTabRuntimeRebuilt(tab)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) Effort() EffortInfo {
|
|
return a.EffortForTab("")
|
|
}
|
|
|
|
func (a *App) EffortForTab(tabID string) EffortInfo {
|
|
entry, err := a.currentProviderEntryForTab(tabID)
|
|
if err != nil {
|
|
return EffortInfo{Current: "auto", Levels: []string{}}
|
|
}
|
|
cap := config.EffortCapabilityForEntry(entry)
|
|
if !cap.Supported {
|
|
return EffortInfo{Supported: false, Current: "auto", Default: cap.Default, Levels: []string{}}
|
|
}
|
|
levels := cap.Levels
|
|
if levels == nil {
|
|
levels = []string{}
|
|
}
|
|
return EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}
|
|
}
|
|
|
|
func (a *App) SetEffort(level string) error {
|
|
return a.SetEffortForTab("", level)
|
|
}
|
|
|
|
func (a *App) SetEffortForTab(tabID, level string) error {
|
|
tab := a.tabByID(tabID)
|
|
if tab == nil {
|
|
if strings.TrimSpace(tabID) == "" {
|
|
entry, err := a.currentProviderEntryForTab("")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
effort, err := config.NormalizeEffort(entry, level)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.applyProviderEffortConfig(entry, effort)
|
|
}
|
|
return fmt.Errorf("tab %q not found", tabID)
|
|
}
|
|
// Build+swap path; serialize with the other rebuild paths (see
|
|
// runtimeRebuildMu). The tab==nil branch above goes through
|
|
// applyProviderEffortConfig → rebuildSetting, which takes the lock itself.
|
|
a.runtimeRebuildMu.Lock()
|
|
defer a.runtimeRebuildMu.Unlock()
|
|
prevPath := a.reconciledSessionPathForTab(tab)
|
|
if prevPath == "" {
|
|
prevPath = a.currentSessionPathFor(tab)
|
|
}
|
|
// Recomputing prevPath after this attach would be a dead store: it is
|
|
// unconditionally derived again after ensureTabControllerWorkspace below.
|
|
if a.controllerForTab(tab) == nil && prevPath != "" {
|
|
a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
|
|
}
|
|
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
|
|
return rebuildControllerActiveWorkError("effort")
|
|
}
|
|
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
|
return err
|
|
}
|
|
prevPath = a.reconciledSessionPathForTab(tab)
|
|
if prevPath == "" {
|
|
prevPath = a.currentSessionPathFor(tab)
|
|
}
|
|
if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
|
|
prevPath = a.reconciledSessionPathForTab(tab)
|
|
if prevPath == "" {
|
|
prevPath = a.currentSessionPathFor(tab)
|
|
}
|
|
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
|
|
return rebuildControllerActiveWorkError("effort")
|
|
}
|
|
}
|
|
snap := a.tabRuntimeSnapshot(tab)
|
|
entry, err := a.currentProviderEntryForTab(tabID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
modelRef := entry.Name + "/" + entry.Model
|
|
effort, err := config.NormalizeEffort(entry, level)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var carried []provider.Message
|
|
oldCtrl := a.controllerForTab(tab)
|
|
if oldCtrl != nil {
|
|
if prevPath == "" {
|
|
prevPath = oldCtrl.SessionPath()
|
|
}
|
|
if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "effort"); err != nil {
|
|
return err
|
|
}
|
|
if err := a.snapshotTabForAction(tab, "changing effort"); err != nil {
|
|
return err
|
|
}
|
|
prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
|
|
carried = oldCtrl.History()
|
|
}
|
|
sharedHost := a.lookupSharedHost(snap.sharedHostKey)
|
|
newCtrl, err := boot.Build(a.bootContext(), boot.Options{
|
|
Model: modelRef,
|
|
RequireKey: false,
|
|
Sink: snap.sink,
|
|
WorkspaceRoot: snap.workspaceRoot,
|
|
SessionDir: sessionDirForSnapshot(snap),
|
|
EffortOverride: &effort,
|
|
TokenMode: snap.currentTokenMode(),
|
|
SharedHost: sharedHost,
|
|
CleanupPendingReconciler: reconcileDesktopCleanupPending,
|
|
SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
|
|
OnSessionRecovered: a.handleTabSessionRecovered(tab),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.bindControllerDisplayRecorder(newCtrl)
|
|
newCtrl.EnableInteractiveApproval()
|
|
applyTabModeToController(newCtrl, snap.mode)
|
|
applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
|
|
newCtrl.SetGoal(snap.goal)
|
|
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
|
|
if err := a.ensureTabSessionLeaseForRebuild(tab, path, "effort"); err != nil {
|
|
newCtrl.Close()
|
|
return err
|
|
}
|
|
resumeWithFreshSystemPrompt(newCtrl, carried, path)
|
|
a.mu.Lock()
|
|
if current := a.tabs[tab.ID]; current != tab {
|
|
a.mu.Unlock()
|
|
newCtrl.Close()
|
|
tab.releaseSessionLease()
|
|
return fmt.Errorf("tab %q changed while switching effort; retry", tab.ID)
|
|
}
|
|
tab.Ctrl = newCtrl
|
|
tab.model = modelRef
|
|
tab.effort = &effort
|
|
tab.Label = newCtrl.Label()
|
|
clearTabStartupError(tab)
|
|
tab.Ready = true
|
|
a.supersedeTabBuildLocked(tab)
|
|
a.saveTabsLocked()
|
|
a.mu.Unlock()
|
|
if oldCtrl != nil {
|
|
oldCtrl.Close()
|
|
}
|
|
// The rebuilt runtime reflects the on-disk config; drop any deferred refresh.
|
|
a.clearDeferredRebuild(tab.ID)
|
|
a.persistTabSessionPath(tab, path)
|
|
a.notifyTabRuntimeRebuilt(tab)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) SetTokenMode(mode string) error {
|
|
return a.SetTokenModeForTab("", mode)
|
|
}
|
|
|
|
func (a *App) SetTokenModeForTab(tabID, mode string) error {
|
|
mode = boot.NormalizeTokenMode(mode)
|
|
tab := a.tabByID(tabID)
|
|
if tab == nil {
|
|
if strings.TrimSpace(tabID) == "" {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("tab %q not found", tabID)
|
|
}
|
|
a.mu.RLock()
|
|
currentMode := boot.NormalizeTokenMode(tab.tokenMode)
|
|
a.mu.RUnlock()
|
|
if mode == currentMode {
|
|
return nil
|
|
}
|
|
// Build+swap path; serialize with the other rebuild paths (see runtimeRebuildMu).
|
|
a.runtimeRebuildMu.Lock()
|
|
defer a.runtimeRebuildMu.Unlock()
|
|
prevPath := a.reconciledSessionPathForTab(tab)
|
|
if prevPath == "" {
|
|
prevPath = a.currentSessionPathFor(tab)
|
|
}
|
|
// Recomputing prevPath after this attach would be a dead store: it is
|
|
// unconditionally derived again after ensureTabControllerWorkspace below.
|
|
if a.controllerForTab(tab) == nil && prevPath != "" {
|
|
a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
|
|
}
|
|
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
|
|
return rebuildControllerActiveWorkError("token mode")
|
|
}
|
|
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
|
return err
|
|
}
|
|
prevPath = a.reconciledSessionPathForTab(tab)
|
|
if prevPath == "" {
|
|
prevPath = a.currentSessionPathFor(tab)
|
|
}
|
|
if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
|
|
prevPath = a.reconciledSessionPathForTab(tab)
|
|
if prevPath == "" {
|
|
prevPath = a.currentSessionPathFor(tab)
|
|
}
|
|
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
|
|
return rebuildControllerActiveWorkError("token mode")
|
|
}
|
|
}
|
|
modelRef, fallback, err := a.resolvedModelForTab(tab)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
snap := a.tabRuntimeSnapshot(tab)
|
|
if fallback && strings.TrimSpace(snap.model) != "" {
|
|
a.noticeForTab(tab.ID, fmt.Sprintf("model %q is no longer available; switched to %s", snap.model, modelRef))
|
|
}
|
|
|
|
var carried []provider.Message
|
|
oldCtrl := a.controllerForTab(tab)
|
|
if oldCtrl != nil {
|
|
if prevPath == "" {
|
|
prevPath = oldCtrl.SessionPath()
|
|
}
|
|
if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "token mode"); err != nil {
|
|
return err
|
|
}
|
|
if err := a.snapshotTabForAction(tab, "changing token mode"); err != nil {
|
|
return err
|
|
}
|
|
prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
|
|
carried = oldCtrl.History()
|
|
}
|
|
sharedHost := a.lookupSharedHost(snap.sharedHostKey)
|
|
newCtrl, err := boot.Build(a.bootContext(), boot.Options{
|
|
Model: modelRef,
|
|
RequireKey: false,
|
|
Sink: snap.sink,
|
|
WorkspaceRoot: snap.workspaceRoot,
|
|
SessionDir: sessionDirForSnapshot(snap),
|
|
EffortOverride: cloneStringPtr(snap.effort),
|
|
TokenMode: mode,
|
|
SharedHost: sharedHost,
|
|
CleanupPendingReconciler: reconcileDesktopCleanupPending,
|
|
SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
|
|
OnSessionRecovered: a.handleTabSessionRecovered(tab),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.bindControllerDisplayRecorder(newCtrl)
|
|
newCtrl.EnableInteractiveApproval()
|
|
applyTabModeToController(newCtrl, snap.mode)
|
|
applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
|
|
newCtrl.SetGoal(snap.goal)
|
|
path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
|
|
if err := a.ensureTabSessionLeaseForRebuild(tab, path, "token mode"); err != nil {
|
|
newCtrl.Close()
|
|
return err
|
|
}
|
|
resumeWithFreshSystemPrompt(newCtrl, carried, path)
|
|
a.mu.Lock()
|
|
if current := a.tabs[tab.ID]; current != tab {
|
|
a.mu.Unlock()
|
|
newCtrl.Close()
|
|
tab.releaseSessionLease()
|
|
return fmt.Errorf("tab %q changed while switching token mode; retry", tab.ID)
|
|
}
|
|
tab.Ctrl = newCtrl
|
|
tab.model = modelRef
|
|
tab.tokenMode = mode
|
|
tab.Label = newCtrl.Label()
|
|
clearTabStartupError(tab)
|
|
tab.Ready = true
|
|
a.supersedeTabBuildLocked(tab)
|
|
a.saveTabsLocked()
|
|
a.mu.Unlock()
|
|
if oldCtrl != nil {
|
|
oldCtrl.Close()
|
|
}
|
|
// The rebuilt runtime reflects the on-disk config; drop any deferred refresh.
|
|
a.clearDeferredRebuild(tab.ID)
|
|
a.persistTabSessionPath(tab, path)
|
|
a.notifyTabRuntimeRebuilt(tab)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {
|
|
return a.applyConfigChange(func(cfg *config.Config) error {
|
|
if _, ok := cfg.Provider(entry.Name); !ok {
|
|
if err := cfg.UpsertProvider(*entry); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if entry.Kind == "anthropic" && effort != "" && entry.Thinking == "" {
|
|
if err := cfg.SetProviderThinking(entry.Name, "adaptive"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, name := range providerEffortTargetNames(cfg, entry) {
|
|
if err := cfg.SetProviderEffort(name, effort); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {
|
|
if cfg == nil || entry == nil {
|
|
return nil
|
|
}
|
|
out := []string{entry.Name}
|
|
seen := map[string]bool{entry.Name: true}
|
|
kind := officialProviderKindFromEntry(*entry)
|
|
if kind == "" {
|
|
return out
|
|
}
|
|
var family []string
|
|
switch kind {
|
|
case "deepseek":
|
|
family = []string{"deepseek", "deepseek-flash", "deepseek-pro"}
|
|
}
|
|
for _, name := range family {
|
|
if seen[name] {
|
|
continue
|
|
}
|
|
p, ok := cfg.Provider(name)
|
|
if !ok || officialProviderKindFromEntry(*p) != kind {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
out = append(out, name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// DirEntry is one entry in the "@" file-reference menu.
|
|
type DirEntry struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path,omitempty"`
|
|
IsDir bool `json:"isDir"`
|
|
DisplayName string `json:"displayName,omitempty"`
|
|
DisplayPath string `json:"displayPath,omitempty"`
|
|
}
|
|
|
|
// FilePreview is a bounded, read-only file payload for the workspace side panel.
|
|
type FilePreview struct {
|
|
Path string `json:"path"`
|
|
Body string `json:"body"`
|
|
Size int64 `json:"size"`
|
|
Truncated bool `json:"truncated"`
|
|
Binary bool `json:"binary"`
|
|
Kind string `json:"kind,omitempty"`
|
|
Mime string `json:"mime,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
Err string `json:"err,omitempty"`
|
|
}
|
|
|
|
type WorkspaceChangeView struct {
|
|
Path string `json:"path"`
|
|
OldPath string `json:"oldPath,omitempty"`
|
|
Sources []string `json:"sources"`
|
|
GitStatus string `json:"gitStatus,omitempty"`
|
|
Turns []int `json:"turns,omitempty"`
|
|
LatestPrompt string `json:"latestPrompt,omitempty"`
|
|
LatestTime int64 `json:"latestTime,omitempty"`
|
|
}
|
|
|
|
type WorkspaceChangesView struct {
|
|
Files []WorkspaceChangeView `json:"files"`
|
|
GitAvailable bool `json:"gitAvailable"`
|
|
GitErr string `json:"gitErr,omitempty"`
|
|
GitBranch string `json:"gitBranch,omitempty"`
|
|
}
|
|
|
|
// workspaceNoiseNames are local cache/vendor entries hidden from the file tree
|
|
// and "@" menu regardless of where they appear.
|
|
var workspaceNoiseNames = map[string]bool{
|
|
".codex": true,
|
|
".DS_Store": true,
|
|
".git": true,
|
|
".npm": true,
|
|
".pnpm-store": true,
|
|
"node_modules": true,
|
|
"Thumbs.db": true,
|
|
}
|
|
|
|
var workspaceNoiseDirs = map[string]bool{
|
|
"bin": true,
|
|
"desktop/build": true,
|
|
"desktop/frontend/dist": true,
|
|
"desktop/frontend/wailsjs": true,
|
|
"dist": true,
|
|
"npm/.stage": true,
|
|
"site/.astro": true,
|
|
"site/dist": true,
|
|
"stage": true,
|
|
"tmp": true,
|
|
}
|
|
|
|
const filePreviewLimit = 256 * 1024
|
|
const fileRefSearchLimit = 20
|
|
|
|
var previewMediaMIMEs = map[string]string{
|
|
".bmp": "image/bmp",
|
|
".gif": "image/gif",
|
|
".jpeg": "image/jpeg",
|
|
".jpg": "image/jpeg",
|
|
".pdf": "application/pdf",
|
|
".png": "image/png",
|
|
".svg": "image/svg+xml",
|
|
".webp": "image/webp",
|
|
}
|
|
|
|
func trimUTF8PartialSuffix(data []byte) []byte {
|
|
if utf8.Valid(data) {
|
|
return data
|
|
}
|
|
for i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {
|
|
if !utf8.RuneStart(data[i]) {
|
|
continue
|
|
}
|
|
if !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {
|
|
return data
|
|
}
|
|
return data[:i]
|
|
}
|
|
return data
|
|
}
|
|
|
|
func previewMediaKind(path string) (kind string, mime string) {
|
|
mime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]
|
|
if mime == "" {
|
|
return "", ""
|
|
}
|
|
if strings.HasPrefix(mime, "image/") {
|
|
return "image", mime
|
|
}
|
|
if mime == "application/pdf" {
|
|
return "pdf", mime
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func workspaceEntryRel(rel, name string) string {
|
|
rel = strings.Trim(filepath.ToSlash(rel), "/")
|
|
if rel == "" || rel == "." {
|
|
return name
|
|
}
|
|
return rel + "/" + name
|
|
}
|
|
|
|
func skipWorkspaceEntry(rel, name string, isDir bool) bool {
|
|
if workspaceNoiseNames[name] {
|
|
return true
|
|
}
|
|
return isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]
|
|
}
|
|
|
|
func (a *App) activeWorkspaceBase() (string, error) {
|
|
return workspaceBaseFromRoot(a.activeWorkspaceRoot())
|
|
}
|
|
|
|
func (a *App) workspaceTargetForTab(tabID string) (string, control.SessionAPI, bool) {
|
|
tabID = strings.TrimSpace(tabID)
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
tab := a.tabByIDLocked(tabID)
|
|
if tab == nil {
|
|
if tabID == "" {
|
|
return ".", nil, true
|
|
}
|
|
return "", nil, false
|
|
}
|
|
return tab.WorkspaceRoot, tab.Ctrl, true
|
|
}
|
|
|
|
func workspaceBaseFromRoot(root string) (string, error) {
|
|
if strings.TrimSpace(root) == "" || root == "." {
|
|
return os.Getwd()
|
|
}
|
|
if abs, err := filepath.Abs(root); err == nil {
|
|
root = abs
|
|
}
|
|
return filepath.Clean(root), nil
|
|
}
|
|
|
|
func workspacePathForBase(base, rel string) (string, bool, error) {
|
|
base = filepath.Clean(base)
|
|
if rel == "" {
|
|
return "", false, os.ErrInvalid
|
|
}
|
|
path := rel
|
|
if !filepath.IsAbs(path) {
|
|
path = filepath.Join(base, rel)
|
|
}
|
|
path = filepath.Clean(path)
|
|
r, err := filepath.Rel(base, path)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if r == ".." || strings.HasPrefix(r, ".."+string(os.PathSeparator)) {
|
|
return "", false, os.ErrPermission
|
|
}
|
|
return path, true, nil
|
|
}
|
|
|
|
// ListDir lists one directory level (directories first, then files, each
|
|
// alphabetical) for the "@" file-reference menu. rel resolves against the active
|
|
// tab workspace. The menu navigates one level at a time, never recursively —
|
|
// bounded for huge trees.
|
|
func (a *App) ListDir(rel string) []DirEntry {
|
|
return a.ListDirForTab("", rel)
|
|
}
|
|
|
|
// ListDirForTab is the tab-scoped variant used by multi-tab frontend surfaces.
|
|
func (a *App) ListDirForTab(tabID, rel string) []DirEntry {
|
|
root, ctrl, ok := a.workspaceTargetForTab(tabID)
|
|
if !ok {
|
|
return []DirEntry{}
|
|
}
|
|
if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
|
|
if entries, handled := browser.ListExternalFolderRefDir(rel); handled {
|
|
return externalFolderDirEntries(entries)
|
|
}
|
|
}
|
|
base, err := workspaceBaseFromRoot(root)
|
|
if err != nil {
|
|
return []DirEntry{}
|
|
}
|
|
dir := base
|
|
if rel != "" {
|
|
path, ok, err := workspacePathForBase(base, rel)
|
|
if err != nil || !ok {
|
|
return []DirEntry{}
|
|
}
|
|
dir = path
|
|
}
|
|
es, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return []DirEntry{}
|
|
}
|
|
dirs, files := []DirEntry{}, []DirEntry{}
|
|
for _, e := range es {
|
|
name := e.Name()
|
|
if skipWorkspaceEntry(rel, name, e.IsDir()) {
|
|
continue
|
|
}
|
|
if e.IsDir() {
|
|
dirs = append(dirs, DirEntry{Name: name, IsDir: true})
|
|
continue
|
|
}
|
|
info, err := e.Info()
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
continue
|
|
}
|
|
files = append(files, DirEntry{Name: name, IsDir: false})
|
|
}
|
|
sort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })
|
|
sort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })
|
|
return append(dirs, files...)
|
|
}
|
|
|
|
// SearchFileRefs finds workspace files by basename for bare "@token" completion.
|
|
func (a *App) SearchFileRefs(query string) []DirEntry {
|
|
return a.SearchFileRefsForTab("", query)
|
|
}
|
|
|
|
// SearchFileRefsForTab is the tab-scoped variant used by multi-tab frontend surfaces.
|
|
func (a *App) SearchFileRefsForTab(tabID, query string) []DirEntry {
|
|
root, ctrl, ok := a.workspaceTargetForTab(tabID)
|
|
if !ok {
|
|
return []DirEntry{}
|
|
}
|
|
base, err := workspaceBaseFromRoot(root)
|
|
if err != nil {
|
|
return []DirEntry{}
|
|
}
|
|
results := fileref.Search(base, query, fileRefSearchLimit)
|
|
out := make([]DirEntry, 0, len(results))
|
|
for _, r := range results {
|
|
out = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})
|
|
}
|
|
if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
|
|
out = append(out, externalFolderDirEntries(browser.SearchExternalFolderRefs(query, fileRefSearchLimit))...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
type externalFolderRefBrowser interface {
|
|
ListExternalFolderRefDir(tokenPath string) ([]control.ExternalFolderRefEntry, bool)
|
|
SearchExternalFolderRefs(query string, limit int) []control.ExternalFolderRefEntry
|
|
ExternalFolderRefLocalPath(tokenPath string) (path, displayPath string, ok bool)
|
|
}
|
|
|
|
func externalFolderRefBrowserFromController(ctrl control.SessionAPI) externalFolderRefBrowser {
|
|
if browser, ok := ctrl.(externalFolderRefBrowser); ok {
|
|
return browser
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func externalFolderDirEntries(entries []control.ExternalFolderRefEntry) []DirEntry {
|
|
out := make([]DirEntry, 0, len(entries))
|
|
for _, e := range entries {
|
|
out = append(out, DirEntry{
|
|
Name: e.Name,
|
|
Path: e.Path,
|
|
IsDir: e.IsDir,
|
|
DisplayName: e.DisplayName,
|
|
DisplayPath: e.DisplayPath,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *App) workspaceOrExternalPathForTab(tabID, rel string) (string, bool, error) {
|
|
root, ctrl, ok := a.workspaceTargetForTab(tabID)
|
|
if !ok {
|
|
return "", false, os.ErrNotExist
|
|
}
|
|
if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
|
|
if path, _, ok := browser.ExternalFolderRefLocalPath(rel); ok {
|
|
return path, true, nil
|
|
}
|
|
}
|
|
base, err := workspaceBaseFromRoot(root)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
return workspacePathForBase(base, rel)
|
|
}
|
|
|
|
// ReadFile returns a small text preview for a file under the current workspace
|
|
// or a session-authorized external folder ref.
|
|
func (a *App) ReadFile(rel string) FilePreview {
|
|
return a.ReadFileForTab("", rel)
|
|
}
|
|
|
|
// ReadFileForTab returns a preview resolved against the requested tab.
|
|
func (a *App) ReadFileForTab(tabID, rel string) FilePreview {
|
|
out := FilePreview{Path: rel}
|
|
path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
|
|
if err != nil || !ok {
|
|
out.Err = "invalid path"
|
|
return out
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
out.Err = err.Error()
|
|
return out
|
|
}
|
|
if info.IsDir() {
|
|
out.Err = "path is a directory"
|
|
return out
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
out.Err = "path is not a regular file"
|
|
return out
|
|
}
|
|
out.Size = info.Size()
|
|
if kind, mime := previewMediaKind(path); kind != "" {
|
|
token := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())
|
|
out.Kind = kind
|
|
out.Mime = mime
|
|
out.URL = "/__reasonix_workspace_media/" + token + "/" + url.PathEscape(info.Name())
|
|
return out
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
out.Err = err.Error()
|
|
return out
|
|
}
|
|
defer f.Close()
|
|
|
|
buf := make([]byte, filePreviewLimit+1)
|
|
n, err := f.Read(buf)
|
|
if err != nil && err != io.EOF {
|
|
out.Err = err.Error()
|
|
return out
|
|
}
|
|
data := buf[:n]
|
|
if len(data) > filePreviewLimit {
|
|
data = data[:filePreviewLimit]
|
|
out.Truncated = true
|
|
}
|
|
|
|
// Check for BOM first (just the first 2-3 bytes — always complete
|
|
// even at a truncation boundary). BOM-prefixed files skip the NUL
|
|
// check since UTF-16 normally contains 0x00 for ASCII characters.
|
|
bomKind := fileenc.DetectQuick(data)
|
|
if bomKind != fileenc.UTF8 {
|
|
enc, _ := fileenc.Detect(data)
|
|
if enc == fileenc.LossyUTF8 {
|
|
out.Binary = true
|
|
return out
|
|
}
|
|
decoded := fileenc.Decode(data, enc)
|
|
out.Body = string(decoded)
|
|
return out
|
|
}
|
|
|
|
// No BOM — NUL in raw bytes is a binary signal.
|
|
if bytes.Contains(data, []byte{0}) {
|
|
out.Binary = true
|
|
return out
|
|
}
|
|
|
|
// Trim any partial multi-byte rune at the truncation boundary BEFORE
|
|
// encoding detection. Without this, a large UTF-8 file truncated
|
|
// mid-character would fail utf8.Valid and be misdetected as GB18030
|
|
// or LossyUTF8, producing mojibake or a false binary classification.
|
|
if out.Truncated {
|
|
data = trimUTF8PartialSuffix(data)
|
|
}
|
|
enc, _ := fileenc.Detect(data)
|
|
if enc == fileenc.LossyUTF8 {
|
|
out.Binary = true
|
|
return out
|
|
}
|
|
out.Body = string(fileenc.Decode(data, enc))
|
|
return out
|
|
}
|
|
|
|
// OpenWorkspacePath opens a workspace or authorized external-ref file/folder in
|
|
// the OS default app.
|
|
func (a *App) OpenWorkspacePath(rel string) error {
|
|
return a.OpenWorkspacePathForTab("", rel)
|
|
}
|
|
|
|
// OpenWorkspacePathForTab opens a path resolved against the requested tab.
|
|
func (a *App) OpenWorkspacePathForTab(tabID, rel string) error {
|
|
path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
|
|
if err != nil || !ok {
|
|
return os.ErrInvalid
|
|
}
|
|
return openWorkspacePath(path)
|
|
}
|
|
|
|
// RevealWorkspacePath shows a workspace or authorized external-ref file in the
|
|
// native file manager.
|
|
func (a *App) RevealWorkspacePath(rel string) error {
|
|
return a.RevealWorkspacePathForTab("", rel)
|
|
}
|
|
|
|
// RevealWorkspacePathForTab reveals a path resolved against the requested tab.
|
|
func (a *App) RevealWorkspacePathForTab(tabID, rel string) error {
|
|
path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
|
|
if err != nil || !ok {
|
|
return os.ErrInvalid
|
|
}
|
|
return revealPath(path)
|
|
}
|
|
|
|
// RevealPath shows an arbitrary absolute path in the native file manager.
|
|
func (a *App) RevealPath(path string) error {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return os.ErrInvalid
|
|
}
|
|
if abs, err := filepath.Abs(path); err == nil {
|
|
path = abs
|
|
}
|
|
return revealPath(path)
|
|
}
|
|
|
|
var revealPath = defaultRevealPath
|
|
|
|
func defaultRevealPath(path string) error {
|
|
switch goruntime.GOOS {
|
|
case "darwin":
|
|
return exec.Command("open", "-R", path).Start()
|
|
case "windows":
|
|
// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the
|
|
// launch environment can strip it), so resolve it directly rather than
|
|
// relying on a PATH lookup.
|
|
explorer := "explorer.exe"
|
|
root := os.Getenv("SystemRoot")
|
|
if root == "" {
|
|
root = os.Getenv("windir")
|
|
}
|
|
if root != "" {
|
|
explorer = filepath.Join(root, "explorer.exe")
|
|
}
|
|
return exec.Command(explorer, "/select,", path).Start()
|
|
default:
|
|
dir := path
|
|
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
|
dir = filepath.Dir(path)
|
|
}
|
|
return exec.Command("xdg-open", dir).Start()
|
|
}
|
|
}
|
|
|
|
func (a *App) noticeForTab(tabID, text string) {
|
|
tab := a.tabByID(tabID)
|
|
if tab != nil && tab.sink != nil {
|
|
tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})
|
|
}
|
|
}
|
|
|
|
func (a *App) warnForTab(tabID, text string) {
|
|
tab := a.tabByID(tabID)
|
|
if tab != nil && tab.sink != nil {
|
|
tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text})
|
|
}
|
|
}
|
|
|
|
func (a *App) runEffortCommandForTab(tabID, input string) {
|
|
entry, err := a.currentProviderEntryForTab(tabID)
|
|
if err != nil {
|
|
a.noticeForTab(tabID, "effort: "+err.Error())
|
|
return
|
|
}
|
|
cap := config.EffortCapabilityForEntry(entry)
|
|
if !cap.Supported {
|
|
a.noticeForTab(tabID, fmt.Sprintf("effort is not configurable for %s", entry.Name))
|
|
return
|
|
}
|
|
args := strings.Fields(input)
|
|
if len(args) < 2 {
|
|
a.noticeForTab(tabID, fmt.Sprintf("effort for %s: %s (default: %s; options: %s)", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, "|")))
|
|
return
|
|
}
|
|
if len(args) > 2 {
|
|
a.noticeForTab(tabID, "usage: /effort "+strings.Join(cap.Levels, "|"))
|
|
return
|
|
}
|
|
effort, err := config.NormalizeEffort(entry, args[1])
|
|
if err != nil {
|
|
a.noticeForTab(tabID, err.Error())
|
|
return
|
|
}
|
|
if err := a.SetEffortForTab(tabID, args[1]); err != nil {
|
|
a.noticeForTab(tabID, "effort: "+err.Error())
|
|
return
|
|
}
|
|
display := effort
|
|
if display == "" {
|
|
display = "auto"
|
|
}
|
|
a.noticeForTab(tabID, fmt.Sprintf("effort for %s set to %s", entry.Name, display))
|
|
}
|
|
|
|
func (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {
|
|
if tab := a.tabByID(tabID); tab != nil {
|
|
a.reconcileTabWithPinnedSessionMeta(tab)
|
|
}
|
|
a.mu.RLock()
|
|
ref := ""
|
|
workspaceRoot := ""
|
|
effortOverride := (*string)(nil)
|
|
if tab := a.tabByIDLocked(tabID); tab != nil {
|
|
ref = tab.model
|
|
workspaceRoot = tab.WorkspaceRoot
|
|
effortOverride = cloneStringPtr(tab.effort)
|
|
}
|
|
a.mu.RUnlock()
|
|
cfg, err := config.LoadForRoot(workspaceRoot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(ref) == "" {
|
|
ref = cfg.DefaultModel
|
|
}
|
|
config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)
|
|
resolved, _, ok := cfg.ResolveModelWithFallback(ref)
|
|
if !ok {
|
|
return nil, fmt.Errorf("unknown model %q", ref)
|
|
}
|
|
entry, ok := cfg.ResolveModel(resolved)
|
|
if !ok {
|
|
return nil, fmt.Errorf("unknown model %q", resolved)
|
|
}
|
|
if effortOverride != nil {
|
|
entry.Effort = *effortOverride
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
func (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {
|
|
if tab == nil {
|
|
return "", false, fmt.Errorf("no active tab")
|
|
}
|
|
cfg, err := config.LoadForRoot(tab.WorkspaceRoot)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
ref := strings.TrimSpace(tab.model)
|
|
if ref == "" {
|
|
ref = cfg.DefaultModel
|
|
}
|
|
config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)
|
|
resolved, fallback, ok := cfg.ResolveModelWithFallback(ref)
|
|
if !ok {
|
|
return "", false, fmt.Errorf("unknown model %q", ref)
|
|
}
|
|
return resolved, fallback, nil
|
|
}
|
|
|
|
func (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {
|
|
var result string
|
|
err := a.withActiveWorkspaceDo(func() error {
|
|
var err error
|
|
result, err = fn()
|
|
return err
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
func (a *App) withActiveWorkspaceDo(fn func() error) error {
|
|
root := a.activeWorkspaceRoot()
|
|
if root != "" && root != "." {
|
|
prev, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chdir(root); err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = os.Chdir(prev) }()
|
|
}
|
|
return fn()
|
|
}
|
|
|
|
// SavePastedImage stores a browser clipboard image data URL under the active
|
|
// tab's workspace .reasonix/attachments and returns the relative @-reference path.
|
|
func (a *App) SavePastedImage(dataURL string) (string, error) {
|
|
return a.withActiveWorkspace(func() (string, error) {
|
|
return control.SaveImageDataURL(dataURL)
|
|
})
|
|
}
|
|
|
|
// SaveClipboardImage reads the native OS clipboard image under the active tab's
|
|
// workspace .reasonix/attachments and returns the relative @-reference path.
|
|
func (a *App) SaveClipboardImage() (string, error) {
|
|
return a.withActiveWorkspace(control.SaveClipboardImage)
|
|
}
|
|
|
|
// SavePastedFile stores a dropped non-image file (the browser exposes its bytes
|
|
// as a data URL but not a real path) under the active tab's workspace
|
|
// .reasonix/attachments and returns the relative @-reference path.
|
|
func (a *App) SavePastedFile(name, dataURL string) (string, error) {
|
|
return a.withActiveWorkspace(func() (string, error) {
|
|
return control.SaveAttachmentDataURL(name, dataURL)
|
|
})
|
|
}
|
|
|
|
// PickExportFile opens the native save dialog and returns the selected path. It
|
|
// returns "" when the user cancels.
|
|
func (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {
|
|
if a.ctx == nil {
|
|
return "", nil
|
|
}
|
|
defaultFilename = safeExportFilename(defaultFilename)
|
|
ext := strings.ToLower(filepath.Ext(defaultFilename))
|
|
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
|
Title: "Export session",
|
|
DefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),
|
|
DefaultFilename: defaultFilename,
|
|
CanCreateDirectories: true,
|
|
Filters: exportFileFilters(mimeType, ext),
|
|
})
|
|
if err != nil || path == "" {
|
|
return "", err
|
|
}
|
|
if ext != "" && filepath.Ext(path) == "" {
|
|
path += ext
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// SaveExportFile writes an exported session payload to a path previously picked
|
|
// by PickExportFile. An empty path is treated as a cancelled export.
|
|
func (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {
|
|
if strings.TrimSpace(path) == "" {
|
|
return nil
|
|
}
|
|
var data []byte
|
|
var err error
|
|
if base64Encoded {
|
|
data, err = base64.StdEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("decode export payload: %w", err)
|
|
}
|
|
} else {
|
|
data = []byte(payload)
|
|
}
|
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func safeExportFilename(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return "reasonix-session.md"
|
|
}
|
|
return filepath.Base(name)
|
|
}
|
|
|
|
func exportFileFilters(mimeType, ext string) []runtime.FileFilter {
|
|
switch mimeType {
|
|
case "text/markdown":
|
|
return []runtime.FileFilter{{DisplayName: "Markdown (*.md)", Pattern: "*.md"}}
|
|
case "application/json":
|
|
return []runtime.FileFilter{{DisplayName: "JSON (*.json)", Pattern: "*.json"}}
|
|
case "application/pdf":
|
|
return []runtime.FileFilter{{DisplayName: "PDF (*.pdf)", Pattern: "*.pdf"}}
|
|
case "image/png":
|
|
return []runtime.FileFilter{{DisplayName: "PNG image (*.png)", Pattern: "*.png"}}
|
|
}
|
|
if ext != "" {
|
|
return []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, ".")) + " files (*" + ext + ")", Pattern: "*" + ext}}
|
|
}
|
|
return []runtime.FileFilter{{DisplayName: "All files (*.*)", Pattern: "*.*"}}
|
|
}
|
|
|
|
// AttachmentDataURL returns a safe data URL for a stored image attachment.
|
|
func (a *App) AttachmentDataURL(path string) (string, error) {
|
|
return a.withActiveWorkspace(func() (string, error) {
|
|
return control.ImageDataURL(path)
|
|
})
|
|
}
|
|
|
|
// DroppedItem is one OS-dropped file resolved into a composer context entry: an
|
|
// in-tree file becomes a workspace @reference (read in place, no copy), while an
|
|
// outside directory becomes a session-scoped workspace @reference; an image or
|
|
// out-of-tree file is copied into .reasonix/attachments.
|
|
type DroppedItem struct {
|
|
Kind string `json:"kind"` // "workspace" | "attachment"
|
|
Path string `json:"path"`
|
|
IsDir bool `json:"isDir,omitempty"`
|
|
DisplayPath string `json:"displayPath,omitempty"`
|
|
PreviewURL string `json:"previewUrl,omitempty"`
|
|
}
|
|
|
|
// AttachDropped turns an absolute path from the native file-drop bridge into a
|
|
// composer context entry. Images are stored as attachments so the chip shows a
|
|
// thumbnail; in-workspace files are referenced relatively (no copy); directories
|
|
// outside the workspace are registered as current-session folder references;
|
|
// files outside the workspace are copied into .reasonix/attachments.
|
|
func (a *App) AttachDropped(path string) (DroppedItem, error) {
|
|
var item DroppedItem
|
|
err := a.withActiveWorkspaceDo(func() error {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if isImageExt(path) {
|
|
if rel, err := control.SaveImageFile(path); err == nil {
|
|
preview, _ := control.ImageDataURL(rel)
|
|
item = DroppedItem{Kind: "attachment", Path: rel, PreviewURL: preview}
|
|
return nil
|
|
}
|
|
}
|
|
if rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {
|
|
item = DroppedItem{Kind: "workspace", Path: rel, IsDir: info.IsDir()}
|
|
return nil
|
|
}
|
|
if info.IsDir() {
|
|
tab, ctrl := a.tabAndCtrlByID("")
|
|
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
|
return err
|
|
}
|
|
if tab != nil {
|
|
ctrl = a.controllerForTab(tab)
|
|
}
|
|
if ctrl == nil {
|
|
return fmt.Errorf("workspace is not ready")
|
|
}
|
|
token, displayPath, err := ctrl.RegisterExternalFolderRef(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
item = DroppedItem{Kind: "workspace", Path: token, IsDir: true, DisplayPath: displayPath}
|
|
return nil
|
|
}
|
|
rel, err := control.SaveAttachmentFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
item = DroppedItem{Kind: "attachment", Path: rel}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return DroppedItem{}, err
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func isImageExt(path string) bool {
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".png", ".jpg", ".jpeg", ".gif", ".webp":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func workspaceRelativeIn(path, workspaceRoot string) (string, bool) {
|
|
root := workspaceRoot
|
|
if !filepath.IsAbs(root) {
|
|
abs, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
root = abs
|
|
}
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
|
return "", false
|
|
}
|
|
return filepath.ToSlash(rel), true
|
|
}
|
|
|
|
// --- memory panel (frontend ⇄ controller) ---
|
|
|
|
// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.
|
|
type MemoryDoc struct {
|
|
Path string `json:"path"`
|
|
Scope string `json:"scope"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// MemoryFact is one saved auto-memory, surfaced read-only in the panel.
|
|
type MemoryFact struct {
|
|
Name string `json:"name"`
|
|
Title string `json:"title,omitempty"`
|
|
Description string `json:"description"`
|
|
Type string `json:"type"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// MemoryArchive is one archived auto-memory kept only for inspection.
|
|
type MemoryArchive struct {
|
|
Name string `json:"name"`
|
|
Title string `json:"title,omitempty"`
|
|
Description string `json:"description"`
|
|
Type string `json:"type"`
|
|
Body string `json:"body"`
|
|
Path string `json:"path"`
|
|
ArchivedAt string `json:"archivedAt,omitempty"`
|
|
}
|
|
|
|
// MemoryScope is one writable quick-add target (scope id + the file it writes to).
|
|
type MemoryScope struct {
|
|
Scope string `json:"scope"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// MemoryView is the whole memory panel payload: hierarchical docs, active saved
|
|
// facts, archived facts, and the writable scopes for the quick-add selector.
|
|
type MemoryView struct {
|
|
Docs []MemoryDoc `json:"docs"`
|
|
Facts []MemoryFact `json:"facts"`
|
|
Archives []MemoryArchive `json:"archives"`
|
|
Scopes []MemoryScope `json:"scopes"`
|
|
StoreDir string `json:"storeDir"`
|
|
StoreGlobalDir string `json:"storeGlobalDir,omitempty"`
|
|
Available bool `json:"available"`
|
|
}
|
|
|
|
// writableScopes are the quick-add targets the panel offers, broad → specific.
|
|
var writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}
|
|
|
|
// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,
|
|
// active/archived auto-memories, and the writable scopes. Read-only; mutations
|
|
// go through Remember / SaveDoc.
|
|
func (a *App) Memory() MemoryView {
|
|
return a.memoryForCtrl(nil, true)
|
|
}
|
|
|
|
// MemoryForTab returns the loaded memory for a specific tab's controller,
|
|
// so the panel can show memory for any open project, not just the active tab.
|
|
// If the tab does not exist or has no controller, returns an empty view
|
|
// instead of falling back to the active tab (which would show the wrong data).
|
|
// An empty tabID is treated as "no tab specified" and falls back to the
|
|
// active tab for backward compatibility.
|
|
func (a *App) MemoryForTab(tabID string) MemoryView {
|
|
if tabID == "" {
|
|
return a.memoryForCtrl(nil, true)
|
|
}
|
|
return a.memoryForCtrl(a.ctrlByTabID(tabID), false)
|
|
}
|
|
|
|
func (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {
|
|
view := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}
|
|
if ctrl == nil {
|
|
if !fallback {
|
|
return view
|
|
}
|
|
a.mu.RLock()
|
|
ctrl = a.activeCtrlLocked()
|
|
a.mu.RUnlock()
|
|
if ctrl == nil {
|
|
return view
|
|
}
|
|
}
|
|
set := ctrl.Memory()
|
|
if set == nil {
|
|
return view
|
|
}
|
|
view.StoreDir = set.Store.Dir
|
|
view.StoreGlobalDir = set.Store.GlobalDir
|
|
view.Available = true
|
|
for _, d := range set.Docs {
|
|
view.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})
|
|
}
|
|
for _, f := range set.Store.List() {
|
|
view.Facts = append(view.Facts, MemoryFact{
|
|
Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,
|
|
})
|
|
}
|
|
for _, f := range set.Store.ListArchived() {
|
|
archivedAt := ""
|
|
if !f.ArchivedAt.IsZero() {
|
|
archivedAt = f.ArchivedAt.Format(time.RFC3339)
|
|
}
|
|
view.Archives = append(view.Archives, MemoryArchive{
|
|
Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,
|
|
Path: f.Path, ArchivedAt: archivedAt,
|
|
})
|
|
}
|
|
for _, sc := range writableScopes {
|
|
if p := set.DocPath(sc); p != "" {
|
|
view.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})
|
|
}
|
|
}
|
|
return view
|
|
}
|
|
|
|
// Remember quick-adds a one-line note to the doc-memory file for scope — the
|
|
// panel's explicit "remember" action, equivalent to typing "/remember <note>".
|
|
// An unknown scope falls back to project. Returns the file written.
|
|
func (a *App) Remember(scope, note string) (string, error) {
|
|
return a.rememberForCtrl(nil, scope, note, true)
|
|
}
|
|
|
|
func (a *App) RememberForTab(tabID, scope, note string) (string, error) {
|
|
if tabID == "" {
|
|
return a.rememberForCtrl(nil, scope, note, true)
|
|
}
|
|
return a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)
|
|
}
|
|
|
|
func (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {
|
|
if ctrl == nil {
|
|
if !fallback {
|
|
return "", nil
|
|
}
|
|
a.mu.RLock()
|
|
ctrl = a.activeCtrlLocked()
|
|
a.mu.RUnlock()
|
|
if ctrl == nil {
|
|
return "", nil
|
|
}
|
|
}
|
|
return ctrl.QuickAdd(parseScope(scope), note)
|
|
}
|
|
|
|
// Forget deletes a saved auto-memory by name — the panel's delete action for a
|
|
// fact the model owns. A no-op when no controller is attached.
|
|
func (a *App) Forget(name string) error {
|
|
return a.forgetForCtrl(nil, name, true)
|
|
}
|
|
|
|
func (a *App) ForgetForTab(tabID, name string) error {
|
|
if tabID == "" {
|
|
return a.forgetForCtrl(nil, name, true)
|
|
}
|
|
return a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)
|
|
}
|
|
|
|
func (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {
|
|
if ctrl == nil {
|
|
if !fallback {
|
|
return nil
|
|
}
|
|
a.mu.RLock()
|
|
ctrl = a.activeCtrlLocked()
|
|
a.mu.RUnlock()
|
|
if ctrl == nil {
|
|
return nil
|
|
}
|
|
}
|
|
return ctrl.ForgetMemory(name)
|
|
}
|
|
|
|
// SaveDoc overwrites a memory doc with the panel editor's contents. The controller
|
|
// validates path against the recognized memory files. Returns the file written.
|
|
func (a *App) SaveDoc(path, body string) (string, error) {
|
|
return a.saveDocForCtrl(nil, path, body, true)
|
|
}
|
|
|
|
func (a *App) SaveDocForTab(tabID, path, body string) (string, error) {
|
|
if tabID == "" {
|
|
return a.saveDocForCtrl(nil, path, body, true)
|
|
}
|
|
return a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)
|
|
}
|
|
|
|
func (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {
|
|
if ctrl == nil {
|
|
if !fallback {
|
|
return "", nil
|
|
}
|
|
a.mu.RLock()
|
|
ctrl = a.activeCtrlLocked()
|
|
a.mu.RUnlock()
|
|
if ctrl == nil {
|
|
return "", nil
|
|
}
|
|
}
|
|
return ctrl.SaveDoc(path, body)
|
|
}
|
|
|
|
// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.
|
|
func parseScope(s string) memory.Scope {
|
|
switch memory.Scope(s) {
|
|
case memory.ScopeUser:
|
|
return memory.ScopeUser
|
|
case memory.ScopeLocal:
|
|
return memory.ScopeLocal
|
|
default:
|
|
return memory.ScopeProject
|
|
}
|
|
}
|
|
|
|
// onboardingKeyEnv is the default provider (deepseek) key from config.Default().
|
|
const onboardingKeyEnv = "DEEPSEEK_API_KEY"
|
|
|
|
// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:
|
|
// billing.FetchWithClient surfaces 401/403 for a bad key.
|
|
const onboardingBalanceURL = "https://api.deepseek.com/user/balance"
|
|
|
|
var connectKeyBalanceFetch = billing.FetchWithClient
|
|
|
|
// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation
|
|
// dialog that replaces web-style confirm() for destructive or important actions.
|
|
type NativeConfirmRequest struct {
|
|
Title string `json:"title"`
|
|
Message string `json:"message"`
|
|
Detail string `json:"detail"`
|
|
ConfirmLabel string `json:"confirmLabel"`
|
|
CancelLabel string `json:"cancelLabel"`
|
|
Destructive bool `json:"destructive"`
|
|
}
|
|
|
|
// ConfirmAction shows a native confirmation dialog and returns true when the user
|
|
// clicks the confirm button. For destructive actions the dialog type is Warning so
|
|
// the platform can apply its danger styling (red tint on macOS, etc.).
|
|
func (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {
|
|
if a.ctx == nil {
|
|
return false, nil
|
|
}
|
|
dialogType := runtime.QuestionDialog
|
|
if req.Destructive {
|
|
dialogType = runtime.WarningDialog
|
|
}
|
|
confirm := req.ConfirmLabel
|
|
if confirm == "" {
|
|
confirm = "OK"
|
|
}
|
|
cancel := req.CancelLabel
|
|
if cancel == "" {
|
|
cancel = "Cancel"
|
|
}
|
|
title := req.Title
|
|
if title == "" {
|
|
title = req.Message
|
|
}
|
|
body := req.Message
|
|
if req.Detail != "" {
|
|
if body != "" {
|
|
body += "\n\n" + req.Detail
|
|
} else {
|
|
body = req.Detail
|
|
}
|
|
}
|
|
defaultBtn := confirm
|
|
if req.Destructive {
|
|
// On destructive actions, make cancel the default so Enter / Space
|
|
// does NOT accidentally confirm. ESC always maps to CancelButton.
|
|
defaultBtn = cancel
|
|
}
|
|
result, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
|
|
Type: dialogType,
|
|
Title: title,
|
|
Message: body,
|
|
Buttons: []string{confirm, cancel},
|
|
DefaultButton: defaultBtn,
|
|
CancelButton: cancel,
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return result == confirm, nil
|
|
}
|
|
|
|
func (a *App) NeedsOnboarding() bool {
|
|
return !config.CredentialStored(onboardingKeyEnv)
|
|
}
|
|
|
|
// ConnectKey validates apiKey against the balance endpoint, persists it to
|
|
// Reasonix's global .env, and rebuilds the controller so the new key takes effect.
|
|
func (a *App) ConnectKey(apiKey string) (string, error) {
|
|
apiKey = strings.TrimSpace(apiKey)
|
|
if apiKey == "" {
|
|
return "", fmt.Errorf("key is required")
|
|
}
|
|
if tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {
|
|
return "", rebuildControllerActiveWorkError("provider key")
|
|
}
|
|
ctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)
|
|
defer cancel()
|
|
if _, err := connectKeyBalanceFetch(ctx, nil, onboardingBalanceURL, apiKey); err != nil {
|
|
return "", fmt.Errorf("validate: %w", err)
|
|
}
|
|
warning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)
|
|
if err != nil {
|
|
return "", fmt.Errorf("save: %w", err)
|
|
}
|
|
if err := a.rebuildSetting("provider key"); err != nil {
|
|
if rebuildWarning, ok := a.deferredRebuildWarning("provider key", err); ok {
|
|
warning = appendSettingsWarning(warning, rebuildWarning)
|
|
} else {
|
|
return "", err
|
|
}
|
|
}
|
|
return warning, nil
|
|
}
|