diff --git a/btca.config.jsonc b/btca.config.jsonc index 17cb362b..aa5bcc55 100644 --- a/btca.config.jsonc +++ b/btca.config.jsonc @@ -76,6 +76,12 @@ "name": "opencode", "url": "https://github.com/anomalyco/opencode", "branch": "dev" + }, + { + "type": "git", + "name": "herald", + "url": "https://github.com/indaco/herald", + "branch": "main" } ], "model": "claude-haiku-4-5", diff --git a/cmd/root.go b/cmd/root.go index 86759d08..7ba2f504 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -799,16 +799,26 @@ func runNormalMode(ctx context.Context) error { appInstance := app.New(appOpts, messages) defer appInstance.Close() + // Buffer for extension messages during startup (printed after startup banner). + var startupExtensionMessages []string + // Set up extension context and emit SessionStart. if kitInstance.HasExtensions() { cwd, _ := os.Getwd() kitInstance.SetExtensionContext(extensions.Context{ - CWD: cwd, - Model: modelName, - Interactive: positionalPrompt == "", - Print: func(text string) { appInstance.PrintFromExtension("", text) }, - PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) }, - PrintError: func(text string) { appInstance.PrintFromExtension("error", text) }, + CWD: cwd, + Model: modelName, + Interactive: positionalPrompt == "", + Print: func(text string) { + // Capture messages during startup, print after startup banner. + startupExtensionMessages = append(startupExtensionMessages, text) + }, + PrintInfo: func(text string) { + startupExtensionMessages = append(startupExtensionMessages, text) + }, + PrintError: func(text string) { + startupExtensionMessages = append(startupExtensionMessages, text) + }, PrintBlock: appInstance.PrintBlockFromExtension, SendMessage: func(text string) { appInstance.Run(text) }, CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) }, @@ -1099,6 +1109,150 @@ func runNormalMode(ctx context.Context) error { }, }) kitInstance.EmitSessionStart() + + // Restore normal print functions for runtime use. + kitInstance.SetExtensionContext(extensions.Context{ + CWD: cwd, + Model: modelName, + Interactive: positionalPrompt == "", + Print: func(text string) { appInstance.PrintFromExtension("", text) }, + PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) }, + PrintError: func(text string) { appInstance.PrintFromExtension("error", text) }, + PrintBlock: appInstance.PrintBlockFromExtension, + SendMessage: func(text string) { appInstance.Run(text) }, + CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) }, + Exit: func() { appInstance.QuitFromExtension() }, + SetWidget: func(config extensions.WidgetConfig) { + kitInstance.SetExtensionWidget(config) + appInstance.NotifyWidgetUpdate() + }, + RemoveWidget: func(id string) { + kitInstance.RemoveExtensionWidget(id) + appInstance.NotifyWidgetUpdate() + }, + SetHeader: func(config extensions.HeaderFooterConfig) { + kitInstance.SetExtensionHeader(config) + appInstance.NotifyWidgetUpdate() + }, + RemoveHeader: func() { + kitInstance.RemoveExtensionHeader() + appInstance.NotifyWidgetUpdate() + }, + SetFooter: func(config extensions.HeaderFooterConfig) { + kitInstance.SetExtensionFooter(config) + appInstance.NotifyWidgetUpdate() + }, + RemoveFooter: func() { + kitInstance.RemoveExtensionFooter() + appInstance.NotifyWidgetUpdate() + }, + PromptSelect: func(config extensions.PromptSelectConfig) extensions.PromptSelectResult { + ch := make(chan app.PromptResponse, 1) + appInstance.SendPromptRequest(app.PromptRequestEvent{ + PromptType: "select", + Message: config.Message, + Options: config.Options, + ResponseCh: ch, + }) + resp := <-ch + if resp.Cancelled { + return extensions.PromptSelectResult{Cancelled: true} + } + return extensions.PromptSelectResult{Value: resp.Value, Index: resp.Index} + }, + PromptConfirm: func(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult { + ch := make(chan app.PromptResponse, 1) + def := "false" + if config.DefaultValue { + def = "true" + } + appInstance.SendPromptRequest(app.PromptRequestEvent{ + PromptType: "confirm", + Message: config.Message, + Default: def, + ResponseCh: ch, + }) + resp := <-ch + if resp.Cancelled { + return extensions.PromptConfirmResult{Cancelled: true} + } + return extensions.PromptConfirmResult{Value: resp.Confirmed} + }, + PromptInput: func(config extensions.PromptInputConfig) extensions.PromptInputResult { + ch := make(chan app.PromptResponse, 1) + appInstance.SendPromptRequest(app.PromptRequestEvent{ + PromptType: "input", + Message: config.Message, + Placeholder: config.Placeholder, + Default: config.Default, + ResponseCh: ch, + }) + resp := <-ch + if resp.Cancelled { + return extensions.PromptInputResult{Cancelled: true} + } + return extensions.PromptInputResult{Value: resp.Value} + }, + ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult { + ch := make(chan app.OverlayResponse, 1) + appInstance.SendOverlayRequest(app.OverlayRequestEvent{ + Title: config.Title, + Content: config.Content.Text, + Markdown: config.Content.Markdown, + BorderColor: config.Style.BorderColor, + Background: config.Style.Background, + Width: config.Width, + MaxHeight: config.MaxHeight, + Anchor: string(config.Anchor), + Actions: config.Actions, + ResponseCh: ch, + }) + resp := <-ch + if resp.Cancelled { + return extensions.OverlayResult{Cancelled: true, Index: -1} + } + return extensions.OverlayResult{ + Action: resp.Action, + Index: resp.Index, + } + }, + SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) { + // In-process subagent via SDK. + sdkCfg := kit.SubagentConfig{ + Prompt: config.Prompt, + Model: config.Model, + SystemPrompt: config.SystemPrompt, + Timeout: config.Timeout, + NoSession: config.NoSession, + } + // Bridge SDK events to extension SubagentEvents. + if config.OnEvent != nil { + sdkCfg.OnEvent = func(e kit.Event) { + se := sdkEventToSubagentEvent(e) + if se.Type != "" { + config.OnEvent(se) + } + } + } + result, err := kitInstance.Subagent(ctx, sdkCfg) + if result == nil { + return nil, &extensions.SubagentResult{Error: err}, err + } + extResult := &extensions.SubagentResult{ + Response: result.Response, + Error: result.Error, + SessionID: result.SessionID, + Elapsed: result.Elapsed, + } + if result.Usage != nil { + extResult.Usage = &extensions.SubagentUsage{ + InputTokens: result.Usage.InputTokens, + OutputTokens: result.Usage.OutputTokens, + } + } + return nil, extResult, err + }, + }) } // Convert extension commands to UI-layer type for the interactive TUI. @@ -1222,7 +1376,7 @@ func runNormalMode(ctx context.Context) error { return fmt.Errorf("--quiet requires a prompt") } - return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI) + return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, startupExtensionMessages) } // runNonInteractiveModeApp executes a single prompt via the app layer and exits, @@ -1278,7 +1432,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui // If --no-exit was requested, hand off to the interactive TUI. if noExit { - return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession) + return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession, nil) } return nil @@ -1376,7 +1530,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) error { +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 { // Determine terminal size; fall back gracefully. termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || termWidth == 0 { @@ -1426,6 +1580,14 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN // Print startup info to stdout before Bubble Tea takes over the screen. appModel.PrintStartupInfo() + // Print any extension messages that were captured during startup. + if len(startupExtensionMessages) > 0 { + fmt.Println() + for _, msg := range startupExtensionMessages { + fmt.Println(msg) + } + } + program := tea.NewProgram(appModel) // Register the program with the app layer so agent events are sent to the TUI. diff --git a/go.mod b/go.mod index 6097bb21..9d7a21cf 100644 --- a/go.mod +++ b/go.mod @@ -82,6 +82,7 @@ require ( github.com/googleapis/gax-go/v2 v2.20.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/indaco/herald v0.9.0 // indirect github.com/kaptinlin/go-i18n v0.2.12 // indirect github.com/kaptinlin/jsonpointer v0.4.17 // indirect github.com/kaptinlin/jsonschema v0.7.6 // indirect diff --git a/go.sum b/go.sum index c64bb0ac..1d58f574 100644 --- a/go.sum +++ b/go.sum @@ -187,6 +187,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/indaco/herald v0.9.0 h1:LrAfXEHkKz8WmctUKdndppIU/qFpylSbZ8galS0DVAc= +github.com/indaco/herald v0.9.0/go.mod h1:T5g1+XLYvpjouhzAGHnAHDCKizhESkoV6+QPZ3DhgWA= github.com/kaptinlin/go-i18n v0.2.12 h1:ywDsvb4KDFddMC2dpI/rrIzGU2mWUSvHmWUm9BMsdl4= github.com/kaptinlin/go-i18n v0.2.12/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU= github.com/kaptinlin/jsonpointer v0.4.17 h1:mY9k8ciWncxbsECyaxKnR0MdmxamNdp2tLQkAKVrtSk= diff --git a/internal/ui/messages.go b/internal/ui/messages.go index 7f3c8dac..4b4438cf 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -9,6 +9,7 @@ import ( "time" "charm.land/lipgloss/v2" + "github.com/indaco/herald" ) // ansiEscapeRe matches ANSI escape sequences used for terminal styling. @@ -22,9 +23,9 @@ const ( UserMessage MessageType = iota AssistantMessage ToolMessage - ToolCallMessage // New type for showing tool calls in progress - SystemMessage // New type for KIT system messages (help, tools, etc.) - ErrorMessage // New type for error messages + ToolCallMessage + SystemMessage + ErrorMessage ) // UIMessage encapsulates a fully rendered message ready for display in the UI, @@ -40,11 +41,6 @@ type UIMessage struct { Streaming bool } -// Helper functions to get theme colors -func getTheme() Theme { - return GetTheme() -} - // toolDisplayNames maps raw tool names to human-friendly display names. var toolDisplayNames = map[string]string{ "bash": "Bash", @@ -57,8 +53,12 @@ var toolDisplayNames = map[string]string{ "run_shell_cmd": "Bash", } +// getTheme returns the current theme (helper for compact_renderer.go) +func getTheme() Theme { + return GetTheme() +} + // toolDisplayName returns a human-friendly display name for a tool. -// Falls back to capitalizing the first letter of the raw name. func toolDisplayName(rawName string) string { if display, ok := toolDisplayNames[rawName]; ok { return display @@ -70,8 +70,6 @@ func toolDisplayName(rawName string) string { } // formatToolParams formats tool input parameters for inline header display. -// Extracts the primary parameter (command/filePath) first, then shows -// remaining params as (key=val, ...). Truncates to maxWidth. func formatToolParams(toolArgs string, maxWidth int) string { args := strings.TrimSpace(toolArgs) if args == "" || args == "{}" { @@ -80,7 +78,6 @@ func formatToolParams(toolArgs string, maxWidth int) string { var params map[string]any if err := json.Unmarshal([]byte(args), ¶ms); err != nil { - // Fallback: strip braces and return raw content args = strings.TrimPrefix(args, "{") args = strings.TrimSuffix(args, "}") args = strings.TrimSpace(args) @@ -94,7 +91,6 @@ func formatToolParams(toolArgs string, maxWidth int) string { return "" } - // Identify primary parameter by checking known keys in priority order primaryKeys := []string{"command", "filePath", "path", "pattern", "query", "url"} var primaryKey string var primaryVal string @@ -111,8 +107,6 @@ func formatToolParams(toolArgs string, maxWidth int) string { result.WriteString(primaryVal) } - // Collect remaining parameters, skipping body-content keys (already - // rendered in the tool body) and any values that are too large. bodyKeys := map[string]bool{ "content": true, "old_text": true, @@ -155,65 +149,35 @@ func formatToolParams(toolArgs string, maxWidth int) string { } // MessageRenderer handles the formatting and rendering of different message types -// with consistent styling, markdown support, and appropriate visual hierarchies -// for the standard (non-compact) display mode. type MessageRenderer struct { - width int - debug bool - - // getToolRenderer returns extension-provided rendering overrides for a - // specific tool. May be nil if no extensions are loaded. Used in - // RenderToolMessage to check for custom header/body formatting before - // falling back to builtin renderers. + width int + debug bool + ty *herald.Typography getToolRenderer func(toolName string) *ToolRendererData } -// newMessageRenderer creates and initializes a new MessageRenderer with the specified -// terminal width and debug mode setting. The width parameter determines line wrapping -// and layout calculations. +// newMessageRenderer creates and initializes a new MessageRenderer func newMessageRenderer(width int, debug bool) *MessageRenderer { return &MessageRenderer{ width: width, debug: debug, + ty: createTypography(GetTheme()), } } -// SetWidth updates the terminal width for the renderer, affecting how content -// is wrapped and formatted in subsequent render operations. +// SetWidth updates the terminal width for the renderer func (r *MessageRenderer) SetWidth(width int) { r.width = width } -// RenderUserMessage renders a user's input message with distinctive right-aligned -// formatting, including the system username, timestamp, and markdown-rendered content. -// The message is displayed with a colored right border for visual distinction. +// RenderUserMessage renders a user's input message func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage { - theme := getTheme() - - // Only run markdown rendering when the message contains code spans or - // fenced code blocks. Plain text is rendered directly so that newlines - // are preserved without the extra paragraph spacing glamour adds. - var messageContent string - if strings.Contains(content, "`") { - // Glamour treats single \n as a soft break, so convert to paragraph - // breaks and collapse the resulting blank lines after rendering. - mdContent := strings.ReplaceAll(content, "\n", "\n\n") - messageContent = r.renderMarkdown(mdContent, r.width-8) - messageContent = removeBlankLines(messageContent) - } else { - messageContent = content + if strings.TrimSpace(content) == "" { + content = "(empty message)" } - fullContent := strings.TrimSuffix(messageContent, "\n") - - // Left border with Blue color for user messages. - rendered := renderContentBlock( - fullContent, - r.width, - WithAlign(lipgloss.Left), - WithBorderColor(theme.Info), - WithMarginBottom(1), - ) + rendered := r.ty.Note(content) + rendered = lipgloss.NewStyle().MarginBottom(1).Render(rendered) return UIMessage{ Type: UserMessage, @@ -223,12 +187,8 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) } } -// RenderAssistantMessage renders an AI assistant's response with left-aligned formatting, -// including the model name, timestamp, and markdown-rendered content. Empty responses -// are ignored and return an empty message. The message features a colored left border -// for visual distinction. +// RenderAssistantMessage renders an AI assistant's response func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage { - // Ignore empty responses - don't render anything if strings.TrimSpace(content) == "" { return UIMessage{ Type: AssistantMessage, @@ -238,17 +198,9 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time. } } - theme := getTheme() - messageContent := r.renderMarkdown(content, r.width-8) - fullContent := strings.TrimSuffix(messageContent, "\n") - - // Left border with Primary (Mauve) color for assistant messages. - rendered := renderContentBlock( - fullContent, - r.width, - WithBorderColor(theme.Primary), - WithMarginBottom(1), - ) + // Use markdown rendering with Chroma syntax highlighting + rendered := toMarkdown(content, r.width-4) + rendered = lipgloss.NewStyle().MarginBottom(1).Render(rendered) return UIMessage{ Type: AssistantMessage, @@ -258,30 +210,14 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time. } } -// RenderSystemMessage renders KIT system messages such as help text, command outputs, -// and informational notifications. These messages are displayed with a distinctive system -// color border and "KIT System" label to differentiate them from user and AI content. +// RenderSystemMessage renders KIT system messages func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage { - theme := getTheme() - - var messageContent string if strings.TrimSpace(content) == "" { - messageContent = "No content available" - } else if strings.Contains(content, "`") { - messageContent = r.renderMarkdown(content, r.width-8) - } else { - messageContent = content + content = "No content available" } - fullContent := "◇ " + strings.TrimSuffix(messageContent, "\n") - - rendered := renderContentBlock( - fullContent, - r.width, - WithNoBorder(), - WithForeground(theme.Muted), - WithMarginBottom(1), - ) + rendered := r.ty.P("◇ " + content) + rendered = lipgloss.NewStyle().MarginBottom(1).Render(rendered) return UIMessage{ Type: SystemMessage, @@ -291,27 +227,9 @@ func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Tim } } -// RenderDebugMessage renders diagnostic and debugging information with special formatting -// including a debug icon, colored border, and structured layout. Debug messages are only -// displayed when debug mode is enabled and help developers troubleshoot issues. +// RenderDebugMessage renders diagnostic and debugging information func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time) UIMessage { - baseStyle := lipgloss.NewStyle() - - theme := getTheme() - style := baseStyle. - Width(r.width - 3). - BorderLeft(true). - Foreground(theme.Muted). - BorderForeground(theme.Tool). - BorderStyle(lipgloss.ThickBorder()). - PaddingLeft(1). - MarginLeft(2). - MarginBottom(1) - - header := baseStyle. - Foreground(theme.Tool). - Bold(true). - Render("🔍 Debug Output") + header := r.ty.H6("🔍 Debug Output") lines := strings.Split(message, "\n") var formattedLines []string @@ -321,87 +239,52 @@ func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time } } - content := baseStyle. - Foreground(theme.Muted). - Render(strings.Join(formattedLines, "\n")) - - fullContent := lipgloss.JoinVertical(lipgloss.Left, + content := r.ty.Compose( header, - content, + r.ty.P(strings.Join(formattedLines, "\n")), ) + content = lipgloss.NewStyle().MarginBottom(1).Render(content) return UIMessage{ - Content: style.Render(fullContent), - Height: lipgloss.Height(style.Render(fullContent)), + Content: content, + Height: lipgloss.Height(content), } } -// RenderDebugConfigMessage renders configuration settings in a formatted debug display -// with key-value pairs shown in a structured layout. Used to display runtime configuration -// for debugging purposes with a distinctive icon and border styling. +// RenderDebugConfigMessage renders configuration settings func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage { - baseStyle := lipgloss.NewStyle() - - theme := getTheme() - style := baseStyle. - Width(r.width - 1). - BorderLeft(true). - Foreground(theme.Muted). - BorderForeground(theme.Tool). - BorderStyle(lipgloss.ThickBorder()). - PaddingLeft(1) - - header := baseStyle. - Foreground(theme.Tool). - Bold(true). - Render("🔧 Debug Configuration") + header := r.ty.H6("🔧 Debug Configuration") var configLines []string for key, value := range config { if value != nil { - configLines = append(configLines, fmt.Sprintf(" %s: %v", key, value)) + configLines = append(configLines, fmt.Sprintf("%s: %v", key, value)) } } - configContent := baseStyle. - Foreground(theme.Muted). - Render(strings.Join(configLines, "\n")) - - parts := []string{header} + var content string if len(configLines) > 0 { - parts = append(parts, configContent) + content = r.ty.Compose( + header, + r.ty.P(strings.Join(configLines, "\n")), + ) + } else { + content = header } - - rendered := style.Render( - lipgloss.JoinVertical(lipgloss.Left, parts...), - ) + content = lipgloss.NewStyle().MarginBottom(1).Render(content) return UIMessage{ Type: SystemMessage, - Content: rendered, - Height: lipgloss.Height(rendered), + Content: content, + Height: lipgloss.Height(content), Timestamp: timestamp, } } -// RenderErrorMessage renders error notifications with distinctive red coloring and -// bold text to ensure visibility. Error messages include timestamp information and -// are displayed with an error-colored border for immediate recognition. +// RenderErrorMessage renders error notifications func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage { - theme := getTheme() - - errorContent := lipgloss.NewStyle(). - Foreground(theme.Error). - Bold(true). - Render(errorMsg) - - rendered := renderContentBlock( - errorContent, - r.width, - WithAlign(lipgloss.Left), - WithBorderColor(theme.Error), - WithMarginBottom(1), - ) + rendered := r.ty.Caution(errorMsg) + rendered = lipgloss.NewStyle().MarginBottom(1).Render(rendered) return UIMessage{ Type: ErrorMessage, @@ -411,43 +294,30 @@ func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Tim } } -// RenderToolCallMessage renders a notification that a tool is being executed, showing -// the tool name, formatted arguments (if any), and execution timestamp. The message -// uses tool-specific coloring to distinguish it from regular conversation messages. +// RenderToolCallMessage renders a notification that a tool is being executed func (r *MessageRenderer) RenderToolCallMessage(toolName, toolArgs string, timestamp time.Time) UIMessage { - // Format timestamp timeStr := timestamp.Local().Format("15:04") - // Format arguments with better presentation - theme := getTheme() var argsContent string if toolArgs != "" && toolArgs != "{}" { - argsContent = lipgloss.NewStyle(). - Foreground(theme.Muted). - Italic(true). - Render(fmt.Sprintf("Arguments: %s", r.formatToolArgs(toolArgs))) + argsContent = r.ty.Italic(fmt.Sprintf("Arguments: %s", r.formatToolArgs(toolArgs))) } - // Create info line info := fmt.Sprintf(" Executing %s (%s)", toolName, timeStr) + infoStyled := r.ty.Small(info) - // Combine parts var fullContent string if argsContent != "" { - fullContent = argsContent + "\n" + - lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info) + fullContent = r.ty.Compose( + argsContent, + infoStyled, + ) } else { - fullContent = lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info) + fullContent = infoStyled } - // Use the new block renderer - rendered := renderContentBlock( - fullContent, - r.width, - WithAlign(lipgloss.Left), - WithBorderColor(theme.Tool), - WithMarginBottom(1), - ) + rendered := r.ty.Warning(fullContent) + rendered = lipgloss.NewStyle().MarginBottom(1).Render(rendered) return UIMessage{ Type: ToolCallMessage, @@ -457,47 +327,18 @@ func (r *MessageRenderer) RenderToolCallMessage(toolName, toolArgs string, times } } -// RenderToolMessage renders a unified tool block combining the tool invocation -// header (icon + display name + params) with the execution result body. The -// border color indicates status: green for success, red for error. This replaces -// the previous two-block approach (separate call + result blocks). +// RenderToolMessage renders a unified tool block func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage { - theme := getTheme() - - // Resolve extension renderer once for all overrides. var extRd *ToolRendererData if r.getToolRenderer != nil { extRd = r.getToolRenderer(toolName) } - // --- Header: [icon] [name] [params] --- - var icon string - borderColor := theme.Success - iconColor := theme.Success - if isError { - icon = "×" - borderColor = theme.Error - iconColor = theme.Error - } else { - icon = "✓" - } - - // Extension can override border color (applies to both success and error). - if extRd != nil && extRd.BorderColor != "" { - borderColor = lipgloss.Color(extRd.BorderColor) - } - - iconStr := lipgloss.NewStyle().Foreground(iconColor).Bold(true).Render(icon) - - // Extension can override display name. displayName := toolDisplayName(toolName) if extRd != nil && extRd.DisplayName != "" { displayName = extRd.DisplayName } - nameStr := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render(displayName) - // Format params with width budget for the header line. - // Check extension renderer for custom header params first. paramBudget := max(r.width-10-len(displayName), 20) var params string if extRd != nil && extRd.RenderHeader != nil { @@ -507,69 +348,59 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin params = formatToolParams(toolArgs, paramBudget) } - header := iconStr + " " + nameStr - if params != "" { - header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params) + var icon string + if isError { + icon = "×" + } else { + icon = "✓" } - // --- Body: check extension renderer first, then builtin, then default --- + // Build the content: icon + name + params on first line, then body + headerLine := icon + " " + displayName + if params != "" { + headerLine += " " + params + } + + // Get body content var body string if extRd != nil && extRd.RenderBody != nil { body = extRd.RenderBody(toolResult, isError, r.width-8) - // Apply markdown rendering if requested and body is non-empty. if body != "" && extRd.BodyMarkdown { body = strings.TrimSuffix(toMarkdown(body, r.width-8), "\n") } } if body == "" { if isError { - body = lipgloss.NewStyle(). - Foreground(theme.Error). - Render(toolResult) + body = r.formatToolResult(toolName, toolResult) } else { body = renderToolBody(toolName, toolArgs, toolResult, r.width-8) if body == "" { - body = r.formatToolResult(toolName, toolResult, r.width-8) + body = r.formatToolResult(toolName, toolResult) } } } if strings.TrimSpace(body) == "" { - body = lipgloss.NewStyle(). - Italic(true). - Foreground(theme.Muted). - Render("(no output)") + body = r.ty.Italic("(no output)") } - // Combine header + body into a single block. - fullContent := header + "\n\n" + strings.TrimSuffix(body, "\n") - - // Build rendering options; extension can override background. - blockOpts := []renderingOption{ - WithAlign(lipgloss.Left), - WithBorderColor(borderColor), - WithMarginBottom(1), - } - if extRd != nil && extRd.Background != "" { - blockOpts = append(blockOpts, WithBackground(lipgloss.Color(extRd.Background))) - } - - rendered := renderContentBlock( - fullContent, - r.width, - blockOpts..., + // Compose: icon + name + params, then body + fullContent := r.ty.Compose( + headerLine, + "", + body, ) + fullContent = lipgloss.NewStyle().MarginBottom(1).Render(fullContent) return UIMessage{ Type: ToolMessage, - Content: rendered, - Height: lipgloss.Height(rendered), + Content: fullContent, + Height: lipgloss.Height(fullContent), } } // formatToolArgs formats tool arguments for display func (r *MessageRenderer) formatToolArgs(args string) string { - // Remove outer braces and clean up JSON formatting args = strings.TrimSpace(args) if strings.HasPrefix(args, "{") && strings.HasSuffix(args, "}") { args = strings.TrimPrefix(args, "{") @@ -577,12 +408,10 @@ func (r *MessageRenderer) formatToolArgs(args string) string { args = strings.TrimSpace(args) } - // If it's empty after cleanup, return a placeholder if args == "" { return "(no arguments)" } - // Truncate if too long, but skip truncation in debug mode if !r.debug { maxLen := 100 if len(args) > maxLen { @@ -594,10 +423,7 @@ func (r *MessageRenderer) formatToolArgs(args string) string { } // formatToolResult formats tool results based on tool type -func (r *MessageRenderer) formatToolResult(toolName, result string, width int) string { - baseStyle := lipgloss.NewStyle() - - // Truncate very long results only if not in debug mode +func (r *MessageRenderer) formatToolResult(toolName, result string) string { if !r.debug { maxLines := 10 lines := strings.Split(result, "\n") @@ -606,51 +432,39 @@ func (r *MessageRenderer) formatToolResult(toolName, result string, width int) s } } - // Format bash/command output with better formatting - if strings.Contains(toolName, "bash") || strings.Contains(toolName, "command") || strings.Contains(toolName, "shell") || toolName == "run_shell_cmd" { - theme := getTheme() - - // Split result into sections if it contains both stdout and stderr + if strings.Contains(toolName, "bash") || strings.Contains(toolName, "command") || + strings.Contains(toolName, "shell") || toolName == "run_shell_cmd" { if strings.Contains(result, "") || strings.Contains(result, "") { - return r.formatBashOutput(result, width, theme) + return parseBashOutput(result, GetTheme()) } - - // For simple output, just render as monospace text with proper line breaks - return baseStyle. - Width(width). - Foreground(theme.Muted). - Render(result) } - // For other tools, render as muted text - theme := getTheme() - return baseStyle. - Width(width). - Foreground(theme.Muted). - Render(result) + return result } -// formatBashOutput formats bash command output with proper section handling. -// Delegates tag parsing to the shared parseBashOutput helper. -func (r *MessageRenderer) formatBashOutput(result string, width int, theme Theme) string { - parsed := parseBashOutput(result, theme) - return lipgloss.NewStyle(). - Width(width). - Foreground(theme.Muted). - Render(parsed) -} - -// renderMarkdown renders markdown content using glamour -func (r *MessageRenderer) renderMarkdown(content string, width int) string { - rendered := toMarkdown(content, width) - return strings.TrimSuffix(rendered, "\n") +// createTypography creates a typography instance from theme +func createTypography(theme Theme) *herald.Typography { + return herald.New( + herald.WithPalette(herald.ColorPalette{ + Primary: theme.Primary, + Secondary: theme.Secondary, + Tertiary: theme.Info, + Accent: theme.Accent, + Highlight: theme.Highlight, + Muted: theme.Muted, + Text: theme.Text, + Surface: theme.Background, + Base: theme.CodeBg, + }), + herald.WithCodeLineNumbers(true), + // Customize alert labels + herald.WithAlertLabel(herald.AlertNote, "You"), + herald.WithAlertLabel(herald.AlertWarning, "Working"), + herald.WithAlertLabel(herald.AlertCaution, "Error"), + ) } // removeBlankLines removes lines that are visually blank from rendered output. -// Glamour wraps every character (including padding spaces) with ANSI color -// codes, so we must strip escape sequences before checking whether a line is -// empty. This collapses paragraph spacing so user messages render without -// extra vertical gaps. func removeBlankLines(s string) string { lines := strings.Split(s, "\n") filtered := lines[:0] diff --git a/internal/ui/model.go b/internal/ui/model.go index e8522a77..6297fa99 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -786,28 +786,29 @@ func (m *AppModel) PrintStartupInfo() { return } - render := func(text string) string { - return m.renderer.RenderSystemMessage(text, time.Now()).Content - } + // Create typography instance for startup rendering + ty := createTypography(GetTheme()) fmt.Println() - // Build the combined startup content. - var lines []string + // Build key-value pairs for startup info + var pairs [][2]string if m.providerName != "" && m.modelName != "" { - lines = append(lines, fmt.Sprintf("Model loaded: %s (%s)", m.providerName, m.modelName)) + pairs = append(pairs, [2]string{"Model", fmt.Sprintf("%s (%s)", m.providerName, m.modelName)}) } if m.loadingMessage != "" { - lines = append(lines, m.loadingMessage) + pairs = append(pairs, [2]string{"Status", m.loadingMessage}) } // Context — loaded AGENTS.md files. if len(m.contextPaths) > 0 { - for _, p := range m.contextPaths { - lines = append(lines, fmt.Sprintf("Context: %s", tildeHome(p))) + contextStr := tildeHome(m.contextPaths[0]) + if len(m.contextPaths) > 1 { + contextStr += fmt.Sprintf(" +%d more", len(m.contextPaths)-1) } + pairs = append(pairs, [2]string{"Context", contextStr}) } // Skills — listed by name. @@ -816,21 +817,22 @@ func (m *AppModel) PrintStartupInfo() { for i, si := range m.skillItems { names[i] = si.Name } - lines = append(lines, fmt.Sprintf("Skills: %s", strings.Join(names, ", "))) + pairs = append(pairs, [2]string{"Skills", strings.Join(names, ", ")}) } // Extension tool count (only shown when > 0). if m.extensionToolCount > 0 { - lines = append(lines, fmt.Sprintf("Loaded %d extension tools", m.extensionToolCount)) + pairs = append(pairs, [2]string{"Extensions", fmt.Sprintf("%d tools", m.extensionToolCount)}) } // MCP tool count (only shown when > 0). if m.mcpToolCount > 0 { - lines = append(lines, fmt.Sprintf("Loaded %d tools from MCP servers", m.mcpToolCount)) + pairs = append(pairs, [2]string{"MCP", fmt.Sprintf("%d tools", m.mcpToolCount)}) } - if len(lines) > 0 { - fmt.Println(render(strings.Join(lines, "\n\n"))) + if len(pairs) > 0 { + rendered := ty.KVGroup(pairs) + fmt.Println(rendered) } } diff --git a/internal/ui/stream.go b/internal/ui/stream.go index 895d7f6d..834bc2f4 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/indaco/herald" "github.com/mark3labs/kit/internal/app" ) @@ -216,6 +217,9 @@ type StreamComponent struct { // height constrains the render output to at most this many lines. // 0 means unconstrained. height int + + // ty provides typography functions for rendering text. + ty *herald.Typography } // NewStreamComponent creates a new StreamComponent ready to be embedded in AppModel. @@ -236,6 +240,7 @@ func NewStreamComponent(compactMode bool, width int, modelName string) *StreamCo modelName: modelName, renderer: renderer, width: width, + ty: createTypography(GetTheme()), } } @@ -511,37 +516,26 @@ func (s *StreamComponent) viewContent(fullContent string) string { return fullContent } -// renderReasoningBlock renders the reasoning/thinking content in a surface-tinted -// box. When collapsed, shows the last 10 lines with a truncation hint. When +// renderReasoningBlock renders the reasoning/thinking content using blockquote. +// When collapsed, shows the last 10 lines with a truncation hint. When // expanded, shows all lines. Includes a "Thought for Xs" duration footer. func (s *StreamComponent) renderReasoningBlock(reasoning string) string { - theme := GetTheme() - maxWidth := max(s.width-4, 20) - lines := strings.Split(strings.TrimRight(reasoning, "\n"), "\n") - contentStyle := lipgloss.NewStyle(). - Foreground(theme.Muted). - Background(theme.MutedBorder). - Italic(true) - var parts []string // When collapsed and content exceeds 10 lines, show only the last 10 - // with a truncation hint (matching iteratr's thinking block pattern). + // with a truncation hint. const maxCollapsedLines = 10 if !s.thinkingVisible && len(lines) > maxCollapsedLines { hidden := len(lines) - maxCollapsedLines - hintStyle := lipgloss.NewStyle(). - Foreground(theme.VeryMuted). - Background(theme.MutedBorder). - Italic(true) - parts = append(parts, hintStyle.Render(fmt.Sprintf("... (%d lines hidden)", hidden))) + parts = append(parts, s.ty.Italic(fmt.Sprintf("... (%d lines hidden)", hidden))) lines = lines[len(lines)-maxCollapsedLines:] } - // Render reasoning text. - parts = append(parts, contentStyle.Width(maxWidth).Render(strings.Join(lines, "\n"))) + // Main content using blockquote for visual distinction. + content := strings.Join(lines, "\n") + parts = append(parts, s.ty.Blockquote(content)) // Duration footer. var duration time.Duration @@ -557,21 +551,11 @@ func (s *StreamComponent) renderReasoningBlock(reasoning string) string { } else { durationStr = fmt.Sprintf("%.1fs", duration.Seconds()) } - footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Background(theme.MutedBorder).Render("Thought for ") + - lipgloss.NewStyle().Foreground(theme.Info).Background(theme.MutedBorder).Render(durationStr) + footer := s.ty.Small(fmt.Sprintf("Thought for %s", durationStr)) parts = append(parts, footer) } - innerContent := strings.Join(parts, "\n") - - // Wrap in box with surface background for visual distinction. - boxStyle := lipgloss.NewStyle(). - Background(theme.MutedBorder). // Surface0 (#313244) - PaddingLeft(1). - Width(maxWidth + 2). - MarginBottom(1) - - return boxStyle.Render(innerContent) + return s.ty.Compose(parts...) } // SetThinkingVisible sets whether reasoning blocks are shown or collapsed. diff --git a/internal/ui/tool_renderers.go b/internal/ui/tool_renderers.go index a47ac5e6..9d55c24e 100644 --- a/internal/ui/tool_renderers.go +++ b/internal/ui/tool_renderers.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "path/filepath" "regexp" "strconv" "strings" @@ -15,8 +16,89 @@ import ( "github.com/alecthomas/chroma/v2/styles" udiff "github.com/aymanbagabas/go-udiff" xansi "github.com/charmbracelet/x/ansi" + "github.com/indaco/herald" ) +// detectLanguage extracts the language from a filename for syntax highlighting. +func detectLanguage(fileName string) string { + ext := strings.ToLower(filepath.Ext(fileName)) + ext = strings.TrimPrefix(ext, ".") + + // Map common extensions to language names + langMap := map[string]string{ + "go": "go", + "py": "python", + "js": "javascript", + "ts": "typescript", + "jsx": "jsx", + "tsx": "tsx", + "rs": "rust", + "java": "java", + "cpp": "cpp", + "c": "c", + "h": "c", + "hpp": "cpp", + "cs": "csharp", + "rb": "ruby", + "php": "php", + "swift": "swift", + "kt": "kotlin", + "scala": "scala", + "r": "r", + "sql": "sql", + "sh": "bash", + "bash": "bash", + "zsh": "zsh", + "fish": "fish", + "ps1": "powershell", + "yaml": "yaml", + "yml": "yaml", + "json": "json", + "toml": "toml", + "xml": "xml", + "html": "html", + "htm": "html", + "css": "css", + "scss": "scss", + "sass": "sass", + "less": "less", + "md": "markdown", + "dockerfile": "dockerfile", + "makefile": "makefile", + "vim": "vim", + "lua": "lua", + "perl": "perl", + "pl": "perl", + "haskell": "haskell", + "hs": "haskell", + "erlang": "erlang", + "erl": "erlang", + "elixir": "elixir", + "ex": "elixir", + "exs": "elixir", + "clojure": "clojure", + "clj": "clojure", + "lisp": "lisp", + "scheme": "scheme", + "racket": "racket", + "ocaml": "ocaml", + "ml": "ocaml", + "fsharp": "fsharp", + "fs": "fsharp", + "fsx": "fsharp", + "dart": "dart", + "flutter": "dart", + "julia": "julia", + "groovy": "groovy", + "gradle": "groovy", + } + + if lang, ok := langMap[ext]; ok { + return lang + } + return ext +} + // Maximum visible lines per tool type before truncation. const ( maxDiffLines = 20 // side-by-side rows for Edit @@ -374,8 +456,7 @@ func renderLsBody(toolResult string, width int) string { // Read tool — code block with line numbers + syntax highlighting // --------------------------------------------------------------------------- -// renderReadBody renders Read tool output with styled line numbers and optional -// syntax highlighting based on file extension. +// renderReadBody renders Read tool output using herald's CodeBlock with line numbers. func renderReadBody(toolArgs, toolResult string, width int) string { if strings.TrimSpace(toolResult) == "" { return "" @@ -390,121 +471,39 @@ func renderReadBody(toolArgs, toolResult string, width int) string { } } - return renderCodeBlock(toolResult, fileName, width) -} - -// codeLine holds a parsed line with optional line number. -type codeLine struct { - lineNum string - code string -} - -// renderCodeBlock renders content with a styled gutter (line numbers) and -// optional syntax highlighting. -func renderCodeBlock(content, fileName string, width int) string { - rawLines := strings.Split(content, "\n") - - // Parse lines: detect "N: content" format from Read tool - var parsed []codeLine - maxNumWidth := 0 - var codeOnly []string - - for _, line := range rawLines { + // Parse lines and extract just the code content (removing "N: " prefix) + lines := strings.Split(toolResult, "\n") + var codeLines []string + for _, line := range lines { if idx := strings.Index(line, ": "); idx > 0 && idx <= 7 { numPart := line[:idx] if _, err := strconv.Atoi(strings.TrimSpace(numPart)); err == nil { - parsed = append(parsed, codeLine{lineNum: numPart, code: line[idx+2:]}) - if len(numPart) > maxNumWidth { - maxNumWidth = len(numPart) - } - codeOnly = append(codeOnly, line[idx+2:]) + codeLines = append(codeLines, line[idx+2:]) continue } } - // No line number — treat as metadata/footer - parsed = append(parsed, codeLine{code: line}) - codeOnly = append(codeOnly, line) + codeLines = append(codeLines, line) } - if len(parsed) == 0 { - return "" + content := strings.Join(codeLines, "\n") + + // Truncate if too long + if len(codeLines) > maxCodeLines { + content = strings.Join(codeLines[:maxCodeLines], "\n") } - // Truncate to maxCodeLines visible lines (preserve footer/metadata lines) - var codeHiddenCount int - totalParsed := len(parsed) - if totalParsed > maxCodeLines { - // Check if last line is a footer (no line number) — keep it - var footerLines []codeLine - for totalParsed > 0 && parsed[totalParsed-1].lineNum == "" { - footerLines = append([]codeLine{parsed[totalParsed-1]}, footerLines...) - totalParsed-- - } - if totalParsed > maxCodeLines { - codeHiddenCount = totalParsed - maxCodeLines - parsed = append(parsed[:maxCodeLines], footerLines...) - codeOnly = codeOnly[:maxCodeLines] - for _, fl := range footerLines { - codeOnly = append(codeOnly, fl.code) - } - } else { - // Restore — footer trimming was enough - parsed = parsed[:totalParsed] - parsed = append(parsed, footerLines...) - } - } + // Detect language from filename + lang := detectLanguage(fileName) - // Syntax highlight the code portion - highlighted := syntaxHighlight(strings.Join(codeOnly, "\n"), fileName) - highlightedLines := strings.Split(highlighted, "\n") + // Use herald's CodeBlock with line numbers + ty := herald.New( + herald.WithCodeLineNumbers(true), + herald.WithCodeFormatter(func(code, language string) string { + return syntaxHighlight(code, fileName) + }), + ) - // Layout - const codeIndent = " " - gutterWidth := max(maxNumWidth+2, 5) - codeWidth := max(width-gutterWidth-len(codeIndent), 20) - - theme := getTheme() - gutterStyle := lipgloss.NewStyle().Foreground(theme.Muted).Background(theme.GutterBg).PaddingRight(1) - codeStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1) - - var result []string - for i, p := range parsed { - // If this line has no line number, it's a metadata/footer line (e.g. truncation notice). - if p.lineNum == "" { - // Render footer lines with code background but no gutter - truncatedFooter := truncateLine(p.code, codeWidth-1) // account for PaddingLeft(1) - footer := codeStyle.Width(codeWidth).Render(truncatedFooter) - emptyGutter := gutterStyle.Width(gutterWidth).Render("") - result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, emptyGutter, footer)) - continue - } - - gutter := gutterStyle.Width(gutterWidth).Render(p.lineNum) - - var codePart string - if i < len(highlightedLines) { - codePart = highlightedLines[i] - } else { - codePart = p.code - } - // Truncate the (possibly ANSI-highlighted) line to fit within - // the code column, preventing lipgloss from wrapping it. - codePart = truncateLine(codePart, codeWidth-1) // account for PaddingLeft(1) - styledCode := codeStyle.Width(codeWidth).Render(codePart) - - result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, gutter, styledCode)) - } - - // Truncation hint - if codeHiddenCount > 0 { - hint := fmt.Sprintf("...(%d more lines)", codeHiddenCount) - emptyGutter := gutterStyle.Width(gutterWidth).Render("") - hintContent := codeStyle.Width(codeWidth). - Foreground(theme.Muted).Italic(true).Render(hint) - result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, emptyGutter, hintContent)) - } - - return strings.Join(result, "\n") + return ty.CodeBlock(content, lang) } // ---------------------------------------------------------------------------