refactor(ui): replace custom message rendering with herald typography library

- Replace MessageRenderer with herald-based implementation
- Use herald alerts (Note, Tip, Warning, Caution) for message types
- Use blockquote for thinking/reasoning content
- Use KVGroup for startup info display
- Add margin-bottom to all message types for visual separation
- Simplify Read tool with herald CodeBlock and line numbers
- Add detectLanguage helper for syntax highlighting
- Capture extension startup messages and print after startup banner
- Remove ~200 lines of custom rendering code
This commit is contained in:
Ed Zynda
2026-03-27 20:54:43 +03:00
parent b68b3dd0bf
commit f12e195390
8 changed files with 424 additions and 454 deletions
+6
View File
@@ -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",
+171 -9
View File
@@ -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.
+1
View File
@@ -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
+2
View File
@@ -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=
+110 -296
View File
@@ -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), &params); 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, "<stdout>") || strings.Contains(result, "<stderr>") {
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]
+16 -14
View File
@@ -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)
}
}
+14 -30
View File
@@ -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.
+104 -105
View File
@@ -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)
}
// ---------------------------------------------------------------------------