Compare commits

...

13 Commits

Author SHA1 Message Date
Ed Zynda d24540693c refactor: simplify max line chars calculation with builtin max() 2026-03-22 14:35:26 +03:00
Ed Zynda f7c8e7757b feat: persist model selection and thinking level across sessions
Model and thinking level choices now survive restarts, matching the
existing theme persistence pattern. Selections are saved to
~/.config/kit/preferences.yml and restored on next launch.

Precedence: CLI flag > config file > saved preference > default

Changes:
- Extended preferences struct with model and thinking_level fields
- Refactored preferences.go to shared load/save helpers (DRY)
- Added SaveModelPreference/LoadModelPreference
- Added SaveThinkingLevelPreference/LoadThinkingLevelPreference
- Persist on /model, model selector, /thinking, and Shift+Tab cycle
- Restore at startup in runNormalMode when no explicit flag/config
- Added modelFlagChanged/thinkingFlagChanged to detect explicit flags
- Comprehensive tests for all preference operations
2026-03-22 13:52:06 +03:00
Ed Zynda 0d5374b17b fix: truncate long lines in tool messages instead of wrapping
Use ANSI-aware truncation (charmbracelet/x/ansi) to prevent ugly line
wrapping in all tool renderers (Bash, Read, Write, Ls, Edit, Subagent).

- Replace byte-length padRight/truncateLine with xansi.StringWidth and
  xansi.Truncate which correctly handle ANSI escape codes and wide chars
- Bash: truncate lines to fit panel width instead of allowing 3x wrapping
- Read/Write: truncate syntax-highlighted lines before lipgloss renders
- Ls: truncate entries before styling
- Compact renderers: use ANSI-aware truncation consistently
2026-03-22 13:41:57 +03:00
Ed Zynda 25f17a104d fix: truncate long individual lines to prevent TUI blow-up
A single very long line (e.g. minified JSON, base64 blob) could wrap
into hundreds of visual rows in the TUI even when within the line-count
and byte-count limits.

Core layer (truncate.go):
- Add defaultMaxLineLen (2000 chars) per-line cap
- Apply truncateLongLines() in both TruncateTail and truncateHead
  before line/byte truncation
- Append '[N chars truncated]' marker to capped lines

UI layer:
- Cap lines in renderBashBody() to width*3 chars before rendering
- Cap lines in shell display handler (model.go) similarly

Add comprehensive tests in truncate_test.go.
2026-03-22 13:31:25 +03:00
Ed Zynda 20125f939b feat: add /share command and session viewer
- Add /share slash command that uploads session JSONL to GitHub Gist
  via the gh CLI and prints a shareable viewer URL
- Add session viewer SPA at www/public/session/index.html served at
  go-kit.dev/session/#GIST_ID
- Viewer supports all message types: text, reasoning/thinking blocks,
  tool calls (bash, read, write, edit, grep, find, ls, spawn_subagent),
  images, model changes, branch summaries, and labels
- Tool-specific rendering with syntax highlighting, diffs, collapsible
  output, and status badges
- Also supports ?url= query param for loading from any JSONL URL
- Dark theme matching Kit brand colors
2026-03-22 13:23:44 +03:00
Ed Zynda d3b67ffd14 feat(ui): render session history on /resume and /import
When a user resumes or imports a session, all conversation messages are
now rendered into the TUI scrollback buffer, giving visual context of
the prior conversation. This includes:

- User messages with original text
- Assistant responses with model name from the session
- Tool calls with name, args, and full output/error status

The implementation does a two-pass walk over the session branch:
1. Builds a toolCallID → {name, args} map from assistant messages
2. Renders each message entry using the existing print helpers

