Compare commits

...

7 Commits

Author SHA1 Message Date
Ed Zynda 3b14814740 feat: persist theme selection across sessions
Theme choices via /theme or ctx.SetTheme() were previously lost on
restart. Now the selected theme name is saved to
~/.config/kit/preferences.yml and restored on next launch.

Precedence: .kit.yml theme > preferences.yml > default (kitt).

- Add internal/ui/preferences.go with atomic save/load
- ApplyTheme() now persists; ApplyThemeWithoutSave() for startup
- Fallback to saved preference in cmd/root.go init()
2026-03-21 21:01:25 +03:00
Ed Zynda a1decf9cff feat: add SubscribeSubagent API for per-tool-call event streaming
Add Kit.SubscribeSubagent(toolCallID, listener) which lets SDK consumers
opt into real-time events from LLM-initiated subagents. Listeners are
keyed by the spawn_subagent tool call ID, which is available in the
ToolCallEvent before the subagent starts.

The typical pattern is:

    kit.OnToolCall(func(e kit.ToolCallEvent) {
        if e.ToolName == "spawn_subagent" {
            kit.SubscribeSubagent(e.ToolCallID, func(child kit.Event) {
                // real-time subagent events
            })
        }
    })

Implementation:
- Thread toolCallID through SubagentSpawnFunc so generate() knows which
  tool call triggered the spawn
- Add subagentListenerSet (per-tool-call event bus) stored in a sync.Map
  on the Kit struct, keyed by toolCallID
- In generate(), wire OnEvent to dispatch to registered listeners only
  when SubscribeSubagent has been called for that tool call
- Listeners are cleaned up automatically when the subagent completes
- No listeners registered = no OnEvent callback = no overhead (the
  default TUI path)
2026-03-21 20:48:40 +03:00
Ed Zynda ec4ac64343 fix: stop re-emitting subagent events onto parent event bus
The core tool spawner in generate() was unconditionally setting OnEvent
to re-emit every child event onto the parent Kit's event bus. This caused
subagent tool calls, streaming text, reasoning, and responses to surface
in the TUI as if they were the parent's own events.

Remove the OnEvent callback from the core tool spawner. The spawn_subagent
tool is a blocking call that returns a summary result — it doesn't need
real-time event streaming. SDK consumers who need real-time subagent events
can call Kit.Subagent() directly with their own OnEvent callback. The
extension and ACP paths are unaffected as they bridge to their own
callbacks independently.
2026-03-21 20:41:28 +03:00
Ed Zynda a95117686e fix: override SHELL env var to bash in command execution
When the user's login shell is nushell, fish, or another non-bash shell,
the SHELL environment variable leaks through to child processes. This
causes tools like tmux to use the wrong shell for pane commands, leading
to failures (e.g. nushell rejects 'sleep 30' because it requires
'sleep 30sec').

Override SHELL to point to the resolved bash binary path in both the
bash tool (internal/core/bash.go) and the TUI shell command handler
(internal/ui/model.go) so child processes always use bash.
2026-03-21 18:47:16 +03:00
Ed Zynda c0880e1ef6 fix: preserve completed tool calls when cancelling with ESC
When pressing ESC twice to cancel an agent turn, completed tool calls
and their results were being discarded along with the in-progress text.
Only the streaming text should be discarded.

The root cause was a chain of two issues:

1. Agent layer (internal/agent/agent.go): Fantasy's Stream() returns
   nil on error, discarding all accumulated step data. Fixed by tracking
   completed step messages via the OnStepFinish callback and returning
   a partial GenerateWithLoopResult alongside the error.

2. App layer (internal/app/app.go): The in-memory message store was
   never synced from the tree session after cancellation. Fixed by
   reloading the store from the tree session (which the SDK's runTurn
   already persists partial progress to).

The existing partial-persistence code in pkg/kit/kit.go runTurn() was
correct but was dead code because the agent layer always returned nil
on error. It now receives the partial result and persists completed
step messages to the tree session as intended.
2026-03-21 18:32:28 +03:00
Ed Zynda 4e66c0b4f7 feat: add session management features (picker, history, /name, /export, /import)
Fill session management gaps compared to pi:
- TUI session picker (/resume, --resume flag) with search, scope/filter
  toggles, delete, right-aligned metadata, and background-highlighted cursor
- Prompt history navigation via up/down arrows (100-entry ring buffer)
- /name <name> command now functional (was stubbed as not implemented)
- /export [path] exports session JSONL to file
- /import <path> loads session from JSONL file
- Remove deprecated unused App.runQueueItem method
2026-03-20 18:08:48 +03:00
Ed Zynda 131ce8f2cc Fix message batching and cancellation persistence
1. Batch queued messages into single agent turn
   - Add PromptResultWithMessages() to SDK for batch submission
   - Rewrite drainQueue() to collect all queued items and submit together
   - This prevents the agent from processing queued messages sequentially

2. Persist tool messages on cancellation
   - When generation is cancelled (double-ESC), persist any completed
     tool calls and results to the session before returning
   - Prevents the agent from re-doing work when user continues

