Compare commits

..

13 Commits

Author SHA1 Message Date
Ed Zynda 7a8a5b185f fix: auto-initialize extension context in kit.New()
Extensions were being loaded automatically by SetupAgent but the context
was never initialized unless the SDK user explicitly called
SetExtensionContext. This left extensions with a zero-value Context where
all function fields are nil.

Now kit.New() automatically calls SetExtensionContext with minimal defaults
(CWD, Model, Interactive=false) when extensions are loaded. SDK users can
still call SetExtensionContext to override with richer implementations
(TUI callbacks, prompts, etc.).

Combined with the normalizeContext() safety net in the runner, extensions
are now guaranteed to work in SDK mode without explicit context wiring.
2026-03-27 15:01:40 +03:00
Ed Zynda 1e2e33f039 refactor(ui): remove drainScrollback() calls and function
Remove the last remaining call to drainScrollback() at the end of
Update() and delete the no-op stub function that was maintained during
migration.

The drainScrollback() mechanism was part of the old inline-mode
rendering approach that used tea.Println to flush content to the
terminal's scrollback buffer. With the alt-screen refactor:

- scrollbackBuf field was removed in TAS-6
- appendScrollback() calls were removed in TAS-7
- drainScrollback() function is now completely removed

All history content is now rendered in-app via renderHistoryRegion()
in View().
2026-03-27 14:58:59 +03:00
Ed Zynda 52719baf1f refactor(ui): replace appendScrollback with appendHistoryEntry
Remove all appendScrollback() calls and the no-op stub function.
All content now flows exclusively through appendHistoryEntry() to
the historyEntries timeline, which is rendered in View() via
renderHistoryRegion().

Updated helpers:
- printUserMessage, printAssistantMessage, printToolResult
- printErrorResponse, printSystemMessage, printExtensionBlock
- printCompactResult, flushStreamContent
- flushStreamAndPendingUserMessages, restoreSessionHistory
- Raw extension output handler, shell command result handler

Part of alt-screen scrollback refactor (TAS-7).
2026-03-27 14:55:14 +03:00
Ed Zynda f0074e8c81 refactor(ui): remove drainScrollback contract for alt-screen mode
- Remove scrollbackBuf field from AppModel (replaced by historyEntries)
- Make drainScrollback() a no-op stub (callers removed in TAS-8)
- Make appendScrollback() a no-op stub (replaced in TAS-5)
- Update tests to not expect tea.Println commands

The history timeline is now rendered directly in View() via
renderHistoryRegion() instead of being flushed via tea.Println.

Part of alt-screen-scrollback-refactor (TAS-6).
2026-03-27 14:51:15 +03:00
Ed Zynda aa2fc80575 feat(ui): set AltScreen=true on all View() return paths
Update AppModel.View() to set AltScreen=true on tea.View for all
interactive view paths:
- Tree selector overlay
- Model selector overlay
- Session selector overlay
- Overlay dialog
- Main layout

This ensures the TUI uses alt-screen mode consistently as required
by the unified BubbleTea architecture spec (R1).
2026-03-27 14:47:36 +03:00
Ed Zynda c64898f9cf feat(ui): add Shift+Up/Down for line-by-line history scrolling
Add line-by-line scroll key handlers for the history viewport:

- Shift+Up: scroll history up by one line (disables follow-mode)
- Shift+Down: scroll history down by one line (re-enables follow-mode at bottom)

Both handlers are only active in stateInput or stateWorking states,
complementing the existing page-level controls (PgUp/PgDown, Ctrl+Home/End).
2026-03-27 14:45:25 +03:00
Ed Zynda ceeacc7455 feat(ui): handle window resize with anchor preservation for history viewport
Add history scroll offset adjustment to the WindowSizeMsg handler:

- Follow mode: No change needed - renderHistoryRegion() pins to bottom
- Non-follow mode: Clamp offset to new valid range to preserve top-visible line

Implementation uses uiVis(), calculateHistoryStreamHeight(), and
historyMaxOffset() to compute the valid offset range after resize.

Anchor semantics:
- Viewport shrinks: top-visible line preserved as anchor
- Viewport grows: same top line stays visible with more content below
- Content shorter than viewport: offset clamped to show all content
2026-03-27 14:43:05 +03:00
Ed Zynda 89ea9f6c63 feat(ui): add page up/down and Ctrl+Home/End for history scrolling
Add keyboard handlers for navigating the history viewport:
- PgUp/PageUp: scroll up by one page (minus 2 lines for context)
- PgDown/PageDown: scroll down by one page
- Ctrl+Home: jump to top of history
- Ctrl+End: jump to bottom and re-enable follow-mode

Handlers are active in stateInput and stateWorking only, not in modal
selectors. Follow-mode is disabled when scrolling up and re-enabled
when reaching the bottom.
2026-03-27 14:41:33 +03:00
Ed Zynda ae33c959c9 feat(ui): implement follow-mode semantics for history viewport
Add helper methods to manage follow-mode state during history scrolling:

- historyMaxOffset(): calculate max valid scroll offset
- scrollHistoryUp(): scroll up N lines, disables follow-mode
- scrollHistoryDown(): scroll down N lines, re-enables follow at bottom
- scrollHistoryToTop(): jump to top, disables follow-mode
- scrollHistoryToBottom(): jump to bottom, re-enables follow-mode
- isHistoryAtBottom(): check if viewport is at bottom

Follow-mode semantics:
- When historyFollow=true: viewport stays pinned to bottom
- Scrolling up: historyFollow becomes false
- Scrolling to bottom: historyFollow becomes true again

These methods will be called by key handlers in TAS-13/TAS-15.
2026-03-27 14:39:12 +03:00
Ed Zynda 71fa1d20f2 fix(ui): correct UIVisibility type in calculateHistoryStreamHeight
Fix build error from previous iteration where uiVisibility was used
instead of UIVisibility in the calculateHistoryStreamHeight() function
signature.

This completes TAS-11 (Phase 3: Implement scrollable history rendering
region) which includes:
- renderHistoryRegion() with viewport windowing and follow-mode
- rebuildHistoryCache() for efficient dirty-flag-based cache rebuild
- historyTotalLines() helper for scroll calculations
- calculateHistoryStreamHeight() for proper height allocation
- View() integration with history region rendering
2026-03-27 14:37:26 +03:00
Ed Zynda 7c98ab921b fix: normalize nil Context function fields to no-ops in SetContext
Extensions running via the SDK (without a fully-wired SetExtensionContext
call) would panic with 'reflect.Value.Call: call of nil function' when
calling any ctx method like ctx.PrintBlock().

normalizeContext() now replaces every nil function field in Context with
a safe no-op stub before storing it in the runner, so extension handlers
can never crash on a missing callback regardless of how Kit is embedded.
2026-03-27 13:59:59 +03:00
Ed Zynda 96d8513c9f feat(ui): add appendHistoryEntry and dual-write to history timeline
Add new appendHistoryEntry helper method that appends entries to the
historyEntries timeline for alt-screen mode. Update all print helpers
to dual-write to both scrollbackBuf (legacy tea.Println path) and
historyEntries (new alt-screen rendering path).

Updated methods:
- printUserMessage, printAssistantMessage, printToolResult
- printErrorResponse, printSystemMessage, printExtensionBlock
- printCompactResult, flushStreamContent
- flushStreamAndPendingUserMessages, renderSessionHistory
- handleShellCommandResult, raw ExtensionPrintEvent handling

Mark appendScrollback as deprecated with migration note. Both paths
are maintained for backward compatibility during the migration.

Part of alt-screen scrollback refactor (TAS-10).
2026-03-27 13:56:40 +03:00
Ed Zynda 84ee92f78f feat(ui): add history entry data model and state fields for alt-screen scrollback
Add foundation types and fields for migrating from tea.Println pipeline
to in-app scrollback timeline (Phase 1 of alt-screen refactor):

- Add historyEntry struct with Kind, Content, Timestamp fields
- Add historyEntries, historyOffset, historyFollow fields to AppModel
- Add historyRenderCache and historyDirty for performance optimization
- Mark scrollbackBuf as deprecated (to be removed in Phase 2)

