Phase 1: Reorganize UI leaf utilities into subpackages

Moved leaf utility files to subpackages for better organization:
- events.go -> core/ (core message types)
- clipboard.go -> clipboard/ (clipboard operations)
- commands.go -> commands/ (slash commands)
- file_processor.go -> fileutil/ (file attachment processing)
- preferences.go -> prefs/ (theme/model preferences)
- enhanced_styles.go, styles.go, themes.go -> style/ (theming system)

Added exports.go to re-export commonly used types for backward
compatibility. External importers can still use ui.XXX without
changes.

All tests pass, basic smoke test successful.
This commit is contained in:
Ed Zynda
2026-04-01 13:54:10 +03:00
parent 7f192ae850
commit 28d2de8f39
28 changed files with 278 additions and 162 deletions
+8 -7
View File
@@ -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 {
+3 -1
View File
@@ -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
+6 -5
View File
@@ -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)
}
+3 -1
View File
@@ -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 != "" {
@@ -1,4 +1,4 @@
package ui
package clipboard
import (
"fmt"
@@ -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
}
@@ -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
}
+62
View File
@@ -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
)
@@ -1,4 +1,4 @@
package ui
package fileutil
import (
"fmt"
+3 -1
View File
@@ -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
+5 -3
View File
@@ -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 {
+29 -26
View File
@@ -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
+5 -3
View File
@@ -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
+12 -10
View File
@@ -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, "<stdout>") || strings.Contains(result, "<stderr>") {
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())
}
+67 -52
View File
@@ -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
+3 -2
View File
@@ -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)
+6 -5
View File
@@ -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)
+4 -2
View File
@@ -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")
@@ -1,4 +1,4 @@
package ui
package prefs
import (
"os"
+6 -4
View File
@@ -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, "")
+12 -9
View File
@@ -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
}
+3 -2
View File
@@ -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 := " "
+3 -1
View File
@@ -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)
@@ -1,4 +1,4 @@
package ui
package style
import (
"fmt"
@@ -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
@@ -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
}
@@ -1,4 +1,4 @@
package ui
package style
import (
"testing"
+3 -2
View File
@@ -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{}
}
}