diff --git a/cmd/root.go b/cmd/root.go index 2a5639df..7ff60910 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,7 @@ import ( "github.com/mark3labs/kit/internal/models" "github.com/mark3labs/kit/internal/prompts" "github.com/mark3labs/kit/internal/ui" + "github.com/mark3labs/kit/internal/ui/commands" kit "github.com/mark3labs/kit/pkg/kit" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -386,21 +387,21 @@ func runKit(ctx context.Context) error { } // extensionCommandsForUI converts extension-registered CommandDefs into the -// ui.ExtensionCommand type used by the interactive TUI. Command names are +// commands.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(k *kit.Kit) []ui.ExtensionCommand { +func extensionCommandsForUI(k *kit.Kit) []commands.ExtensionCommand { defs := k.Extensions().Commands() if len(defs) == 0 { return nil } - cmds := make([]ui.ExtensionCommand, 0, len(defs)) + cmds := make([]commands.ExtensionCommand, 0, len(defs)) for _, d := range defs { name := d.Name if len(name) > 0 && name[0] != '/' { name = "/" + name } - ec := ui.ExtensionCommand{ + ec := commands.ExtensionCommand{ Name: name, Description: d.Description, Execute: func(args string) (string, error) { @@ -1547,7 +1548,7 @@ func runNormalMode(ctx context.Context) error { emitBeforeFork := beforeForkProviderForUI(kitInstance) emitBeforeSessionSwitch := beforeSessionSwitchProviderForUI(kitInstance) getGlobalShortcuts := globalShortcutsProviderForUI(kitInstance) - getExtensionCommands := func() []ui.ExtensionCommand { + getExtensionCommands := func() []commands.ExtensionCommand { return extensionCommandsForUI(kitInstance) } @@ -1627,7 +1628,7 @@ func runNormalMode(ctx context.Context) error { // // When --no-exit is set, after the prompt completes the interactive BubbleTea // TUI is started so the user can continue the conversation. -func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error) error { +func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error) error { // Expand @file references in the prompt before sending to the agent. if cwd, err := os.Getwd(); err == nil { prompt = ui.ProcessFileAttachments(prompt, cwd) @@ -1768,7 +1769,7 @@ func writeJSONError(err error) { // 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit). // // SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering. -func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, startupExtensionMessages []string) error { +func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, startupExtensionMessages []string) error { // Determine terminal size; fall back gracefully. termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || termWidth == 0 { diff --git a/internal/ui/block_renderer.go b/internal/ui/block_renderer.go index c0a7fd92..0b1a06f4 100644 --- a/internal/ui/block_renderer.go +++ b/internal/ui/block_renderer.go @@ -4,6 +4,8 @@ import ( "image/color" "charm.land/lipgloss/v2" + + "github.com/mark3labs/kit/internal/ui/style" ) // blockRenderer handles rendering of content blocks with configurable options @@ -175,7 +177,7 @@ func renderContentBlock(content string, containerWidth int, options ...rendering borderChars = 1 } - theme := GetTheme() + theme := style.GetTheme() // Resolve foreground color: caller override or theme default. fgColor := theme.Text diff --git a/internal/ui/children_test.go b/internal/ui/children_test.go index f6ef0dc6..fc8b2017 100644 --- a/internal/ui/children_test.go +++ b/internal/ui/children_test.go @@ -6,6 +6,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/mark3labs/kit/internal/app" + "github.com/mark3labs/kit/internal/ui/core" ) // ========================================================================== @@ -59,7 +60,7 @@ func TestInputComponent_SubmitEmitsSubmitMsg(t *testing.T) { t.Fatal("expected a cmd from pressing enter on non-empty input") } - sm, ok := msg.(submitMsg) + sm, ok := msg.(core.SubmitMsg) if !ok { t.Fatalf("expected submitMsg, got %T", msg) } @@ -83,7 +84,7 @@ func TestInputComponent_CtrlD_SubmitEmitsSubmitMsg(t *testing.T) { if msg == nil { t.Fatal("expected a cmd from ctrl+d on non-empty input") } - sm, ok := msg.(submitMsg) + sm, ok := msg.(core.SubmitMsg) if !ok { t.Fatalf("expected submitMsg from ctrl+d, got %T", msg) } @@ -175,7 +176,7 @@ func TestInputComponent_ClearForwardsAsSubmitMsg(t *testing.T) { t.Fatalf("%s: expected submitMsg cmd, got nil", alias) } msg := runCmd(cmd) - sm, ok := msg.(submitMsg) + sm, ok := msg.(core.SubmitMsg) if !ok { t.Fatalf("%s: expected submitMsg, got %T", alias, msg) } @@ -230,7 +231,7 @@ func TestInputComponent_ClearQueue_ForwardsAsSubmitMsg(t *testing.T) { t.Fatalf("%s: expected submitMsg cmd, got nil", alias) } msg := runCmd(cmd) - sm, ok := msg.(submitMsg) + sm, ok := msg.(core.SubmitMsg) if !ok { t.Fatalf("%s: expected submitMsg, got %T", alias, msg) } @@ -258,7 +259,7 @@ func TestInputComponent_UnknownSlashCommand_ForwardsAsSubmit(t *testing.T) { if msg == nil { t.Fatal("expected submitMsg for unknown slash command") } - sm, ok := msg.(submitMsg) + sm, ok := msg.(core.SubmitMsg) if !ok { t.Fatalf("expected submitMsg for unknown slash command, got %T", msg) } diff --git a/internal/ui/cli.go b/internal/ui/cli.go index 56748276..3d8bbad9 100644 --- a/internal/ui/cli.go +++ b/internal/ui/cli.go @@ -8,6 +8,8 @@ import ( "charm.land/fantasy" "charm.land/lipgloss/v2" "golang.org/x/term" + + "github.com/mark3labs/kit/internal/ui/style" ) // CLI manages the command-line interface for KIT, providing message rendering, @@ -125,7 +127,7 @@ func (c *CLI) DisplayInfo(message string) { // DisplayExtensionBlock renders a custom styled block with the given border // color and optional subtitle. Used by extensions via ctx.PrintBlock. func (c *CLI) DisplayExtensionBlock(text, borderColor, subtitle string) { - theme := GetTheme() + theme := style.GetTheme() borderClr := theme.Info if borderColor != "" { diff --git a/internal/ui/clipboard.go b/internal/ui/clipboard/clipboard.go similarity index 99% rename from internal/ui/clipboard.go rename to internal/ui/clipboard/clipboard.go index 31204f8d..113bb0d1 100644 --- a/internal/ui/clipboard.go +++ b/internal/ui/clipboard/clipboard.go @@ -1,4 +1,4 @@ -package ui +package clipboard import ( "fmt" diff --git a/internal/ui/commands.go b/internal/ui/commands/commands.go similarity index 95% rename from internal/ui/commands.go rename to internal/ui/commands/commands.go index ccd329ef..e80f0901 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands/commands.go @@ -1,4 +1,4 @@ -package ui +package commands import ( "slices" @@ -7,6 +7,10 @@ import ( "github.com/mark3labs/kit/internal/models" ) +// ListThemesFunc is set by the ui package to provide theme name completion. +// This breaks the circular dependency between commands and ui packages. +var ListThemesFunc func() []string + // SlashCommand represents a user-invokable slash command with its metadata. // Commands can have multiple aliases and are organized by category for better // discoverability and help display. @@ -99,7 +103,10 @@ var SlashCommands = []SlashCommand{ Description: "Switch color theme (e.g. /theme catppuccin)", Category: "System", Complete: func(prefix string) []string { - names := ListThemes() + if ListThemesFunc == nil { + return nil + } + names := ListThemesFunc() if prefix == "" { return names } diff --git a/internal/ui/events.go b/internal/ui/core/events.go similarity index 83% rename from internal/ui/events.go rename to internal/ui/core/events.go index 1990f57e..88e09d83 100644 --- a/internal/ui/events.go +++ b/internal/ui/core/events.go @@ -1,4 +1,4 @@ -package ui +package core // ImageAttachment holds a clipboard image that will be sent alongside the // user's text prompt to the LLM. The data is raw image bytes; MediaType is @@ -10,9 +10,9 @@ type ImageAttachment struct { MediaType string } -// submitMsg is sent by the InputComponent when the user submits a text prompt. +// SubmitMsg is sent by the InputComponent when the user submits a text prompt. // The parent model receives this and calls app.Run(Text) to start agent processing. -type submitMsg struct { +type SubmitMsg struct { // Text is the user's input text to send to the agent. Text string // Images holds clipboard image attachments to send alongside the text. @@ -20,10 +20,10 @@ type submitMsg struct { Images []ImageAttachment } -// cancelTimerExpiredMsg is sent by the tea.Tick command that starts when the user +// CancelTimerExpiredMsg is sent by the tea.Tick command that starts when the user // presses ESC once during stateWorking. If this message arrives before the user // presses ESC a second time, the canceling state is reset to false. -type cancelTimerExpiredMsg struct{} +type CancelTimerExpiredMsg struct{} // --- Tree session events --- @@ -42,14 +42,14 @@ type TreeNodeSelectedMsg struct { // TreeCancelledMsg is sent when the user cancels the tree selector (ESC). type TreeCancelledMsg struct{} -// shellCommandMsg is sent by the InputComponent when the user submits a +// ShellCommandMsg is sent by the InputComponent when the user submits a // ! or !! prefixed command. The parent model intercepts this to execute // the shell command directly instead of forwarding to the LLM. // // Matching pi's behavior: // - !cmd → run shell command, output INCLUDED in LLM context // - !!cmd → run shell command, output EXCLUDED from LLM context -type shellCommandMsg struct { +type ShellCommandMsg struct { // Command is the shell command to execute (prefix stripped). Command string // ExcludeFromContext is true for !! (output excluded from LLM context), @@ -57,9 +57,9 @@ type shellCommandMsg struct { ExcludeFromContext bool } -// shellCommandResultMsg carries the result of a shell command execution +// ShellCommandResultMsg carries the result of a shell command execution // back to the parent model for display. -type shellCommandResultMsg struct { +type ShellCommandResultMsg struct { // Command is the original shell command that was executed. Command string // Output is the combined stdout/stderr output. @@ -68,6 +68,6 @@ type shellCommandResultMsg struct { ExitCode int // Err is non-nil if the command failed to start or timed out. Err error - // ExcludeFromContext mirrors the flag from shellCommandMsg. + // ExcludeFromContext mirrors the flag from ShellCommandMsg. ExcludeFromContext bool } diff --git a/internal/ui/exports.go b/internal/ui/exports.go new file mode 100644 index 00000000..5a91f41e --- /dev/null +++ b/internal/ui/exports.go @@ -0,0 +1,62 @@ +package ui + +// This file re-exports types from subpackages for backward compatibility. +// External importers can continue using ui.XXX without needing to import +// from subpackages directly. + +import ( + "github.com/mark3labs/kit/internal/ui/commands" + "github.com/mark3labs/kit/internal/ui/core" + "github.com/mark3labs/kit/internal/ui/fileutil" + "github.com/mark3labs/kit/internal/ui/prefs" + "github.com/mark3labs/kit/internal/ui/style" +) + +// Re-export from core package +type ( + ImageAttachment = core.ImageAttachment + SubmitMsg = core.SubmitMsg + CancelTimerExpiredMsg = core.CancelTimerExpiredMsg + TreeNodeSelectedMsg = core.TreeNodeSelectedMsg + TreeCancelledMsg = core.TreeCancelledMsg + ShellCommandMsg = core.ShellCommandMsg + ShellCommandResultMsg = core.ShellCommandResultMsg +) + +// Re-export from commands package +type ( + SlashCommand = commands.SlashCommand + ExtensionCommand = commands.ExtensionCommand +) + +// Re-export functions from fileutil package +var ProcessFileAttachments = fileutil.ProcessFileAttachments + +// Re-export from prefs package +var ( + LoadThemePreference = prefs.LoadThemePreference + SaveThemePreference = prefs.SaveThemePreference + LoadModelPreference = prefs.LoadModelPreference + SaveModelPreference = prefs.SaveModelPreference + LoadThinkingLevelPreference = prefs.LoadThinkingLevelPreference + SaveThinkingLevelPreference = prefs.SaveThinkingLevelPreference +) + +// Re-export from style package +type ( + Theme = style.Theme + MarkdownThemeColors = style.MarkdownThemeColors +) + +var ( + GetTheme = style.GetTheme + SetTheme = style.SetTheme + DefaultTheme = style.DefaultTheme + ApplyTheme = style.ApplyTheme + ApplyThemeWithoutSave = style.ApplyThemeWithoutSave + ListThemes = style.ListThemes + RegisterThemeFromConfig = style.RegisterThemeFromConfig + KitBanner = style.KitBanner + AdaptiveColor = style.AdaptiveColor + IsDarkBackground = style.IsDarkBackground +) diff --git a/internal/ui/file_processor.go b/internal/ui/fileutil/processor.go similarity index 99% rename from internal/ui/file_processor.go rename to internal/ui/fileutil/processor.go index 5181142f..fa2cd7f6 100644 --- a/internal/ui/file_processor.go +++ b/internal/ui/fileutil/processor.go @@ -1,4 +1,4 @@ -package ui +package fileutil import ( "fmt" diff --git a/internal/ui/format.go b/internal/ui/format.go index 84c47942..9b92e7d1 100644 --- a/internal/ui/format.go +++ b/internal/ui/format.go @@ -5,6 +5,8 @@ import ( "time" "charm.land/lipgloss/v2" + + "github.com/mark3labs/kit/internal/ui/style" ) // Renderer is the interface satisfied by MessageRenderer. It allows model.go @@ -30,7 +32,7 @@ var _ Renderer = (*MessageRenderer)(nil) // combined, styled output string with tags stripped. // // Shared by MessageRenderer. -func parseBashOutput(result string, theme Theme) string { +func parseBashOutput(result string, theme style.Theme) string { var formattedResult strings.Builder remaining := result diff --git a/internal/ui/fuzzy.go b/internal/ui/fuzzy.go index fab261e1..cfc9f0d6 100644 --- a/internal/ui/fuzzy.go +++ b/internal/ui/fuzzy.go @@ -2,20 +2,22 @@ package ui import ( "strings" + + "github.com/mark3labs/kit/internal/ui/commands" ) // FuzzyMatch represents the result of a fuzzy string matching operation, // containing the matched command and its relevance score. Higher scores // indicate better matches. type FuzzyMatch struct { - Command *SlashCommand + Command *commands.SlashCommand Score int } // FuzzyMatchCommands performs fuzzy string matching on the provided slash commands // based on the query string. Returns a slice of matches sorted by relevance score // in descending order. An empty query returns all commands with zero scores. -func FuzzyMatchCommands(query string, commands []SlashCommand) []FuzzyMatch { +func FuzzyMatchCommands(query string, commands []commands.SlashCommand) []FuzzyMatch { if query == "" || query == "/" { // Return all commands when query is empty or just "/" matches := make([]FuzzyMatch, len(commands)) @@ -57,7 +59,7 @@ func FuzzyMatchCommands(query string, commands []SlashCommand) []FuzzyMatch { } // fuzzyScore calculates the fuzzy match score for a command -func fuzzyScore(query string, cmd *SlashCommand) int { +func fuzzyScore(query string, cmd *commands.SlashCommand) int { // Check exact match first cmdName := strings.ToLower(strings.TrimPrefix(cmd.Name, "/")) if cmdName == query { diff --git a/internal/ui/input.go b/internal/ui/input.go index ba6605b6..4c3b5193 100644 --- a/internal/ui/input.go +++ b/internal/ui/input.go @@ -10,6 +10,9 @@ import ( "charm.land/lipgloss/v2" "github.com/mark3labs/kit/internal/clipboard" + "github.com/mark3labs/kit/internal/ui/commands" + "github.com/mark3labs/kit/internal/ui/core" + "github.com/mark3labs/kit/internal/ui/style" ) // InputComponent is the interactive text input field for the parent AppModel. @@ -29,7 +32,7 @@ import ( // app.Run(). type InputComponent struct { textarea textarea.Model - commands []SlashCommand + commands []commands.SlashCommand showPopup bool filtered []FuzzyMatch selected int @@ -42,17 +45,17 @@ type InputComponent struct { // Argument completion state. When the user types "/cmd " followed by // a partial argument and the command has a Complete function, the popup // switches to argument-completion mode showing suggestions from Complete. - argMode bool // true when showing arg completions - argCommand string // command prefix for arg mode (e.g. "/bookmark") - argSynthCmds []SlashCommand // backing storage for synthetic arg entries + argMode bool // true when showing arg completions + argCommand string // command prefix for arg mode (e.g. "/bookmark") + argSynthCmds []commands.SlashCommand // backing storage for synthetic arg entries // File completion state. When the user types @ followed by a partial // file path, the popup shows file/directory suggestions from the cwd. - fileMode bool // true when showing @file completions - filePrefix string // current text after @ being matched - fileAtStartIdx int // byte offset of @ in the textarea value - fileSuggestions []FileSuggestion // backing storage for file entries - fileSynthCmds []SlashCommand // synthetic SlashCommands wrapping file entries + fileMode bool // true when showing @file completions + filePrefix string // current text after @ being matched + fileAtStartIdx int // byte offset of @ in the textarea value + fileSuggestions []FileSuggestion // backing storage for file entries + fileSynthCmds []commands.SlashCommand // synthetic commands.SlashCommands wrapping file entries // cwd is the working directory used for @file path resolution and // autocomplete suggestions. Set by the parent via SetCwd. @@ -71,7 +74,7 @@ type InputComponent struct { // pendingImages holds clipboard images attached to the next submission. // Images are added via Ctrl+V and cleared on submit or Ctrl+U. - pendingImages []ImageAttachment + pendingImages []core.ImageAttachment // history stores previously submitted prompts (most recent last). // Limited to maxHistory entries; duplicates of the previous entry are @@ -94,7 +97,7 @@ const maxHistory = 100 // clipboardImageMsg is the result of an async clipboard image read. type clipboardImageMsg struct { - image *ImageAttachment + image *core.ImageAttachment err error } @@ -119,7 +122,7 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom ) // Style the textarea using theme colors. - theme := GetTheme() + theme := style.GetTheme() styles := ta.Styles() styles.Focused.Base = lipgloss.NewStyle() styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(theme.VeryMuted) @@ -130,7 +133,7 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom return &InputComponent{ textarea: ta, - commands: SlashCommands, + commands: commands.SlashCommands, width: width, popupHeight: 7, title: title, @@ -329,7 +332,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.filePrefix = prefix s.fileAtStartIdx = atIdx s.fileSuggestions = suggestions - s.fileSynthCmds = make([]SlashCommand, len(suggestions)) + s.fileSynthCmds = make([]commands.SlashCommand, len(suggestions)) s.filtered = make([]FuzzyMatch, len(suggestions)) for i, fs := range suggestions { name := fs.RelPath @@ -337,7 +340,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if fs.IsDir { desc = "directory" } - s.fileSynthCmds[i] = SlashCommand{Name: name, Description: desc} + s.fileSynthCmds[i] = commands.SlashCommand{Name: name, Description: desc} s.filtered[i] = FuzzyMatch{Command: &s.fileSynthCmds[i], Score: fs.Score} } s.selected = 0 @@ -396,14 +399,14 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd { cmd := strings.TrimSpace(trimmed[2:]) if cmd != "" { return func() tea.Msg { - return shellCommandMsg{Command: cmd, ExcludeFromContext: true} + return core.ShellCommandMsg{Command: cmd, ExcludeFromContext: true} } } } else if strings.HasPrefix(trimmed, "!") { cmd := strings.TrimSpace(trimmed[1:]) if cmd != "" { return func() tea.Msg { - return shellCommandMsg{Command: cmd, ExcludeFromContext: false} + return core.ShellCommandMsg{Command: cmd, ExcludeFromContext: false} } } } @@ -413,7 +416,7 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd { // /clear and /clear-queue) are forwarded to the parent model via // submitMsg so the parent can update its own state (ScrollList, queue // counts, etc.) in one place. - if sc := GetCommandByName(trimmed); sc != nil { + if sc := commands.GetCommandByName(trimmed); sc != nil { switch sc.Name { case "/quit": return tea.Quit @@ -426,7 +429,7 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd { images := s.pendingImages s.pendingImages = nil return func() tea.Msg { - return submitMsg{Text: trimmed, Images: images} + return core.SubmitMsg{Text: trimmed, Images: images} } } @@ -463,7 +466,7 @@ func (s *InputComponent) resetHistoryBrowsing() { func (s *InputComponent) View() tea.View { containerStyle := lipgloss.NewStyle() - theme := GetTheme() + theme := style.GetTheme() // PaddingLeft(3) aligns with message content: border(1) + paddingLeft(2). titleStyle := lipgloss.NewStyle(). @@ -558,7 +561,7 @@ func (s *InputComponent) RenderPopupCentered(termWidth, termHeight int) string { // renderPopupWithOptions renders the popup content with optional center styling. func (s *InputComponent) renderPopupWithOptions(centered bool) string { - theme := GetTheme() + theme := style.GetTheme() popupWidth := max(s.width-4, 20) // Use the theme background for the popup - the full-width item backgrounds @@ -729,10 +732,10 @@ func (s *InputComponent) completeArgs(line string) []FuzzyMatch { s.argMode = true s.argCommand = cmdName - s.argSynthCmds = make([]SlashCommand, len(suggestions)) + s.argSynthCmds = make([]commands.SlashCommand, len(suggestions)) s.filtered = make([]FuzzyMatch, len(suggestions)) for i, sug := range suggestions { - s.argSynthCmds[i] = SlashCommand{Name: sug} + s.argSynthCmds[i] = commands.SlashCommand{Name: sug} s.filtered[i] = FuzzyMatch{Command: &s.argSynthCmds[i]} } return s.filtered @@ -740,7 +743,7 @@ func (s *InputComponent) completeArgs(line string) []FuzzyMatch { // findCommandWithComplete looks up a command by name that has a non-nil // Complete function. -func (s *InputComponent) findCommandWithComplete(name string) *SlashCommand { +func (s *InputComponent) findCommandWithComplete(name string) *commands.SlashCommand { for i := range s.commands { if s.commands[i].Name == name && s.commands[i].Complete != nil { return &s.commands[i] @@ -758,7 +761,7 @@ func readClipboardImageCmd() tea.Cmd { return clipboardImageMsg{err: err} } return clipboardImageMsg{ - image: &ImageAttachment{ + image: &core.ImageAttachment{ Data: img.Data, MediaType: img.MediaType, }, @@ -768,7 +771,7 @@ func readClipboardImageCmd() tea.Cmd { // ClearPendingImages removes all pending image attachments and returns them. // Used by the parent model when consuming images for submission. -func (s *InputComponent) ClearPendingImages() []ImageAttachment { +func (s *InputComponent) ClearPendingImages() []core.ImageAttachment { images := s.pendingImages s.pendingImages = nil return images diff --git a/internal/ui/message_items.go b/internal/ui/message_items.go index cf091c83..5d94bacb 100644 --- a/internal/ui/message_items.go +++ b/internal/ui/message_items.go @@ -6,6 +6,8 @@ import ( "time" "charm.land/lipgloss/v2" + + "github.com/mark3labs/kit/internal/ui/style" ) // -------------------------------------------------------------------------- @@ -149,9 +151,9 @@ func (s *StreamingMessageItem) Render(width int) string { var rendered string if s.role == "reasoning" { // Render as reasoning/thinking block with live duration counter - theme := GetTheme() + theme := style.GetTheme() mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted) - ty := createTypography(theme) + ty := createTypography(style.Theme(theme)) content := strings.TrimLeft(s.content, " \t\n") var parts []string @@ -255,7 +257,7 @@ func (m *StreamingBashOutputItem) Render(width int) string { return m.cachedRender } - theme := GetTheme() + theme := style.GetTheme() var parts []string // Header with command diff --git a/internal/ui/messages.go b/internal/ui/messages.go index 2c022b38..861caedb 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -9,6 +9,8 @@ import ( "charm.land/lipgloss/v2" "github.com/indaco/herald" + + "github.com/mark3labs/kit/internal/ui/style" ) // MessageType represents different categories of messages displayed in the UI, @@ -138,7 +140,7 @@ func newMessageRenderer(width int, debug bool) *MessageRenderer { return &MessageRenderer{ width: width, debug: debug, - ty: createTypography(GetTheme()), + ty: createTypography(style.GetTheme()), } } @@ -176,7 +178,7 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time. } // Use markdown rendering with Chroma syntax highlighting - rendered := toMarkdown(content, r.width-4) + rendered := style.ToMarkdown(content, r.width-4) rendered = styleMarginBottom1.Render(rendered) return UIMessage{ @@ -200,7 +202,7 @@ func (r *MessageRenderer) RenderReasoningBlock(content string, timestamp time.Ti } } - theme := GetTheme() + theme := style.GetTheme() // Match live streaming styling: muted italic text // Same as stream.go renderReasoningBlock() lines := strings.Split(strings.TrimRight(content, "\n"), "\n") @@ -323,16 +325,16 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin } var icon string - iconColor := GetTheme().Success + iconColor := style.GetTheme().Success if isError { icon = "×" - iconColor = GetTheme().Error + iconColor = style.GetTheme().Error } else { icon = "✓" } // Style the tool name with color - theme := GetTheme() + theme := style.GetTheme() nameColor := theme.Info if isError { nameColor = theme.Error @@ -351,7 +353,7 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin if extRd != nil && extRd.RenderBody != nil { body = extRd.RenderBody(toolResult, isError, r.width-8) if body != "" && extRd.BodyMarkdown { - body = strings.TrimSuffix(toMarkdown(body, r.width-8), "\n") + body = strings.TrimSuffix(style.ToMarkdown(body, r.width-8), "\n") } } if body == "" { @@ -397,7 +399,7 @@ func (r *MessageRenderer) formatToolResult(toolName, result string) string { if strings.Contains(toolName, "bash") || strings.Contains(toolName, "command") || strings.Contains(toolName, "shell") { if strings.Contains(result, "") || strings.Contains(result, "") { - return parseBashOutput(result, GetTheme()) + return parseBashOutput(result, style.GetTheme()) } } @@ -405,7 +407,7 @@ func (r *MessageRenderer) formatToolResult(toolName, result string) string { } // createTypography creates a typography instance from theme -func createTypography(theme Theme) *herald.Typography { +func createTypography(theme style.Theme) *herald.Typography { return herald.New( herald.WithPalette(herald.ColorPalette{ Primary: theme.Primary, @@ -437,5 +439,5 @@ func createTypography(theme Theme) *herald.Typography { // UpdateTheme refreshes the renderer's typography instance with colors from // the current theme. This is called when the user changes themes via /theme. func (r *MessageRenderer) UpdateTheme() { - r.ty = createTypography(GetTheme()) + r.ty = createTypography(style.GetTheme()) } diff --git a/internal/ui/model.go b/internal/ui/model.go index 861d5d3f..df2a6a8b 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -19,6 +19,11 @@ import ( "github.com/mark3labs/kit/internal/models" "github.com/mark3labs/kit/internal/prompts" "github.com/mark3labs/kit/internal/session" + "github.com/mark3labs/kit/internal/ui/commands" + uicore "github.com/mark3labs/kit/internal/ui/core" + "github.com/mark3labs/kit/internal/ui/fileutil" + "github.com/mark3labs/kit/internal/ui/prefs" + "github.com/mark3labs/kit/internal/ui/style" kit "github.com/mark3labs/kit/pkg/kit" ) @@ -276,7 +281,7 @@ type AppModelOptions struct { // ExtensionCommands are slash commands registered by extensions. They // appear in autocomplete, /help, and are dispatched when submitted. - ExtensionCommands []ExtensionCommand + ExtensionCommands []commands.ExtensionCommand // PromptTemplates are user-defined prompt templates loaded from ~/.kit/prompts/, // .kit/prompts/, or explicit --prompt-template paths. They appear in autocomplete @@ -355,7 +360,7 @@ type AppModelOptions struct { // GetExtensionCommands, if non-nil, returns the current extension // commands. Called on WidgetUpdateEvent to refresh the command list // after an extension hot-reload. May be nil if no extensions loaded. - GetExtensionCommands func() []ExtensionCommand + GetExtensionCommands func() []commands.ExtensionCommand // SetModel changes the active model at runtime. The model string uses // "provider/model" format (e.g. "anthropic/claude-sonnet-4-5-20250929"). @@ -478,7 +483,7 @@ type AppModel struct { // extensionCommands are slash commands from extensions, dispatched via // handleExtensionCommand when submitted. - extensionCommands []ExtensionCommand + extensionCommands []commands.ExtensionCommand // promptTemplates are user-defined prompt templates for expansion. // They appear in autocomplete and are expanded when submitted. @@ -542,7 +547,7 @@ type AppModel struct { // getExtensionCommands returns the current extension commands. Used // to refresh the command list after an extension hot-reload. May be nil. - getExtensionCommands func() []ExtensionCommand + getExtensionCommands func() []commands.ExtensionCommand // setModel changes the active model at runtime. Wired from cmd/root.go. // May be nil if model switching is not supported. @@ -710,6 +715,9 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { m.setModel = opts.SetModel m.emitModelChange = opts.EmitModelChange m.thinkingLevel = opts.ThinkingLevel + + // Initialize the theme list function for command completion. + commands.ListThemesFunc = style.ListThemes m.thinkingVisible = true // default to showing thinking blocks m.isReasoningModel = opts.IsReasoningModel m.setThinkingLevel = opts.SetThinkingLevel @@ -741,7 +749,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { // Merge extension commands into the InputComponent's autocomplete source. if ic, ok := m.input.(*InputComponent); ok && len(opts.ExtensionCommands) > 0 { for _, ec := range opts.ExtensionCommands { - ic.commands = append(ic.commands, SlashCommand{ + ic.commands = append(ic.commands, commands.SlashCommand{ Name: ec.Name, Description: ec.Description, Category: "Extensions", @@ -753,7 +761,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { // Merge prompt templates into the InputComponent's autocomplete source. if ic, ok := m.input.(*InputComponent); ok && len(opts.PromptTemplates) > 0 { for _, tpl := range opts.PromptTemplates { - ic.commands = append(ic.commands, SlashCommand{ + ic.commands = append(ic.commands, commands.SlashCommand{ Name: "/" + tpl.Name, Description: tpl.Description, Category: "Prompts", @@ -810,12 +818,12 @@ func (m *AppModel) AddStartupMessageToScrollList() { } // Add the ASCII logo at the very top. - logo := KitBanner() + logo := style.KitBanner() logoMsg := NewStyledMessageItem(generateMessageID(), "logo", logo, logo) m.messages = append(m.messages, logoMsg) // Build key-value pairs for startup info. - ty := createTypography(GetTheme()) + ty := createTypography(style.GetTheme()) var pairs [][2]string if m.providerName != "" && m.modelName != "" { @@ -873,7 +881,7 @@ func (m *AppModel) AddStartupMessageToScrollList() { // Add a visual separator after startup info: blank line + HR + blank line. // Uses a single pre-rendered item so there are no left borders on the spacing. - theme := GetTheme() + theme := style.GetTheme() separator := strings.Repeat("─", 80) separatorStyled := lipgloss.NewStyle(). Foreground(theme.Border). @@ -916,7 +924,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { // ── Tree selector events ───────────────────────────────────────────────── - case TreeNodeSelectedMsg: + case uicore.TreeNodeSelectedMsg: // User selected a node in the tree. Branch to it and return to input. if ts := m.appCtrl.GetTreeSession(); ts != nil { // For user messages: branch to parent (so user can resubmit). @@ -961,7 +969,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = stateInput return m, tea.Batch(cmds...) - case TreeCancelledMsg: + case uicore.TreeCancelledMsg: m.treeSelector = nil m.state = stateInput return m, nil @@ -985,7 +993,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.printSystemMessage(fmt.Sprintf("Switched to %s", msg.ModelString)) // Persist model selection for next launch. - go func() { _ = SaveModelPreference(msg.ModelString) }() + go func() { _ = prefs.SaveModelPreference(msg.ModelString) }() if m.emitModelChange != nil { emit := m.emitModelChange newModel := msg.ModelString @@ -1259,7 +1267,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Preprocess @file references. processedText := text if m.cwd != "" { - processedText = ProcessFileAttachments(text, m.cwd) + processedText = fileutil.ProcessFileAttachments(text, m.cwd) } // Inject the steer message. @@ -1304,7 +1312,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // If remap target is unrecognized, fall through to normal handling. case EditorKeySubmit: text := action.SubmitText - var images []ImageAttachment + var images []uicore.ImageAttachment if text == "" { if ic, ok := m.input.(*InputComponent); ok { text = strings.TrimSpace(ic.textarea.Value()) @@ -1315,7 +1323,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if text != "" { cmds = append(cmds, func() tea.Msg { - return submitMsg{Text: text, Images: images} + return uicore.SubmitMsg{Text: text, Images: images} }) } intercepted = true @@ -1331,11 +1339,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // ── Cancel timer expired ───────────────────────────────────────────────── - case cancelTimerExpiredMsg: + case uicore.CancelTimerExpiredMsg: m.canceling = false // ── Input submitted ────────────────────────────────────────────────────── - case submitMsg: + case uicore.SubmitMsg: // Re-enable auto-scroll when user submits a new message. m.scrollList.autoScroll = true @@ -1345,7 +1353,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // their name and their args are passed through to the handler. if strings.HasPrefix(msg.Text, "/") { name, args, _ := strings.Cut(msg.Text, " ") - if sc := GetCommandByName(name); sc != nil { + if sc := commands.GetCommandByName(name); sc != nil { if cmd := m.handleSlashCommand(sc, strings.TrimSpace(args)); cmd != nil { cmds = append(cmds, cmd) } @@ -1372,7 +1380,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // ScrollList) uses the original user text so the UI stays clean. processedText := msg.Text if m.cwd != "" { - processedText = ProcessFileAttachments(msg.Text, m.cwd) + processedText = fileutil.ProcessFileAttachments(msg.Text, m.cwd) } // Convert image attachments to kit.LLMFilePart for the app layer. @@ -1423,7 +1431,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // ── Shell command (! / !!) ─────────────────────────────────────────────── - case shellCommandMsg: + case uicore.ShellCommandMsg: // Show spinner while the shell command runs. m.state = stateWorking if m.stream != nil { @@ -1434,7 +1442,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Execute the shell command asynchronously so the TUI stays responsive. cmds = append(cmds, m.executeShellCommand(msg)) - case shellCommandResultMsg: + case uicore.ShellCommandResultMsg: // Stop spinner now that the command has finished. if m.stream != nil { updated, cmd := m.stream.Update(app.SpinnerEvent{Show: false}) @@ -1738,14 +1746,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.extensionCommands = newCmds if ic, ok := m.input.(*InputComponent); ok { // Remove old extension commands and add fresh ones. - var builtins []SlashCommand + var builtins []commands.SlashCommand for _, sc := range ic.commands { if sc.Category != "Extensions" { builtins = append(builtins, sc) } } for _, ec := range newCmds { - builtins = append(builtins, SlashCommand{ + builtins = append(builtins, commands.SlashCommand{ Name: ec.Name, Description: ec.Description, Category: "Extensions", @@ -1983,7 +1991,7 @@ func (m *AppModel) View() tea.View { // Add canceling warning between scrollback and separator // (doesn't go inside scrollback viewport to avoid affecting scroll position) - theme := GetTheme() + theme := style.GetTheme() if m.canceling { warning := lipgloss.NewStyle(). Foreground(theme.Warning). @@ -2101,7 +2109,7 @@ func (m *AppModel) renderScrollback() string { // This bar is always present so its height is constant, eliminating layout // shifts from spinner or usage info appearing/disappearing. func (m *AppModel) renderStatusBar() string { - theme := GetTheme() + theme := style.GetTheme() // Left side: spinner animation (when active). var leftSide string @@ -2210,12 +2218,12 @@ func (m *AppModel) cycleThinkingLevel() { } // Persist thinking level for next launch. - go func() { _ = SaveThinkingLevelPreference(next) }() + go func() { _ = prefs.SaveThinkingLevelPreference(next) }() } // renderSeparator renders the separator line with an optional queue/steer count badge. func (m *AppModel) renderSeparator() string { - theme := GetTheme() + theme := style.GetTheme() lineStyle := lipgloss.NewStyle().Foreground(theme.Muted) queueLen := len(m.queuedMessages) steerLen := len(m.steeringMessages) @@ -2270,7 +2278,7 @@ func (m *AppModel) renderWidgetSlot(placement string) string { return "" } - theme := GetTheme() + theme := style.GetTheme() var blocks []string for _, w := range widgets { content := w.Text @@ -2310,7 +2318,7 @@ func (m *AppModel) renderHeaderFooter(getter func() *WidgetData) string { return "" } - theme := GetTheme() + theme := style.GetTheme() var opts []renderingOption opts = append(opts, WithAlign(lipgloss.Left)) @@ -2338,13 +2346,13 @@ func (m *AppModel) renderQueuedMessages() string { if len(m.queuedMessages) == 0 && len(m.steeringMessages) == 0 { return "" } - theme := GetTheme() + theme := style.GetTheme() var blocks []string // Render steering messages first (higher priority). if len(m.steeringMessages) > 0 { - badge := CreateBadge("STEERING", theme.Warning) + badge := style.CreateBadge("STEERING", theme.Warning) for _, msg := range m.steeringMessages { content := msg + "\n" + badge rendered := renderContentBlock( @@ -2359,7 +2367,7 @@ func (m *AppModel) renderQueuedMessages() string { // Render queued messages. if len(m.queuedMessages) > 0 { - badge := CreateBadge("QUEUED", theme.Accent) + badge := style.CreateBadge("QUEUED", theme.Accent) for _, msg := range m.queuedMessages { content := msg + "\n" + badge rendered := renderContentBlock( @@ -2450,7 +2458,7 @@ func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) { // handleSlashCommand executes a recognized slash command and returns a tea.Cmd. // args contains any text after the command name (may be empty). -func (m *AppModel) handleSlashCommand(sc *SlashCommand, args string) tea.Cmd { +func (m *AppModel) handleSlashCommand(sc *commands.SlashCommand, args string) tea.Cmd { switch sc.Name { case "/quit": m.quitting = true @@ -2529,7 +2537,7 @@ func (m *AppModel) printSystemMessage(text string) { // printExtensionBlock renders a custom styled block from an extension with // caller-chosen border color and optional subtitle into the ScrollList. func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) { - theme := GetTheme() + theme := style.GetTheme() // Resolve border color: use the extension's hex value, fall back to theme info. borderClr := theme.Info @@ -2583,7 +2591,7 @@ func (m *AppModel) handleExtensionCommand(text string) tea.Cmd { // Split: "/sub list files" → name="/sub", args="list files" name, args, _ := strings.Cut(text, " ") - ecmd := FindExtensionCommand(name, m.extensionCommands) + ecmd := commands.FindExtensionCommand(name, m.extensionCommands) if ecmd == nil { return nil } @@ -2851,8 +2859,15 @@ func (m *AppModel) appendStreamingChunk(role, content string) { streamMsg.AppendChunk(content) // Auto-scroll to bottom if enabled (iteratr pattern) // Don't call SetItems() - the slice reference hasn't changed - if m.scrollList != nil && m.scrollList.autoScroll { - m.scrollList.GotoBottom() + if m.scrollList != nil { + if m.scrollList.autoScroll { + m.scrollList.GotoBottom() + } else if m.scrollList.AtBottom() { + // User manually scrolled back to bottom during streaming, + // re-enable auto-scroll so they follow new content + m.scrollList.autoScroll = true + m.scrollList.GotoBottom() + } } return } @@ -3065,7 +3080,7 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd { } // Persist model selection for next launch. - go func() { _ = SaveModelPreference(args) }() + go func() { _ = prefs.SaveModelPreference(args) }() m.printSystemMessage(fmt.Sprintf("Switched to %s", args)) return nil @@ -3081,8 +3096,8 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd { func (m *AppModel) handleThemeCommand(args string) tea.Cmd { if args == "" { // List available themes. - names := ListThemes() - active := ActiveThemeName() + names := style.ListThemes() + active := style.ActiveThemeName() var lines []string lines = append(lines, "Available themes:") @@ -3094,8 +3109,8 @@ func (m *AppModel) handleThemeCommand(args string) tea.Cmd { } } lines = append(lines, "") - lines = append(lines, fmt.Sprintf("User themes: %s", userThemesDir())) - if pdir := projectThemesDir(); pdir != "" { + lines = append(lines, fmt.Sprintf("User themes: %s", style.UserThemesDir())) + if pdir := style.ProjectThemesDir(); pdir != "" { lines = append(lines, fmt.Sprintf("Project themes: %s", pdir)) } else { lines = append(lines, "Project themes: .kit/themes/ (not found)") @@ -3104,7 +3119,7 @@ func (m *AppModel) handleThemeCommand(args string) tea.Cmd { return nil } - if err := ApplyTheme(args); err != nil { + if err := style.ApplyTheme(args); err != nil { m.printSystemMessage(fmt.Sprintf("Theme error: %v", err)) return nil } @@ -3159,7 +3174,7 @@ func (m *AppModel) handleThinkingCommand(args string) tea.Cmd { }() } // Persist thinking level for next launch. - go func() { _ = SaveThinkingLevelPreference(string(level)) }() + go func() { _ = prefs.SaveThinkingLevelPreference(string(level)) }() m.printSystemMessage(fmt.Sprintf("Thinking level set to: %s — %s", level, models.ThinkingLevelDescription(level))) return nil } @@ -3657,11 +3672,11 @@ func (m *AppModel) handleSessionInfoCommand() tea.Cmd { // Cancel timer command // -------------------------------------------------------------------------- -// cancelTimerCmd returns a tea.Cmd that fires cancelTimerExpiredMsg after 2s. +// cancelTimerCmd returns a tea.Cmd that fires CancelTimerExpiredMsg after 2s. // This is used for the double-tap ESC cancel flow. func cancelTimerCmd() tea.Cmd { return tea.Tick(2*time.Second, func(_ time.Time) tea.Msg { - return cancelTimerExpiredMsg{} + return uicore.CancelTimerExpiredMsg{} }) } @@ -3845,9 +3860,9 @@ func (m *AppModel) resolveOverlay(resp app.OverlayResponse) { const shellCommandTimeout = 120 * time.Second // executeShellCommand runs a shell command asynchronously and returns the -// result as a shellCommandResultMsg. This is launched from Update() as a +// result as a ShellCommandResultMsg. This is launched from Update() as a // tea.Cmd so the TUI stays responsive during execution. -func (m *AppModel) executeShellCommand(msg shellCommandMsg) tea.Cmd { +func (m *AppModel) executeShellCommand(msg uicore.ShellCommandMsg) tea.Cmd { command := msg.Command excludeFromContext := msg.ExcludeFromContext cwd := m.cwd @@ -3882,7 +3897,7 @@ func (m *AppModel) executeShellCommand(msg shellCommandMsg) tea.Cmd { // Non-zero exit is reported via exitCode, not as an error. err = nil } else if ctx.Err() == context.DeadlineExceeded { - return shellCommandResultMsg{ + return uicore.ShellCommandResultMsg{ Command: command, Output: fmt.Sprintf("command timed out after %v", shellCommandTimeout), ExitCode: -1, @@ -3904,7 +3919,7 @@ func (m *AppModel) executeShellCommand(msg shellCommandMsg) tea.Cmd { combined.WriteString(stderr.String()) } - return shellCommandResultMsg{ + return uicore.ShellCommandResultMsg{ Command: command, Output: combined.String(), ExitCode: exitCode, @@ -3917,8 +3932,8 @@ func (m *AppModel) executeShellCommand(msg shellCommandMsg) tea.Cmd { // handleShellCommandResult processes the result of a shell command execution. // It prints the output to the ScrollList and optionally injects it into the // conversation context (for ! commands) so the LLM can see it. -func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd { - theme := GetTheme() +func (m *AppModel) handleShellCommandResult(msg uicore.ShellCommandResultMsg) tea.Cmd { + theme := style.GetTheme() // Build the display header. var header string diff --git a/internal/ui/model_selector.go b/internal/ui/model_selector.go index d7c4f451..73b2855c 100644 --- a/internal/ui/model_selector.go +++ b/internal/ui/model_selector.go @@ -10,6 +10,7 @@ import ( "charm.land/lipgloss/v2" "github.com/mark3labs/kit/internal/models" + "github.com/mark3labs/kit/internal/ui/style" ) // ModelEntry holds display metadata for a single model in the selector. @@ -188,7 +189,7 @@ func (ms *ModelSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View implements tea.Model. func (ms *ModelSelectorComponent) View() tea.View { - theme := GetTheme() + theme := style.GetTheme() headerStyle := lipgloss.NewStyle(). Bold(true). @@ -395,7 +396,7 @@ func (ms *ModelSelectorComponent) fuzzyScoreModel(query string, entry ModelEntry } func (ms *ModelSelectorComponent) renderEntry(entry ModelEntry, isCursor bool) string { - theme := GetTheme() + theme := style.GetTheme() modelStr := entry.ModelID providerStr := fmt.Sprintf("[%s]", entry.Provider) diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index a4a8c99d..75641f34 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/mark3labs/kit/internal/app" "github.com/mark3labs/kit/internal/session" + "github.com/mark3labs/kit/internal/ui/core" kit "github.com/mark3labs/kit/pkg/kit" ) @@ -167,7 +168,7 @@ func TestStateTransition_InputToWorking(t *testing.T) { t.Fatalf("expected stateInput, got %v", m.state) } - m = sendMsg(m, submitMsg{Text: "hello"}) + m = sendMsg(m, core.SubmitMsg{Text: "hello"}) if m.state != stateWorking { t.Fatalf("expected stateWorking after submitMsg, got %v", m.state) @@ -355,7 +356,7 @@ func TestESCCancel_timerExpiry(t *testing.T) { m.state = stateWorking m.canceling = true - m = sendMsg(m, cancelTimerExpiredMsg{}) + m = sendMsg(m, core.CancelTimerExpiredMsg{}) if m.canceling { t.Fatal("expected canceling=false after timer expiry") @@ -408,7 +409,7 @@ func TestQueuedMessages_storedOnQueuedSubmit(t *testing.T) { m, _, _ := newTestAppModel(ctrl) m.state = stateWorking - _, cmd := m.Update(submitMsg{Text: "queued prompt"}) + _, cmd := m.Update(core.SubmitMsg{Text: "queued prompt"}) if len(m.queuedMessages) != 1 { t.Fatalf("expected 1 queued message, got %d", len(m.queuedMessages)) @@ -557,7 +558,7 @@ func TestSubmitMsg_printsUserMessage(t *testing.T) { ctrl := &stubAppController{} m, _, _ := newTestAppModel(ctrl) - m = sendMsg(m, submitMsg{Text: "user query"}) + m = sendMsg(m, core.SubmitMsg{Text: "user query"}) // In alt screen mode, user messages are added to the in-memory ScrollList // rather than printed separately. Verify the message was added. @@ -876,7 +877,7 @@ func TestSubmit_duringWorking_stays(t *testing.T) { m, _, _ := newTestAppModel(ctrl) m.state = stateWorking - m = sendMsg(m, submitMsg{Text: "queued prompt"}) + m = sendMsg(m, core.SubmitMsg{Text: "queued prompt"}) if m.state != stateWorking { t.Fatalf("expected stateWorking to persist after submitMsg during working, got %v", m.state) diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go index 6608e4d2..a52aaef2 100644 --- a/internal/ui/overlay.go +++ b/internal/ui/overlay.go @@ -6,6 +6,8 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + + "github.com/mark3labs/kit/internal/ui/style" ) // --------------------------------------------------------------------------- @@ -133,7 +135,7 @@ func (o *overlayDialog) handleKey(msg tea.KeyPressMsg) (*overlayResult, tea.Cmd) // composition. The dialog is a bordered box centered (or anchored) // horizontally within the terminal width. func (o *overlayDialog) Render() string { - theme := GetTheme() + theme := style.GetTheme() // Calculate dialog dimensions, clamped to terminal bounds. termW := max(o.width, 10) @@ -157,7 +159,7 @@ func (o *overlayDialog) Render() string { // Render body text (potentially as markdown). bodyText := o.content if o.markdown { - bodyText = toMarkdown(bodyText, innerWidth) + bodyText = style.ToMarkdown(bodyText, innerWidth) } bodyText = strings.TrimRight(bodyText, "\n") diff --git a/internal/ui/preferences.go b/internal/ui/prefs/preferences.go similarity index 99% rename from internal/ui/preferences.go rename to internal/ui/prefs/preferences.go index 6b3fe624..e52b058c 100644 --- a/internal/ui/preferences.go +++ b/internal/ui/prefs/preferences.go @@ -1,4 +1,4 @@ -package ui +package prefs import ( "os" diff --git a/internal/ui/prompt.go b/internal/ui/prompt.go index 1c2d8b81..4ea3a128 100644 --- a/internal/ui/prompt.go +++ b/internal/ui/prompt.go @@ -7,6 +7,8 @@ import ( "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + + "github.com/mark3labs/kit/internal/ui/style" ) // --------------------------------------------------------------------------- @@ -204,7 +206,7 @@ func (p *promptOverlay) updateInput(msg tea.KeyPressMsg) (*promptResult, tea.Cmd // AppModel layout. The prompt replaces the normal input area (below the // separator and above the status bar) rather than taking over the full screen. func (p *promptOverlay) Render() string { - theme := GetTheme() + theme := style.GetTheme() var content string switch p.mode { @@ -224,7 +226,7 @@ func (p *promptOverlay) Render() string { ) } -func (p *promptOverlay) viewSelect(theme Theme) string { +func (p *promptOverlay) viewSelect(theme style.Theme) string { var lines []string lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message)) lines = append(lines, "") @@ -247,7 +249,7 @@ func (p *promptOverlay) viewSelect(theme Theme) string { return strings.Join(lines, "\n") } -func (p *promptOverlay) viewConfirm(theme Theme) string { +func (p *promptOverlay) viewConfirm(theme style.Theme) string { var lines []string lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message)) lines = append(lines, "") @@ -272,7 +274,7 @@ func (p *promptOverlay) viewConfirm(theme Theme) string { return strings.Join(lines, "\n") } -func (p *promptOverlay) viewInput(theme Theme) string { +func (p *promptOverlay) viewInput(theme style.Theme) string { var lines []string lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message)) lines = append(lines, "") diff --git a/internal/ui/scrolllist.go b/internal/ui/scrolllist.go index 79f8bace..05d6c01f 100644 --- a/internal/ui/scrolllist.go +++ b/internal/ui/scrolllist.go @@ -4,6 +4,9 @@ import ( "strings" "charm.land/lipgloss/v2" + + "github.com/mark3labs/kit/internal/ui/clipboard" + "github.com/mark3labs/kit/internal/ui/style" ) // highlightStyle is lazily initialized to avoid creating it on every render @@ -12,7 +15,7 @@ var highlightStyle lipgloss.Style // initHighlightStyle creates the highlight style with proper colors func initHighlightStyle() lipgloss.Style { if highlightStyle.String() == "" { - theme := GetTheme() + theme := style.GetTheme() highlightStyle = lipgloss.NewStyle(). Background(theme.Secondary). Foreground(theme.Background). @@ -50,11 +53,11 @@ type ScrollList struct { selectable bool // Whether items can be selected via mouse/keyboard // Selection tracking for copy+paste (crush-style) - selection CopySelection // Current text selection - mouseDown bool // Whether mouse button is currently down - mouseDownX int // X coordinate where mouse was pressed - mouseDownY int // Y coordinate where mouse was pressed - mouseDownItem int // Item index where mouse was pressed + selection clipboard.CopySelection // Current text selection + mouseDown bool // Whether mouse button is currently down + mouseDownX int // X coordinate where mouse was pressed + mouseDownY int // Y coordinate where mouse was pressed + mouseDownItem int // Item index where mouse was pressed } // NewScrollList creates a new ScrollList with the given dimensions. @@ -174,7 +177,7 @@ func (s *ScrollList) HandleMouseDown(x, y int) bool { // Start a new selection at click position if itemIdx >= 0 { - s.selection = CopySelection{ + s.selection = clipboard.CopySelection{ StartItemIdx: itemIdx, StartLine: lineIdx, StartCol: x, @@ -262,13 +265,13 @@ func (s *ScrollList) HandleMouseUp(x, y int) bool { } // GetSelection returns the current text selection. -func (s *ScrollList) GetSelection() CopySelection { +func (s *ScrollList) GetSelection() clipboard.CopySelection { return s.selection } // ClearSelection clears the current text selection. func (s *ScrollList) ClearSelection() { - s.selection = CopySelection{} + s.selection = clipboard.CopySelection{} s.mouseDown = false } diff --git a/internal/ui/session_selector.go b/internal/ui/session_selector.go index d498c9d2..6bdd5420 100644 --- a/internal/ui/session_selector.go +++ b/internal/ui/session_selector.go @@ -12,6 +12,7 @@ import ( "charm.land/lipgloss/v2" "github.com/mark3labs/kit/internal/session" + "github.com/mark3labs/kit/internal/ui/style" ) // SessionSelectedMsg is sent when the user selects a session from the picker. @@ -250,7 +251,7 @@ func (ss *SessionSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View implements tea.Model. func (ss *SessionSelectorComponent) View() tea.View { - theme := GetTheme() + theme := style.GetTheme() w := ss.width var b strings.Builder @@ -405,7 +406,7 @@ func removeByPath(sessions []session.SessionInfo, path string) []session.Session // renderEntry renders a single session line with right-aligned metadata. // Layout: [cursor 2] [message ...variable...] [padding] [count age] [cwd?] func (ss *SessionSelectorComponent) renderEntry(info session.SessionInfo, isCursor, isCurrent, isDeleting bool, width int) string { - theme := GetTheme() + theme := style.GetTheme() // ── Cursor indicator (2 chars) ─────────────────────────────── cursorStr := " " diff --git a/internal/ui/stream.go b/internal/ui/stream.go index 65712f78..9d2ca9a6 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -9,7 +9,9 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/indaco/herald" + "github.com/mark3labs/kit/internal/app" + "github.com/mark3labs/kit/internal/ui/style" ) // thinkTagRegex matches ... tags that some models (Qwen, DeepSeek) wrap @@ -31,7 +33,7 @@ func knightRiderFrames() []string { const numDots = 8 const dot = "▪" - theme := GetTheme() + theme := style.GetTheme() bright := lipgloss.NewStyle().Foreground(theme.Primary) med := lipgloss.NewStyle().Foreground(theme.Muted) diff --git a/internal/ui/enhanced_styles.go b/internal/ui/style/enhanced.go similarity index 99% rename from internal/ui/enhanced_styles.go rename to internal/ui/style/enhanced.go index b7a1e47e..0917e5f0 100644 --- a/internal/ui/enhanced_styles.go +++ b/internal/ui/style/enhanced.go @@ -1,4 +1,4 @@ -package ui +package style import ( "fmt" diff --git a/internal/ui/styles.go b/internal/ui/style/styles.go similarity index 96% rename from internal/ui/styles.go rename to internal/ui/style/styles.go index bbe7ed61..fbc6d6d1 100644 --- a/internal/ui/styles.go +++ b/internal/ui/style/styles.go @@ -1,4 +1,4 @@ -package ui +package style import ( "charm.land/lipgloss/v2" @@ -85,10 +85,10 @@ func GetMarkdownTypography() *herald.Typography { return ty } -// toMarkdown renders markdown content using herald-md. +// ToMarkdown renders markdown content using herald-md. // The width parameter is currently unused as herald handles wrapping // based on terminal width internally. -func toMarkdown(content string, width int) string { +func ToMarkdown(content string, width int) string { ty := GetMarkdownTypography() rendered := heraldmd.Render(ty, []byte(content)) return rendered diff --git a/internal/ui/themes.go b/internal/ui/style/themes.go similarity index 99% rename from internal/ui/themes.go rename to internal/ui/style/themes.go index 39d52cb1..5ff0cb33 100644 --- a/internal/ui/themes.go +++ b/internal/ui/style/themes.go @@ -1,4 +1,4 @@ -package ui +package style import ( "encoding/json" @@ -11,6 +11,8 @@ import ( "strings" "gopkg.in/yaml.v3" + + "github.com/mark3labs/kit/internal/ui/prefs" ) // --------------------------------------------------------------------------- @@ -410,10 +412,10 @@ func initThemeRegistry() { } // 2. User themes from ~/.config/kit/themes/ - scanThemesDir(userThemesDir()) + scanThemesDir(UserThemesDir()) // 3. Project-local themes from .kit/themes/ - scanThemesDir(projectThemesDir()) + scanThemesDir(ProjectThemesDir()) sortRegistry() } @@ -461,7 +463,7 @@ func removeFromRegistry(name string) { } // userThemesDir returns ~/.config/kit/themes, creating it if needed. -func userThemesDir() string { +func UserThemesDir() string { cfgDir, err := os.UserConfigDir() if err != nil { return "" @@ -473,7 +475,7 @@ func userThemesDir() string { // projectThemesDir returns .kit/themes/ relative to the working directory. // Returns "" if the directory doesn't exist (does NOT create it). -func projectThemesDir() string { +func ProjectThemesDir() string { dir := filepath.Join(".kit", "themes") info, err := os.Stat(dir) if err != nil || !info.IsDir() { @@ -525,7 +527,7 @@ func ApplyTheme(name string) error { return err } SetTheme(t) - _ = SaveThemePreference(name) + _ = prefs.SaveThemePreference(name) return nil } diff --git a/internal/ui/themes_test.go b/internal/ui/style/themes_test.go similarity index 99% rename from internal/ui/themes_test.go rename to internal/ui/style/themes_test.go index d27a7ebe..17e9f503 100644 --- a/internal/ui/themes_test.go +++ b/internal/ui/style/themes_test.go @@ -1,4 +1,4 @@ -package ui +package style import ( "testing" diff --git a/internal/ui/tree_selector.go b/internal/ui/tree_selector.go index b2371bda..d5bda4a6 100644 --- a/internal/ui/tree_selector.go +++ b/internal/ui/tree_selector.go @@ -10,6 +10,7 @@ import ( "charm.land/lipgloss/v2" "github.com/mark3labs/kit/internal/session" + "github.com/mark3labs/kit/internal/ui/core" ) // TreeFilterMode controls which entries are visible in the tree selector. @@ -138,7 +139,7 @@ func (ts *TreeSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ts.selectedID = ts.flatNodes[ts.cursor].ID ts.active = false return ts, func() tea.Msg { - return TreeNodeSelectedMsg{ + return core.TreeNodeSelectedMsg{ ID: ts.selectedID, Entry: ts.flatNodes[ts.cursor].Entry, IsUser: ts.isUserMessage(ts.flatNodes[ts.cursor].Entry), @@ -155,7 +156,7 @@ func (ts *TreeSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ts.cancelled = true ts.active = false return ts, func() tea.Msg { - return TreeCancelledMsg{} + return core.TreeCancelledMsg{} } }