Files
2026-07-13 13:00:08 +08:00

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
}