From 8407d924b9d290e8d9f1003f20f4746fc5a98a36 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Sun, 1 Mar 2026 15:24:48 +0300 Subject: [PATCH] feat: add UIVisibility, GetContextStats APIs and compact tool renderers - Add ctx.SetUIVisibility() to toggle built-in TUI chrome (startup message, status bar, separator, input hint) from extensions - Add ctx.GetContextStats() returning accurate API-reported token counts instead of text-based heuristic; fix event ordering so extension handlers see up-to-date conversation state - Add compact tool body renderers for compact mode: Read/Edit/Write/Ls show one-line summaries, Bash shows first 3 lines instead of full 20-line syntax-highlighted output - Add minimal.go example extension using UIVisibility + GetContextStats --- cmd/root.go | 46 +++++++- examples/extensions/minimal.go | 71 ++++++++++++ internal/extensions/api.go | 55 +++++++++ internal/extensions/runner.go | 24 ++++ internal/extensions/symbols.go | 6 + internal/ui/compact_renderer.go | 5 +- internal/ui/input.go | 17 ++- internal/ui/model.go | 69 +++++++++++- internal/ui/slash_command_input.go | 21 ++-- internal/ui/tool_renderers.go | 174 +++++++++++++++++++++++++++++ pkg/kit/compaction.go | 16 ++- pkg/kit/kit.go | 45 +++++++- 12 files changed, 516 insertions(+), 33 deletions(-) create mode 100644 examples/extensions/minimal.go diff --git a/cmd/root.go b/cmd/root.go index 49208519..5497e038 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -411,6 +411,27 @@ func editorInterceptorProviderForUI(k *kit.Kit) func() *ui.EditorInterceptor { } } +// uiVisibilityProviderForUI returns a function that converts extension UI +// visibility overrides to a *ui.UIVisibility for the TUI. Returns nil if +// extensions are disabled — the UI treats nil as "show everything". +func uiVisibilityProviderForUI(k *kit.Kit) func() *ui.UIVisibility { + if !k.HasExtensions() { + return nil + } + return func() *ui.UIVisibility { + v := k.GetExtensionUIVisibility() + if v == nil { + return nil + } + return &ui.UIVisibility{ + HideStartupMessage: v.HideStartupMessage, + HideStatusBar: v.HideStatusBar, + HideSeparator: v.HideSeparator, + HideInputHint: v.HideInputHint, + } + } +} + // 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". @@ -629,6 +650,19 @@ func runNormalMode(ctx context.Context) error { } return extensions.PromptInputResult{Value: resp.Value} }, + SetUIVisibility: func(v extensions.UIVisibility) { + kitInstance.SetExtensionUIVisibility(v) + appInstance.NotifyWidgetUpdate() + }, + GetContextStats: func() extensions.ContextStats { + s := kitInstance.GetContextStats() + return extensions.ContextStats{ + EstimatedTokens: s.EstimatedTokens, + ContextLimit: s.ContextLimit, + UsagePercent: s.UsagePercent, + MessageCount: s.MessageCount, + } + }, SetEditor: func(config extensions.EditorConfig) { kitInstance.SetExtensionEditor(config) // Use a goroutine for NotifyWidgetUpdate because this may be @@ -696,10 +730,11 @@ func runNormalMode(ctx context.Context) error { getFooter := footerProviderForUI(kitInstance) getToolRenderer := toolRendererProviderForUI(kitInstance) getEditorInterceptor := editorInterceptorProviderForUI(kitInstance) + getUIVisibility := uiVisibilityProviderForUI(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, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor) + return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility) } // Quiet mode is not allowed in interactive mode @@ -707,7 +742,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, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor) + return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility) } // runNonInteractiveModeApp executes a single prompt via the app layer and exits, @@ -720,7 +755,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, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor) 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, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility) error { if quiet { // Quiet mode: no intermediate display, just print final response. if err := appInstance.RunOnce(ctx, prompt); err != nil { @@ -746,7 +781,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, getHeader, getFooter, getToolRenderer, getEditorInterceptor) + return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility) } return nil @@ -763,7 +798,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, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor) 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, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility) error { // Determine terminal size; fall back gracefully. termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || termWidth == 0 { @@ -791,6 +826,7 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN GetFooter: getFooter, GetToolRenderer: getToolRenderer, GetEditorInterceptor: getEditorInterceptor, + GetUIVisibility: getUIVisibility, }) // Print startup info to stdout before Bubble Tea takes over the screen. diff --git a/examples/extensions/minimal.go b/examples/extensions/minimal.go new file mode 100644 index 00000000..416eaba5 --- /dev/null +++ b/examples/extensions/minimal.go @@ -0,0 +1,71 @@ +//go:build ignore + +package main + +import ( + "fmt" + "math" + "strings" + + "kit/ext" +) + +// Init demonstrates a minimal-chrome extension — a port of Pi's minimal.ts. +// Hides the startup banner, status bar, separator, and input hint, replacing +// them with a compact footer showing model name and a context usage bar: +// +// claude-sonnet-4-5-20250929 [###-------] 30% (3.9K/200K tokens) +// +// Usage: kit -e examples/extensions/minimal.go +func Init(api ext.API) { + // updateFooter builds the footer text from current context stats. + updateFooter := func(ctx ext.Context) { + stats := ctx.GetContextStats() + pct := stats.UsagePercent * 100 + if pct > 100 { + pct = 100 + } + filled := int(math.Round(pct)) / 10 + bar := strings.Repeat("#", filled) + strings.Repeat("-", 10-filled) + + // Format token counts like the built-in status bar (e.g. "3.9K/200K"). + fmtTokens := func(n int) string { + if n >= 1000 { + return fmt.Sprintf("%.1fK", float64(n)/1000) + } + return fmt.Sprintf("%d", n) + } + + text := fmt.Sprintf("%s [%s] %d%%", ctx.Model, bar, int(math.Round(pct))) + if stats.ContextLimit > 0 { + text += fmt.Sprintf(" (%s/%s tokens)", + fmtTokens(stats.EstimatedTokens), fmtTokens(stats.ContextLimit)) + } + + ctx.SetFooter(ext.HeaderFooterConfig{ + Content: ext.WidgetContent{Text: text}, + Style: ext.WidgetStyle{BorderColor: "#585b70"}, + }) + } + + api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) { + // Strip built-in chrome for a minimal look. + ctx.SetUIVisibility(ext.UIVisibility{ + HideStartupMessage: true, + HideStatusBar: true, + HideSeparator: true, + HideInputHint: true, + }) + + updateFooter(ctx) + }) + + // Refresh after each agent turn — context usage changes here. + api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) { + updateFooter(ctx) + }) + + api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) { + ctx.RemoveFooter() + }) +} diff --git a/internal/extensions/api.go b/internal/extensions/api.go index 6054ee3f..019955aa 100644 --- a/internal/extensions/api.go +++ b/internal/extensions/api.go @@ -206,6 +206,31 @@ type Context struct { // ResetEditor removes the active editor interceptor and restores the // default built-in editor behavior. No-op if no interceptor is set. ResetEditor func() + + // SetUIVisibility controls which built-in TUI chrome elements are + // visible. By default all elements are shown (zero value = show all). + // Call this during OnSessionStart to configure the initial layout. + // + // Example — minimal chrome: + // + // ctx.SetUIVisibility(ext.UIVisibility{ + // HideStartupMessage: true, + // HideStatusBar: true, + // HideSeparator: true, + // HideInputHint: true, + // }) + SetUIVisibility func(UIVisibility) + + // GetContextStats returns current context-window usage information + // (estimated tokens, context limit, usage percentage, message count). + // Useful for building context meters, auto-compaction triggers, etc. + // + // Example: + // + // stats := ctx.GetContextStats() + // pct := int(stats.UsagePercent * 100) + // fmt.Sprintf("[%s%s] %d%%", strings.Repeat("#", pct/10), strings.Repeat("-", 10-pct/10), pct) + GetContextStats func() ContextStats } // PrintBlockOpts configures a custom styled block for PrintBlock. @@ -472,6 +497,36 @@ type HeaderFooterConfig struct { Style WidgetStyle } +// --------------------------------------------------------------------------- +// UI visibility (exposed to Yaegi — concrete struct) +// --------------------------------------------------------------------------- + +// UIVisibility controls which built-in TUI chrome elements are visible. +// The zero value shows everything (backward compatible). Extensions call +// ctx.SetUIVisibility to customise the layout — for example, a "minimal" +// theme can hide the startup banner, status bar, and input hint and replace +// them with a single custom footer. +type UIVisibility struct { + HideStartupMessage bool // Hide the "Model loaded..." startup block + HideStatusBar bool // Hide the "provider · model Tokens: ..." line + HideSeparator bool // Hide the "────────" divider between stream and input + HideInputHint bool // Hide the "enter submit · ctrl+j..." hint below input +} + +// --------------------------------------------------------------------------- +// Context stats (exposed to Yaegi — concrete struct) +// --------------------------------------------------------------------------- + +// ContextStats contains current context-window usage information. +// Extensions can poll this via ctx.GetContextStats() to build usage +// meters, auto-compaction triggers, etc. +type ContextStats struct { + EstimatedTokens int // Estimated token count of the current conversation + ContextLimit int // Model's context window size (tokens), 0 if unknown + UsagePercent float64 // Fraction of context used (0.0–1.0), 0 if limit unknown + MessageCount int // Number of messages in the conversation +} + // --------------------------------------------------------------------------- // Overlay types (exposed to Yaegi — concrete structs) // --------------------------------------------------------------------------- diff --git a/internal/extensions/runner.go b/internal/extensions/runner.go index e0de1a2e..921285ec 100644 --- a/internal/extensions/runner.go +++ b/internal/extensions/runner.go @@ -18,6 +18,7 @@ type Runner struct { header *HeaderFooterConfig // nil = no custom header footer *HeaderFooterConfig // nil = no custom footer customEditor *EditorConfig // nil = no custom editor interceptor + uiVisibility *UIVisibility // nil = show everything (default) mu sync.RWMutex } @@ -269,6 +270,29 @@ func (r *Runner) GetEditor() *EditorConfig { return &e } +// --------------------------------------------------------------------------- +// UI visibility management +// --------------------------------------------------------------------------- + +// SetUIVisibility updates the UI visibility overrides. Thread-safe. +func (r *Runner) SetUIVisibility(v UIVisibility) { + r.mu.Lock() + defer r.mu.Unlock() + r.uiVisibility = &v +} + +// GetUIVisibility returns the current UI visibility overrides, or nil if +// none have been set (meaning show everything). Thread-safe. +func (r *Runner) GetUIVisibility() *UIVisibility { + r.mu.RLock() + defer r.mu.RUnlock() + if r.uiVisibility == nil { + return nil + } + v := *r.uiVisibility + return &v +} + // --------------------------------------------------------------------------- // Tool renderer management // --------------------------------------------------------------------------- diff --git a/internal/extensions/symbols.go b/internal/extensions/symbols.go index 92430fef..0a956ff4 100644 --- a/internal/extensions/symbols.go +++ b/internal/extensions/symbols.go @@ -37,6 +37,12 @@ func Symbols() interp.Exports { // Header/Footer types "HeaderFooterConfig": reflect.ValueOf((*HeaderFooterConfig)(nil)), + // UI visibility + "UIVisibility": reflect.ValueOf((*UIVisibility)(nil)), + + // Context stats + "ContextStats": reflect.ValueOf((*ContextStats)(nil)), + // Overlay types "OverlayAnchor": reflect.ValueOf((*OverlayAnchor)(nil)), "OverlayCenter": reflect.ValueOf(OverlayCenter), diff --git a/internal/ui/compact_renderer.go b/internal/ui/compact_renderer.go index 5801b9e7..3deabc08 100644 --- a/internal/ui/compact_renderer.go +++ b/internal/ui/compact_renderer.go @@ -188,7 +188,7 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params) } - // Format body: check extension renderer first, then builtin, then default. + // Format body: check extension renderer first, then compact builtin, then default. var body string if extRd != nil && extRd.RenderBody != nil { body = extRd.RenderBody(toolResult, isError, r.width-4) @@ -201,7 +201,8 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin if isError { body = lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatToolResult(toolResult)) } else { - body = renderToolBody(toolName, toolArgs, toolResult, r.width-4) + // Use compact summary renderers instead of full tool body renderers. + body = renderToolBodyCompact(toolName, toolArgs, toolResult, r.width-4) if body == "" { formatted := r.formatToolResult(toolResult) if formatted == "" { diff --git a/internal/ui/input.go b/internal/ui/input.go index 0780efd2..119aafd3 100644 --- a/internal/ui/input.go +++ b/internal/ui/input.go @@ -39,6 +39,9 @@ type InputComponent struct { // appCtrl is used for slash commands that mutate app state. // May be nil in tests; nil-safe. appCtrl AppController + + // hideHint suppresses the "enter submit · ctrl+j..." hint text. + hideHint bool } // NewInputComponent creates a new InputComponent with the given width, title, @@ -254,13 +257,15 @@ func (s *InputComponent) View() tea.View { view.WriteString(s.renderPopup()) } - helpStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")). - MarginTop(1). - PaddingLeft(3) + if !s.hideHint { + helpStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + MarginTop(1). + PaddingLeft(3) - view.WriteString("\n") - view.WriteString(helpStyle.Render("enter submit • ctrl+j / alt+enter new line")) + view.WriteString("\n") + view.WriteString(helpStyle.Render("enter submit • ctrl+j / alt+enter new line")) + } return tea.NewView(containerStyle.Render(view.String())) } diff --git a/internal/ui/model.go b/internal/ui/model.go index e348f74f..6a426c71 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -167,6 +167,15 @@ type WidgetData struct { NoBorder bool } +// UIVisibility controls which built-in TUI chrome elements are visible. +// The zero value shows everything (backward compatible). +type UIVisibility struct { + HideStartupMessage bool // Hide the "Model loaded..." startup block + HideStatusBar bool // Hide the "provider · model Tokens: ..." line + HideSeparator bool // Hide the "────────" divider between stream and input + HideInputHint bool // Hide the "enter submit · ctrl+j..." hint below input +} + // AppModelOptions holds configuration passed to NewAppModel. type AppModelOptions struct { // CompactMode selects the compact renderer for message formatting. @@ -242,6 +251,12 @@ type AppModelOptions struct { // intercept key events and during View() to wrap input rendering. // May be nil if no extensions are loaded. GetEditorInterceptor func() *EditorInterceptor + + // GetUIVisibility returns the current UI visibility overrides set by + // an extension, or nil if none have been set (show everything). + // Called during View() and PrintStartupInfo() to conditionally hide + // built-in chrome elements. May be nil if no extensions are loaded. + GetUIVisibility func() *UIVisibility } // AppModel is the root Bubble Tea model for the interactive TUI. It owns the @@ -349,6 +364,9 @@ type AppModel struct { // getEditorInterceptor returns the current editor interceptor. May be nil. getEditorInterceptor func() *EditorInterceptor + // getUIVisibility returns extension-provided UI visibility overrides. May be nil. + getUIVisibility func() *UIVisibility + // prompt holds the state of an active interactive prompt overlay. Nil // when no prompt is active. Managed by updatePromptState(). prompt *promptOverlay @@ -462,6 +480,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { m.getHeader = opts.GetHeader m.getFooter = opts.GetFooter m.getEditorInterceptor = opts.GetEditorInterceptor + m.getUIVisibility = opts.GetUIVisibility // Store context/skills metadata and tool counts for startup display. m.contextPaths = opts.ContextPaths @@ -510,12 +529,27 @@ func (m *AppModel) Init() tea.Cmd { return tea.Batch(cmds...) } +// uiVis returns the current UIVisibility, defaulting to zero value (show all) +// if no extension has set visibility overrides. +func (m *AppModel) uiVis() UIVisibility { + if m.getUIVisibility != nil { + if v := m.getUIVisibility(); v != nil { + return *v + } + } + return UIVisibility{} +} + // PrintStartupInfo prints the startup banner (model name, context, skills, // tool counts) to stdout. Call this before program.Run() so the messages are // visible above the Bubble Tea managed region. // // All startup information is rendered inside a single system message block. func (m *AppModel) PrintStartupInfo() { + if m.uiVis().HideStartupMessage { + return + } + render := func(text string) string { return m.renderer.RenderSystemMessage(text, time.Now()).Content } @@ -1049,8 +1083,14 @@ func (m *AppModel) View() tea.View { return tea.NewView(m.overlay.Render()) } + vis := m.uiVis() + streamView := m.renderStream() - separator := m.renderSeparator() + + // Propagate hint visibility to the input component before rendering. + if ic, ok := m.input.(*InputComponent); ok { + ic.hideHint = vis.HideInputHint + } // When a prompt is active, it replaces the input area for consistency // (appears below the separator, in the same position as the input). @@ -1060,7 +1100,6 @@ func (m *AppModel) View() tea.View { } else { inputView = m.renderInput() } - statusBar := m.renderStatusBar() // Build the stacked layout. Optional header/footer wrap the core layout. var parts []string @@ -1076,7 +1115,10 @@ func (m *AppModel) View() tea.View { if streamView != "" { parts = append(parts, streamView) } - parts = append(parts, separator) + + if !vis.HideSeparator { + parts = append(parts, m.renderSeparator()) + } // Render "above" widgets between separator and queued messages. if aboveView := m.renderWidgetSlot("above"); aboveView != "" { @@ -1094,7 +1136,9 @@ func (m *AppModel) View() tea.View { parts = append(parts, belowView) } - parts = append(parts, statusBar) + if !vis.HideStatusBar { + parts = append(parts, m.renderStatusBar()) + } // Custom footer (if set by extension) — below everything. if footerView := m.renderHeaderFooter(m.getFooter); footerView != "" { @@ -1666,11 +1710,24 @@ func (m *AppModel) flushStreamContent() tea.Cmd { // 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 + vis := m.uiVis() + + separatorLines := 1 + if vis.HideSeparator { + separatorLines = 0 + } + statusBarLines := 1 + if vis.HideStatusBar { + statusBarLines = 0 + } const linesPerQueuedMsg = 5 queuedLines := len(m.queuedMessages) * linesPerQueuedMsg + // Propagate hint visibility before measuring input height. + if ic, ok := m.input.(*InputComponent); ok { + ic.hideHint = vis.HideInputHint + } + // Measure the actual rendered input (or prompt overlay) height so we // don't rely on a fragile constant that drifts when styling changes. // Use renderInput() which includes the editor interceptor's Render diff --git a/internal/ui/slash_command_input.go b/internal/ui/slash_command_input.go index d9f8aab7..496a8926 100644 --- a/internal/ui/slash_command_input.go +++ b/internal/ui/slash_command_input.go @@ -26,6 +26,7 @@ type SlashCommandInput struct { value string submitNext bool // Flag to submit on next update renderedLines int // Track how many lines were rendered + hideHint bool // Suppress the "enter submit · ctrl+j..." hint } // NewSlashCommandInput creates and initializes a new slash command input field with @@ -219,17 +220,19 @@ func (s *SlashCommandInput) View() tea.View { s.renderedLines += 1 + popupLines // newline + popup } - // Add help text at bottom - helpStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")). - MarginTop(1). - PaddingLeft(3) + // Add help text at bottom (unless hidden by extension). + if !s.hideHint { + helpStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + MarginTop(1). + PaddingLeft(3) - helpText := "enter submit • ctrl+j / alt+enter new line" + helpText := "enter submit • ctrl+j / alt+enter new line" - view.WriteString("\n") - view.WriteString(helpStyle.Render(helpText)) - s.renderedLines += 2 // newline + help text + view.WriteString("\n") + view.WriteString(helpStyle.Render(helpText)) + s.renderedLines += 2 // newline + help text + } // Apply container padding to entire view return tea.NewView(containerStyle.Render(view.String())) diff --git a/internal/ui/tool_renderers.go b/internal/ui/tool_renderers.go index 163d2e1b..07f63d63 100644 --- a/internal/ui/tool_renderers.go +++ b/internal/ui/tool_renderers.go @@ -696,3 +696,177 @@ func truncateLine(s string, maxWidth int) string { } return s[:maxWidth-1] + "…" } + +// --------------------------------------------------------------------------- +// Compact tool body renderers — one-line summaries for compact mode +// --------------------------------------------------------------------------- + +// renderToolBodyCompact returns a brief summary string for tool results in +// compact display mode. Returns empty string to fall back to default. +func renderToolBodyCompact(toolName, toolArgs, toolResult string, width int) string { + switch { + case toolName == "edit": + return renderEditCompact(toolArgs, toolResult) + case toolName == "ls": + return renderLsCompact(toolResult) + case toolName == "read": + return renderReadCompact(toolResult) + case toolName == "write": + return renderWriteCompact(toolArgs) + case toolName == "bash" || toolName == "run_shell_cmd" || + strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"): + return renderBashCompact(toolResult, width) + } + return "" +} + +// renderReadCompact returns a line-count summary for Read tool output. +func renderReadCompact(toolResult string) string { + content := strings.TrimSpace(toolResult) + if content == "" { + return "" + } + + lines := strings.Split(content, "\n") + + // Count actual code lines (those with "N: " line-number prefix) + codeLines := 0 + 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 { + codeLines++ + } + } + } + if codeLines == 0 { + return "" + } + + theme := getTheme() + summary := fmt.Sprintf("%d lines", codeLines) + return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary) +} + +// renderEditCompact returns a change-count summary for Edit tool output. +func renderEditCompact(toolArgs, toolResult string) string { + var args map[string]any + if err := json.Unmarshal([]byte(toolArgs), &args); err != nil { + return "" + } + + oldText, _ := args["old_text"].(string) + newText, _ := args["new_text"].(string) + if oldText == "" && newText == "" { + return "" + } + + oldCount := len(strings.Split(oldText, "\n")) + newCount := len(strings.Split(newText, "\n")) + + theme := getTheme() + var summary string + if oldCount == newCount { + summary = fmt.Sprintf("%d lines modified", oldCount) + } else { + summary = fmt.Sprintf("-%d/+%d lines", oldCount, newCount) + } + return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary) +} + +// renderWriteCompact returns a line-count summary for Write tool output. +func renderWriteCompact(toolArgs string) string { + var args map[string]any + if err := json.Unmarshal([]byte(toolArgs), &args); err != nil { + return "" + } + + content, _ := args["content"].(string) + if content == "" { + return "" + } + + count := len(strings.Split(content, "\n")) + theme := getTheme() + summary := fmt.Sprintf("%d lines written", count) + return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary) +} + +// renderLsCompact returns an entry-count summary for Ls tool output. +func renderLsCompact(toolResult string) string { + content := strings.TrimSpace(toolResult) + if content == "" { + return "" + } + + entries := strings.Split(content, "\n") + theme := getTheme() + summary := fmt.Sprintf("%d entries", len(entries)) + return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary) +} + +// renderBashCompact returns the first few lines of bash output as a compact +// summary. Shows up to 3 meaningful output lines. +func renderBashCompact(toolResult string, width int) string { + result := strings.TrimSpace(toolResult) + if result == "" { + return "" + } + + lines := strings.Split(result, "\n") + + // Filter to meaningful output lines (skip STDERR: label, keep exit codes separate) + var outputLines []string + var exitCode string + inStderr := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "STDERR:" { + inStderr = true + continue + } + if strings.HasPrefix(trimmed, "Exit code:") { + exitCode = trimmed + continue + } + if trimmed == "" { + continue + } + outputLines = append(outputLines, line) + _ = inStderr // stderr lines are included in output + } + + if len(outputLines) == 0 { + if exitCode != "" { + theme := getTheme() + return lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode) + } + return "" + } + + const maxLines = 3 + theme := getTheme() + + display := outputLines + if len(display) > maxLines { + display = display[:maxLines] + } + + // Truncate each line to available width + lineMax := max(width-4, 20) + for i, line := range display { + if len(line) > lineMax { + display[i] = line[:lineMax-3] + "..." + } + } + + summary := strings.Join(display, "\n") + if len(outputLines) > maxLines { + summary += fmt.Sprintf("\n...(%d more lines)", len(outputLines)-maxLines) + } + if exitCode != "" { + summary += "\n" + lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode) + } + + return lipgloss.NewStyle().Foreground(theme.Muted).Render(summary) +} diff --git a/pkg/kit/compaction.go b/pkg/kit/compaction.go index ea6fb786..b2061399 100644 --- a/pkg/kit/compaction.go +++ b/pkg/kit/compaction.go @@ -43,9 +43,23 @@ func (m *Kit) ShouldCompact() bool { // GetContextStats returns current context usage statistics including // estimated token count, context limit, usage percentage, and message count. +// +// When API-reported token counts are available (after at least one turn), +// EstimatedTokens uses the real input token count from the most recent API +// response. This is significantly more accurate than the text-based heuristic +// because it includes system prompts, tool definitions, and other overhead +// that the heuristic cannot account for. func (m *Kit) GetContextStats() ContextStats { messages := m.treeSession.GetFantasyMessages() - estimated := compaction.EstimateMessageTokens(messages) + + // Prefer the real API-reported input token count when available. + m.lastInputTokensMu.RLock() + estimated := m.lastInputTokens + m.lastInputTokensMu.RUnlock() + if estimated == 0 { + // Fall back to heuristic before first turn completes. + estimated = compaction.EstimateMessageTokens(messages) + } stats := ContextStats{ EstimatedTokens: estimated, diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 6d697dd3..c8147a2a 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "charm.land/fantasy" @@ -48,6 +49,13 @@ type Kit struct { afterToolResult *hookRegistry[AfterToolResultHook, AfterToolResultResult] beforeTurn *hookRegistry[BeforeTurnHook, BeforeTurnResult] afterTurn *hookRegistry[AfterTurnHook, AfterTurnResult] + + // lastInputTokens stores the API-reported input token count from the + // most recent turn. Used by GetContextStats() to return accurate usage + // instead of the text-based heuristic which misses system prompts, + // tool definitions, etc. + lastInputTokensMu sync.RWMutex + lastInputTokens int } // Subscribe registers an EventListener that will be called for every lifecycle @@ -263,6 +271,23 @@ func (m *Kit) GetExtensionEditor() *extensions.EditorConfig { return m.extRunner.GetEditor() } +// SetExtensionUIVisibility stores extension-provided UI visibility overrides. +// No-op if extensions are disabled. +func (m *Kit) SetExtensionUIVisibility(v extensions.UIVisibility) { + if m.extRunner != nil { + m.extRunner.SetUIVisibility(v) + } +} + +// GetExtensionUIVisibility returns extension-provided UI visibility overrides, +// or nil if none have been set. Returns nil if extensions are disabled. +func (m *Kit) GetExtensionUIVisibility() *extensions.UIVisibility { + if m.extRunner == nil { + return nil + } + return m.extRunner.GetUIVisibility() +} + // HasExtensions returns true if the extension runner is configured and active. func (m *Kit) HasExtensions() bool { return m.extRunner != nil @@ -785,16 +810,28 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr responseText := result.FinalResponse.Content.Text() - m.events.emit(MessageEndEvent{Content: responseText}) - m.events.emit(TurnEndEvent{Response: responseText}) - - // Persist new messages (tool calls, tool results, assistant response). + // Persist new messages (tool calls, tool results, assistant response) + // BEFORE emitting events so that extension handlers calling + // GetContextStats() see up-to-date token counts. if len(result.ConversationMessages) > sentCount { for _, msg := range result.ConversationMessages[sentCount:] { _, _ = m.treeSession.AppendFantasyMessage(msg) } } + // Store the API-reported token count so GetContextStats() matches the + // built-in status bar (which uses input + output tokens). The + // text-based heuristic misses system prompts, tool definitions, etc. + if result.FinalResponse != nil { + u := result.FinalResponse.Usage + m.lastInputTokensMu.Lock() + m.lastInputTokens = int(u.InputTokens) + int(u.OutputTokens) + m.lastInputTokensMu.Unlock() + } + + m.events.emit(MessageEndEvent{Content: responseText}) + m.events.emit(TurnEndEvent{Response: responseText}) + // Run AfterTurn hooks. if m.afterTurn.hasHooks() { m.afterTurn.run(AfterTurnHook{Response: responseText})