Both issues caused the agent to lose context:
- Batching: Multiple queued messages now submitted as one turn
- Cancellation: Tool results from cancelled turns are preserved
2026-03-20 17:18:07 +03:00
16 changed files with 1413 additions and 94 deletions
+32 -9
View File
@@ -232,6 +232,9 @@ func init() {
if err == nil && viper.InConfig("theme") {
uiTheme := configToUiTheme(theme)
ui.SetTheme(uiTheme)
} else if pref := ui.LoadThemePreference(); pref != "" {
// No explicit theme in config — fall back to persisted preference.
_ = ui.ApplyThemeWithoutSave(pref)
}
rootCmd.PersistentFlags().
@@ -678,11 +681,16 @@ func runNormalMode(ctx context.Context) error {
},
}
if resumeFlag {
// TODO: TUI session picker.
sessions, _ := kit.ListSessions("")
if len(sessions) > 0 {
kitOpts.SessionPath = sessions[0].Path
// When --resume is combined with interactive mode, the TUI session
// picker will be shown at startup. For non-interactive mode, fall
// back to auto-selecting the most recent session.
if positionalPrompt != "" {
sessions, _ := kit.ListSessions("")
if len(sessions) > 0 {
kitOpts.SessionPath = sessions[0].Path
}
}
// Interactive mode: ShowSessionPicker is set below on AppModelOptions.
}
kitInstance, err := kit.New(ctx, kitOpts)
@@ -1081,9 +1089,21 @@ func runNormalMode(ctx context.Context) error {
return kitInstance.SetThinkingLevel(context.Background(), level)
}
// Build session-switching callback. Opens a JSONL session file and
// replaces the active tree session on both the Kit SDK and App layer.
switchSessionForUI := func(path string) error {
ts, err := kit.OpenTreeSession(path)
if err != nil {
return fmt.Errorf("failed to open session: %w", err)
}
kitInstance.SetTreeSession(ts)
appInstance.SwitchTreeSession(ts)
return nil
}
// Check if running in non-interactive mode
if positionalPrompt != "" {
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI)
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI)
}
// Quiet mode is not allowed in interactive mode
@@ -1091,7 +1111,7 @@ func runNormalMode(ctx context.Context) error {
return fmt.Errorf("--quiet requires a prompt")
}
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI)
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI)
}
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
@@ -1104,7 +1124,7 @@ func runNormalMode(ctx context.Context) error {
//
// When --no-exit is set, after the prompt completes the interactive BubbleTea
// TUI is started so the user can continue the conversation.
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error) error {
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error) error {
// Expand @file references in the prompt before sending to the agent.
if cwd, err := os.Getwd(); err == nil {
prompt = ui.ProcessFileAttachments(prompt, cwd)
@@ -1147,7 +1167,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
// If --no-exit was requested, hand off to the interactive TUI.
if noExit {
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel)
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession)
}
return nil
@@ -1245,7 +1265,7 @@ func writeJSONError(err error) {
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
//
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error) error {
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error) error {
// Determine terminal size; fall back gracefully.
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || termWidth == 0 {
@@ -1254,6 +1274,7 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
}
cwd, _ := os.Getwd()
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
CompactMode: viper.GetBool("compact"),
ModelName: modelName,
@@ -1286,6 +1307,8 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
ThinkingLevel: thinkingLevel,
IsReasoningModel: isReasoningModel,
SetThinkingLevel: setThinkingLevel,
SwitchSession: switchSession,
ShowSessionPicker: resumeFlag,
})
// Print startup info to stdout before Bubble Tea takes over the screen.
+24
View File
@@ -249,6 +249,12 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
onToolCallContent != nil || onStreamingResponse != nil || onReasoningDelta != nil
if a.streamingEnabled || hasCallbacks {
// Track completed step messages so we can return partial results
// on cancellation. Fantasy's Stream() discards accumulated steps
// when it returns an error, but the OnStepFinish callback fires
// for every step that completed before the error occurred.
var completedStepMessages []fantasy.Message
// Use fantasy's streaming agent
result, err := a.fantasyAgent.Stream(ctx, fantasy.AgentStreamCall{
Prompt: prompt,
@@ -319,6 +325,10 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// Step callbacks for content that accompanies tool calls
OnStepFinish: func(step fantasy.StepResult) error {
// Accumulate messages from completed steps so they can be
// persisted even if a later step is cancelled.
completedStepMessages = append(completedStepMessages, step.Messages...)
if ctx.Err() != nil {
return ctx.Err()
}
@@ -332,6 +342,20 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
},
})
if err != nil {
// On cancellation (or any error), return a partial result
// containing messages from completed steps so the caller can
// persist tool calls and results that finished before the
// cancellation. The original input messages are included so
// the caller sees the full conversation up to the point of
// cancellation.
if len(completedStepMessages) > 0 {
partialMessages := make([]fantasy.Message, 0, len(messages)+len(completedStepMessages))
partialMessages = append(partialMessages, messages...)
partialMessages = append(partialMessages, completedStepMessages...)
return &GenerateWithLoopResult{
ConversationMessages: partialMessages,
}, err
}
return nil, err
}
+155 -32
View File
@@ -217,6 +217,22 @@ func (a *App) GetTreeSession() *session.TreeManager {
return a.opts.TreeSession
}
// SwitchTreeSession replaces the active tree session with a new one and
// reloads the in-memory message store from the new session's messages.
// The old tree session is closed. Used by /resume to switch sessions.
func (a *App) SwitchTreeSession(ts *session.TreeManager) {
// Close old session.
if old := a.opts.TreeSession; old != nil {
_ = old.Close()
}
a.opts.TreeSession = ts
// Reload messages from new session.
a.store.Clear()
if ts != nil {
a.store.Replace(ts.GetFantasyMessages())
}
}
// AddContextMessage adds a user-role message to the conversation history
// without triggering an LLM response. Used by the ! shell command prefix
// to inject command output into context so the LLM can reference it in
@@ -391,41 +407,63 @@ func (a *App) Close() {
// Internal: queue drain loop
// --------------------------------------------------------------------------
// drainQueue runs in a goroutine. It executes the given item and then
// continues draining the queue until it is empty.
// drainQueue runs in a goroutine. It collects all queued items (including the
// first one) and submits them together as a single batch. This ensures that
// when multiple messages are queued while the agent is working, they are all
// submitted together in one turn rather than sequentially.
// Must be called with a.busy == true and a.wg incremented.
func (a *App) drainQueue(first queueItem) {
defer a.wg.Done()
item := first
for {
a.runQueueItem(item)
// Collect all items to process in this batch
var items []queueItem
items = append(items, first)
// Process batches until no more items are queued
for {
// Drain the queue to collect any pending items
a.mu.Lock()
// Stop draining if the app is shutting down.
if a.closed || a.rootCtx.Err() != nil {
a.busy = false
a.queue = a.queue[:0]
a.mu.Unlock()
return
}
if len(a.queue) == 0 {
a.busy = false
a.mu.Unlock()
return
}
item = a.queue[0]
a.queue = a.queue[1:]
qLen := len(a.queue)
items = append(items, a.queue...)
a.queue = a.queue[:0] // Clear the queue
queueLen := len(a.queue)
a.mu.Unlock()
// sendEvent must be called without a.mu held (see sendEvent comment).
a.sendEvent(QueueUpdatedEvent{Length: qLen})
// Send queue updated event (queue is now empty)
a.sendEvent(QueueUpdatedEvent{Length: queueLen})
// Process all collected items as a single batch
a.runQueueBatch(items)
// Check if more items were queued while we were processing
a.mu.Lock()
hasMore := len(a.queue) > 0
if hasMore {
// Start a new batch with the newly queued items
items = a.queue
a.queue = a.queue[:0]
}
a.mu.Unlock()
if !hasMore {
// No more items, we're done
break
}
// Process the new batch
}
// Mark as no longer busy
a.mu.Lock()
a.busy = false
a.mu.Unlock()
}
// runQueueItem executes a single queue item: adds the user message to the store,
// runs the agent step, and sends the appropriate event to the program.
func (a *App) runQueueItem(item queueItem) {
// runQueueBatch executes multiple queue items as a single agent turn.
// All items are submitted together, and the agent responds once to the combined context.
func (a *App) runQueueBatch(items []queueItem) {
if len(items) == 0 {
return
}
// Create a per-step cancellable context.
stepCtx, cancel := context.WithCancel(a.rootCtx)
a.mu.Lock()
@@ -444,12 +482,19 @@ func (a *App) runQueueItem(item queueItem) {
}
}
result, err := a.executeStep(stepCtx, item.Prompt, eventFn, item.Files)
// Execute the batch
result, err := a.executeBatch(stepCtx, items, eventFn)
if err != nil {
if stepCtx.Err() != nil {
// Step was cancelled by the user (e.g. double-ESC). Send a
// cancellation event so the TUI can cut off the response
// cleanly without printing an error.
// Step was cancelled by the user (e.g. double-ESC). Sync
// the in-memory store from the tree session so that any
// tool calls/results that completed before cancellation
// are preserved in the conversation history. The SDK's
// runTurn already persisted partial progress to the tree
// session; we just need to reload it here.
if ts := a.opts.TreeSession; ts != nil {
a.store.Replace(ts.GetFantasyMessages())
}
a.sendEvent(StepCancelledEvent{})
return
}
@@ -507,9 +552,87 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
return result, nil
}
// --------------------------------------------------------------------------
// Internal: event helpers
// --------------------------------------------------------------------------
// executeBatch runs a batch of queue items as a single agent step by delegating
// to the SDK's PromptResultWithMessages(), which handles session persistence,
// hooks, extension events, and the generation loop.
func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(tea.Msg)) (*kit.TurnResult, error) {
// Test hook: bypass SDK entirely (single item only for test compatibility).
if a.opts.PromptFunc != nil {
if len(items) == 1 {
return a.opts.PromptFunc(ctx, items[0].Prompt)
}
// For batch mode with PromptFunc, just use the first item
return a.opts.PromptFunc(ctx, items[0].Prompt)
}
sendFn := func(msg tea.Msg) {
if eventFn != nil {
eventFn(msg)
}
}
// Subscribe to SDK events for TUI rendering. The subscription is
// temporary — it lives only for the duration of this step.
unsub := a.subscribeSDKEvents(sendFn)
defer unsub()
// Show spinner while the agent works.
sendFn(SpinnerEvent{Show: true})
// Check if any items have file attachments
hasFiles := false
for _, item := range items {
if len(item.Files) > 0 {
hasFiles = true
break
}
}
var result *kit.TurnResult
var err error
if len(items) == 1 {
// Single item: use the original path for compatibility
item := items[0]
if len(item.Files) > 0 || hasFiles {
result, err = a.opts.Kit.PromptResultWithFiles(ctx, item.Prompt, item.Files)
} else {
result, err = a.opts.Kit.PromptResult(ctx, item.Prompt)
}
} else {
// Multiple items: batch them together
var messages []string
for _, item := range items {
messages = append(messages, item.Prompt)
}
// TODO: Handle file attachments in batch mode
// For now, files are ignored in batch mode (rare edge case)
if hasFiles {
// If files exist, fall back to processing just the first item with files
for _, item := range items {
if len(item.Files) > 0 {
result, err = a.opts.Kit.PromptResultWithFiles(ctx, item.Prompt, item.Files)
break
}
}
} else {
result, err = a.opts.Kit.PromptResultWithMessages(ctx, messages)
}
}
if err != nil {
return nil, err
}
// Sync in-memory store with the SDK's authoritative conversation.
a.store.Replace(result.Messages)
// Update usage tracker (using last item's prompt for tracking).
a.updateUsageFromTurnResult(result, items[len(items)-1].Prompt)
return result, nil
}
// sendEvent sends a tea.Msg to the registered program if one is set.
// Must NOT be called with a.mu held (to avoid deadlock with the program).
+20 -36
View File
@@ -120,9 +120,8 @@ func TestRun_single(t *testing.T) {
// Run (queued prompts)
// --------------------------------------------------------------------------
// TestRun_queued verifies that a second Run() call while the first is in-flight
// enqueues the prompt rather than spawning a second goroutine, and that the
// queue is drained after the first step completes.
// TestRun_queued verifies that queued prompts are batched together and submitted
// as a single agent turn rather than individually.
func TestRun_queued(t *testing.T) {
gate := make(chan struct{})
callCount := 0
@@ -134,13 +133,7 @@ func TestRun_queued(t *testing.T) {
callCount++
mu.Unlock()
<-gate
return turnResult("first"), nil
},
func(_ context.Context) (*kit.TurnResult, error) {
mu.Lock()
callCount++
mu.Unlock()
return turnResult("second"), nil
return turnResult("batch result"), nil
},
)
app := newTestApp(stub)
@@ -165,11 +158,15 @@ func TestRun_queued(t *testing.T) {
t.Fatal("app did not become idle within 3s after queued runs")
}
// Wait for the goroutine to fully finish (avoid race with queue check)
app.wg.Wait()
mu.Lock()
total := callCount
mu.Unlock()
if total != 2 {
t.Fatalf("expected 2 calls, got %d", total)
// With batching, both prompts should be processed in a single call
if total != 1 {
t.Fatalf("expected 1 batched call, got %d", total)
}
if got := app.QueueLength(); got != 0 {
t.Fatalf("expected empty queue after drain, got %d", got)
@@ -180,31 +177,22 @@ func TestRun_queued(t *testing.T) {
// Queue drain ordering
// --------------------------------------------------------------------------
// TestQueueDrainOrdering verifies that queued prompts are consumed in FIFO order.
// TestQueueDrainOrdering verifies that queued prompts are batched together and
// processed in a single agent turn.
func TestQueueDrainOrdering(t *testing.T) {
gate := make(chan struct{})
var order []string
var receivedPrompt string
var mu sync.Mutex
stub := newStubWithFuncs(
func(ctx context.Context) (*kit.TurnResult, error) {
mu.Lock()
order = append(order, "first")
// In test mode with PromptFunc, we receive the first prompt
// but all messages are batched together
receivedPrompt = "batched"
mu.Unlock()
<-gate
return turnResult("first"), nil
},
func(_ context.Context) (*kit.TurnResult, error) {
mu.Lock()
order = append(order, "second")
mu.Unlock()
return turnResult("second"), nil
},
func(_ context.Context) (*kit.TurnResult, error) {
mu.Lock()
order = append(order, "third")
mu.Unlock()
return turnResult("third"), nil
return turnResult("batch result"), nil
},
)
@@ -228,16 +216,12 @@ func TestQueueDrainOrdering(t *testing.T) {
}
mu.Lock()
got := order
got := receivedPrompt
mu.Unlock()
if len(got) != 3 {
t.Fatalf("expected 3 calls, got %d: %v", len(got), got)
}
for i, want := range []string{"first", "second", "third"} {
if got[i] != want {
t.Fatalf("call[%d]: expected %q, got %q", i, want, got[i])
}
// With batching, all 3 prompts should be processed in a single call
if got != "batched" {
t.Fatalf("expected batched processing, got %q", got)
}
}
+10 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
@@ -90,11 +91,19 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
cmd.Dir = workDir
}
// Ensure SHELL is set to bash so child processes (e.g. tmux) use bash
// rather than the user's login shell (which may be nushell, fish, etc.).
bashPath, err := exec.LookPath("bash")
if err != nil {
bashPath = "/bin/bash"
}
cmd.Env = append(os.Environ(), "SHELL="+bashPath)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
err = cmd.Run()
exitCode := 0
if err != nil {
+4 -2
View File
@@ -28,7 +28,9 @@ type SubagentSpawnResult struct {
// SubagentSpawnFunc is a callback that spawns an in-process subagent. The
// parent Kit instance injects this into the context so the core tool can
// call back without importing pkg/kit (which would create a cycle).
type SubagentSpawnFunc func(ctx context.Context, prompt, model, systemPrompt string, timeout time.Duration) (*SubagentSpawnResult, error)
// The toolCallID parameter is the LLM-assigned ID of the spawn_subagent
// tool call, enabling the parent to correlate subagent events.
type SubagentSpawnFunc func(ctx context.Context, toolCallID, prompt, model, systemPrompt string, timeout time.Duration) (*SubagentSpawnResult, error)
type subagentCtxKey struct{}
@@ -129,7 +131,7 @@ func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRe
}
// Spawn in-process subagent.
result, err := spawner(ctx, args.Task, args.Model, args.SystemPrompt, timeout)
result, err := spawner(ctx, call.ID, args.Task, args.Model, args.SystemPrompt, timeout)
if err != nil || result.Error != nil {
spawnErr := err
if spawnErr == nil {
+16
View File
@@ -141,6 +141,22 @@ var SlashCommands = []SlashCommand{
Description: "Set a display name for this session",
Category: "Navigation",
},
{
Name: "/resume",
Description: "Open session picker to switch sessions",
Category: "Navigation",
Aliases: []string{"/r"},
},
{
Name: "/export",
Description: "Export session (JSONL by default, or /export path.jsonl)",
Category: "System",
},
{
Name: "/import",
Description: "Import a session from a JSONL file (/import path.jsonl)",
Category: "System",
},
{
Name: "/session",
Description: "Show session info and statistics",
+89
View File
@@ -68,8 +68,26 @@ type InputComponent struct {
// pendingImages holds clipboard images attached to the next submission.
// Images are added via Ctrl+V and cleared on submit or Ctrl+U.
pendingImages []ImageAttachment
// history stores previously submitted prompts (most recent last).
// Limited to maxHistory entries; duplicates of the previous entry are
// skipped. Empty strings are never stored.
history []string
// historyIndex is the current position when browsing history.
// When not browsing, historyIndex == len(history).
historyIndex int
// savedInput holds the user's in-progress text before they started
// browsing history, so it can be restored when they press down past
// the end of history.
savedInput string
// browsingHistory is true when the user is navigating history with
// up/down arrows. Set to false when they type a character or submit.
browsingHistory bool
}
// maxHistory is the maximum number of prompt entries kept in history.
const maxHistory = 100
// clipboardImageMsg is the result of an async clipboard image read.
type clipboardImageMsg struct {
image *ImageAttachment
@@ -138,6 +156,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if s.submitNext {
s.submitNext = false
value := s.textarea.Value()
s.pushHistory(value)
s.textarea.SetValue("")
s.textarea.CursorEnd()
s.showPopup = false
@@ -166,10 +185,47 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+d", "enter":
value := s.textarea.Value()
s.pushHistory(value)
s.textarea.SetValue("")
s.textarea.CursorEnd()
s.lastValue = ""
return s, s.handleSubmit(value)
case "up":
// Navigate prompt history backward (older entries).
if len(s.history) > 0 {
if !s.browsingHistory {
// Start browsing — save current input.
s.savedInput = s.textarea.Value()
s.browsingHistory = true
s.historyIndex = len(s.history)
}
if s.historyIndex > 0 {
s.historyIndex--
s.textarea.SetValue(s.history[s.historyIndex])
s.textarea.CursorEnd()
s.lastValue = s.textarea.Value()
}
return s, nil
}
case "down":
// Navigate prompt history forward (newer entries).
if s.browsingHistory {
if s.historyIndex < len(s.history)-1 {
s.historyIndex++
s.textarea.SetValue(s.history[s.historyIndex])
s.textarea.CursorEnd()
s.lastValue = s.textarea.Value()
} else {
// Past the end — restore saved input.
s.historyIndex = len(s.history)
s.browsingHistory = false
s.textarea.SetValue(s.savedInput)
s.textarea.CursorEnd()
s.lastValue = s.textarea.Value()
s.savedInput = ""
}
return s, nil
}
case "ctrl+v":
// Try to read an image from the clipboard asynchronously.
return s, readClipboardImageCmd()
@@ -250,6 +306,11 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
value := s.textarea.Value()
if value != s.lastValue {
s.lastValue = value
// User typed something — exit history browsing mode.
if s.browsingHistory {
s.browsingHistory = false
s.savedInput = ""
}
lines := strings.Split(value, "\n")
line := lines[len(lines)-1] // current line (last line for multi-line)
@@ -372,6 +433,34 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
}
}
// pushHistory adds a prompt to the history ring buffer. Empty strings and
// consecutive duplicates of the last entry are skipped. When the buffer
// exceeds maxHistory, the oldest entry is dropped.
func (s *InputComponent) pushHistory(value string) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return
}
// Skip consecutive duplicates.
if len(s.history) > 0 && s.history[len(s.history)-1] == trimmed {
s.resetHistoryBrowsing()
return
}
s.history = append(s.history, trimmed)
if len(s.history) > maxHistory {
s.history = s.history[len(s.history)-maxHistory:]
}
s.resetHistoryBrowsing()
}
// resetHistoryBrowsing resets the history browsing state so the index
// points past the end (ready for new input).
func (s *InputComponent) resetHistoryBrowsing() {
s.historyIndex = len(s.history)
s.browsingHistory = false
s.savedInput = ""
}
// View implements tea.Model. Renders the title, textarea, autocomplete popup
// (if visible), and help text.
func (s *InputComponent) View() tea.View {
+216 -10
View File
@@ -44,6 +44,9 @@ const (
// stateModelSelector means the /model selector overlay is active.
stateModelSelector
// stateSessionSelector means the /resume session picker is active.
stateSessionSelector
)
// AppController is the interface the parent TUI model uses to interact with the
@@ -330,6 +333,16 @@ type AppModelOptions struct {
// May be nil if extensions are not loaded.
EmitModelChange func(newModel, previousModel, source string)
// SwitchSession opens a session by JSONL file path, replacing the
// active tree session and reloading messages. Called when the user
// picks a session from /resume. May be nil if session switching is
// not supported.
SwitchSession func(path string) error
// ShowSessionPicker, when true, opens the session picker immediately
// on startup (used by --resume flag).
ShowSessionPicker bool
// ThinkingLevel is the initial thinking level (e.g. "off", "medium").
ThinkingLevel string
// IsReasoningModel is true when the current model supports reasoning.
@@ -497,6 +510,13 @@ type AppModel struct {
// modelSelector is the model selection overlay, active in stateModelSelector.
modelSelector *ModelSelectorComponent
// sessionSelector is the session picker overlay, active in stateSessionSelector.
sessionSelector *SessionSelectorComponent
// switchSession opens a session by JSONL path, replacing the active session.
// Wired from cmd/root.go.
switchSession func(path string) error
// prompt holds the state of an active interactive prompt overlay. Nil
// when no prompt is active. Managed by updatePromptState().
prompt *promptOverlay
@@ -630,6 +650,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
m.thinkingVisible = true // default to showing thinking blocks
m.isReasoningModel = opts.IsReasoningModel
m.setThinkingLevel = opts.SetThinkingLevel
m.switchSession = opts.SwitchSession
// Store context/skills metadata and tool counts for startup display.
m.contextPaths = opts.ContextPaths
@@ -660,6 +681,12 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
m.stream = NewStreamComponent(opts.CompactMode, width, opts.ModelName)
m.stream.SetThinkingVisible(m.thinkingVisible)
// If --resume was passed, open the session picker immediately.
if opts.ShowSessionPicker {
m.sessionSelector = NewSessionSelector(opts.Cwd, width, height)
m.state = stateSessionSelector
}
// Propagate initial height distribution to children.
m.distributeHeight()
@@ -868,6 +895,33 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateInput
return m, nil
// ── Session selector events ──────────────────────────────────────────────
case SessionSelectedMsg:
m.sessionSelector = nil
m.state = stateInput
if m.switchSession != nil {
if err := m.switchSession(msg.Path); err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to switch session: %v", err))
} else {
m.printSystemMessage("Session loaded. Continue where you left off.")
}
} else {
m.printSystemMessage("Session switching not available.")
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case SessionSelectorCancelledMsg:
m.sessionSelector = nil
m.state = stateInput
return m, nil
case SessionDeletedMsg:
// Session was deleted from picker — just show a message.
m.printSystemMessage(fmt.Sprintf("Deleted session: %s", msg.Name))
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
// ── Window resize ────────────────────────────────────────────────────────
case tea.WindowSizeMsg:
m.width = msg.Width
@@ -951,6 +1005,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
// Route to session selector when active.
if m.state == stateSessionSelector && m.sessionSelector != nil {
updated, cmd := m.sessionSelector.Update(msg)
m.sessionSelector = updated.(*SessionSelectorComponent)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
switch msg.String() {
case "esc":
if m.state == stateWorking {
@@ -1065,6 +1127,24 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/name":
if cmd := m.handleNameCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/export":
if cmd := m.handleExportCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/import":
if cmd := m.handleImportCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
}
}
@@ -1467,6 +1547,11 @@ func (m *AppModel) View() tea.View {
return m.modelSelector.View()
}
// Session selector overlay replaces the normal layout.
if m.state == stateSessionSelector && m.sessionSelector != nil {
return m.sessionSelector.View()
}
// Overlay dialog replaces the normal layout.
if m.state == stateOverlay && m.overlay != nil {
return tea.NewView(m.overlay.Render())
@@ -1888,7 +1973,13 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
case "/new":
return m.handleNewCommand()
case "/name":
return m.handleNameCommand()
return m.handleNameCommand("")
case "/resume":
return m.handleResumeCommand()
case "/export":
return m.handleExportCommand("")
case "/import":
return m.handleImportCommand("")
case "/session":
return m.handleSessionInfoCommand()
@@ -1990,10 +2081,14 @@ func (m *AppModel) printHelpMessage() {
"**Navigation:**\n" +
"- `/tree`: Navigate session tree (switch branches)\n" +
"- `/fork`: Branch from an earlier message\n" +
"- `/new`: Start a new branch (preserves history)\n\n" +
"- `/new`: Start a new branch (preserves history)\n" +
"- `/resume`: Open session picker to switch sessions\n" +
"- `/name <name>`: Set a display name for this session\n\n" +
"**System:**\n" +
"- `/compact [instructions]`: Summarise older messages to free context space\n" +
"- `/clear`: Clear message history\n" +
"- `/export [path]`: Export session as JSONL\n" +
"- `/import <path.jsonl>`: Import session from JSONL file\n" +
"- `/reset-usage`: Reset usage statistics\n" +
"- `/quit`: Exit the application\n\n"
@@ -2621,21 +2716,124 @@ func (m *AppModel) performFork(targetID string, isUser bool, userText string) te
}
// handleNameCommand sets a display name for the current session.
func (m *AppModel) handleNameCommand() tea.Cmd {
// Usage: /name <new name> — sets the session name.
//
// /name — shows the current name.
func (m *AppModel) handleNameCommand(args string) tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
m.printSystemMessage("No tree session active.")
return nil
}
// For now, prompt user to provide name via input. We print instructions
// and the next non-command input starting with "name:" will be captured.
// TODO: inline input dialog.
currentName := ts.GetSessionName()
if currentName != "" {
m.printSystemMessage(fmt.Sprintf("Current session name: %q\nTo rename, type: `/name <new name>` (not yet implemented — use the session file directly).", currentName))
if args == "" {
// No argument — show current name.
currentName := ts.GetSessionName()
if currentName != "" {
m.printSystemMessage(fmt.Sprintf("Session name: %q\nTo rename: `/name <new name>`", currentName))
} else {
m.printSystemMessage("Session has no name. Set one with: `/name <new name>`")
}
return nil
}
m.printSystemMessage("To name this session, use: `/name <new name>` (not yet implemented — use the session file directly).")
// Set the session name.
if _, err := ts.AppendSessionInfo(args); err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to set session name: %v", err))
return nil
}
m.printSystemMessage(fmt.Sprintf("Session named %q", args))
return nil
}
// handleExportCommand exports the current session to a file.
// Usage: /export — copies the JSONL file to cwd with a descriptive name.
//
// /export path.jsonl — copies to the specified path.
func (m *AppModel) handleExportCommand(args string) tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
m.printSystemMessage("No tree session active.")
return nil
}
srcPath := ts.GetFilePath()
if srcPath == "" {
m.printSystemMessage("Session is in-memory (not persisted). Nothing to export.")
return nil
}
// Determine destination path.
dstPath := args
if dstPath == "" {
// Generate a name based on session name or ID.
name := ts.GetSessionName()
if name == "" {
name = ts.GetSessionID()[:12]
}
// Sanitize for filename.
name = strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' || r == ' ' {
return '_'
}
return r
}, name)
dstPath = fmt.Sprintf("session_%s.jsonl", name)
}
// Copy the file.
data, err := os.ReadFile(srcPath)
if err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to read session file: %v", err))
return nil
}
if err := os.WriteFile(dstPath, data, 0644); err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to write export file: %v", err))
return nil
}
m.printSystemMessage(fmt.Sprintf("Session exported to: %s (%d bytes)", dstPath, len(data)))
return nil
}
// handleImportCommand imports a session from a JSONL file.
// Usage: /import path.jsonl
func (m *AppModel) handleImportCommand(args string) tea.Cmd {
if args == "" {
m.printSystemMessage("Usage: `/import <path.jsonl>`")
return nil
}
if m.switchSession == nil {
m.printSystemMessage("Session switching is not available.")
return nil
}
// Verify file exists before attempting to switch.
if _, err := os.Stat(args); err != nil {
m.printSystemMessage(fmt.Sprintf("File not found: %s", args))
return nil
}
if err := m.switchSession(args); err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to import session: %v", err))
return nil
}
m.printSystemMessage(fmt.Sprintf("Session imported from: %s", args))
return nil
}
// handleResumeCommand opens the session picker so the user can switch sessions.
func (m *AppModel) handleResumeCommand() tea.Cmd {
if m.switchSession == nil {
m.printSystemMessage("Session switching is not available.")
return nil
}
m.sessionSelector = NewSessionSelector(m.cwd, m.width, m.height)
m.state = stateSessionSelector
return nil
}
@@ -2874,6 +3072,14 @@ func (m *AppModel) executeShellCommand(msg shellCommandMsg) tea.Cmd {
cmd.Dir = cwd
}
// Ensure SHELL is set to bash so child processes (e.g. tmux) use bash
// rather than the user's login shell (which may be nushell, fish, etc.).
bashPath, _ := exec.LookPath("bash")
if bashPath == "" {
bashPath = "/bin/bash"
}
cmd.Env = append(os.Environ(), "SHELL="+bashPath)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
+79
View File
@@ -0,0 +1,79 @@
package ui
import (
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// preferences holds user-mutable runtime state that persists across sessions.
// Stored at ~/.config/kit/preferences.yml, separate from the declarative
// .kit.yml config so we never clobber user comments or formatting.
type preferences struct {
Theme string `yaml:"theme,omitempty"`
}
// preferencesPath returns ~/.config/kit/preferences.yml.
// Returns "" if the config directory cannot be determined.
func preferencesPath() string {
cfgDir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(cfgDir, "kit", "preferences.yml")
}
// LoadThemePreference reads the persisted theme name from preferences.yml.
// Returns "" if no preference is saved or the file doesn't exist.
func LoadThemePreference() string {
path := preferencesPath()
if path == "" {
return ""
}
data, err := os.ReadFile(path)
if err != nil {
return ""
}
var prefs preferences
if err := yaml.Unmarshal(data, &prefs); err != nil {
return ""
}
return strings.TrimSpace(prefs.Theme)
}
// SaveThemePreference persists the theme name to ~/.config/kit/preferences.yml.
// Preserves other preference fields. Uses atomic write (temp + rename) to
// avoid corruption from concurrent Kit instances.
func SaveThemePreference(name string) error {
path := preferencesPath()
if path == "" {
return nil // silently skip if config dir unavailable
}
// Load existing preferences to preserve other fields.
var prefs preferences
if data, err := os.ReadFile(path); err == nil {
_ = yaml.Unmarshal(data, &prefs)
}
prefs.Theme = name
data, err := yaml.Marshal(&prefs)
if err != nil {
return err
}
// Ensure parent directory exists.
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
// Atomic write: write to temp file, then rename.
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
+90
View File
@@ -0,0 +1,90 @@
package ui
import (
"os"
"path/filepath"
"testing"
)
func TestSaveAndLoadThemePreference(t *testing.T) {
// Use a temp dir as XDG_CONFIG_HOME so we don't touch the real config.
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
// Initially no preference is saved.
if got := LoadThemePreference(); got != "" {
t.Fatalf("expected empty preference, got %q", got)
}
// Save a preference.
if err := SaveThemePreference("dracula"); err != nil {
t.Fatalf("SaveThemePreference: %v", err)
}
// Load it back.
if got := LoadThemePreference(); got != "dracula" {
t.Fatalf("expected %q, got %q", "dracula", got)
}
// Overwrite with a different theme.
if err := SaveThemePreference("nord"); err != nil {
t.Fatalf("SaveThemePreference: %v", err)
}
if got := LoadThemePreference(); got != "nord" {
t.Fatalf("expected %q, got %q", "nord", got)
}
// Verify the file exists and is valid YAML.
path := filepath.Join(tmp, "kit", "preferences.yml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading preferences file: %v", err)
}
if len(data) == 0 {
t.Fatal("preferences file is empty")
}
}
func TestLoadThemePreference_MissingFile(t *testing.T) {
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
// No file exists — should return empty string, not error.
if got := LoadThemePreference(); got != "" {
t.Fatalf("expected empty string for missing file, got %q", got)
}
}
func TestLoadThemePreference_InvalidYAML(t *testing.T) {
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
// Write invalid YAML.
dir := filepath.Join(tmp, "kit")
_ = os.MkdirAll(dir, 0o755)
_ = os.WriteFile(filepath.Join(dir, "preferences.yml"), []byte(":::bad yaml"), 0o644)
// Should return empty string, not panic.
if got := LoadThemePreference(); got != "" {
t.Fatalf("expected empty string for invalid YAML, got %q", got)
}
}
func TestSaveThemePreference_PreservesOtherFields(t *testing.T) {
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
// Pre-populate with extra content (simulating future fields).
dir := filepath.Join(tmp, "kit")
_ = os.MkdirAll(dir, 0o755)
_ = os.WriteFile(filepath.Join(dir, "preferences.yml"), []byte("theme: old\n"), 0o644)
// Overwrite theme.
if err := SaveThemePreference("catppuccin"); err != nil {
t.Fatalf("SaveThemePreference: %v", err)
}
if got := LoadThemePreference(); got != "catppuccin" {
t.Fatalf("expected %q, got %q", "catppuccin", got)
}
}
+535
View File
@@ -0,0 +1,535 @@
package ui
import (
"fmt"
"regexp"
"strings"
"time"
"unicode/utf8"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/session"
)
// SessionSelectedMsg is sent when the user selects a session from the picker.
type SessionSelectedMsg struct {
Path string // absolute path to the JSONL session file
}
// SessionSelectorCancelledMsg is sent when the user cancels the picker.
type SessionSelectorCancelledMsg struct{}
// SessionDeletedMsg is sent after a session is deleted so the parent can
// react (e.g. print a message).
type SessionDeletedMsg struct {
Name string
}
// SessionScopeMode controls which sessions are shown.
type SessionScopeMode int
const (
SessionScopeCwd SessionScopeMode = iota // current folder only
SessionScopeAll // all sessions across projects
)
func (m SessionScopeMode) String() string {
if m == SessionScopeAll {
return "All"
}
return "Current Folder"
}
// SessionFilterMode controls filtering of the session list.
type SessionFilterMode int
const (
SessionFilterAll SessionFilterMode = iota // show all sessions
SessionFilterNamed // only named sessions
)
func (m SessionFilterMode) String() string {
if m == SessionFilterNamed {
return "Named"
}
return "All"
}
// controlCharsRe matches ASCII control characters for stripping from previews.
var controlCharsRe = regexp.MustCompile(`[\x00-\x1f\x7f]`)
// SessionSelectorComponent is a full-screen Bubble Tea component that lets
// the user browse and select from available sessions. Modeled after pi's
// session picker: right-aligned metadata, background-highlighted selection,
// scope/filter toggles, and inline search.
type SessionSelectorComponent struct {
allSessions []session.SessionInfo
cwdSessions []session.SessionInfo
filtered []session.SessionInfo
cursor int
search string
scope SessionScopeMode
filter SessionFilterMode
// currentPath is the active session file path for marking it in the list.
currentPath string
width int
height int
active bool
// confirmDelete is non-negative when a delete confirmation is pending.
confirmDelete int
}
// NewSessionSelector creates a session selector. It loads sessions for the
// current working directory and all sessions across projects. If cwd is
// empty, only "All" scope is available.
func NewSessionSelector(cwd string, width, height int) *SessionSelectorComponent {
ss := &SessionSelectorComponent{
width: width,
height: height,
active: true,
confirmDelete: -1,
}
// Load sessions (errors are swallowed — empty list is fine).
if cwd != "" {
ss.cwdSessions, _ = session.ListSessions(cwd)
ss.scope = SessionScopeCwd
}
ss.allSessions, _ = session.ListAllSessions()
if cwd == "" || len(ss.cwdSessions) == 0 {
ss.scope = SessionScopeAll
}
ss.rebuildFiltered()
return ss
}
// SetCurrentPath sets the currently active session path so the picker can
// highlight it in the list.
func (ss *SessionSelectorComponent) SetCurrentPath(path string) {
ss.currentPath = path
}
// Init implements tea.Model.
func (ss *SessionSelectorComponent) Init() tea.Cmd {
return nil
}
// Update implements tea.Model.
func (ss *SessionSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
ss.width = msg.Width
ss.height = msg.Height
return ss, nil
case tea.KeyPressMsg:
// Delete confirmation mode.
if ss.confirmDelete >= 0 {
switch msg.String() {
case "y", "Y":
idx := ss.confirmDelete
ss.confirmDelete = -1
if idx < len(ss.filtered) {
info := ss.filtered[idx]
if err := session.DeleteSession(info.Path); err == nil {
name := sessionDisplayName(info)
ss.removeSession(info.Path)
ss.rebuildFiltered()
return ss, func() tea.Msg {
return SessionDeletedMsg{Name: name}
}
}
}
return ss, nil
default:
ss.confirmDelete = -1
return ss, nil
}
}
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))):
if ss.cursor > 0 {
ss.cursor--
}
case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))):
if ss.cursor < len(ss.filtered)-1 {
ss.cursor++
}
case key.Matches(msg, key.NewBinding(key.WithKeys("pgup"))):
ss.cursor -= ss.visibleHeight()
if ss.cursor < 0 {
ss.cursor = 0
}
case key.Matches(msg, key.NewBinding(key.WithKeys("pgdown"))):
ss.cursor += ss.visibleHeight()
if ss.cursor >= len(ss.filtered) {
ss.cursor = len(ss.filtered) - 1
}
if ss.cursor < 0 {
ss.cursor = 0
}
case key.Matches(msg, key.NewBinding(key.WithKeys("home"))):
ss.cursor = 0
case key.Matches(msg, key.NewBinding(key.WithKeys("end"))):
ss.cursor = max(len(ss.filtered)-1, 0)
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
if ss.cursor < len(ss.filtered) {
info := ss.filtered[ss.cursor]
ss.active = false
return ss, func() tea.Msg {
return SessionSelectedMsg{Path: info.Path}
}
}
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
if ss.search != "" {
ss.search = ""
ss.rebuildFiltered()
} else {
ss.active = false
return ss, func() tea.Msg {
return SessionSelectorCancelledMsg{}
}
}
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
if ss.scope == SessionScopeCwd {
ss.scope = SessionScopeAll
} else {
ss.scope = SessionScopeCwd
}
ss.rebuildFiltered()
case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+n"))):
if ss.filter == SessionFilterAll {
ss.filter = SessionFilterNamed
} else {
ss.filter = SessionFilterAll
}
ss.rebuildFiltered()
case key.Matches(msg, key.NewBinding(key.WithKeys("d"))):
if ss.cursor < len(ss.filtered) {
ss.confirmDelete = ss.cursor
}
return ss, nil
default:
if msg.Text != "" && len(msg.Text) == 1 {
ch := msg.Text[0]
if ch >= 32 && ch < 127 {
ss.search += string(ch)
ss.rebuildFiltered()
}
}
if key.Matches(msg, key.NewBinding(key.WithKeys("backspace"))) && len(ss.search) > 0 {
ss.search = ss.search[:len(ss.search)-1]
ss.rebuildFiltered()
}
}
}
return ss, nil
}
// View implements tea.Model.
func (ss *SessionSelectorComponent) View() tea.View {
theme := GetTheme()
w := ss.width
var b strings.Builder
// ── Header: title + scope badges ─────────────────────────────
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(theme.Accent).PaddingLeft(1)
b.WriteString(titleStyle.Render(fmt.Sprintf("Resume Session (%s)", ss.scope)))
b.WriteString("\n")
// ── Help / keybindings ───────────────────────────────────────
helpStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(1)
if w >= 75 {
b.WriteString(helpStyle.Render("tab: scope N: named D: delete R: rename type to search esc: cancel"))
} else if w >= 50 {
b.WriteString(helpStyle.Render("tab scope N named D del type to search esc"))
} else {
b.WriteString(helpStyle.Render("tab N D esc"))
}
b.WriteString("\n")
// ── Search (only shown when active) ──────────────────────────
if ss.search != "" {
searchStyle := lipgloss.NewStyle().Foreground(theme.Info).PaddingLeft(1)
b.WriteString(searchStyle.Render(fmt.Sprintf("> %s", ss.search)))
b.WriteString("\n")
}
b.WriteString("\n")
// ── Delete confirmation ──────────────────────────────────────
if ss.confirmDelete >= 0 && ss.confirmDelete < len(ss.filtered) {
warnStyle := lipgloss.NewStyle().Foreground(theme.Error).Bold(true).PaddingLeft(1)
name := sessionDisplayName(ss.filtered[ss.confirmDelete])
b.WriteString(warnStyle.Render(fmt.Sprintf("Delete %q? (y/N)", truncateRunes(name, 40))))
b.WriteString("\n")
}
// ── Session list ─────────────────────────────────────────────
if len(ss.filtered) == 0 {
emptyStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
if ss.search != "" {
b.WriteString(emptyStyle.Render(fmt.Sprintf("No sessions matching %q", ss.search)))
} else if ss.filter == SessionFilterNamed {
b.WriteString(emptyStyle.Render("No named sessions. Press N to show all."))
} else if ss.scope == SessionScopeCwd {
b.WriteString(emptyStyle.Render("No sessions in current folder. Press tab to view all."))
} else {
b.WriteString(emptyStyle.Render("No sessions found"))
}
b.WriteString("\n")
} else {
visH := ss.visibleHeight()
// Center the cursor in the visible window.
startIdx := max(0, min(ss.cursor-visH/2, len(ss.filtered)-visH))
endIdx := min(startIdx+visH, len(ss.filtered))
for i := startIdx; i < endIdx; i++ {
info := ss.filtered[i]
isCursor := i == ss.cursor
isCurrent := info.Path == ss.currentPath
isDeleting := i == ss.confirmDelete
line := ss.renderEntry(info, isCursor, isCurrent, isDeleting, w)
b.WriteString(line)
b.WriteString("\n")
}
// Scroll position indicator.
if len(ss.filtered) > visH {
posStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
b.WriteString(posStyle.Render(fmt.Sprintf("(%d/%d)", ss.cursor+1, len(ss.filtered))))
b.WriteString("\n")
}
}
return tea.NewView(b.String())
}
// IsActive returns whether the selector is still accepting input.
func (ss *SessionSelectorComponent) IsActive() bool {
return ss.active
}
// --- Internal helpers ---
func (ss *SessionSelectorComponent) visibleHeight() int {
// Reserve: title(1) + help(1) + blank(1) + scroll indicator(1) = 4.
// Optional: search(1), delete confirm(1).
chrome := 4
if ss.search != "" {
chrome++
}
if ss.confirmDelete >= 0 {
chrome++
}
return max(ss.height-chrome, 3)
}
func (ss *SessionSelectorComponent) rebuildFiltered() {
var source []session.SessionInfo
if ss.scope == SessionScopeCwd {
source = ss.cwdSessions
} else {
source = ss.allSessions
}
if ss.filter == SessionFilterNamed {
var named []session.SessionInfo
for _, s := range source {
if s.Name != "" {
named = append(named, s)
}
}
source = named
}
if ss.search != "" {
query := strings.ToLower(ss.search)
var matches []session.SessionInfo
for _, s := range source {
haystack := strings.ToLower(s.Name + " " + s.FirstMessage + " " + s.Cwd)
if strings.Contains(haystack, query) {
matches = append(matches, s)
}
}
ss.filtered = matches
} else {
ss.filtered = source
}
if ss.cursor >= len(ss.filtered) {
ss.cursor = max(len(ss.filtered)-1, 0)
}
}
func (ss *SessionSelectorComponent) removeSession(path string) {
ss.cwdSessions = removeByPath(ss.cwdSessions, path)
ss.allSessions = removeByPath(ss.allSessions, path)
}
func removeByPath(sessions []session.SessionInfo, path string) []session.SessionInfo {
result := make([]session.SessionInfo, 0, len(sessions))
for _, s := range sessions {
if s.Path != path {
result = append(result, s)
}
}
return result
}
// renderEntry renders a single session line with right-aligned metadata.
// Layout: [cursor 2] [message ...variable...] [padding] [count age] [cwd?]
func (ss *SessionSelectorComponent) renderEntry(info session.SessionInfo, isCursor, isCurrent, isDeleting bool, width int) string {
theme := GetTheme()
// ── Cursor indicator (2 chars) ───────────────────────────────
cursorStr := " "
if isCursor {
cursorStr = lipgloss.NewStyle().Foreground(theme.Accent).Render(" ")
}
const cursorW = 2
// ── Right part: message count + relative time (+ optional cwd) ──
age := relativeTime(info.Modified)
msgCount := fmt.Sprintf("%d", info.MessageCount)
rightPart := msgCount + " " + age
if ss.scope == SessionScopeAll && info.Cwd != "" {
shortCwd := shortenPath(info.Cwd)
if len(shortCwd) > 25 {
shortCwd = "..." + shortCwd[len(shortCwd)-22:]
}
rightPart = shortCwd + " " + rightPart
}
rightW := utf8.RuneCountInString(rightPart)
// ── Message text ─────────────────────────────────────────────
displayText := sessionDisplayName(info)
// Strip control characters and collapse whitespace.
displayText = controlCharsRe.ReplaceAllString(displayText, " ")
displayText = strings.Join(strings.Fields(displayText), " ")
availableForMsg := max(width-cursorW-rightW-2, 10) // 2 for min spacing
displayText = truncateRunes(displayText, availableForMsg)
msgW := utf8.RuneCountInString(displayText)
// ── Style the message ────────────────────────────────────────
msgStyle := lipgloss.NewStyle()
switch {
case isDeleting:
msgStyle = msgStyle.Foreground(theme.Error)
case isCurrent:
msgStyle = msgStyle.Foreground(theme.Accent)
case info.Name != "":
msgStyle = msgStyle.Foreground(theme.Warning)
default:
msgStyle = msgStyle.Foreground(theme.Text)
}
if isCursor {
msgStyle = msgStyle.Bold(true)
}
styledMsg := msgStyle.Render(displayText)
// ── Style the right part ─────────────────────────────────────
rightColor := theme.Muted
if isDeleting {
rightColor = theme.Error
}
styledRight := lipgloss.NewStyle().Foreground(rightColor).Render(rightPart)
// ── Assemble with spacing ────────────────────────────────────
spacing := max(width-cursorW-msgW-rightW, 1)
line := cursorStr + styledMsg + strings.Repeat(" ", spacing) + styledRight
// ── Background highlight for selected row ────────────────────
if isCursor {
// Use a subtle background highlight. We apply it by wrapping the
// full line in a style with a background color.
bgStyle := lipgloss.NewStyle().
Background(theme.Highlight).
Width(width)
line = bgStyle.Render(line)
}
return line
}
// --- Package helpers ---
// sessionDisplayName returns the best display string for a session:
// the name if set, the first message, or a fallback.
func sessionDisplayName(info session.SessionInfo) string {
if info.Name != "" {
return info.Name
}
if info.FirstMessage != "" {
return info.FirstMessage
}
return "(empty session)"
}
// truncateRunes truncates a string to at most maxRunes runes, appending "..."
// if truncated.
func truncateRunes(s string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= maxRunes {
return s
}
if maxRunes <= 3 {
return string(runes[:maxRunes])
}
return string(runes[:maxRunes-1]) + "…"
}
// shortenPath replaces the user's home directory prefix with ~.
func shortenPath(path string) string {
return tildeHome(path)
}
// relativeTime formats a time as a short relative string like "5m", "2h", "3d".
func relativeTime(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "now"
case d < time.Hour:
return fmt.Sprintf("%dm", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh", int(d.Hours()))
case d < 7*24*time.Hour:
return fmt.Sprintf("%dd", int(d.Hours()/24))
case d < 30*24*time.Hour:
return fmt.Sprintf("%dw", int(d.Hours()/(24*7)))
case d < 365*24*time.Hour:
return fmt.Sprintf("%dmo", int(d.Hours()/(24*30)))
default:
return fmt.Sprintf("%dy", int(d.Hours()/(24*365)))
}
}
+16
View File
@@ -439,7 +439,23 @@ func LoadThemeByName(name string) (Theme, error) {
}
// ApplyTheme loads a theme by name and sets it as the active global theme.
// The selection is persisted to ~/.config/kit/preferences.yml so it survives
// across sessions. Persistence errors are silently ignored — the theme is
// still applied in-memory even if the write fails.
func ApplyTheme(name string) error {
t, err := LoadThemeByName(name)
if err != nil {
return err
}
SetTheme(t)
_ = SaveThemePreference(name)
return nil
}
// ApplyThemeWithoutSave loads a theme by name and sets it as the active global
// theme without persisting the choice. Used at startup to restore a previously
// saved preference without redundantly re-writing it.
func ApplyThemeWithoutSave(name string) error {
t, err := LoadThemeByName(name)
if err != nil {
return err
+75
View File
@@ -359,3 +359,78 @@ func (m *Kit) OnTurnEnd(handler func(TurnEndEvent)) func() {
}
})
}
// ---------------------------------------------------------------------------
// Subagent event subscriptions
// ---------------------------------------------------------------------------
// subagentListenerSet holds per-tool-call listeners for subagent events.
type subagentListenerSet struct {
mu sync.RWMutex
listeners map[int]EventListener
nextID int
}
func newSubagentListenerSet() *subagentListenerSet {
return &subagentListenerSet{listeners: make(map[int]EventListener)}
}
func (s *subagentListenerSet) add(listener EventListener) func() {
s.mu.Lock()
id := s.nextID
s.nextID++
s.listeners[id] = listener
s.mu.Unlock()
return func() {
s.mu.Lock()
delete(s.listeners, id)
s.mu.Unlock()
}
}
func (s *subagentListenerSet) emit(event Event) {
s.mu.RLock()
snapshot := make([]EventListener, 0, len(s.listeners))
for _, l := range s.listeners {
snapshot = append(snapshot, l)
}
s.mu.RUnlock()
for _, l := range snapshot {
l(event)
}
}
// SubscribeSubagent registers a listener for real-time events from a subagent
// identified by its tool call ID. Returns an unsubscribe function.
//
// The listener receives the same event types as Subscribe() (ToolCallEvent,
// MessageUpdateEvent, etc.) but scoped to the child agent's activity. If the
// tool call ID doesn't correspond to an active or future spawn_subagent call,
// the listener simply never fires.
//
// Typical usage — register inside an OnToolCall handler:
//
// kit.OnToolCall(func(e kit.ToolCallEvent) {
// if e.ToolName == "spawn_subagent" {
// kit.SubscribeSubagent(e.ToolCallID, func(child kit.Event) {
// // real-time subagent events
// })
// }
// })
func (m *Kit) SubscribeSubagent(toolCallID string, listener EventListener) func() {
actual, _ := m.subagentListeners.LoadOrStore(toolCallID, newSubagentListenerSet())
return actual.(*subagentListenerSet).add(listener)
}
// getSubagentListenerSet returns the listener set for a tool call, or nil.
func (m *Kit) getSubagentListenerSet(toolCallID string) *subagentListenerSet {
if v, ok := m.subagentListeners.Load(toolCallID); ok {
return v.(*subagentListenerSet)
}
return nil
}
// cleanupSubagentListeners removes the listener set for a completed tool call.
func (m *Kit) cleanupSubagentListeners(toolCallID string) {
m.subagentListeners.Delete(toolCallID)
}
+46 -4
View File
@@ -62,6 +62,10 @@ type Kit struct {
// tool definitions, etc.
lastInputTokensMu sync.RWMutex
lastInputTokens int
// subagentListeners holds per-tool-call event listeners registered via
// SubscribeSubagent(). Keyed by toolCallID → *subagentListenerSet.
subagentListeners sync.Map
}
// Subscribe registers an EventListener that will be called for every lifecycle
@@ -1401,17 +1405,23 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
// spawn_subagent core tool can create child Kit instances without
// importing pkg/kit (which would create an import cycle).
ctx = core.WithSubagentSpawner(ctx, func(
spawnCtx context.Context, prompt, model, systemPrompt string, timeout time.Duration,
spawnCtx context.Context, toolCallID, prompt, model, systemPrompt string, timeout time.Duration,
) (*core.SubagentSpawnResult, error) {
// Build OnEvent: dispatch to per-tool-call listeners if any are
// registered via SubscribeSubagent(). Listeners are cleaned up
// after the subagent completes.
var onEvent func(Event)
if listeners := m.getSubagentListenerSet(toolCallID); listeners != nil {
onEvent = listeners.emit
}
result, err := m.Subagent(spawnCtx, SubagentConfig{
Prompt: prompt,
Model: model,
SystemPrompt: systemPrompt,
Timeout: timeout,
OnEvent: func(e Event) {
m.events.emit(e)
},
OnEvent: onEvent,
})
m.cleanupSubagentListeners(toolCallID)
if result == nil {
return &core.SubagentSpawnResult{Error: err}, err
}
@@ -1554,6 +1564,14 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
result, err := m.generate(ctx, messages)
if err != nil {
// Persist any messages that were generated during this turn (tool calls,
// tool results) even if the generation was cancelled. This ensures that
// partial progress like completed tool executions are not lost.
if result != nil && len(result.ConversationMessages) > sentCount {
for _, msg := range result.ConversationMessages[sentCount:] {
_, _ = m.treeSession.AppendFantasyMessage(msg)
}
}
m.events.emit(TurnEndEvent{Error: err})
// Run AfterTurn hooks even on error.
if m.afterTurn.hasHooks() {
@@ -1752,6 +1770,30 @@ func (m *Kit) PromptResultWithFiles(ctx context.Context, message string, files [
})
}
// PromptResultWithMessages submits multiple user messages in a single turn.
// All messages are persisted to the session and sent to the agent together.
// The agent will respond once to the combined context of all messages.
// Returns the full turn result including usage statistics and conversation messages.
func (m *Kit) PromptResultWithMessages(ctx context.Context, messages []string) (*TurnResult, error) {
if len(messages) == 0 {
return nil, fmt.Errorf("no messages provided")
}
// Build prompt label from all messages
promptLabel := strings.Join(messages, " | ")
if len(promptLabel) > 100 {
promptLabel = promptLabel[:100] + "..."
}
// Build fantasy messages from all strings
var preMessages []fantasy.Message
for _, msg := range messages {
preMessages = append(preMessages, fantasy.NewUserMessage(msg))
}
return m.runTurn(ctx, promptLabel, messages[len(messages)-1], preMessages)
}
// ClearSession resets the tree session's leaf pointer to the root, starting
// a fresh conversation branch.
func (m *Kit) ClearSession() {
+6
View File
@@ -34,6 +34,12 @@ func DeleteSession(path string) error {
return session.DeleteSession(path)
}
// OpenTreeSession opens an existing JSONL session file. This is a package-level
// function (no Kit instance required) used by the CLI for session switching.
func OpenTreeSession(path string) (*TreeManager, error) {
return session.OpenTreeSession(path)
}
// --- Instance methods on Kit ---
// GetTreeSession returns the tree session manager, or nil if not configured.