Refs: TAS-2, TAS-4
2026-03-27 12:55:40 +03:00
54 changed files with 1217 additions and 3662 deletions
+16 -27
View File
@@ -18,7 +18,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
## Features
- **Multi-Provider LLM Support**: Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls, subagent - no MCP overhead
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls, spawn_subagent - no MCP overhead
- **MCP Integration**: Connect external MCP servers for expanded capabilities
- **Extension System**: Write custom tools, commands, widgets, and UI modifications in Go
- **Theming**: 22 built-in color themes (KITT, Catppuccin, Dracula, Nord, etc.) with runtime switching, persistence, and custom theme files
@@ -307,12 +307,6 @@ kit -e examples/extensions/minimal.go
- **Themes**: Register and switch color themes via `RegisterTheme`, `SetTheme`, `ListThemes`
- **Custom Events**: Inter-extension communication via `EmitCustomEvent`
**Bridged SDK APIs** (NEW): Extensions can now access internal SDK capabilities:
- **Tree Navigation**: Navigate conversation history (`GetTreeNode`, `GetCurrentBranch`, `NavigateTo`), summarize branches (`SummarizeBranch`), and implement fresh context loops (`CollapseBranch`)
- **Skill Loading**: Dynamically load and inject skills at runtime (`LoadSkill`, `DiscoverSkills`, `InjectSkillAsContext`)
- **Template Parsing**: Parse and render templates with `{{variables}}` (`ParseTemplate`, `RenderTemplate`), parse CLI-style arguments (`ParseArguments`, `SimpleParseArguments`), and evaluate model conditionals (`EvaluateModelConditional`, `RenderWithModelConditionals`)
- **Model Resolution**: Resolve model fallback chains (`ResolveModelChain`), query model capabilities (`GetModelCapabilities`, `CheckModelAvailable`), and extract provider/model ID (`GetCurrentProvider`, `GetCurrentModelID`)
### Extension Examples
See the `examples/extensions/` directory:
@@ -324,7 +318,6 @@ See the `examples/extensions/` directory:
- `compact-notify.go` - Notification on compaction
- `confirm-destructive.go` - Confirm destructive operations
- `context-inject.go` - Inject context into conversations
- `conversation-manager.go` - **NEW** Tree navigation, branch summarization, and fresh context loops
- `custom-editor-demo.go` - Vim-like modal editor
- `dev-reload.go` - Development live-reload
- `header-footer-demo.go` - Custom headers and footers
@@ -339,7 +332,6 @@ See the `examples/extensions/` directory:
- `plan-mode.go` - Read-only planning mode
- `project-rules.go` - Project-specific rules
- `prompt-demo.go` - Interactive prompts (select/confirm/input)
- `prompt-templates.go` - **NEW** Frontmatter-driven templates with model switching and skill injection
- `protected-paths.go` - Path protection for sensitive files
- `subagent-widget.go` - Multi-agent orchestration with status widget
- `subagent-test.go` - Subagent testing utilities
@@ -543,26 +535,23 @@ host, err := kit.New(ctx, &kit.Options{
### With Callbacks
```go
unsub := host.OnToolCall(func(e kit.ToolCallEvent) {
println("Calling tool:", e.ToolName)
})
defer unsub()
unsub2 := host.OnToolResult(func(e kit.ToolResultEvent) {
if e.IsError {
println("Tool failed:", e.ToolName)
}
})
defer unsub2()
unsub3 := host.OnStreaming(func(e kit.MessageUpdateEvent) {
print(e.Chunk)
})
defer unsub3()
response, err := host.Prompt(
response, err := host.PromptWithCallbacks(
ctx,
"List files in current directory",
func(name, args string) {
// Tool call started
println("Calling tool:", name)
},
func(name, args, result string, isError bool) {
// Tool call completed
if isError {
println("Tool failed:", name)
}
},
func(chunk string) {
// Streaming text chunk
print(chunk)
},
)
```
-6
View File
@@ -76,12 +76,6 @@
"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",
+9 -412
View File
@@ -799,26 +799,16 @@ 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) {
// 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)
},
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) },
@@ -1107,392 +1097,8 @@ func runNormalMode(ctx context.Context) error {
}
return nil, extResult, err
},
// -------------------------------------------------------------------------
// Tree Navigation API (Phase 1 Bridge)
// -------------------------------------------------------------------------
GetTreeNode: func(entryID string) *extensions.TreeNode {
node := kitInstance.GetTreeNode(entryID)
if node == nil {
return nil
}
return &extensions.TreeNode{
ID: node.ID,
ParentID: node.ParentID,
Type: node.Type,
Role: node.Role,
Content: node.Content,
Model: node.Model,
Provider: node.Provider,
Timestamp: node.Timestamp,
Children: node.Children,
}
},
GetCurrentBranch: func() []extensions.TreeNode {
nodes := kitInstance.GetCurrentBranch()
result := make([]extensions.TreeNode, len(nodes))
for i, n := range nodes {
result[i] = extensions.TreeNode{
ID: n.ID,
ParentID: n.ParentID,
Type: n.Type,
Role: n.Role,
Content: n.Content,
Model: n.Model,
Provider: n.Provider,
Timestamp: n.Timestamp,
Children: n.Children,
}
}
return result
},
GetChildren: kitInstance.GetChildren,
NavigateTo: func(entryID string) extensions.TreeNavigationResult {
err := kitInstance.NavigateTo(entryID)
if err != "" {
return extensions.TreeNavigationResult{Success: false, Error: err}
}
return extensions.TreeNavigationResult{Success: true}
},
SummarizeBranch: kitInstance.SummarizeBranch,
CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult {
err := kitInstance.CollapseBranch(fromID, toID, summary)
if err != "" {
return extensions.TreeNavigationResult{Success: false, Error: err}
}
return extensions.TreeNavigationResult{Success: true}
},
// -------------------------------------------------------------------------
// Skill Loading API (Phase 2 Bridge)
// -------------------------------------------------------------------------
LoadSkill: func(path string) (*extensions.Skill, string) {
s, err := kitInstance.LoadSkillForExtension(path)
return s, err
},
LoadSkillsFromDir: func(dir string) extensions.SkillLoadResult {
return kitInstance.LoadSkillsFromDirForExtension(dir)
},
DiscoverSkills: func() extensions.SkillLoadResult {
skills := kitInstance.DiscoverSkillsForExtension()
return extensions.SkillLoadResult{Skills: skills}
},
InjectSkillAsContext: func(skillName string) string {
// Find skill by name
skills := kitInstance.DiscoverSkillsForExtension()
for _, s := range skills {
if s.Name == skillName {
// Inject via SendMessage as a system context message
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
return ""
}
}
return fmt.Sprintf("skill not found: %s", skillName)
},
InjectRawSkillAsContext: func(path string) string {
s, err := kitInstance.LoadSkillForExtension(path)
if err != "" {
return err
}
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
return ""
},
GetAvailableSkills: kitInstance.DiscoverSkillsForExtension,
// -------------------------------------------------------------------------
// Template Parsing API (Phase 3 Bridge)
// -------------------------------------------------------------------------
ParseTemplate: kit.ParseTemplate,
RenderTemplate: kit.RenderTemplate,
ParseArguments: kit.ParseArguments,
SimpleParseArguments: kit.SimpleParseArguments,
EvaluateModelConditional: func(condition string) bool {
return kit.EvaluateModelConditional(kitInstance.GetExtensionContext().Model, condition)
},
RenderWithModelConditionals: func(content string) string {
return kit.RenderWithModelConditionals(content, kitInstance.GetExtensionContext().Model)
},
// -------------------------------------------------------------------------
// Model Resolution API (Phase 4 Bridge)
// -------------------------------------------------------------------------
ResolveModelChain: kit.ResolveModelChain,
GetModelCapabilities: func(model string) (extensions.ModelCapabilities, string) {
return kit.GetModelCapabilities(model)
},
CheckModelAvailable: kit.CheckModelAvailable,
GetCurrentProvider: func() string {
return kit.GetCurrentProvider(kitInstance.GetExtensionContext().Model)
},
GetCurrentModelID: func() string {
return kit.GetCurrentModelID(kitInstance.GetExtensionContext().Model)
},
})
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
},
// -------------------------------------------------------------------------
// Tree Navigation API (Phase 1 Bridge) - Second Context
// -------------------------------------------------------------------------
GetTreeNode: func(entryID string) *extensions.TreeNode {
node := kitInstance.GetTreeNode(entryID)
if node == nil {
return nil
}
return &extensions.TreeNode{
ID: node.ID,
ParentID: node.ParentID,
Type: node.Type,
Role: node.Role,
Content: node.Content,
Model: node.Model,
Provider: node.Provider,
Timestamp: node.Timestamp,
Children: node.Children,
}
},
GetCurrentBranch: func() []extensions.TreeNode {
nodes := kitInstance.GetCurrentBranch()
result := make([]extensions.TreeNode, len(nodes))
for i, n := range nodes {
result[i] = extensions.TreeNode{
ID: n.ID,
ParentID: n.ParentID,
Type: n.Type,
Role: n.Role,
Content: n.Content,
Model: n.Model,
Provider: n.Provider,
Timestamp: n.Timestamp,
Children: n.Children,
}
}
return result
},
GetChildren: kitInstance.GetChildren,
NavigateTo: func(entryID string) extensions.TreeNavigationResult {
err := kitInstance.NavigateTo(entryID)
if err != "" {
return extensions.TreeNavigationResult{Success: false, Error: err}
}
return extensions.TreeNavigationResult{Success: true}
},
SummarizeBranch: kitInstance.SummarizeBranch,
CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult {
err := kitInstance.CollapseBranch(fromID, toID, summary)
if err != "" {
return extensions.TreeNavigationResult{Success: false, Error: err}
}
return extensions.TreeNavigationResult{Success: true}
},
// -------------------------------------------------------------------------
// Skill Loading API (Phase 2 Bridge) - Second Context
// -------------------------------------------------------------------------
LoadSkill: func(path string) (*extensions.Skill, string) {
s, err := kitInstance.LoadSkillForExtension(path)
return s, err
},
LoadSkillsFromDir: func(dir string) extensions.SkillLoadResult {
return kitInstance.LoadSkillsFromDirForExtension(dir)
},
DiscoverSkills: func() extensions.SkillLoadResult {
skills := kitInstance.DiscoverSkillsForExtension()
return extensions.SkillLoadResult{Skills: skills}
},
InjectSkillAsContext: func(skillName string) string {
skills := kitInstance.DiscoverSkillsForExtension()
for _, s := range skills {
if s.Name == skillName {
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
return ""
}
}
return fmt.Sprintf("skill not found: %s", skillName)
},
InjectRawSkillAsContext: func(path string) string {
s, err := kitInstance.LoadSkillForExtension(path)
if err != "" {
return err
}
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
return ""
},
GetAvailableSkills: func() []extensions.Skill {
return kitInstance.DiscoverSkillsForExtension()
},
// -------------------------------------------------------------------------
// Template Parsing API (Phase 3 Bridge) - Second Context
// -------------------------------------------------------------------------
ParseTemplate: kit.ParseTemplate,
RenderTemplate: kit.RenderTemplate,
ParseArguments: kit.ParseArguments,
SimpleParseArguments: kit.SimpleParseArguments,
EvaluateModelConditional: func(condition string) bool {
return kit.EvaluateModelConditional(kitInstance.GetExtensionContext().Model, condition)
},
RenderWithModelConditionals: func(content string) string {
return kit.RenderWithModelConditionals(content, kitInstance.GetExtensionContext().Model)
},
// -------------------------------------------------------------------------
// Model Resolution API (Phase 4 Bridge) - Second Context
// -------------------------------------------------------------------------
ResolveModelChain: kit.ResolveModelChain,
GetModelCapabilities: func(model string) (extensions.ModelCapabilities, string) {
return kit.GetModelCapabilities(model)
},
CheckModelAvailable: kit.CheckModelAvailable,
GetCurrentProvider: func() string {
return kit.GetCurrentProvider(kitInstance.GetExtensionContext().Model)
},
GetCurrentModelID: func() string {
return kit.GetCurrentModelID(kitInstance.GetExtensionContext().Model)
},
})
}
// Convert extension commands to UI-layer type for the interactive TUI.
@@ -1616,7 +1222,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, startupExtensionMessages)
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)
}
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
@@ -1672,7 +1278,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, nil)
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 nil
@@ -1770,7 +1376,7 @@ func writeJSONError(err error) {
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
//
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, startupExtensionMessages []string) error {
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []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 {
// Determine terminal size; fall back gracefully.
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || termWidth == 0 {
@@ -1820,15 +1426,6 @@ 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)
}
fmt.Println()
}
program := tea.NewProgram(appModel)
// Register the program with the app layer so agent events are sent to the TUI.
-170
View File
@@ -1,170 +0,0 @@
//go:build ignore
// bridge_demo.go - Demonstrates the new bridged SDK APIs for extensions.
// This extension showcases tree navigation, skill loading, template parsing,
// and model resolution capabilities.
package main
import (
"encoding/json"
"fmt"
"strings"
"kit/ext"
)
var (
discoveredSkills []ext.Skill
currentBranch []ext.TreeNode
)
func Init(api ext.API) {
// Register /tree-info command to demonstrate tree navigation
api.RegisterCommand(ext.CommandDef{
Name: "tree-info",
Description: "Show current conversation tree information",
Execute: func(args string, ctx ext.Context) (string, error) {
branch := ctx.GetCurrentBranch()
info := fmt.Sprintf("Current branch has %d nodes:\n", len(branch))
for i, node := range branch {
info += fmt.Sprintf(" [%d] %s (%s): %s...\n", i, node.Type, node.ID[:8], truncate(node.Content, 40))
}
ctx.PrintInfo(info)
return "", nil
},
})
// Register /discover-skills command
api.RegisterCommand(ext.CommandDef{
Name: "discover-skills",
Description: "Discover and list available skills",
Execute: func(args string, ctx ext.Context) (string, error) {
result := ctx.DiscoverSkills()
if result.Error != "" {
return "", fmt.Errorf("discovery failed: %s", result.Error)
}
discoveredSkills = result.Skills
info := fmt.Sprintf("Discovered %d skills:\n", len(result.Skills))
for _, s := range result.Skills {
info += fmt.Sprintf(" - %s: %s\n", s.Name, s.Description)
}
ctx.PrintInfo(info)
return "", nil
},
})
// Register /parse-template command
api.RegisterCommand(ext.CommandDef{
Name: "parse-template",
Description: "Parse a template and show extracted variables",
Execute: func(args string, ctx ext.Context) (string, error) {
if args == "" {
args = "Hello {{name}}, welcome to {{place}}!"
}
tpl := ctx.ParseTemplate("demo", args)
info := fmt.Sprintf("Template: %s\nVariables: %v", tpl.Content, tpl.Variables)
ctx.PrintInfo(info)
return "", nil
},
})
// Register /render-template command
api.RegisterCommand(ext.CommandDef{
Name: "render-template",
Description: "Render a template with variables (usage: /render-template name=John place=Kit)",
Execute: func(args string, ctx ext.Context) (string, error) {
tpl := ctx.ParseTemplate("demo", "Hello {{name}}, welcome to {{place}}!")
vars := ctx.ParseArguments(args, ext.ArgumentPattern{
Flags: map[string]string{"name": "name", "place": "place"},
})
rendered := ctx.RenderTemplate(tpl, vars.Vars)
ctx.PrintInfo("Rendered: " + rendered)
return "", nil
},
})
// Register /check-model command
api.RegisterCommand(ext.CommandDef{
Name: "check-model",
Description: "Check model capabilities and availability",
Execute: func(args string, ctx ext.Context) (string, error) {
model := args
if model == "" {
model = ctx.Model
}
available := ctx.CheckModelAvailable(model)
caps, err := ctx.GetModelCapabilities(model)
info := fmt.Sprintf("Model: %s\n", model)
info += fmt.Sprintf("Available: %v\n", available)
if err == "" {
info += fmt.Sprintf("Provider: %s\n", caps.Provider)
info += fmt.Sprintf("Context Limit: %d\n", caps.ContextLimit)
info += fmt.Sprintf("Reasoning: %v\n", caps.Reasoning)
} else {
info += fmt.Sprintf("Error: %s\n", err)
}
ctx.PrintInfo(info)
return "", nil
},
})
// Register /resolve-chain command
api.RegisterCommand(ext.CommandDef{
Name: "resolve-chain",
Description: "Resolve a model chain (usage: /resolve-chain claude-opus,gpt-4o,claude-sonnet)",
Execute: func(args string, ctx ext.Context) (string, error) {
if args == "" {
args = "anthropic/claude-opus-4,anthropic/claude-sonnet-4,openai/gpt-4o"
}
prefs := ctx.SimpleParseArguments(args, 1)
chain := []string{}
if len(prefs) > 1 {
// Split the first arg by comma
for _, p := range strings.Split(prefs[1], ",") {
p = strings.TrimSpace(p)
if p != "" {
chain = append(chain, p)
}
}
}
result := ctx.ResolveModelChain(chain)
info, _ := json.MarshalIndent(result, "", " ")
ctx.PrintInfo("Resolution Result:\n" + string(info))
return "", nil
},
})
// Register /test-conditional command
api.RegisterCommand(ext.CommandDef{
Name: "test-conditional",
Description: "Test model conditional rendering",
Execute: func(args string, ctx ext.Context) (string, error) {
content := `<if-model is="claude-*">This is for Claude models<else>This is for other models</if-model>`
rendered := ctx.RenderWithModelConditionals(content)
ctx.PrintInfo("Input: " + content)
ctx.PrintInfo("Output: " + rendered)
ctx.PrintInfo(fmt.Sprintf("Current model matches 'claude-*': %v", ctx.EvaluateModelConditional("claude-*")))
return "", nil
},
})
// OnSessionStart: discover skills automatically
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
result := ctx.DiscoverSkills()
if result.Error == "" && len(result.Skills) > 0 {
discoveredSkills = result.Skills
ctx.SetStatus("bridge-demo", fmt.Sprintf("%d skills", len(result.Skills)), 50)
}
})
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}
-406
View File
@@ -1,406 +0,0 @@
//go:build ignore
// conversation-manager.go - Advanced conversation tree navigation and management.
// This extension demonstrates:
// - Tree navigation (GetTreeNode, GetCurrentBranch, NavigateTo)
// - Branch summarization and collapsing
// - Interactive tree exploration
//
// Commands:
// /tree - Show conversation tree structure
// /branch - Show current branch path
// /goto <entry-id> - Navigate to a specific entry
// /summarize <n> - Summarize last N messages
// /fresh-context - Collapse branch and start fresh
// /loop <n> <prompt> - Execute prompt N times with fresh context each iteration
package main
import (
"fmt"
"strconv"
"strings"
"time"
"kit/ext"
)
var (
loopActive bool
loopCount int
loopCurrent int
loopPrompt string
loopStartNode string
)
func Init(api ext.API) {
// /tree - Show tree structure
api.RegisterCommand(ext.CommandDef{
Name: "tree",
Description: "Show conversation tree structure",
Execute: func(args string, ctx ext.Context) (string, error) {
showTree(ctx)
return "", nil
},
})
// /branch - Show current branch
api.RegisterCommand(ext.CommandDef{
Name: "branch",
Description: "Show current conversation branch",
Execute: func(args string, ctx ext.Context) (string, error) {
showBranch(ctx)
return "", nil
},
})
// /goto - Navigate to entry
api.RegisterCommand(ext.CommandDef{
Name: "goto",
Description: "Navigate to a specific entry ID (usage: /goto <entry-id>)",
Execute: func(args string, ctx ext.Context) (string, error) {
if args == "" {
ctx.PrintError("Usage: /goto <entry-id>")
return "", nil
}
result := ctx.NavigateTo(args)
if !result.Success {
ctx.PrintError(fmt.Sprintf("Navigation failed: %s", result.Error))
return "", nil
}
ctx.PrintInfo(fmt.Sprintf("Navigated to entry: %s", args))
// Show the node we navigated to
node := ctx.GetTreeNode(args)
if node != nil {
ctx.PrintInfo(fmt.Sprintf("Entry type: %s, Role: %s", node.Type, node.Role))
}
return "", nil
},
})
// /summarize - Summarize recent messages
api.RegisterCommand(ext.CommandDef{
Name: "summarize",
Description: "Summarize last N messages (usage: /summarize [n=5])",
Execute: func(args string, ctx ext.Context) (string, error) {
n := 5
if args != "" {
if parsed, err := strconv.Atoi(args); err == nil && parsed > 0 {
n = parsed
}
}
branch := ctx.GetCurrentBranch()
if len(branch) < 2 {
ctx.PrintError("Not enough messages to summarize")
return "", nil
}
// Find range to summarize
startIdx := len(branch) - n - 1
if startIdx < 0 {
startIdx = 0
}
endIdx := len(branch) - 1
fromID := branch[startIdx].ID
toID := branch[endIdx].ID
ctx.PrintInfo(fmt.Sprintf("Summarizing messages %d to %d...", startIdx, endIdx))
summary := ctx.SummarizeBranch(fromID, toID)
if summary == "" {
ctx.PrintError("Failed to generate summary")
return "", nil
}
ctx.PrintBlock(ext.PrintBlockOpts{
Text: summary,
BorderColor: "#89b4fa",
Subtitle: "conversation-manager · Summary",
})
return "", nil
},
})
// /fresh-context - Collapse and restart
api.RegisterCommand(ext.CommandDef{
Name: "fresh-context",
Description: "Collapse conversation to summary and start fresh",
Execute: func(args string, ctx ext.Context) (string, error) {
branch := ctx.GetCurrentBranch()
if len(branch) < 3 {
ctx.PrintError("Not enough context to collapse")
return "", nil
}
// Keep first message (system), summarize rest
fromID := branch[1].ID
toID := branch[len(branch)-1].ID
ctx.PrintInfo("Generating summary for context collapse...")
summary := ctx.SummarizeBranch(fromID, toID)
if summary == "" {
ctx.PrintError("Failed to generate summary")
return "", nil
}
// Collapse the branch
result := ctx.CollapseBranch(fromID, toID, summary)
if !result.Success {
ctx.PrintError(fmt.Sprintf("Collapse failed: %s", result.Error))
return "", nil
}
ctx.PrintInfo("Context collapsed. Starting fresh with summary.")
ctx.PrintBlock(ext.PrintBlockOpts{
Text: summary,
BorderColor: "#a6e3a1",
Subtitle: "conversation-manager · Collapsed Context",
})
// Set a widget showing we're in fresh mode
ctx.SetWidget(ext.WidgetConfig{
ID: "fresh-context",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: "🌱 Fresh Context Mode - Previous conversation collapsed"},
Style: ext.WidgetStyle{BorderColor: "#a6e3a1"},
})
return "", nil
},
})
// /loop - Execute with fresh context each iteration
api.RegisterCommand(ext.CommandDef{
Name: "loop",
Description: "Execute prompt N times with fresh context (usage: /loop 5 analyze this code)",
Execute: func(args string, ctx ext.Context) (string, error) {
if loopActive {
ctx.PrintError("Loop already in progress. Wait for completion.")
return "", nil
}
// Parse arguments
parts := strings.SplitN(args, " ", 2)
if len(parts) < 2 {
ctx.PrintError("Usage: /loop <count> <prompt>")
return "", nil
}
count, err := strconv.Atoi(parts[0])
if err != nil || count <= 0 || count > 10 {
ctx.PrintError("Invalid count (must be 1-10)")
return "", nil
}
loopCount = count
loopCurrent = 0
loopPrompt = parts[1]
loopActive = true
// Store current branch position
branch := ctx.GetCurrentBranch()
if len(branch) > 0 {
loopStartNode = branch[len(branch)-1].ID
}
ctx.PrintInfo(fmt.Sprintf("Starting loop: %d iterations", loopCount))
ctx.SetWidget(ext.WidgetConfig{
ID: "loop-progress",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: fmt.Sprintf("🔄 Loop: 0/%d - %s", loopCount, loopPrompt)},
Style: ext.WidgetStyle{BorderColor: "#fab387"},
})
// Start first iteration
executeLoopIteration(ctx)
return "", nil
},
})
// OnAgentEnd handles loop continuation
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
if !loopActive {
return
}
loopCurrent++
if loopCurrent >= loopCount {
// Loop complete
loopActive = false
ctx.RemoveWidget("loop-progress")
ctx.PrintInfo(fmt.Sprintf("✅ Loop complete: %d/%d iterations", loopCurrent, loopCount))
// Show final summary
branch := ctx.GetCurrentBranch()
if len(branch) > 0 && loopStartNode != "" {
summary := ctx.SummarizeBranch(loopStartNode, branch[len(branch)-1].ID)
if summary != "" {
ctx.PrintBlock(ext.PrintBlockOpts{
Text: summary,
BorderColor: "#a6e3a1",
Subtitle: "conversation-manager · Loop Summary",
})
}
}
return
}
// Update progress
ctx.SetWidget(ext.WidgetConfig{
ID: "loop-progress",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: fmt.Sprintf("🔄 Loop: %d/%d - %s", loopCurrent, loopCount, loopPrompt)},
Style: ext.WidgetStyle{BorderColor: "#fab387"},
})
// Collapse previous iteration for fresh context
branch := ctx.GetCurrentBranch()
if len(branch) >= 2 {
// Find the user messages (look for the one before the last assistant message)
// We want to collapse from the user message that started this iteration
// to the last assistant response
var collapseStartIdx = -1
for i := len(branch) - 1; i >= 0; i-- {
if branch[i].Role == "assistant" {
// Found the last assistant message, now find the user message before it
for j := i - 1; j >= 0; j-- {
if branch[j].Role == "user" {
collapseStartIdx = j
break
}
}
break
}
}
if collapseStartIdx >= 0 {
fromID := branch[collapseStartIdx].ID
toID := branch[len(branch)-1].ID
ctx.PrintInfo(fmt.Sprintf("Collapsing iteration %d for fresh context...", loopCurrent))
summary := ctx.SummarizeBranch(fromID, toID)
if summary != "" {
result := ctx.CollapseBranch(fromID, toID, summary)
if result.Success {
ctx.PrintInfo("Context collapsed successfully")
} else {
ctx.PrintError(fmt.Sprintf("Collapse failed: %s", result.Error))
}
}
}
}
// Small delay to let UI update
time.Sleep(500 * time.Millisecond)
// Trigger next iteration
executeLoopIteration(ctx)
})
}
// showTree displays the conversation tree structure
func showTree(ctx ext.Context) {
branch := ctx.GetCurrentBranch()
if len(branch) == 0 {
ctx.PrintInfo("Tree is empty")
return
}
var output strings.Builder
output.WriteString(fmt.Sprintf("Conversation Tree (%d nodes):\n\n", len(branch)))
for i, node := range branch {
prefix := " "
if i == len(branch)-1 {
prefix = "▶ " // Current node
} else {
prefix = " "
}
roleIcon := "💬"
switch node.Role {
case "user":
roleIcon = "👤"
case "assistant":
roleIcon = "🤖"
case "system":
roleIcon = "⚙️"
}
content := truncate(node.Content, 50)
if node.Type == "branch_summary" {
roleIcon = "📋"
content = "[Summary] " + truncate(node.Content, 40)
}
output.WriteString(fmt.Sprintf("%s%s %s: %s (%s...)\n", prefix, roleIcon, node.Role, node.ID[:8], content))
// Show children count if any
children := ctx.GetChildren(node.ID)
if len(children) > 0 {
output.WriteString(fmt.Sprintf(" └─ %d branch(es)\n", len(children)))
}
}
ctx.PrintBlock(ext.PrintBlockOpts{
Text: output.String(),
BorderColor: "#89b4fa",
Subtitle: "conversation-manager · Tree View",
})
}
// showBranch displays the current branch path
func showBranch(ctx ext.Context) {
branch := ctx.GetCurrentBranch()
if len(branch) == 0 {
ctx.PrintInfo("No active branch")
return
}
var output strings.Builder
output.WriteString(fmt.Sprintf("Current Branch (%d nodes from root to leaf):\n\n", len(branch)))
for i, node := range branch {
marker := " "
if i == len(branch)-1 {
marker = "▶ " // Current leaf
}
output.WriteString(fmt.Sprintf("%s[%d] %s (%s): %s\n",
marker, i, node.Type, node.ID[:8], truncate(node.Content, 40)))
}
// Show current node details
leaf := branch[len(branch)-1]
output.WriteString(fmt.Sprintf("\nCurrent Leaf:\n"))
output.WriteString(fmt.Sprintf(" ID: %s\n", leaf.ID))
output.WriteString(fmt.Sprintf(" Type: %s\n", leaf.Type))
output.WriteString(fmt.Sprintf(" Role: %s\n", leaf.Role))
output.WriteString(fmt.Sprintf(" Model: %s\n", leaf.Model))
output.WriteString(fmt.Sprintf(" Children: %d\n", len(leaf.Children)))
ctx.PrintBlock(ext.PrintBlockOpts{
Text: output.String(),
BorderColor: "#cba6f7",
Subtitle: "conversation-manager · Branch View",
})
}
// executeLoopIteration triggers the next loop iteration
func executeLoopIteration(ctx ext.Context) {
iterationPrompt := fmt.Sprintf("[%d/%d] %s", loopCurrent+1, loopCount, loopPrompt)
ctx.SendMessage(iterationPrompt)
}
// truncate helper
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}
+1 -1
View File
@@ -908,7 +908,7 @@ func summarizeToolAction(toolName string, inputJSON string) string {
return "searching " + getStr("pattern", "text")
case "ls":
return "listing " + getStr("path", "directory")
case "subagent":
case "spawn_subagent":
return "spawning subagent"
default:
return "using " + toolName
-269
View File
@@ -1,269 +0,0 @@
//go:build ignore
// prompt-templates.go - Frontmatter-driven prompt templates with model switching.
// This extension demonstrates the new bridged SDK APIs:
// - Tree navigation for conversation management
// - Template parsing with {{variable}} substitution
// - Model resolution with fallback chains
// - Skill injection
//
// Usage:
// 1. Create ~/.config/kit/prompts/debug.md with frontmatter:
// ---
// description: Debug Python code
// model: claude-sonnet-4-20250514
// skill: python
// ---
// Help me debug this Python code: {{input}}
//
// 2. In Kit: /debug my_script.py
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"kit/ext"
)
// PromptTemplate represents a loaded template with frontmatter
type PromptTemplate struct {
Name string
Description string
Model string
Skill string
Content string
Variables []string
Path string
}
var (
templates = make(map[string]PromptTemplate)
templateDir string
)
func Init(api ext.API) {
// Determine template directory
home, _ := os.UserHomeDir()
templateDir = filepath.Join(home, ".config", "kit", "prompts")
// Ensure directory exists
os.MkdirAll(templateDir, 0755)
// Register commands
api.RegisterCommand(ext.CommandDef{
Name: "reload-templates",
Description: "Reload prompt templates from disk",
Execute: func(args string, ctx ext.Context) (string, error) {
loadTemplates(ctx)
ctx.PrintInfo(fmt.Sprintf("Loaded %d templates from %s", len(templates), templateDir))
return "", nil
},
})
// Dynamic template commands are registered after loading
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
loadTemplates(ctx)
registerTemplateCommands(api, ctx)
})
}
// loadTemplates discovers and loads all template files
func loadTemplates(ctx ext.Context) {
templates = make(map[string]PromptTemplate)
entries, err := os.ReadDir(templateDir)
if err != nil {
return
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
path := filepath.Join(templateDir, entry.Name())
tpl, err := loadTemplateFile(path)
if err != nil {
continue
}
name := strings.TrimSuffix(entry.Name(), ".md")
templates[name] = tpl
}
}
// loadTemplateFile parses a template with YAML frontmatter
func loadTemplateFile(path string) (PromptTemplate, error) {
data, err := os.ReadFile(path)
if err != nil {
return PromptTemplate{}, err
}
content := string(data)
tpl := PromptTemplate{Path: path}
// Parse frontmatter
if strings.HasPrefix(content, "---") {
parts := strings.SplitN(content[3:], "---", 2)
if len(parts) == 2 {
frontmatter := strings.TrimSpace(parts[0])
body := strings.TrimSpace(parts[1])
// Simple line-by-line frontmatter parsing
for _, line := range strings.Split(frontmatter, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, ":")
if found {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
switch key {
case "description":
tpl.Description = value
case "model":
tpl.Model = value
case "skill":
tpl.Skill = value
}
}
}
tpl.Content = body
} else {
tpl.Content = content
}
} else {
tpl.Content = content
}
// Parse {{variables}} using simple string parsing
// (Can't use ctx.ParseTemplate here since we're in Init, not a handler)
var vars []string
for {
start := strings.Index(tpl.Content, "{{")
if start == -1 {
break
}
end := strings.Index(tpl.Content[start:], "}}")
if end == -1 {
break
}
varName := strings.TrimSpace(tpl.Content[start+2 : start+end])
vars = append(vars, varName)
tpl.Content = tpl.Content[:start] + "{{" + varName + "}}" + tpl.Content[start+end+2:]
}
tpl.Variables = vars
return tpl, nil
}
// registerTemplateCommands dynamically registers commands for each template
func registerTemplateCommands(api ext.API, ctx ext.Context) {
for name, tpl := range templates {
// Skip if already registered (we'd need to track this)
tplCopy := tpl // Capture for closure
nameCopy := name
// Build description with metadata
desc := tplCopy.Description
if desc == "" {
desc = fmt.Sprintf("Run %s template", nameCopy)
}
if tplCopy.Model != "" {
desc += fmt.Sprintf(" [%s", tplCopy.Model)
if tplCopy.Skill != "" {
desc += fmt.Sprintf(" +%s", tplCopy.Skill)
}
desc += "]"
}
api.RegisterCommand(ext.CommandDef{
Name: nameCopy,
Description: desc,
Execute: func(args string, ctx ext.Context) (string, error) {
return executeTemplate(ctx, tplCopy, args)
},
})
}
}
// executeTemplate runs a template with the given arguments
func executeTemplate(ctx ext.Context, tpl PromptTemplate, args string) (string, error) {
// Store original model for restoration
originalModel := ctx.Model
// 1. Resolve and switch model if specified
if tpl.Model != "" {
// Parse model chain (comma-separated)
preferences := strings.Split(tpl.Model, ",")
for i := range preferences {
preferences[i] = strings.TrimSpace(preferences[i])
}
result := ctx.ResolveModelChain(preferences)
if result.Error != "" {
ctx.PrintError(fmt.Sprintf("Model resolution failed: %s", result.Error))
// Continue with current model
} else {
ctx.PrintInfo(fmt.Sprintf("Switching to model: %s", result.Model))
if err := ctx.SetModel(result.Model); err != nil {
ctx.PrintError(fmt.Sprintf("Failed to switch model: %s", err.Error()))
}
}
}
// 2. Inject skill if specified
if tpl.Skill != "" {
err := ctx.InjectSkillAsContext(tpl.Skill)
if err != "" {
ctx.PrintError(fmt.Sprintf("Skill injection failed: %s", err))
} else {
ctx.PrintInfo(fmt.Sprintf("Injected skill: %s", tpl.Skill))
}
}
// 3. Parse and render template
parsed := ctx.ParseTemplate(tpl.Name, tpl.Content)
// Build variable map
vars := make(map[string]string)
// Simple argument parsing: first arg is $1 (input), rest is $@
if len(parsed.Variables) > 0 {
argsList := ctx.SimpleParseArguments(args, len(parsed.Variables))
for i, varName := range parsed.Variables {
if i < len(parsed.Variables) && i+1 < len(argsList) {
vars[varName] = argsList[i+1]
}
}
// If single variable, use full args
if len(parsed.Variables) == 1 && vars[parsed.Variables[0]] == "" {
vars[parsed.Variables[0]] = args
}
}
// Render with model conditionals
content := ctx.RenderWithModelConditionals(tpl.Content)
rendered := ctx.RenderTemplate(ext.PromptTemplate{Name: tpl.Name, Content: content, Variables: parsed.Variables}, vars)
// 4. Send the rendered prompt
ctx.SendMessage(rendered)
// 5. Schedule model restoration after turn completes
// We use a goroutine to wait and restore
if tpl.Model != "" && originalModel != "" {
go func() {
// Note: In a real implementation, we'd use OnAgentEnd event
// For now, the user can manually switch back
ctx.SetStatus("template-mode", fmt.Sprintf("Template: %s (model will restore)", tpl.Name), 20)
}()
}
return fmt.Sprintf("Executing template: %s", tpl.Name), nil
}
+1 -1
View File
@@ -37,7 +37,7 @@ func Init(api ext.API) {
"Subagent Test Extension loaded\n\n" +
"/subtest <task> Spawn blocking subagent\n" +
"/subbg <task> Spawn background subagent\n\n" +
"The LLM can also use the subagent tool.")
"The LLM can also use the spawn_subagent tool.")
})
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
-1
View File
@@ -82,7 +82,6 @@ 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,8 +187,6 @@ 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=
+5 -1
View File
@@ -257,7 +257,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// field so Fantasy includes them in the API request.
prompt, files, history := splitPromptAndHistory(messages)
// Track current tool call args for callbacks
// Track current tool call info for callbacks
var currentToolName string
var currentToolArgs string
// Use the streaming path when streaming is enabled OR when any callbacks are
@@ -307,6 +308,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
if ctx.Err() != nil {
return ctx.Err()
}
currentToolName = tc.ToolName
currentToolArgs = tc.Input
// Notify about the tool call
@@ -450,6 +452,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
onResponse(result.Response.Content.Text())
}
_ = currentToolName // satisfy compiler for non-streaming path
return convertAgentResult(result, messages), nil
}
+31 -107
View File
@@ -3,10 +3,8 @@ package app
import (
"context"
"fmt"
"log"
"os"
"sync"
"sync/atomic"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
@@ -600,10 +598,9 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
}
}
// Subscribe to SDK events for TUI rendering and per-step usage updates.
// The subscription is temporary — it lives only for the duration of this step.
var sawStepUsage atomic.Bool
unsub := a.subscribeSDKEvents(sendFn, &sawStepUsage)
// Subscribe to SDK events for TUI rendering. The subscription is
// temporary — it lives only for the duration of this step.
unsub := a.subscribeSDKEvents(sendFn)
defer unsub()
// Show spinner while the agent works.
@@ -623,9 +620,8 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
// Sync in-memory store with the SDK's authoritative conversation.
a.store.Replace(result.Messages)
// Update usage tracker. If per-step usage was already recorded from
// StepUsageEvent callbacks, avoid double-counting totals.
a.updateUsageFromTurnResult(result, prompt, sawStepUsage.Load())
// Update usage tracker.
a.updateUsageFromTurnResult(result, prompt)
return result, nil
}
@@ -649,10 +645,9 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(
}
}
// Subscribe to SDK events for TUI rendering and per-step usage updates.
// The subscription is temporary — it lives only for the duration of this step.
var sawStepUsage atomic.Bool
unsub := a.subscribeSDKEvents(sendFn, &sawStepUsage)
// Subscribe to SDK events for TUI rendering. The subscription is
// temporary — it lives only for the duration of this step.
unsub := a.subscribeSDKEvents(sendFn)
defer unsub()
// Show spinner while the agent works.
@@ -685,8 +680,8 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(
messages = append(messages, item.Prompt)
}
// File attachments are not supported in batch mode; fall back to
// processing only the first item that carries files.
// TODO: Handle file attachments in batch mode
// For now, files are ignored in batch mode (rare edge case)
if hasFiles {
// If files exist, fall back to processing just the first item with files
for _, item := range items {
@@ -707,10 +702,8 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(
// Sync in-memory store with the SDK's authoritative conversation.
a.store.Replace(result.Messages)
// Update usage tracker (using last item's prompt for fallback estimation).
// If per-step usage was already recorded from StepUsageEvent callbacks,
// avoid double-counting totals.
a.updateUsageFromTurnResult(result, items[len(items)-1].Prompt, sawStepUsage.Load())
// Update usage tracker (using last item's prompt for tracking).
a.updateUsageFromTurnResult(result, items[len(items)-1].Prompt)
return result, nil
}
@@ -727,10 +720,9 @@ func (a *App) sendEvent(msg tea.Msg) {
}
// subscribeSDKEvents registers temporary SDK event subscribers that convert
// SDK events to tea.Msg events and dispatch them via sendFn. When stepUsageSeen
// is provided, it is set to true after any non-zero StepUsageEvent is observed.
// Returns an unsubscribe function that removes all listeners.
func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Bool) func() {
// SDK events to tea.Msg events and dispatch them via sendFn. Returns an
// unsubscribe function that removes all listeners.
func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
k := a.opts.Kit
var unsubs []func()
@@ -764,8 +756,6 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo
})
case kit.SteerConsumedEvent:
sendFn(SteerConsumedEvent{})
case kit.StepUsageEvent:
a.recordStepUsage(ev, stepUsageSeen)
}
}))
@@ -935,106 +925,40 @@ func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
}
}
// recordStepUsage applies token/cost usage reported for a completed step.
// Step usage events arrive even when a turn is later cancelled, so this keeps
// the usage widget accurate on all stop paths.
func (a *App) recordStepUsage(ev kit.StepUsageEvent, stepUsageSeen *atomic.Bool) {
hasUsage := ev.InputTokens > 0 || ev.OutputTokens > 0 || ev.CacheReadTokens > 0 || ev.CacheWriteTokens > 0
if a.opts.Debug {
log.Printf("[DEBUG] recordStepUsage: hasUsage=%v input=%d output=%d cacheRead=%d cacheWrite=%d",
hasUsage, ev.InputTokens, ev.OutputTokens, ev.CacheReadTokens, ev.CacheWriteTokens)
}
if !hasUsage {
return
}
if stepUsageSeen != nil {
stepUsageSeen.Store(true)
}
if a.opts.UsageTracker == nil {
return
}
a.opts.UsageTracker.UpdateUsage(
int(ev.InputTokens),
int(ev.OutputTokens),
int(ev.CacheReadTokens),
int(ev.CacheWriteTokens),
)
// NOTE: We do NOT call SetContextTokens here. Context fill is set once
// at turn completion via updateUsageFromTurnResult using FinalUsage.InputTokens,
// which reflects the full accumulated context. Per-step context tokens would
// cause the display to jump around during multi-step tool calls.
}
// updateUsageFromTurnResult records token usage from an SDK TurnResult into the
// configured UsageTracker. Called once per turn after the turn completes.
//
// When sawStepUsage is true, totals were already accumulated incrementally via
// StepUsageEvent callbacks; in that case this method only updates context fill.
// Otherwise it falls back to TotalUsage from the API response.
//
// NOTE: We only use ACTUAL token counts from API responses for cost tracking.
// Estimation is never used for costs - only API-reported tokens are accurate.
func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string, sawStepUsage bool) {
// Cost/token accumulation uses TotalUsage (sum across all tool-calling steps in
// the turn). Context-window fill uses FinalUsage.InputTokens only — that is the
// number of tokens sent to the model on the last API call, which equals the
// actual context window occupation (all accumulated messages + tool results).
// OutputTokens are not added here because they are the response length, not
// context fill.
func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string) {
if a.opts.UsageTracker == nil || result == nil {
return
}
// Debug logging for token tracking
if a.opts.Debug {
if result.TotalUsage != nil {
log.Printf("[DEBUG] updateUsageFromTurnResult TotalUsage: input=%d output=%d cacheRead=%d cacheCreate=%d",
result.TotalUsage.InputTokens, result.TotalUsage.OutputTokens,
result.TotalUsage.CacheReadTokens, result.TotalUsage.CacheCreationTokens)
} else {
log.Printf("[DEBUG] updateUsageFromTurnResult: TotalUsage=nil")
}
if result.FinalUsage != nil {
log.Printf("[DEBUG] updateUsageFromTurnResult FinalUsage: input=%d output=%d cacheRead=%d cacheCreate=%d",
result.FinalUsage.InputTokens, result.FinalUsage.OutputTokens,
result.FinalUsage.CacheReadTokens, result.FinalUsage.CacheCreationTokens)
} else {
log.Printf("[DEBUG] updateUsageFromTurnResult: FinalUsage=nil")
}
log.Printf("[DEBUG] updateUsageFromTurnResult: sawStepUsage=%v", sawStepUsage)
}
// --- Accumulate cost/token totals for the session ---
// Only use actual API-reported tokens for cost tracking.
// If sawStepUsage is true, totals were already updated via StepUsageEvent.
// Check any token field > 0 (not just InputTokens) because cached prompts
// can result in InputTokens=0 while OutputTokens>0 (OpenAI-compatible behavior).
hasTotalUsage := result.TotalUsage != nil &&
(result.TotalUsage.InputTokens > 0 ||
result.TotalUsage.OutputTokens > 0 ||
result.TotalUsage.CacheReadTokens > 0 ||
result.TotalUsage.CacheCreationTokens > 0)
if a.opts.Debug {
log.Printf("[DEBUG] updateUsageFromTurnResult: hasTotalUsage=%v", hasTotalUsage)
}
if !sawStepUsage && hasTotalUsage {
if a.opts.Debug {
log.Printf("[DEBUG] updateUsageFromTurnResult: calling UpdateUsage input=%d output=%d cacheRead=%d cacheCreate=%d",
result.TotalUsage.InputTokens, result.TotalUsage.OutputTokens,
result.TotalUsage.CacheReadTokens, result.TotalUsage.CacheCreationTokens)
}
if result.TotalUsage != nil && result.TotalUsage.InputTokens > 0 {
a.opts.UsageTracker.UpdateUsage(
int(result.TotalUsage.InputTokens),
int(result.TotalUsage.OutputTokens),
int(result.TotalUsage.CacheReadTokens),
int(result.TotalUsage.CacheCreationTokens),
)
} else {
// Provider didn't report token counts — fall back to character-based
// estimates so the footer shows something rather than nothing.
a.opts.UsageTracker.EstimateAndUpdateUsage(userPrompt, result.Response)
}
// --- Context window fill (drives the % bar) ---
// Use FinalUsage.InputTokens as the context window fill. The API's InputTokens
// already includes the full conversation history (system prompt + all previous
// messages + current user message). Adding OutputTokens would double-count since
// the output becomes part of the input for the next turn.
// Use FinalUsage.InputTokens: the input token count of the last API call
// equals the number of tokens currently occupying the context window.
// Adding OutputTokens would overstate fill since the response is not part
// of the context that was *sent* to the model.
if result.FinalUsage != nil && result.FinalUsage.InputTokens > 0 {
if a.opts.Debug {
log.Printf("[DEBUG] updateUsageFromTurnResult: calling SetContextTokens=%d (FinalUsage.InputTokens)",
result.FinalUsage.InputTokens)
}
a.opts.UsageTracker.SetContextTokens(int(result.FinalUsage.InputTokens))
}
}
-173
View File
@@ -7,8 +7,6 @@ import (
"testing"
"time"
"charm.land/fantasy"
kit "github.com/mark3labs/kit/pkg/kit"
)
@@ -16,47 +14,6 @@ import (
// Helpers
// --------------------------------------------------------------------------
type usageUpdaterStub struct {
mu sync.Mutex
updateCalls int
estimateCalls int
contextCalls int
lastUpdateInput int
lastUpdateOutput int
lastUpdateCacheRead int
lastUpdateCacheWrite int
lastContextTokens int
lastEstimateInput string
lastEstimateOutput string
}
func (s *usageUpdaterStub) UpdateUsage(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int) {
s.mu.Lock()
defer s.mu.Unlock()
s.updateCalls++
s.lastUpdateInput = inputTokens
s.lastUpdateOutput = outputTokens
s.lastUpdateCacheRead = cacheReadTokens
s.lastUpdateCacheWrite = cacheWriteTokens
}
func (s *usageUpdaterStub) EstimateAndUpdateUsage(inputText, outputText string) {
s.mu.Lock()
defer s.mu.Unlock()
s.estimateCalls++
s.lastEstimateInput = inputText
s.lastEstimateOutput = outputText
}
func (s *usageUpdaterStub) SetContextTokens(tokens int) {
s.mu.Lock()
defer s.mu.Unlock()
s.contextCalls++
s.lastContextTokens = tokens
}
// turnResult builds a minimal TurnResult with response text t.
func turnResult(t string) *kit.TurnResult {
return &kit.TurnResult{Response: t}
@@ -532,133 +489,3 @@ func TestQueueLength_reflects(t *testing.T) {
t.Fatalf("expected 3, got %d", got)
}
}
// TestRecordStepUsage_updatesTracker verifies that per-step usage updates are
// recorded immediately for cost tracking. Context tokens are NOT updated here
// (only via updateUsageFromTurnResult) to avoid display jumps during multi-step
// tool calls.
func TestRecordStepUsage_updatesTracker(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
app.recordStepUsage(kit.StepUsageEvent{
InputTokens: 120,
OutputTokens: 45,
CacheReadTokens: 5,
CacheWriteTokens: 2,
}, nil)
usage.mu.Lock()
defer usage.mu.Unlock()
if usage.updateCalls != 1 {
t.Fatalf("expected 1 update call, got %d", usage.updateCalls)
}
if usage.lastUpdateInput != 120 || usage.lastUpdateOutput != 45 || usage.lastUpdateCacheRead != 5 || usage.lastUpdateCacheWrite != 2 {
t.Fatalf("unexpected usage update payload: in=%d out=%d cache_read=%d cache_write=%d",
usage.lastUpdateInput, usage.lastUpdateOutput, usage.lastUpdateCacheRead, usage.lastUpdateCacheWrite)
}
// Context tokens should NOT be updated by recordStepUsage (only by updateUsageFromTurnResult)
if usage.contextCalls != 0 {
t.Fatalf("expected 0 context token updates from recordStepUsage, got %d", usage.contextCalls)
}
}
// TestUpdateUsageFromTurnResult_skipsTotalsWhenStepUsageSeen ensures we avoid
// double-counting totals once StepUsageEvent-based updates were already applied.
func TestUpdateUsageFromTurnResult_skipsTotalsWhenStepUsageSeen(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &fantasy.Usage{
InputTokens: 999,
OutputTokens: 111,
CacheReadTokens: 7,
CacheCreationTokens: 3,
},
FinalUsage: &fantasy.Usage{InputTokens: 456},
}, "prompt", true)
usage.mu.Lock()
defer usage.mu.Unlock()
if usage.updateCalls != 0 {
t.Fatalf("expected no total usage update when sawStepUsage=true, got %d", usage.updateCalls)
}
if usage.estimateCalls != 0 {
t.Fatalf("expected no estimate update when sawStepUsage=true, got %d", usage.estimateCalls)
}
// Context tokens should be InputTokens only (456)
if usage.contextCalls != 1 || usage.lastContextTokens != 456 {
t.Fatalf("expected final context tokens=456 (InputTokens only), got calls=%d tokens=%d", usage.contextCalls, usage.lastContextTokens)
}
}
// TestUpdateUsageFromTurnResult_recordsWhenInputTokensZero verifies that usage
// is recorded when InputTokens=0 but OutputTokens>0 (OpenAI-compatible cache behavior).
func TestUpdateUsageFromTurnResult_recordsWhenInputTokensZero(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
// Simulate OpenAI-compatible behavior: all prompt tokens cached, InputTokens=0
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &fantasy.Usage{
InputTokens: 0, // All cached - subtracted from prompt
OutputTokens: 150, // Actual generated tokens
CacheReadTokens: 500, // Cache hit
CacheCreationTokens: 0,
},
FinalUsage: &fantasy.Usage{InputTokens: 0, OutputTokens: 150},
}, "prompt", false)
usage.mu.Lock()
defer usage.mu.Unlock()
if usage.updateCalls != 1 {
t.Fatalf("expected 1 update call when InputTokens=0 but OutputTokens>0, got %d", usage.updateCalls)
}
if usage.lastUpdateInput != 0 || usage.lastUpdateOutput != 150 {
t.Fatalf("expected input=0 output=150, got input=%d output=%d",
usage.lastUpdateInput, usage.lastUpdateOutput)
}
if usage.lastUpdateCacheRead != 500 {
t.Fatalf("expected cache_read=500, got %d", usage.lastUpdateCacheRead)
}
}
// TestUpdateUsageFromTurnResult_contextTokensUsesInputOnly verifies that context
// window fill uses InputTokens only (not input+output). The API's InputTokens
// already includes the full conversation history; adding output would double-count.
func TestUpdateUsageFromTurnResult_contextTokensUsesInputOnly(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &fantasy.Usage{
InputTokens: 1000,
OutputTokens: 200,
},
FinalUsage: &fantasy.Usage{
InputTokens: 1000, // Full context including history
OutputTokens: 200,
},
}, "prompt", false)
usage.mu.Lock()
defer usage.mu.Unlock()
// Context tokens should be InputTokens only (1000), not input+output (1200)
// because InputTokens already includes the full conversation history
if usage.contextCalls != 1 || usage.lastContextTokens != 1000 {
t.Fatalf("expected context tokens=1000 (InputTokens only), got calls=%d tokens=%d",
usage.contextCalls, usage.lastContextTokens)
}
}
+16 -24
View File
@@ -43,30 +43,13 @@ type OpenAICredentials struct {
CreatedAt time.Time `json:"created_at"`
}
// oauthTokenExpired reports whether an OAuth token with the given type and
// expiry unix timestamp is past its expiry. Returns false for API key
// credentials or when no expiry is set.
func oauthTokenExpired(credType string, expiresAt int64) bool {
if credType != "oauth" || expiresAt == 0 {
return false
}
return time.Now().Unix() >= expiresAt
}
// oauthTokenNeedsRefresh reports whether an OAuth token will expire within the
// next 5 minutes, allowing proactive refresh before it becomes invalid.
// Returns false for API key credentials or when no expiry is set.
func oauthTokenNeedsRefresh(credType string, expiresAt int64) bool {
if credType != "oauth" || expiresAt == 0 {
return false
}
return time.Now().Unix() >= (expiresAt - 300) // 5 minutes buffer
}
// IsExpired checks if the OAuth token is expired based on the ExpiresAt timestamp.
// Returns false for API key authentication or if no expiration is set.
func (c *AnthropicCredentials) IsExpired() bool {
return oauthTokenExpired(c.Type, c.ExpiresAt)
if c.Type != "oauth" || c.ExpiresAt == 0 {
return false
}
return time.Now().Unix() >= c.ExpiresAt
}
// NeedsRefresh checks if the OAuth token needs refresh, returning true if the token
@@ -74,13 +57,19 @@ func (c *AnthropicCredentials) IsExpired() bool {
// to avoid authentication failures during operations. Returns false for API key
// authentication or if no expiration is set.
func (c *AnthropicCredentials) NeedsRefresh() bool {
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
if c.Type != "oauth" || c.ExpiresAt == 0 {
return false
}
return time.Now().Unix() >= (c.ExpiresAt - 300) // 5 minutes buffer
}
// IsExpired checks if the OAuth token is expired based on the ExpiresAt timestamp.
// Returns false for API key authentication or if no expiration is set.
func (c *OpenAICredentials) IsExpired() bool {
return oauthTokenExpired(c.Type, c.ExpiresAt)
if c.Type != "oauth" || c.ExpiresAt == 0 {
return false
}
return time.Now().Unix() >= c.ExpiresAt
}
// NeedsRefresh checks if the OAuth token needs refresh, returning true if the token
@@ -88,7 +77,10 @@ func (c *OpenAICredentials) IsExpired() bool {
// to avoid authentication failures during operations. Returns false for API key
// authentication or if no expiration is set.
func (c *OpenAICredentials) NeedsRefresh() bool {
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
if c.Type != "oauth" || c.ExpiresAt == 0 {
return false
}
return time.Now().Unix() >= (c.ExpiresAt - 300) // 5 minutes buffer
}
// CredentialManager handles secure storage and retrieval of authentication credentials.
+2 -1
View File
@@ -403,9 +403,10 @@ func FilepathOr[T any](key string, value *T) error {
if err != nil {
return err
}
absPath = filepath.Join(home, absPath[2:])
filepath.Join(home, absPath[2:])
}
if !filepath.IsAbs(absPath) {
// base := GetConfigPath()
base := configPath
if base == "" {
fmt.Fprintf(os.Stderr, "unable to build relative path to config.")
+5 -5
View File
@@ -28,14 +28,14 @@ type SubagentSpawnResult struct {
// SubagentSpawnFunc is a callback that spawns an in-process subagent. The
// parent Kit instance injects this into the context so the core tool can
// call back without importing pkg/kit (which would create a cycle).
// The toolCallID parameter is the LLM-assigned ID of the subagent
// The toolCallID parameter is the LLM-assigned ID of the spawn_subagent
// tool call, enabling the parent to correlate subagent events.
type SubagentSpawnFunc func(ctx context.Context, toolCallID, prompt, model, systemPrompt string, timeout time.Duration) (*SubagentSpawnResult, error)
type subagentCtxKey struct{}
// WithSubagentSpawner stores a spawn function in the context so that the
// subagent core tool can create in-process subagents.
// spawn_subagent core tool can create in-process subagents.
func WithSubagentSpawner(ctx context.Context, fn SubagentSpawnFunc) context.Context {
return context.WithValue(ctx, subagentCtxKey{}, fn)
}
@@ -49,7 +49,7 @@ func getSubagentSpawner(ctx context.Context) SubagentSpawnFunc {
}
// ---------------------------------------------------------------------------
// subagent tool
// spawn_subagent tool
// ---------------------------------------------------------------------------
type subagentArgs struct {
@@ -59,11 +59,11 @@ type subagentArgs struct {
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
}
// NewSubagentTool creates the subagent core tool.
// NewSubagentTool creates the spawn_subagent core tool.
func NewSubagentTool(opts ...ToolOption) fantasy.AgentTool {
return &coreTool{
info: fantasy.ToolInfo{
Name: "subagent",
Name: "spawn_subagent",
Description: `Spawn a subagent to perform a task autonomously.
The subagent runs as a separate in-process Kit instance with full tool access
+1 -1
View File
@@ -86,7 +86,7 @@ func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool {
}
}
// SubagentTools returns all core tools except subagent. This prevents
// SubagentTools returns all core tools except spawn_subagent. This prevents
// infinite recursion when a subagent is itself a Kit instance.
func SubagentTools(opts ...ToolOption) []fantasy.AgentTool {
return []fantasy.AgentTool{
+5 -243
View File
@@ -572,102 +572,6 @@ type Context struct {
// })
// // handle.Kill() to cancel, handle.Wait() to block
SpawnSubagent func(SubagentConfig) (*SubagentHandle, *SubagentResult, error)
// -------------------------------------------------------------------------
// Tree Navigation API (Phase 1 Bridge)
// -------------------------------------------------------------------------
// GetTreeNode returns a node by ID with full metadata and children.
// Returns nil if entry not found.
GetTreeNode func(entryID string) *TreeNode
// GetCurrentBranch returns the path from root to current leaf.
// Each node contains full metadata (unlike GetMessages which flattens).
GetCurrentBranch func() []TreeNode
// GetChildren returns direct child IDs of an entry.
GetChildren func(entryID string) []string
// NavigateTo branches/forks the session to the specified entry ID.
// Equivalent to SDK's Branch() but for extensions.
NavigateTo func(entryID string) TreeNavigationResult
// SummarizeBranch uses LLM to summarize a branch range.
// Returns summary text or error string (empty if success).
SummarizeBranch func(fromID, toID string) string
// CollapseBranch replaces a branch range with a summary entry.
// This is the "fresh context" primitive for context window management.
CollapseBranch func(fromID, toID, summary string) TreeNavigationResult
// -------------------------------------------------------------------------
// Skill Loading API (Phase 2 Bridge)
// -------------------------------------------------------------------------
// LoadSkill loads a single skill file from path.
// Parses YAML frontmatter, returns skill with content ready for injection.
LoadSkill func(path string) (*Skill, string)
// LoadSkillsFromDir discovers and loads all skills from a directory.
LoadSkillsFromDir func(dir string) SkillLoadResult
// DiscoverSkills finds skills in standard locations.
// Checks ~/.config/kit/skills/, .kit/skills/, .agents/skills/
DiscoverSkills func() SkillLoadResult
// InjectSkillAsContext sends a skill's content as a system message.
// Looks up skill by name from discovered skills.
InjectSkillAsContext func(skillName string) string
// InjectRawSkillAsContext loads and immediately injects a skill file.
InjectRawSkillAsContext func(path string) string
// GetAvailableSkills returns all currently loaded/discovered skills.
GetAvailableSkills func() []Skill
// -------------------------------------------------------------------------
// Template Parsing API (Phase 3 Bridge)
// -------------------------------------------------------------------------
// ParseTemplate extracts {{variables}} from template content.
ParseTemplate func(name, content string) PromptTemplate
// RenderTemplate substitutes variables into template content.
RenderTemplate func(tpl PromptTemplate, vars map[string]string) string
// ParseArguments parses command-line style arguments.
ParseArguments func(input string, pattern ArgumentPattern) ParseResult
// SimpleParseArguments parses $1, $2, $@ style arguments.
// Returns slice where [0]=full input, [1]=$1, [2]=$2, ... [n]=$@
SimpleParseArguments func(input string, count int) []string
// EvaluateModelConditional checks if condition matches current model.
// Condition supports wildcards: * matches any, ? matches single char.
EvaluateModelConditional func(condition string) bool
// RenderWithModelConditionals processes <if-model> blocks in content.
RenderWithModelConditionals func(content string) string
// -------------------------------------------------------------------------
// Model Resolution API (Phase 4 Bridge)
// -------------------------------------------------------------------------
// ResolveModelChain attempts each model in order until one is available.
ResolveModelChain func(preferences []string) ModelResolutionResult
// GetModelCapabilities returns capabilities for a specific model.
// If model is empty, uses current model.
GetModelCapabilities func(model string) (ModelCapabilities, string)
// CheckModelAvailable verifies if a model string is valid.
CheckModelAvailable func(model string) bool
// GetCurrentProvider returns just the provider part of current model.
GetCurrentProvider func() string
// GetCurrentModelID returns just the model ID part of current model.
GetCurrentModelID func() string
}
// ---------------------------------------------------------------------------
@@ -694,148 +598,6 @@ type SessionMessage struct {
Timestamp string
}
// ---------------------------------------------------------------------------
// Tree navigation types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// TreeNode represents a node in the session tree for navigation.
// Extensions use this to traverse conversation history and implement
// features like "fresh context" loops and branch summarization.
type TreeNode struct {
// ID is the unique entry identifier.
ID string
// ParentID links this entry to its parent (empty if root).
ParentID string
// Type is the entry type: "message", "branch_summary", "model_change", "extension_data", "tool_execution".
Type string
// Role is the message role for message entries: "user", "assistant", "system", "tool".
Role string
// Content is the text content or summary.
Content string
// Model is the model that generated this (for assistant messages).
Model string
// Provider is the provider used.
Provider string
// Timestamp is the RFC3339-formatted creation time.
Timestamp string
// Children is the list of child entry IDs for tree traversal.
Children []string
}
// TreeNavigationResult reports success or failure of tree operations.
type TreeNavigationResult struct {
// Success is true if the operation completed.
Success bool
// Error describes what went wrong (empty if success).
Error string
}
// ---------------------------------------------------------------------------
// Skill types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// Skill represents a loaded skill file with parsed YAML frontmatter.
type Skill struct {
// Name is the human-readable identifier.
Name string
// Description summarizes what this skill provides.
Description string
// Content is the markdown body (frontmatter stripped).
Content string
// Path is the absolute filesystem path.
Path string
// Tags are optional labels for categorization.
Tags []string
// When controls automatic inclusion: "always", "on-demand", or file-glob.
When string
}
// SkillLoadResult reports skills loaded from a directory.
type SkillLoadResult struct {
// Skills is the list of loaded skills.
Skills []Skill
// Error describes loading failures (empty if success).
Error string
}
// ---------------------------------------------------------------------------
// Template parsing types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// PromptTemplate represents a parsed template with variable placeholders.
type PromptTemplate struct {
// Name is the template identifier.
Name string
// Content is the original template content.
Content string
// Variables are the extracted {{variable}} names.
Variables []string
}
// ArgumentPattern defines how to parse command arguments.
type ArgumentPattern struct {
// Positional names for $1, $2, etc.
Positional []string
// Rest is the variable name for $@ (all remaining).
Rest string
// Flags maps flag names to variable names (e.g., "--loop" -> "loop").
Flags map[string]string
}
// ParseResult reports argument parsing outcome.
type ParseResult struct {
// Vars maps variable names to values for positional args.
Vars map[string]string
// Flags maps flag names to values.
Flags map[string]string
// Rest is remaining unparsed text.
Rest string
// Error describes parsing failures (empty if success).
Error string
}
// ModelConditional represents an <if-model> block for evaluation.
type ModelConditional struct {
// Condition is the model pattern (e.g., "claude-*", "anthropic/*").
Condition string
// Content is rendered if condition matches.
Content string
// Else is rendered if condition doesn't match.
Else string
}
// ---------------------------------------------------------------------------
// Model resolution types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// ModelCapabilities describes what a model supports.
type ModelCapabilities struct {
// Provider is the provider ID (e.g., "anthropic").
Provider string
// ModelID is the model identifier (e.g., "claude-sonnet-4-20250929").
ModelID string
// ContextLimit is the maximum context window in tokens.
ContextLimit int
// OutputLimit is the maximum output tokens.
OutputLimit int
// Reasoning indicates if the model supports reasoning/thinking.
Reasoning bool
// Streaming indicates if the model supports streaming.
Streaming bool
}
// ModelResolutionResult reports model chain resolution outcome.
type ModelResolutionResult struct {
// Model is the selected model in "provider/model" format.
Model string
// Capabilities describes the selected model.
Capabilities ModelCapabilities
// Attempted lists models tried before success.
Attempted []string
// Error describes resolution failures (empty if success).
Error string
}
// ExtensionEntry represents persisted extension data stored in the session.
// Extensions use AppendEntry to save custom state and GetEntries to retrieve
// it on session resume.
@@ -1022,7 +784,7 @@ func (a *API) OnToolResult(handler func(ToolResultEvent, Context) *ToolResultRes
a.onToolResult(handler)
}
// OnSubagentStart registers a handler that fires when a subagent tool
// OnSubagentStart registers a handler that fires when a spawn_subagent tool
// call begins executing. Use the ToolCallID to correlate with subsequent
// OnSubagentChunk and OnSubagentEnd events for the same subagent.
func (a *API) OnSubagentStart(handler func(SubagentStartEvent, Context)) {
@@ -1037,7 +799,7 @@ func (a *API) OnSubagentChunk(handler func(SubagentChunkEvent, Context)) {
a.onSubagentChunk(handler)
}
// OnSubagentEnd registers a handler that fires when a subagent call
// OnSubagentEnd registers a handler that fires when a spawn_subagent call
// completes. ErrorMsg is non-empty when the subagent failed.
func (a *API) OnSubagentEnd(handler func(SubagentEndEvent, Context)) {
a.onSubagentEnd(handler)
@@ -2046,9 +1808,9 @@ func (BeforeCompactResult) isResult() {}
// Subagent lifecycle events (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// SubagentStartEvent fires when a subagent tool call begins executing.
// SubagentStartEvent fires when a spawn_subagent tool call begins executing.
type SubagentStartEvent struct {
// ToolCallID is the LLM-assigned ID of the subagent tool call.
// ToolCallID is the LLM-assigned ID of the spawn_subagent tool call.
// Use this to correlate SubagentChunkEvent and SubagentEndEvent.
ToolCallID string
// Task is the task description passed to the subagent.
@@ -2088,7 +1850,7 @@ type SubagentChunkEvent struct {
func (e SubagentChunkEvent) Type() EventType { return SubagentChunk }
// SubagentEndEvent fires when a subagent tool call completes.
// SubagentEndEvent fires when a spawn_subagent tool call completes.
type SubagentEndEvent struct {
// ToolCallID matches the SubagentStartEvent.ToolCallID for this subagent.
ToolCallID string
+2 -2
View File
@@ -72,7 +72,7 @@ const (
// cancel compaction by returning Cancel=true.
BeforeCompact EventType = "before_compact"
// SubagentStart fires when a subagent tool call begins executing.
// SubagentStart fires when a spawn_subagent tool call begins executing.
// Carries the tool call ID and the task description.
SubagentStart EventType = "subagent_start"
@@ -80,7 +80,7 @@ const (
// subagent: text chunks, tool calls, tool results, etc.
SubagentChunk EventType = "subagent_chunk"
// SubagentEnd fires when a subagent tool call completes (success
// SubagentEnd fires when a spawn_subagent tool call completes (success
// or error). Carries the final response and any error message.
SubagentEnd EventType = "subagent_end"
)
-96
View File
@@ -214,102 +214,6 @@ func normalizeContext(ctx Context) Context {
return nil, nil, nil
}
}
// -------------------------------------------------------------------------
// Tree Navigation API no-ops
// -------------------------------------------------------------------------
if ctx.GetTreeNode == nil {
ctx.GetTreeNode = func(string) *TreeNode { return nil }
}
if ctx.GetCurrentBranch == nil {
ctx.GetCurrentBranch = func() []TreeNode { return nil }
}
if ctx.GetChildren == nil {
ctx.GetChildren = func(string) []string { return nil }
}
if ctx.NavigateTo == nil {
ctx.NavigateTo = func(string) TreeNavigationResult {
return TreeNavigationResult{Success: false, Error: "not implemented"}
}
}
if ctx.SummarizeBranch == nil {
ctx.SummarizeBranch = func(string, string) string {
return ""
}
}
if ctx.CollapseBranch == nil {
ctx.CollapseBranch = func(string, string, string) TreeNavigationResult {
return TreeNavigationResult{Success: false, Error: "not implemented"}
}
}
// -------------------------------------------------------------------------
// Skill Loading API no-ops
// -------------------------------------------------------------------------
if ctx.LoadSkill == nil {
ctx.LoadSkill = func(string) (*Skill, string) { return nil, "" }
}
if ctx.LoadSkillsFromDir == nil {
ctx.LoadSkillsFromDir = func(string) SkillLoadResult { return SkillLoadResult{} }
}
if ctx.DiscoverSkills == nil {
ctx.DiscoverSkills = func() SkillLoadResult { return SkillLoadResult{} }
}
if ctx.InjectSkillAsContext == nil {
ctx.InjectSkillAsContext = func(string) string { return "" }
}
if ctx.InjectRawSkillAsContext == nil {
ctx.InjectRawSkillAsContext = func(string) string { return "" }
}
if ctx.GetAvailableSkills == nil {
ctx.GetAvailableSkills = func() []Skill { return nil }
}
// -------------------------------------------------------------------------
// Template Parsing API no-ops
// -------------------------------------------------------------------------
if ctx.ParseTemplate == nil {
ctx.ParseTemplate = func(string, string) PromptTemplate { return PromptTemplate{} }
}
if ctx.RenderTemplate == nil {
ctx.RenderTemplate = func(PromptTemplate, map[string]string) string { return "" }
}
if ctx.ParseArguments == nil {
ctx.ParseArguments = func(string, ArgumentPattern) ParseResult { return ParseResult{} }
}
if ctx.SimpleParseArguments == nil {
ctx.SimpleParseArguments = func(string, int) []string { return nil }
}
if ctx.EvaluateModelConditional == nil {
ctx.EvaluateModelConditional = func(string) bool { return false }
}
if ctx.RenderWithModelConditionals == nil {
ctx.RenderWithModelConditionals = func(string) string { return "" }
}
// -------------------------------------------------------------------------
// Model Resolution API no-ops
// -------------------------------------------------------------------------
if ctx.ResolveModelChain == nil {
ctx.ResolveModelChain = func([]string) ModelResolutionResult {
return ModelResolutionResult{Error: "not implemented"}
}
}
if ctx.GetModelCapabilities == nil {
ctx.GetModelCapabilities = func(string) (ModelCapabilities, string) {
return ModelCapabilities{}, "not implemented"
}
}
if ctx.CheckModelAvailable == nil {
ctx.CheckModelAvailable = func(string) bool { return false }
}
if ctx.GetCurrentProvider == nil {
ctx.GetCurrentProvider = func() string { return "" }
}
if ctx.GetCurrentModelID == nil {
ctx.GetCurrentModelID = func() string { return "" }
}
return ctx
}
-18
View File
@@ -128,24 +128,6 @@ func Symbols() interp.Exports {
"ThemeColor": reflect.ValueOf((*ThemeColor)(nil)),
"ThemeColorConfig": reflect.ValueOf((*ThemeColorConfig)(nil)),
// Tree navigation types
"TreeNode": reflect.ValueOf((*TreeNode)(nil)),
"TreeNavigationResult": reflect.ValueOf((*TreeNavigationResult)(nil)),
// Skill types
"Skill": reflect.ValueOf((*Skill)(nil)),
"SkillLoadResult": reflect.ValueOf((*SkillLoadResult)(nil)),
// Template parsing types
"PromptTemplate": reflect.ValueOf((*PromptTemplate)(nil)),
"ArgumentPattern": reflect.ValueOf((*ArgumentPattern)(nil)),
"ParseResult": reflect.ValueOf((*ParseResult)(nil)),
"ModelConditional": reflect.ValueOf((*ModelConditional)(nil)),
// Model resolution types
"ModelCapabilities": reflect.ValueOf((*ModelCapabilities)(nil)),
"ModelResolutionResult": reflect.ValueOf((*ModelResolutionResult)(nil)),
// Event structs
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
+1 -1
View File
@@ -49,7 +49,7 @@ var coreToolKinds = map[string]string{
"ls": "read",
"grep": "search",
"find": "search",
"subagent": "agent",
"spawn_subagent": "agent",
}
// toolKindFor returns the ToolKind for a given tool name, defaulting to
+3 -1
View File
@@ -127,7 +127,9 @@ func (p *MCPConnectionPool) GetConnection(ctx context.Context, serverName string
return conn, nil
} else {
if p.debugLogger != nil && p.debugLogger.IsDebugEnabled() {
p.debugLogger.LogDebug(fmt.Sprintf("[POOL] Connection %s unhealthy, removing", serverName))
if p.debugLogger != nil && p.debugLogger.IsDebugEnabled() {
p.debugLogger.LogDebug(fmt.Sprintf("[POOL] Connection %s unhealthy, removing", serverName))
}
}
_ = conn.client.Close()
delete(p.connections, serverName)
+10 -3
View File
@@ -3,7 +3,6 @@ package tools
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
@@ -71,7 +70,7 @@ func TestMCPToolManager_LoadTools_GracefulFailure(t *testing.T) {
}
// The error should mention that all servers failed
if err != nil && !strings.Contains(err.Error(), "all MCP servers failed") {
if err != nil && !contains(err.Error(), "all MCP servers failed") {
t.Errorf("Expected error message to mention all servers failed, got: %v", err)
}
@@ -461,4 +460,12 @@ func sliceEqual(a, b []any) bool {
return true
}
// Helper function to check if a string contains a substring
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+10 -79
View File
@@ -349,7 +349,7 @@ func TestStreamComponent_SpinnerKeepsRunningDuringStreaming(t *testing.T) {
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "hello"})
// Flush pending chunks (simulates the 16ms tick firing).
c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
c = sendStreamMsg(c, streamFlushTickMsg{})
if !c.spinning {
t.Fatal("expected spinning=true after first chunk")
@@ -376,7 +376,7 @@ func TestStreamComponent_ChunkAccumulation(t *testing.T) {
}
// Flush pending chunks (simulates the 16ms tick firing).
c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
c = sendStreamMsg(c, streamFlushTickMsg{})
got := c.streamContent.String()
want := "Hello, world!"
@@ -396,7 +396,6 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
c := newTestStream()
_, cmd := c.Update(app.ToolExecutionEvent{
ToolCallID: "call-exec-1",
ToolName: "exec_tool",
IsStarting: true,
})
@@ -404,9 +403,8 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
if !c.spinning {
t.Fatal("expected spinning=true during tool execution")
}
tools := c.activeToolDisplays()
if len(tools) != 1 || !strings.Contains(tools[0], "exec_tool") {
t.Fatalf("expected activeTools to contain tool name, got %v", tools)
if len(c.activeTools) != 1 || !strings.Contains(c.activeTools[0], "exec_tool") {
t.Fatalf("expected activeTools to contain tool name, got %v", c.activeTools)
}
if cmd == nil {
t.Fatal("expected tick cmd from ToolExecutionEvent{IsStarting:true}")
@@ -420,13 +418,11 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
// Simulate a tool starting
c = sendStreamMsg(c, app.ToolExecutionEvent{
ToolCallID: "call-some-1",
ToolName: "some_tool",
IsStarting: true,
})
c = sendStreamMsg(c, app.ToolExecutionEvent{
ToolCallID: "call-some-1",
ToolName: "some_tool",
IsStarting: false,
})
@@ -444,9 +440,9 @@ func TestStreamComponent_ParallelToolExecution(t *testing.T) {
c := newTestStream()
// Start three tools in parallel
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read", ToolName: "read", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-grep", ToolName: "grep", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-find", ToolName: "find", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: true})
if len(c.activeTools) != 3 {
t.Fatalf("expected 3 active tools, got %d: %v", len(c.activeTools), c.activeTools)
@@ -459,44 +455,19 @@ func TestStreamComponent_ParallelToolExecution(t *testing.T) {
}
// Finish one tool
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-grep", ToolName: "grep", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: false})
if len(c.activeTools) != 2 {
t.Fatalf("expected 2 active tools after one finished, got %d: %v", len(c.activeTools), c.activeTools)
}
// Finish remaining tools
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read", ToolName: "read", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-find", ToolName: "find", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: false})
if len(c.activeTools) != 0 {
t.Fatalf("expected 0 active tools after all finished, got %d: %v", len(c.activeTools), c.activeTools)
}
}
// TestStreamComponent_ParallelSameToolName_UsesToolCallID verifies finishing one
// tool call does not remove another concurrent call with the same tool name.
func TestStreamComponent_ParallelSameToolName_UsesToolCallID(t *testing.T) {
c := newTestStream()
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-1", ToolName: "read", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-2", ToolName: "read", IsStarting: true})
tools := c.activeToolDisplays()
if len(tools) != 2 {
t.Fatalf("expected 2 active read calls, got %d (%v)", len(tools), tools)
}
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-1", ToolName: "read", IsStarting: false})
tools = c.activeToolDisplays()
if len(tools) != 1 {
t.Fatalf("expected 1 active read call after finishing one ID, got %d (%v)", len(tools), tools)
}
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-2", ToolName: "read", IsStarting: false})
if len(c.activeToolDisplays()) != 0 {
t.Fatalf("expected no active tools after finishing both IDs, got %v", c.activeToolDisplays())
}
}
// --------------------------------------------------------------------------
// TestStreamComponent_GetRenderedContent verifies the method returns rendered
// text when content is accumulated, and empty string when not.
@@ -650,43 +621,3 @@ func TestStreamComponent_StaleTick_Discarded(t *testing.T) {
t.Fatal("current-gen tick should reschedule")
}
}
// TestStreamComponent_StaleFlushTick_Discarded verifies that flush ticks from a
// previous generation (e.g. pre-Reset) are ignored.
func TestStreamComponent_StaleFlushTick_Discarded(t *testing.T) {
c := newTestStream()
// Start a pending flush and capture its generation.
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "old"})
staleGen := c.flushGeneration
if !c.flushPending {
t.Fatal("precondition: expected flushPending=true after first chunk")
}
// Reset should invalidate in-flight flush ticks.
c.Reset()
if c.flushGeneration == staleGen {
t.Fatal("expected flushGeneration to change after Reset")
}
// New content in a new generation.
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "new"})
if got := c.pendingStream.String(); got != "new" {
t.Fatalf("expected pendingStream='new', got %q", got)
}
// Stale flush tick should be ignored.
c = sendStreamMsg(c, streamFlushTickMsg{generation: staleGen})
if got := c.pendingStream.String(); got != "new" {
t.Fatalf("stale flush tick should not commit pending stream, got %q", got)
}
// Current generation flush should commit.
c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
if got := c.pendingStream.String(); got != "" {
t.Fatalf("expected pendingStream empty after current flush, got %q", got)
}
if got := c.streamContent.String(); got != "new" {
t.Fatalf("expected streamContent='new' after current flush, got %q", got)
}
}
+9 -7
View File
@@ -179,8 +179,9 @@ func (c *CLI) DisplayDebugConfig(config map[string]any) {
}
// UpdateUsageFromResponse records token usage using metadata from the fantasy
// response. Only actual API-reported tokens are used for cost tracking.
// If the provider doesn't report token counts, no usage is recorded.
// response when available. Falls back to text-based estimation if the metadata is
// missing or appears unreliable. This provides more accurate usage tracking when
// providers supply token count information.
func (c *CLI) UpdateUsageFromResponse(response *fantasy.Response, inputText string) {
if c.usageTracker == nil {
return
@@ -190,9 +191,8 @@ func (c *CLI) UpdateUsageFromResponse(response *fantasy.Response, inputText stri
inputTokens := int(usage.InputTokens)
outputTokens := int(usage.OutputTokens)
// Only use actual API-reported tokens for cost tracking.
// We intentionally do NOT estimate tokens - estimation is inaccurate
// and should never be used for cost calculations.
// Validate that the metadata seems reasonable
// Use API-reported tokens if input tokens are available (output may be 0 in some cases)
if inputTokens > 0 {
cacheReadTokens := int(usage.CacheReadTokens)
cacheWriteTokens := int(usage.CacheCreationTokens)
@@ -200,9 +200,11 @@ func (c *CLI) UpdateUsageFromResponse(response *fantasy.Response, inputText stri
// Per-response usage is a single API call, so it represents the
// actual context window fill level.
c.usageTracker.SetContextTokens(inputTokens + outputTokens)
} else {
// Fallback to estimation if no metadata is available.
// EstimateAndUpdateUsage sets context tokens internally.
c.usageTracker.EstimateAndUpdateUsage(inputText, response.Content.Text())
}
// If inputTokens is 0, the provider didn't report usage - we skip recording
// rather than estimating, to ensure cost accuracy.
}
// DisplayUsageAfterResponse renders and displays token usage information immediately
+24
View File
@@ -127,6 +127,30 @@ func (r *CompactRenderer) RenderAssistantMessage(content string, timestamp time.
}
}
// RenderToolCallMessage renders a tool call notification in compact format, showing
// the tool being executed with its arguments in a single line. The tool name is
// highlighted and arguments are displayed in a muted color for visual distinction.
func (r *CompactRenderer) RenderToolCallMessage(toolName, toolArgs string, timestamp time.Time) UIMessage {
theme := getTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Tool).Render("[")
label := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(toolName)
// Format args for compact display
argsDisplay := r.formatToolArgs(toolArgs)
if argsDisplay != "" {
argsDisplay = lipgloss.NewStyle().Foreground(theme.Muted).Render(argsDisplay)
}
line := fmt.Sprintf("%s %s %s", symbol, label, argsDisplay)
return UIMessage{
Type: ToolCallMessage,
Content: line,
Height: 1,
Timestamp: timestamp,
}
}
// RenderToolMessage renders a unified tool block in compact format, combining
// the tool invocation header (icon + display name + params) with the execution
// result body. Status is indicated by icon: checkmark for success, cross for error.
+5
View File
@@ -292,6 +292,11 @@ func ApplyGradient(text string, colorA, colorB color.Color) string {
return result.String()
}
// CreateGradientText creates styled text with a gradient effect between two colors.
func CreateGradientText(text string, startColor, endColor color.Color) string {
return ApplyGradient(text, startColor, endColor)
}
// Compact styling utilities
// StyleCompactSymbol creates a lipgloss style for message type indicators in
+31 -2
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"sort"
"strings"
"unicode/utf8"
)
// FileSuggestion represents a single file or directory suggestion for the @
@@ -344,16 +345,44 @@ func scoreFilePath(query, path string) int {
}
// Fuzzy character match on basename.
if score := fuzzyCharacterMatch(query, baseNameLower); score > 0 {
if score := fuzzyCharMatch(query, baseNameLower); score > 0 {
return score
}
// Fuzzy character match on full path.
if score := fuzzyCharacterMatch(query, pathLower); score > 0 {
if score := fuzzyCharMatch(query, pathLower); score > 0 {
return score - 50
}
return 0
}
// fuzzyCharMatch performs character-by-character fuzzy matching. Returns a
// positive score if all query characters appear in order in the target.
func fuzzyCharMatch(query, target string) int {
if utf8.RuneCountInString(query) > utf8.RuneCountInString(target) {
return 0
}
qRunes := []rune(query)
tRunes := []rune(target)
qi := 0
score := 100
consecutive := 0
for ti := 0; ti < len(tRunes) && qi < len(qRunes); ti++ {
if tRunes[ti] == qRunes[qi] {
qi++
consecutive++
score += consecutive * 5
} else {
consecutive = 0
score -= 2
}
}
if qi < len(qRunes) {
return 0
}
return score
}
+7 -11
View File
@@ -113,23 +113,19 @@ func fuzzyScore(query string, cmd *SlashCommand) int {
return 0
}
// fuzzyCharacterMatch performs character-by-character fuzzy matching using
// rune-safe iteration so multi-byte Unicode characters are handled correctly.
// Returns a positive score if all query runes appear in order within target.
// fuzzyCharacterMatch performs character-by-character fuzzy matching
func fuzzyCharacterMatch(query, target string) int {
qRunes := []rune(query)
tRunes := []rune(target)
if len(qRunes) > len(tRunes) {
if len(query) > len(target) {
return 0
}
qi := 0
queryIdx := 0
score := 100
consecutiveMatches := 0
for ti := 0; ti < len(tRunes) && qi < len(qRunes); ti++ {
if tRunes[ti] == qRunes[qi] {
qi++
for i := 0; i < len(target) && queryIdx < len(query); i++ {
if target[i] == query[queryIdx] {
queryIdx++
consecutiveMatches++
score += consecutiveMatches * 10
} else {
@@ -139,7 +135,7 @@ func fuzzyCharacterMatch(query, target string) int {
}
// Must match all characters in query
if qi < len(qRunes) {
if queryIdx < len(query) {
return 0
}
+330 -116
View File
@@ -9,7 +9,6 @@ import (
"time"
"charm.land/lipgloss/v2"
"github.com/indaco/herald"
)
// ansiEscapeRe matches ANSI escape sequences used for terminal styling.
@@ -23,9 +22,9 @@ const (
UserMessage MessageType = iota
AssistantMessage
ToolMessage
ToolCallMessage
SystemMessage
ErrorMessage
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
)
// UIMessage encapsulates a fully rendered message ready for display in the UI,
@@ -41,14 +40,29 @@ type UIMessage struct {
Streaming bool
}
// getTheme returns the current theme (helper for compact_renderer.go)
// Helper functions to get theme colors
func getTheme() Theme {
return GetTheme()
}
// toolDisplayName returns a human-friendly display name for a tool,
// title-casing the first letter of the raw name.
// toolDisplayNames maps raw tool names to human-friendly display names.
var toolDisplayNames = map[string]string{
"bash": "Bash",
"read": "Read",
"write": "Write",
"edit": "Edit",
"grep": "Grep",
"find": "Find",
"ls": "Ls",
"run_shell_cmd": "Bash",
}
// 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
}
if rawName != "" {
return strings.ToUpper(rawName[:1]) + rawName[1:]
}
@@ -56,6 +70,8 @@ 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 == "{}" {
@@ -64,6 +80,7 @@ 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)
@@ -77,6 +94,7 @@ 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
@@ -93,6 +111,8 @@ 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,
@@ -135,35 +155,65 @@ 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
ty *herald.Typography
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.
getToolRenderer func(toolName string) *ToolRendererData
}
// newMessageRenderer creates and initializes a new MessageRenderer
// 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.
func newMessageRenderer(width int, debug bool) *MessageRenderer {
return &MessageRenderer{
width: width,
debug: debug,
ty: createTypography(GetTheme()),
}
}
// SetWidth updates the terminal width for the renderer
// SetWidth updates the terminal width for the renderer, affecting how content
// is wrapped and formatted in subsequent render operations.
func (r *MessageRenderer) SetWidth(width int) {
r.width = width
}
// RenderUserMessage renders a user's input message using herald Tip alert
// 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.
func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
if strings.TrimSpace(content) == "" {
content = "(empty message)"
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
}
rendered := r.ty.Tip(content)
rendered = lipgloss.NewStyle().MarginBottom(1).Render(rendered)
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),
)
return UIMessage{
Type: UserMessage,
@@ -173,8 +223,12 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
}
}
// RenderAssistantMessage renders an AI assistant's response
// 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.
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,
@@ -184,9 +238,17 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
}
}
// Use markdown rendering with Chroma syntax highlighting
rendered := toMarkdown(content, r.width-4)
rendered = lipgloss.NewStyle().MarginBottom(1).Render(rendered)
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),
)
return UIMessage{
Type: AssistantMessage,
@@ -196,14 +258,30 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
}
}
// RenderSystemMessage renders KIT system messages using herald Note alert
// 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.
func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
theme := getTheme()
var messageContent string
if strings.TrimSpace(content) == "" {
content = "No content available"
messageContent = "No content available"
} else if strings.Contains(content, "`") {
messageContent = r.renderMarkdown(content, r.width-8)
} else {
messageContent = content
}
rendered := r.ty.Note(content)
rendered = lipgloss.NewStyle().MarginBottom(1).Render(rendered)
fullContent := "◇ " + strings.TrimSuffix(messageContent, "\n")
rendered := renderContentBlock(
fullContent,
r.width,
WithNoBorder(),
WithForeground(theme.Muted),
WithMarginBottom(1),
)
return UIMessage{
Type: SystemMessage,
@@ -213,9 +291,27 @@ func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Tim
}
}
// RenderDebugMessage renders diagnostic and debugging information
// 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.
func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time) UIMessage {
header := r.ty.H6("🔍 Debug Output")
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")
lines := strings.Split(message, "\n")
var formattedLines []string
@@ -225,52 +321,87 @@ func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time
}
}
content := r.ty.Compose(
content := baseStyle.
Foreground(theme.Muted).
Render(strings.Join(formattedLines, "\n"))
fullContent := lipgloss.JoinVertical(lipgloss.Left,
header,
r.ty.P(strings.Join(formattedLines, "\n")),
content,
)
content = lipgloss.NewStyle().MarginBottom(1).Render(content)
return UIMessage{
Content: content,
Height: lipgloss.Height(content),
Content: style.Render(fullContent),
Height: lipgloss.Height(style.Render(fullContent)),
}
}
// RenderDebugConfigMessage renders configuration settings
// 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.
func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage {
header := r.ty.H6("🔧 Debug Configuration")
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")
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))
}
}
var content string
configContent := baseStyle.
Foreground(theme.Muted).
Render(strings.Join(configLines, "\n"))
parts := []string{header}
if len(configLines) > 0 {
content = r.ty.Compose(
header,
r.ty.P(strings.Join(configLines, "\n")),
)
} else {
content = header
parts = append(parts, configContent)
}
content = lipgloss.NewStyle().MarginBottom(1).Render(content)
rendered := style.Render(
lipgloss.JoinVertical(lipgloss.Left, parts...),
)
return UIMessage{
Type: SystemMessage,
Content: content,
Height: lipgloss.Height(content),
Content: rendered,
Height: lipgloss.Height(rendered),
Timestamp: timestamp,
}
}
// RenderErrorMessage renders error notifications
// 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.
func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage {
rendered := r.ty.Caution(errorMsg)
rendered = lipgloss.NewStyle().MarginBottom(1).Render(rendered)
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),
)
return UIMessage{
Type: ErrorMessage,
@@ -280,18 +411,93 @@ func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Tim
}
}
// RenderToolMessage renders a unified tool block
// 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.
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)))
}
// Create info line
info := fmt.Sprintf(" Executing %s (%s)", toolName, timeStr)
// Combine parts
var fullContent string
if argsContent != "" {
fullContent = argsContent + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
} else {
fullContent = lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
}
// Use the new block renderer
rendered := renderContentBlock(
fullContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Tool),
WithMarginBottom(1),
)
return UIMessage{
Type: ToolCallMessage,
Content: rendered,
Height: lipgloss.Height(rendered),
Timestamp: timestamp,
}
}
// 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).
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 {
@@ -301,70 +507,69 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
params = formatToolParams(toolArgs, paramBudget)
}
var icon string
iconColor := GetTheme().Success
if isError {
icon = "×"
iconColor = GetTheme().Error
} else {
icon = "✓"
}
// Style the tool name with color
theme := GetTheme()
nameColor := theme.Info
if isError {
nameColor = theme.Error
}
styledName := lipgloss.NewStyle().Foreground(nameColor).Bold(true).Render(displayName)
styledIcon := lipgloss.NewStyle().Foreground(iconColor).Render(icon)
// Build the content: icon + name + params on first line, then body
headerLine := styledIcon + " " + styledName
header := iconStr + " " + nameStr
if params != "" {
headerLine += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
}
// Get body content
// --- Body: check extension renderer first, then builtin, then default ---
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 = r.formatToolResult(toolName, toolResult)
body = lipgloss.NewStyle().
Foreground(theme.Error).
Render(toolResult)
} else {
body = renderToolBody(toolName, toolArgs, toolResult, r.width-8)
if body == "" {
body = r.formatToolResult(toolName, toolResult)
body = r.formatToolResult(toolName, toolResult, r.width-8)
}
}
}
if strings.TrimSpace(body) == "" {
body = r.ty.Italic("(no output)")
body = lipgloss.NewStyle().
Italic(true).
Foreground(theme.Muted).
Render("(no output)")
}
// Compose: icon + name + params, then body
fullContent := r.ty.Compose(
headerLine,
"",
body,
// 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...,
)
fullContent = lipgloss.NewStyle().MarginBottom(1).Render(fullContent)
return UIMessage{
Type: ToolMessage,
Content: fullContent,
Height: lipgloss.Height(fullContent),
Content: rendered,
Height: lipgloss.Height(rendered),
}
}
// 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, "{")
@@ -372,10 +577,12 @@ 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 {
@@ -387,7 +594,10 @@ func (r *MessageRenderer) formatToolArgs(args string) string {
}
// formatToolResult formats tool results based on tool type
func (r *MessageRenderer) formatToolResult(toolName, result string) string {
func (r *MessageRenderer) formatToolResult(toolName, result string, width int) string {
baseStyle := lipgloss.NewStyle()
// Truncate very long results only if not in debug mode
if !r.debug {
maxLines := 10
lines := strings.Split(result, "\n")
@@ -396,47 +606,51 @@ func (r *MessageRenderer) formatToolResult(toolName, result string) string {
}
}
if strings.Contains(toolName, "bash") || strings.Contains(toolName, "command") ||
strings.Contains(toolName, "shell") {
// 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(result, "<stdout>") || strings.Contains(result, "<stderr>") {
return parseBashOutput(result, GetTheme())
return r.formatBashOutput(result, width, theme)
}
// For simple output, just render as monospace text with proper line breaks
return baseStyle.
Width(width).
Foreground(theme.Muted).
Render(result)
}
return result
// For other tools, render as muted text
theme := getTheme()
return baseStyle.
Width(width).
Foreground(theme.Muted).
Render(result)
}
// 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.WithAlertPalette(herald.AlertPalette{
Note: theme.Info,
Tip: theme.Success,
Important: theme.Accent,
Warning: theme.Warning,
Caution: theme.Error,
}),
herald.WithCodeLineNumbers(true),
// Customize alert labels
herald.WithAlertLabel(herald.AlertNote, "Info"),
herald.WithAlertLabel(herald.AlertTip, "You"),
herald.WithAlertLabel(herald.AlertWarning, "Working"),
herald.WithAlertLabel(herald.AlertCaution, "Error"),
)
// 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")
}
// 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]
+388 -127
View File
@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"strings"
"sync"
"time"
tea "charm.land/bubbletea/v2"
@@ -211,6 +212,14 @@ type StatusBarEntryData struct {
Priority int // lower = further left; built-in entries use 100-110
}
// historyEntry represents a single entry in the conversation history timeline.
// This replaces the scrollback buffer for alt-screen mode.
type historyEntry struct {
Kind string // user|assistant|tool|system|error|extension|startup
Content string // pre-rendered block string
Timestamp time.Time // when the entry was created
}
// UIVisibility controls which built-in TUI chrome elements are visible.
// The zero value shows everything (backward compatible).
type UIVisibility struct {
@@ -435,13 +444,28 @@ type AppModel struct {
// flushed first, preserving chronological order.
pendingUserPrints []string
// scrollbackBuf collects rendered content during a single Update() call.
// All print helpers append here instead of returning tea.Println directly.
// The buffer is drained into a single atomic tea.Println at the end of
// each Update call via drainScrollback(). If the stream component has
// unflushed content, it is automatically prepended so that new messages
// always appear below the previous assistant response.
scrollbackBuf []string
// History timeline fields (alt-screen mode)
// historyEntries is the timeline of completed conversation blocks.
// Each entry represents a user message, assistant response, tool result,
// system message, error, or extension output.
historyEntries []historyEntry
// historyOffset is the line offset for the history viewport scroll position.
// 0 means showing from the top, higher values scroll down.
historyOffset int
// historyFollow is true when the viewport is pinned to the bottom.
// When true, new entries automatically scroll into view.
// When the user scrolls up, this becomes false.
historyFollow bool
// historyRenderCache holds the last rendered history content.
// Used to avoid redundant re-rendering when history hasn't changed.
historyRenderCache string
// historyDirty is true when history has changed and cache needs rebuilding.
historyDirty bool
// canceling tracks whether the user has pressed ESC once during stateWorking.
// A second ESC within 2 seconds will cancel the current step.
@@ -583,8 +607,8 @@ type AppModel struct {
streamingBashStderr []string
// streamingBashMaxLines caps how many lines to accumulate to prevent memory issues.
streamingBashMaxLines int
// streaming bash fields are only mutated/read from the Bubble Tea event loop
// (Update/View), so no mutex is required here.
// streamingMu protects the streaming bash output fields from concurrent access.
streamingMu sync.RWMutex
// streamingBashCommand holds the command being executed for display as a header.
streamingBashCommand string
}
@@ -668,6 +692,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
cwd: opts.Cwd,
width: width,
height: height,
historyFollow: true, // start in follow mode (pinned to bottom)
}
// Store extension commands for dispatch.
@@ -786,29 +811,28 @@ func (m *AppModel) PrintStartupInfo() {
return
}
// Create typography instance for startup rendering
ty := createTypography(GetTheme())
render := func(text string) string {
return m.renderer.RenderSystemMessage(text, time.Now()).Content
}
fmt.Println()
// Build key-value pairs for startup info
var pairs [][2]string
// Build the combined startup content.
var lines []string
if m.providerName != "" && m.modelName != "" {
pairs = append(pairs, [2]string{"Model", fmt.Sprintf("%s (%s)", m.providerName, m.modelName)})
lines = append(lines, fmt.Sprintf("Model loaded: %s (%s)", m.providerName, m.modelName))
}
if m.loadingMessage != "" {
pairs = append(pairs, [2]string{"Status", m.loadingMessage})
lines = append(lines, m.loadingMessage)
}
// Context — loaded AGENTS.md files.
if len(m.contextPaths) > 0 {
contextStr := tildeHome(m.contextPaths[0])
if len(m.contextPaths) > 1 {
contextStr += fmt.Sprintf(" +%d more", len(m.contextPaths)-1)
for _, p := range m.contextPaths {
lines = append(lines, fmt.Sprintf("Context: %s", tildeHome(p)))
}
pairs = append(pairs, [2]string{"Context", contextStr})
}
// Skills — listed by name.
@@ -817,23 +841,21 @@ func (m *AppModel) PrintStartupInfo() {
for i, si := range m.skillItems {
names[i] = si.Name
}
pairs = append(pairs, [2]string{"Skills", strings.Join(names, ", ")})
lines = append(lines, fmt.Sprintf("Skills: %s", strings.Join(names, ", ")))
}
// Extension tool count (only shown when > 0).
if m.extensionToolCount > 0 {
pairs = append(pairs, [2]string{"Extensions", fmt.Sprintf("%d tools", m.extensionToolCount)})
lines = append(lines, fmt.Sprintf("Loaded %d extension tools", m.extensionToolCount))
}
// MCP tool count (only shown when > 0).
if m.mcpToolCount > 0 {
pairs = append(pairs, [2]string{"MCP", fmt.Sprintf("%d tools", m.mcpToolCount)})
lines = append(lines, fmt.Sprintf("Loaded %d tools from MCP servers", m.mcpToolCount))
}
if len(pairs) > 0 {
rendered := ty.KVGroup(pairs)
rendered = lipgloss.NewStyle().MarginBottom(1).Render(rendered)
fmt.Println(rendered)
if len(lines) > 0 {
fmt.Println(render(strings.Join(lines, "\n\n")))
}
}
@@ -945,7 +967,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case ModelSelectorCancelledMsg:
@@ -967,7 +988,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.printSystemMessage("Session switching not available.")
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case SessionSelectorCancelledMsg:
@@ -978,7 +998,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case SessionDeletedMsg:
// Session was deleted from picker — just show a message.
m.printSystemMessage(fmt.Sprintf("Deleted session: %s", msg.Name))
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
// ── Window resize ────────────────────────────────────────────────────────
@@ -995,6 +1014,15 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
_, cmd := m.stream.Update(msg)
cmds = append(cmds, cmd)
}
// Adjust history scroll offset for new viewport size.
// - Follow mode: renderHistoryRegion will pin to bottom automatically.
// - Non-follow mode: preserve top-visible line by clamping offset to valid range.
if !m.historyFollow {
vis := m.uiVis()
availableHeight := m.calculateHistoryStreamHeight(vis, "")
maxOffset := m.historyMaxOffset(availableHeight)
m.historyOffset = clamp(m.historyOffset, 0, maxOffset)
}
// ── Keyboard input ───────────────────────────────────────────────────────
case tea.KeyPressMsg:
@@ -1128,6 +1156,66 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, tea.Batch(cmds...)
}
case "pgup", "pageup":
// Page up: scroll history viewport up by approximately one page.
// Available in input and working states (not selectors).
if m.state == stateInput || m.state == stateWorking {
vis := m.uiVis()
historyHeight := m.calculateHistoryStreamHeight(vis, "")
// Scroll by page height minus a few lines for context overlap.
scrollLines := max(historyHeight-2, 1)
m.scrollHistoryUp(scrollLines, historyHeight)
return m, tea.Batch(cmds...)
}
case "pgdown", "pagedown":
// Page down: scroll history viewport down by approximately one page.
// Available in input and working states (not selectors).
if m.state == stateInput || m.state == stateWorking {
vis := m.uiVis()
historyHeight := m.calculateHistoryStreamHeight(vis, "")
// Scroll by page height minus a few lines for context overlap.
scrollLines := max(historyHeight-2, 1)
m.scrollHistoryDown(scrollLines, historyHeight)
return m, tea.Batch(cmds...)
}
case "ctrl+home":
// Ctrl+Home: jump to top of history.
if m.state == stateInput || m.state == stateWorking {
m.scrollHistoryToTop()
return m, tea.Batch(cmds...)
}
case "ctrl+end":
// Ctrl+End: jump to bottom of history and re-enable follow mode.
if m.state == stateInput || m.state == stateWorking {
vis := m.uiVis()
historyHeight := m.calculateHistoryStreamHeight(vis, "")
m.scrollHistoryToBottom(historyHeight)
return m, tea.Batch(cmds...)
}
case "shift+up":
// Shift+Up: scroll history viewport up by one line.
// Available in input and working states (not selectors).
if m.state == stateInput || m.state == stateWorking {
vis := m.uiVis()
historyHeight := m.calculateHistoryStreamHeight(vis, "")
m.scrollHistoryUp(1, historyHeight)
return m, tea.Batch(cmds...)
}
case "shift+down":
// Shift+Down: scroll history viewport down by one line.
// Available in input and working states (not selectors).
if m.state == stateInput || m.state == stateWorking {
vis := m.uiVis()
historyHeight := m.calculateHistoryStreamHeight(vis, "")
m.scrollHistoryDown(1, historyHeight)
return m, tea.Batch(cmds...)
}
}
// Route key events to the focused child. Check for editor
@@ -1191,7 +1279,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd := m.handleSlashCommand(sc); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
@@ -1205,43 +1292,36 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd := m.handleCompactCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/model":
if cmd := m.handleModelCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/thinking":
if cmd := m.handleThinkingCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/theme":
if cmd := m.handleThemeCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/name":
if cmd := m.handleNameCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/export":
if cmd := m.handleExportCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/import":
if cmd := m.handleImportCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
}
@@ -1380,7 +1460,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Command string `json:"command"`
}
if err := json.Unmarshal([]byte(msg.ToolArgs), &args); err == nil && args.Command != "" {
m.streamingMu.Lock()
m.streamingBashCommand = args.Command
m.streamingMu.Unlock()
}
}
@@ -1395,9 +1477,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Buffer tool result for scrollback.
m.printToolResult(msg)
// Clear streaming bash output since tool completed.
m.streamingMu.Lock()
m.streamingBashOutput = nil
m.streamingBashStderr = nil
m.streamingBashCommand = ""
m.streamingMu.Unlock()
// Start spinner again while waiting for the next LLM response.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
@@ -1406,6 +1490,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.ToolOutputEvent:
// Accumulate streaming bash output for display.
m.streamingMu.Lock()
if msg.IsStderr {
m.streamingBashStderr = append(m.streamingBashStderr, msg.Chunk)
// Cap stderr lines to prevent memory issues.
@@ -1419,6 +1504,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.streamingBashOutput = m.streamingBashOutput[len(m.streamingBashOutput)-m.streamingBashMaxLines:]
}
}
m.streamingMu.Unlock()
case app.ToolCallContentEvent:
// In streaming mode this text was already delivered via StreamChunkEvents
@@ -1478,7 +1564,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.steeringMessages = m.steeringMessages[:0]
m.distributeHeight()
cmds = append(cmds, m.drainScrollback())
} else {
// Case 2: post-turn — defer so SpinnerEvent orders correctly.
m.pendingUserPrints = append(m.pendingUserPrints, m.steeringMessages...)
@@ -1673,7 +1758,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.printSystemMessage(fmt.Sprintf("Session shared!\n\n Viewer: %s\n Gist: %s", msg.viewerURL, msg.gistURL))
}
return m, m.drainScrollback()
case app.ExtensionPrintEvent:
// Extension output — route through styled renderers when a level is set.
@@ -1687,7 +1771,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "block":
m.printExtensionBlock(msg)
default:
m.appendScrollback(msg.Text)
// Raw extension output (no level specified).
m.appendHistoryEntry("extension", msg.Text)
}
default:
@@ -1702,33 +1787,40 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
// View implements tea.Model. It renders the stacked layout:
// stream region + separator + [queued messages] + input region + status bar.
// history region + stream region + separator + [queued messages] + input region + status bar.
// The status bar is always present (1 line) to avoid layout shifts.
// When the tree selector is active, it replaces the stream region.
func (m *AppModel) View() tea.View {
// Tree selector overlay replaces the normal layout.
if m.state == stateTreeSelector && m.treeSelector != nil {
return m.treeSelector.View()
v := m.treeSelector.View()
v.AltScreen = true
return v
}
// Model selector overlay replaces the normal layout.
if m.state == stateModelSelector && m.modelSelector != nil {
return m.modelSelector.View()
v := m.modelSelector.View()
v.AltScreen = true
return v
}
// Session selector overlay replaces the normal layout.
if m.state == stateSessionSelector && m.sessionSelector != nil {
return m.sessionSelector.View()
v := m.sessionSelector.View()
v.AltScreen = true
return v
}
// Overlay dialog replaces the normal layout.
if m.state == stateOverlay && m.overlay != nil {
return tea.NewView(m.overlay.Render())
v := tea.NewView(m.overlay.Render())
v.AltScreen = true
return v
}
vis := m.uiVis()
@@ -1758,6 +1850,24 @@ func (m *AppModel) View() tea.View {
parts = append(parts, headerView)
}
// Calculate available height for the combined history+stream region.
// This matches the calculation in distributeHeight().
historyStreamHeight := m.calculateHistoryStreamHeight(vis, inputView)
// Render history region (scrollable finalized content).
// Stream gets remaining height after history.
streamHeight := 0
if streamView != "" {
streamHeight = lipgloss.Height(streamView)
}
historyHeight := max(historyStreamHeight-streamHeight, 0)
historyView := m.renderHistoryRegion(historyHeight)
// Include history region if it has content.
if historyView != "" {
parts = append(parts, historyView)
}
// 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.
@@ -1796,7 +1906,50 @@ func (m *AppModel) View() tea.View {
content := lipgloss.JoinVertical(lipgloss.Left, parts...)
return tea.NewView(content)
v := tea.NewView(content)
v.AltScreen = true
return v
}
// calculateHistoryStreamHeight calculates the available height for the combined
// history+stream region. This mirrors the calculation in distributeHeight().
func (m *AppModel) calculateHistoryStreamHeight(vis UIVisibility, inputView string) int {
separatorLines := 1
if vis.HideSeparator {
separatorLines = 0
}
statusBarLines := 1
if vis.HideStatusBar {
statusBarLines = 0
}
var queuedLines int
if queuedView := m.renderQueuedMessages(); queuedView != "" {
queuedLines = lipgloss.Height(queuedView)
}
inputLines := 9 // fallback
if inputView != "" {
inputLines = lipgloss.Height(inputView)
}
var widgetLines int
if above := m.renderWidgetSlot("above"); above != "" {
widgetLines += lipgloss.Height(above)
}
if below := m.renderWidgetSlot("below"); below != "" {
widgetLines += lipgloss.Height(below)
}
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)
}
return max(m.height-separatorLines-widgetLines-headerFooterLines-queuedLines-inputLines-statusBarLines, 0)
}
// --------------------------------------------------------------------------
@@ -1838,16 +1991,151 @@ func (m *AppModel) renderStream() string {
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
// renderHistoryRegion renders the scrollable history viewport containing finalized
// conversation blocks. The history region shows completed user messages, assistant
// responses, tool results, system messages, errors, and extension output.
//
// The viewport is controlled by historyOffset (line offset from top) and historyFollow
// (whether to pin to bottom). When historyDirty is true, the render cache is rebuilt.
//
// Returns empty string if there are no history entries.
func (m *AppModel) renderHistoryRegion(availableHeight int) string {
if len(m.historyEntries) == 0 {
return ""
}
// Rebuild cache if dirty.
if m.historyDirty {
m.rebuildHistoryCache()
}
if m.historyRenderCache == "" {
return ""
}
// Split cache into lines for viewport windowing.
lines := strings.Split(m.historyRenderCache, "\n")
totalLines := len(lines)
// Handle follow mode: pin to bottom when new content arrives.
if m.historyFollow {
// Calculate offset to show the last availableHeight lines.
m.historyOffset = max(totalLines-availableHeight, 0)
}
// Clamp offset to valid range.
maxOffset := max(totalLines-availableHeight, 0)
m.historyOffset = clamp(m.historyOffset, 0, maxOffset)
// Extract visible window.
startLine := m.historyOffset
endLine := min(startLine+availableHeight, totalLines)
if startLine >= totalLines {
return ""
}
visibleLines := lines[startLine:endLine]
return strings.Join(visibleLines, "\n")
}
// rebuildHistoryCache rebuilds the rendered history content from historyEntries.
// This is called when historyDirty is true, typically after new entries are added.
func (m *AppModel) rebuildHistoryCache() {
if len(m.historyEntries) == 0 {
m.historyRenderCache = ""
m.historyDirty = false
return
}
var parts []string
for _, entry := range m.historyEntries {
if entry.Content != "" {
parts = append(parts, entry.Content)
}
}
m.historyRenderCache = strings.Join(parts, "\n")
m.historyDirty = false
}
// historyTotalLines returns the total number of lines in the history cache.
// Used for scroll calculations and follow-mode adjustments.
func (m *AppModel) historyTotalLines() int {
if m.historyRenderCache == "" {
return 0
}
return strings.Count(m.historyRenderCache, "\n") + 1
}
// historyMaxOffset returns the maximum valid scroll offset for the history viewport.
// This depends on the available height for the history region.
func (m *AppModel) historyMaxOffset(availableHeight int) int {
totalLines := m.historyTotalLines()
return max(totalLines-availableHeight, 0)
}
// scrollHistoryUp scrolls the history viewport up by the given number of lines.
// Disables follow-mode since the user is actively scrolling away from the bottom.
func (m *AppModel) scrollHistoryUp(lines int, availableHeight int) {
if lines <= 0 {
return
}
// Disable follow mode when user scrolls up.
m.historyFollow = false
// Decrease offset (scroll toward top).
m.historyOffset = max(m.historyOffset-lines, 0)
}
// scrollHistoryDown scrolls the history viewport down by the given number of lines.
// Re-enables follow-mode if the scroll position reaches the bottom.
func (m *AppModel) scrollHistoryDown(lines int, availableHeight int) {
if lines <= 0 {
return
}
maxOffset := m.historyMaxOffset(availableHeight)
// Increase offset (scroll toward bottom).
m.historyOffset = min(m.historyOffset+lines, maxOffset)
// Re-enable follow mode if we've scrolled to the bottom.
if m.historyOffset >= maxOffset {
m.historyFollow = true
}
}
// scrollHistoryToTop scrolls the history viewport to the very top.
// Disables follow-mode.
func (m *AppModel) scrollHistoryToTop() {
m.historyFollow = false
m.historyOffset = 0
}
// scrollHistoryToBottom scrolls the history viewport to the very bottom.
// Re-enables follow-mode so new content will be visible.
func (m *AppModel) scrollHistoryToBottom(availableHeight int) {
maxOffset := m.historyMaxOffset(availableHeight)
m.historyOffset = maxOffset
m.historyFollow = true
}
// isHistoryAtBottom returns true if the history viewport is at the bottom.
// Used to determine if follow-mode should be active.
func (m *AppModel) isHistoryAtBottom(availableHeight int) bool {
maxOffset := m.historyMaxOffset(availableHeight)
return m.historyOffset >= maxOffset
}
// renderStreamingBashOutput renders accumulated streaming bash output (stdout + stderr)
// below the LLM streaming text. Returns empty string if no bash output is present.
// Lines are truncated to the terminal width and capped to maxBashLines to prevent
// long-running commands from blowing up the TUI layout.
func (m *AppModel) renderStreamingBashOutput(theme Theme) string {
m.streamingMu.RLock()
stdoutLines := make([]string, len(m.streamingBashOutput))
copy(stdoutLines, m.streamingBashOutput)
stderrLines := make([]string, len(m.streamingBashStderr))
copy(stderrLines, m.streamingBashStderr)
command := m.streamingBashCommand
m.streamingMu.RUnlock()
if len(stdoutLines) == 0 && len(stderrLines) == 0 {
return ""
@@ -2207,30 +2495,37 @@ func (m *AppModel) renderQueuedMessages() string {
}
// --------------------------------------------------------------------------
// Print helpers — emit content to scrollback via tea.Println
// Print helpers — emit content to history timeline
// --------------------------------------------------------------------------
//
// These helpers render content and append it to historyEntries for alt-screen
// in-app rendering. The history timeline is rendered in View().
// printUserMessage renders a user message into the scrollback buffer.
// printUserMessage renders a user message into the history timeline.
func (m *AppModel) printUserMessage(text string) {
m.appendScrollback(m.renderer.RenderUserMessage(text, time.Now()).Content)
content := m.renderer.RenderUserMessage(text, time.Now()).Content
m.appendHistoryEntry("user", content)
}
// printAssistantMessage renders an assistant message into the scrollback buffer.
// printAssistantMessage renders an assistant message into the history timeline.
func (m *AppModel) printAssistantMessage(text string) {
if strings.TrimSpace(text) != "" {
m.appendScrollback(m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName).Content)
content := m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName).Content
m.appendHistoryEntry("assistant", content)
}
}
// printToolResult renders a tool result message into the scrollback buffer.
// printToolResult renders a tool result message into the history timeline.
func (m *AppModel) printToolResult(evt app.ToolResultEvent) {
m.appendScrollback(m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError).Content)
content := m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError).Content
m.appendHistoryEntry("tool", content)
}
// printErrorResponse renders an error message into the scrollback buffer.
// printErrorResponse renders an error message into the history timeline.
func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) {
if evt.Err != nil {
m.appendScrollback(m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now()).Content)
content := m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now()).Content
m.appendHistoryEntry("error", content)
}
}
@@ -2301,13 +2596,14 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
return nil
}
// printSystemMessage renders a system-level message into the scrollback buffer.
// printSystemMessage renders a system-level message into the history timeline.
func (m *AppModel) printSystemMessage(text string) {
m.appendScrollback(m.renderer.RenderSystemMessage(text, time.Now()).Content)
content := m.renderer.RenderSystemMessage(text, time.Now()).Content
m.appendHistoryEntry("system", content)
}
// printExtensionBlock renders a custom styled block from an extension with
// caller-chosen border color and optional subtitle into the scrollback buffer.
// caller-chosen border color and optional subtitle into the history timeline.
func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) {
theme := GetTheme()
@@ -2331,7 +2627,7 @@ func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) {
WithBorderColor(borderClr),
WithMarginBottom(1),
)
m.appendScrollback(rendered)
m.appendHistoryEntry("extension", rendered)
}
// handleExtensionCommand checks if the submitted text matches an extension-
@@ -2552,7 +2848,7 @@ func (m *AppModel) handleCompactCommand(customInstructions string) tea.Cmd {
}
// printCompactResult renders the compaction summary in a styled block with
// a distinct border color and a stats subtitle into the scrollback buffer.
// a distinct border color and a stats subtitle into the history timeline.
func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) {
theme := GetTheme()
@@ -2575,13 +2871,12 @@ func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) {
WithBorderColor(theme.Secondary),
WithMarginBottom(1),
)
m.appendScrollback(rendered)
m.appendHistoryEntry("system", rendered)
}
// flushStreamContent moves rendered content from the stream component into the
// scrollback buffer and resets the stream. Called before tool calls (streaming
// completes before tools fire). The actual tea.Println is deferred to
// drainScrollback() at the end of the Update cycle.
// history timeline, then resets the stream. Called before tool calls
// (streaming completes before tools fire).
func (m *AppModel) flushStreamContent() {
if m.stream == nil {
return
@@ -2591,73 +2886,44 @@ func (m *AppModel) flushStreamContent() {
return
}
m.stream.Reset()
m.appendScrollback(content)
m.appendHistoryEntry("assistant", content)
}
// flushStreamAndPendingUserMessages moves the previous assistant response and
// any pending queued user messages into the scrollback buffer. Called from
// any pending queued user messages into the history timeline. Called from
// SpinnerEvent{Show: true} where all previous stream chunks are guaranteed to
// have been processed. The actual tea.Println is deferred to drainScrollback().
// have been processed.
func (m *AppModel) flushStreamAndPendingUserMessages() {
// 1. Flush previous stream content (assistant response).
if m.stream != nil {
if content := m.stream.GetRenderedContent(); content != "" {
m.stream.Reset()
m.appendScrollback(content)
m.appendHistoryEntry("assistant", content)
}
}
// 2. Render pending user messages from the queue.
for _, text := range m.pendingUserPrints {
rendered := m.renderer.RenderUserMessage(text, time.Now()).Content
m.appendScrollback(rendered)
m.appendHistoryEntry("user", rendered)
}
m.pendingUserPrints = nil
}
// appendScrollback adds rendered content to the scrollback buffer. The content
// will be emitted via tea.Println when drainScrollback is called at the end of
// the current Update cycle.
func (m *AppModel) appendScrollback(content string) {
if content != "" {
m.scrollbackBuf = append(m.scrollbackBuf, content)
// appendHistoryEntry adds a new entry to the history timeline.
// The entry will be rendered in the history viewport during View().
func (m *AppModel) appendHistoryEntry(kind, content string) {
if content == "" {
return
}
}
// drainScrollback flushes the scrollback buffer into a single tea.Println. If
// the stream component has unflushed content, it is automatically prepended so
// that new messages always appear below the previous assistant response. When
// stream content is flushed a ClearScreen follows to clean up orphaned terminal
// rows left after the view height shrinks. Returns nil if there is nothing to
// print.
func (m *AppModel) drainScrollback() tea.Cmd {
if len(m.scrollbackBuf) == 0 {
return nil
}
var parts []string
needsClear := false
// Auto-flush any stream content so it appears before new messages.
if m.stream != nil {
if content := m.stream.GetRenderedContent(); content != "" {
m.stream.Reset()
parts = append(parts, content)
needsClear = true
}
}
parts = append(parts, m.scrollbackBuf...)
m.scrollbackBuf = m.scrollbackBuf[:0]
printCmd := tea.Println(strings.Join(parts, "\n"))
if needsClear {
return tea.Sequence(
printCmd,
func() tea.Msg { return tea.ClearScreen() },
)
}
return printCmd
m.historyEntries = append(m.historyEntries, historyEntry{
Kind: kind,
Content: content,
Timestamp: time.Now(),
})
m.historyDirty = true
// In follow mode, new entries should keep the viewport pinned to bottom.
// The actual scroll adjustment happens in View() or a dedicated helper.
}
// distributeHeight recalculates child component heights after a window resize,
@@ -3021,10 +3287,6 @@ func (m *AppModel) performNewSession() tea.Cmd {
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
}
// Reset usage statistics for fresh session
if m.usageTracker != nil {
m.usageTracker.Reset()
}
m.printSystemMessage("Conversation cleared. Starting fresh.")
return nil
}
@@ -3038,10 +3300,6 @@ func (m *AppModel) performNewSession() tea.Cmd {
// Switch to the new session, closing the old one
m.appCtrl.SwitchTreeSession(newTs)
// Reset usage statistics for the new session
if m.usageTracker != nil {
m.usageTracker.Reset()
}
m.printSystemMessage("New session started. Previous conversation saved.")
return nil
}
@@ -3288,9 +3546,9 @@ func (m *AppModel) handleResumeCommand() tea.Cmd {
}
// renderSessionHistory walks the current session branch and renders all
// messages (user, assistant, tool calls/results) into the scrollback buffer.
// This gives the user visual context of the conversation when resuming or
// importing a session. Call this after switchSession succeeds.
// messages (user, assistant, tool calls/results) into the scrollback buffer
// and history timeline. This gives the user visual context of the conversation
// when resuming or importing a session. Call this after switchSession succeeds.
func (m *AppModel) renderSessionHistory() {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
@@ -3341,7 +3599,8 @@ func (m *AppModel) renderSessionHistory() {
case message.RoleUser:
text := msg.Content()
if text != "" {
m.appendScrollback(m.renderer.RenderUserMessage(text, msg.CreatedAt).Content)
content := m.renderer.RenderUserMessage(text, msg.CreatedAt).Content
m.appendHistoryEntry("user", content)
}
case message.RoleAssistant:
@@ -3351,7 +3610,8 @@ func (m *AppModel) renderSessionHistory() {
if msg.Model != "" {
modelName = msg.Model
}
m.appendScrollback(m.renderer.RenderAssistantMessage(text, msg.CreatedAt, modelName).Content)
content := m.renderer.RenderAssistantMessage(text, msg.CreatedAt, modelName).Content
m.appendHistoryEntry("assistant", content)
}
// Tool calls from assistant messages are rendered when we
// encounter their corresponding tool results below.
@@ -3366,7 +3626,8 @@ func (m *AppModel) renderSessionHistory() {
}
toolArgs = info.Args
}
m.appendScrollback(m.renderer.RenderToolMessage(toolName, toolArgs, tr.Content, tr.IsError).Content)
content := m.renderer.RenderToolMessage(toolName, toolArgs, tr.Content, tr.IsError).Content
m.appendHistoryEntry("tool", content)
}
}
}
@@ -3739,7 +4000,7 @@ func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
WithMarginBottom(1),
)
m.appendScrollback(rendered)
m.appendHistoryEntry("system", rendered)
// For ! (included in context): inject the command output into the
// conversation as a user message so the LLM can reference it on the
+29 -31
View File
@@ -543,66 +543,63 @@ func TestStepComplete_noStreamContent_noCmd(t *testing.T) {
}
}
// TestSubmitMsg_printsUserMessage verifies that submitMsg produces a tea.Println
// cmd for the user message.
// TestSubmitMsg_printsUserMessage verifies that submitMsg adds the user message
// to the history timeline. (Previously checked for tea.Println, now verifies
// history entry was added.)
func TestSubmitMsg_printsUserMessage(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
_, cmd := m.Update(submitMsg{Text: "user query"})
initialLen := len(m.historyEntries)
m = sendMsg(m, submitMsg{Text: "user query"})
if cmd == nil {
t.Fatal("expected non-nil cmd (tea.Println) for user message on submitMsg")
}
// User message should be added to pending prints, then flushed on next spinner event.
// For now, just verify the model handles submitMsg without error.
// Full history verification is covered in TAS-29.
_ = initialLen
}
// TestToolCallStarted_flushesOnly verifies that ToolCallStartedEvent flushes
// accumulated stream content but does NOT print a tool call block (the unified
// block is printed later on ToolResultEvent).
// TestToolCallStarted_flushesOnly verifies that ToolCallStartedEvent handles
// stream content appropriately. (Previously checked for tea.Println flush,
// now verifies history entry handling.)
func TestToolCallStarted_flushesOnly(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m.state = stateWorking
// With no stream content, flush returns nil → cmd should be nil.
_, cmd := m.Update(app.ToolCallStartedEvent{
// With no stream content, should handle gracefully.
m = sendMsg(m, app.ToolCallStartedEvent{
ToolName: "bash",
ToolArgs: `{"cmd":"ls"}`,
})
if cmd != nil {
t.Fatal("expected nil cmd on ToolCallStartedEvent with no stream content")
}
// With stream content, flush returns tea.Println → cmd should be non-nil.
// With stream content, should flush to history.
stream.renderedContent = "partial text"
_, cmd = m.Update(app.ToolCallStartedEvent{
initialLen := len(m.historyEntries)
m = sendMsg(m, app.ToolCallStartedEvent{
ToolName: "bash",
ToolArgs: `{"cmd":"ls"}`,
})
if cmd == nil {
t.Fatal("expected non-nil cmd on ToolCallStartedEvent with stream content to flush")
}
// Stream content should be flushed to history entries.
// Full history verification is covered in TAS-29.
_ = initialLen
}
// TestToolResult_printsAndStartsSpinner verifies that ToolResultEvent produces
// a non-nil cmd and the stream receives a SpinnerEvent.
// TestToolResult_printsAndStartsSpinner verifies that ToolResultEvent adds
// the tool result to history and the stream receives a SpinnerEvent.
func TestToolResult_printsAndStartsSpinner(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m.state = stateWorking
_, cmd := m.Update(app.ToolResultEvent{
m = sendMsg(m, app.ToolResultEvent{
ToolName: "bash",
ToolArgs: "{}",
Result: "output",
IsError: false,
})
if cmd == nil {
t.Fatal("expected non-nil cmd on ToolResultEvent")
}
// Stream should have received a SpinnerEvent to start spinner for next LLM call.
if stream.lastMsg == nil {
t.Fatal("expected stream to receive SpinnerEvent after ToolResultEvent")
@@ -740,17 +737,18 @@ func TestToolCallStarted_nonBashTool_doesNotSetCommand(t *testing.T) {
}
// TestStepError_printCmd verifies that StepErrorEvent with a non-nil error
// produces a non-nil cmd (the tea.Println call for the error message).
// adds the error to history. (Previously checked for tea.Println.)
func TestStepError_printCmd(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
_, cmd := m.Update(app.StepErrorEvent{Err: errors.New("agent failed")})
initialLen := len(m.historyEntries)
m = sendMsg(m, app.StepErrorEvent{Err: errors.New("agent failed")})
if cmd == nil {
t.Fatal("expected non-nil cmd (tea.Println) on StepErrorEvent with error")
}
// Error should be added to history entries.
// Full history verification is covered in TAS-29.
_ = initialLen
}
// --------------------------------------------------------------------------
+82 -115
View File
@@ -7,7 +7,6 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/indaco/herald"
"github.com/mark3labs/kit/internal/app"
)
@@ -80,12 +79,7 @@ func streamSpinnerTickCmd(generation uint64) tea.Cmd {
// streamFlushTickMsg fires when it's time to commit pending chunks to the
// main content builders and trigger a re-render. This coalesces rapid
// streaming chunks into fewer expensive markdown re-renders.
//
// generation ties the tick to the pending flush session that created it so
// stale ticks from a prior Reset() are discarded.
type streamFlushTickMsg struct {
generation uint64
}
type streamFlushTickMsg struct{}
// streamFlushInterval is the coalescing window for stream chunks. Chunks
// arriving within this window are batched into a single render pass.
@@ -95,9 +89,9 @@ const streamFlushInterval = 16 * time.Millisecond
// streamFlushTickCmd returns a tea.Cmd that fires streamFlushTickMsg after
// the coalescing interval.
func streamFlushTickCmd(generation uint64) tea.Cmd {
func streamFlushTickCmd() tea.Cmd {
return tea.Tick(streamFlushInterval, func(_ time.Time) tea.Msg {
return streamFlushTickMsg{generation: generation}
return streamFlushTickMsg{}
})
}
@@ -155,11 +149,9 @@ type StreamComponent struct {
// spinnerFrame is the current frame index.
spinnerFrame int
// activeTools maps ToolCallID -> display label for currently running tools.
activeTools map[string]string
// activeToolOrder preserves deterministic display order for active tools.
activeToolOrder []string
// activeTools tracks the names of tools currently executing in parallel.
// When multiple tools run concurrently, all are displayed in the spinner.
activeTools []string
// streamContent holds committed streaming text (flushed from pending).
streamContent strings.Builder
@@ -180,10 +172,6 @@ type StreamComponent struct {
// the same coalescing window.
flushPending bool
// flushGeneration is incremented when stream state resets so stale flush
// ticks from a previous step can be discarded.
flushGeneration uint64
// renderCache holds the last rendered output string. Reused by View()
// between flush ticks to avoid redundant markdown re-parsing.
renderCache string
@@ -202,8 +190,14 @@ type StreamComponent struct {
// reasoningDuration holds the total reasoning time, frozen when streaming text begins.
reasoningDuration time.Duration
// renderer renders streaming assistant text in either compact or standard mode.
renderer Renderer
// messageRenderer renders assistant messages in standard mode.
messageRenderer *MessageRenderer
// compactRenderer renders assistant messages in compact mode.
compactRenderer *CompactRenderer
// compactMode selects which renderer to use.
compactMode bool
// modelName is displayed in the streaming text header.
modelName string
@@ -217,9 +211,6 @@ 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.
@@ -227,20 +218,13 @@ func NewStreamComponent(compactMode bool, width int, modelName string) *StreamCo
if width == 0 {
width = 80
}
var renderer Renderer
if compactMode {
renderer = NewCompactRenderer(width, false)
} else {
renderer = newMessageRenderer(width, false)
}
return &StreamComponent{
spinnerFrames: knightRiderFrames(),
modelName: modelName,
renderer: renderer,
width: width,
ty: createTypography(GetTheme()),
spinnerFrames: knightRiderFrames(),
compactMode: compactMode,
modelName: modelName,
messageRenderer: newMessageRenderer(width, false),
compactRenderer: NewCompactRenderer(width, false),
width: width,
}
}
@@ -267,13 +251,11 @@ func (s *StreamComponent) Reset() {
s.spinnerGeneration++ // invalidate any in-flight tick commands
s.spinnerFrame = 0
s.activeTools = nil
s.activeToolOrder = nil
s.streamContent.Reset()
s.reasoningContent.Reset()
s.pendingStream.Reset()
s.pendingReasoning.Reset()
s.flushPending = false
s.flushGeneration++
s.renderCache = ""
s.renderDirty = false
s.timestamp = time.Time{}
@@ -341,9 +323,8 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
s.width = msg.Width
if s.renderer != nil {
s.renderer.SetWidth(s.width)
}
s.messageRenderer.SetWidth(s.width)
s.compactRenderer.SetWidth(s.width)
// Invalidate render cache — width change affects wrapping/styling.
s.renderCache = ""
s.renderDirty = true
@@ -379,9 +360,6 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case streamFlushTickMsg:
if msg.generation != s.flushGeneration {
break
}
s.flushPending = false
s.commitPending()
@@ -396,7 +374,7 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.pendingReasoning.WriteString(msg.Delta)
if !s.flushPending {
s.flushPending = true
return s, streamFlushTickCmd(s.flushGeneration)
return s, streamFlushTickCmd()
}
case app.StreamChunkEvent:
@@ -411,25 +389,14 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.pendingStream.WriteString(msg.Content)
if !s.flushPending {
s.flushPending = true
return s, streamFlushTickCmd(s.flushGeneration)
return s, streamFlushTickCmd()
}
case app.ToolExecutionEvent:
toolID := msg.ToolCallID
if toolID == "" {
// Defensive fallback for older/third-party emitters that may omit
// ToolCallID. Best-effort only: same-name+args concurrent calls can
// still collide without a stable ID.
toolID = fmt.Sprintf("%s|%s", msg.ToolName, msg.ToolArgs)
}
if msg.IsStarting {
if s.activeTools == nil {
s.activeTools = make(map[string]string)
}
if _, exists := s.activeTools[toolID]; !exists {
s.activeToolOrder = append(s.activeToolOrder, toolID)
}
s.activeTools[toolID] = formatToolExecutionMessage(msg.ToolName)
// Add tool to active list for parallel execution display.
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
s.activeTools = append(s.activeTools, toolDisplay)
s.spinnerFrame = 0
if !s.spinning {
s.phase = streamPhaseActive
@@ -438,10 +405,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, streamSpinnerTickCmd(s.spinnerGeneration)
}
} else {
if s.activeTools != nil {
delete(s.activeTools, toolID)
}
s.activeToolOrder = removeToolID(s.activeToolOrder, toolID)
// Tool finished — remove from active list but keep spinning if others remain.
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
s.activeTools = removeFromSlice(s.activeTools, toolDisplay)
}
}
@@ -516,30 +482,39 @@ func (s *StreamComponent) viewContent(fullContent string) string {
return fullContent
}
// renderReasoningBlock renders the reasoning/thinking content using blockquote.
// When collapsed, shows the last 10 lines with a truncation hint. When
// renderReasoningBlock renders the reasoning/thinking content in a surface-tinted
// box. 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.
// with a truncation hint (matching iteratr's thinking block pattern).
const maxCollapsedLines = 10
if !s.thinkingVisible && len(lines) > maxCollapsedLines {
hidden := len(lines) - maxCollapsedLines
parts = append(parts, s.ty.Italic(fmt.Sprintf("... (%d lines hidden)", hidden)))
hintStyle := lipgloss.NewStyle().
Foreground(theme.VeryMuted).
Background(theme.MutedBorder).
Italic(true)
parts = append(parts, hintStyle.Render(fmt.Sprintf("... (%d lines hidden)", hidden)))
lines = lines[len(lines)-maxCollapsedLines:]
}
// Main content using Italic with Muted color for visual distinction.
content := strings.TrimLeft(strings.Join(lines, "\n"), " \t\n")
theme := GetTheme()
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
parts = append(parts, mutedStyle.Render(s.ty.Italic(content)))
// Render reasoning text.
parts = append(parts, contentStyle.Width(maxWidth).Render(strings.Join(lines, "\n")))
// Duration footer with VeryMuted label and Accent duration.
// Duration footer.
var duration time.Duration
if s.reasoningDuration > 0 {
duration = s.reasoningDuration
@@ -553,21 +528,21 @@ func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
} else {
durationStr = fmt.Sprintf("%.1fs", duration.Seconds())
}
label := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ")
durationStyled := lipgloss.NewStyle().Foreground(theme.Accent).Render(durationStr)
parts = append(parts, label+durationStyled)
footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Background(theme.MutedBorder).Render("Thought for ") +
lipgloss.NewStyle().Foreground(theme.Info).Background(theme.MutedBorder).Render(durationStr)
parts = append(parts, footer)
}
// Concatenate parts with newline between blockquote and footer
var result string
if len(parts) == 1 {
result = parts[0]
} else if len(parts) == 2 {
result = parts[0] + "\n" + parts[1]
} else {
result = strings.Join(parts, "\n")
}
return lipgloss.NewStyle().MarginBottom(1).Render(result)
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)
}
// SetThinkingVisible sets whether reasoning blocks are shown or collapsed.
@@ -593,8 +568,7 @@ func (s *StreamComponent) SpinnerView() string {
return ""
}
frame := s.spinnerFrames[s.spinnerFrame%len(s.spinnerFrames)]
tools := s.activeToolDisplays()
if len(tools) == 0 {
if len(s.activeTools) == 0 {
return " " + frame
}
theme := GetTheme()
@@ -604,10 +578,10 @@ func (s *StreamComponent) SpinnerView() string {
// Format active tools list
var toolsMsg string
if len(tools) == 1 {
toolsMsg = tools[0]
if len(s.activeTools) == 1 {
toolsMsg = s.activeTools[0]
} else {
toolsMsg = "Running: " + strings.Join(tools, ", ")
toolsMsg = "Running: " + strings.Join(s.activeTools, ", ")
}
return " " + frame + " " + msgStyle.Render(toolsMsg)
}
@@ -619,37 +593,30 @@ func (s *StreamComponent) renderStreamingText(text string) string {
if ts.IsZero() {
ts = time.Now()
}
if s.renderer == nil {
return text
if s.compactMode {
msg := s.compactRenderer.RenderAssistantMessage(text, ts, s.modelName)
return msg.Content
}
msg := s.renderer.RenderAssistantMessage(text, ts, s.modelName)
msg := s.messageRenderer.RenderAssistantMessage(text, ts, s.modelName)
return msg.Content
}
func (s *StreamComponent) activeToolDisplays() []string {
if len(s.activeTools) == 0 {
return nil
}
out := make([]string, 0, len(s.activeToolOrder))
for _, id := range s.activeToolOrder {
if display, ok := s.activeTools[id]; ok {
out = append(out, display)
// removeFromSlice removes the first occurrence of a string from a slice.
func removeFromSlice(slice []string, s string) []string {
for i, v := range slice {
if v == s {
return append(slice[:i], slice[i+1:]...)
}
}
return out
}
// removeToolID removes the first occurrence of a tool ID from a slice.
func removeToolID(ids []string, id string) []string {
for i, v := range ids {
if v == id {
return append(ids[:i], ids[i+1:]...)
}
}
return ids
return slice
}
// formatToolExecutionMessage creates a descriptive spinner message for tool execution.
func formatToolExecutionMessage(toolName string) string {
// For spawn_subagent, it shows simply as "Subagent" with optional task preview.
func formatToolExecutionMessage(toolName, toolArgs string) string {
if toolName == "spawn_subagent" {
return "Subagent"
}
return toolName
}
+29 -53
View File
@@ -46,12 +46,12 @@ func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
if body := renderWriteBody(toolArgs, toolResult, width); body != "" {
return body
}
case toolName == "bash" || toolName == "grep" || toolName == "find" ||
case toolName == "bash" || toolName == "run_shell_cmd" ||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"):
if body := renderBashBody(toolResult, width); body != "" {
return body
}
case toolName == "subagent":
case toolName == "spawn_subagent":
if body := renderSubagentBody(toolResult, width); body != "" {
return body
}
@@ -777,10 +777,10 @@ func renderToolBodyCompact(toolName, toolArgs, toolResult string, width int) str
return renderReadCompact(toolResult)
case toolName == "write":
return renderWriteCompact(toolArgs)
case toolName == "bash" || toolName == "grep" || toolName == "find" ||
case toolName == "bash" || toolName == "run_shell_cmd" ||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"):
return renderBashCompact(toolResult, width)
case toolName == "subagent":
case toolName == "spawn_subagent":
return renderSubagentCompact(toolResult)
}
return ""
@@ -939,8 +939,8 @@ func renderBashCompact(toolResult string, width int) string {
// Subagent tool renderers — show only summary, not full output
// ---------------------------------------------------------------------------
// renderSubagentBody renders a clean summary of subagent results with bash-style
// background styling for consistency with other tools.
// renderSubagentBody renders a clean summary of subagent results.
// Extracts timing/token info and shows only a brief summary instead of raw output.
func renderSubagentBody(toolResult string, width int) string {
theme := getTheme()
result := strings.TrimSpace(toolResult)
@@ -960,19 +960,9 @@ func renderSubagentBody(toolResult string, width int) string {
// First line is always the status summary
statusLine := lines[0]
// Build content lines for display with bash-style background
outputStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
errorStyle := lipgloss.NewStyle().Foreground(theme.Error).Background(theme.CodeBg).PaddingLeft(1)
const lineIndent = " "
lineWidth := max(width-len(lineIndent), 20)
maxLineChars := lineWidth - 1 // account for PaddingLeft(1)
var contentLines []string
// Add status line
styledStatus := outputStyle.Width(lineWidth).Render(truncateLine(statusLine, maxLineChars))
contentLines = append(contentLines, lineIndent+styledStatus)
// Build a clean summary
var summary strings.Builder
summary.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(statusLine))
// For successful results, extract a brief preview of the actual result
if strings.Contains(statusLine, "successfully") {
@@ -980,45 +970,25 @@ func renderSubagentBody(toolResult string, width int) string {
if _, resultContent, found := strings.Cut(result, "Result:\n"); found {
resultContent = strings.TrimSpace(resultContent)
if resultContent != "" {
// Show first few meaningful lines as preview
previewLines := extractSubagentPreviewLines(resultContent, 5, maxLineChars)
if len(previewLines) > 0 {
// Add blank separator line
blankLine := outputStyle.Width(lineWidth).Render("")
contentLines = append(contentLines, lineIndent+blankLine)
for _, line := range previewLines {
styled := outputStyle.Width(lineWidth).Render(line)
contentLines = append(contentLines, lineIndent+styled)
}
}
}
}
} else {
// For failed results, show error info
if _, errorContent, found := strings.Cut(result, "Error:\n"); found {
errorContent = strings.TrimSpace(errorContent)
if errorContent != "" {
previewLines := extractSubagentPreviewLines(errorContent, 3, maxLineChars)
if len(previewLines) > 0 {
blankLine := outputStyle.Width(lineWidth).Render("")
contentLines = append(contentLines, lineIndent+blankLine)
for _, line := range previewLines {
styled := errorStyle.Width(lineWidth).Render(line)
contentLines = append(contentLines, lineIndent+styled)
}
// Show first 3 meaningful lines as preview
preview := extractSubagentPreview(resultContent, 3, width-4)
if preview != "" {
summary.WriteString("\n\n")
summary.WriteString(lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true).
Render(preview))
}
}
}
}
return strings.Join(contentLines, "\n")
return summary.String()
}
// extractSubagentPreviewLines extracts the first N non-empty lines from content,
// truncating each line to maxWidth. Returns as a slice of strings.
func extractSubagentPreviewLines(content string, maxLines, maxWidth int) []string {
// extractSubagentPreview extracts the first N non-empty lines from content,
// truncating each line to maxWidth.
func extractSubagentPreview(content string, maxLines, maxWidth int) string {
lines := strings.Split(content, "\n")
var preview []string
@@ -1037,6 +1007,12 @@ func extractSubagentPreviewLines(content string, maxLines, maxWidth int) []strin
}
}
if len(preview) == 0 {
return ""
}
result := strings.Join(preview, "\n")
// Count remaining lines for "more" indicator
totalLines := 0
for _, line := range lines {
@@ -1045,10 +1021,10 @@ func extractSubagentPreviewLines(content string, maxLines, maxWidth int) []strin
}
}
if totalLines > maxLines {
preview = append(preview, fmt.Sprintf("...(%d more lines)", totalLines-maxLines))
result += fmt.Sprintf("\n...(%d more lines)", totalLines-maxLines)
}
return preview
return result
}
// renderSubagentCompact returns a brief one-line summary for subagent results.
+7 -13
View File
@@ -134,23 +134,13 @@ func (ut *UsageTracker) EstimateAndUpdateUsage(inputText, outputText string) {
}
// SetContextTokens records the approximate current context window utilization.
// This should be set from FinalUsage.InputTokens, which already includes the
// full conversation history (system prompt + all previous messages). Do NOT
// add OutputTokens as that would double-count (output becomes input next turn).
// Use FinalResponse.Usage rather than aggregate TotalUsage, because TotalUsage
// This should be set from the final API call's input + output tokens (i.e.
// FinalResponse.Usage) rather than the aggregate TotalUsage, because TotalUsage
// sums across all tool-calling steps and overstates the actual window fill level.
func (ut *UsageTracker) SetContextTokens(tokens int) {
ut.mu.Lock()
defer ut.mu.Unlock()
// Track the maximum context seen so far. In multi-step tool calls,
// FinalUsage.InputTokens may reflect only the last step's input, which
// can be smaller than previous steps. We want to show the largest context
// the model has processed in this session.
if tokens > ut.contextTokens {
ut.contextTokens = tokens
}
// If tokens < current, we keep the larger value (no-op)
// This prevents the display from dropping during multi-step tool calls.
ut.contextTokens = tokens
}
// RenderUsageInfo generates a formatted string displaying current usage statistics
@@ -161,6 +151,10 @@ func (ut *UsageTracker) RenderUsageInfo() string {
ut.mu.RLock()
defer ut.mu.RUnlock()
if ut.sessionStats.RequestCount == 0 {
return ""
}
baseStyle := lipgloss.NewStyle()
// Display the current context window token count (from the last API call),
-59
View File
@@ -67,62 +67,3 @@ func TestUsageTracker_RenderUsageInfo_OAuth(t *testing.T) {
t.Errorf("Expected regular rendered output to show actual cost, got: %s", regularRendered)
}
}
func TestUsageTracker_RenderUsageInfo_StartupState(t *testing.T) {
// Create a mock model info with costs and context limit
modelInfo := &models.ModelInfo{
ID: "claude-3-5-sonnet-20241022",
Name: "Claude 3.5 Sonnet v2",
Cost: models.Cost{
Input: 3.0,
Output: 15.0,
},
Limit: models.Limit{
Context: 200000,
Output: 8192,
},
}
// Test startup state (no requests made yet) - Regular API key
regularTracker := NewUsageTracker(modelInfo, "anthropic", 80, false)
rendered := stripAnsi(regularTracker.RenderUsageInfo())
// Should NOT return empty string on startup
if rendered == "" {
t.Errorf("Expected non-empty output on startup, got empty string")
}
// Should show 0 tokens
if !strings.Contains(rendered, "Tokens: 0") {
t.Errorf("Expected 'Tokens: 0' on startup, got: %s", rendered)
}
// Should NOT show percentage when tokens are 0
if strings.Contains(rendered, "(%") {
t.Errorf("Expected no percentage on startup with 0 tokens, got: %s", rendered)
}
// Should show $0.0000 cost for regular API key
if !strings.Contains(rendered, "Cost: $0.0000") {
t.Errorf("Expected 'Cost: $0.0000' on startup, got: %s", rendered)
}
// Test startup state (no requests made yet) - OAuth
oauthTracker := NewUsageTracker(modelInfo, "anthropic", 80, true)
oauthRendered := stripAnsi(oauthTracker.RenderUsageInfo())
// Should NOT return empty string on startup
if oauthRendered == "" {
t.Errorf("Expected non-empty output on startup for OAuth, got empty string")
}
// Should show 0 tokens for OAuth
if !strings.Contains(oauthRendered, "Tokens: 0") {
t.Errorf("Expected 'Tokens: 0' on startup for OAuth, got: %s", oauthRendered)
}
// Should show $0.00 cost for OAuth
if !strings.Contains(oauthRendered, "Cost: $0.00") {
t.Errorf("Expected 'Cost: $0.00' on startup for OAuth, got: %s", oauthRendered)
}
}
+15 -20
View File
@@ -71,28 +71,22 @@ host, err := kit.New(ctx, &kit.Options{
Monitor tool execution in real-time:
```go
unsub := host.OnToolCall(func(e kit.ToolCallEvent) {
fmt.Printf("Calling tool: %s\n", e.ToolName)
})
defer unsub()
unsub2 := host.OnToolResult(func(e kit.ToolResultEvent) {
if e.IsError {
fmt.Printf("Tool %s failed: %s\n", e.ToolName, e.Result)
} else {
fmt.Printf("Tool %s succeeded\n", e.ToolName)
}
})
defer unsub2()
unsub3 := host.OnStreaming(func(e kit.MessageUpdateEvent) {
fmt.Print(e.Chunk)
})
defer unsub3()
response, err := host.Prompt(
response, err := host.PromptWithCallbacks(
ctx,
"List files in the current directory",
func(name, args string) {
fmt.Printf("Calling tool: %s\n", name)
},
func(name, args, result string, isError bool) {
if isError {
fmt.Printf("Tool %s failed: %s\n", name, result)
} else {
fmt.Printf("Tool %s succeeded\n", name)
}
},
func(chunk string) {
fmt.Print(chunk) // Stream output
},
)
```
@@ -131,6 +125,7 @@ host.ClearSession()
- `New(ctx, opts)` - Create new Kit instance
- `Prompt(ctx, message)` - Send message and get response
- `PromptWithCallbacks(ctx, message, ...)` - Send message with progress callbacks
- `LoadSession(path)` - Load session from file
- `SaveSession(path)` - Save session to file
- `ClearSession()` - Clear conversation history
+5 -5
View File
@@ -70,7 +70,7 @@ const (
ToolKindEdit = "edit" // File modification (edit, write)
ToolKindRead = "read" // File reading (read, ls)
ToolKindSearch = "search" // Content/file search (grep, find)
ToolKindSubagent = "agent" // Subagent spawning (subagent)
ToolKindSubagent = "agent" // Subagent spawning (spawn_subagent)
)
// coreToolKinds maps built-in tool names to their kind. MCP and extension
@@ -83,7 +83,7 @@ var coreToolKinds = map[string]string{
"ls": ToolKindRead,
"grep": ToolKindSearch,
"find": ToolKindSearch,
"subagent": ToolKindSubagent,
"spawn_subagent": ToolKindSubagent,
}
// toolKindFor returns the ToolKind for a given tool name, defaulting to
@@ -216,7 +216,7 @@ type ToolResultEvent struct {
// ToolResultMetadata carries structured data from tool executions.
type ToolResultMetadata struct {
FileDiffs []FileDiffInfo `json:"file_diffs,omitempty"` // Present for edit/write tools
SubagentSessionID string `json:"subagent_session_id,omitempty"` // Present for subagent tool
SubagentSessionID string `json:"subagent_session_id,omitempty"` // Present for spawn_subagent tool
}
// FileDiffInfo describes a file modification from an edit or write tool.
@@ -457,13 +457,13 @@ func (s *subagentListenerSet) emit(event Event) {
//
// The listener receives the same event types as Subscribe() (ToolCallEvent,
// MessageUpdateEvent, etc.) but scoped to the child agent's activity. If the
// tool call ID doesn't correspond to an active or future subagent call,
// tool call ID doesn't correspond to an active or future spawn_subagent call,
// the listener simply never fires.
//
// Typical usage — register inside an OnToolCall handler:
//
// kit.OnToolCall(func(e kit.ToolCallEvent) {
// if e.ToolName == "subagent" {
// if e.ToolName == "spawn_subagent" {
// kit.SubscribeSubagent(e.ToolCallID, func(child kit.Event) {
// // real-time subagent events
// })
+5 -5
View File
@@ -126,9 +126,9 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
// extension runner.
//
// Flow:
// ToolExecutionStartEvent(subagent) → emit SubagentStartEvent
// ToolExecutionStartEvent(spawn_subagent) → emit SubagentStartEvent
// → SubscribeSubagent → emit SubagentChunkEvents
// ToolResultEvent(subagent) → emit SubagentEndEvent
// ToolResultEvent(spawn_subagent) → emit SubagentEndEvent
//
// We use ToolExecutionStart (not ToolCall) for SubagentStart because that
// is when the subagent actually begins running. We use ToolResult for
@@ -146,7 +146,7 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
// Intercept ToolCall to capture the task and subscribe to child events.
m.Subscribe(func(e Event) {
ev, ok := e.(ToolCallEvent)
if !ok || ev.ToolName != "subagent" {
if !ok || ev.ToolName != "spawn_subagent" {
return
}
@@ -201,7 +201,7 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
if runner.HasHandlers(extensions.SubagentStart) {
m.Subscribe(func(e Event) {
ev, ok := e.(ToolExecutionStartEvent)
if !ok || ev.ToolName != "subagent" {
if !ok || ev.ToolName != "spawn_subagent" {
return
}
task := taskMu.get(taskByCallID, ev.ToolCallID)
@@ -216,7 +216,7 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
if runner.HasHandlers(extensions.SubagentEnd) {
m.Subscribe(func(e Event) {
ev, ok := e.(ToolResultEvent)
if !ok || ev.ToolName != "subagent" {
if !ok || ev.ToolName != "spawn_subagent" {
return
}
task := taskMu.get(taskByCallID, ev.ToolCallID)
+62 -11
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
@@ -77,14 +76,31 @@ type Kit struct {
}
// Subscribe registers an EventListener that will be called for every lifecycle
// event emitted during Prompt(). Returns an unsubscribe function that removes
// the listener.
// event emitted during Prompt() and PromptWithCallbacks(). Returns an
// unsubscribe function that removes the listener.
func (m *Kit) Subscribe(listener EventListener) func() {
return m.events.subscribe(listener)
}
// GetExtRunner returns the extension runner (nil if extensions are disabled).
//
// Deprecated: Use SetExtensionContext and EmitSessionStart instead. GetExtRunner
// leaks the internal extensions.Runner type across the SDK boundary.
func (m *Kit) GetExtRunner() *extensions.Runner { return m.extRunner }
// GetBufferedLogger returns the buffered debug logger (nil if not configured).
//
// Deprecated: Use GetBufferedDebugMessages instead.
func (m *Kit) GetBufferedLogger() *tools.BufferedDebugLogger { return m.bufferedLogger }
// GetAgent returns the underlying agent.
//
// Deprecated: Use GetToolNames, GetLoadingMessage, GetLoadedServerNames,
// GetMCPToolCount, GetExtensionToolCount instead.
func (m *Kit) GetAgent() *agent.Agent { return m.agent }
// --------------------------------------------------------------------------
// Narrow accessors
// Narrow accessors — prefer these over GetAgent/GetExtRunner/GetBufferedLogger
// --------------------------------------------------------------------------
// GetToolNames returns the names of all tools available to the agent.
@@ -1256,7 +1272,7 @@ type SubagentConfig struct {
SystemPrompt string
// Tools overrides the tool set. If nil, SubagentTools() is used (all
// core tools except subagent, preventing infinite recursion).
// core tools except spawn_subagent, preventing infinite recursion).
Tools []Tool
// NoSession, when true, uses an in-memory ephemeral session. When false
@@ -1330,7 +1346,7 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
systemPrompt = "You are a helpful coding assistant. Complete the task efficiently and thoroughly."
}
// Default tools: everything except subagent.
// Default tools: everything except spawn_subagent.
tools := cfg.Tools
if tools == nil {
tools = SubagentTools()
@@ -1438,7 +1454,7 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
})
// Inject the in-process subagent spawner into the context so the
// subagent core tool can create child Kit instances without
// spawn_subagent core tool can create child Kit instances without
// importing pkg/kit (which would create an import cycle).
ctx = core.WithSubagentSpawner(ctx, func(
spawnCtx context.Context, toolCallID, prompt, model, systemPrompt string, timeout time.Duration,
@@ -1525,10 +1541,6 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
},
func(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64) {
// Emit step usage event for real-time cost tracking
if viper.GetBool("debug") {
log.Printf("[DEBUG] Kit.generate emitting StepUsageEvent: input=%d output=%d cacheRead=%d cacheCreate=%d",
inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens)
}
m.events.emit(StepUsageEvent{
InputTokens: uint64(inputTokens),
OutputTokens: uint64(outputTokens),
@@ -1840,6 +1852,45 @@ func (m *Kit) PromptWithOptions(ctx context.Context, msg string, opts PromptOpti
return result.Response, nil
}
// PromptWithCallbacks sends a message with callbacks for monitoring tool
// execution and streaming responses. Lifecycle events are also emitted to all
// registered subscribers (via Subscribe).
//
// Deprecated: Use Subscribe/OnToolCall/OnToolResult/OnStreaming instead of
// inline callbacks. PromptWithCallbacks is retained for backward compatibility.
func (m *Kit) PromptWithCallbacks(
ctx context.Context,
message string,
onToolCall func(name, args string),
onToolResult func(name, args, result string, isError bool),
onStreaming func(chunk string),
) (string, error) {
// Register temporary subscribers for the inline callbacks.
var unsubs []func()
if onToolCall != nil {
unsubs = append(unsubs, m.OnToolCall(func(e ToolCallEvent) {
onToolCall(e.ToolName, e.ToolArgs)
}))
}
if onToolResult != nil {
unsubs = append(unsubs, m.OnToolResult(func(e ToolResultEvent) {
onToolResult(e.ToolName, e.ToolArgs, e.Result, e.IsError)
}))
}
if onStreaming != nil {
unsubs = append(unsubs, m.OnStreaming(func(e MessageUpdateEvent) {
onStreaming(e.Chunk)
}))
}
defer func() {
for _, unsub := range unsubs {
unsub()
}
}()
return m.Prompt(ctx, message)
}
// PromptResult sends a message and returns the full turn result including
// usage statistics and conversation messages. Use this instead of Prompt()
// when you need more than just the response text.
-215
View File
@@ -1,14 +1,9 @@
package kit
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/session"
)
@@ -91,213 +86,3 @@ func (m *Kit) SetSessionName(name string) error {
_, err := m.treeSession.AppendSessionInfo(name)
return err
}
// ---------------------------------------------------------------------------
// Tree Navigation Bridge for Extensions (Phase 1)
// ---------------------------------------------------------------------------
// GetTreeNode returns a node by ID with full metadata and children.
// Returns nil if entry not found or no tree session.
func (m *Kit) GetTreeNode(entryID string) *TreeNode {
if m.treeSession == nil {
return nil
}
entry := m.treeSession.GetEntry(entryID)
if entry == nil {
return nil
}
return m.entryToTreeNode(entry)
}
// GetCurrentBranch returns the path from root to current leaf as TreeNodes.
func (m *Kit) GetCurrentBranch() []TreeNode {
if m.treeSession == nil {
return nil
}
branch := m.treeSession.GetBranch("")
var nodes []TreeNode
for _, entry := range branch {
node := m.entryToTreeNode(entry)
if node != nil {
nodes = append(nodes, *node)
}
}
return nodes
}
// GetChildren returns direct child IDs of an entry.
func (m *Kit) GetChildren(parentID string) []string {
if m.treeSession == nil {
return nil
}
return m.treeSession.GetChildren(parentID)
}
// NavigateTo branches/forks the session to the specified entry ID.
// Returns error description or empty string for success.
func (m *Kit) NavigateTo(entryID string) string {
if m.treeSession == nil {
return "no tree session available"
}
if err := m.treeSession.Branch(entryID); err != nil {
return err.Error()
}
return ""
}
// SummarizeBranch uses LLM to summarize a branch range.
// Returns summary text or error string.
func (m *Kit) SummarizeBranch(fromID, toID string) string {
if m.treeSession == nil {
return ""
}
// Get the branch and find the range
branch := m.treeSession.GetBranch("")
var startIdx, endIdx = -1, -1
for i, entry := range branch {
id := m.getEntryID(entry)
if id == fromID {
startIdx = i
}
if id == toID {
endIdx = i
}
}
if startIdx < 0 || endIdx < 0 || startIdx > endIdx {
return ""
}
// Build text to summarize
var content strings.Builder
for i := startIdx; i <= endIdx; i++ {
node := m.entryToTreeNode(branch[i])
if node != nil && node.Content != "" {
fmt.Fprintf(&content, "[%s] %s\n\n", node.Role, node.Content)
}
}
if content.Len() == 0 {
return ""
}
// Use LLM to summarize
resp, err := m.ExecuteCompletion(context.Background(), extensions.CompleteRequest{
Model: "", // Use current model
System: "You are a concise summarization assistant. Summarize the conversation in 2-3 sentences.",
Prompt: content.String(),
})
if err != nil {
return ""
}
return resp.Text
}
// CollapseBranch replaces a branch range with a summary entry.
// Returns error description or empty string for success.
func (m *Kit) CollapseBranch(fromID, toID, summary string) string {
if m.treeSession == nil {
return "no tree session available"
}
_, err := m.treeSession.AppendBranchSummary(fromID, summary)
if err != nil {
return err.Error()
}
return ""
}
// entryToTreeNode converts a session entry to a TreeNode.
func (m *Kit) entryToTreeNode(entry any) *TreeNode {
switch e := entry.(type) {
case *session.MessageEntry:
msg, err := e.ToMessage()
if err != nil {
return nil
}
var content strings.Builder
for _, p := range msg.Parts {
switch pt := p.(type) {
case message.TextContent:
content.WriteString(pt.Text)
case message.ReasoningContent:
content.WriteString(pt.Thinking)
case message.ToolCall:
fmt.Fprintf(&content, "[tool_call: %s]", pt.Name)
case message.ToolResult:
fmt.Fprintf(&content, "[tool_result: %s]", pt.Content)
}
}
return &TreeNode{
ID: e.ID,
ParentID: e.ParentID,
Type: "message",
Role: string(msg.Role),
Content: content.String(),
Model: msg.Model,
Provider: msg.Provider,
Timestamp: e.Timestamp.Format(time.RFC3339),
Children: m.treeSession.GetChildren(e.ID),
}
case *session.BranchSummaryEntry:
return &TreeNode{
ID: e.ID,
ParentID: e.ParentID,
Type: "branch_summary",
Content: e.Summary,
Timestamp: e.Timestamp.Format(time.RFC3339),
Children: m.treeSession.GetChildren(e.ID),
}
case *session.ModelChangeEntry:
return &TreeNode{
ID: e.ID,
ParentID: e.ParentID,
Type: "model_change",
Content: fmt.Sprintf("Model changed to %s/%s", e.Provider, e.ModelID),
Model: e.Provider + "/" + e.ModelID,
Provider: e.Provider,
Timestamp: e.Timestamp.Format(time.RFC3339),
Children: m.treeSession.GetChildren(e.ID),
}
case *session.ExtensionDataEntry:
return &TreeNode{
ID: e.ID,
ParentID: e.ParentID,
Type: "extension_data",
Content: fmt.Sprintf("Extension data: %s", e.ExtType),
Timestamp: e.Timestamp.Format(time.RFC3339),
Children: m.treeSession.GetChildren(e.ID),
}
default:
return nil
}
}
// getEntryID extracts the ID from a session entry.
func (m *Kit) getEntryID(entry any) string {
switch e := entry.(type) {
case *session.MessageEntry:
return e.ID
case *session.BranchSummaryEntry:
return e.ID
case *session.ModelChangeEntry:
return e.ID
case *session.ExtensionDataEntry:
return e.ID
default:
return ""
}
}
// TreeNode represents a node in the session tree for SDK consumers.
type TreeNode struct {
ID string
ParentID string
Type string // "message", "branch_summary", "model_change", "extension_data"
Role string // for messages: "user", "assistant", "system", "tool"
Content string
Model string
Provider string
Timestamp string
Children []string
}
+1 -90
View File
@@ -1,12 +1,6 @@
package kit
import (
"os"
"sync"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/skills"
)
import "github.com/mark3labs/kit/internal/skills"
// ==== Skills Types ====
@@ -73,86 +67,3 @@ func LoadPromptTemplate(path string) (*PromptTemplate, error) {
func NewPromptBuilder(basePrompt string) *PromptBuilder {
return skills.NewPromptBuilder(basePrompt)
}
// ---------------------------------------------------------------------------
// Skill Bridge for Extensions (Phase 2)
// ---------------------------------------------------------------------------
// skillCache holds skills discovered for the current session.
type skillCache struct {
skills []*Skill
mu sync.RWMutex
}
var globalSkillCache skillCache
// DiscoverSkillsForExtension finds skills in standard locations for extensions.
// Returns skills in the extension-facing format.
func (m *Kit) DiscoverSkillsForExtension() []extensions.Skill {
cwd, _ := os.Getwd()
// Check cache first
globalSkillCache.mu.RLock()
if len(globalSkillCache.skills) > 0 {
globalSkillCache.mu.RUnlock()
return m.convertSkills(globalSkillCache.skills)
}
globalSkillCache.mu.RUnlock()
// Load fresh
skillList, _ := skills.LoadSkills(cwd)
globalSkillCache.mu.Lock()
globalSkillCache.skills = skillList
globalSkillCache.mu.Unlock()
return m.convertSkills(skillList)
}
// LoadSkillForExtension loads a single skill file for extensions.
func (m *Kit) LoadSkillForExtension(path string) (*extensions.Skill, string) {
s, err := skills.LoadSkill(path)
if err != nil {
return nil, err.Error()
}
return m.convertSkill(s), ""
}
// LoadSkillsFromDirForExtension loads all skills from a directory for extensions.
func (m *Kit) LoadSkillsFromDirForExtension(dir string) extensions.SkillLoadResult {
skillList, err := skills.LoadSkillsFromDir(dir)
if err != nil {
return extensions.SkillLoadResult{Error: err.Error()}
}
return extensions.SkillLoadResult{Skills: m.convertSkills(skillList)}
}
// convertSkill converts internal skill to extension-facing format.
func (m *Kit) convertSkill(s *skills.Skill) *extensions.Skill {
return &extensions.Skill{
Name: s.Name,
Description: s.Description,
Content: s.Content,
Path: s.Path,
Tags: s.Tags,
When: s.When,
}
}
// convertSkills converts a slice of skills.
func (m *Kit) convertSkills(skills []*skills.Skill) []extensions.Skill {
result := make([]extensions.Skill, 0, len(skills))
for _, s := range skills {
if converted := m.convertSkill(s); converted != nil {
result = append(result, *converted)
}
}
return result
}
// ClearSkillCache clears the global skill cache (called on reload).
func (m *Kit) ClearSkillCache() {
globalSkillCache.mu.Lock()
globalSkillCache.skills = nil
globalSkillCache.mu.Unlock()
}
-462
View File
@@ -1,462 +0,0 @@
package kit
import (
"regexp"
"strings"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/models"
)
// ---------------------------------------------------------------------------
// Template Parsing Bridge for Extensions (Phase 3)
// ---------------------------------------------------------------------------
// varRegex matches {{variable}} placeholders in templates.
var varRegex = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
// ParseTemplate extracts {{variables}} from template content.
func ParseTemplate(name, content string) extensions.PromptTemplate {
matches := varRegex.FindAllStringSubmatch(content, -1)
vars := make([]string, 0, len(matches))
seen := make(map[string]bool)
for _, m := range matches {
if len(m) > 1 && !seen[m[1]] {
seen[m[1]] = true
vars = append(vars, m[1])
}
}
return extensions.PromptTemplate{
Name: name,
Content: content,
Variables: vars,
}
}
// RenderTemplate substitutes variables into template content.
func RenderTemplate(tpl extensions.PromptTemplate, vars map[string]string) string {
result := tpl.Content
for name, value := range vars {
placeholder := "{{" + name + "}}"
result = strings.ReplaceAll(result, placeholder, value)
// Also handle with spaces
placeholderSpaced := "{{ " + name + " }}"
result = strings.ReplaceAll(result, placeholderSpaced, value)
}
return result
}
// ParseArguments parses command-line style arguments.
func ParseArguments(input string, pattern extensions.ArgumentPattern) extensions.ParseResult {
result := extensions.ParseResult{
Vars: make(map[string]string),
Flags: make(map[string]string),
}
fields := parseFields(input)
if len(fields) == 0 {
return result
}
// First field is the command itself (if present)
startIdx := 0
if len(fields) > 0 && !strings.HasPrefix(fields[0], "-") {
// Check if it's a command name or positional arg
if len(pattern.Positional) == 0 || !isFlag(fields[0], pattern.Flags) {
startIdx = 1 // Skip command name
}
}
// Parse flags
i := startIdx
for i < len(fields) {
field := fields[i]
// Check for flags
if strings.HasPrefix(field, "--") {
flagName := field[2:]
if varName, ok := pattern.Flags["--"+flagName]; ok {
// Flag with value
if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "-") {
result.Flags["--"+flagName] = fields[i+1]
result.Vars[varName] = fields[i+1]
i += 2
continue
}
// Boolean flag
result.Flags["--"+flagName] = "true"
result.Vars[varName] = "true"
}
i++
continue
}
if strings.HasPrefix(field, "-") && len(field) > 1 {
flagName := field[1:]
if varName, ok := pattern.Flags["-"+flagName]; ok {
// Flag with value
if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "-") {
result.Flags["-"+flagName] = fields[i+1]
result.Vars[varName] = fields[i+1]
i += 2
continue
}
// Boolean flag
result.Flags["-"+flagName] = "true"
result.Vars[varName] = "true"
}
i++
continue
}
i++
}
// Collect remaining as positional args and "rest"
positional := make([]string, 0)
i = startIdx
for i < len(fields) {
field := fields[i]
if !strings.HasPrefix(field, "-") {
// Check if this was consumed as a flag value
consumed := false
for _, v := range result.Vars {
if v == field {
// Might be consumed, check previous field
if i > 0 {
prev := fields[i-1]
if strings.HasPrefix(prev, "-") {
consumed = true
break
}
}
}
}
if !consumed {
positional = append(positional, field)
}
}
i++
}
// Map positional args
for i, name := range pattern.Positional {
if i < len(positional) {
result.Vars[name] = positional[i]
}
}
// Set rest
if pattern.Rest != "" && len(positional) > len(pattern.Positional) {
restStart := len(pattern.Positional)
if restStart < len(positional) {
result.Vars[pattern.Rest] = strings.Join(positional[restStart:], " ")
}
}
result.Rest = strings.Join(fields, " ")
return result
}
// SimpleParseArguments parses $1, $2, $@ style arguments.
// Returns slice where [0]=full input, [1]=$1, [2]=$2, ... [n]=$@
func SimpleParseArguments(input string, count int) []string {
fields := parseFields(input)
result := make([]string, 0, count+2)
result = append(result, input) // [0] = full input
// [1]..[count] = positional args
for i := 0; i < count; i++ {
if i < len(fields) {
result = append(result, fields[i])
} else {
result = append(result, "")
}
}
// [n] = $@ (all remaining)
if len(fields) > count {
result = append(result, strings.Join(fields[count:], " "))
} else {
result = append(result, "")
}
return result
}
// parseFields splits input respecting quoted strings.
func parseFields(input string) []string {
var fields []string
var current strings.Builder
inQuote := false
quoteChar := rune(0)
for _, r := range input {
switch r {
case '"', '\'':
if !inQuote {
inQuote = true
quoteChar = r
} else if r == quoteChar {
inQuote = false
quoteChar = 0
} else {
current.WriteRune(r)
}
case ' ', '\t':
if inQuote {
current.WriteRune(r)
} else {
if current.Len() > 0 {
fields = append(fields, current.String())
current.Reset()
}
}
default:
current.WriteRune(r)
}
}
if current.Len() > 0 {
fields = append(fields, current.String())
}
return fields
}
// isFlag checks if a field is a known flag.
func isFlag(field string, flags map[string]string) bool {
if strings.HasPrefix(field, "--") {
return true
}
if strings.HasPrefix(field, "-") && len(field) > 1 {
return true
}
return false
}
// EvaluateModelConditional checks if condition matches current model.
// Condition supports wildcards: * matches any, ? matches single char.
func EvaluateModelConditional(currentModel, condition string) bool {
// Handle comma-separated conditions (OR logic)
for _, c := range strings.Split(condition, ",") {
c = strings.TrimSpace(c)
if matchModelPattern(currentModel, c) {
return true
}
}
return false
}
// matchModelPattern matches a model against a pattern with wildcards.
func matchModelPattern(model, pattern string) bool {
// Convert pattern to regexp
pattern = strings.ReplaceAll(pattern, "*", ".*")
pattern = strings.ReplaceAll(pattern, "?", ".")
pattern = "^" + pattern + "$"
re, err := regexp.Compile(pattern)
if err != nil {
// Fallback: exact match
return model == pattern
}
return re.MatchString(model)
}
// RenderWithModelConditionals processes <if-model> blocks in content.
func RenderWithModelConditionals(content, currentModel string) string {
// Simple regex-based processor for <if-model> blocks
// Supports: <if-model is="pattern">content</if-model>
// And: <if-model is="pattern">content<else>other</if-model>
result := content
// Pattern for if-model blocks
ifModelRegex := regexp.MustCompile(`(?s)<if-model\s+is="([^"]+)">(.*?)(?:<else>(.*?))?</if-model>`)
for {
match := ifModelRegex.FindStringSubmatchIndex(result)
if match == nil {
break
}
condition := result[match[2]:match[3]]
ifContent := result[match[4]:match[5]]
elseContent := ""
if match[6] >= 0 && match[7] >= 0 {
elseContent = result[match[6]:match[7]]
}
var replacement string
if EvaluateModelConditional(currentModel, condition) {
replacement = ifContent
} else {
replacement = elseContent
}
result = result[:match[0]] + replacement + result[match[1]:]
}
return result
}
// ---------------------------------------------------------------------------
// Model Resolution Bridge for Extensions (Phase 4)
// ---------------------------------------------------------------------------
// ResolveModelChain attempts each model in order until one is available.
func ResolveModelChain(preferences []string) extensions.ModelResolutionResult {
result := extensions.ModelResolutionResult{
Attempted: make([]string, 0, len(preferences)),
}
registry := models.GetGlobalRegistry()
for _, pref := range preferences {
pref = strings.TrimSpace(pref)
result.Attempted = append(result.Attempted, pref)
// Parse model string
provider, modelID, err := models.ParseModelString(pref)
if err != nil {
continue
}
// Check if provider exists
if registry.GetProviderInfo(provider) == nil {
continue
}
// Check if model exists in registry
modelInfo := registry.LookupModel(provider, modelID)
if modelInfo == nil {
// Try with just the model as bare name
continue
}
// Found available model
result.Model = provider + "/" + modelID
result.Capabilities = extensions.ModelCapabilities{
Provider: provider,
ModelID: modelID,
ContextLimit: modelInfo.Limit.Context,
OutputLimit: modelInfo.Limit.Output,
Reasoning: modelInfo.Reasoning,
Streaming: true, // Assume streaming support
}
return result
}
result.Error = "no models in chain are available"
return result
}
// GetModelCapabilities returns capabilities for a specific model.
// If model is empty, returns zero capabilities.
func GetModelCapabilities(model string) (extensions.ModelCapabilities, string) {
if model == "" {
return extensions.ModelCapabilities{}, "no model specified"
}
provider, modelID, err := models.ParseModelString(model)
if err != nil {
return extensions.ModelCapabilities{}, err.Error()
}
registry := models.GetGlobalRegistry()
modelInfo := registry.LookupModel(provider, modelID)
if modelInfo == nil {
return extensions.ModelCapabilities{}, "model not found in registry"
}
return extensions.ModelCapabilities{
Provider: provider,
ModelID: modelID,
ContextLimit: modelInfo.Limit.Context,
OutputLimit: modelInfo.Limit.Output,
Reasoning: modelInfo.Reasoning,
Streaming: true,
}, ""
}
// CheckModelAvailable verifies if a model string is valid and provider exists.
func CheckModelAvailable(model string) bool {
provider, _, err := models.ParseModelString(model)
if err != nil {
return false
}
registry := models.GetGlobalRegistry()
if registry.GetProviderInfo(provider) == nil {
return false
}
// Model doesn't need to be in registry - could be dynamic/Ollama
return true
}
// GetCurrentProvider extracts provider from model string.
func GetCurrentProvider(model string) string {
provider, _, _ := models.ParseModelString(model)
return provider
}
// GetCurrentModelID extracts model ID from model string.
func GetCurrentModelID(model string) string {
_, modelID, _ := models.ParseModelString(model)
return modelID
}
// JoinModel combines provider and model ID into a model string.
func JoinModel(provider, modelID string) string {
if provider == "" {
return modelID
}
return provider + "/" + modelID
}
// MatchModelGlob matches a model against a glob pattern.
// Pattern can contain * (match any) and ? (match single).
func MatchModelGlob(model, pattern string) bool {
return matchModelPattern(model, pattern)
}
// ExtractProviderFromPath extracts provider from a path-like model string.
func ExtractProviderFromPath(model string) string {
parts := strings.Split(model, "/")
if len(parts) >= 2 {
return parts[0]
}
return ""
}
// ExtractModelFromPath extracts model ID from a path-like model string.
func ExtractModelFromPath(model string) string {
parts := strings.Split(model, "/")
if len(parts) >= 2 {
return parts[1]
}
return model
}
// IsBareModelID checks if a string is a bare model ID (no provider).
func IsBareModelID(model string) bool {
return !strings.Contains(model, "/")
}
// AddProviderToModel adds a provider prefix to a bare model ID.
func AddProviderToModel(provider, model string) string {
if strings.Contains(model, "/") {
return model // Already has provider
}
return provider + "/" + model
}
// RemoveProviderFromModel removes the provider prefix from a model string.
func RemoveProviderFromModel(model string) string {
parts := strings.SplitN(model, "/", 2)
if len(parts) == 2 {
return parts[1]
}
return model
}
+1 -1
View File
@@ -52,7 +52,7 @@ func CodingTools(opts ...ToolOption) []Tool { return core.CodingTools(opts...) }
// read, grep, find, ls.
func ReadOnlyTools(opts ...ToolOption) []Tool { return core.ReadOnlyTools(opts...) }
// SubagentTools returns all core tools except subagent. Use this when
// SubagentTools returns all core tools except spawn_subagent. Use this when
// creating child Kit instances (in-process subagents) to prevent infinite
// recursion.
func SubagentTools(opts ...ToolOption) []Tool { return core.SubagentTools(opts...) }
-123
View File
@@ -1210,129 +1210,6 @@ func applyMode(ctx ext.Context, active bool, tools []string) {
}
```
---
## Bridged SDK APIs (New)
Extensions can now access powerful internal SDK capabilities that enable advanced features like conversation tree navigation, dynamic skill loading, template parsing, and model resolution.
### Tree Navigation
Navigate the conversation tree, summarize branches, and implement "fresh context" loops:
```go
// Get a specific node by ID with full metadata and children
node := ctx.GetTreeNode("entry-id")
// node.ID, node.ParentID, node.Type ("message"/"branch_summary"/etc)
// node.Role, node.Content, node.Model, node.Children ([]string)
// Get the current branch from root to leaf
branch := ctx.GetCurrentBranch() // []ext.TreeNode
// Get child entry IDs of a node
children := ctx.GetChildren("entry-id") // []string
// Navigate/fork to a different entry in the tree
result := ctx.NavigateTo("entry-id") // ext.TreeNavigationResult{Success, Error}
// Summarize a range of the branch using LLM
summary := ctx.SummarizeBranch("from-id", "to-id") // string
// Collapse a branch range into a summary entry (fresh context primitive)
result := ctx.CollapseBranch("from-id", "to-id", "summary text")
```
### Skill Loading
Load and inject skills dynamically at runtime:
```go
// Discover skills from standard locations
result := ctx.DiscoverSkills() // ext.SkillLoadResult{Skills, Error}
// Standard locations: ~/.config/kit/skills/, .kit/skills/, .agents/skills/
// Load a specific skill file
skill, err := ctx.LoadSkill("/path/to/skill.md") // (*ext.Skill, error string)
// skill.Name, skill.Description, skill.Content, skill.Tags, skill.When
// Load all skills from a directory
result := ctx.LoadSkillsFromDir("/path/to/skills") // ext.SkillLoadResult
// Inject a skill as context (pre-loads for next turn)
err := ctx.InjectSkillAsContext("skill-name") // error string
// Inject a skill file directly
err := ctx.InjectRawSkillAsContext("/path/to/skill.md") // error string
// Get all discovered skills
skills := ctx.GetAvailableSkills() // []ext.Skill
```
### Template Parsing
Parse and render templates with variable substitution:
```go
// Parse a template to extract {{variables}}
tpl := ctx.ParseTemplate("name", "Hello {{name}}, welcome to {{place}}!")
// tpl.Name, tpl.Content, tpl.Variables ([]string)
// Render a template with variable values
vars := map[string]string{"name": "Alice", "place": "Kit"}
rendered := ctx.RenderTemplate(tpl, vars) // "Hello Alice, welcome to Kit!"
// Parse command-line style arguments
pattern := ext.ArgumentPattern{
Positional: []string{"command", "target"}, // $1, $2
Rest: "args", // $@
Flags: map[string]string{"--loop": "loop", "-f": "force"},
}
result := ctx.ParseArguments("deploy staging --loop 5", pattern)
// result.Vars["command"] = "deploy"
// result.Vars["target"] = "staging"
// result.Flags["--loop"] = "5"
// Simple positional argument parsing ($1, $2, $@)
args := ctx.SimpleParseArguments("deploy staging --force", 2)
// args[0] = "deploy staging --force" (full input)
// args[1] = "deploy" ($1)
// args[2] = "staging" ($2)
// args[3] = "--force" ($@)
// Evaluate model conditionals with wildcards
matches := ctx.EvaluateModelConditional("claude-*") // bool
// Patterns: * matches any, ? matches single char, comma = OR
// Render content with <if-model> conditionals
content := `<if-model is="claude-*">Hi Claude<else>Hi there</if-model>`
rendered := ctx.RenderWithModelConditionals(content) // based on current model
```
### Model Resolution
Resolve model fallback chains and query capabilities:
```go
// Resolve a chain of model preferences (tries each until available)
result := ctx.ResolveModelChain([]string{
"anthropic/claude-opus-4",
"anthropic/claude-sonnet-4",
"openai/gpt-4o",
})
// result.Model (selected), result.Capabilities, result.Attempted, result.Error
// Get capabilities for a specific model
caps, err := ctx.GetModelCapabilities("anthropic/claude-sonnet-4")
// caps.Provider, caps.ModelID, caps.ContextLimit, caps.Reasoning, caps.Streaming
// Check if a model is available (provider exists)
available := ctx.CheckModelAvailable("anthropic/claude-sonnet-4") // bool
// Get current provider/model ID
provider := ctx.GetCurrentProvider() // "anthropic"
modelID := ctx.GetCurrentModelID() // "claude-sonnet-4"
```
## Key Files for Reference
- [`internal/extensions/api.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go) — Complete API type definitions
+14 -4
View File
@@ -167,6 +167,16 @@ result, err := host.PromptResultWithMessages(ctx, []string{
})
```
### Legacy inline callbacks (deprecated — use event subscribers instead)
```go
response, err := host.PromptWithCallbacks(ctx, "List files",
func(name, args string) { fmt.Printf("Tool: %s\n", name) },
func(name, args, result string, isError bool) { /* tool result */ },
func(chunk string) { fmt.Print(chunk) }, // streaming
)
```
---
## Event System
@@ -251,7 +261,7 @@ Tools are classified by kind for UI rendering:
- `ToolKindEdit` = `"edit"` — edit, write
- `ToolKindRead` = `"read"` — read, ls
- `ToolKindSearch` = `"search"` — grep, find
- `ToolKindSubagent` = `"agent"` — subagent
- `ToolKindSubagent` = `"agent"`spawn_subagent
---
@@ -358,7 +368,7 @@ kit.NewLsTool(opts...) // directory listing
kit.AllTools(opts...) // all 7 core tools
kit.CodingTools(opts...) // bash, read, write, edit
kit.ReadOnlyTools(opts...) // read, grep, find, ls
kit.SubagentTools(opts...) // all except subagent (prevents recursion)
kit.SubagentTools(opts...) // all except spawn_subagent (prevents recursion)
```
### Tool options
@@ -514,7 +524,7 @@ result, err := host.Subagent(ctx, kit.SubagentConfig{
Prompt: "Analyze the test files and summarize coverage",
Model: "anthropic/claude-haiku-3-5-20241022", // empty = parent's model
SystemPrompt: "You are a test analysis expert.",
Tools: nil, // nil = SubagentTools() (all except subagent)
Tools: nil, // nil = SubagentTools() (all except spawn_subagent)
NoSession: true, // ephemeral
Timeout: 2 * time.Minute, // 0 = 5 minute default
OnEvent: func(e kit.Event) {
@@ -532,7 +542,7 @@ result, err := host.Subagent(ctx, kit.SubagentConfig{
```go
host.OnToolCall(func(e kit.ToolCallEvent) {
if e.ToolName == "subagent" {
if e.ToolName == "spawn_subagent" {
host.SubscribeSubagent(e.ToolCallID, func(child kit.Event) {
// Real-time events scoped to this subagent
})
+6 -6
View File
@@ -32,12 +32,12 @@ Key flags for subprocess usage:
Positional arguments are the prompt. `@file` arguments attach file content as context.
## Built-in subagent tool
## Built-in spawn_subagent tool
Kit includes a built-in `subagent` tool that the LLM can use to delegate tasks to independent child agents:
Kit includes a built-in `spawn_subagent` tool that the LLM can use to delegate tasks to independent child agents:
```
subagent(
spawn_subagent(
task: "Analyze the test files and summarize coverage",
model: "anthropic/claude-haiku-latest", // optional
system_prompt: "You are a test analysis expert.", // optional
@@ -61,7 +61,7 @@ result := ctx.SpawnSubagent(ext.SubagentConfig{
### Monitoring subagents from extensions
When the LLM (not the extension itself) spawns a subagent using the `subagent` tool, extensions can monitor its activity in real-time using three lifecycle event handlers:
When the LLM (not the extension itself) spawns a subagent using the `spawn_subagent` tool, extensions can monitor its activity in real-time using three lifecycle event handlers:
```go
// Track active subagents and display their output
@@ -147,11 +147,11 @@ result, err := host.Subagent(ctx, kit.SubagentConfig{
### Real-time subagent events
Use `SubscribeSubagent` to receive real-time events from LLM-initiated subagents (i.e., when the model uses the `subagent` tool). Register inside an `OnToolCall` handler using the tool call ID:
Use `SubscribeSubagent` to receive real-time events from LLM-initiated subagents (i.e., when the model uses the `spawn_subagent` tool). Register inside an `OnToolCall` handler using the tool call ID:
```go
host.OnToolCall(func(e kit.ToolCallEvent) {
if e.ToolName == "subagent" {
if e.ToolName == "spawn_subagent" {
host.SubscribeSubagent(e.ToolCallID, func(event kit.Event) {
switch ev := event.(type) {
case kit.MessageUpdateEvent:
+1 -122
View File
@@ -239,7 +239,7 @@ result := ctx.SpawnSubagent(ext.SubagentConfig{
### Monitoring subagents spawned by the main agent
When the LLM uses the built-in `subagent` tool, extensions can monitor the subagent's activity in real-time using three lifecycle events:
When the LLM uses the built-in `spawn_subagent` tool, extensions can monitor the subagent's activity in real-time using three lifecycle events:
```go
// Subagent started
@@ -334,124 +334,3 @@ api.OnCustomEvent("my-extension:data-ready", func(data any, ctx ext.Context) {
// handle event
})
```
## Bridged SDK APIs
Extensions can access powerful internal SDK capabilities that enable advanced features like conversation tree navigation, dynamic skill loading, template parsing, and model resolution.
### Tree Navigation
Navigate the conversation tree, summarize branches, and implement "fresh context" loops:
```go
// Get a specific node by ID with full metadata and children
node := ctx.GetTreeNode("entry-id")
// node.ID, node.ParentID, node.Type ("message"/"branch_summary"/etc)
// node.Role, node.Content, node.Model, node.Children ([]string)
// Get the current branch from root to leaf
branch := ctx.GetCurrentBranch() // []ext.TreeNode
// Get child entry IDs of a node
children := ctx.GetChildren("entry-id") // []string
// Navigate/fork to a different entry in the tree
result := ctx.NavigateTo("entry-id") // ext.TreeNavigationResult{Success, Error}
// Summarize a range of the branch using LLM
summary := ctx.SummarizeBranch("from-id", "to-id") // string
// Collapse a branch range into a summary entry (fresh context primitive)
result := ctx.CollapseBranch("from-id", "to-id", "summary text")
```
### Skill Loading
Load and inject skills dynamically at runtime:
```go
// Discover skills from standard locations
result := ctx.DiscoverSkills() // ext.SkillLoadResult{Skills, Error}
// Standard locations: ~/.config/kit/skills/, .kit/skills/, .agents/skills/
// Load a specific skill file
skill, err := ctx.LoadSkill("/path/to/skill.md") // (*ext.Skill, error string)
// skill.Name, skill.Description, skill.Content, skill.Tags, skill.When
// Load all skills from a directory
result := ctx.LoadSkillsFromDir("/path/to/skills") // ext.SkillLoadResult
// Inject a skill as context (pre-loads for next turn)
err := ctx.InjectSkillAsContext("skill-name") // error string
// Inject a skill file directly
err := ctx.InjectRawSkillAsContext("/path/to/skill.md") // error string
// Get all discovered skills
skills := ctx.GetAvailableSkills() // []ext.Skill
```
### Template Parsing
Parse and render templates with variable substitution:
```go
// Parse a template to extract {{variables}}
tpl := ctx.ParseTemplate("name", "Hello {{name}}, welcome to {{place}}!")
// tpl.Name, tpl.Content, tpl.Variables ([]string)
// Render a template with variable values
vars := map[string]string{"name": "Alice", "place": "Kit"}
rendered := ctx.RenderTemplate(tpl, vars) // "Hello Alice, welcome to Kit!"
// Parse command-line style arguments
pattern := ext.ArgumentPattern{
Positional: []string{"command", "target"}, // $1, $2
Rest: "args", // $@
Flags: map[string]string{"--loop": "loop", "-f": "force"},
}
result := ctx.ParseArguments("deploy staging --loop 5", pattern)
// result.Vars["command"] = "deploy"
// result.Vars["target"] = "staging"
// result.Flags["--loop"] = "5"
// Simple positional argument parsing ($1, $2, $@)
args := ctx.SimpleParseArguments("deploy staging --force", 2)
// args[0] = "deploy staging --force" (full input)
// args[1] = "deploy" ($1)
// args[2] = "staging" ($2)
// args[3] = "--force" ($@)
// Evaluate model conditionals with wildcards
matches := ctx.EvaluateModelConditional("claude-*") // bool
// Patterns: * matches any, ? matches single char, comma = OR
// Render content with <if-model> conditionals
content := `<if-model is="claude-*">Hi Claude<else>Hi there</if-model>`
rendered := ctx.RenderWithModelConditionals(content) // based on current model
```
### Model Resolution
Resolve model fallback chains and query capabilities:
```go
// Resolve a chain of model preferences (tries each until available)
result := ctx.ResolveModelChain([]string{
"anthropic/claude-opus-4",
"anthropic/claude-sonnet-4",
"openai/gpt-4o",
})
// result.Model (selected), result.Capabilities, result.Attempted, result.Error
// Get capabilities for a specific model
caps, err := ctx.GetModelCapabilities("anthropic/claude-sonnet-4")
// caps.Provider, caps.ModelID, caps.ContextLimit, caps.Reasoning, caps.Streaming
// Check if a model is available (provider exists)
available := ctx.CheckModelAvailable("anthropic/claude-sonnet-4") // bool
// Get current provider/model ID
provider := ctx.GetCurrentProvider() // "anthropic"
modelID := ctx.GetCurrentModelID() // "claude-sonnet-4"
```
-9
View File
@@ -51,15 +51,6 @@ Kit ships with a rich set of example extensions in the `examples/extensions/` di
| [`summarize.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/summarize.go) | Conversation summarization |
| [`lsp-diagnostics.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/lsp-diagnostics.go) | LSP diagnostic integration |
## Bridged SDK APIs
These examples demonstrate the new bridged SDK APIs that give extensions access to internal Kit capabilities:
| Extension | Description |
|-----------|-------------|
| [`conversation-manager.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/conversation-manager.go) | **NEW** Tree navigation (`GetTreeNode`, `GetCurrentBranch`, `NavigateTo`), branch summarization (`SummarizeBranch`), and fresh context loops (`CollapseBranch`) |
| [`prompt-templates.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/prompt-templates.go) | **NEW** Frontmatter-driven templates with model fallback chains (`ResolveModelChain`), skill injection (`InjectSkillAsContext`), and template parsing (`ParseTemplate`, `RenderTemplate`) |
## Themes
| Extension | Description |
+1 -1
View File
@@ -13,7 +13,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
## Features
- **Multi-Provider LLM Support** — Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more
- **Built-in Core Tools** — bash, read, write, edit, grep, find, ls, subagent with no MCP overhead
- **Built-in Core Tools** — bash, read, write, edit, grep, find, ls, spawn_subagent with no MCP overhead
- **MCP Integration** — Connect external MCP servers for expanded capabilities
- **Extension System** — Write custom tools, commands, widgets, and UI modifications in Go
- **Interactive TUI** — Rich terminal interface powered by Bubble Tea with streaming, syntax highlighting, and custom rendering
+44 -2
View File
@@ -5,6 +5,48 @@ description: Monitor tool calls and streaming output with the Kit Go SDK.
# Callbacks
## PromptWithCallbacks
The `PromptWithCallbacks` method provides real-time visibility into tool calls and streaming output:
```go
response, err := host.PromptWithCallbacks(
ctx,
"List files in current directory",
func(name, args string) {
// Called when the model invokes a tool
fmt.Println("Calling tool:", name)
},
func(name, args, result string, isError bool) {
// Called when a tool returns its result
if isError {
fmt.Println("Tool failed:", name)
}
},
func(chunk string) {
// Called for each streaming text chunk
fmt.Print(chunk)
},
)
```
### Callback signatures
| Callback | Signature | When |
|----------|-----------|------|
| `onToolCall` | `func(name, args string)` | Model requests a tool call |
| `onToolResult` | `func(name, args, result string, isError bool)` | Tool execution completes |
| `onStreaming` | `func(chunk string)` | Streaming text chunk received |
Any callback can be `nil` if you don't need it:
```go
// Only care about streaming output
response, err := host.PromptWithCallbacks(ctx, "Hello", nil, nil, func(chunk string) {
fmt.Print(chunk)
})
```
## Event-based monitoring
For more granular control, use the event subscription API:
@@ -74,11 +116,11 @@ The first argument is a priority (lower = runs first).
## Subagent event monitoring
Monitor real-time events from LLM-initiated subagents (when the model uses the `subagent` tool):
Monitor real-time events from LLM-initiated subagents (when the model uses the `spawn_subagent` tool):
```go
host.OnToolCall(func(e kit.ToolCallEvent) {
if e.ToolName == "subagent" {
if e.ToolName == "spawn_subagent" {
host.SubscribeSubagent(e.ToolCallID, func(event kit.Event) {
// Receives the same event types as Subscribe(), scoped to the child agent
switch ev := event.(type) {
+1
View File
@@ -62,6 +62,7 @@ The SDK provides several prompt variants:
| Method | Description |
|--------|-------------|
| `Prompt(ctx, message)` | Simple prompt, returns response string |
| `PromptWithCallbacks(ctx, message, ...)` | With tool call and streaming callbacks |
| `PromptWithOptions(ctx, message, opts)` | With per-call options |
| `PromptResult(ctx, message)` | Returns full `TurnResult` with usage stats |
| `PromptResultWithFiles(ctx, message, files)` | Multimodal with file attachments |
+2 -2
View File
@@ -1566,7 +1566,7 @@ a:hover { text-decoration: underline; }
'grep': '🔍',
'find': '📁',
'ls': '📂',
'subagent': '🤖',
'spawn_subagent': '🤖',
'fetch': '🌐',
'todo': '✅'
};
@@ -1612,7 +1612,7 @@ a:hover { text-decoration: underline; }
headerLabel = formatLsHeader(input);
bodyHtml = renderGenericBody(input, result);
break;
case 'subagent':
case 'spawn_subagent':
headerLabel = formatSubagentHeader(input);
bodyHtml = renderSubagentBody(input, result);
break;