From ad070869004092c35d17542a94bd2ec868499429 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Sat, 28 Feb 2026 01:01:12 +0300 Subject: [PATCH] cleanup --- cmd/root.go | 97 ++- cmd/setup.go | 23 +- {pkg/kit => internal/kitsetup}/setup.go | 5 +- internal/ui/block_renderer.go | 125 +-- internal/ui/cli.go | 134 +--- internal/ui/compact_renderer.go | 69 +- internal/ui/debug_logger.go | 9 +- internal/ui/enhanced_styles.go | 7 - internal/ui/format.go | 81 ++ internal/ui/messages.go | 309 +------- internal/ui/model.go | 76 +- internal/ui/model_test.go | 1 - internal/ui/spinner.go | 23 +- internal/ui/styles.go | 54 +- pkg/kit/kit.go | 144 +++- plans/00-move-sdk-to-top-level.md | 503 ------------ plans/01-export-tools-and-factories.md | 253 ------- plans/02-richer-type-exports.md | 196 ----- plans/03-event-subscriber-system.md | 348 --------- plans/04-enhanced-session-management.md | 298 -------- plans/05-additional-prompt-modes.md | 276 ------- plans/06-auth-model-management.md | 192 ----- plans/07-compaction-apis.md | 166 ---- plans/08-skills-prompts-system.md | 133 ---- plans/09-extension-hook-system.md | 275 ------- plans/10-app-as-sdk-consumer.md | 714 ------------------ plans/README.md | 104 --- ...26-02-27-simplify-streaming-and-styling.md | 561 ++++++++++++++ 28 files changed, 929 insertions(+), 4247 deletions(-) rename {pkg/kit => internal/kitsetup}/setup.go (96%) create mode 100644 internal/ui/format.go delete mode 100644 plans/00-move-sdk-to-top-level.md delete mode 100644 plans/01-export-tools-and-factories.md delete mode 100644 plans/02-richer-type-exports.md delete mode 100644 plans/03-event-subscriber-system.md delete mode 100644 plans/04-enhanced-session-management.md delete mode 100644 plans/05-additional-prompt-modes.md delete mode 100644 plans/06-auth-model-management.md delete mode 100644 plans/07-compaction-apis.md delete mode 100644 plans/08-skills-prompts-system.md delete mode 100644 plans/09-extension-hook-system.md delete mode 100644 plans/10-app-as-sdk-consumer.md delete mode 100644 plans/README.md create mode 100644 thoughts/2026-02-27-simplify-streaming-and-styling.md diff --git a/cmd/root.go b/cmd/root.go index 4f05d61a..e24ecf1d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,7 +10,6 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/fantasy" "charm.land/lipgloss/v2" - "github.com/mark3labs/kit/internal/agent" "github.com/mark3labs/kit/internal/app" "github.com/mark3labs/kit/internal/config" "github.com/mark3labs/kit/internal/extensions" @@ -63,34 +62,35 @@ var ( tlsSkipVerify bool ) -// agentUIAdapter adapts agent.Agent to ui.AgentInterface -type agentUIAdapter struct { - agent *agent.Agent +// kitUIAdapter adapts *kit.Kit to ui.AgentInterface so the CLI setup layer +// can display tool/server metadata without importing internal types. +type kitUIAdapter struct { + kit *kit.Kit } -func (a *agentUIAdapter) GetLoadingMessage() string { - return a.agent.GetLoadingMessage() +func (a *kitUIAdapter) GetLoadingMessage() string { + return a.kit.GetLoadingMessage() } -func (a *agentUIAdapter) GetTools() []any { - tools := a.agent.GetTools() - result := make([]any, len(tools)) - for i, tool := range tools { - result[i] = tool +func (a *kitUIAdapter) GetTools() []any { + names := a.kit.GetToolNames() + result := make([]any, len(names)) + for i, name := range names { + result[i] = name } return result } -func (a *agentUIAdapter) GetLoadedServerNames() []string { - return a.agent.GetLoadedServerNames() +func (a *kitUIAdapter) GetLoadedServerNames() []string { + return a.kit.GetLoadedServerNames() } -func (a *agentUIAdapter) GetMCPToolCount() int { - return a.agent.GetMCPToolCount() +func (a *kitUIAdapter) GetMCPToolCount() int { + return a.kit.GetMCPToolCount() } -func (a *agentUIAdapter) GetExtensionToolCount() int { - return a.agent.GetExtensionToolCount() +func (a *kitUIAdapter) GetExtensionToolCount() int { + return a.kit.GetExtensionToolCount() } // rootCmd represents the base command when called without any subcommands. @@ -280,14 +280,14 @@ func runKit(ctx context.Context) error { // ui.ExtensionCommand type used by the interactive TUI. Command names are // normalised to start with "/" so they integrate with the slash-command // autocomplete and dispatch pipeline. -func extensionCommandsForUI(runner *extensions.Runner) []ui.ExtensionCommand { - if runner == nil { - return nil - } - defs := runner.RegisteredCommands() +func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand { + defs := k.ExtensionCommands() if len(defs) == 0 { return nil } + // We still need the raw runner for GetContext() in the Execute closure. + // This is the last remaining use of GetExtRunner() in cmd/. + runner := k.GetExtRunner() cmds := make([]ui.ExtensionCommand, 0, len(defs)) for _, d := range defs { name := d.Name @@ -346,16 +346,18 @@ func runNormalMode(ctx context.Context) error { // Build Kit options from CLI flags and create the SDK instance. // kit.New() handles: config → skills → agent → session → extension bridge. kitOpts := &kit.Options{ - MCPConfig: mcpConfig, - ShowSpinner: true, - SpinnerFunc: spinnerFunc, - UseBufferedLogger: true, - Quiet: quietFlag, - Debug: debugMode, - NoSession: noSessionFlag, - Continue: continueFlag, - SessionPath: sessionPath, - AutoCompact: autoCompactFlag, + Quiet: quietFlag, + Debug: debugMode, + NoSession: noSessionFlag, + Continue: continueFlag, + SessionPath: sessionPath, + AutoCompact: autoCompactFlag, + CLI: &kit.CLIOptions{ + MCPConfig: mcpConfig, + ShowSpinner: true, + SpinnerFunc: spinnerFunc, + UseBufferedLogger: true, + }, } if resumeFlag { // TODO: TUI session picker. @@ -371,27 +373,23 @@ func runNormalMode(ctx context.Context) error { } defer func() { _ = kitInstance.Close() }() - // Extract agent + metadata for display and app options. - mcpAgent := kitInstance.GetAgent() - parsedProvider, modelName, serverNames, toolNames, mcpToolCount, extensionToolCount := CollectAgentMetadata(mcpAgent, mcpConfig) + // Extract metadata for display and app options. + parsedProvider, modelName, serverNames, toolNames, mcpToolCount, extensionToolCount := CollectAgentMetadata(kitInstance, mcpConfig) // Create CLI for non-interactive mode only. var cli *ui.CLI if promptFlag != "" { - cli, err = SetupCLIForNonInteractive(mcpAgent) + cli, err = SetupCLIForNonInteractive(kitInstance) if err != nil { return fmt.Errorf("failed to setup CLI: %v", err) } // Display buffered debug messages if any (non-interactive path only). - if bl := kitInstance.GetBufferedLogger(); bl != nil && cli != nil { - msgs := bl.GetMessages() - if len(msgs) > 0 { - cli.DisplayDebugMessage(strings.Join(msgs, "\n ")) - } + if msgs := kitInstance.GetBufferedDebugMessages(); len(msgs) > 0 && cli != nil { + cli.DisplayDebugMessage(strings.Join(msgs, "\n ")) } - DisplayDebugConfig(cli, mcpAgent, mcpConfig, parsedProvider) + DisplayDebugConfig(cli, kitInstance, mcpConfig, parsedProvider) } // Load existing messages from resumed/continued sessions. @@ -402,7 +400,6 @@ func runNormalMode(ctx context.Context) error { } // Create the app.App instance. - extRunner := kitInstance.GetExtRunner() appOpts := BuildAppOptions(mcpConfig, modelName, serverNames, toolNames) appOpts.Kit = kitInstance appOpts.TreeSession = treeSession @@ -423,9 +420,9 @@ func runNormalMode(ctx context.Context) error { defer appInstance.Close() // Set up extension context and emit SessionStart. - if extRunner != nil { + if kitInstance.HasExtensions() { cwd, _ := os.Getwd() - extRunner.SetContext(extensions.Context{ + kitInstance.SetExtensionContext(extensions.Context{ CWD: cwd, Model: modelName, Interactive: promptFlag == "", @@ -435,13 +432,11 @@ func runNormalMode(ctx context.Context) error { PrintBlock: appInstance.PrintBlockFromExtension, SendMessage: func(text string) { appInstance.Run(text) }, }) - if extRunner.HasHandlers(extensions.SessionStart) { - _, _ = extRunner.Emit(extensions.SessionStartEvent{}) - } + kitInstance.EmitSessionStart() } // Convert extension commands to UI-layer type for the interactive TUI. - extCommands := extensionCommandsForUI(extRunner) + extCommands := extensionCommandsForUI(kitInstance) // Build context/skills display metadata for the startup banner. var contextPaths []string @@ -464,7 +459,7 @@ func runNormalMode(ctx context.Context) error { // Check if running in non-interactive mode if promptFlag != "" { - return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, mcpAgent.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems) + return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems) } // Quiet mode is not allowed in interactive mode @@ -472,7 +467,7 @@ func runNormalMode(ctx context.Context) error { return fmt.Errorf("--quiet flag can only be used with --prompt/-p") } - return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, mcpAgent.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems) + return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems) } // runNonInteractiveModeApp executes a single prompt via the app layer and exits, diff --git a/cmd/setup.go b/cmd/setup.go index c3012537..f8a868ce 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -3,7 +3,6 @@ package cmd import ( "strings" - "github.com/mark3labs/kit/internal/agent" "github.com/mark3labs/kit/internal/app" "github.com/mark3labs/kit/internal/config" "github.com/mark3labs/kit/internal/ui" @@ -12,9 +11,9 @@ import ( ) // CollectAgentMetadata extracts model display info and tool/server name lists -// from the agent, used to populate app.Options and UI setup. +// from the Kit instance, used to populate app.Options and UI setup. // It also returns the number of MCP tools and extension tools separately. -func CollectAgentMetadata(mcpAgent *agent.Agent, mcpConfig *config.Config) (provider, modelName string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int) { +func CollectAgentMetadata(k *kit.Kit, mcpConfig *config.Config) (provider, modelName string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int) { modelString := viper.GetString("model") provider, modelName, _ = kit.ParseModelString(modelString) if modelName == "" { @@ -25,13 +24,9 @@ func CollectAgentMetadata(mcpAgent *agent.Agent, mcpConfig *config.Config) (prov serverNames = append(serverNames, name) } - for _, tool := range mcpAgent.GetTools() { - info := tool.Info() - toolNames = append(toolNames, info.Name) - } - - mcpToolCount = mcpAgent.GetMCPToolCount() - extensionToolCount = mcpAgent.GetExtensionToolCount() + toolNames = k.GetToolNames() + mcpToolCount = k.GetMCPToolCount() + extensionToolCount = k.GetExtensionToolCount() return provider, modelName, serverNames, toolNames, mcpToolCount, extensionToolCount } @@ -52,7 +47,7 @@ func BuildAppOptions(mcpConfig *config.Config, modelName string, serverNames, to // DisplayDebugConfig builds and displays the debug configuration map through // the CLI for non-interactive mode. -func DisplayDebugConfig(cli *ui.CLI, mcpAgent *agent.Agent, mcpConfig *config.Config, provider string) { +func DisplayDebugConfig(cli *ui.CLI, k *kit.Kit, mcpConfig *config.Config, provider string) { if quietFlag || cli == nil || !viper.GetBool("debug") { return } @@ -89,7 +84,7 @@ func DisplayDebugConfig(cli *ui.CLI, mcpAgent *agent.Agent, mcpConfig *config.Co if len(mcpConfig.MCPServers) > 0 { mcpServers := make(map[string]any) loadedServerSet := make(map[string]bool) - for _, name := range mcpAgent.GetLoadedServerNames() { + for _, name := range k.GetLoadedServerNames() { loadedServerSet[name] = true } @@ -130,8 +125,8 @@ func DisplayDebugConfig(cli *ui.CLI, mcpAgent *agent.Agent, mcpConfig *config.Co // SetupCLIForNonInteractive creates the CLI display layer for non-interactive // mode (--prompt). Returns nil when quiet mode is active. -func SetupCLIForNonInteractive(mcpAgent *agent.Agent) (*ui.CLI, error) { - agentAdapter := &agentUIAdapter{agent: mcpAgent} +func SetupCLIForNonInteractive(k *kit.Kit) (*ui.CLI, error) { + agentAdapter := &kitUIAdapter{kit: k} return ui.SetupCLI(&ui.CLISetupOptions{ Agent: agentAdapter, ModelString: viper.GetString("model"), diff --git a/pkg/kit/setup.go b/internal/kitsetup/setup.go similarity index 96% rename from pkg/kit/setup.go rename to internal/kitsetup/setup.go index bba88f10..deb0066c 100644 --- a/pkg/kit/setup.go +++ b/internal/kitsetup/setup.go @@ -1,4 +1,7 @@ -package kit +// Package kitsetup contains agent creation logic used by both the CLI binary +// and the SDK's kit.New(). It is internal — external SDK consumers should use +// kit.New() which delegates here. +package kitsetup import ( "context" diff --git a/internal/ui/block_renderer.go b/internal/ui/block_renderer.go index 214637b3..86002e43 100644 --- a/internal/ui/block_renderer.go +++ b/internal/ui/block_renderer.go @@ -10,7 +10,6 @@ import ( type blockRenderer struct { align *lipgloss.Position borderColor *color.Color - bgColor *color.Color fullWidth bool noBorder bool paddingTop int @@ -34,14 +33,6 @@ func WithFullWidth() renderingOption { } } -// WithBackground returns a renderingOption that sets a background color -// for the entire block. -func WithBackground(c color.Color) renderingOption { - return func(br *blockRenderer) { - br.bgColor = &c - } -} - // WithNoBorder returns a renderingOption that disables all borders on the // block, rendering content with only padding. func WithNoBorder() renderingOption { @@ -165,104 +156,36 @@ func renderContentBlock(content string, containerWidth int, options ...rendering } theme := GetTheme() - hasBg := renderer.bgColor != nil - if hasBg { - // When a background color is set we use a three-phase render so - // the border extends the full block height including padding: - // 1. Render content with bg + horizontal padding (no border, - // no vertical padding). - // 2. Use Place() to add vertical padding with uniform bg fill. - // 3. Apply the border to the padded block. + // Single-pass render: padding, border, and foreground in one style. + style := lipgloss.NewStyle(). + PaddingLeft(renderer.paddingLeft). + PaddingRight(renderer.paddingRight). + PaddingTop(renderer.paddingTop). + PaddingBottom(renderer.paddingBottom). + Foreground(theme.Text) - // Phase 1 — content with background + horizontal padding. - innerStyle := lipgloss.NewStyle(). - PaddingLeft(renderer.paddingLeft). - PaddingRight(renderer.paddingRight). - Foreground(theme.Text). - Background(*renderer.bgColor) + if hasBorder { + style = style.BorderStyle(lipgloss.ThickBorder()) - if renderer.fullWidth { - innerStyle = innerStyle.Width(renderer.width - borderChars) + switch borderAlign { + case lipgloss.Right: + style = style. + BorderRight(true). + BorderRightForeground(borderColor) + default: + style = style. + BorderLeft(true). + BorderLeftForeground(borderColor) } - - content = innerStyle.Render(content) - - // Phase 2 — vertical padding via Place() with bg-filled whitespace. - if renderer.paddingTop > 0 || renderer.paddingBottom > 0 { - renderedH := lipgloss.Height(content) - renderedW := lipgloss.Width(content) - totalH := renderedH + renderer.paddingTop + renderer.paddingBottom - - bgStyle := lipgloss.NewStyle().Background(*renderer.bgColor) - - // Determine vertical position so padding distributes correctly. - vPos := lipgloss.Center - switch { - case renderer.paddingTop > 0 && renderer.paddingBottom == 0: - vPos = lipgloss.Bottom - case renderer.paddingBottom > 0 && renderer.paddingTop == 0: - vPos = lipgloss.Top - } - - content = lipgloss.Place( - renderedW, totalH, - lipgloss.Left, vPos, - content, - lipgloss.WithWhitespaceStyle(bgStyle), - ) - } - - // Phase 3 — apply border to the full-height block. - if hasBorder { - borderStyle := lipgloss.NewStyle(). - BorderStyle(lipgloss.ThickBorder()) - - switch borderAlign { - case lipgloss.Right: - borderStyle = borderStyle. - BorderRight(true). - BorderRightForeground(borderColor) - default: - borderStyle = borderStyle. - BorderLeft(true). - BorderLeftForeground(borderColor) - } - - content = borderStyle.Render(content) - } - } else { - // No background — PaddingTop/PaddingBottom work fine (no visible - // banding), so render everything in a single style pass. - style := lipgloss.NewStyle(). - PaddingLeft(renderer.paddingLeft). - PaddingRight(renderer.paddingRight). - PaddingTop(renderer.paddingTop). - PaddingBottom(renderer.paddingBottom). - Foreground(theme.Text) - - if hasBorder { - style = style.BorderStyle(lipgloss.ThickBorder()) - - switch borderAlign { - case lipgloss.Right: - style = style. - BorderRight(true). - BorderRightForeground(borderColor) - default: - style = style. - BorderLeft(true). - BorderLeftForeground(borderColor) - } - } - - if renderer.fullWidth { - style = style.Width(renderer.width - borderChars) - } - - content = style.Render(content) } + if renderer.fullWidth { + style = style.Width(renderer.width - borderChars) + } + + content = style.Render(content) + // Add margins if renderer.marginTop > 0 { for range renderer.marginTop { diff --git a/internal/ui/cli.go b/internal/ui/cli.go index f18fbf65..a678a9d3 100644 --- a/internal/ui/cli.go +++ b/internal/ui/cli.go @@ -15,15 +15,12 @@ import ( // display modes, handles streaming responses, tracks token usage, and manages the // overall conversation flow between the user and AI assistants. type CLI struct { - messageRenderer *MessageRenderer - compactRenderer *CompactRenderer - messageContainer *MessageContainer - usageTracker *UsageTracker - width int - height int - compactMode bool - debug bool - modelName string + renderer Renderer + usageTracker *UsageTracker + width int + compactMode bool + debug bool + modelName string } // NewCLI creates and initializes a new CLI instance with the specified display modes. @@ -36,9 +33,11 @@ func NewCLI(debug bool, compact bool) (*CLI, error) { debug: debug, } cli.updateSize() - cli.messageRenderer = NewMessageRenderer(cli.width, debug) - cli.compactRenderer = NewCompactRenderer(cli.width, debug) - cli.messageContainer = NewMessageContainer(cli.width, cli.height-4, compact) // Pass compact mode + if compact { + cli.renderer = NewCompactRenderer(cli.width, debug) + } else { + cli.renderer = NewMessageRenderer(cli.width, debug) + } return cli, nil } @@ -71,9 +70,6 @@ func (c *CLI) GetDebugLogger() *CLIDebugLogger { // This name is displayed in message headers to indicate which model is responding. func (c *CLI) SetModelName(modelName string) { c.modelName = modelName - if c.messageContainer != nil { - c.messageContainer.SetModelName(modelName) - } } // ShowSpinner displays an animated spinner while executing the provided action @@ -94,14 +90,7 @@ func (c *CLI) ShowSpinner(action func() error) error { // formatting based on the current display mode (standard or compact). The message // is timestamped and styled according to the active theme. func (c *CLI) DisplayUserMessage(message string) { - var msg UIMessage - if c.compactMode { - msg = c.compactRenderer.RenderUserMessage(message, time.Now()) - } else { - msg = c.messageRenderer.RenderUserMessage(message, time.Now()) - } - c.messageContainer.AddMessage(msg) - c.displayContainer() + fmt.Println(c.renderer.RenderUserMessage(message, time.Now()).Content) } // DisplayAssistantMessage renders and displays an AI assistant's response message @@ -115,14 +104,7 @@ func (c *CLI) DisplayAssistantMessage(message string) error { // with the specified model name shown in the message header. The message is // formatted according to the current display mode and includes timestamp information. func (c *CLI) DisplayAssistantMessageWithModel(message, modelName string) error { - var msg UIMessage - if c.compactMode { - msg = c.compactRenderer.RenderAssistantMessage(message, time.Now(), modelName) - } else { - msg = c.messageRenderer.RenderAssistantMessage(message, time.Now(), modelName) - } - c.messageContainer.AddMessage(msg) - c.displayContainer() + fmt.Println(c.renderer.RenderAssistantMessage(message, time.Now(), modelName).Content) return nil } @@ -137,44 +119,21 @@ func (c *CLI) DisplayToolCallMessage(toolName, toolArgs string) { // including the tool name, arguments, and result. The isError parameter determines // whether the result should be displayed as an error or success message. func (c *CLI) DisplayToolMessage(toolName, toolArgs, toolResult string, isError bool) { - var msg UIMessage - if c.compactMode { - msg = c.compactRenderer.RenderToolMessage(toolName, toolArgs, toolResult, isError) - } else { - msg = c.messageRenderer.RenderToolMessage(toolName, toolArgs, toolResult, isError) - } - - // Always display immediately - spinner management is handled externally - c.messageContainer.AddMessage(msg) - c.displayContainer() + fmt.Println(c.renderer.RenderToolMessage(toolName, toolArgs, toolResult, isError).Content) } // DisplayError renders and displays an error message with distinctive formatting // to ensure visibility. The error is timestamped and styled according to the // current display mode's error theme. func (c *CLI) DisplayError(err error) { - var msg UIMessage - if c.compactMode { - msg = c.compactRenderer.RenderErrorMessage(err.Error(), time.Now()) - } else { - msg = c.messageRenderer.RenderErrorMessage(err.Error(), time.Now()) - } - c.messageContainer.AddMessage(msg) - c.displayContainer() + fmt.Println(c.renderer.RenderErrorMessage(err.Error(), time.Now()).Content) } // DisplayInfo renders and displays an informational system message. These messages // are typically used for status updates, notifications, or other non-error system // communications to the user. func (c *CLI) DisplayInfo(message string) { - var msg UIMessage - if c.compactMode { - msg = c.compactRenderer.RenderSystemMessage(message, time.Now()) - } else { - msg = c.messageRenderer.RenderSystemMessage(message, time.Now()) - } - c.messageContainer.AddMessage(msg) - c.displayContainer() + fmt.Println(c.renderer.RenderSystemMessage(message, time.Now()).Content) } // DisplayExtensionBlock renders a custom styled block with the given border @@ -195,7 +154,7 @@ func (c *CLI) DisplayExtensionBlock(text, borderColor, subtitle string) { rendered := renderContentBlock( content, - c.messageRenderer.width, + c.width, WithAlign(lipgloss.Left), WithBorderColor(borderClr), WithMarginBottom(1), @@ -206,14 +165,7 @@ func (c *CLI) DisplayExtensionBlock(text, borderColor, subtitle string) { // DisplayCancellation displays a system message indicating that the current // AI generation has been cancelled by the user (typically via ESC key). func (c *CLI) DisplayCancellation() { - var msg UIMessage - if c.compactMode { - msg = c.compactRenderer.RenderSystemMessage("Generation cancelled by user (ESC pressed)", time.Now()) - } else { - msg = c.messageRenderer.RenderSystemMessage("Generation cancelled by user (ESC pressed)", time.Now()) - } - c.messageContainer.AddMessage(msg) - c.displayContainer() + fmt.Println(c.renderer.RenderSystemMessage("Generation cancelled by user (ESC pressed)", time.Now()).Content) } // DisplayDebugMessage renders and displays a debug message if debug mode is enabled. @@ -223,42 +175,14 @@ func (c *CLI) DisplayDebugMessage(message string) { if !c.debug { return } - var msg UIMessage - if c.compactMode { - msg = c.compactRenderer.RenderDebugMessage(message, time.Now()) - } else { - msg = c.messageRenderer.RenderDebugMessage(message, time.Now()) - } - c.messageContainer.AddMessage(msg) - c.displayContainer() + fmt.Println(c.renderer.RenderDebugMessage(message, time.Now()).Content) } // DisplayDebugConfig renders and displays configuration settings in a formatted // debug message. The config parameter should contain key-value pairs representing // configuration options that will be displayed for debugging purposes. func (c *CLI) DisplayDebugConfig(config map[string]any) { - var msg UIMessage - if c.compactMode { - msg = c.compactRenderer.RenderDebugConfigMessage(config, time.Now()) - } else { - msg = c.messageRenderer.RenderDebugConfigMessage(config, time.Now()) - } - c.messageContainer.AddMessage(msg) - c.displayContainer() -} - -// displayContainer renders and displays the message container for one-shot -// (non-streaming) messages. Output matches the interactive TUI's tea.Println -// path — no extra padding or width wrapping is applied so both modes produce -// identical visual output. -func (c *CLI) displayContainer() { - content := c.messageContainer.Render() - if content != "" { - fmt.Println(content) - } - - // Clear messages after display; one-shot messages don't need to persist. - c.messageContainer.messages = nil + fmt.Println(c.renderer.RenderDebugConfigMessage(config, time.Now()).Content) } // UpdateUsageFromResponse records token usage using metadata from the fantasy @@ -309,27 +233,19 @@ func (c *CLI) DisplayUsageAfterResponse() { // updateSize updates the CLI size based on terminal dimensions func (c *CLI) updateSize() { - width, height, err := term.GetSize(int(os.Stdout.Fd())) + width, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil { - c.width = 80 // Fallback width - c.height = 24 // Fallback height + c.width = 80 // Fallback width return } // Add left and right padding (4 characters total: 2 on each side) paddingTotal := 4 c.width = width - paddingTotal - c.height = height - // Update renderers if they exist - if c.messageRenderer != nil { - c.messageRenderer.SetWidth(c.width) - } - if c.compactRenderer != nil { - c.compactRenderer.SetWidth(c.width) - } - if c.messageContainer != nil { - c.messageContainer.SetSize(c.width, c.height-4) + // Update renderer if it exists + if c.renderer != nil { + c.renderer.SetWidth(c.width) } if c.usageTracker != nil { c.usageTracker.SetWidth(c.width) diff --git a/internal/ui/compact_renderer.go b/internal/ui/compact_renderer.go index 580462a6..86e794b1 100644 --- a/internal/ui/compact_renderer.go +++ b/internal/ui/compact_renderer.go @@ -443,70 +443,9 @@ func (r *CompactRenderer) formatToolResult(result string) string { return strings.Join(lines, "\n") } -// formatBashOutput formats bash command output by removing stdout/stderr tags and styling appropriately +// formatBashOutput formats bash command output by removing stdout/stderr tags +// and styling appropriately. Delegates tag parsing to the shared parseBashOutput +// helper. func (r *CompactRenderer) formatBashOutput(result string) string { - theme := getTheme() - - // Replace tag pairs with styled content - var formattedResult strings.Builder - remaining := result - - for { - // Find stderr tags - stderrStart := strings.Index(remaining, "") - stderrEnd := strings.Index(remaining, "") - - // Find stdout tags - stdoutStart := strings.Index(remaining, "") - stdoutEnd := strings.Index(remaining, "") - - // Process whichever comes first - if stderrStart != -1 && stderrEnd != -1 && stderrEnd > stderrStart && - (stdoutStart == -1 || stderrStart < stdoutStart) { - // Process stderr - // Add content before the tag - if stderrStart > 0 { - formattedResult.WriteString(remaining[:stderrStart]) - } - - // Extract and style stderr content - stderrContent := remaining[stderrStart+8 : stderrEnd] - // Trim leading/trailing newlines but preserve internal ones - stderrContent = strings.Trim(stderrContent, "\n") - if len(stderrContent) > 0 { - // Style stderr content with error color, same as non-compact mode - styledContent := lipgloss.NewStyle().Foreground(theme.Error).Render(stderrContent) - formattedResult.WriteString(styledContent) - } - - // Continue with remaining content - remaining = remaining[stderrEnd+9:] // Skip past - - } else if stdoutStart != -1 && stdoutEnd != -1 && stdoutEnd > stdoutStart { - // Process stdout - // Add content before the tag - if stdoutStart > 0 { - formattedResult.WriteString(remaining[:stdoutStart]) - } - - // Extract stdout content (no special styling needed) - stdoutContent := remaining[stdoutStart+8 : stdoutEnd] - // Trim leading/trailing newlines but preserve internal ones - stdoutContent = strings.Trim(stdoutContent, "\n") - if len(stdoutContent) > 0 { - formattedResult.WriteString(stdoutContent) - } - - // Continue with remaining content - remaining = remaining[stdoutEnd+9:] // Skip past - - } else { - // No more tags, add remaining content - formattedResult.WriteString(remaining) - break - } - } - - // Trim any leading/trailing whitespace from the final result - return strings.TrimSpace(formattedResult.String()) + return parseBashOutput(result, getTheme()) } diff --git a/internal/ui/debug_logger.go b/internal/ui/debug_logger.go index b0a04e78..8866430c 100644 --- a/internal/ui/debug_logger.go +++ b/internal/ui/debug_logger.go @@ -68,14 +68,7 @@ func (l *CLIDebugLogger) LogDebug(message string) { } // Use the CLI's debug message rendering - var msg UIMessage - if l.cli.compactMode { - msg = l.cli.compactRenderer.RenderDebugMessage(formattedMessage, time.Now()) - } else { - msg = l.cli.messageRenderer.RenderDebugMessage(formattedMessage, time.Now()) - } - l.cli.messageContainer.AddMessage(msg) - l.cli.displayContainer() + fmt.Println(l.cli.renderer.RenderDebugMessage(formattedMessage, time.Now()).Content) } // IsDebugEnabled checks whether debug logging is currently active. Returns true diff --git a/internal/ui/enhanced_styles.go b/internal/ui/enhanced_styles.go index a78670dc..6d55b1c0 100644 --- a/internal/ui/enhanced_styles.go +++ b/internal/ui/enhanced_styles.go @@ -14,13 +14,6 @@ import ( // isDarkBg caches the terminal background detection result at package init. var isDarkBg = lipgloss.HasDarkBackground(os.Stdin, os.Stdout) -// colorHex returns the hex string representation of a color.Color by -// converting its RGBA values. -func colorHex(c color.Color) string { - r, g, b, _ := c.RGBA() - return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8) -} - // AdaptiveColor picks between a light-mode and dark-mode hex color string // based on the detected terminal background. This replaces the old // lipgloss.AdaptiveColor{Light: ..., Dark: ...} pattern from v1. diff --git a/internal/ui/format.go b/internal/ui/format.go new file mode 100644 index 00000000..4032bff1 --- /dev/null +++ b/internal/ui/format.go @@ -0,0 +1,81 @@ +package ui + +import ( + "strings" + "time" + + "charm.land/lipgloss/v2" +) + +// Renderer is the interface satisfied by both MessageRenderer and +// CompactRenderer. It allows model.go and cli.go to call rendering methods +// without branching on compact mode. +type Renderer interface { + RenderUserMessage(content string, timestamp time.Time) UIMessage + RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage + RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage + RenderSystemMessage(content string, timestamp time.Time) UIMessage + RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage + RenderDebugMessage(message string, timestamp time.Time) UIMessage + RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage + SetWidth(width int) +} + +// Compile-time checks that both renderers satisfy the Renderer interface. +var _ Renderer = (*MessageRenderer)(nil) +var _ Renderer = (*CompactRenderer)(nil) + +// parseBashOutput parses / tagged output from bash tool +// results, styling stderr with the theme's error color. Returns the +// combined, styled output string with tags stripped. +// +// Shared by both MessageRenderer and CompactRenderer. +func parseBashOutput(result string, theme Theme) string { + var formattedResult strings.Builder + remaining := result + + for { + // Find stderr tags + stderrStart := strings.Index(remaining, "") + stderrEnd := strings.Index(remaining, "") + + // Find stdout tags + stdoutStart := strings.Index(remaining, "") + stdoutEnd := strings.Index(remaining, "") + + // Process whichever comes first + if stderrStart != -1 && stderrEnd != -1 && stderrEnd > stderrStart && + (stdoutStart == -1 || stderrStart < stdoutStart) { + // Process stderr + if stderrStart > 0 { + formattedResult.WriteString(remaining[:stderrStart]) + } + stderrContent := remaining[stderrStart+8 : stderrEnd] + stderrContent = strings.Trim(stderrContent, "\n") + if len(stderrContent) > 0 { + styledContent := lipgloss.NewStyle().Foreground(theme.Error).Render(stderrContent) + formattedResult.WriteString(styledContent) + } + remaining = remaining[stderrEnd+9:] // Skip past + + } else if stdoutStart != -1 && stdoutEnd != -1 && stdoutEnd > stdoutStart { + // Process stdout + if stdoutStart > 0 { + formattedResult.WriteString(remaining[:stdoutStart]) + } + stdoutContent := remaining[stdoutStart+8 : stdoutEnd] + stdoutContent = strings.Trim(stdoutContent, "\n") + if len(stdoutContent) > 0 { + formattedResult.WriteString(stdoutContent) + } + remaining = remaining[stdoutEnd+9:] // Skip past + + } else { + // No more tags, add remaining content + formattedResult.WriteString(remaining) + break + } + } + + return strings.TrimSpace(formattedResult.String()) +} diff --git a/internal/ui/messages.go b/internal/ui/messages.go index e6e4822b..08ca4880 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -193,10 +193,7 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) theme := getTheme() - // Render the message content with the user-message background so that - // glamour-rendered markdown inherits the highlight color. - bgHex := colorHex(theme.Highlight) - messageContent := r.renderMarkdownWithBg(content, r.width-8, bgHex) // Account for padding and borders + messageContent := r.renderMarkdown(content, r.width-8) // Account for padding and borders // Create info line info := fmt.Sprintf(" %s (%s)", username, timeStr) @@ -205,13 +202,12 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" + lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info) - // Use the new block renderer + // Use the block renderer — left border with Primary color, no background. rendered := renderContentBlock( fullContent, r.width, WithAlign(lipgloss.Left), WithBorderColor(theme.Primary), - WithBackground(theme.Highlight), WithMarginBottom(1), ) @@ -653,75 +649,14 @@ func (r *MessageRenderer) formatToolResult(toolName, result string, width int) s Render(result) } -// formatBashOutput formats bash command output with proper section handling +// formatBashOutput formats bash command output with proper section handling. +// Delegates tag parsing to the shared parseBashOutput helper. func (r *MessageRenderer) formatBashOutput(result string, width int, theme Theme) string { - baseStyle := lipgloss.NewStyle() - - // Replace tag pairs with styled content - var formattedResult strings.Builder - remaining := result - - for { - // Find stderr tags - stderrStart := strings.Index(remaining, "") - stderrEnd := strings.Index(remaining, "") - - // Find stdout tags - stdoutStart := strings.Index(remaining, "") - stdoutEnd := strings.Index(remaining, "") - - // Process whichever comes first - if stderrStart != -1 && stderrEnd != -1 && stderrEnd > stderrStart && - (stdoutStart == -1 || stderrStart < stdoutStart) { - // Process stderr - // Add content before the tag - if stderrStart > 0 { - formattedResult.WriteString(remaining[:stderrStart]) - } - // Extract and style stderr content - stderrContent := remaining[stderrStart+8 : stderrEnd] - // Trim leading/trailing newlines but preserve internal ones - stderrContent = strings.Trim(stderrContent, "\n") - if len(stderrContent) > 0 { - styledContent := baseStyle.Foreground(theme.Error).Render(stderrContent) - formattedResult.WriteString(styledContent) - } - - // Continue with remaining content - remaining = remaining[stderrEnd+9:] // Skip past - - } else if stdoutStart != -1 && stdoutEnd != -1 && stdoutEnd > stdoutStart { - // Process stdout - // Add content before the tag - if stdoutStart > 0 { - formattedResult.WriteString(remaining[:stdoutStart]) - } - - // Extract stdout content (no special styling needed) - stdoutContent := remaining[stdoutStart+8 : stdoutEnd] - // Trim leading/trailing newlines but preserve internal ones - stdoutContent = strings.Trim(stdoutContent, "\n") - if len(stdoutContent) > 0 { - formattedResult.WriteString(stdoutContent) - } - - // Continue with remaining content - remaining = remaining[stdoutEnd+9:] // Skip past - - } else { - // No more tags, add remaining content - formattedResult.WriteString(remaining) - break - } - } - - // Trim any leading/trailing whitespace from the final result - finalResult := strings.TrimSpace(formattedResult.String()) - - return baseStyle. + parsed := parseBashOutput(result, theme) + return lipgloss.NewStyle(). Width(width). Foreground(theme.Muted). - Render(finalResult) + Render(parsed) } // renderMarkdown renders markdown content using glamour @@ -729,233 +664,3 @@ func (r *MessageRenderer) renderMarkdown(content string, width int) string { rendered := toMarkdown(content, width) return strings.TrimSuffix(rendered, "\n") } - -// renderMarkdownWithBg renders markdown content using glamour with a background -// color applied to every element so the output blends with a colored block. -func (r *MessageRenderer) renderMarkdownWithBg(content string, width int, bgHex string) string { - rendered := toMarkdownWithBg(content, width, bgHex) - return strings.TrimSuffix(rendered, "\n") -} - -// MessageContainer manages a collection of UI messages, handling their display, -// updates, and layout within the terminal. It supports both standard and compact -// display modes and maintains state for streaming message updates. -type MessageContainer struct { - messages []UIMessage - width int - height int - compactMode bool // Add compact mode flag - modelName string // Store current model name - wasCleared bool // Track if container was explicitly cleared -} - -// NewMessageContainer creates and initializes a new MessageContainer with the -// specified dimensions and display mode. The container starts empty and will -// display a welcome message until the first message is added. -func NewMessageContainer(width, height int, compact bool) *MessageContainer { - return &MessageContainer{ - messages: make([]UIMessage, 0), - width: width, - height: height, - compactMode: compact, - } -} - -// AddMessage appends a new UIMessage to the container's collection and resets -// the cleared state flag. Messages are displayed in the order they were added. -func (c *MessageContainer) AddMessage(msg UIMessage) { - c.messages = append(c.messages, msg) - c.wasCleared = false // Reset the cleared flag when adding messages -} - -// SetModelName updates the AI model name used for rendering assistant messages. -// This name is displayed in message headers to indicate which model is responding. -func (c *MessageContainer) SetModelName(modelName string) { - c.modelName = modelName -} - -// UpdateLastMessage efficiently updates the content of the most recent message -// in the container. This is primarily used for streaming responses where the -// assistant's message is progressively built. Only works for assistant messages. -func (c *MessageContainer) UpdateLastMessage(content string) { - if len(c.messages) == 0 { - return - } - - lastIdx := len(c.messages) - 1 - lastMsg := &c.messages[lastIdx] - - // Only re-render if content actually changed and it's an assistant message - if lastMsg.Type == AssistantMessage { - // Create appropriate renderer based on compact mode - var newMsg UIMessage - if c.compactMode { - compactRenderer := NewCompactRenderer(c.width, false) - newMsg = compactRenderer.RenderAssistantMessage(content, lastMsg.Timestamp, c.modelName) - } else { - renderer := NewMessageRenderer(c.width, false) - newMsg = renderer.RenderAssistantMessage(content, lastMsg.Timestamp, c.modelName) - } - newMsg.Streaming = lastMsg.Streaming // Preserve streaming state - c.messages[lastIdx] = newMsg - } -} - -// Clear removes all messages from the container and sets a flag to prevent -// showing the welcome screen. Used when starting a fresh conversation. -func (c *MessageContainer) Clear() { - c.messages = make([]UIMessage, 0) - c.wasCleared = true -} - -// SetSize updates the container's dimensions, typically called when the terminal -// is resized. This affects how messages are wrapped and displayed. -func (c *MessageContainer) SetSize(width, height int) { - c.width = width - c.height = height -} - -// Render generates the complete visual representation of all messages in the -// container. Returns an empty state display if no messages exist, or formats -// all messages according to the current display mode (standard or compact). -func (c *MessageContainer) Render() string { - if len(c.messages) == 0 { - // Don't show welcome box if explicitly cleared - if c.wasCleared { - return "" - } - if c.compactMode { - return c.renderCompactEmptyState() - } - return c.renderEmptyState() - } - - if c.compactMode { - return c.renderCompactMessages() - } - - var parts []string - - for i, msg := range c.messages { - // Center each message horizontally - centeredMsg := lipgloss.PlaceHorizontal( - c.width, - lipgloss.Center, - msg.Content, - ) - parts = append(parts, centeredMsg) - - // Add spacing between messages (except after the last one) - if i < len(c.messages)-1 { - parts = append(parts, "") - } - } - - style := lipgloss.NewStyle(). - Width(c.width) - - // No padding needed between messages - - return style.Render( - lipgloss.JoinVertical(lipgloss.Top, parts...), - ) -} - -// renderEmptyState renders an enhanced initial empty state -func (c *MessageContainer) renderEmptyState() string { - baseStyle := lipgloss.NewStyle() - - // Create a welcome box with border - theme := getTheme() - welcomeBox := baseStyle. - Width(c.width-4). - Border(lipgloss.RoundedBorder()). - BorderForeground(theme.System). - Padding(2, 4). - Align(lipgloss.Center) - - // Main title - title := baseStyle. - Foreground(theme.System). - Bold(true). - Render("KIT") - - // Subtitle with better typography - subtitle := baseStyle. - Foreground(theme.Primary). - Bold(true). - MarginTop(1). - Render("AI Assistant with MCP Tools") - - // Feature highlights - features := []string{ - "Natural language conversations", - "Powerful tool integrations", - "Multi-provider LLM support", - "Usage tracking & analytics", - } - - var featureList []string - for _, feature := range features { - featureList = append(featureList, baseStyle. - Foreground(theme.Muted). - MarginLeft(2). - Render("• "+feature)) - } - - // Getting started prompt - prompt := baseStyle. - Foreground(theme.Accent). - Italic(true). - MarginTop(2). - Render("Start by typing your message below or use /help for commands") - - // Combine all elements - content := lipgloss.JoinVertical( - lipgloss.Center, - title, - subtitle, - "", - lipgloss.JoinVertical(lipgloss.Left, featureList...), - "", - prompt, - ) - - welcomeContent := welcomeBox.Render(content) - - // Center the welcome box vertically - return baseStyle. - Width(c.width). - Height(c.height). - Align(lipgloss.Center). - AlignVertical(lipgloss.Center). - Render(welcomeContent) -} - -// renderCompactMessages renders messages in compact format -func (c *MessageContainer) renderCompactMessages() string { - var lines []string - - for _, msg := range c.messages { - lines = append(lines, msg.Content) - } - - return strings.Join(lines, "\n") -} - -// renderCompactEmptyState renders a simple empty state for compact mode -func (c *MessageContainer) renderCompactEmptyState() string { - theme := getTheme() - - // Simple compact welcome - welcome := lipgloss.NewStyle(). - Foreground(theme.System). - Bold(true). - Render("KIT - AI Assistant with MCP Tools") - - help := lipgloss.NewStyle(). - Foreground(theme.Muted). - Render("Type your message or /help for commands") - - return fmt.Sprintf("%s\n%s\n\n", welcome, help) -} diff --git a/internal/ui/model.go b/internal/ui/model.go index 267450f2..8483d961 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -156,13 +156,13 @@ type AppModel struct { // Placeholder until StreamComponent is implemented in TAS-16. stream streamComponentIface - // renderer renders completed assistant messages for tea.Println output. - renderer *MessageRenderer + // renderer renders completed messages for tea.Println output. It is either + // a *MessageRenderer (standard mode) or a *CompactRenderer (compact mode), + // chosen at construction time via the Renderer interface. + renderer Renderer - // compactRdr renders in compact mode. - compactRdr *CompactRenderer - - // compactMode selects which renderer to use. + // compactMode is retained for StreamComponent selection and any remaining + // mode-specific logic (e.g. startup info formatting). compactMode bool // modelName is the LLM model name shown in rendered messages. @@ -262,11 +262,18 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { height = 24 // sensible fallback } + // Choose the renderer implementation based on compact mode. + var rdr Renderer + if opts.CompactMode { + rdr = NewCompactRenderer(width, false) + } else { + rdr = NewMessageRenderer(width, false) + } + m := &AppModel{ state: stateInput, appCtrl: appCtrl, - renderer: NewMessageRenderer(width, false), - compactRdr: NewCompactRenderer(width, false), + renderer: rdr, compactMode: opts.CompactMode, modelName: opts.ModelName, providerName: opts.ProviderName, @@ -335,9 +342,6 @@ func (m *AppModel) Init() tea.Cmd { // All startup information is rendered inside a single system message block. func (m *AppModel) PrintStartupInfo() { render := func(text string) string { - if m.compactMode { - return m.compactRdr.RenderSystemMessage(text, time.Now()).Content - } return m.renderer.RenderSystemMessage(text, time.Now()).Content } @@ -880,15 +884,7 @@ func (m *AppModel) renderQueuedMessages() string { // printUserMessage renders a user message and emits it above the BT region. func (m *AppModel) printUserMessage(text string) tea.Cmd { - var rendered string - if m.compactMode { - msg := m.compactRdr.RenderUserMessage(text, time.Now()) - rendered = msg.Content - } else { - msg := m.renderer.RenderUserMessage(text, time.Now()) - rendered = msg.Content - } - return tea.Println(rendered) + return tea.Println(m.renderer.RenderUserMessage(text, time.Now()).Content) } // printAssistantMessage renders an assistant message and emits it above the BT region. @@ -896,28 +892,12 @@ func (m *AppModel) printAssistantMessage(text string) tea.Cmd { if text == "" { return nil } - var rendered string - if m.compactMode { - msg := m.compactRdr.RenderAssistantMessage(text, time.Now(), m.modelName) - rendered = msg.Content - } else { - msg := m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName) - rendered = msg.Content - } - return tea.Println(rendered) + return tea.Println(m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName).Content) } // printToolResult renders a tool result message and emits it above the BT region. func (m *AppModel) printToolResult(evt app.ToolResultEvent) tea.Cmd { - var rendered string - if m.compactMode { - msg := m.compactRdr.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError) - rendered = msg.Content - } else { - msg := m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError) - rendered = msg.Content - } - return tea.Println(rendered) + return tea.Println(m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError).Content) } // printErrorResponse renders an error message and emits it above the BT region. @@ -925,15 +905,7 @@ func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) tea.Cmd { if evt.Err == nil { return nil } - var rendered string - if m.compactMode { - msg := m.compactRdr.RenderErrorMessage(evt.Err.Error(), time.Now()) - rendered = msg.Content - } else { - msg := m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now()) - rendered = msg.Content - } - return tea.Println(rendered) + return tea.Println(m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now()).Content) } // -------------------------------------------------------------------------- @@ -990,15 +962,7 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd { // printSystemMessage renders a system-level message and emits it above the BT region. func (m *AppModel) printSystemMessage(text string) tea.Cmd { - var rendered string - if m.compactMode { - msg := m.compactRdr.RenderSystemMessage(text, time.Now()) - rendered = msg.Content - } else { - msg := m.renderer.RenderSystemMessage(text, time.Now()) - rendered = msg.Content - } - return tea.Println(rendered) + return tea.Println(m.renderer.RenderSystemMessage(text, time.Now()).Content) } // printExtensionBlock renders a custom styled block from an extension with diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index c448078b..e5fff0b2 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -101,7 +101,6 @@ func newTestAppModel(ctrl AppController) (*AppModel, *stubStreamComponent, *stub stream: stream, input: input, renderer: NewMessageRenderer(80, false), - compactRdr: NewCompactRenderer(80, false), compactMode: false, modelName: "test-model", width: 80, diff --git a/internal/ui/spinner.go b/internal/ui/spinner.go index 634f7b25..84439941 100644 --- a/internal/ui/spinner.go +++ b/internal/ui/spinner.go @@ -15,18 +15,20 @@ import ( // The KITT-style frames are generated by knightRiderFrames() in stream.go // (same package) and use the active theme colors. type Spinner struct { - frames []string - fps time.Duration - done chan struct{} - once sync.Once + frames []string + fps time.Duration + done chan struct{} + finished chan struct{} // closed by run() after cleanup + once sync.Once } // NewSpinner creates a new animated KITT-style spinner using theme colors. func NewSpinner() *Spinner { return &Spinner{ - frames: knightRiderFrames(), - fps: time.Second / 14, - done: make(chan struct{}), + frames: knightRiderFrames(), + fps: time.Second / 14, + done: make(chan struct{}), + finished: make(chan struct{}), } } @@ -36,14 +38,17 @@ func (s *Spinner) Start() { go s.run() } -// Stop halts the spinner animation and cleans up. This method blocks until -// the animation goroutine has exited and the line is cleared. +// Stop halts the spinner animation and blocks until the animation goroutine +// has exited and the line is cleared. Safe to call multiple times. func (s *Spinner) Stop() { s.once.Do(func() { close(s.done) }) + <-s.finished } // run is the animation loop that renders spinner frames to stderr. func (s *Spinner) run() { + defer close(s.finished) // unblock Stop() + ticker := time.NewTicker(s.fps) defer ticker.Stop() diff --git a/internal/ui/styles.go b/internal/ui/styles.go index c4e12557..88d1635d 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -23,15 +23,9 @@ func BaseStyle() lipgloss.Style { // GetMarkdownRenderer creates and returns a configured glamour.TermRenderer for // rendering markdown content with syntax highlighting and proper formatting. The // renderer is customized with our theme colors and adapted to the specified width. -// An optional background color hex string (e.g. "#45475a") can be provided so -// that the rendered markdown inherits the background color. -func GetMarkdownRenderer(width int, bgHex ...string) *glamour.TermRenderer { - var bg string - if len(bgHex) > 0 { - bg = bgHex[0] - } +func GetMarkdownRenderer(width int) *glamour.TermRenderer { r, _ := glamour.NewTermRenderer( - glamour.WithStyles(generateMarkdownStyleConfig(bg)), + glamour.WithStyles(generateMarkdownStyleConfig()), glamour.WithWordWrap(width), ) return r @@ -100,32 +94,15 @@ func resolveColorScheme() colorScheme { } // generateMarkdownStyleConfig creates an ansi.StyleConfig for markdown rendering. -// An optional background color hex string can be provided; when non-empty it is -// applied to the Document, Paragraph, List, and BlockQuote elements so that -// glamour-rendered content inherits the background uniformly. -func generateMarkdownStyleConfig(bgHex ...string) ansi.StyleConfig { +func generateMarkdownStyleConfig() ansi.StyleConfig { cs := resolveColorScheme() - // Background color for indent/whitespace tokens inside glamour. - // When empty the tokens are transparent. - bgColor := "" - if len(bgHex) > 0 && bgHex[0] != "" { - bgColor = bgHex[0] - } - - // Document-level background (propagates to child elements). - var docBg *string - if bgColor != "" { - docBg = &bgColor - } - return ansi.StyleConfig{ Document: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ - BlockPrefix: "", - BlockSuffix: "", - Color: &cs.text, - BackgroundColor: docBg, + BlockPrefix: "", + BlockSuffix: "", + Color: &cs.text, }, Margin: uintPtr(0), // Remove margin to prevent spacing }, @@ -135,13 +112,11 @@ func generateMarkdownStyleConfig(bgHex ...string) ansi.StyleConfig { Italic: new(true), Prefix: "┃ ", }, - Indent: uintPtr(1), - IndentToken: new(lipgloss.NewStyle().Background(lipgloss.Color(bgColor)).Render(" ")), + Indent: uintPtr(1), }, List: ansi.StyleList{ LevelIndent: 0, // Remove list indentation StyleBlock: ansi.StyleBlock{ - IndentToken: new(lipgloss.NewStyle().Background(lipgloss.Color(bgColor)).Render(" ")), StylePrimitive: ansi.StylePrimitive{ Color: &cs.text, }, @@ -316,13 +291,11 @@ func generateMarkdownStyleConfig(bgHex ...string) ansi.StyleConfig { Color: &cs.link, }, Text: ansi.StylePrimitive{ - Color: &cs.text, - BackgroundColor: docBg, + Color: &cs.text, }, Paragraph: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ - Color: &cs.text, - BackgroundColor: docBg, + Color: &cs.text, }, }, } @@ -334,12 +307,3 @@ func toMarkdown(content string, width int) string { rendered, _ := r.Render(content) return rendered } - -// toMarkdownWithBg renders markdown content using glamour with a background -// color applied to all elements so the rendered text blends with the block's -// background. -func toMarkdownWithBg(content string, width int, bgHex string) string { - r := GetMarkdownRenderer(width, bgHex) - rendered, _ := r.Render(content) - return rendered -} diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index dc8aa4ae..84464167 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -13,6 +13,7 @@ import ( "github.com/mark3labs/kit/internal/agent" "github.com/mark3labs/kit/internal/config" "github.com/mark3labs/kit/internal/extensions" + "github.com/mark3labs/kit/internal/kitsetup" "github.com/mark3labs/kit/internal/session" "github.com/mark3labs/kit/internal/skills" "github.com/mark3labs/kit/internal/tools" @@ -57,15 +58,97 @@ func (m *Kit) Subscribe(listener EventListener) func() { } // GetExtRunner returns the extension runner (nil if extensions are disabled). +// +// Deprecated: Use SetExtensionContext and EmitSessionStart instead. GetExtRunner +// leaks the internal extensions.Runner type across the SDK boundary. func (m *Kit) GetExtRunner() *extensions.Runner { return m.extRunner } // GetBufferedLogger returns the buffered debug logger (nil if not configured). +// +// Deprecated: Use GetBufferedDebugMessages instead. func (m *Kit) GetBufferedLogger() *tools.BufferedDebugLogger { return m.bufferedLogger } -// GetAgent returns the underlying agent. Callers that need the raw agent -// (e.g. for GetTools(), GetLoadingMessage()) can use this. +// GetAgent returns the underlying agent. +// +// Deprecated: Use GetToolNames, GetLoadingMessage, GetLoadedServerNames, +// GetMCPToolCount, GetExtensionToolCount instead. func (m *Kit) GetAgent() *agent.Agent { return m.agent } +// -------------------------------------------------------------------------- +// Narrow accessors — prefer these over GetAgent/GetExtRunner/GetBufferedLogger +// -------------------------------------------------------------------------- + +// GetToolNames returns the names of all tools available to the agent. +func (m *Kit) GetToolNames() []string { + agentTools := m.agent.GetTools() + names := make([]string, len(agentTools)) + for i, t := range agentTools { + names[i] = t.Info().Name + } + return names +} + +// GetLoadingMessage returns the agent's startup info message (e.g. GPU +// fallback info), or empty string if none. +func (m *Kit) GetLoadingMessage() string { + return m.agent.GetLoadingMessage() +} + +// GetLoadedServerNames returns the names of successfully loaded MCP servers. +func (m *Kit) GetLoadedServerNames() []string { + return m.agent.GetLoadedServerNames() +} + +// GetMCPToolCount returns the number of tools loaded from external MCP servers. +func (m *Kit) GetMCPToolCount() int { + return m.agent.GetMCPToolCount() +} + +// GetExtensionToolCount returns the number of tools registered by extensions. +func (m *Kit) GetExtensionToolCount() int { + return m.agent.GetExtensionToolCount() +} + +// GetBufferedDebugMessages returns any debug messages that were buffered +// during initialization, then clears the buffer. Returns nil if no messages +// were buffered or if buffered logging was not configured. +func (m *Kit) GetBufferedDebugMessages() []string { + if m.bufferedLogger == nil { + return nil + } + return m.bufferedLogger.GetMessages() +} + +// SetExtensionContext configures the extension runner with the given context +// functions. No-op if extensions are disabled. +func (m *Kit) SetExtensionContext(ctx extensions.Context) { + if m.extRunner != nil { + m.extRunner.SetContext(ctx) + } +} + +// EmitSessionStart fires the SessionStart event for extensions. +// No-op if extensions are disabled or no handlers are registered. +func (m *Kit) EmitSessionStart() { + if m.extRunner != nil && m.extRunner.HasHandlers(extensions.SessionStart) { + _, _ = m.extRunner.Emit(extensions.SessionStartEvent{}) + } +} + +// ExtensionCommands returns the slash commands registered by extensions. +// Returns nil if extensions are disabled or no commands are registered. +func (m *Kit) ExtensionCommands() []extensions.CommandDef { + if m.extRunner == nil { + return nil + } + return m.extRunner.RegisteredCommands() +} + +// HasExtensions returns true if the extension runner is configured and active. +func (m *Kit) HasExtensions() bool { + return m.extRunner != nil +} + // Options configures Kit creation with optional overrides for model, // prompts, configuration, and behavior settings. All fields are optional // and will use CLI defaults if not specified. @@ -93,12 +176,25 @@ type Options struct { AutoCompact bool // Auto-compact when near context limit CompactionOptions *CompactionOptions // Config for auto-compaction (nil = defaults) - // CLI-specific fields (ignored by programmatic SDK users) - MCPConfig *config.Config // Pre-loaded MCP config (skips LoadAndValidateConfig if set) - ShowSpinner bool // Show loading spinner for Ollama models - SpinnerFunc SpinnerFunc // Spinner implementation (nil = no spinner) - UseBufferedLogger bool // Buffer debug messages for later display - Debug bool // Enable debug logging + // Debug enables debug logging for the SDK. + Debug bool + + // CLI is optional CLI-specific configuration. SDK users leave this nil. + CLI *CLIOptions +} + +// CLIOptions holds fields only relevant to the CLI binary. SDK users should +// not need these; they are separated to keep the main Options struct clean. +type CLIOptions struct { + // MCPConfig is a pre-loaded MCP config. When set, LoadAndValidateConfig + // is skipped during Kit creation. + MCPConfig *config.Config + // ShowSpinner shows a loading spinner for Ollama models. + ShowSpinner bool + // SpinnerFunc provides the spinner implementation (nil = no spinner). + SpinnerFunc SpinnerFunc + // UseBufferedLogger buffers debug messages for later display. + UseBufferedLogger bool } // InitTreeSession creates or opens a tree session based on the given options. @@ -211,8 +307,11 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { viper.Set("system-prompt", pb.Build()) } - // Load MCP configuration. Use pre-loaded config if provided. - mcpConfig := opts.MCPConfig + // Load MCP configuration. Use pre-loaded config if provided via CLI options. + var mcpConfig *config.Config + if opts.CLI != nil { + mcpConfig = opts.CLI.MCPConfig + } if mcpConfig == nil { mcpConfig, err = config.LoadAndValidateConfig() if err != nil { @@ -228,17 +327,22 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { beforeTurn := newHookRegistry[BeforeTurnHook, BeforeTurnResult]() afterTurn := newHookRegistry[AfterTurnHook, AfterTurnResult]() + // Build agent setup options, pulling CLI-specific fields when available. + setupOpts := kitsetup.AgentSetupOptions{ + MCPConfig: mcpConfig, + Quiet: opts.Quiet, + CoreTools: opts.Tools, + ExtraTools: opts.ExtraTools, + ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult), + } + if opts.CLI != nil { + setupOpts.ShowSpinner = opts.CLI.ShowSpinner + setupOpts.SpinnerFunc = opts.CLI.SpinnerFunc + setupOpts.UseBufferedLogger = opts.CLI.UseBufferedLogger + } + // Create agent using shared setup with the hook tool wrapper. - agentResult, err := SetupAgent(ctx, AgentSetupOptions{ - MCPConfig: mcpConfig, - Quiet: opts.Quiet, - ShowSpinner: opts.ShowSpinner, - SpinnerFunc: opts.SpinnerFunc, - UseBufferedLogger: opts.UseBufferedLogger, - CoreTools: opts.Tools, - ExtraTools: opts.ExtraTools, - ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult), - }) + agentResult, err := kitsetup.SetupAgent(ctx, setupOpts) if err != nil { return nil, err } diff --git a/plans/00-move-sdk-to-top-level.md b/plans/00-move-sdk-to-top-level.md deleted file mode 100644 index 235b2b2a..00000000 --- a/plans/00-move-sdk-to-top-level.md +++ /dev/null @@ -1,503 +0,0 @@ -# Plan 00: Create `pkg/kit` SDK Package & Extract Init from `cmd` - -**Priority**: P0 -**Effort**: Medium-High -**Goal**: Create `pkg/kit` as the canonical SDK package; extract shared logic from `cmd/` so both the CLI and external users consume the same API - -## Background - -Currently the SDK lives in `sdk/` and imports `cmd/` to access `InitConfig`, `SetupAgent`, etc. This creates a circular dependency problem: if the CLI app wants to consume the SDK, `cmd` would import `sdk` which imports `cmd`. - -The fix is two-fold: -1. Move the SDK to `pkg/kit/` (idiomatic Go for public library packages) -2. Extract configuration/agent-setup logic from `cmd/` into `pkg/kit/` so both the CLI and SDK share the same code path without circular deps - -### Architecture Before - -``` -main.go → cmd/ → internal/agent, internal/session, internal/config, ... - -sdk/kit.go → cmd.InitConfig() ← SDK depends on cmd (problem!) - → cmd.SetupAgent() - → internal/session -``` - -### Architecture After - -``` -cmd/kit/main.go → cmd/ → pkg/kit/ → internal/agent, internal/session, ... - ← CLI consumes SDK - -pkg/kit/ → internal/agent, internal/session, internal/config, ... - ← External users consume SDK - -internal/app/ → pkg/kit/ ← App consumes SDK (gradual migration) - → internal/ui/ ← App owns UI only -``` - -## Prerequisites - -- None. This is the foundation for all other plans. - -## Step-by-Step - -### Step 1: Create `pkg/kit/` directory - -```bash -mkdir -p pkg/kit -``` - -### Step 2: Extract config-loading logic from `cmd/root.go` into `pkg/kit/config.go` - -The two functions `InitConfig()` and `LoadConfigWithEnvSubstitution()` currently live in `cmd/root.go` and depend on package-level variables (`configFile`, `debugMode`). Extract them as pure functions that accept parameters. - -**File**: Create `pkg/kit/config.go` - -```go -package kit - -import ( - "fmt" - "os" - "strings" - - "github.com/mark3labs/kit/internal/config" - "github.com/spf13/viper" -) - -// InitConfig initializes the viper configuration system. -// It searches for config files in standard locations and loads them with -// environment variable substitution. -// -// configFile: explicit config file path (empty = search defaults) -// debug: if true, print warnings about missing configs -func InitConfig(configFile string, debug bool) error { - if configFile != "" { - return LoadConfigWithEnvSubstitution(configFile) - } - - // Ensure a config file exists (create default if none found) - if err := config.EnsureConfigExists(); err != nil { - if debug { - fmt.Fprintf(os.Stderr, "Warning: Could not create default config file: %v\n", err) - } - } - - home, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("error finding home directory: %w", err) - } - - viper.AddConfigPath(".") - viper.AddConfigPath(home) - - configNames := []string{".kit"} - configLoaded := false - - for _, name := range configNames { - viper.SetConfigName(name) - if err := viper.ReadInConfig(); err == nil { - configPath := viper.ConfigFileUsed() - if err := LoadConfigWithEnvSubstitution(configPath); err != nil { - if strings.Contains(err.Error(), "environment variable substitution failed") { - return fmt.Errorf("error reading config file '%s': %w", configPath, err) - } - continue - } - configLoaded = true - break - } - } - - if !configLoaded && debug { - fmt.Fprintf(os.Stderr, "No config file found in current directory or home directory\n") - } - - viper.SetEnvPrefix("KIT") - viper.AutomaticEnv() - return nil -} - -// LoadConfigWithEnvSubstitution loads a config file with ${ENV_VAR} expansion. -func LoadConfigWithEnvSubstitution(configPath string) error { - rawContent, err := os.ReadFile(configPath) - if err != nil { - return fmt.Errorf("failed to read config file: %w", err) - } - - substituter := &config.EnvSubstituter{} - processedContent, err := substituter.SubstituteEnvVars(string(rawContent)) - if err != nil { - return fmt.Errorf("config env substitution failed: %w", err) - } - - configType := "yaml" - if strings.HasSuffix(configPath, ".json") { - configType = "json" - } - - config.SetConfigPath(configPath) - viper.SetConfigType(configType) - return viper.ReadConfig(strings.NewReader(processedContent)) -} -``` - -**Source**: Extracted from `cmd/root.go:119-213` - -### Step 3: Extract agent setup logic from `cmd/setup.go` into `pkg/kit/setup.go` - -Move `BuildProviderConfig`, `AgentSetupOptions`, `AgentSetupResult`, `SetupAgent`, and `setupExtensions` to the SDK. The key change: replace the `quietFlag` package-level variable dependency with an explicit `Quiet` field on `AgentSetupOptions`. - -**File**: Create `pkg/kit/setup.go` - -```go -package kit - -import ( - "context" - "fmt" - - "charm.land/fantasy" - "github.com/mark3labs/kit/internal/agent" - "github.com/mark3labs/kit/internal/config" - "github.com/mark3labs/kit/internal/extensions" - "github.com/mark3labs/kit/internal/hooks" - "github.com/mark3labs/kit/internal/models" - "github.com/mark3labs/kit/internal/tools" - "github.com/spf13/viper" -) - -// AgentSetupOptions configures agent creation. -type AgentSetupOptions struct { - MCPConfig *config.Config - ShowSpinner bool - SpinnerFunc agent.SpinnerFunc - UseBufferedLogger bool - Quiet bool // Replaces cmd's quietFlag package var -} - -// AgentSetupResult contains the created agent and related components. -type AgentSetupResult struct { - Agent *agent.Agent - BufferedLogger *tools.BufferedDebugLogger - ExtRunner *extensions.Runner -} - -// BuildProviderConfig creates a ProviderConfig from the current viper state. -func BuildProviderConfig() (*models.ProviderConfig, string, error) { - systemPrompt, err := config.LoadSystemPrompt(viper.GetString("system-prompt")) - if err != nil { - return nil, "", fmt.Errorf("failed to load system prompt: %w", err) - } - - temperature := float32(viper.GetFloat64("temperature")) - topP := float32(viper.GetFloat64("top-p")) - topK := int32(viper.GetInt("top-k")) - numGPU := int32(viper.GetInt("num-gpu-layers")) - mainGPU := int32(viper.GetInt("main-gpu")) - - cfg := &models.ProviderConfig{ - ModelString: viper.GetString("model"), - SystemPrompt: systemPrompt, - ProviderAPIKey: viper.GetString("provider-api-key"), - ProviderURL: viper.GetString("provider-url"), - MaxTokens: viper.GetInt("max-tokens"), - Temperature: &temperature, - TopP: &topP, - TopK: &topK, - StopSequences: viper.GetStringSlice("stop-sequences"), - NumGPU: &numGPU, - MainGPU: &mainGPU, - TLSSkipVerify: viper.GetBool("tls-skip-verify"), - } - - return cfg, systemPrompt, nil -} - -// SetupAgent creates an agent from the current configuration state. -func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, error) { - modelConfig, systemPrompt, err := BuildProviderConfig() - if err != nil { - return nil, err - } - - var debugLogger tools.DebugLogger - var bufferedLogger *tools.BufferedDebugLogger - if viper.GetBool("debug") { - if opts.UseBufferedLogger { - bufferedLogger = tools.NewBufferedDebugLogger(true) - debugLogger = bufferedLogger - } else { - debugLogger = tools.NewSimpleDebugLogger(true) - } - } - - var extRunner *extensions.Runner - var extOpts extensionCreationOpts - if !viper.GetBool("no-extensions") { - var extErr error - extRunner, extOpts, extErr = loadExtensions() - if extErr != nil { - fmt.Printf("Warning: Failed to load extensions: %v\n", extErr) - } - } - - a, err := agent.CreateAgent(ctx, &agent.AgentCreationOptions{ - ModelConfig: modelConfig, - MCPConfig: opts.MCPConfig, - SystemPrompt: systemPrompt, - MaxSteps: viper.GetInt("max-steps"), - StreamingEnabled: viper.GetBool("stream"), - ShowSpinner: opts.ShowSpinner, - Quiet: opts.Quiet, - SpinnerFunc: opts.SpinnerFunc, - DebugLogger: debugLogger, - ToolWrapper: extOpts.toolWrapper, - ExtraTools: extOpts.extraTools, - }) - if err != nil { - return nil, fmt.Errorf("failed to create agent: %w", err) - } - - return &AgentSetupResult{ - Agent: a, - ExtRunner: extRunner, - BufferedLogger: bufferedLogger, - }, nil -} - -// unexported helpers - -type extensionCreationOpts struct { - toolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool - extraTools []fantasy.AgentTool -} - -func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) { - extraPaths := viper.GetStringSlice("extension") - loaded, err := extensions.LoadExtensions(extraPaths) - if err != nil { - return nil, extensionCreationOpts{}, err - } - - hooksCfg, _ := hooks.LoadHooksConfig() - if hooksCfg != nil && len(hooksCfg.Hooks) > 0 { - compat := extensions.HooksAsExtension(hooksCfg) - if compat != nil { - loaded = append([]extensions.LoadedExtension{*compat}, loaded...) - } - } - - if len(loaded) == 0 { - return nil, extensionCreationOpts{}, nil - } - - runner := extensions.NewRunner(loaded) - wrapper := func(tools []fantasy.AgentTool) []fantasy.AgentTool { - return extensions.WrapToolsWithExtensions(tools, runner) - } - extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools()) - - return runner, extensionCreationOpts{ - toolWrapper: wrapper, - extraTools: extTools, - }, nil -} -``` - -**Source**: Extracted from `cmd/setup.go:28-185` - -### Step 4: Move SDK core (`sdk/kit.go`, `sdk/types.go`) into `pkg/kit/` - -Move the files and update them to import from the local package (no more `cmd` import): - -**File**: Move `sdk/kit.go` to `pkg/kit/kit.go` - -Key changes: -- `package sdk` → `package kit` -- Remove `import "github.com/mark3labs/kit/cmd"` entirely -- Replace `cmd.InitConfig()` → `InitConfig(...)` (same package) -- Replace `cmd.LoadConfigWithEnvSubstitution(...)` → `LoadConfigWithEnvSubstitution(...)` (same package) -- Replace `cmd.SetupAgent(...)` → `SetupAgent(...)` (same package) -- Replace `cmd.AgentSetupOptions{...}` → `AgentSetupOptions{...}` (same package) - -**File**: Move `sdk/types.go` to `pkg/kit/types.go` - -Key change: `package sdk` → `package kit` - -### Step 5: Move `main.go` to `cmd/kit/main.go` - -**File**: Create `cmd/kit/main.go` with the current `main.go` contents - -```go -package main - -import ( - "context" - "fmt" - "os" - - "github.com/charmbracelet/fang" - "github.com/mark3labs/kit/cmd" -) - -var version = "dev" - -func main() { - if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") { - fmt.Println(version) - os.Exit(0) - } - ctx := context.Background() - rootCmd := cmd.GetRootCommand(version) - if err := fang.Execute(ctx, rootCmd); err != nil { - os.Exit(1) - } -} -``` - -Delete root `main.go`. - -### Step 6: Update `cmd/root.go` to delegate to `pkg/kit` - -**File**: `cmd/root.go` - -Replace the `InitConfig` function body with a call to the SDK: - -```go -import kit "github.com/mark3labs/kit/pkg/kit" - -func InitConfig() { - if err := kit.InitConfig(configFile, debugMode); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - os.Exit(1) - } -} -``` - -Keep `LoadConfigWithEnvSubstitution` as a thin wrapper or remove it entirely (callers use `kit.LoadConfigWithEnvSubstitution` directly). - -### Step 7: Update `cmd/setup.go` to delegate to `pkg/kit` - -**File**: `cmd/setup.go` - -Replace `BuildProviderConfig`, `SetupAgent`, etc. with thin wrappers that inject CLI-specific state: - -```go -import kit "github.com/mark3labs/kit/pkg/kit" - -// BuildProviderConfig delegates to the SDK. -func BuildProviderConfig() (*models.ProviderConfig, string, error) { - return kit.BuildProviderConfig() -} - -// SetupAgent delegates to the SDK, injecting CLI-specific quiet flag. -func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, error) { - result, err := kit.SetupAgent(ctx, kit.AgentSetupOptions{ - MCPConfig: opts.MCPConfig, - ShowSpinner: opts.ShowSpinner, - SpinnerFunc: opts.SpinnerFunc, - UseBufferedLogger: opts.UseBufferedLogger, - Quiet: quietFlag, // Inject CLI package-level state - }) - if err != nil { - return nil, err - } - // Map SDK result back to cmd types (or make cmd use SDK types directly) - return &AgentSetupResult{ - Agent: result.Agent, - BufferedLogger: result.BufferedLogger, - ExtRunner: result.ExtRunner, - }, nil -} -``` - -**Alternative (cleaner)**: Remove `cmd` wrapper types entirely and have all callers in `cmd/` use `kit.AgentSetupOptions` and `kit.AgentSetupResult` directly. This is the app-as-consumer pattern. - -### Step 8: Update `.goreleaser.yaml` - -Add `main: ./cmd/kit`: - -```yaml -builds: - - id: kit - main: ./cmd/kit - binary: kit - ldflags: - - -s -w -X main.version={{.Version}} -``` - -### Step 9: Update examples and tests - -**Move**: `sdk/examples/` → `pkg/kit/examples/` - -Update all imports: -- `"github.com/mark3labs/kit/sdk"` → `kit "github.com/mark3labs/kit/pkg/kit"` -- All `sdk.` → `kit.` - -**Move**: `sdk/kit_test.go` → `pkg/kit/kit_test.go` -- `package sdk_test` → `package kit_test` -- Update import path - -### Step 10: Clean up old `sdk/` directory - -Remove `sdk/` entirely after all files are moved. - -### Step 11: Update documentation - -- `README.md`: Update import paths to `"github.com/mark3labs/kit/pkg/kit"` -- Move `sdk/README.md` → `pkg/kit/README.md` with updated paths - -### Step 12: Verify - -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -go vet ./... -``` - -Confirm no remaining imports of `"github.com/mark3labs/kit/sdk"` or `"github.com/mark3labs/kit/cmd"` from `pkg/kit/`. - -## Files Changed Summary - -| Action | File | Change | -|--------|------|--------| -| CREATE | `pkg/kit/config.go` | Extracted InitConfig, LoadConfigWithEnvSubstitution | -| CREATE | `pkg/kit/setup.go` | Extracted BuildProviderConfig, SetupAgent, AgentSetupOptions/Result | -| MOVE | `sdk/kit.go` → `pkg/kit/kit.go` | Change package, remove cmd import | -| MOVE | `sdk/types.go` → `pkg/kit/types.go` | Change package | -| MOVE | `sdk/kit_test.go` → `pkg/kit/kit_test.go` | Change package and imports | -| MOVE | `sdk/examples/` → `pkg/kit/examples/` | Update imports | -| CREATE | `cmd/kit/main.go` | New CLI entrypoint | -| DELETE | `main.go` | Moved to cmd/kit/ | -| EDIT | `cmd/root.go` | Delegate InitConfig to pkg/kit | -| EDIT | `cmd/setup.go` | Delegate SetupAgent to pkg/kit (or use SDK types directly) | -| EDIT | `.goreleaser.yaml` | Add `main: ./cmd/kit` | -| DELETE | `sdk/` | Entire directory after moves | - -## Dependency Graph After - -``` -cmd/kit/main.go → cmd/ -cmd/ → pkg/kit/ (CLI uses SDK) - → internal/app/ (CLI uses app for TUI) - → internal/ui/ (CLI uses UI) -pkg/kit/ → internal/agent, internal/session, internal/config, ... - (SDK uses internals, never cmd) -internal/app/ → pkg/kit/ (App uses SDK — gradual migration) - → internal/ui/ (App owns TUI) -``` - -**No circular dependencies.** - -## Verification Checklist - -- [ ] `go build -o output/kit ./cmd/kit` succeeds -- [ ] `go test -race ./...` passes -- [ ] `go vet ./...` clean -- [ ] No `pkg/kit/` file imports `cmd/` -- [ ] `cmd/` files import `pkg/kit/` for shared logic -- [ ] No remaining references to `"github.com/mark3labs/kit/sdk"` -- [ ] Examples compile with new import path -- [ ] `.goreleaser.yaml` builds from `./cmd/kit` -- [ ] CI passes (`go test ./...`) diff --git a/plans/01-export-tools-and-factories.md b/plans/01-export-tools-and-factories.md deleted file mode 100644 index 21538d53..00000000 --- a/plans/01-export-tools-and-factories.md +++ /dev/null @@ -1,253 +0,0 @@ -# Plan 01: Export Tools and Tool Factories - -**Priority**: P0 -**Effort**: Medium -**Goal**: Expose built-in tools as public APIs with pre-built instances and factory functions. The Kit CLI app should also consume these exports instead of reaching into `internal/core` directly. - -## Background - -Pi SDK exports individual tools and tool factories: -- Pre-built: `readTool`, `bashTool`, `editTool`, etc. -- Factories: `createReadTool(cwd)`, `createBashTool(cwd)`, etc. -- Bundles: `allTools`, `codingTools`, `readOnlyTools` - -Kit currently keeps all tools internal (`internal/core/`). The agent setup in `internal/agent/agent.go:97` calls `core.AllTools()` directly. After this plan, both SDK users AND the agent use the same public tool constructors. - -## Prerequisites - -- Plan 00 (Create `pkg/kit/` package) - -## Architecture - -``` -pkg/kit/ -├── kit.go # Kit struct, New(), Prompt(), etc. -├── types.go # Type aliases -├── tools.go # NEW: Public tool exports, factories, bundles -├── config.go # Extracted from cmd -├── setup.go # Extracted from cmd -internal/core/ -├── tools.go # MODIFY: Add WithWorkDir option -├── read.go # MODIFY: Accept workdir param -├── write.go # MODIFY: Accept workdir param -├── bash.go # MODIFY: Accept workdir param + cmd.Dir -├── edit.go # MODIFY: Accept workdir param -├── grep.go # MODIFY: Accept workdir param + cmd.Dir -├── find.go # MODIFY: Accept workdir param + cmd.Dir -├── ls.go # MODIFY: Accept workdir param -├── truncate.go # Unchanged -internal/agent/ -├── agent.go # MODIFY: Use public constructors via core package -``` - -## Step-by-Step - -### Step 1: Add ToolOption pattern to `internal/core/tools.go` - -**File**: `internal/core/tools.go` - -Add a functional options pattern for tool creation: - -```go -// ToolOption configures tool behavior. -type ToolOption func(*toolConfig) - -type toolConfig struct { - workDir string -} - -// WithWorkDir sets the working directory for file-based tools. -// If empty, os.Getwd() is used at execution time. -func WithWorkDir(dir string) ToolOption { - return func(c *toolConfig) { - c.workDir = dir - } -} - -func applyOptions(opts []ToolOption) toolConfig { - var cfg toolConfig - for _, o := range opts { - o(&cfg) - } - return cfg -} -``` - -Update all collection functions to accept variadic options: - -```go -func CodingTools(opts ...ToolOption) []fantasy.AgentTool { ... } -func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool { ... } -func AllTools(opts ...ToolOption) []fantasy.AgentTool { ... } -``` - -### Step 2: Update path resolution to accept workDir - -**File**: `internal/core/read.go` - -Replace `resolvePath()` at line 134-144 with configurable version: - -```go -func resolvePathWithWorkDir(path, workDir string) (string, error) { - if filepath.IsAbs(path) { - return filepath.Clean(path), nil - } - baseDir := workDir - if baseDir == "" { - var err error - baseDir, err = os.Getwd() - if err != nil { - return "", fmt.Errorf("failed to get working directory: %w", err) - } - } - return filepath.Clean(filepath.Join(baseDir, path)), nil -} - -// Backward-compat wrapper -func resolvePath(path string) (string, error) { - return resolvePathWithWorkDir(path, "") -} -``` - -### Steps 3-9: Update each tool constructor - -For each tool (`read.go`, `write.go`, `edit.go`, `bash.go`, `grep.go`, `find.go`, `ls.go`): -- Change `NewXxxTool()` to `NewXxxTool(opts ...ToolOption)` -- Apply `cfg := applyOptions(opts)` in the constructor -- Pass `cfg.workDir` to path resolution or `cmd.Dir` -- For bash/grep/find (subprocess tools): set `cmd.Dir = cfg.workDir` on `exec.CommandContext` -- Existing callers pass no args, so they get default behavior (backward compatible) - -### Step 10: Create `pkg/kit/tools.go` - -**File**: `pkg/kit/tools.go` - -```go -package kit - -import ( - "charm.land/fantasy" - "github.com/mark3labs/kit/internal/core" -) - -// Tool is the interface that all Kit tools implement. -type Tool = fantasy.AgentTool - -// ToolOption configures tool behavior. -type ToolOption = core.ToolOption - -// WithWorkDir sets the working directory for file-based tools. -var WithWorkDir = core.WithWorkDir - -// Individual tool constructors -func NewReadTool(opts ...ToolOption) Tool { return core.NewReadTool(opts...) } -func NewWriteTool(opts ...ToolOption) Tool { return core.NewWriteTool(opts...) } -func NewEditTool(opts ...ToolOption) Tool { return core.NewEditTool(opts...) } -func NewBashTool(opts ...ToolOption) Tool { return core.NewBashTool(opts...) } -func NewGrepTool(opts ...ToolOption) Tool { return core.NewGrepTool(opts...) } -func NewFindTool(opts ...ToolOption) Tool { return core.NewFindTool(opts...) } -func NewLsTool(opts ...ToolOption) Tool { return core.NewLsTool(opts...) } - -// Tool bundles -func AllTools(opts ...ToolOption) []Tool { return core.AllTools(opts...) } -func CodingTools(opts ...ToolOption) []Tool { return core.CodingTools(opts...) } -func ReadOnlyTools(opts ...ToolOption) []Tool { return core.ReadOnlyTools(opts...) } -``` - -### Step 11: Add GetTools() to Kit struct - -**File**: `pkg/kit/kit.go` - -```go -// GetTools returns all tools available to the agent (core + MCP + extensions). -func (m *Kit) GetTools() []Tool { - return m.agent.GetTools() -} -``` - -### Step 12: App-as-Consumer — Agent uses SDK tool constructors - -This is the key "dog-fooding" step. Currently `internal/agent/agent.go:97` calls `core.AllTools()` directly. After this change, the agent setup should get its tool list from the caller (via `AgentConfig.Tools`) rather than hardcoding `core.AllTools()`. - -**File**: `internal/agent/agent.go` - -Change the `AgentConfig` struct to accept tools explicitly: - -```go -type AgentConfig struct { - // ... existing fields ... - CoreTools []fantasy.AgentTool // NEW: if empty, defaults to core.AllTools() -} -``` - -In `NewAgent()` at line 96-97, change: -```go -// Before: -coreTools := core.AllTools() - -// After: -coreTools := agentConfig.CoreTools -if len(coreTools) == 0 { - coreTools = core.AllTools() // Default fallback -} -``` - -Then in `pkg/kit/setup.go`, the `SetupAgent` function passes tools from the SDK: - -```go -a, err := agent.CreateAgent(ctx, &agent.AgentCreationOptions{ - // ... existing fields ... - CoreTools: core.AllTools(), // Explicit — could be customized via Options -}) -``` - -And in `pkg/kit/kit.go`, the `Options` struct gets a `Tools` field: - -```go -type Options struct { - // ... existing fields ... - Tools []Tool // Custom tool set. If empty, AllTools() is used. -} -``` - -This allows SDK users to pass custom tools: - -```go -k, _ := kit.New(ctx, &kit.Options{ - Tools: kit.CodingTools(kit.WithWorkDir("/my/project")), -}) -``` - -### Step 13: Write tests and verify - -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -go vet ./... -``` - -## Files Changed Summary - -| Action | File | Change | -|--------|------|--------| -| EDIT | `internal/core/tools.go` | Add ToolOption, WithWorkDir, update collection funcs | -| EDIT | `internal/core/read.go` | resolvePathWithWorkDir, accept opts | -| EDIT | `internal/core/write.go` | Accept opts | -| EDIT | `internal/core/edit.go` | Accept opts | -| EDIT | `internal/core/bash.go` | Accept opts, set cmd.Dir | -| EDIT | `internal/core/grep.go` | Accept opts, set cmd.Dir | -| EDIT | `internal/core/find.go` | Accept opts, set cmd.Dir | -| EDIT | `internal/core/ls.go` | Accept opts | -| CREATE | `pkg/kit/tools.go` | Public tool exports and factories | -| EDIT | `pkg/kit/kit.go` | Add GetTools(), Tools option | -| EDIT | `internal/agent/agent.go` | Accept CoreTools in config instead of hardcoding | -| EDIT | `pkg/kit/setup.go` | Pass tools through to agent creation | - -## Verification Checklist - -- [ ] `go build -o output/kit ./cmd/kit` succeeds -- [ ] `go test -race ./...` passes (agent still gets default tools) -- [ ] Tools with `WithWorkDir("/tmp")` resolve paths relative to `/tmp` -- [ ] Tools with no options use `os.Getwd()` (backward compatible) -- [ ] SDK users can pass custom tool sets via `kit.Options{Tools: ...}` -- [ ] Agent accepts injected tools instead of hardcoding `core.AllTools()` diff --git a/plans/02-richer-type-exports.md b/plans/02-richer-type-exports.md deleted file mode 100644 index f26ca759..00000000 --- a/plans/02-richer-type-exports.md +++ /dev/null @@ -1,196 +0,0 @@ -# Plan 02: Richer Type Exports - -**Priority**: P0 -**Effort**: Low -**Goal**: Export 40+ internal types so SDK users and the CLI app share the same type surface - -## Background - -Currently only 3 type aliases are exported: `Message`, `ToolCall`, `ToolResult`. Pi exports 50+ types. SDK users and the CLI app both need access to messages, sessions, config, agents, models, and callback types. By exporting from `pkg/kit`, both external consumers and the CLI share the same types — no parallel definitions. - -## Prerequisites - -- Plan 00 (Create `pkg/kit/`) - -## Key Principle: Shared Types - -After this plan, `cmd/` should progressively adopt types from `pkg/kit/` instead of importing from `internal/` directly. For example: -- `cmd/setup.go` should reference `kit.ProviderConfig` rather than `models.ProviderConfig` -- `cmd/root.go` session setup should use `kit.SessionInfo` rather than `session.SessionInfo` - -This is a gradual migration — the type aliases make this zero-cost since `kit.ProviderConfig = models.ProviderConfig` (same underlying type). - -## Step-by-Step - -### Step 1: Expand `pkg/kit/types.go` with all type groups - -**File**: `pkg/kit/types.go` - -```go -package kit - -import ( - "charm.land/fantasy" - "github.com/mark3labs/kit/internal/agent" - "github.com/mark3labs/kit/internal/config" - "github.com/mark3labs/kit/internal/message" - "github.com/mark3labs/kit/internal/models" - "github.com/mark3labs/kit/internal/session" -) - -// ==== Message Types (internal/message/content.go) ==== - -type Message = message.Message -type MessageRole = message.MessageRole - -const ( - RoleUser = message.RoleUser - RoleAssistant = message.RoleAssistant - RoleTool = message.RoleTool - RoleSystem = message.RoleSystem -) - -type ContentPart = message.ContentPart -type TextContent = message.TextContent -type ReasoningContent = message.ReasoningContent -type ToolCall = message.ToolCall -type ToolResult = message.ToolResult -type Finish = message.Finish - -// ==== Session Types (internal/session/) ==== - -type Session = session.Session -type SessionMetadata = session.Metadata -type SessionManager = session.Manager -type SessionInfo = session.SessionInfo -type TreeManager = session.TreeManager -type SessionHeader = session.SessionHeader -type MessageEntry = session.MessageEntry - -// ==== Config Types (internal/config/) ==== - -type Config = config.Config -type MCPServerConfig = config.MCPServerConfig - -// ==== Agent Types (internal/agent/) ==== - -type AgentConfig = agent.AgentConfig -type GenerateResult = agent.GenerateWithLoopResult - -type ( - ToolCallHandler = agent.ToolCallHandler - ToolExecutionHandler = agent.ToolExecutionHandler - ToolResultHandler = agent.ToolResultHandler - ResponseHandler = agent.ResponseHandler - StreamingResponseHandler = agent.StreamingResponseHandler - ToolCallContentHandler = agent.ToolCallContentHandler -) - -// ==== Provider & Model Types (internal/models/) ==== - -type ProviderConfig = models.ProviderConfig -type ProviderResult = models.ProviderResult -type ModelInfo = models.ModelInfo -type ModelCost = models.Cost -type ModelLimit = models.Limit -type ProviderInfo = models.ProviderInfo -type ModelsRegistry = models.ModelsRegistry - -// ==== Fantasy Types (re-exported) ==== - -type FantasyMessage = fantasy.Message -type FantasyUsage = fantasy.Usage -type FantasyResponse = fantasy.Response - -// ==== Constructor & Helper Functions ==== - -var ( - NewSession = session.NewSession - NewSessionManager = session.NewManager - ListSessions = session.ListSessions - ListAllSessions = session.ListAllSessions - ParseModelString = models.ParseModelString - CreateProvider = models.CreateProvider - GetGlobalRegistry = models.GetGlobalRegistry - LoadSystemPrompt = config.LoadSystemPrompt -) - -// ==== Conversion Helpers ==== - -func ConvertToFantasyMessages(msg *Message) []fantasy.Message { - return msg.ToFantasyMessages() -} - -func ConvertFromFantasyMessage(msg fantasy.Message) Message { - return message.FromFantasyMessage(msg) -} -``` - -### Step 2: App-as-Consumer — Migrate `cmd/` to use SDK types - -After this plan, start migrating `cmd/` callers to use `kit.*` types. Since these are aliases, this is purely cosmetic and zero-cost, but it establishes the pattern: - -**Example in `cmd/setup.go`**: -```go -// Before: -import "github.com/mark3labs/kit/internal/models" -cfg := &models.ProviderConfig{...} - -// After (preferred, gradual migration): -import kit "github.com/mark3labs/kit/pkg/kit" -cfg := &kit.ProviderConfig{...} -``` - -This is not blocking — both work simultaneously due to Go type aliases. - -### Step 3: Write a compilation test - -**File**: `pkg/kit/types_test.go` - -```go -package kit_test - -import ( - "testing" - kit "github.com/mark3labs/kit/pkg/kit" -) - -func TestTypeExports(t *testing.T) { - if kit.RoleUser != "user" { t.Error("RoleUser") } - if kit.RoleAssistant != "assistant" { t.Error("RoleAssistant") } - - msg := kit.Message{ - Role: kit.RoleUser, - Parts: []kit.ContentPart{ - kit.TextContent{Text: "hello"}, - }, - } - if msg.Content() != "hello" { t.Error("message content") } - - s := kit.NewSession() - if s == nil { t.Error("NewSession") } -} -``` - -### Step 4: Verify - -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -go vet ./... -``` - -## Files Changed Summary - -| Action | File | Change | -|--------|------|--------| -| EDIT | `pkg/kit/types.go` | Add ~40 type aliases, constants, constructors | -| CREATE | `pkg/kit/types_test.go` | Compilation test | - -## Verification Checklist - -- [ ] `go build -o output/kit ./cmd/kit` succeeds -- [ ] `go test -race ./...` passes -- [ ] No circular import errors -- [ ] Type aliases are interchangeable with internal types -- [ ] `cmd/` can import and use `kit.*` types alongside internal types diff --git a/plans/03-event-subscriber-system.md b/plans/03-event-subscriber-system.md deleted file mode 100644 index 713dca3f..00000000 --- a/plans/03-event-subscriber-system.md +++ /dev/null @@ -1,348 +0,0 @@ -# Plan 03: Event/Subscriber System - -**Priority**: P1 -**Effort**: High -**Goal**: Create a unified event system in the SDK that replaces the three parallel event systems currently in the codebase - -## Background - -Kit currently has **three separate event systems** that overlap: - -1. **SDK callbacks** (`sdk/kit.go`) — 3 function pointers on `PromptWithCallbacks` -2. **Extension events** (`internal/extensions/events.go`) — 13 typed events dispatched via `Runner.Emit()` -3. **App/TUI events** (`internal/app/events.go`) — 13 `tea.Msg` structs for BubbleTea UI updates - -Pi uses a single `session.subscribe(listener)` pattern. This plan creates a unified event system in `pkg/kit/` that: -- Replaces SDK callbacks -- Becomes the canonical event layer that extensions and the app emit through -- The TUI adapts SDK events into `tea.Msg` for rendering (TUI-specific concern stays in `internal/ui/`) - -## Prerequisites - -- Plan 00 (Create `pkg/kit/`) -- Plan 02 (Richer type exports) - -## Design Decisions - -1. **Single source of truth** — events are defined in `pkg/kit/`, not scattered across packages -2. **Multiple subscribers** supported with unsubscribe -3. **Thread-safe** emission -4. **App subscribes to SDK events** — the TUI layer adapts them to `tea.Msg` -5. **Extensions emit through SDK** — the extension runner emits SDK events, not its own types - -## Step-by-Step - -### Step 1: Define public event types - -**File**: `pkg/kit/events.go` (new) - -```go -package kit - -import "sync" - -// EventType identifies the kind of event. -type EventType string - -const ( - EventTurnStart EventType = "turn_start" - EventTurnEnd EventType = "turn_end" - EventMessageStart EventType = "message_start" - EventMessageUpdate EventType = "message_update" - EventMessageEnd EventType = "message_end" - EventToolCall EventType = "tool_call" - EventToolExecutionStart EventType = "tool_execution_start" - EventToolExecutionEnd EventType = "tool_execution_end" - EventToolResult EventType = "tool_result" - EventToolCallContent EventType = "tool_call_content" - EventResponse EventType = "response" - EventSessionStart EventType = "session_start" - EventSessionShutdown EventType = "session_shutdown" -) - -// Event is the interface for all event types. -type Event interface { - EventType() EventType -} -``` - -### Step 2: Define concrete event structs - -These cover the union of all three current event systems: - -```go -type TurnStartEvent struct{ Prompt string } -func (e TurnStartEvent) EventType() EventType { return EventTurnStart } - -type TurnEndEvent struct{ Response string; Error error } -func (e TurnEndEvent) EventType() EventType { return EventTurnEnd } - -type MessageStartEvent struct{} -func (e MessageStartEvent) EventType() EventType { return EventMessageStart } - -type MessageUpdateEvent struct{ Chunk string } -func (e MessageUpdateEvent) EventType() EventType { return EventMessageUpdate } - -type MessageEndEvent struct{ Content string } -func (e MessageEndEvent) EventType() EventType { return EventMessageEnd } - -type ToolCallEvent struct{ ToolName string; ToolArgs string } -func (e ToolCallEvent) EventType() EventType { return EventToolCall } - -type ToolExecutionStartEvent struct{ ToolName string } -func (e ToolExecutionStartEvent) EventType() EventType { return EventToolExecutionStart } - -type ToolExecutionEndEvent struct{ ToolName string } -func (e ToolExecutionEndEvent) EventType() EventType { return EventToolExecutionEnd } - -type ToolResultEvent struct{ ToolName, ToolArgs, Result string; IsError bool } -func (e ToolResultEvent) EventType() EventType { return EventToolResult } - -type ToolCallContentEvent struct{ Content string } -func (e ToolCallContentEvent) EventType() EventType { return EventToolCallContent } - -type ResponseEvent struct{ Content string } -func (e ResponseEvent) EventType() EventType { return EventResponse } -``` - -### Step 3: Implement EventBus - -```go -type EventListener func(event Event) - -type eventBus struct { - mu sync.RWMutex - listeners map[int]EventListener - nextID int -} - -func newEventBus() *eventBus { - return &eventBus{listeners: make(map[int]EventListener)} -} - -func (eb *eventBus) subscribe(listener EventListener) func() { - eb.mu.Lock() - id := eb.nextID - eb.nextID++ - eb.listeners[id] = listener - eb.mu.Unlock() - return func() { - eb.mu.Lock() - delete(eb.listeners, id) - eb.mu.Unlock() - } -} - -func (eb *eventBus) emit(event Event) { - eb.mu.RLock() - snapshot := make([]EventListener, 0, len(eb.listeners)) - for _, l := range eb.listeners { - snapshot = append(snapshot, l) - } - eb.mu.RUnlock() - for _, l := range snapshot { - l(event) - } -} -``` - -### Step 4: Wire EventBus into Kit struct - -**File**: `pkg/kit/kit.go` - -```go -type Kit struct { - agent *agent.Agent - sessionMgr *session.Manager - modelString string - events *eventBus -} - -func (m *Kit) Subscribe(listener EventListener) func() { - return m.events.subscribe(listener) -} -``` - -### Step 5: Wire all agent callbacks to emit events - -Update `Prompt()` and `PromptWithCallbacks()` to emit events at every stage of the agent generation flow. Events fire at these points (matching the lifecycle in `internal/app/app.go:364-520`): - -1. Before generation: `TurnStartEvent`, `MessageStartEvent` -2. During streaming: `MessageUpdateEvent` per chunk -3. On tool call: `ToolCallEvent`, `ToolExecutionStartEvent` -4. On tool result: `ToolExecutionEndEvent`, `ToolResultEvent` -5. On response: `ResponseEvent` -6. After generation: `MessageEndEvent`, `TurnEndEvent` - -Extract shared callback helpers to avoid duplication: - -```go -func (m *Kit) makeToolCallHandler() agent.ToolCallHandler { - return func(name, args string) { - m.events.emit(ToolCallEvent{ToolName: name, ToolArgs: args}) - } -} -// ... similar for all callback types -``` - -### Step 6: App-as-Consumer — TUI subscribes to SDK events - -This is the critical refactor. Currently `internal/app/app.go:executeStep()` emits TUI events directly via `sendFn(StreamChunkEvent{...})`. After this change: - -1. The SDK's `Prompt()` emits SDK events -2. The app subscribes to SDK events and converts them to `tea.Msg` - -**File**: `internal/app/app.go` (migration pattern) - -```go -// In App initialization, subscribe to SDK events and bridge to TUI -func (a *App) setupEventBridge() { - a.kit.Subscribe(func(e kit.Event) { - switch ev := e.(type) { - case kit.MessageUpdateEvent: - a.sendToTUI(StreamChunkEvent{Content: ev.Chunk}) - case kit.ToolCallEvent: - a.sendToTUI(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs}) - case kit.ToolResultEvent: - a.sendToTUI(ToolResultEvent{ - ToolName: ev.ToolName, ToolArgs: ev.ToolArgs, - Result: ev.Result, IsError: ev.IsError, - }) - case kit.ResponseEvent: - a.sendToTUI(ResponseCompleteEvent{Content: ev.Content}) - // ... etc - } - }) -} -``` - -**Migration steps**: -1. First: app subscribes to SDK events AND keeps its own emission (dual-emit phase) -2. Then: remove direct emission from `executeStep()`, rely solely on SDK events -3. Finally: remove `internal/app/events.go` types that are now redundant - -### Step 7: Extension events bridge to SDK events - -The extension `Runner` should emit through the SDK event bus rather than its own parallel system. This can be bridged: - -```go -// In Kit initialization, bridge extension events to SDK events -func (m *Kit) bridgeExtensionEvents(runner *extensions.Runner) { - // When extensions emit events, forward them as SDK events - // This is done by having the Runner call back into the SDK - runner.SetEventForwarder(func(event extensions.Event) { - switch e := event.(type) { - case extensions.ToolCallEvent: - m.events.emit(ToolCallEvent{ToolName: e.ToolName, ToolArgs: e.Input}) - // ... etc - } - }) -} -``` - -**Note**: This is a gradual migration. The extension Runner keeps its typed events for Yaegi compatibility, but forwards them to the SDK bus. Eventually the extension system could be refactored to emit SDK events natively. - -### Step 8: Typed convenience subscribers - -```go -func (m *Kit) OnToolCall(handler func(ToolCallEvent)) func() { - return m.Subscribe(func(e Event) { - if tc, ok := e.(ToolCallEvent); ok { handler(tc) } - }) -} - -func (m *Kit) OnToolResult(handler func(ToolResultEvent)) func() { - return m.Subscribe(func(e Event) { - if tr, ok := e.(ToolResultEvent); ok { handler(tr) } - }) -} - -func (m *Kit) OnStreaming(handler func(MessageUpdateEvent)) func() { - return m.Subscribe(func(e Event) { - if mu, ok := e.(MessageUpdateEvent); ok { handler(mu) } - }) -} -``` - -### Step 9: Write tests and verify - -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -go vet ./... -``` - -## Files Changed Summary - -| Action | File | Change | -|--------|------|--------| -| CREATE | `pkg/kit/events.go` | Event types, EventBus, Subscribe() | -| EDIT | `pkg/kit/kit.go` | Add eventBus field, Subscribe(), callback helpers | -| EDIT | `internal/app/app.go` | Subscribe to SDK events (gradual migration) | -| EDIT | `internal/extensions/runner.go` | Optional: event forwarding to SDK bus | - -## Event Flow After This Plan - -``` -Agent.GenerateWithLoopAndStreaming() - ↓ fantasy callbacks -pkg/kit/kit.go (SDK Prompt method) - ↓ emits SDK events -EventBus - ↓ dispatches to all subscribers - ├── External SDK user's listener - ├── App TUI bridge → tea.Msg → BubbleTea Update() - └── Extension bridge → Runner.Emit() → Yaegi handlers -``` - -**Single source of truth**: The SDK EventBus is the only event dispatcher. - -## Verification Checklist - -- [x] `go build -o output/kit ./cmd/kit` succeeds -- [x] `go test -race ./...` passes -- [x] Events fire in correct order: TurnStart → MessageStart → updates → ToolCall → ToolResult → MessageEnd → TurnEnd -- [x] Multiple subscribers receive all events -- [x] Unsubscribe removes listener -- [ ] App TUI still renders correctly via event bridge (deferred — see below) -- [x] Thread-safe under concurrent calls - -## Implemented (Steps 1-5, 8-9) - -Core event system is complete: -- Event types, concrete structs, EventBus in `pkg/kit/events.go` -- `Kit.Subscribe()` + typed helpers (`OnToolCall`, `OnToolResult`, `OnStreaming`, `OnResponse`, `OnTurnStart`, `OnTurnEnd`) -- `Prompt()` and `PromptWithCallbacks()` emit full lifecycle events -- 10 tests covering subscribe/unsubscribe, ordering, concurrency, self-unsubscribe -- Example updated to use `Subscribe` API; `PromptWithCallbacks` marked deprecated - -## Deferred (Steps 6-7) - -### Step 6: App TUI bridge — app subscribes to SDK events - -The app (`internal/app/app.go`) currently owns an `AgentRunner` interface (not a `*Kit`), -and emits `tea.Msg` events directly from `executeStep()` callbacks. To bridge through the -SDK EventBus: - -1. The app needs a `*Kit` reference (or at minimum an `*eventBus` / `Subscribe` func) -2. `executeStep()` would stop emitting `tea.Msg` directly and instead let the SDK emit - SDK events, with a subscriber in the app that converts them to `tea.Msg` -3. Dual-emit phase first (both old and new), then remove direct emission - -**Why deferred**: The app doesn't have a `Kit` reference — it receives an `AgentRunner`. -Changing this requires restructuring `internal/app/options.go` and `cmd/root.go` where -the app is created. This is better done as part of the gradual "app consumes SDK" migration -(tracked in the README architecture diagram). - -### Step 7: Extension events bridge — Runner emits through SDK EventBus - -The extension `Runner` (`internal/extensions/runner.go`) has its own typed events. To -forward them through the SDK bus: - -1. Add `SetEventForwarder(func(extensions.Event))` to Runner -2. In Kit initialization, bridge extension events to SDK events -3. Extensions keep their typed events for Yaegi compatibility but forward to SDK bus - -**Why deferred**: Same dependency as Step 6 — requires the Kit instance to be wired -into the extension runner initialization path. Plan 09 (Extension hook system) is the -natural place to complete this bridge. diff --git a/plans/04-enhanced-session-management.md b/plans/04-enhanced-session-management.md deleted file mode 100644 index e17cb7b7..00000000 --- a/plans/04-enhanced-session-management.md +++ /dev/null @@ -1,298 +0,0 @@ -# Plan 04: Enhanced Session Management - -**Priority**: P1 -**Effort**: High -**Goal**: Expose session management in the SDK; CLI session flags map to SDK options - -## Background - -Kit has rich session infrastructure internally (`store.go`, `tree_manager.go`) but none of it is in the SDK. The CLI handles sessions in `cmd/root.go:479-557` with flags like `--continue`, `--resume`, `--session`, `--no-session`. After this plan, both the CLI and external users configure sessions through `kit.Options`. - -## Prerequisites - -- Plan 00 (Create `pkg/kit/`) -- Plan 02 (Richer type exports) - -## Key Principle - -The CLI should NOT have its own session setup logic. Instead: -1. CLI parses `--continue`, `--session`, etc. into `kit.Options` fields -2. `kit.New()` handles all session initialization -3. The CLI gets back a `*Kit` with the session already configured - -## Step-by-Step - -### Step 1: Add session options to Kit Options - -**File**: `pkg/kit/kit.go` - -```go -type Options struct { - // ... existing fields (Model, SystemPrompt, ConfigFile, etc.) ... - - // Session configuration - SessionDir string // Base directory for session discovery (default: cwd) - SessionPath string // Open a specific session file - Continue bool // Continue most recent session for SessionDir - NoSession bool // Ephemeral mode — no persistence -} -``` - -### Step 2: Add tree session to Kit struct - -```go -type Kit struct { - agent *agent.Agent - sessionMgr *session.Manager - treeSession *session.TreeManager - modelString string - events *eventBus -} -``` - -### Step 3: Initialize tree session in New() - -```go -func New(ctx context.Context, opts *Options) (*Kit, error) { - // ... existing config + agent setup ... - - cwd, _ := os.Getwd() - sessionDir := cwd - if opts != nil && opts.SessionDir != "" { - sessionDir = opts.SessionDir - } - - var treeSession *session.TreeManager - if opts != nil && opts.NoSession { - treeSession = session.InMemoryTreeSession(sessionDir) - } else if opts != nil && opts.Continue { - ts, err := session.ContinueRecent(sessionDir) - if err != nil { - ts, err = session.CreateTreeSession(sessionDir) - if err != nil { - return nil, fmt.Errorf("failed to create session: %w", err) - } - } - treeSession = ts - } else if opts != nil && opts.SessionPath != "" { - ts, err := session.OpenTreeSession(opts.SessionPath) - if err != nil { - return nil, fmt.Errorf("failed to open session: %w", err) - } - treeSession = ts - } else { - ts, err := session.CreateTreeSession(sessionDir) - if err != nil { - return nil, fmt.Errorf("failed to create session: %w", err) - } - treeSession = ts - } - - return &Kit{ - agent: setupResult.Agent, - sessionMgr: sessionMgr, - treeSession: treeSession, - modelString: modelString, - events: newEventBus(), - }, nil -} -``` - -### Step 4: Wire Prompt() to use tree session - -```go -func (m *Kit) Prompt(ctx context.Context, message string) (string, error) { - var messages []fantasy.Message - if m.treeSession != nil { - msgs, _, _ := m.treeSession.BuildContext() - messages = msgs - } else { - messages = m.sessionMgr.GetMessages() - } - - // ... generation ... - - // Persist to tree session - if m.treeSession != nil { - m.treeSession.AppendFantasyMessage(userMsg) - for _, msg := range result.Messages { - m.treeSession.AppendMessage(msg) - } - } - - // Keep legacy manager in sync - _ = m.sessionMgr.ReplaceAllMessages(result.ConversationMessages) - return response, nil -} -``` - -### Step 5: Add session management methods - -**File**: `pkg/kit/sessions.go` (new) - -```go -package kit - -import ( - "fmt" - "os" - "github.com/mark3labs/kit/internal/session" -) - -// Package-level session operations (don't require a Kit instance) - -func ListSessions(dir string) ([]SessionInfo, error) { - if dir == "" { - var err error - dir, err = os.Getwd() - if err != nil { return nil, err } - } - return session.ListSessions(dir) -} - -func ListAllSessions() ([]SessionInfo, error) { - return session.ListAllSessions() -} - -func DeleteSession(path string) error { - return session.DeleteSession(path) -} - -// Instance methods - -func (m *Kit) GetTreeSession() *TreeManager { return m.treeSession } - -func (m *Kit) GetSessionPath() string { - if m.treeSession != nil { return m.treeSession.GetFilePath() } - return "" -} - -func (m *Kit) GetSessionID() string { - if m.treeSession != nil { return m.treeSession.GetSessionID() } - return "" -} - -func (m *Kit) Branch(entryID string) error { - if m.treeSession == nil { - return fmt.Errorf("branching requires tree session") - } - m.treeSession.Branch(entryID) - msgs, _, _ := m.treeSession.BuildContext() - return m.sessionMgr.ReplaceAllMessages(msgs) -} - -func (m *Kit) SetSessionName(name string) error { - if m.treeSession == nil { - return fmt.Errorf("session naming requires tree session") - } - m.treeSession.AppendSessionInfo(name) - return nil -} - -func (m *Kit) ClearSession() { - m.sessionMgr = session.NewManager("") - if m.treeSession != nil { - m.treeSession.ResetLeaf() - } -} -``` - -### Step 6: App-as-Consumer — CLI delegates session setup to SDK - -This is the critical step. Currently `cmd/root.go:479-557` has its own session setup logic with if/else chains for each flag. Replace it with `kit.Options`: - -**File**: `cmd/root.go` (migration) - -```go -// Before (cmd/root.go:479-557): -// Complex if/else chain checking noSessionFlag, continueFlag, resumeFlag, sessionPath - -// After: -import kit "github.com/mark3labs/kit/pkg/kit" - -func buildKitOptions() *kit.Options { - opts := &kit.Options{ - Model: modelFlag, - ConfigFile: configFile, - Quiet: quietFlag, - } - - // Map CLI flags to SDK options - if noSessionFlag { - opts.NoSession = true - } else if continueFlag { - opts.Continue = true - } else if sessionPath != "" { - opts.SessionPath = sessionPath - } - // resumeFlag: handled by listing sessions then picking one - // (call kit.ListSessions first, then set opts.SessionPath) - - return opts -} - -// The Kit instance handles all session init internally: -k, err := kit.New(ctx, buildKitOptions()) -``` - -**For --resume** (currently half-implemented with a TODO for TUI picker): -```go -if resumeFlag { - sessions, err := kit.ListSessions("") - if err != nil || len(sessions) == 0 { - // Fall back to new session - } else { - // TODO: Show TUI picker. For now, pick most recent. - opts.SessionPath = sessions[0].Path - } -} -``` - -### Step 7: App uses Kit's session instead of creating its own TreeManager - -Currently `internal/app/app.go` receives a `TreeSession` via its `Options`. After migration, the app receives a `*Kit` instance and uses its tree session: - -```go -// Before: -type Options struct { - TreeSession *session.TreeManager - // ... -} - -// After (gradual): -type Options struct { - Kit *kit.Kit // The SDK instance - // ... -} - -// App gets messages: -msgs := a.opts.Kit.GetTreeSession().GetFantasyMessages() -``` - -### Step 8: Verify - -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -go vet ./... -``` - -## Files Changed Summary - -| Action | File | Change | -|--------|------|--------| -| EDIT | `pkg/kit/kit.go` | Add treeSession, session Options fields, wire Prompt | -| CREATE | `pkg/kit/sessions.go` | ListSessions, Branch, SetSessionName, etc. | -| EDIT | `cmd/root.go` | Replace session setup logic with kit.Options mapping | -| EDIT | `internal/app/app.go` | Accept Kit instance for session access (gradual) | - -## Verification Checklist - -- [ ] `go build -o output/kit ./cmd/kit` succeeds -- [ ] `go test -race ./...` passes -- [ ] `kit.New(ctx, &kit.Options{Continue: true})` resumes recent session -- [ ] `kit.New(ctx, &kit.Options{NoSession: true})` creates ephemeral session -- [ ] `kit.ListSessions("")` returns sessions -- [ ] CLI `--continue` flag maps to `kit.Options{Continue: true}` -- [ ] CLI `--no-session` flag maps to `kit.Options{NoSession: true}` -- [ ] CLI no longer has its own session initialization logic diff --git a/plans/05-additional-prompt-modes.md b/plans/05-additional-prompt-modes.md deleted file mode 100644 index 9b9d25bc..00000000 --- a/plans/05-additional-prompt-modes.md +++ /dev/null @@ -1,276 +0,0 @@ -# Plan 05: Additional Prompt Modes - -**Priority**: P1 -**Effort**: Medium -**Goal**: Add `Steer()`, `FollowUp()`, `PromptWithOptions()` methods; app's `executeStep()` should call SDK methods - -## Background - -Pi has `session.prompt()`, `session.steer()`, `session.followUp()`, `session.compact()`. Kit only has `Prompt()` and `PromptWithCallbacks()`. The Kit CLI app implements its own agent loop in `internal/app/app.go:executeStep()` which duplicates SDK logic. After this plan, both the app and SDK users call the same methods. - -## Prerequisites - -- Plan 00 (Create `pkg/kit/`) -- Plan 03 (Event subscriber system) - -## Step-by-Step - -### Step 1: Extract shared callback helpers - -To avoid duplicating callback wiring across `Prompt`, `Steer`, `FollowUp`, etc., extract internal helpers: - -**File**: `pkg/kit/kit.go` - -```go -func (m *Kit) makeToolCallHandler() agent.ToolCallHandler { - return func(name, args string) { - m.events.emit(ToolCallEvent{ToolName: name, ToolArgs: args}) - } -} - -func (m *Kit) makeToolExecutionHandler() agent.ToolExecutionHandler { - return func(name string, isStarting bool) { - if isStarting { - m.events.emit(ToolExecutionStartEvent{ToolName: name}) - } else { - m.events.emit(ToolExecutionEndEvent{ToolName: name}) - } - } -} - -func (m *Kit) makeToolResultHandler() agent.ToolResultHandler { - return func(name, args, result string, isError bool) { - m.events.emit(ToolResultEvent{ToolName: name, ToolArgs: args, Result: result, IsError: isError}) - } -} - -func (m *Kit) makeResponseHandler() agent.ResponseHandler { - return func(content string) { m.events.emit(ResponseEvent{Content: content}) } -} - -func (m *Kit) makeStreamingHandler() agent.StreamingResponseHandler { - return func(chunk string) { m.events.emit(MessageUpdateEvent{Chunk: chunk}) } -} - -// getMessages retrieves conversation history from the best available source. -func (m *Kit) getMessages() []fantasy.Message { - if m.treeSession != nil { - msgs, _, _ := m.treeSession.BuildContext() - return msgs - } - return m.sessionMgr.GetMessages() -} - -// updateSession persists generation results. -func (m *Kit) updateSession(userMsg fantasy.Message, result *agent.GenerateWithLoopResult) { - if m.treeSession != nil { - m.treeSession.AppendFantasyMessage(userMsg) - for _, msg := range result.Messages { - m.treeSession.AppendMessage(msg) - } - } - _ = m.sessionMgr.ReplaceAllMessages(result.ConversationMessages) -} - -// generate is the shared generation path for all prompt modes. -func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.GenerateWithLoopResult, error) { - return m.agent.GenerateWithLoopAndStreaming( - ctx, messages, - m.makeToolCallHandler(), - m.makeToolExecutionHandler(), - m.makeToolResultHandler(), - m.makeResponseHandler(), - nil, // onToolCallContent - m.makeStreamingHandler(), - ) -} -``` - -### Step 2: Refactor Prompt() to use shared helpers - -```go -func (m *Kit) Prompt(ctx context.Context, msg string) (string, error) { - messages := m.getMessages() - userMsg := fantasy.NewUserMessage(msg) - messages = append(messages, userMsg) - - m.events.emit(TurnStartEvent{Prompt: msg}) - m.events.emit(MessageStartEvent{}) - - result, err := m.generate(ctx, messages) - if err != nil { - m.events.emit(TurnEndEvent{Error: err}) - return "", fmt.Errorf("generation failed: %w", err) - } - - m.updateSession(userMsg, result) - response := result.FinalResponse.Content.Text() - m.events.emit(MessageEndEvent{Content: response}) - m.events.emit(TurnEndEvent{Response: response}) - return response, nil -} -``` - -### Step 3: Add Steer() - -```go -// Steer injects a system message and triggers a new agent turn. -// Use for dynamically adjusting behavior without a visible user message. -func (m *Kit) Steer(ctx context.Context, instruction string) (string, error) { - messages := m.getMessages() - sysMsg := fantasy.NewSystemMessage(instruction) - messages = append(messages, sysMsg) - userMsg := fantasy.NewUserMessage("Please acknowledge and follow the above instruction.") - messages = append(messages, userMsg) - - m.events.emit(TurnStartEvent{Prompt: "[steer] " + instruction}) - m.events.emit(MessageStartEvent{}) - - result, err := m.generate(ctx, messages) - if err != nil { - m.events.emit(TurnEndEvent{Error: err}) - return "", fmt.Errorf("steer failed: %w", err) - } - - m.updateSession(userMsg, result) - response := result.FinalResponse.Content.Text() - m.events.emit(MessageEndEvent{Content: response}) - m.events.emit(TurnEndEvent{Response: response}) - return response, nil -} -``` - -### Step 4: Add FollowUp() - -```go -// FollowUp continues the conversation without new user input. -func (m *Kit) FollowUp(ctx context.Context) (string, error) { - messages := m.getMessages() - if len(messages) == 0 { - return "", fmt.Errorf("cannot follow up: no previous messages") - } - userMsg := fantasy.NewUserMessage("Continue.") - messages = append(messages, userMsg) - - m.events.emit(TurnStartEvent{Prompt: "[follow-up]"}) - m.events.emit(MessageStartEvent{}) - - result, err := m.generate(ctx, messages) - if err != nil { - m.events.emit(TurnEndEvent{Error: err}) - return "", fmt.Errorf("follow-up failed: %w", err) - } - - m.updateSession(userMsg, result) - response := result.FinalResponse.Content.Text() - m.events.emit(MessageEndEvent{Content: response}) - m.events.emit(TurnEndEvent{Response: response}) - return response, nil -} -``` - -### Step 5: Add PromptWithOptions() - -```go -type PromptOptions struct { - SystemMessage string // Injected before the prompt - MaxSteps int // Override max steps for this call (0 = default) -} - -func (m *Kit) PromptWithOptions(ctx context.Context, msg string, opts PromptOptions) (string, error) { - messages := m.getMessages() - if opts.SystemMessage != "" { - messages = append(messages, fantasy.NewSystemMessage(opts.SystemMessage)) - } - userMsg := fantasy.NewUserMessage(msg) - messages = append(messages, userMsg) - - m.events.emit(TurnStartEvent{Prompt: msg}) - m.events.emit(MessageStartEvent{}) - - result, err := m.generate(ctx, messages) - if err != nil { - m.events.emit(TurnEndEvent{Error: err}) - return "", fmt.Errorf("generation failed: %w", err) - } - - m.updateSession(userMsg, result) - response := result.FinalResponse.Content.Text() - m.events.emit(MessageEndEvent{Content: response}) - m.events.emit(TurnEndEvent{Response: response}) - return response, nil -} -``` - -### Step 6: App-as-Consumer — Refactor `executeStep()` to use SDK - -Currently `internal/app/app.go:executeStep()` (lines 364-520) contains a full agent loop with extension events, message building, and session persistence. It should be replaced by SDK method calls. - -**File**: `internal/app/app.go` (migration) - -```go -// Before: 150+ lines of agent loop logic in executeStep() - -// After: executeStep delegates to the Kit SDK -func (a *App) executeStep(ctx context.Context, prompt string, sendFn func(tea.Msg)) (*agent.GenerateWithLoopResult, error) { - // Extension Input hook (stays in app — it's a pre-SDK concern) - if a.opts.Extensions != nil && a.opts.Extensions.HasHandlers(extensions.Input) { - result, _ := a.opts.Extensions.Emit(extensions.InputEvent{Text: prompt}) - if r, ok := result.(extensions.InputResult); ok && r.Action == "handled" { - return nil, nil - } - if r, ok := result.(extensions.InputResult); ok && r.Text != "" { - prompt = r.Text - } - } - - sendFn(SpinnerEvent{Show: true}) - - // Use SDK prompt — events handled by subscriber bridge (Plan 03) - response, err := a.kit.Prompt(ctx, prompt) - if err != nil { - sendFn(StepErrorEvent{Err: err}) - return nil, err - } - - sendFn(SpinnerEvent{Show: false}) - sendFn(StepCompleteEvent{}) - _ = response - - return nil, nil // Result comes through events -} -``` - -**Note**: This is a simplification. The real migration needs to handle: -- Extension `BeforeAgentStart` events (map to Plan 09 hooks) -- Spinner show/hide -- The fact that `executeStep` returns `*GenerateWithLoopResult` for further processing - -The migration is gradual: -1. **Phase 1**: App calls `kit.Prompt()` for simple cases -2. **Phase 2**: Extension events bridge through SDK hooks (Plan 09) -3. **Phase 3**: `executeStep()` becomes a thin adapter - -### Step 7: Verify - -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -go vet ./... -``` - -## Files Changed Summary - -| Action | File | Change | -|--------|------|--------| -| EDIT | `pkg/kit/kit.go` | Steer(), FollowUp(), PromptWithOptions(), shared helpers | -| EDIT | `internal/app/app.go` | Gradual migration of executeStep to use SDK | - -## Verification Checklist - -- [ ] `Steer()` injects system message and triggers response -- [ ] `FollowUp()` continues without user message -- [ ] `PromptWithOptions()` accepts per-call system message -- [ ] All methods emit events via EventBus -- [ ] Shared helpers eliminate callback duplication -- [ ] App's `executeStep()` uses SDK (at least for simple paths) diff --git a/plans/06-auth-model-management.md b/plans/06-auth-model-management.md deleted file mode 100644 index bf296511..00000000 --- a/plans/06-auth-model-management.md +++ /dev/null @@ -1,192 +0,0 @@ -# Plan 06: Auth & Model Management APIs - -**Priority**: P2 -**Effort**: Medium -**Goal**: Expose provider management, model validation, and API key handling in the SDK; CLI auth commands consume SDK APIs - -## Background - -Pi exports `AuthStorage`, `ModelRegistry`, `SettingsManager` for programmatic auth/model management. Kit has this internally (`internal/models/registry.go`, `internal/auth/credentials.go`, `internal/models/providers.go`) but none is exposed through the SDK. - -## Prerequisites - -- Plan 00 (Create `pkg/kit/`) -- Plan 02 (Richer type exports) - -## Step-by-Step - -### Step 1: Export model registry functions - -**File**: `pkg/kit/models.go` (new) - -```go -package kit - -import ( - "fmt" - "github.com/mark3labs/kit/internal/models" -) - -// LookupModel returns information about a model, or nil if unknown. -func LookupModel(provider, modelID string) *ModelInfo { - return models.GetGlobalRegistry().LookupModel(provider, modelID) -} - -// GetSupportedProviders returns all known provider names. -func GetSupportedProviders() []string { - return models.GetGlobalRegistry().GetSupportedProviders() -} - -// GetModelsForProvider returns all known models for a provider. -func GetModelsForProvider(provider string) (map[string]ModelInfo, error) { - return models.GetGlobalRegistry().GetModelsForProvider(provider) -} - -// GetProviderInfo returns information about a provider (env vars, API URL, etc.). -func GetProviderInfo(provider string) *ProviderInfo { - return models.GetGlobalRegistry().GetProviderInfo(provider) -} - -// ValidateEnvironment checks if required API keys are set for a provider. -func ValidateEnvironment(provider string, apiKey string) error { - return models.GetGlobalRegistry().ValidateEnvironment(provider, apiKey) -} - -// SuggestModels returns model names similar to an invalid model string. -func SuggestModels(provider, invalidModel string) []string { - return models.GetGlobalRegistry().SuggestModels(provider, invalidModel) -} - -// RefreshModelRegistry reloads the model database from models.dev. -func RefreshModelRegistry() { - models.ReloadGlobalRegistry() -} - -// ParseModelString splits a "provider/model" string into components. -func ParseModelString(modelString string) (provider, model string, err error) { - return models.ParseModelString(modelString) -} - -// CheckProviderReady validates that a provider is properly configured. -func CheckProviderReady(provider string) error { - info := models.GetGlobalRegistry().GetProviderInfo(provider) - if info == nil { - return fmt.Errorf("unknown provider: %s", provider) - } - return models.GetGlobalRegistry().ValidateEnvironment(provider, "") -} -``` - -### Step 2: Add model info to Kit instance - -**File**: `pkg/kit/kit.go` - -```go -// GetModel returns the current model string (e.g., "anthropic/claude-sonnet-4-5-20250929"). -func (m *Kit) GetModel() string { - return m.modelString -} - -// GetModelInfo returns detailed information about the current model. -// Returns nil if the model is not in the registry. -func (m *Kit) GetModelInfo() *ModelInfo { - provider, modelID, err := models.ParseModelString(m.modelString) - if err != nil { - return nil - } - return models.GetGlobalRegistry().LookupModel(provider, modelID) -} -``` - -### Step 3: Export auth credential management - -**File**: `pkg/kit/auth.go` (new) - -```go -package kit - -import "github.com/mark3labs/kit/internal/auth" - -// CredentialManager manages API keys and OAuth credentials. -type CredentialManager = auth.CredentialManager - -// NewCredentialManager creates a credential manager. -func NewCredentialManager() (*CredentialManager, error) { - return auth.NewCredentialManager() -} - -// HasAnthropicCredentials checks if Anthropic credentials are stored. -func HasAnthropicCredentials() bool { - cm, err := auth.NewCredentialManager() - if err != nil { - return false - } - return cm.GetAnthropicCredentials() != nil -} - -// GetAnthropicAPIKey resolves the Anthropic API key using the standard -// resolution order: stored credentials -> ANTHROPIC_API_KEY env var. -func GetAnthropicAPIKey() string { - key, err := auth.GetAnthropicAPIKey("") - if err != nil { - return "" - } - return key -} -``` - -### Step 4: App-as-Consumer — CLI commands use SDK APIs - -Currently CLI commands like `kit models`, `kit update-models`, and provider validation logic directly import `internal/models` and `internal/auth`. They should use `pkg/kit` functions instead. - -**File**: `cmd/root.go` or wherever model validation happens - -```go -// Before: -import "github.com/mark3labs/kit/internal/models" -registry := models.GetGlobalRegistry() -info := registry.LookupModel(provider, model) - -// After: -import kit "github.com/mark3labs/kit/pkg/kit" -info := kit.LookupModel(provider, model) -``` - -**File**: `cmd/` auth-related commands - -```go -// Before: -import "github.com/mark3labs/kit/internal/auth" -cm, _ := auth.NewCredentialManager() - -// After: -import kit "github.com/mark3labs/kit/pkg/kit" -cm, _ := kit.NewCredentialManager() -``` - -Since these are type aliases, existing code continues to work during gradual migration. - -### Step 5: Write tests and verify - -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -go vet ./... -``` - -## Files Changed Summary - -| Action | File | Change | -|--------|------|--------| -| CREATE | `pkg/kit/models.go` | Model registry, parsing, validation, suggestions | -| CREATE | `pkg/kit/auth.go` | Credential management exports | -| EDIT | `pkg/kit/kit.go` | Add GetModel(), GetModelInfo() | -| EDIT | `cmd/` | Migrate to use pkg/kit functions (gradual) | - -## Verification Checklist - -- [ ] `ParseModelString` handles "provider/model" format -- [ ] `GetSupportedProviders` returns provider list -- [ ] `LookupModel` returns info for known models -- [ ] `CheckProviderReady` gives clear error messages -- [ ] CLI commands use SDK functions instead of internal imports diff --git a/plans/07-compaction-apis.md b/plans/07-compaction-apis.md deleted file mode 100644 index b358857f..00000000 --- a/plans/07-compaction-apis.md +++ /dev/null @@ -1,166 +0,0 @@ -# Plan 07: Compaction APIs - -**Priority**: P2 -**Effort**: Medium -**Goal**: Add context window management with token estimation, compaction triggers, and summarization. CLI `--compact` flag should use the SDK. - -## Background - -Pi exports `compact()`, `generateBranchSummary()`, `shouldCompact()`, `calculateContextTokens()`. Kit has no compaction — only `len(text)/4` estimation in `ui/usage_tracker.go:69` for display. This plan adds compaction from scratch, designed SDK-first so the CLI consumes it. - -## Prerequisites - -- Plan 00 (Create `pkg/kit/`) -- Plan 03 (Event subscriber system) -- Plan 04 (Enhanced session management — tree sessions for branch summaries) - -## Step-by-Step - -### Step 1: Create `internal/compaction/` package - -**File**: `internal/compaction/compaction.go` (new) - -```go -package compaction - -// EstimateTokens provides a rough token count (~4 chars per token). -func EstimateTokens(text string) int { - return len(text) / 4 -} - -// EstimateMessageTokens estimates tokens for a slice of fantasy messages. -func EstimateMessageTokens(messages []fantasy.Message) int { ... } - -// ShouldCompact checks if conversation exceeds threshold percentage of limit. -func ShouldCompact(messages []fantasy.Message, contextLimit int, thresholdPct float64) bool { ... } - -// CompactionResult contains statistics from a compaction. -type CompactionResult struct { - Summary string - OriginalTokens int - CompactedTokens int - MessagesRemoved int -} - -// CompactionOptions configures compaction behavior. -type CompactionOptions struct { - ContextLimit int // Model's context window (tokens) - ThresholdPct float64 // Trigger threshold (0.0-1.0), default 0.8 - PreserveRecent int // Recent messages to keep, default 10 - SummaryPrompt string // Custom summary prompt (empty = default) -} - -// FindCutPoint determines where to cut for compaction. -func FindCutPoint(messages []fantasy.Message, preserveRecent int) int { ... } - -// Compact summarizes older messages using the LLM. -func Compact(ctx context.Context, model fantasy.LanguageModel, messages []fantasy.Message, opts CompactionOptions) (*CompactionResult, []fantasy.Message, error) { ... } -``` - -Full implementations as described in the original plan (summarize messages before cut point using LLM, return summary + preserved recent messages). - -### Step 2: Export compaction in SDK - -**File**: `pkg/kit/types.go` — add type aliases: - -```go -type CompactionResult = compaction.CompactionResult -type CompactionOptions = compaction.CompactionOptions -``` - -### Step 3: Add Compact() and context methods to Kit - -**File**: `pkg/kit/kit.go` - -```go -// Compact summarizes older messages to reduce context usage. -func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions) (*CompactionResult, error) { ... } - -// EstimateContextTokens returns estimated token count of current conversation. -func (m *Kit) EstimateContextTokens() int { ... } - -// ShouldCompact checks if conversation is near the context limit. -func (m *Kit) ShouldCompact() bool { ... } - -// ContextStats returns current context usage statistics. -type ContextStats struct { - EstimatedTokens int - ContextLimit int - UsagePercent float64 - MessageCount int -} - -func (m *Kit) GetContextStats() ContextStats { ... } -``` - -### Step 4: Add auto-compaction option - -```go -type Options struct { - // ... existing fields ... - AutoCompact bool // Auto-compact when near limit - CompactionOptions *CompactionOptions // Config for auto-compact -} -``` - -In `Prompt()`, check before generation: -```go -if m.autoCompact && m.ShouldCompact() { - m.Compact(ctx, m.compactionOpts) // best-effort -} -``` - -### Step 5: App-as-Consumer — CLI `--compact` flag uses SDK - -Currently `cmd/root.go` has a `compactMode` flag (line 37) but compaction is not implemented. After this plan: - -**File**: `cmd/root.go` - -```go -// Map --compact flag to SDK option -if compactMode { - kitOpts.AutoCompact = true -} -``` - -The CLI could also expose a `/compact` slash command in interactive mode that calls `kit.Compact()`: - -```go -// In interactive command handler: -case "/compact": - result, err := k.Compact(ctx, nil) - if err != nil { - fmt.Printf("Compaction failed: %v\n", err) - } else { - fmt.Printf("Compacted: %d messages removed, %d -> %d tokens\n", - result.MessagesRemoved, result.OriginalTokens, result.CompactedTokens) - } -``` - -The usage tracker in `internal/ui/usage_tracker.go` should also use `kit.EstimateContextTokens()` instead of its own `len(text)/4` heuristic — single source of truth. - -### Step 6: Write tests and verify - -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -``` - -## Files Changed Summary - -| Action | File | Change | -|--------|------|--------| -| CREATE | `internal/compaction/compaction.go` | Core compaction logic | -| EDIT | `pkg/kit/types.go` | Export CompactionResult, CompactionOptions | -| EDIT | `pkg/kit/kit.go` | Compact(), ShouldCompact(), GetContextStats(), auto-compact | -| EDIT | `cmd/root.go` | Map --compact to SDK option | -| EDIT | `internal/ui/usage_tracker.go` | Use SDK token estimation | - -## Verification Checklist - -- [ ] Token estimation is reasonable -- [ ] `ShouldCompact()` triggers near context limit -- [ ] `Compact()` reduces message count and tokens -- [ ] Auto-compaction triggers before prompts -- [ ] CLI `--compact` flag maps to `kit.Options{AutoCompact: true}` -- [ ] Usage tracker uses SDK estimation diff --git a/plans/08-skills-prompts-system.md b/plans/08-skills-prompts-system.md deleted file mode 100644 index b6eedd68..00000000 --- a/plans/08-skills-prompts-system.md +++ /dev/null @@ -1,133 +0,0 @@ -# Plan 08: Skills & Prompts System - -**Priority**: P2 -**Effort**: Medium -**Goal**: Expose skills loading, prompt templates, and dynamic system prompt management. CLI and SDK share the same skills infrastructure. - -## Background - -Pi exports `loadSkills()`, `formatSkillsForPrompt()`, `PromptTemplate`, `expandPromptTemplate()`. Kit has an extension system but no "skills" concept (markdown-based instruction files) or prompt template system. This plan introduces a skills layer designed SDK-first. - -## Prerequisites - -- Plan 00 (Create `pkg/kit/`) -- Plan 02 (Richer type exports) - -## Step-by-Step - -### Step 1: Create `internal/skills/` package - -**File**: `internal/skills/skills.go` — Skill loading and parsing - -```go -type Skill struct { - Name string - Description string - Content string - Path string - Tags []string - When string // "always", "on-demand", "file:*.go" -} - -func LoadSkill(path string) (*Skill, error) { ... } // Markdown with YAML frontmatter -func LoadSkillsFromDir(dir string) ([]*Skill, error) { ... } // .md/.txt files + SKILL.md subdirs -func LoadSkills(cwd string) ([]*Skill, error) { ... } // Auto-discover .kit/skills/ + ~/.config/kit/skills/ -func FormatForPrompt(skills []*Skill) string { ... } // Format for system prompt -``` - -**File**: `internal/skills/templates.go` — Prompt templates - -```go -type PromptTemplate struct { - Name string - Content string - Variables []string -} - -func NewPromptTemplate(name, content string) *PromptTemplate { ... } -func LoadPromptTemplate(path string) (*PromptTemplate, error) { ... } -func (t *PromptTemplate) Expand(values map[string]string) string { ... } -func (t *PromptTemplate) ExpandStrict(values map[string]string) (string, error) { ... } -``` - -**File**: `internal/skills/prompt_builder.go` — System prompt composition - -```go -type PromptBuilder struct { ... } - -func NewPromptBuilder(basePrompt string) *PromptBuilder { ... } -func (pb *PromptBuilder) WithSkills(skills []*Skill) *PromptBuilder { ... } -func (pb *PromptBuilder) WithSection(name, content string) *PromptBuilder { ... } -func (pb *PromptBuilder) Build() string { ... } -``` - -### Step 2: Export in SDK - -**File**: `pkg/kit/skills.go` (new) - -```go -package kit - -import "github.com/mark3labs/kit/internal/skills" - -type Skill = skills.Skill -type PromptTemplate = skills.PromptTemplate -type PromptBuilder = skills.PromptBuilder - -func LoadSkill(path string) (*Skill, error) { return skills.LoadSkill(path) } -func LoadSkillsFromDir(dir string) ([]*Skill, error) { return skills.LoadSkillsFromDir(dir) } -func LoadSkills(cwd string) ([]*Skill, error) { return skills.LoadSkills(cwd) } -func FormatSkillsForPrompt(s []*Skill) string { return skills.FormatForPrompt(s) } -func NewPromptTemplate(name, content string) *PromptTemplate { return skills.NewPromptTemplate(name, content) } -func LoadPromptTemplate(path string) (*PromptTemplate, error) { return skills.LoadPromptTemplate(path) } -func NewPromptBuilder(basePrompt string) *PromptBuilder { return skills.NewPromptBuilder(basePrompt) } -``` - -### Step 3: Integrate skills into Kit Options - -```go -type Options struct { - // ... existing fields ... - Skills []string // Skill files/dirs to load (empty = auto-discover) - SkillsDir string // Override default skills directory -} -``` - -In `New()`, load skills and compose system prompt via `PromptBuilder`. - -### Step 4: App-as-Consumer — CLI uses SDK for skills - -Currently Kit's extension loader (`internal/extensions/loader.go`) discovers extensions from `.kit/extensions/` and `~/.config/kit/extensions/`. The skills system follows the same pattern but for instruction files. - -The CLI should: -1. Use `kit.LoadSkills(cwd)` to discover skills -2. Pass them via `kit.Options{Skills: ...}` or let auto-discovery handle it -3. A `/skills` slash command in interactive mode could list loaded skills - -The existing `.agents/skills/` directory in the repo (used by btca) aligns with this convention. The SDK auto-discovers from `.kit/skills/` to avoid conflict with the `.agents/` convention used by other tools. - -### Step 5: Write tests and verify - -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -``` - -## Files Changed Summary - -| Action | File | Change | -|--------|------|--------| -| CREATE | `internal/skills/skills.go` | Skill loading/parsing | -| CREATE | `internal/skills/templates.go` | PromptTemplate | -| CREATE | `internal/skills/prompt_builder.go` | System prompt composition | -| CREATE | `pkg/kit/skills.go` | Public SDK exports | -| EDIT | `pkg/kit/kit.go` | Skills option, auto-loading | - -## Verification Checklist - -- [ ] Skills with YAML frontmatter parse correctly -- [ ] Skills without frontmatter load (name from filename) -- [ ] PromptTemplate expansion works -- [ ] PromptBuilder composes multi-section prompts -- [ ] Auto-discovery finds skills in standard directories -- [ ] CLI uses SDK for skill loading diff --git a/plans/09-extension-hook-system.md b/plans/09-extension-hook-system.md deleted file mode 100644 index 6e693e64..00000000 --- a/plans/09-extension-hook-system.md +++ /dev/null @@ -1,275 +0,0 @@ -# Plan 09: Extension Hook System - -**Priority**: P3 -**Effort**: High -**Goal**: Expose Go-native interception hooks in the SDK. The Kit CLI app registers its own extension handlers as SDK hooks, proving the API is complete. - -## Background - -Pi has 20+ lifecycle hooks. Kit already has an internal extension system (`internal/extensions/`) with 13 event types, a `Runner` for dispatch, and tool wrapping. But none of this is accessible through the SDK. - -This plan exposes hooks in the SDK and migrates the app's extension dispatch to use them — making the CLI the proof that the hook API is production-ready. - -## Prerequisites - -- Plan 00 (Create `pkg/kit/`) -- Plan 01 (Export tools — for custom tool registration) -- Plan 02 (Richer type exports) -- Plan 03 (Event subscriber system — observation layer) - -## Design: Events vs Hooks - -| | Events (Plan 03) | Hooks (This Plan) | -|--|------------------|-------------------| -| Purpose | **Observe** | **Intercept** | -| Can block? | No | Yes (BeforeToolCall) | -| Can modify? | No | Yes (AfterToolResult) | -| Pattern | `Subscribe(func(Event))` | `OnBeforeToolCall(func(Hook) *Result)` | -| Priority | N/A | High/Normal/Low ordering | - -Both coexist — events fire regardless; hooks run before/after and can alter execution. - -## Step-by-Step - -### Step 1: Define hook input/result types - -**File**: `pkg/kit/hooks.go` (new) - -```go -package kit - -type HookPriority int - -const ( - HookPriorityHigh HookPriority = 0 - HookPriorityNormal HookPriority = 50 - HookPriorityLow HookPriority = 100 -) - -// BeforeToolCall — can block tool execution -type BeforeToolCallHook struct { - ToolName string - ToolArgs string -} -type BeforeToolCallResult struct { - Block bool - Reason string -} - -// AfterToolResult — can modify tool output -type AfterToolResultHook struct { - ToolName string - ToolArgs string - Result string - IsError bool -} -type AfterToolResultResult struct { - Result *string // non-nil overrides - IsError *bool // non-nil overrides -} - -// BeforeTurn — can modify prompt, inject context -type BeforeTurnHook struct { - Prompt string -} -type BeforeTurnResult struct { - Prompt *string // override prompt - SystemPrompt *string // prepend system message - InjectText *string // prepend user message (context) -} - -// AfterTurn — observe completion -type AfterTurnHook struct { - Response string - Error error -} -``` - -### Step 2: Implement generic hook registry with priority ordering - -```go -type hookRegistry[In any, Out any] struct { - mu sync.RWMutex - hooks []hookEntry[In, Out] - next int -} - -type hookEntry[In any, Out any] struct { - id int - priority HookPriority - handler func(In) *Out -} - -func (hr *hookRegistry[In, Out]) register(p HookPriority, h func(In) *Out) func() { ... } -func (hr *hookRegistry[In, Out]) run(input In) *Out { ... } // first non-nil result wins -``` - -### Step 3: Add registries to Kit struct and expose registration methods - -```go -type Kit struct { - // ... existing fields ... - beforeToolCall *hookRegistry[BeforeToolCallHook, BeforeToolCallResult] - afterToolResult *hookRegistry[AfterToolResultHook, AfterToolResultResult] - beforeTurn *hookRegistry[BeforeTurnHook, BeforeTurnResult] - afterTurn *hookRegistry[AfterTurnHook, struct{}] -} - -func (m *Kit) OnBeforeToolCall(p HookPriority, h func(BeforeToolCallHook) *BeforeToolCallResult) func() { ... } -func (m *Kit) OnAfterToolResult(p HookPriority, h func(AfterToolResultHook) *AfterToolResultResult) func() { ... } -func (m *Kit) OnBeforeTurn(p HookPriority, h func(BeforeTurnHook) *BeforeTurnResult) func() { ... } -func (m *Kit) OnAfterTurn(p HookPriority, h func(AfterTurnHook)) func() { ... } -``` - -### Step 4: Wire hooks into Prompt flow - -In `Prompt()`: -1. Run `beforeTurn` hooks — can modify prompt, inject system/context messages -2. Wrap tools with `hookedTool` that runs `beforeToolCall` (can block) and `afterToolResult` (can modify) -3. Run `afterTurn` hooks after generation - -### Step 5: Tool wrapping via hooks - -```go -type hookedTool struct { - inner fantasy.AgentTool - kit *Kit -} - -func (h *hookedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { - // 1. BeforeToolCall hook — can block - result := h.kit.beforeToolCall.run(BeforeToolCallHook{...}) - if result != nil && result.Block { return error } - - // 2. Execute actual tool - resp, err := h.inner.Run(ctx, call) - - // 3. AfterToolResult hook — can modify - after := h.kit.afterToolResult.run(AfterToolResultHook{...}) - if after != nil { /* apply overrides */ } - - return resp, err -} -``` - -The hook wrapper composes with the existing extension wrapper: -```go -// Extension wrapper runs first (inner), SDK hooks run outside (outer) -tools = extensionWrapper(tools) // extensions wrap -tools = m.wrapToolsWithHooks(tools) // SDK hooks wrap on top -``` - -### Step 6: App-as-Consumer — Extension system registers as SDK hooks - -This is the payoff step. The app's extension `Runner` currently dispatches events directly in `internal/app/app.go:executeStep()`. After this plan, extensions register as SDK hooks during initialization: - -**File**: `pkg/kit/setup.go` or a new `pkg/kit/extensions_bridge.go` - -```go -// bridgeExtensions registers extension handlers as SDK hooks. -// This makes the extension system a consumer of the SDK hook API. -func (m *Kit) bridgeExtensions(runner *extensions.Runner) { - // Extension BeforeAgentStart → SDK BeforeTurn hook - if runner.HasHandlers(extensions.BeforeAgentStart) { - m.OnBeforeTurn(HookPriorityNormal, func(h BeforeTurnHook) *BeforeTurnResult { - result, _ := runner.Emit(extensions.BeforeAgentStartEvent{Prompt: h.Prompt}) - if r, ok := result.(extensions.BeforeAgentStartResult); ok { - return &BeforeTurnResult{ - SystemPrompt: r.SystemPrompt, - InjectText: r.InjectText, - } - } - return nil - }) - } - - // Extension Input → SDK BeforeTurn hook (higher priority, runs first) - if runner.HasHandlers(extensions.Input) { - m.OnBeforeTurn(HookPriorityHigh, func(h BeforeTurnHook) *BeforeTurnResult { - result, _ := runner.Emit(extensions.InputEvent{Text: h.Prompt}) - if r, ok := result.(extensions.InputResult); ok { - if r.Action == "transform" { - return &BeforeTurnResult{Prompt: &r.Text} - } - } - return nil - }) - } - - // Extension ToolCall → SDK BeforeToolCall hook - // (Already handled by extensions.WrapToolsWithExtensions, but could also - // be bridged here for SDK-only consumers) -} -``` - -Called during `Kit.New()`: -```go -if setupResult.ExtRunner != nil { - k.bridgeExtensions(setupResult.ExtRunner) -} -``` - -**Migration path**: -1. **Phase 1** (this plan): Bridge existing extensions as SDK hooks -2. **Phase 2** (future): `executeStep()` in app.go uses only SDK hooks, removes direct runner calls -3. **Phase 3** (future): Extension runner emits SDK events/hooks natively instead of its own types - -### Step 7: Custom tool registration via Options - -```go -type Options struct { - // ... existing fields ... - ExtraTools []Tool // Additional tools for the agent -} -``` - -### Step 8: Write tests and verify - -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -``` - -## Files Changed Summary - -| Action | File | Change | -|--------|------|--------| -| CREATE | `pkg/kit/hooks.go` | Hook types, registry, registration methods | -| EDIT | `pkg/kit/kit.go` | Hook registries, tool wrapper, Prompt hook invocation | -| CREATE | `pkg/kit/extensions_bridge.go` | Bridge extension events to SDK hooks | -| EDIT | `internal/app/app.go` | Gradual migration to use SDK hooks | - -## API Surface After This Plan - -```go -// Block dangerous tool calls -k.OnBeforeToolCall(kit.HookPriorityHigh, func(h kit.BeforeToolCallHook) *kit.BeforeToolCallResult { - if h.ToolName == "bash" && isDangerous(h.ToolArgs) { - return &kit.BeforeToolCallResult{Block: true, Reason: "dangerous"} - } - return nil -}) - -// Modify tool results -k.OnAfterToolResult(kit.HookPriorityNormal, func(h kit.AfterToolResultHook) *kit.AfterToolResultResult { - sanitized := redact(h.Result) - return &kit.AfterToolResultResult{Result: &sanitized} -}) - -// Inject context before each turn -k.OnBeforeTurn(kit.HookPriorityNormal, func(h kit.BeforeTurnHook) *kit.BeforeTurnResult { - ctx := loadProjectContext() - return &kit.BeforeTurnResult{InjectText: &ctx} -}) -``` - -## Verification Checklist - -- [ ] BeforeToolCall hooks can block tool calls -- [ ] AfterToolResult hooks can modify results -- [ ] BeforeTurn hooks can modify prompts and inject context -- [ ] Priority ordering works correctly -- [ ] Unregister removes hooks -- [ ] Extension system bridges to SDK hooks -- [ ] Hooks compose with existing extension wrapper -- [ ] Thread-safe under concurrent access diff --git a/plans/10-app-as-sdk-consumer.md b/plans/10-app-as-sdk-consumer.md deleted file mode 100644 index e4d688b6..00000000 --- a/plans/10-app-as-sdk-consumer.md +++ /dev/null @@ -1,714 +0,0 @@ -# Plan 10: App-as-SDK-Consumer — Complete Integration - -**Priority**: P4 -**Effort**: High -**Goal**: Make the CLI app a full consumer of the SDK. `cmd/root.go` creates a `*Kit` via `kit.New()`. The app receives `*Kit`, calls `kit.PromptResult()`, subscribes to SDK events for TUI rendering, and extension observation events route through the SDK EventBus. This closes all deferred work from Plans 03, 05, and 09. - -## Background - -Plans 00–09 built the SDK surface (`pkg/kit/`) but the CLI app still bypasses it for the critical path: - -- `cmd/root.go` calls `SetupAgent()` directly instead of `kit.New()` -- `internal/app/app.go:executeStep()` calls `agent.GenerateWithLoopAndStreaming()` directly with 150+ lines of manual callback wiring, extension event dispatch, and session persistence — all of which the SDK already handles in `runTurn()` -- Extension observation events (AgentStart, AgentEnd, MessageStart, MessageUpdate, MessageEnd) are emitted from `executeStep()`, not from the SDK -- The app receives an `AgentRunner` interface, not a `*Kit` - -After this plan, `executeStep()` becomes a thin wrapper around `kit.PromptResult()`, and extension events flow through the SDK's EventBus. - -### Deferred Items Resolved - -| Source | What | How | -|--------|------|-----| -| Plan 03 Step 6 | App TUI subscribes to SDK events | Step 5 | -| Plan 03 Step 7 | Extension observation events forward to SDK EventBus | Step 4 | -| Plan 05 Step 6 | `executeStep()` delegates to SDK `Prompt()` | Step 6 | -| Plan 09 Phase 2 | App uses only SDK hooks, no direct runner calls | Step 6 | - -## Prerequisites - -- Plans 00–09 (all complete) - -## Step-by-Step - -### Step 1: Extend Kit for CLI consumption - -**Files**: `pkg/kit/kit.go`, `pkg/kit/setup.go` - -The CLI needs fields that the programmatic SDK doesn't: spinner for Ollama loading, buffered debug logger, pre-loaded MCP config. Add these to `Options` and expose results via getters. - -**1a. Add CLI fields to `Options`** (`pkg/kit/kit.go:48-71`): - -```go -type Options struct { - // ... existing fields ... - - // CLI-specific fields (ignored by programmatic SDK users) - MCPConfig *config.Config // Pre-loaded MCP config (skips LoadAndValidateConfig if set) - ShowSpinner bool // Show loading spinner for Ollama models - SpinnerFunc agent.SpinnerFunc // Spinner implementation (nil = no spinner) - UseBufferedLogger bool // Buffer debug messages for later display - Debug bool // Enable debug logging -} -``` - -**1b. Add fields and getters to `Kit` struct** (`pkg/kit/kit.go:22-36`): - -```go -type Kit struct { - // ... existing fields ... - extRunner *extensions.Runner - bufferedLogger *tools.BufferedDebugLogger -} -``` - -Getters: - -```go -// GetExtRunner returns the extension runner (nil if extensions are disabled). -func (m *Kit) GetExtRunner() *extensions.Runner { return m.extRunner } - -// GetBufferedLogger returns the buffered debug logger (nil if not configured). -func (m *Kit) GetBufferedLogger() *tools.BufferedDebugLogger { return m.bufferedLogger } - -// GetAgent returns the underlying agent. Callers that need the raw agent -// (e.g. for GetTools(), GetLoadingMessage()) can use this. -func (m *Kit) GetAgent() *agent.Agent { return m.agent } - -// GetTreeSession returns the current tree session manager. -// (Already exists as a method — verify it's public.) -``` - -**1c. Update `New()`** (`pkg/kit/kit.go:111-204`): - -- If `opts.MCPConfig != nil`, skip `config.LoadAndValidateConfig()` and use it directly -- If `opts.Debug`, set `viper.Set("debug", true)` -- Pass `ShowSpinner`, `SpinnerFunc`, `UseBufferedLogger` through to `SetupAgent()` -- Store `agentResult.ExtRunner` and `agentResult.BufferedLogger` on the Kit struct - -```go -// In New(), replace lines 152-176: -mcpConfig := opts.MCPConfig -if mcpConfig == nil { - var err error - mcpConfig, err = config.LoadAndValidateConfig() - if err != nil { - return nil, fmt.Errorf("failed to load MCP config: %w", err) - } -} - -agentResult, err := SetupAgent(ctx, AgentSetupOptions{ - MCPConfig: mcpConfig, - Quiet: opts.Quiet, - ShowSpinner: opts.ShowSpinner, - SpinnerFunc: opts.SpinnerFunc, - UseBufferedLogger: opts.UseBufferedLogger, - CoreTools: opts.Tools, - ExtraTools: opts.ExtraTools, - ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult), -}) - -// Store on Kit struct: -k := &Kit{ - // ... existing fields ... - extRunner: agentResult.ExtRunner, - bufferedLogger: agentResult.BufferedLogger, -} -``` - -**Verification**: -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -golangci-lint run ./... -``` - -Existing behavior is unchanged — the new fields default to zero values. - ---- - -### Step 2: Add TurnResult and PromptResult method - -**File**: `pkg/kit/kit.go` - -The current `Prompt()` returns `(string, error)`, which is fine for simple SDK usage but the app needs usage stats and conversation messages. Add a richer return path. - -**2a. Define TurnResult** (new, in `pkg/kit/kit.go`): - -```go -// TurnResult contains the full result of a prompt turn, including usage -// statistics and the updated conversation. Use PromptResult() instead of -// Prompt() when you need access to this data. -type TurnResult struct { - // Response is the assistant's final text response. - Response string - - // TotalUsage is the aggregate token usage across all steps in the turn - // (includes tool-calling loop iterations). Nil if the provider didn't - // report usage. - TotalUsage *FantasyUsage - - // FinalUsage is the token usage from the last API call only. Use this - // for context window fill estimation (InputTokens + OutputTokens ≈ - // current context size). Nil if unavailable. - FinalUsage *FantasyUsage - - // Messages is the full updated conversation after the turn, including - // any tool call/result messages added during the agent loop. - Messages []FantasyMessage -} -``` - -**2b. Modify `runTurn()` to return `*TurnResult`** (`pkg/kit/kit.go:319`): - -Change signature from: -```go -func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, preMessages []fantasy.Message) (string, error) -``` -To: -```go -func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, preMessages []fantasy.Message) (*TurnResult, error) -``` - -Build and return `TurnResult` from the `agent.GenerateWithLoopResult`: - -```go -responseText := result.FinalResponse.Content.Text() - -turnResult := &TurnResult{ - Response: responseText, - Messages: result.ConversationMessages, -} -if result.TotalUsage != nil { - turnResult.TotalUsage = result.TotalUsage -} -if result.FinalResponse != nil { - turnResult.FinalUsage = &result.FinalResponse.Usage -} - -// ... existing event emission and persistence ... - -return turnResult, nil -``` - -On the error path, return `nil, err` (as before, but with `*TurnResult` instead of `""`). - -**2c. Update all prompt methods** to extract the string from `TurnResult`: - -```go -func (m *Kit) Prompt(ctx context.Context, message string) (string, error) { - result, err := m.runTurn(ctx, message, message, []fantasy.Message{ - fantasy.NewUserMessage(message), - }) - if err != nil { - return "", err - } - return result.Response, nil -} -``` - -Same pattern for `Steer()`, `FollowUp()`, `PromptWithOptions()`, `PromptWithCallbacks()`. - -**2d. Add `PromptResult()` method**: - -```go -// PromptResult sends a message and returns the full turn result including -// usage statistics and conversation messages. Use this instead of Prompt() -// when you need more than just the response text. -func (m *Kit) PromptResult(ctx context.Context, message string) (*TurnResult, error) { - return m.runTurn(ctx, message, message, []fantasy.Message{ - fantasy.NewUserMessage(message), - }) -} -``` - -**Verification**: -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -golangci-lint run ./... -``` - -Existing `Prompt()` callers (examples, tests) are unaffected. - ---- - -### Step 3: Migrate cmd/root.go to use kit.New() - -**Files**: `cmd/root.go`, `cmd/setup.go` - -Replace the manual `SetupAgent()` → `InitTreeSession()` → `BuildAppOptions()` chain with a single `kit.New()` call. - -**3a. Replace agent creation** in `runNormalMode()` (`cmd/root.go:336-362`): - -Before: -```go -agentResult, err := SetupAgent(ctx, AgentSetupOptions{...}) -mcpAgent := agentResult.Agent -defer func() { _ = mcpAgent.Close() }() -provider, modelName, serverNames, toolNames := CollectAgentMetadata(mcpAgent, mcpConfig) -``` - -After: -```go -// Build Kit options from CLI flags. -kitOpts := &kit.Options{ - MCPConfig: mcpConfig, - ShowSpinner: true, - SpinnerFunc: spinnerFunc, - UseBufferedLogger: true, - Quiet: quietFlag, - Debug: debugMode, - NoSession: noSessionFlag, - Continue: continueFlag, - SessionPath: sessionPath, - AutoCompact: autoCompactFlag, -} -if resumeFlag { - sessions, _ := kit.ListSessions("") - if len(sessions) > 0 { - kitOpts.SessionPath = sessions[0].Path - } -} - -kitInstance, err := kit.New(ctx, kitOpts) -if err != nil { - return err -} -defer kitInstance.Close() -``` - -**3b. Extract metadata from Kit instead of raw agent**: - -```go -mcpAgent := kitInstance.GetAgent() -provider, modelName, serverNames, toolNames := CollectAgentMetadata(mcpAgent, mcpConfig) -``` - -**3c. Get buffered logger and tree session from Kit**: - -```go -bufferedLogger := kitInstance.GetBufferedLogger() -// ... display buffered debug messages ... - -treeSession := kitInstance.GetTreeSession() -var messages []fantasy.Message -if treeSession != nil { - messages = treeSession.GetFantasyMessages() -} -``` - -**3d. Build app options using Kit**: - -```go -appOpts := BuildAppOptions(mcpAgent, mcpConfig, modelName, serverNames, toolNames, kitInstance.GetExtRunner()) -appOpts.TreeSession = treeSession -appOpts.Kit = kitInstance // NEW — added in Step 5 -``` - -**3e. Extension context setup** — use Kit's extension runner: - -```go -extRunner := kitInstance.GetExtRunner() -if extRunner != nil { - extRunner.SetContext(extensions.Context{...}) - // Emit SessionStart -} -``` - -**3f. Remove the separate `kit.InitTreeSession()` call** — Kit.New() handles session creation. - -**3g. Remove the `defer func() { _ = mcpAgent.Close() }()`** — `kitInstance.Close()` handles cleanup. - -**Verification**: -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -golangci-lint run ./... -# Manual: run `kit -p "hello"` to verify non-interactive mode -# Manual: run `kit` to verify interactive mode -``` - -The app still uses its own `executeStep()` at this point — that migrates in Step 6. - ---- - -### Step 4: Bridge extension observation events through SDK EventBus - -**File**: `pkg/kit/extensions_bridge.go` - -Currently `bridgeExtensions()` only bridges `Input` and `BeforeAgentStart` (hook events). The observation events (AgentStart, AgentEnd, MessageStart, MessageUpdate, MessageEnd) are emitted from `app.executeStep()` directly to the extension runner. After this step, the SDK emits them from `runTurn()`/`generate()` and the bridge forwards to extensions. - -**4a. Subscribe to SDK events and forward to extension runner**: - -Add to `bridgeExtensions()` (`pkg/kit/extensions_bridge.go:16`): - -```go -func (m *Kit) bridgeExtensions(runner *extensions.Runner) { - // ... existing Input and BeforeAgentStart hooks ... - - // Forward SDK observation events to extension runner. - // These events are emitted by runTurn()/generate() and forwarded here - // so extensions see them without the app having to emit them manually. - - if runner.HasHandlers(extensions.AgentStart) { - m.Subscribe(func(e Event) { - if ev, ok := e.(TurnStartEvent); ok { - runner.Emit(extensions.AgentStartEvent{Prompt: ev.Prompt}) - } - }) - } - - if runner.HasHandlers(extensions.MessageStart) { - m.Subscribe(func(e Event) { - if _, ok := e.(MessageStartEvent); ok { - runner.Emit(extensions.MessageStartEvent{}) - } - }) - } - - if runner.HasHandlers(extensions.MessageUpdate) { - m.Subscribe(func(e Event) { - if ev, ok := e.(MessageUpdateEvent); ok { - runner.Emit(extensions.MessageUpdateEvent{Chunk: ev.Chunk}) - } - }) - } - - if runner.HasHandlers(extensions.MessageEnd) { - m.Subscribe(func(e Event) { - if ev, ok := e.(MessageEndEvent); ok { - runner.Emit(extensions.MessageEndEvent{Content: ev.Content}) - } - }) - } - - if runner.HasHandlers(extensions.AgentEnd) { - m.Subscribe(func(e Event) { - if ev, ok := e.(TurnEndEvent); ok { - stopReason := "completed" - response := ev.Response - if ev.Error != nil { - stopReason = "error" - response = "" - } - runner.Emit(extensions.AgentEndEvent{ - Response: response, - StopReason: stopReason, - }) - } - }) - } -} -``` - -**4b. Add SessionShutdown to Kit.Close()**: - -In `pkg/kit/kit.go:Close()`: - -```go -func (m *Kit) Close() error { - // Emit SessionShutdown for extensions. - if m.extRunner != nil && m.extRunner.HasHandlers(extensions.SessionShutdown) { - m.extRunner.Emit(extensions.SessionShutdownEvent{}) - } - if m.treeSession != nil { - _ = m.treeSession.Close() - } - return m.agent.Close() -} -``` - -**Verification**: -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -golangci-lint run ./... -``` - -At this point, extension observation events will fire from BOTH `executeStep()` (app) and the SDK bridge. This is intentional for the transition — Step 6 removes the app-side emission. - ---- - -### Step 5: Wire app to Kit — add Kit field and SDK event → tea.Msg bridge - -**Files**: `internal/app/options.go`, `internal/app/app.go` - -Give the app a `*Kit` reference so it can call SDK prompt methods and subscribe to events. - -**5a. Add Kit field to `app.Options`** (`internal/app/options.go:50`): - -```go -import kit "github.com/mark3labs/kit/pkg/kit" - -type Options struct { - // Kit is the SDK instance. When set, executeStep() delegates to - // kit.PromptResult() and events flow through SDK subscriptions. - Kit *kit.Kit - - // Agent is the agent used to run the agentic loop. Required when Kit - // is nil. When Kit is set, this field is ignored (Kit owns the agent). - Agent AgentRunner - - // ... rest unchanged ... -} -``` - -**5b. Create SDK event → tea.Msg bridge function** (`internal/app/app.go`): - -```go -// subscribeSDKEvents registers temporary SDK event subscribers that convert -// SDK events to tea.Msg events and dispatch them via sendFn. Returns an -// unsubscribe function that removes all listeners. -func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() { - k := a.opts.Kit - var unsubs []func() - - unsubs = append(unsubs, k.Subscribe(func(e kit.Event) { - switch ev := e.(type) { - case kit.ToolCallEvent: - sendFn(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs}) - case kit.ToolExecutionStartEvent: - sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: true}) - case kit.ToolExecutionEndEvent: - sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: false}) - case kit.ToolResultEvent: - sendFn(ToolResultEvent{ - ToolName: ev.ToolName, ToolArgs: ev.ToolArgs, - Result: ev.Result, IsError: ev.IsError, - }) - case kit.ToolCallContentEvent: - sendFn(ToolCallContentEvent{Content: ev.Content}) - case kit.ResponseEvent: - sendFn(ResponseCompleteEvent{Content: ev.Content}) - case kit.MessageUpdateEvent: - sendFn(StreamChunkEvent{Content: ev.Chunk}) - } - })) - - return func() { - for _, unsub := range unsubs { - unsub() - } - } -} -``` - -**5c. Pass Kit in `cmd/root.go`**: - -In the `BuildAppOptions` call or directly after: - -```go -appOpts.Kit = kitInstance -``` - -**Verification**: -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -golangci-lint run ./... -``` - -The bridge function exists but is not called yet. Step 6 wires it in. - ---- - -### Step 6: Migrate executeStep() to use kit.PromptResult() - -**File**: `internal/app/app.go` - -Replace the 150+ line `executeStep()` with a thin wrapper around `kit.PromptResult()`. - -**6a. Rewrite executeStep()**: - -The new `executeStep()` when `opts.Kit` is set: - -```go -func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg)) (*agent.GenerateWithLoopResult, error) { - if a.opts.Kit == nil { - return a.executeStepLegacy(ctx, prompt, eventFn) - } - - sendFn := func(msg tea.Msg) { - if eventFn != nil { - eventFn(msg) - } - } - - // Subscribe to SDK events for TUI rendering. The subscription is - // temporary — it lives only for the duration of this step. - unsub := a.subscribeSDKEvents(sendFn) - defer unsub() - - // Show spinner while the agent works. - sendFn(SpinnerEvent{Show: true}) - - result, err := a.opts.Kit.PromptResult(ctx, prompt) - if err != nil { - return nil, err - } - - // Sync in-memory store with the SDK's authoritative conversation. - a.store.Replace(result.Messages) - - // Update usage tracker. - a.updateUsageFromTurnResult(result, prompt) - - return &agent.GenerateWithLoopResult{ - ConversationMessages: result.Messages, - }, nil -} -``` - -**6b. Rename existing executeStep to executeStepLegacy**: - -Keep the old implementation as `executeStepLegacy()` so the transition is safe. It remains as a fallback when `opts.Kit == nil` (e.g. in tests that supply a stub `AgentRunner`). - -**6c. Add `updateUsageFromTurnResult` helper**: - -```go -func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string) { - if a.opts.UsageTracker == nil || result == nil { - return - } - - if result.TotalUsage != nil { - inputTokens := int(result.TotalUsage.InputTokens) - outputTokens := int(result.TotalUsage.OutputTokens) - if inputTokens > 0 && outputTokens > 0 { - cacheReadTokens := int(result.TotalUsage.CacheReadTokens) - cacheWriteTokens := int(result.TotalUsage.CacheCreationTokens) - a.opts.UsageTracker.UpdateUsage(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens) - } else { - a.opts.UsageTracker.EstimateAndUpdateUsage(userPrompt, result.Response) - return - } - } - - if result.FinalUsage != nil { - if ct := int(result.FinalUsage.InputTokens) + int(result.FinalUsage.OutputTokens); ct > 0 { - a.opts.UsageTracker.SetContextTokens(ct) - } - } -} -``` - -**6d. Remove extension event emission from `executeStepLegacy()`**: - -Since the SDK bridge (Step 4) now forwards extension observation events, remove these direct calls from `executeStepLegacy()`: -- `extensions.AgentStart` emission (line 432-434) -- `extensions.MessageStart` emission (line 440-442) -- `extensions.MessageUpdate` emission (line 473-475) -- `extensions.MessageEnd` emission (line 496-498) -- `extensions.AgentEnd` emission (lines 482-487, 501-506) - -The `Input` and `BeforeAgentStart` extensions are already handled by the SDK hooks (bridged in Plan 09). Remove those too from `executeStepLegacy()`: -- `extensions.Input` emission (lines 372-387) -- `extensions.BeforeAgentStart` emission (lines 414-429) - -What remains in `executeStepLegacy()` is just the core generation call — which is now essentially the same as calling `kit.PromptResult()`. - -**6e. Remove SessionShutdown from `app.Close()`**: - -Since `Kit.Close()` now handles SessionShutdown (Step 4b), remove: - -```go -// In app.Close() — remove: -if a.opts.Extensions != nil && a.opts.Extensions.HasHandlers(extensions.SessionShutdown) { - _, _ = a.opts.Extensions.Emit(extensions.SessionShutdownEvent{}) -} -``` - -**Verification**: -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -golangci-lint run ./... -# Manual: run `kit -p "list files in the current directory"` — verify tool calls render -# Manual: run `kit` in interactive mode — verify streaming, tool results, spinner -# Manual: create a .kit/extensions/ extension with AgentStart handler — verify it fires -``` - ---- - -### Step 7: Clean up dead code - -**Files**: `internal/app/app.go`, `internal/app/options.go`, `internal/app/events.go`, `cmd/setup.go` - -**7a. Remove `executeStepLegacy()`**: - -Once confident the SDK path works, delete `executeStepLegacy()` entirely. Update `executeStep()` to remove the `if a.opts.Kit == nil` guard. - -**7b. Remove `AgentRunner` interface**: - -`internal/app/options.go:17-28` — delete `AgentRunner`. The `Agent AgentRunner` field is no longer used when `Kit` is set. Remove the `Agent` field from `Options`. - -**7c. Remove `Extensions` field from `app.Options`**: - -`internal/app/options.go:94-98` — the app no longer calls `a.opts.Extensions.Emit()` directly. Extension dispatch goes through SDK hooks/events. Remove the field and all `a.opts.Extensions` references in `app.go`. - -**7d. Simplify `BuildAppOptions()` in `cmd/setup.go`**: - -Remove the `mcpAgent` and `extRunner` parameters since the app gets these from `Kit`: - -```go -func BuildAppOptions(kitInstance *kit.Kit, mcpConfig *config.Config, - modelName string, serverNames, toolNames []string) app.Options { - return app.Options{ - Kit: kitInstance, - MCPConfig: mcpConfig, - ModelName: modelName, - ServerNames: serverNames, - ToolNames: toolNames, - StreamingEnabled: viper.GetBool("stream"), - Quiet: quietFlag, - Debug: viper.GetBool("debug"), - CompactMode: viper.GetBool("compact"), - } -} -``` - -**7e. Remove `updateUsage()` from `app.go`** (`app.go:596-627`): - -Replaced by `updateUsageFromTurnResult()` which works with `TurnResult` instead of raw `GenerateWithLoopResult`. - -**7f. Simplify `SessionStart` emission**: - -Move SessionStart from `cmd/root.go:448` into `Kit.New()` or a new `Kit.EmitSessionStart()` method called by the CLI after extension context is configured. - -**7g. Remove `inputSource()` helper** (`app.go:524-532`): - -Only used by the now-removed Input extension emission. - -**7h. Run final verification**: - -```bash -go build -o output/kit ./cmd/kit -go test -race ./... -golangci-lint run ./... -``` - -Confirm no references to removed types/functions. Confirm no unused imports. - ---- - -## Verification Checklist - -- [ ] `go build -o output/kit ./cmd/kit` succeeds -- [ ] `go test -race ./...` passes -- [ ] `golangci-lint run ./...` — 0 issues -- [ ] `kit.New()` creates agent, session, extensions in one call -- [ ] `cmd/root.go` no longer calls `SetupAgent()` directly -- [ ] `executeStep()` delegates to `kit.PromptResult()` -- [ ] SDK events drive TUI rendering (tool calls, streaming, results) -- [ ] Extension observation events (AgentStart/End, MessageStart/Update/End) fire via SDK bridge -- [ ] Extension interception events (Input, BeforeAgentStart, ToolCall, ToolResult) still work -- [ ] Usage tracker receives correct token counts -- [ ] Session persistence works (tree session) -- [ ] `--continue` / `--no-session` / `--session` flags work -- [ ] Spinner shows/hides correctly -- [ ] Interactive mode (BubbleTea) works -- [ ] Non-interactive mode (`-p "..."`) works -- [ ] Extension SessionShutdown fires on close -- [ ] No remaining direct `extensions.Emit()` calls in `app.go` -- [ ] `AgentRunner` interface removed -- [ ] `app.Options.Extensions` field removed diff --git a/plans/README.md b/plans/README.md deleted file mode 100644 index 1ee2c7e9..00000000 --- a/plans/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# SDK Revamp Plans - -## Core Architectural Principle - -**The Kit CLI app is the primary consumer of the SDK.** - -The SDK is not a thin wrapper for external users. The CLI is built on top of it: - -1. `pkg/kit/` defines the canonical API for agents, sessions, events, and hooks -2. `cmd/` parses CLI flags, maps them to `kit.Options`, and calls `kit.New()` -3. `internal/app/` subscribes to SDK events for TUI rendering and uses SDK prompt methods -4. If the app needs a capability, it is added to the SDK first, then consumed by the app -5. External users get the exact same API the CLI uses - -### Architecture - -``` -cmd/kit/main.go - | - v -cmd/ Parses flags, maps to kit.Options - | - v -pkg/kit/ Canonical SDK: New(), Prompt(), Subscribe(), hooks - | - +---> internal/agent/ Agent creation, generation loop - +---> internal/session/ Session persistence, tree manager - +---> internal/config/ Config loading, MCP server config - +---> internal/core/ Built-in tools (read, write, bash, etc.) - +---> internal/models/ Provider registry, model validation - +---> internal/auth/ Credential management, OAuth - +---> internal/compaction/ Context summarization (Plan 07) - +---> internal/skills/ Skill loading, templates (Plan 08) - +---> internal/extensions/ Yaegi extension runtime - -internal/app/ TUI/interactive mode — subscribes to SDK events - | - +---> pkg/kit/ Uses SDK for prompts, sessions, tools - +---> internal/ui/ Owns BubbleTea rendering only -``` - -**No circular dependencies.** `pkg/kit/` never imports `cmd/`. `cmd/` imports `pkg/kit/`. - -### Before vs After - -| Concern | Before (Parallel) | After (SDK-First) | -|---------|-------------------|-------------------| -| Config init | `cmd.InitConfig()` called by both CLI and SDK | `kit.InitConfig()` in `pkg/kit/`, `cmd/` delegates | -| Agent creation | `cmd.SetupAgent()` called by both | `kit.SetupAgent()` in `pkg/kit/`, `cmd/` delegates | -| Session setup | `cmd/root.go` has 80-line if/else chain | `kit.Options{Continue: true}`, SDK handles it | -| Events | 3 parallel systems (SDK callbacks, extension events, TUI msgs) | Single SDK EventBus, TUI bridges via `Subscribe()` | -| Tool exposure | Internal only | `kit.AllTools()`, `kit.NewReadTool(kit.WithWorkDir(...))` | -| Hooks | Only via Yaegi extensions | `kit.OnBeforeToolCall()` — extensions bridge to SDK hooks | - -## Plan Execution Order - -| Plan | Priority | Description | Depends On | -|------|----------|-------------|------------| -| **00** | P0 | Create `pkg/kit/`, extract init from `cmd/` | None | -| **01** | P0 | Export tools and tool factories | 00 | -| **02** | P0 | Richer type exports (40+ types) | 00 | -| **03** | P1 | Unified event/subscriber system (core done; app/ext bridge deferred) | 00, 02 | -| **04** | P1 | Enhanced session management | 00, 02 | -| **05** | P1 | Additional prompt modes (Steer, FollowUp) | 00, 03 | -| **06** | P2 | Auth & model management APIs | 00, 02 | -| **07** | P2 | Compaction APIs | 00, 03, 04 | -| **08** | P2 | Skills & prompts system | 00, 02 | -| **09** | P3 | Extension hook system | 00, 01, 02, 03 | -| **10** | P4 | App-as-SDK-consumer — complete integration | 00–09 | - -### Recommended Batches - -**Batch 1 — Foundation** (Plans 00, 01, 02): -Restructure package, expose tools and types. SDK is usable for basic programmatic access. CLI starts delegating to SDK. - -**Batch 2 — Rich Interaction** (Plans 03, 04, 05): -Unified events, sessions, prompt modes. App migrates to SDK for event handling and session setup. - -**Batch 3 — Management** (Plans 06, 07, 08): -Auth, compaction, skills. CLI commands use SDK functions. - -**Batch 4 — Extensibility** (Plan 09): -Hook system with extension bridge. App's extension dispatch routes through SDK hooks. - -**Batch 5 — Full Integration** (Plan 10): -CLI uses `kit.New()`, app calls `kit.PromptResult()`, extension events route through SDK EventBus. Closes all deferred items from Plans 03, 05, 09. Removes `AgentRunner` interface, `app.Options.Extensions`, and legacy `executeStep` code. - -## Parity with Pi SDK - -After all plans: - -| Capability | Pi | Kit (After) | -|-----------|-----|-------------| -| Top-level package imports | Yes | `pkg/kit/` | -| Tool exports + factories | Yes | Plan 01 | -| Rich type surface (50+) | Yes | Plan 02 | -| Event subscriber system | Yes | Plan 03 | -| Session management (list/continue/branch) | Yes | Plan 04 | -| Multiple prompt modes | Yes | Plan 05 | -| Auth/model management | Yes | Plan 06 | -| Compaction APIs | Yes | Plan 07 | -| Skills/prompts system | Yes | Plan 08 | -| Extension hooks (20+ events) | Yes | Plan 09 | -| App built on SDK | Yes | Plan 10 (completes deferred work from 03, 05, 09) | diff --git a/thoughts/2026-02-27-simplify-streaming-and-styling.md b/thoughts/2026-02-27-simplify-streaming-and-styling.md new file mode 100644 index 00000000..6b24895e --- /dev/null +++ b/thoughts/2026-02-27-simplify-streaming-and-styling.md @@ -0,0 +1,561 @@ +# Simplify Streaming & Styling Implementation Plan + +## Overview + +Kit's `internal/ui/` layer has accumulated complexity that can be reduced without touching the SDK's public API or changing user-visible behavior. This plan targets renderer duplication, an over-engineered block renderer, dead-weight abstractions, and minor SDK API hygiene -- all informed by a comparative analysis with Charm's crush codebase. + +## Current State Analysis + +### What's Good (Keep) +- **SDK event bus** (`pkg/kit/events.go`): Clean, type-safe, correct separation for "build your own kit" consumers. +- **SDK -> App event translation** (`internal/app/app.go:444-475`): Necessary bridge between public API and internal TUI. Single `Subscribe()` call, clean mapping. +- **Tool-specific renderers** (`internal/ui/tool_renderers.go`): Side-by-side diff, syntax-highlighted code, green-tinted Write blocks, bash output -- all useful. +- **Deferred markdown rendering** in `StreamComponent`: Accumulates chunks in `strings.Builder`, only calls glamour on flush. Correct approach. +- **KITT spinner** (`stream.go`, `spinner.go`): Clean, theme-aware, shared between TUI and stderr paths. +- **Theme system** (`enhanced_styles.go`): Catppuccin-based adaptive dark/light. Solid. + +### What's Not Good (Change) + +1. **Renderer duplication**: `MessageRenderer` (961 lines) and `CompactRenderer` (512 lines) share near-identical logic: + - `formatBashOutput()` copy-pasted (~60 lines each, `messages.go:656` and `compact_renderer.go:447`) + - `formatToolArgs()` duplicated (`messages.go:594` and `compact_renderer.go:387`) + - Every `print*` method in `model.go` (lines 882-936) has `if m.compactMode { compact } else { standard }` branching + - Both renderers already share `renderToolBody()` and `formatToolParams()` -- proving the pattern works + +2. **Block renderer 3-phase pipeline**: `block_renderer.go:170-233` only triggers for `WithBackground()`, which is used by **one call site**: user messages (`messages.go:214`). 60 lines of complexity for one message type. + +3. **`MessageContainer` is dead weight for TUI**: The TUI model (`model.go`) never uses it -- renders directly via `printUserMessage()` -> `tea.Println()`. Only `cli.go` uses it, and immediately clears after each display (`cli.go:261`). A stateful container used statelessly. + +4. **Dual markdown paths**: `toMarkdown()` and `toMarkdownWithBg()` (`styles.go:332-344`) exist because user messages pass a background hex. Removing user message backgrounds collapses this to one function. + +5. **SDK public API leaks**: `SetupAgent()`, `AgentSetupOptions`, `BuildProviderConfig()` are exported from `pkg/kit/` but only called internally by `New()` (`kit.go:232`). `Options` has 4 CLI-specific fields. + +### Key Discoveries +- `WithBackground()` is called exactly once: `messages.go:214` for user messages +- `toMarkdownWithBg()` is called exactly once: `messages.go:199` via `renderMarkdownWithBg()` +- `SetupAgent` is called exactly once: `kit.go:232` inside `New()` +- `BuildProviderConfig` is called exactly once: `setup.go:87` inside `SetupAgent()` +- `GetAgent()` is used in `cmd/root.go:375` to call `CollectAgentMetadata()` which needs `GetTools()`, `GetLoadingMessage()`, `GetLoadedServerNames()`, `GetMCPToolCount()`, `GetExtensionToolCount()` +- `GetExtRunner()` is used in `cmd/root.go:405` to set extension context and emit `SessionStart` +- `GetBufferedLogger()` is used in `cmd/root.go:387` to flush debug messages in non-interactive mode +- `formatBashOutput()` has identical semantics in both renderers (parse ``/`` tags, style stderr with error color) but slightly different signatures + +## Desired End State + +After this plan is complete: +- **One renderer interface** with two implementations sharing all common logic via a base struct +- **No `MessageContainer`** -- both CLI and TUI paths render messages directly +- **Single-pass block rendering** -- no 3-phase pipeline +- **Single markdown path** -- no `toMarkdownWithBg()` / `renderMarkdownWithBg()` +- **Tighter SDK API** -- `SetupAgent` and friends are internal; `Options` is split +- **All existing tests pass**, visual output is unchanged (or trivially different for user message background) + +### Verification +- `go build -o output/kit ./cmd/kit` succeeds +- `go test -race ./...` passes +- `go vet ./...` clean +- Manual: interactive TUI renders messages identically (user messages lose background fill but keep border) +- Manual: `--prompt` non-interactive mode output is identical +- Manual: compact mode output is identical + +## What We're NOT Doing + +- Changing the SDK event bus or event types +- Changing the app layer (`internal/app/`) event translation +- Modifying the streaming pipeline (chunk accumulation, deferred flush) +- Altering tool-specific renderers (diff, code, write, bash) +- Touching the KITT spinner implementation +- Changing the Catppuccin theme or color palette +- Using alternate screen buffer + +## Implementation Approach + +Bottom-up: start with the shared rendering infrastructure, then remove dead code, then clean up the SDK surface. Each phase is independently shippable. + +--- + +## Phase 1: Unify Renderers + +### Overview +Extract duplicated logic into shared functions and introduce a `Renderer` interface so the `if compact { ... } else { ... }` branching throughout `model.go` collapses to a single call. + +### Changes Required + +#### 1. Extract shared formatting functions +**File**: `internal/ui/format.go` (new) +**Changes**: Move duplicated functions out of both renderers into package-level helpers. + +```go +package ui + +// parseBashOutput parses / tagged output and returns +// styled text. Shared by both MessageRenderer and CompactRenderer. +func parseBashOutput(result string, theme Theme) (stdout string, stderr string) { + // ... extracted from messages.go:657-725 and compact_renderer.go:447-511 +} + +// formatToolArgsCompact formats tool arguments for compact single-line display. +func formatToolArgsCompact(args string, maxWidth int) string { + // ... extracted from compact_renderer.go:387-409 +} +``` + +Functions to extract: +- `formatBashOutput()` -- merge the two implementations (identical logic, different return types) +- `formatToolArgsCompact()` from `CompactRenderer` +- `formatToolArgs()` from `MessageRenderer` (the JSON-stripping version) + +#### 2. Define Renderer interface +**File**: `internal/ui/messages.go` +**Changes**: Add interface at top of file. + +```go +// Renderer is the interface satisfied by both MessageRenderer and +// CompactRenderer. It allows model.go to call rendering methods without +// branching on compact mode. +type Renderer interface { + RenderUserMessage(content string, timestamp time.Time) UIMessage + RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage + RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage + RenderSystemMessage(content string, timestamp time.Time) UIMessage + RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage + RenderDebugMessage(message string, timestamp time.Time) UIMessage + RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage + SetWidth(width int) +} +``` + +Both `MessageRenderer` and `CompactRenderer` already satisfy this (they have identical method signatures). No code changes needed on either struct. + +#### 3. Collapse branching in model.go +**File**: `internal/ui/model.go` +**Changes**: Replace the dual `renderer` + `compactRdr` fields with a single `renderer Renderer` field. + +Replace fields at `model.go:160-166`: +```go +// Before: +renderer *MessageRenderer +compactRdr *CompactRenderer +compactMode bool + +// After: +renderer Renderer +compactMode bool // retained for StreamComponent selection +``` + +Update constructor at `model.go:268-269`: +```go +// Before: +renderer: NewMessageRenderer(width, false), +compactRdr: NewCompactRenderer(width, false), + +// After: +renderer: func() Renderer { + if opts.CompactMode { + return NewCompactRenderer(width, false) + } + return NewMessageRenderer(width, false) +}(), +``` + +Collapse all `print*` helpers (`model.go:882-1001`). Example for `printUserMessage`: +```go +// Before (model.go:882-892): +func (m *AppModel) printUserMessage(text string) tea.Cmd { + var rendered string + if m.compactMode { + msg := m.compactRdr.RenderUserMessage(text, time.Now()) + rendered = msg.Content + } else { + msg := m.renderer.RenderUserMessage(text, time.Now()) + rendered = msg.Content + } + return tea.Println(rendered) +} + +// After: +func (m *AppModel) printUserMessage(text string) tea.Cmd { + return tea.Println(m.renderer.RenderUserMessage(text, time.Now()).Content) +} +``` + +Apply the same pattern to: `printAssistantMessage`, `printToolResult`, `printErrorResponse`, `printSystemMessage`, `PrintStartupInfo`. + +#### 4. Update cli.go to use Renderer interface +**File**: `internal/ui/cli.go` +**Changes**: Replace dual renderer fields with single `Renderer`. + +```go +// Before (cli.go:18-19): +messageRenderer *MessageRenderer +compactRenderer *CompactRenderer + +// After: +renderer Renderer +``` + +Collapse all `Display*` methods the same way as model.go. + +#### 5. Collapse formatBashOutput duplication +**File**: `internal/ui/messages.go` and `internal/ui/compact_renderer.go` +**Changes**: Both `formatBashOutput` methods call the new shared `parseBashOutput()` from `format.go`, then apply their own styling (MessageRenderer applies width, CompactRenderer doesn't). + +### Success Criteria + +#### Automated Verification: +- [ ] Build succeeds: `go build -o output/kit ./cmd/kit` +- [ ] All tests pass: `go test -race ./...` +- [ ] No lint errors: `go vet ./...` +- [ ] Format clean: `go fmt ./...` + +#### Manual Verification: +- [ ] Interactive TUI renders all message types correctly (user, assistant, tool, system, error) +- [ ] Compact mode renders correctly +- [ ] `--prompt` non-interactive mode renders correctly +- [ ] Tool-specific renderers (diff, code, write, bash) display unchanged + +**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human that the manual testing was successful before proceeding to the next phase. + +--- + +## Phase 2: Simplify Block Renderer + +### Overview +Remove the 3-phase background rendering pipeline. User messages switch from background fill to border-only (matching assistant messages). This eliminates `WithBackground()`, `toMarkdownWithBg()`, `renderMarkdownWithBg()`, and the `generateMarkdownStyleConfig(bgHex)` background propagation path. + +### Changes Required + +#### 1. Remove user message background +**File**: `internal/ui/messages.go` +**Changes**: In `RenderUserMessage()`, remove `WithBackground(theme.Highlight)` and the `renderMarkdownWithBg` call. + +```go +// Before (messages.go:196-216): +bgHex := colorHex(theme.Highlight) +messageContent := r.renderMarkdownWithBg(content, r.width-8, bgHex) +... +rendered := renderContentBlock( + fullContent, r.width, + WithAlign(lipgloss.Left), + WithBorderColor(theme.Primary), + WithBackground(theme.Highlight), + WithMarginBottom(1), +) + +// After: +messageContent := r.renderMarkdown(content, r.width-8) +... +rendered := renderContentBlock( + fullContent, r.width, + WithAlign(lipgloss.Left), + WithBorderColor(theme.Primary), + WithMarginBottom(1), +) +``` + +User messages will now have a left border (like system messages) but no background fill. The `theme.Primary` border color (Catppuccin Mauve) still visually distinguishes them. + +#### 2. Remove 3-phase pipeline from block_renderer.go +**File**: `internal/ui/block_renderer.go` +**Changes**: Remove the `WithBackground()` option, the `bgColor` field, and the entire `if hasBg { ... }` branch (lines 170-233). Only the simple single-pass path remains. + +The `blockRenderer` struct drops the `bgColor` field. `renderContentBlock()` shrinks from ~145 lines to ~60. + +#### 3. Remove markdown background helpers +**File**: `internal/ui/styles.go` +**Changes**: Remove `toMarkdownWithBg()` (lines 338-344). Remove the `bgHex` parameter from `GetMarkdownRenderer()` and `generateMarkdownStyleConfig()`. Remove all `BackgroundColor: docBg` fields and `IndentToken` background styling from the glamour config. + +**File**: `internal/ui/messages.go` +**Changes**: Remove `renderMarkdownWithBg()` method (lines 733-738). + +#### 4. Remove colorHex helper if unused +**File**: `internal/ui/enhanced_styles.go` +**Changes**: Check if `colorHex()` is still used elsewhere. If not, remove it. + +### Success Criteria + +#### Automated Verification: +- [ ] Build succeeds: `go build -o output/kit ./cmd/kit` +- [ ] All tests pass: `go test -race ./...` +- [ ] No lint errors: `go vet ./...` + +#### Manual Verification: +- [ ] User messages display with left border (no background fill) -- visually acceptable +- [ ] Markdown inside user messages renders correctly without background +- [ ] All other message types unchanged +- [ ] Code blocks in user messages (if any) still render correctly + +**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human that the manual testing was successful before proceeding to the next phase. + +--- + +## Phase 3: Remove MessageContainer + +### Overview +`MessageContainer` (`messages.go:740-961`) is not used by the TUI. The CLI path uses it as a one-shot printer (add message, render, clear). Replace CLI usage with direct renderer calls. + +### Changes Required + +#### 1. Simplify CLI to use Renderer directly +**File**: `internal/ui/cli.go` +**Changes**: Remove the `messageContainer` field. Each `Display*` method calls the renderer and prints directly: + +```go +// Before (cli.go:96-105): +func (c *CLI) DisplayUserMessage(message string) { + var msg UIMessage + if c.compactMode { + msg = c.compactRenderer.RenderUserMessage(message, time.Now()) + } else { + msg = c.messageRenderer.RenderUserMessage(message, time.Now()) + } + c.messageContainer.AddMessage(msg) + c.displayContainer() +} + +// After (with Phase 1's Renderer interface): +func (c *CLI) DisplayUserMessage(message string) { + fmt.Println(c.renderer.RenderUserMessage(message, time.Now()).Content) +} +``` + +Apply the same to: `DisplayAssistantMessageWithModel`, `DisplayToolMessage`, `DisplayError`, `DisplayInfo`, `DisplayCancellation`, `DisplayDebugMessage`, `DisplayDebugConfig`. + +Remove `displayContainer()` method (`cli.go:254-262`). + +#### 2. Delete MessageContainer +**File**: `internal/ui/messages.go` +**Changes**: Remove `MessageContainer` struct and all its methods (lines 740-961): `NewMessageContainer`, `AddMessage`, `SetModelName`, `UpdateLastMessage`, `Clear`, `SetSize`, `Render`, `renderEmptyState`, `renderCompactMessages`, `renderCompactEmptyState`. + +This removes ~220 lines including the welcome screen rendering. The welcome screen (`renderEmptyState`) is not shown in the TUI path (startup info is printed via `PrintStartupInfo()`). For the CLI path, the startup info is displayed via `SetupCLI()` in `factory.go:116-133`. + +#### 3. Remove unused CLI fields +**File**: `internal/ui/cli.go` +**Changes**: Remove `height` field (only used by `MessageContainer`). Remove `modelName` field (pass directly to renderer calls -- already available on the `CLI` struct from `SetModelName`). Simplify `updateSize()` to not propagate to a container. + +### Success Criteria + +#### Automated Verification: +- [ ] Build succeeds: `go build -o output/kit ./cmd/kit` +- [ ] All tests pass: `go test -race ./...` +- [ ] No lint errors: `go vet ./...` + +#### Manual Verification: +- [ ] `--prompt` mode displays all message types correctly +- [ ] `--prompt --compact` mode works +- [ ] Debug messages display in debug mode +- [ ] No welcome screen regression (startup info still shown via `PrintStartupInfo` / `SetupCLI`) + +**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human that the manual testing was successful before proceeding to the next phase. + +--- + +## Phase 4: SDK Public API Tightening + +### Overview +Move internal-only exports out of `pkg/kit/` and split `Options` to separate SDK concerns from CLI concerns. Add narrower accessor methods to replace `GetAgent()`. + +### Changes Required + +#### 1. Move SetupAgent to internal +**File**: `pkg/kit/setup.go` -> `internal/kitsetup/setup.go` (new package) +**Changes**: Move `SetupAgent()`, `AgentSetupOptions`, `AgentSetupResult`, `BuildProviderConfig()`, `extensionCreationOpts`, and `loadExtensions()` to a new `internal/kitsetup` package. + +Update `pkg/kit/kit.go:232` to call `kitsetup.SetupAgent()` instead. + +Note: `AgentSetupResult` references `*agent.Agent`, `*tools.BufferedDebugLogger`, and `*extensions.Runner` -- all internal types. This is fine since the new package is also internal. + +#### 2. Split Options +**File**: `pkg/kit/kit.go` +**Changes**: Move CLI-specific fields out of `Options` into a separate struct consumed only by the CLI. + +```go +// Options configures Kit creation for SDK users. +type Options struct { + Model string + SystemPrompt string + ConfigFile string + MaxSteps int + Streaming bool + Quiet bool + Tools []Tool + ExtraTools []Tool + + // Session configuration + SessionDir string + SessionPath string + Continue bool + NoSession bool + + // Skills + Skills []string + SkillsDir string + + // Compaction + AutoCompact bool + CompactionOptions *CompactionOptions + + // Debug enables debug logging for the SDK. + Debug bool +} + +// CLIOptions holds fields only relevant to the CLI binary. +// SDK users should not need these. +type CLIOptions struct { + MCPConfig *config.Config + ShowSpinner bool + SpinnerFunc SpinnerFunc + UseBufferedLogger bool +} +``` + +Update `New()` to accept `CLIOptions` as an optional second parameter or embed it in a `NewWithCLIOptions()` variant. The simplest approach: add `CLIOptions *CLIOptions` as an optional field on `Options` itself: + +```go +type Options struct { + // ... SDK fields ... + + // CLI is optional CLI-specific configuration. SDK users leave this nil. + CLI *CLIOptions +} +``` + +This keeps `New()` signature unchanged while making it clear which fields are SDK vs CLI. + +#### 3. Add narrower accessor methods +**File**: `pkg/kit/kit.go` +**Changes**: Add methods that expose only what `cmd/root.go` actually needs, without leaking internal types. + +```go +// GetToolNames returns the names of all tools available to the agent. +func (m *Kit) GetToolNames() []string { + tools := m.agent.GetTools() + names := make([]string, len(tools)) + for i, t := range tools { + names[i] = t.Info().Name + } + return names +} + +// GetLoadingMessage returns the agent's startup info message (e.g. GPU +// fallback info), or empty string if none. +func (m *Kit) GetLoadingMessage() string { + return m.agent.GetLoadingMessage() +} + +// GetLoadedServerNames returns the names of successfully loaded MCP servers. +func (m *Kit) GetLoadedServerNames() []string { + return m.agent.GetLoadedServerNames() +} + +// GetMCPToolCount returns the number of tools loaded from external MCP servers. +func (m *Kit) GetMCPToolCount() int { + return m.agent.GetMCPToolCount() +} + +// GetExtensionToolCount returns the number of tools registered by extensions. +func (m *Kit) GetExtensionToolCount() int { + return m.agent.GetExtensionToolCount() +} +``` + +#### 4. Update cmd/root.go to use new accessors +**File**: `cmd/root.go` and `cmd/setup.go` +**Changes**: Replace `kitInstance.GetAgent()` calls with the new narrower methods. `CollectAgentMetadata()` takes `*kit.Kit` instead of `*agent.Agent`. + +```go +// Before (cmd/setup.go:17): +func CollectAgentMetadata(mcpAgent *agent.Agent, mcpConfig *config.Config) (...) + +// After: +func CollectAgentMetadata(k *kit.Kit, mcpConfig *config.Config) (...) +``` + +For `GetExtRunner()`: The only usage is in `cmd/root.go:405-441` to set extension context. This can be handled by adding a `SetExtensionContext()` method on Kit that wraps the runner interaction: + +```go +// SetExtensionContext configures the extension runner with the given +// context functions. No-op if extensions are disabled. +func (m *Kit) SetExtensionContext(ctx ExtensionContext) { ... } + +// EmitSessionStart fires the SessionStart event for extensions. +func (m *Kit) EmitSessionStart() { ... } +``` + +For `GetBufferedLogger()`: The only usage is `cmd/root.go:387-391` to flush debug messages. Add: + +```go +// GetBufferedDebugMessages returns any debug messages that were buffered +// during initialization, then clears the buffer. Returns nil if no +// messages were buffered. +func (m *Kit) GetBufferedDebugMessages() []string { ... } +``` + +#### 5. Deprecate old accessors +**File**: `pkg/kit/kit.go` +**Changes**: Mark `GetAgent()`, `GetExtRunner()`, `GetBufferedLogger()` as deprecated but keep them for one release cycle: + +```go +// Deprecated: Use GetToolNames(), GetLoadingMessage(), etc. instead. +func (m *Kit) GetAgent() *agent.Agent { return m.agent } +``` + +### Success Criteria + +#### Automated Verification: +- [ ] Build succeeds: `go build -o output/kit ./cmd/kit` +- [ ] All tests pass: `go test -race ./...` +- [ ] No lint errors: `go vet ./...` +- [ ] `go doc pkg/kit` shows clean public API without `SetupAgent` + +#### Manual Verification: +- [ ] Interactive mode works end-to-end +- [ ] `--prompt` mode works end-to-end +- [ ] Extensions load and dispatch correctly +- [ ] Debug mode shows buffered messages in non-interactive mode + +**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human that the manual testing was successful before proceeding to the next phase. + +--- + +## Testing Strategy + +### Unit Tests +- Verify `Renderer` interface is satisfied by both implementations (compile-time check via `var _ Renderer = (*MessageRenderer)(nil)`) +- Verify `parseBashOutput()` handles all tag combinations (stdout only, stderr only, mixed, no tags) +- Verify `formatToolParams()` truncation behavior (existing test coverage) +- Verify `SetupAgent` is no longer importable from external packages + +### Integration Tests +- Existing `model_test.go` and `children_test.go` continue to pass +- Existing `usage_tracker_test.go` and `usage_tracker_render_test.go` continue to pass + +### Manual Testing Steps +1. Start interactive TUI, send a message, verify user/assistant/tool rendering +2. Send a message that triggers tool calls (bash, read, edit), verify tool blocks +3. Use `/compact` command, verify compaction summary renders +4. Use `--prompt "list files"` in non-interactive mode, verify output +5. Use `--prompt --compact "list files"`, verify compact output +6. Use `--prompt --quiet "list files"`, verify only response text appears +7. Start with `--debug`, verify debug messages display +8. Verify `/usage`, `/tools`, `/servers`, `/help` commands render correctly + +## Performance Considerations + +- Removing `MessageContainer` eliminates one allocation + clear cycle per message in CLI mode +- Removing the 3-phase block render eliminates two extra `lipgloss.Place()` / `lipgloss.Width()` calls per user message +- Collapsing the renderer branching eliminates one virtual dispatch vs two field lookups -- negligible +- No performance regressions expected; this is purely code simplification + +## Migration Notes + +- `SetupAgent`, `AgentSetupOptions`, `AgentSetupResult`, `BuildProviderConfig` move from public to internal. Any external SDK consumer using these (unlikely -- they're undocumented escape hatches) would need to use `kit.New()` instead. +- User messages lose their subtle background tint. This is a minor visual change. The border color (`theme.Primary`) still distinguishes them clearly from assistant messages (no border). +- `GetAgent()`, `GetExtRunner()`, `GetBufferedLogger()` are deprecated, not removed. External consumers have a migration path to the narrower methods. + +## References +- Crush streaming architecture: `internal/agent/hyper/provider.go`, `internal/app/app.go` (non-interactive path) +- Crush styling: `internal/ui/styles/styles.go`, `internal/ui/chat/assistant.go` (render caching) +- Kit block renderer: `internal/ui/block_renderer.go:170-233` (3-phase pipeline) +- Kit renderer duplication: `internal/ui/messages.go:656` vs `internal/ui/compact_renderer.go:447` +- Kit SDK setup: `pkg/kit/setup.go` (only called from `pkg/kit/kit.go:232`)