Wired into both the /resume session picker (SessionSelectedMsg handler)
and the /import command handler.
2026-03-22 00:57:57 +03:00
Ed Zynda 915dc066dd docs: update documentation for recent features
- Document theme persistence in themes.md and README.md
- Document session commands (/resume, /export, /import, /name) in commands.md and sessions.md
- Document prompt history (up/down arrows) in commands.md
- Document SubscribeSubagent API in sdk/callbacks.md and advanced/subagents.md
2026-03-21 21:15:27 +03:00
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
25 changed files with 4023 additions and 95 deletions
+2
View File
@@ -232,6 +232,8 @@ Kit ships with 22 built-in color themes that control all UI elements. Switch at
/theme tokyonight
```
Theme selections are automatically saved and restored on next launch (stored in `~/.config/kit/preferences.yml`).
### Custom themes
Drop a `.yml` file in `~/.config/kit/themes/` (user) or `.kit/themes/` (project):
+64 -9
View File
@@ -65,6 +65,11 @@ var (
// TLS configuration
tlsSkipVerify bool
// Preference restoration flags — set in RunE after cobra parses, used
// in runNormalMode to decide whether to apply saved preferences.
modelFlagChanged bool
thinkingFlagChanged bool
)
// kitUIAdapter adapts *kit.Kit to ui.AgentInterface so the CLI setup layer
@@ -113,6 +118,17 @@ var rootCmd = &cobra.Command{
if len(args) > 0 {
processPositionalArgs(args)
}
// Record whether --model / --thinking-level were explicitly set by the
// user so that runNormalMode can fall back to saved preferences when
// they weren't. Must be captured here (after cobra parses) and before
// runKit because rootCmd can't be referenced inside runNormalMode
// without creating an initialization cycle.
if f := cmd.PersistentFlags().Lookup("model"); f != nil {
modelFlagChanged = f.Changed
}
if f := cmd.PersistentFlags().Lookup("thinking-level"); f != nil {
thinkingFlagChanged = f.Changed
}
return runKit(context.Background())
},
}
@@ -232,6 +248,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().
@@ -643,6 +662,22 @@ func runNormalMode(ctx context.Context) error {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
// Restore persisted model preference when no explicit --model flag or
// config file model is set. Precedence: CLI flag > config file > saved
// preference > built-in default. This mirrors how themes are persisted.
if !modelFlagChanged && !viper.InConfig("model") {
if pref := ui.LoadModelPreference(); pref != "" {
viper.Set("model", pref)
}
}
// Restore persisted thinking level preference (same precedence chain).
if !thinkingFlagChanged && !viper.InConfig("thinking-level") {
if pref := ui.LoadThinkingLevelPreference(); pref != "" {
viper.Set("thinking-level", pref)
}
}
// Load MCP configuration.
mcpConfig, err := config.LoadAndValidateConfig()
if err != nil {
@@ -678,11 +713,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 +1121,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 +1143,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 +1156,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 +1199,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 +1297,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 +1306,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 +1339,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
}
+25 -41
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
@@ -470,47 +486,15 @@ func (a *App) runQueueBatch(items []queueItem) {
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.
a.sendEvent(StepCancelledEvent{})
return
}
a.sendEvent(StepErrorEvent{Err: err})
return
}
a.sendEvent(StepCompleteEvent{ResponseText: result.Response})
}
// 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.
// Deprecated: Use runQueueBatch which handles both single and multiple items.
func (a *App) runQueueItem(item queueItem) {
// Create a per-step cancellable context.
stepCtx, cancel := context.WithCancel(a.rootCtx)
a.mu.Lock()
a.cancelStep = cancel
a.mu.Unlock()
defer cancel()
// Build event function that sends to the registered tea.Program (if any).
a.mu.Lock()
prog := a.program
a.mu.Unlock()
eventFn := func(msg tea.Msg) {
if prog != nil {
prog.Send(msg)
}
}
result, err := a.executeStep(stepCtx, item.Prompt, eventFn, item.Files)
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
}
+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 {
+28 -10
View File
@@ -6,14 +6,17 @@ import (
)
const (
defaultMaxLines = 2000
defaultMaxBytes = 50 * 1024 // 50KB
grepMaxLineLen = 500
defaultMaxLines = 2000
defaultMaxBytes = 50 * 1024 // 50KB
defaultMaxLineLen = 2000 // max characters per line before truncation
grepMaxLineLen = 500
// DefaultMaxLines is the exported default line limit for truncation.
DefaultMaxLines = defaultMaxLines
// DefaultMaxBytes is the exported default byte limit for truncation.
DefaultMaxBytes = defaultMaxBytes
// DefaultMaxLineLen is the exported default per-line character limit.
DefaultMaxLineLen = defaultMaxLineLen
)
// TruncationResult describes how output was truncated.
@@ -26,6 +29,8 @@ type TruncationResult struct {
}
// TruncateTail keeps the last maxLines lines and at most maxBytes bytes.
// Individual lines longer than defaultMaxLineLen are truncated to prevent
// extremely long single lines from blowing up the TUI when wrapped.
// Used for bash output where the tail is most relevant.
func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
if maxLines <= 0 {
@@ -38,11 +43,11 @@ func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
lines := strings.Split(content, "\n")
total := len(lines)
if len(content) <= maxBytes && total <= maxLines {
return TruncationResult{Content: content, Total: total, Kept: total}
}
// Truncate individual long lines first to prevent single lines from
// wrapping into hundreds of visual lines in the TUI.
lines = truncateLongLines(lines, defaultMaxLineLen)
// Truncate by lines first (keep tail)
// Truncate by lines (keep tail)
truncBy := ""
if total > maxLines {
lines = lines[total-maxLines:]
@@ -78,6 +83,7 @@ func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
}
// truncateHead keeps the first maxLines lines and at most maxBytes bytes.
// Individual lines longer than defaultMaxLineLen are truncated.
// Used for read, grep, find, ls output where the head is most relevant.
func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
if maxLines <= 0 {
@@ -90,9 +96,8 @@ func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
lines := strings.Split(content, "\n")
total := len(lines)
if len(content) <= maxBytes && total <= maxLines {
return TruncationResult{Content: content, Total: total, Kept: total}
}
// Truncate individual long lines first.
lines = truncateLongLines(lines, defaultMaxLineLen)
truncBy := ""
if total > maxLines {
@@ -125,6 +130,19 @@ func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
}
}
// truncateLongLines caps each line to maxLen characters, appending a
// "[...N chars truncated]" marker to any line that exceeds the limit.
// This prevents a single very long line (e.g. minified JSON/JS) from
// wrapping into hundreds of visual rows and blowing up the TUI.
func truncateLongLines(lines []string, maxLen int) []string {
for i, line := range lines {
if len(line) > maxLen {
lines[i] = line[:maxLen] + fmt.Sprintf("... [%d chars truncated]", len(line)-maxLen)
}
}
return lines
}
// truncateLine truncates a single line to maxChars, appending "..." if cut.
func truncateLine(line string, maxChars int) string {
if maxChars <= 0 {
+163
View File
@@ -0,0 +1,163 @@
package core
import (
"strings"
"testing"
)
func TestTruncateTail_LongLines(t *testing.T) {
// A single line of 5000 chars should be truncated to defaultMaxLineLen.
longLine := strings.Repeat("x", 5000)
tr := TruncateTail(longLine, 2000, 50*1024)
if len(tr.Content) > defaultMaxLineLen+100 { // +100 for the "[...N chars truncated]" suffix
t.Errorf("single long line not truncated: got %d chars, want <= %d", len(tr.Content), defaultMaxLineLen+100)
}
if !strings.Contains(tr.Content, "chars truncated]") {
t.Error("truncated line should contain truncation marker")
}
}
func TestTruncateTail_NormalLines(t *testing.T) {
// Lines within the limit should pass through unchanged.
content := "line1\nline2\nline3"
tr := TruncateTail(content, 2000, 50*1024)
if tr.Content != content {
t.Errorf("got %q, want %q", tr.Content, content)
}
if tr.Truncated {
t.Error("should not be marked as truncated")
}
}
func TestTruncateTail_LineCount(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = "line"
}
content := strings.Join(lines, "\n")
tr := TruncateTail(content, 10, 50*1024)
if !tr.Truncated {
t.Error("should be marked as truncated")
}
if tr.Total != 100 {
t.Errorf("total = %d, want 100", tr.Total)
}
if tr.Kept != 10 {
t.Errorf("kept = %d, want 10", tr.Kept)
}
}
func TestTruncateHead_LongLines(t *testing.T) {
longLine := strings.Repeat("y", 5000)
tr := truncateHead(longLine, 2000, 50*1024)
if len(tr.Content) > defaultMaxLineLen+100 {
t.Errorf("single long line not truncated: got %d chars, want <= %d", len(tr.Content), defaultMaxLineLen+100)
}
if !strings.Contains(tr.Content, "chars truncated]") {
t.Error("truncated line should contain truncation marker")
}
}
func TestTruncateHead_NormalLines(t *testing.T) {
content := "line1\nline2\nline3"
tr := truncateHead(content, 2000, 50*1024)
if tr.Content != content {
t.Errorf("got %q, want %q", tr.Content, content)
}
if tr.Truncated {
t.Error("should not be marked as truncated")
}
}
func TestTruncateHead_LineCount(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = "line"
}
content := strings.Join(lines, "\n")
tr := truncateHead(content, 10, 50*1024)
if !tr.Truncated {
t.Error("should be marked as truncated")
}
if tr.Total != 100 {
t.Errorf("total = %d, want 100", tr.Total)
}
if tr.Kept != 10 {
t.Errorf("kept = %d, want 10", tr.Kept)
}
}
func TestTruncateLongLines(t *testing.T) {
lines := []string{
"short",
strings.Repeat("a", 3000),
"also short",
}
result := truncateLongLines(lines, 100)
if result[0] != "short" {
t.Error("short line should be unchanged")
}
if len(result[1]) > 200 { // 100 chars + marker
t.Errorf("long line not truncated: len=%d", len(result[1]))
}
if !strings.Contains(result[1], "chars truncated]") {
t.Error("should contain truncation marker")
}
if result[2] != "also short" {
t.Error("short line should be unchanged")
}
}
func TestTruncateTail_MixedLongAndManyLines(t *testing.T) {
// 50 lines, each 3000 chars — tests both per-line and total truncation.
lines := make([]string, 50)
for i := range lines {
lines[i] = strings.Repeat("z", 3000)
}
content := strings.Join(lines, "\n")
tr := TruncateTail(content, 10, 50*1024)
// Should keep 10 lines.
if tr.Kept != 10 {
t.Errorf("kept = %d, want 10", tr.Kept)
}
// Each line should be capped at ~defaultMaxLineLen.
resultLines := strings.Split(tr.Content, "\n")
for i, line := range resultLines {
if len(line) > defaultMaxLineLen+100 {
t.Errorf("line %d too long: %d chars", i, len(line))
}
}
}
func TestTruncateLine(t *testing.T) {
short := "hello"
if truncateLine(short, 10) != short {
t.Error("short line should be unchanged")
}
long := strings.Repeat("x", 100)
result := truncateLine(long, 10)
if len(result) != 13 { // 10 + "..."
t.Errorf("got len %d, want 13", len(result))
}
// Default max for 0 — input shorter than default, so unchanged
result2 := truncateLine(long, 0)
if result2 != long {
t.Errorf("100-char line should be unchanged when maxChars defaults to %d", grepMaxLineLen)
}
// Longer input with default
veryLong := strings.Repeat("x", 1000)
result3 := truncateLine(veryLong, 0)
if len(result3) != grepMaxLineLen+3 {
t.Errorf("got len %d, want %d", len(result3), grepMaxLineLen+3)
}
}
+21
View File
@@ -141,6 +141,27 @@ 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: "/share",
Description: "Share session via GitHub Gist (requires gh CLI)",
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 {
+428 -10
View File
@@ -15,6 +15,7 @@ import (
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/core"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/session"
)
@@ -44,6 +45,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 +334,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 +511,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 +651,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 +682,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()
@@ -852,6 +880,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.modelName = parts[1]
}
m.printSystemMessage(fmt.Sprintf("Switched to %s", msg.ModelString))
// Persist model selection for next launch.
go func() { _ = SaveModelPreference(msg.ModelString) }()
if m.emitModelChange != nil {
emit := m.emitModelChange
newModel := msg.ModelString
@@ -868,6 +898,34 @@ 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.renderSessionHistory()
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 +1009,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 +1131,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...)
}
}
}
@@ -1421,6 +1505,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, m.performFork(msg.targetID, msg.isUser, msg.userText))
}
case shareResultMsg:
if msg.err != nil {
m.printSystemMessage(fmt.Sprintf("Share failed: %v", msg.err))
} else {
m.printSystemMessage(fmt.Sprintf("Session shared!\n\n Viewer: %s\n Gist: %s", msg.viewerURL, msg.gistURL))
}
return m, m.drainScrollback()
case app.ExtensionPrintEvent:
// Extension output — route through styled renderers when a level is set.
switch msg.Level {
@@ -1467,6 +1559,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())
@@ -1678,6 +1775,9 @@ func (m *AppModel) cycleThinkingLevel() {
_ = m.setThinkingLevel(next)
}()
}
// Persist thinking level for next launch.
go func() { _ = SaveThinkingLevelPreference(next) }()
}
// renderSeparator renders the separator line with an optional queue count badge.
@@ -1888,7 +1988,15 @@ 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 "/share":
return m.handleShareCommand()
case "/import":
return m.handleImportCommand("")
case "/session":
return m.handleSessionInfoCommand()
@@ -1990,10 +2098,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"
@@ -2415,6 +2527,9 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
go emit(newModel, prev, "user")
}
// Persist model selection for next launch.
go func() { _ = SaveModelPreference(args) }()
m.printSystemMessage(fmt.Sprintf("Switched to %s", args))
return nil
}
@@ -2504,6 +2619,8 @@ func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
_ = m.setThinkingLevel(string(level))
}()
}
// Persist thinking level for next launch.
go func() { _ = SaveThinkingLevelPreference(string(level)) }()
m.printSystemMessage(fmt.Sprintf("Thinking level set to: %s — %s", level, models.ThinkingLevelDescription(level)))
return nil
}
@@ -2621,24 +2738,300 @@ 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
}
// handleShareCommand uploads the current session as a GitHub Gist and prints
// a shareable viewer URL. Requires the GitHub CLI (gh) to be installed and
// authenticated.
func (m *AppModel) handleShareCommand() 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 share.")
return nil
}
// Check that gh CLI is available.
if _, err := exec.LookPath("gh"); err != nil {
m.printSystemMessage("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/")
return nil
}
// Check that gh is authenticated.
authCheck := exec.Command("gh", "auth", "status")
if err := authCheck.Run(); err != nil {
m.printSystemMessage("GitHub CLI is not logged in. Run 'gh auth login' first.")
return nil
}
// Copy session to a temp file with a clean name.
data, err := os.ReadFile(srcPath)
if err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to read session file: %v", err))
return nil
}
name := ts.GetSessionName()
if name == "" {
name = "session"
}
// Sanitize for filename.
name = strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' || r == ' ' {
return '_'
}
return r
}, name)
tmpFile, err := os.CreateTemp("", fmt.Sprintf("kit-%s-*.jsonl", name))
if err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to create temp file: %v", err))
return nil
}
tmpPath := tmpFile.Name()
if _, err := tmpFile.Write(data); err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
m.printSystemMessage(fmt.Sprintf("Failed to write temp file: %v", err))
return nil
}
_ = tmpFile.Close()
m.printSystemMessage("Uploading session to GitHub Gist...")
// Run gh gist create in background to avoid blocking the UI.
return func() tea.Msg {
defer func() { _ = os.Remove(tmpPath) }()
cmd := exec.Command("gh", "gist", "create", tmpPath, "--desc", "Kit session shared via /share")
output, err := cmd.Output()
if err != nil {
return shareResultMsg{err: fmt.Errorf("failed to create gist: %w", err)}
}
// gh outputs the gist URL like: https://gist.github.com/username/abc123def456
gistURL := strings.TrimSpace(string(output))
// Extract gist ID (last path segment).
parts := strings.Split(gistURL, "/")
gistID := parts[len(parts)-1]
viewerURL := fmt.Sprintf("https://go-kit.dev/session/#%s", gistID)
return shareResultMsg{gistURL: gistURL, viewerURL: viewerURL}
}
}
// 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.renderSessionHistory()
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
}
// renderSessionHistory walks the current session branch and renders all
// messages (user, assistant, tool calls/results) into the scrollback buffer.
// This gives the user visual context of the conversation when resuming or
// importing a session. Call this after switchSession succeeds.
func (m *AppModel) renderSessionHistory() {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
return
}
branch := ts.GetBranch("")
if len(branch) == 0 {
return
}
// First pass: build a map of tool call ID → {name, args} from assistant
// messages so we can pair them with tool results.
type toolCallInfo struct {
Name string
Args string
}
toolCallMap := make(map[string]toolCallInfo)
for _, entry := range branch {
me, ok := entry.(*session.MessageEntry)
if !ok {
continue
}
if me.Role != "assistant" {
continue
}
msg, err := me.ToMessage()
if err != nil {
continue
}
for _, tc := range msg.ToolCalls() {
toolCallMap[tc.ID] = toolCallInfo{Name: tc.Name, Args: tc.Input}
}
}
// Second pass: render each message in order.
for _, entry := range branch {
me, ok := entry.(*session.MessageEntry)
if !ok {
continue
}
msg, err := me.ToMessage()
if err != nil {
continue
}
switch msg.Role {
case message.RoleUser:
text := msg.Content()
if text != "" {
m.appendScrollback(m.renderer.RenderUserMessage(text, msg.CreatedAt).Content)
}
case message.RoleAssistant:
text := msg.Content()
if text != "" {
modelName := m.modelName
if msg.Model != "" {
modelName = msg.Model
}
m.appendScrollback(m.renderer.RenderAssistantMessage(text, msg.CreatedAt, modelName).Content)
}
// Tool calls from assistant messages are rendered when we
// encounter their corresponding tool results below.
case message.RoleTool:
for _, tr := range msg.ToolResults() {
toolName := tr.Name
toolArgs := ""
if info, ok := toolCallMap[tr.ToolCallID]; ok {
if toolName == "" {
toolName = info.Name
}
toolArgs = info.Args
}
m.appendScrollback(m.renderer.RenderToolMessage(toolName, toolArgs, tr.Content, tr.IsError).Content)
}
}
}
}
// handleSessionInfoCommand shows session statistics.
func (m *AppModel) handleSessionInfoCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
@@ -2689,6 +3082,13 @@ func cancelTimerCmd() tea.Cmd {
// Interactive prompt support
// --------------------------------------------------------------------------
// shareResultMsg carries the result of an async gist upload.
type shareResultMsg struct {
err error
gistURL string
viewerURL string
}
// extensionCmdResultMsg carries the result of an asynchronously executed
// extension slash command. Extension commands run async (via tea.Cmd) so they
// can safely call blocking operations like ctx.PromptSelect().
@@ -2874,6 +3274,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
@@ -2945,9 +3353,19 @@ func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
var displayHiddenCount int
if displayOutput != "" {
lines := strings.Split(displayOutput, "\n")
// Cap individual line length to prevent long lines from wrapping
// into excessive visual rows.
maxLineChars := max(m.width*3, 200)
for i, line := range lines {
if len(line) > maxLineChars {
lines[i] = line[:maxLineChars] + "…"
}
}
if len(lines) > maxShellDisplayLines {
displayHiddenCount = len(lines) - maxShellDisplayLines
displayOutput = strings.Join(lines[:maxShellDisplayLines], "\n")
} else {
displayOutput = strings.Join(lines, "\n")
}
}
+129
View File
@@ -0,0 +1,129 @@
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"`
Model string `yaml:"model,omitempty"`
ThinkingLevel string `yaml:"thinking_level,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")
}
// loadPreferences reads and parses the preferences file.
// Returns zero-value preferences if the file is missing or invalid.
func loadPreferences() preferences {
path := preferencesPath()
if path == "" {
return preferences{}
}
data, err := os.ReadFile(path)
if err != nil {
return preferences{}
}
var prefs preferences
if err := yaml.Unmarshal(data, &prefs); err != nil {
return preferences{}
}
return prefs
}
// savePreferences atomically writes the preferences file, merging into any
// existing content. The mutate function receives the current preferences and
// should modify them in place.
func savePreferences(mutate func(*preferences)) 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)
}
mutate(&prefs)
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)
}
// ── Theme preference ────────────────────────────────────────────────────────
// LoadThemePreference reads the persisted theme name from preferences.yml.
// Returns "" if no preference is saved or the file doesn't exist.
func LoadThemePreference() string {
return strings.TrimSpace(loadPreferences().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 {
return savePreferences(func(p *preferences) {
p.Theme = name
})
}
// ── Model preference ────────────────────────────────────────────────────────
// LoadModelPreference reads the persisted model string (e.g.
// "anthropic/claude-sonnet-4-5-20250929") from preferences.yml.
// Returns "" if no preference is saved.
func LoadModelPreference() string {
return strings.TrimSpace(loadPreferences().Model)
}
// SaveModelPreference persists the model string to preferences.yml.
func SaveModelPreference(model string) error {
return savePreferences(func(p *preferences) {
p.Model = model
})
}
// ── Thinking level preference ───────────────────────────────────────────────
// LoadThinkingLevelPreference reads the persisted thinking level from
// preferences.yml. Returns "" if no preference is saved.
func LoadThinkingLevelPreference() string {
return strings.TrimSpace(loadPreferences().ThinkingLevel)
}
// SaveThinkingLevelPreference persists the thinking level to preferences.yml.
func SaveThinkingLevelPreference(level string) error {
return savePreferences(func(p *preferences) {
p.ThinkingLevel = level
})
}
+180
View File
@@ -0,0 +1,180 @@
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)
}
}
func TestSaveAndLoadModelPreference(t *testing.T) {
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
// Initially empty.
if got := LoadModelPreference(); got != "" {
t.Fatalf("expected empty, got %q", got)
}
// Save a model.
if err := SaveModelPreference("anthropic/claude-sonnet-4-5-20250929"); err != nil {
t.Fatalf("SaveModelPreference: %v", err)
}
if got := LoadModelPreference(); got != "anthropic/claude-sonnet-4-5-20250929" {
t.Fatalf("expected %q, got %q", "anthropic/claude-sonnet-4-5-20250929", got)
}
// Overwrite.
if err := SaveModelPreference("openai/gpt-4o"); err != nil {
t.Fatalf("SaveModelPreference: %v", err)
}
if got := LoadModelPreference(); got != "openai/gpt-4o" {
t.Fatalf("expected %q, got %q", "openai/gpt-4o", got)
}
}
func TestSaveAndLoadThinkingLevelPreference(t *testing.T) {
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
// Initially empty.
if got := LoadThinkingLevelPreference(); got != "" {
t.Fatalf("expected empty, got %q", got)
}
// Save a level.
if err := SaveThinkingLevelPreference("medium"); err != nil {
t.Fatalf("SaveThinkingLevelPreference: %v", err)
}
if got := LoadThinkingLevelPreference(); got != "medium" {
t.Fatalf("expected %q, got %q", "medium", got)
}
// Overwrite.
if err := SaveThinkingLevelPreference("high"); err != nil {
t.Fatalf("SaveThinkingLevelPreference: %v", err)
}
if got := LoadThinkingLevelPreference(); got != "high" {
t.Fatalf("expected %q, got %q", "high", got)
}
}
func TestPreferencesPreserveEachOther(t *testing.T) {
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
// Save all three preferences.
if err := SaveThemePreference("dracula"); err != nil {
t.Fatal(err)
}
if err := SaveModelPreference("anthropic/claude-haiku-3-5-20241022"); err != nil {
t.Fatal(err)
}
if err := SaveThinkingLevelPreference("high"); err != nil {
t.Fatal(err)
}
// All three should be preserved.
if got := LoadThemePreference(); got != "dracula" {
t.Fatalf("theme: expected %q, got %q", "dracula", got)
}
if got := LoadModelPreference(); got != "anthropic/claude-haiku-3-5-20241022" {
t.Fatalf("model: expected %q, got %q", "anthropic/claude-haiku-3-5-20241022", got)
}
if got := LoadThinkingLevelPreference(); got != "high" {
t.Fatalf("thinking_level: expected %q, got %q", "high", got)
}
// Updating one should not affect the others.
if err := SaveModelPreference("openai/gpt-4o"); err != nil {
t.Fatal(err)
}
if got := LoadThemePreference(); got != "dracula" {
t.Fatalf("theme after model update: expected %q, got %q", "dracula", got)
}
if got := LoadThinkingLevelPreference(); got != "high" {
t.Fatalf("thinking_level after model update: expected %q, got %q", "high", 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
+35 -17
View File
@@ -14,6 +14,7 @@ import (
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
udiff "github.com/aymanbagabas/go-udiff"
xansi "github.com/charmbracelet/x/ansi"
)
// Maximum visible lines per tool type before truncation.
@@ -322,6 +323,8 @@ func renderLsBody(toolResult string, width int) string {
var result []string
for _, line := range lines {
// Truncate before styling to prevent wrapping.
line = truncateLine(line, codeWidth-1) // account for PaddingLeft(1)
styled := codeStyle.Width(codeWidth).Render(line)
result = append(result, indent+styled)
}
@@ -431,7 +434,8 @@ func renderCodeBlock(content, fileName string, width int) string {
// If this line has no line number, it's a metadata/footer line (e.g. truncation notice).
if p.lineNum == "" {
// Render footer lines with code background but no gutter
footer := codeStyle.Width(codeWidth).Render(p.code)
truncatedFooter := truncateLine(p.code, codeWidth-1) // account for PaddingLeft(1)
footer := codeStyle.Width(codeWidth).Render(truncatedFooter)
emptyGutter := gutterStyle.Width(gutterWidth).Render("")
result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, emptyGutter, footer))
continue
@@ -445,6 +449,9 @@ func renderCodeBlock(content, fileName string, width int) string {
} else {
codePart = p.code
}
// Truncate the (possibly ANSI-highlighted) line to fit within
// the code column, preventing lipgloss from wrapping it.
codePart = truncateLine(codePart, codeWidth-1) // account for PaddingLeft(1)
styledCode := codeStyle.Width(codeWidth).Render(codePart)
result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, gutter, styledCode))
@@ -528,6 +535,9 @@ func renderWriteBlock(content, fileName string, width int) string {
} else {
codePart = line
}
// Truncate the (possibly ANSI-highlighted) line to fit within
// the code column, preventing lipgloss from wrapping it.
codePart = truncateLine(codePart, codeWidth-1) // account for PaddingLeft(1)
styledCode := writeStyle.Width(codeWidth).Render(codePart)
result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, gutter, styledCode))
@@ -578,9 +588,16 @@ func renderBashBody(toolResult string, width int) string {
}
const lineIndent = " "
// Truncate individual lines to the available width so they never wrap.
// This mirrors Crush's approach: truncate, don't wrap.
lineWidth := max(width-len(lineIndent), 20)
// Account for PaddingLeft(1) on the output/stderr styles
maxLineChars := lineWidth - 1
var rendered []string
inStderr := false
for _, line := range lines {
line = truncateLine(line, maxLineChars)
// Detect the STDERR: label that Kit's bash tool emits
if strings.TrimSpace(line) == "STDERR:" {
inStderr = true
@@ -682,23 +699,28 @@ func syntaxHighlight(source, fileName string) string {
// Helpers
// ---------------------------------------------------------------------------
// padRight pads s with spaces to exactly width characters.
// padRight pads s with spaces to exactly width visual characters.
// This is ANSI-aware: it measures the visual width of s (ignoring escape
// codes and accounting for wide characters) before padding or truncating.
func padRight(s string, width int) string {
if len(s) >= width {
return s[:width]
w := xansi.StringWidth(s)
if w >= width {
return xansi.Truncate(s, width, "")
}
return s + strings.Repeat(" ", width-len(s))
return s + strings.Repeat(" ", width-w)
}
// truncateLine truncates a line to maxWidth, adding "…" if truncated.
// truncateLine truncates a line to maxWidth visual characters, adding "…"
// if truncated. This is ANSI-aware: escape codes are preserved and wide
// characters are measured correctly.
func truncateLine(s string, maxWidth int) string {
if len(s) <= maxWidth {
if xansi.StringWidth(s) <= maxWidth {
return s
}
if maxWidth < 2 {
return s[:maxWidth]
return xansi.Truncate(s, maxWidth, "")
}
return s[:maxWidth-1] + "…"
return xansi.Truncate(s, maxWidth, "…")
}
// ---------------------------------------------------------------------------
@@ -858,12 +880,10 @@ func renderBashCompact(toolResult string, width int) string {
display = display[:maxLines]
}
// Truncate each line to available width
// Truncate each line to available width (ANSI-aware)
lineMax := max(width-4, 20)
for i, line := range display {
if len(line) > lineMax {
display[i] = line[:lineMax-3] + "..."
}
display[i] = truncateLine(line, lineMax)
}
summary := strings.Join(display, "\n")
@@ -940,10 +960,8 @@ func extractSubagentPreview(content string, maxLines, maxWidth int) string {
continue
}
// Truncate long lines
if len(trimmed) > maxWidth {
trimmed = trimmed[:maxWidth-3] + "..."
}
// Truncate long lines (ANSI-aware)
trimmed = truncateLine(trimmed, maxWidth)
preview = append(preview, trimmed)
if len(preview) >= maxLines {
+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)
}
+14 -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
}
+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.
+25
View File
@@ -71,3 +71,28 @@ result, err := host.Subagent(ctx, kit.SubagentConfig{
Timeout: 5 * time.Minute,
})
```
### Real-time subagent events
Use `SubscribeSubagent` to receive real-time events from LLM-initiated subagents (i.e., when the model uses the `spawn_subagent` tool). Register inside an `OnToolCall` handler using the tool call ID:
```go
host.OnToolCall(func(e kit.ToolCallEvent) {
if e.ToolName == "spawn_subagent" {
host.SubscribeSubagent(e.ToolCallID, func(event kit.Event) {
switch ev := event.(type) {
case kit.MessageUpdateEvent:
fmt.Print(ev.Chunk) // streaming text from child
case kit.ToolCallEvent:
fmt.Printf("Child calling: %s\n", ev.ToolName)
case kit.ToolResultEvent:
fmt.Printf("Child result: %s\n", ev.ToolName)
}
})
}
})
```
The listener receives the same event types as `Subscribe()` (`ToolCallEvent`, `MessageUpdateEvent`, `ReasoningDeltaEvent`, etc.) but scoped to the child agent's activity. Listeners are cleaned up automatically when the subagent completes.
If no listeners are registered for a tool call, no event dispatching overhead is incurred.
+8 -1
View File
@@ -75,10 +75,17 @@ These commands are available inside the Kit TUI during an interactive session:
| `/tree` | Navigate session tree |
| `/fork` | Branch from an earlier message |
| `/new` | Start a new session |
| `/name` | Set session display name |
| `/name [name]` | Set or show session display name |
| `/resume` | Open session picker to switch sessions (alias: `/r`) |
| `/session` | Show session info |
| `/export [path]` | Export session as JSONL (default: auto-generated path) |
| `/import <path>` | Import a session from a JSONL file |
| `/quit` | Exit Kit |
### Prompt history
Use **↑** and **↓** arrow keys to navigate through previously submitted prompts. Kit keeps the last 100 entries. Consecutive duplicates are skipped.
## ACP server
Run Kit as an [ACP (Agent Client Protocol)](https://agentclientprotocol.com) agent server. ACP-compatible clients communicate with Kit over JSON-RPC 2.0 on stdin/stdout.
+22
View File
@@ -113,3 +113,25 @@ host.OnAfterTurn(0, func(ctx context.Context) error {
```
The first argument is a priority (lower = runs first).
## Subagent event monitoring
Monitor real-time events from LLM-initiated subagents (when the model uses the `spawn_subagent` tool):
```go
host.OnToolCall(func(e kit.ToolCallEvent) {
if e.ToolName == "spawn_subagent" {
host.SubscribeSubagent(e.ToolCallID, func(event kit.Event) {
// Receives the same event types as Subscribe(), scoped to the child agent
switch ev := event.(type) {
case kit.MessageUpdateEvent:
fmt.Print(ev.Chunk)
case kit.ToolCallEvent:
fmt.Printf("Subagent calling: %s\n", ev.ToolName)
}
})
}
})
```
`SubscribeSubagent` returns an unsubscribe function. Listeners are also cleaned up automatically when the subagent completes. See [Subagents](/advanced/subagents) for more details.
+17
View File
@@ -39,6 +39,8 @@ kit --resume
kit -r
```
The session picker supports search, scope/filter toggles (all sessions vs. current directory), and session deletion. You can also open it during a session with the `/resume` slash command.
### Open a specific session
```bash
@@ -46,6 +48,21 @@ kit --session path/to/session.jsonl
kit -s path/to/session.jsonl
```
## Session commands
These slash commands are available during an interactive session:
| Command | Description |
|---------|-------------|
| `/name [name]` | Set or display the session's display name |
| `/session` | Show session info (path, ID, message count) |
| `/resume` | Open the session picker to switch sessions |
| `/export [path]` | Export session as JSONL (auto-generates path if omitted) |
| `/import <path>` | Import and switch to a session from a JSONL file |
| `/tree` | Navigate the session tree |
| `/fork` | Branch from an earlier message |
| `/new` | Start a fresh session |
## Ephemeral mode
Run without creating a session file:
+12
View File
@@ -19,6 +19,8 @@ Switch themes at runtime with the `/theme` command:
Run `/theme` with no arguments to list all available themes.
**Theme selections are automatically saved** to `~/.config/kit/preferences.yml` and restored on next launch. You don't need to add anything to your config file — just `/theme <name>` and it sticks.
## Built-in themes
| Theme | Style |
@@ -276,4 +278,14 @@ When multiple sources define the same theme name, later sources win:
3. Project-local themes (`.kit/themes/`)
4. Extension-registered themes (highest)
### Startup theme resolution
At startup, Kit determines which theme to apply:
1. **`.kit.yml` `theme:` key** — explicit config always wins (highest priority)
2. **`~/.config/kit/preferences.yml`** — persisted `/theme` selection
3. **Default `kitt` theme** — fallback
The preferences file is updated automatically whenever you use `/theme` or `ctx.SetTheme()`. It is separate from `.kit.yml` so it never clobbers your config comments or formatting.
Theme changes via `/theme` or `ctx.SetTheme()` take effect immediately on all UI elements, including previously rendered messages.
File diff suppressed because it is too large Load Diff