diff --git a/cmd/root.go b/cmd/root.go index ecf2685c..b83fb431 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -330,6 +330,48 @@ func widgetProviderForUI(k *kit.Kit) func(string) []ui.WidgetData { } } +// headerProviderForUI returns a function that converts the extension header +// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled, +// which is safe — the UI treats a nil GetHeader as "no header". +func headerProviderForUI(k *kit.Kit) func() *ui.WidgetData { + if !k.HasExtensions() { + return nil + } + return func() *ui.WidgetData { + config := k.GetExtensionHeader() + if config == nil { + return nil + } + return &ui.WidgetData{ + Text: config.Content.Text, + Markdown: config.Content.Markdown, + BorderColor: config.Style.BorderColor, + NoBorder: config.Style.NoBorder, + } + } +} + +// footerProviderForUI returns a function that converts the extension footer +// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled, +// which is safe — the UI treats a nil GetFooter as "no footer". +func footerProviderForUI(k *kit.Kit) func() *ui.WidgetData { + if !k.HasExtensions() { + return nil + } + return func() *ui.WidgetData { + config := k.GetExtensionFooter() + if config == nil { + return nil + } + return &ui.WidgetData{ + Text: config.Content.Text, + Markdown: config.Content.Markdown, + BorderColor: config.Style.BorderColor, + NoBorder: config.Style.NoBorder, + } + } +} + func runNormalMode(ctx context.Context) error { // Validate flag combinations if quietFlag && promptFlag == "" { @@ -464,6 +506,22 @@ func runNormalMode(ctx context.Context) error { 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{ @@ -537,9 +595,14 @@ func runNormalMode(ctx context.Context) error { }) } + // Build extension UI providers once (shared between both modes). + getWidgets := widgetProviderForUI(kitInstance) + getHeader := headerProviderForUI(kitInstance) + getFooter := footerProviderForUI(kitInstance) + // Check if running in non-interactive mode if promptFlag != "" { - return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, widgetProviderForUI(kitInstance)) + return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter) } // Quiet mode is not allowed in interactive mode @@ -547,7 +610,7 @@ func runNormalMode(ctx context.Context) error { return fmt.Errorf("--quiet flag can only be used with --prompt/-p") } - return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, widgetProviderForUI(kitInstance)) + return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter) } // runNonInteractiveModeApp executes a single prompt via the app layer and exits, @@ -560,7 +623,7 @@ func runNormalMode(ctx context.Context) error { // // When --no-exit is set, after the prompt completes the interactive BubbleTea // TUI is started so the user can continue the conversation. -func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData) error { +func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData) error { if quiet { // Quiet mode: no intermediate display, just print final response. if err := appInstance.RunOnce(ctx, prompt); err != nil { @@ -586,7 +649,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, contextPaths, skillItems, getWidgets) + return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter) } return nil @@ -603,7 +666,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui // 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, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData) error { +func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData) error { // Determine terminal size; fall back gracefully. termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || termWidth == 0 { @@ -627,6 +690,8 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN ContextPaths: contextPaths, SkillItems: skillItems, GetWidgets: getWidgets, + GetHeader: getHeader, + GetFooter: getFooter, }) // Print startup info to stdout before Bubble Tea takes over the screen. diff --git a/examples/extensions/header-footer-demo.go b/examples/extensions/header-footer-demo.go new file mode 100644 index 00000000..42d36c32 --- /dev/null +++ b/examples/extensions/header-footer-demo.go @@ -0,0 +1,120 @@ +//go:build ignore + +package main + +import ( + "fmt" + "time" + + "kit/ext" +) + +// Init demonstrates the custom header/footer system. The header shows +// project context (branch, CWD) and the footer shows a running summary +// of agent activity. Slash commands toggle them on/off. +func Init(api ext.API) { + var turnCount int + var lastResponse string + + // Show a custom header with project context when the session starts. + api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) { + ctx.SetHeader(ext.HeaderFooterConfig{ + Content: ext.WidgetContent{ + Text: fmt.Sprintf("Project: %s | Model: %s | %s", + ctx.CWD, ctx.Model, time.Now().Format("Jan 2, 15:04")), + }, + Style: ext.WidgetStyle{ + BorderColor: "#89b4fa", + }, + }) + + ctx.SetFooter(ext.HeaderFooterConfig{ + Content: ext.WidgetContent{ + Text: "Ready | 0 turns", + }, + Style: ext.WidgetStyle{ + BorderColor: "#a6e3a1", + }, + }) + }) + + // Update footer after each agent turn with activity summary. + api.OnAgentEnd(func(ae ext.AgentEndEvent, ctx ext.Context) { + turnCount++ + lastResponse = ae.Response + if len(lastResponse) > 60 { + lastResponse = lastResponse[:57] + "..." + } + + ctx.SetFooter(ext.HeaderFooterConfig{ + Content: ext.WidgetContent{ + Text: fmt.Sprintf("Turns: %d | Last: %s | %s", + turnCount, ae.StopReason, time.Now().Format("15:04:05")), + }, + Style: ext.WidgetStyle{ + BorderColor: "#a6e3a1", + }, + }) + }) + + // /header-off — remove the custom header. + api.RegisterCommand(ext.CommandDef{ + Name: "header-off", + Description: "Remove the custom header", + Execute: func(_ string, ctx ext.Context) (string, error) { + ctx.RemoveHeader() + return "Header removed.", nil + }, + }) + + // /header-on — restore the custom header. + api.RegisterCommand(ext.CommandDef{ + Name: "header-on", + Description: "Restore the custom header", + Execute: func(_ string, ctx ext.Context) (string, error) { + ctx.SetHeader(ext.HeaderFooterConfig{ + Content: ext.WidgetContent{ + Text: fmt.Sprintf("Project: %s | Model: %s | %s", + ctx.CWD, ctx.Model, time.Now().Format("Jan 2, 15:04")), + }, + Style: ext.WidgetStyle{ + BorderColor: "#89b4fa", + }, + }) + return "Header restored.", nil + }, + }) + + // /footer-off — remove the custom footer. + api.RegisterCommand(ext.CommandDef{ + Name: "footer-off", + Description: "Remove the custom footer", + Execute: func(_ string, ctx ext.Context) (string, error) { + ctx.RemoveFooter() + return "Footer removed.", nil + }, + }) + + // /footer-on — restore the custom footer. + api.RegisterCommand(ext.CommandDef{ + Name: "footer-on", + Description: "Restore the custom footer", + Execute: func(_ string, ctx ext.Context) (string, error) { + ctx.SetFooter(ext.HeaderFooterConfig{ + Content: ext.WidgetContent{ + Text: fmt.Sprintf("Turns: %d | %s", turnCount, time.Now().Format("15:04:05")), + }, + Style: ext.WidgetStyle{ + BorderColor: "#a6e3a1", + }, + }) + return "Footer restored.", nil + }, + }) + + // Clean up on shutdown. + api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) { + ctx.RemoveHeader() + ctx.RemoveFooter() + }) +} diff --git a/internal/extensions/api.go b/internal/extensions/api.go index 6d2a50d8..62197db1 100644 --- a/internal/extensions/api.go +++ b/internal/extensions/api.go @@ -83,6 +83,38 @@ type Context struct { // the ID does not exist. RemoveWidget func(id string) + // SetHeader places a custom header at the top of the TUI view, above + // the stream region. Only one header can be active at a time; calling + // SetHeader replaces any previous header. The header persists across + // agent turns until explicitly removed. + // + // Example: + // + // ctx.SetHeader(ext.HeaderFooterConfig{ + // Content: ext.WidgetContent{Text: "Project: my-app | Branch: main"}, + // Style: ext.WidgetStyle{BorderColor: "#89b4fa"}, + // }) + SetHeader func(HeaderFooterConfig) + + // RemoveHeader removes the custom header. No-op if no header is set. + RemoveHeader func() + + // SetFooter places a custom footer at the bottom of the TUI view, + // below the status bar. Only one footer can be active at a time; + // calling SetFooter replaces any previous footer. The footer persists + // across agent turns until explicitly removed. + // + // Example: + // + // ctx.SetFooter(ext.HeaderFooterConfig{ + // Content: ext.WidgetContent{Text: "Ready | 3 tasks remaining"}, + // Style: ext.WidgetStyle{BorderColor: "#a6e3a1"}, + // }) + SetFooter func(HeaderFooterConfig) + + // RemoveFooter removes the custom footer. No-op if no footer is set. + RemoveFooter func() + // PromptSelect shows a selection list to the user and blocks until // they pick an option or cancel (ESC). Returns a cancelled result in // non-interactive mode. Safe to call from event handlers and slash @@ -369,6 +401,22 @@ type PromptInputResult struct { Cancelled bool } +// --------------------------------------------------------------------------- +// Header/Footer types (exposed to Yaegi — concrete structs) +// --------------------------------------------------------------------------- + +// HeaderFooterConfig describes a custom header or footer region that replaces +// or augments the default TUI chrome. Extensions use ctx.SetHeader/SetFooter +// to place one; only one header and one footer can be active at a time (the +// latest call wins). Reuses WidgetContent and WidgetStyle for consistency. +type HeaderFooterConfig struct { + // Content describes what to render. + Content WidgetContent + + // Style configures the appearance. + Style WidgetStyle +} + // --------------------------------------------------------------------------- // ToolDef / CommandDef // --------------------------------------------------------------------------- diff --git a/internal/extensions/runner.go b/internal/extensions/runner.go index 33c43840..98964c5a 100644 --- a/internal/extensions/runner.go +++ b/internal/extensions/runner.go @@ -15,6 +15,8 @@ type Runner struct { extensions []LoadedExtension ctx Context widgets map[string]WidgetConfig // keyed by widget ID + header *HeaderFooterConfig // nil = no custom header + footer *HeaderFooterConfig // nil = no custom footer mu sync.RWMutex } @@ -173,6 +175,64 @@ func (r *Runner) GetWidgets(placement WidgetPlacement) []WidgetConfig { return result } +// --------------------------------------------------------------------------- +// Header/Footer management +// --------------------------------------------------------------------------- + +// SetHeader places or replaces the custom header. Thread-safe. +func (r *Runner) SetHeader(config HeaderFooterConfig) { + r.mu.Lock() + defer r.mu.Unlock() + r.header = &config +} + +// RemoveHeader removes the custom header. No-op if none is set. Thread-safe. +func (r *Runner) RemoveHeader() { + r.mu.Lock() + defer r.mu.Unlock() + r.header = nil +} + +// GetHeader returns the current custom header, or nil if none is set. +// Thread-safe. +func (r *Runner) GetHeader() *HeaderFooterConfig { + r.mu.RLock() + defer r.mu.RUnlock() + if r.header == nil { + return nil + } + // Return a copy to avoid races on the caller side. + h := *r.header + return &h +} + +// SetFooter places or replaces the custom footer. Thread-safe. +func (r *Runner) SetFooter(config HeaderFooterConfig) { + r.mu.Lock() + defer r.mu.Unlock() + r.footer = &config +} + +// RemoveFooter removes the custom footer. No-op if none is set. Thread-safe. +func (r *Runner) RemoveFooter() { + r.mu.Lock() + defer r.mu.Unlock() + r.footer = nil +} + +// GetFooter returns the current custom footer, or nil if none is set. +// Thread-safe. +func (r *Runner) GetFooter() *HeaderFooterConfig { + r.mu.RLock() + defer r.mu.RUnlock() + if r.footer == nil { + return nil + } + // Return a copy to avoid races on the caller side. + f := *r.footer + return &f +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/internal/extensions/symbols.go b/internal/extensions/symbols.go index 245c1da6..33d15473 100644 --- a/internal/extensions/symbols.go +++ b/internal/extensions/symbols.go @@ -34,6 +34,9 @@ func Symbols() interp.Exports { "WidgetAbove": reflect.ValueOf(WidgetAbove), "WidgetBelow": reflect.ValueOf(WidgetBelow), + // Header/Footer types + "HeaderFooterConfig": reflect.ValueOf((*HeaderFooterConfig)(nil)), + // Prompt types "PromptSelectConfig": reflect.ValueOf((*PromptSelectConfig)(nil)), "PromptSelectResult": reflect.ValueOf((*PromptSelectResult)(nil)), diff --git a/internal/ui/model.go b/internal/ui/model.go index 605b6eaa..52d7dd2f 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -146,6 +146,16 @@ type AppModelOptions struct { // ("above" or "below"). Called during View() to render persistent // extension widgets. May be nil if no extensions are loaded. GetWidgets func(placement string) []WidgetData + + // GetHeader returns the current custom header set by an extension, or + // nil if no header is active. Called during View() to render a + // persistent header above the stream region. May be nil. + GetHeader func() *WidgetData + + // GetFooter returns the current custom footer set by an extension, or + // nil if no footer is active. Called during View() to render a + // persistent footer below the status bar. May be nil. + GetFooter func() *WidgetData } // AppModel is the root Bubble Tea model for the interactive TUI. It owns the @@ -155,13 +165,17 @@ type AppModelOptions struct { // // Layout (stacked, no alt screen): // -// ┌─ stream region (variable height) ─────────────────┐ +// ┌─ [custom header] (optional, from extension) ──────┐ +// ├─ stream region (variable height) ─────────────────┤ // │ │ // ├─ separator line (with optional queue count) ───────┤ +// │ [above widgets] │ // │ queued How do I fix the build? │ // │ queued Also check the tests │ // ├─ input region (fixed height from textarea) ────────┤ +// │ [below widgets] │ // │ Tokens: 23.4K (12%) | Cost: $0.00 provider·model │ +// ├─ [custom footer] (optional, from extension) ──────┤ // └────────────────────────────────────────────────────┘ // // The status bar is always present (1 line) to avoid layout shifts that @@ -240,6 +254,12 @@ type AppModel struct { // getWidgets returns extension widgets for a given placement. May be nil. getWidgets func(placement string) []WidgetData + // getHeader returns the current custom header. May be nil. + getHeader func() *WidgetData + + // getFooter returns the current custom footer. May be nil. + getFooter func() *WidgetData + // prompt holds the state of an active interactive prompt overlay. Nil // when no prompt is active. Managed by updatePromptState(). prompt *promptOverlay @@ -333,6 +353,8 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { // Store extension commands for dispatch. m.extensionCommands = opts.ExtensionCommands m.getWidgets = opts.GetWidgets + m.getHeader = opts.GetHeader + m.getFooter = opts.GetFooter // Store context/skills metadata and tool counts for startup display. m.contextPaths = opts.ContextPaths @@ -852,10 +874,17 @@ func (m *AppModel) View() tea.View { } statusBar := m.renderStatusBar() + // Build the stacked layout. Optional header/footer wrap the core layout. + var parts []string + + // Custom header (if set by extension) — above everything. + if headerView := m.renderHeaderFooter(m.getHeader); headerView != "" { + parts = append(parts, headerView) + } + // Only include the stream region when it has content. When idle the // stream renders "" which JoinVertical would pad to a full-width blank // line, inflating the view unnecessarily. - var parts []string if streamView != "" { parts = append(parts, streamView) } @@ -879,6 +908,11 @@ func (m *AppModel) View() tea.View { parts = append(parts, statusBar) + // Custom footer (if set by extension) — below everything. + if footerView := m.renderHeaderFooter(m.getFooter); footerView != "" { + parts = append(parts, footerView) + } + content := lipgloss.JoinVertical(lipgloss.Left, parts...) return tea.NewView(content) @@ -1019,6 +1053,40 @@ func (m *AppModel) renderWidgetSlot(placement string) string { return strings.Join(blocks, "\n") } +// renderHeaderFooter renders a custom header or footer from an extension. The +// getter function returns the current data (*WidgetData) or nil when inactive. +// Returns "" when the getter is nil or returns nil. Uses the same rendering +// pipeline as widgets for visual consistency. +func (m *AppModel) renderHeaderFooter(getter func() *WidgetData) string { + if getter == nil { + return "" + } + data := getter() + if data == nil { + return "" + } + + theme := GetTheme() + + var opts []renderingOption + opts = append(opts, WithAlign(lipgloss.Left)) + + if data.NoBorder { + opts = append(opts, WithNoBorder()) + } else { + borderClr := theme.Accent + if data.BorderColor != "" { + borderClr = lipgloss.Color(data.BorderColor) + } + opts = append(opts, WithBorderColor(borderClr)) + } + + // Compact padding like widgets. + opts = append(opts, WithPaddingTop(0), WithPaddingBottom(0)) + + return renderContentBlock(data.Text, m.width, opts...) +} + // renderQueuedMessages renders queued prompts as styled content blocks with a // "QUEUED" badge, anchored between the separator and input. Each message is // displayed in a bordered block matching the overall message styling. @@ -1392,13 +1460,15 @@ func (m *AppModel) flushStreamContent() tea.Cmd { // // Layout (line counts): // -// stream region = total - separator(1) - widgets - queued(N*5) - input(measured) - widgets - statusBar(1) +// header = measured dynamically (0 if not set) +// stream region = total - header - separator(1) - widgets - queued(N*5) - input(measured) - widgets - statusBar(1) - footer // separator = 1 line // above widgets = measured dynamically // queued msgs = ~5 lines per message (padding + text + badge + padding) // input region = measured dynamically via lipgloss.Height() // below widgets = measured dynamically // status bar = 1 line (always present) +// footer = measured dynamically (0 if not set) func (m *AppModel) distributeHeight() { const separatorLines = 1 const statusBarLines = 1 // always-present status bar @@ -1427,7 +1497,16 @@ func (m *AppModel) distributeHeight() { widgetLines += lipgloss.Height(below) } - streamHeight := max(m.height-separatorLines-widgetLines-queuedLines-inputLines-statusBarLines, 0) + // Measure header/footer heights. + var headerFooterLines int + if headerView := m.renderHeaderFooter(m.getHeader); headerView != "" { + headerFooterLines += lipgloss.Height(headerView) + } + if footerView := m.renderHeaderFooter(m.getFooter); footerView != "" { + headerFooterLines += lipgloss.Height(footerView) + } + + streamHeight := max(m.height-separatorLines-widgetLines-headerFooterLines-queuedLines-inputLines-statusBarLines, 0) if m.stream != nil { m.stream.SetHeight(streamHeight) diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index fcc082e4..27421feb 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -169,6 +169,56 @@ func (m *Kit) GetExtensionWidgets(placement extensions.WidgetPlacement) []extens return m.extRunner.GetWidgets(placement) } +// SetExtensionHeader places or replaces the custom header from extensions. +// Delegates to the extension runner. No-op if extensions are disabled. +func (m *Kit) SetExtensionHeader(config extensions.HeaderFooterConfig) { + if m.extRunner != nil { + m.extRunner.SetHeader(config) + } +} + +// RemoveExtensionHeader removes the custom extension header. +// Delegates to the extension runner. No-op if extensions are disabled. +func (m *Kit) RemoveExtensionHeader() { + if m.extRunner != nil { + m.extRunner.RemoveHeader() + } +} + +// GetExtensionHeader returns the current custom header, or nil if none is set. +// Returns nil if extensions are disabled. +func (m *Kit) GetExtensionHeader() *extensions.HeaderFooterConfig { + if m.extRunner == nil { + return nil + } + return m.extRunner.GetHeader() +} + +// SetExtensionFooter places or replaces the custom footer from extensions. +// Delegates to the extension runner. No-op if extensions are disabled. +func (m *Kit) SetExtensionFooter(config extensions.HeaderFooterConfig) { + if m.extRunner != nil { + m.extRunner.SetFooter(config) + } +} + +// RemoveExtensionFooter removes the custom extension footer. +// Delegates to the extension runner. No-op if extensions are disabled. +func (m *Kit) RemoveExtensionFooter() { + if m.extRunner != nil { + m.extRunner.RemoveFooter() + } +} + +// GetExtensionFooter returns the current custom footer, or nil if none is set. +// Returns nil if extensions are disabled. +func (m *Kit) GetExtensionFooter() *extensions.HeaderFooterConfig { + if m.extRunner == nil { + return nil + } + return m.extRunner.GetFooter() +} + // HasExtensions returns true if the extension runner is configured and active. func (m *Kit) HasExtensions() bool { return m.extRunner != nil