Compare commits

...

34 Commits

Author SHA1 Message Date
Ed Zynda a2ece01ecf ui: stream overflow lines into terminal scrollback buffer
Previously, when streaming text grew taller than the allocated view
height, the top (older) lines were silently discarded by viewContent().
This meant users could not scroll up to see them.

Now, overflow lines are emitted directly via tea.Println so they land
in the terminal's real scrollback buffer — matching the diagram where
completed text lives in the red scrollback region and the green viewable
area always shows the most recent streaming lines + input/footer.

Key changes:
- StreamComponent: add scrollbackFlushedLines counter and ConsumeOverflow()
  method that returns newly overflowed lines and advances the pointer
- StreamComponent.Reset(): zero the counter between steps
- StreamComponent.GetRenderedContent(): skip already-flushed lines so
  the end-of-step flush doesn't re-emit content already in scrollback
- AppModel.Update(): call ConsumeOverflow() each cycle and emit overflow
  directly via tea.Println (not appendScrollback, to avoid triggering
  drainScrollback's auto-flush guard while streaming is active)
- streamComponentIface: add ConsumeOverflow() to interface
- model_test.go: add stub ConsumeOverflow() to test double
- children_test.go: add 7 unit tests covering ConsumeOverflow and the
  updated GetRenderedContent skip-flushed-lines behaviour
2026-03-30 18:22:03 +03:00
Ed Zynda 623c9fb5ad docs(agents): add BTCA configured resources list to AGENTS.md
Enumerate all 14 external repositories configured in btca.config.jsonc
for easy reference when researching dependencies.
2026-03-30 18:20:43 +03:00
Ed Zynda 139506f336 fix(ui): refresh herald typography on theme change
When users run `/theme <name>`, the alert colors (Tip, Note, Warning, etc.)
now update correctly. Previously, MessageRenderer and StreamComponent cached
herald.Typography instances that weren't refreshed after theme changes.

Changes:
- Added UpdateTheme() method to Renderer interface
- Implemented UpdateTheme() for MessageRenderer to recreate herald typography
- Added no-op UpdateTheme() stub for CompactRenderer (fetches colors fresh)
- Implemented UpdateTheme() for StreamComponent reasoning block renderer
- Modified handleThemeCommand() to notify all renderers of theme changes

This ensures newly rendered messages use the current theme's alert colors.
2026-03-30 17:06:06 +03:00
Ed Zynda 6d424554ad Add KIT logo above startup info in TUI
- Display kitBanner() before PrintStartupInfo() when running Kit normally
- The ASCII art banner with KITT scanner lights now appears at the top
  of the screen, before Model, Context, Skills information
- Maintains consistent styling with the existing usage/help screen
2026-03-30 16:57:27 +03:00
Ed Zynda 5a3d3fdd7d fix: properly handle tags from Qwen/DeepSeek models
Models like Qwen and DeepSeek wrap reasoning content in  ...  XML-like
tags within the regular content field. This was causing the reasoning
text to appear twice - once as a reasoning block and once as regular text.

Changes:

1. Provider hooks (providers.go):
   - Extract reasoning from  tags and emit proper reasoning events
   - Use openai provider directly with custom ExtraContentFunc and
     StreamExtraFunc hooks to parse thinking content

2. Stream filtering (stream.go):
   - Filter out all text content between  and  tags at the
     streaming level to prevent duplicate rendering
   - Track state with inThinkTag flag across stream chunks

3. Message conversion (content.go):
   - Strip any remaining  tags from text content when converting
     from fantasy messages

The regex patterns use string concatenation to avoid XML tag corruption:
  regexp.MustCompile( +  +  +  +  +  +  + )

Fixes duplicate reasoning text when using custom provider with models
that wrap thinking in  tags.
2026-03-30 16:31:58 +03:00
Ed Zynda c91225629d fix: handle custom provider model persistence and bare model names
Two related fixes for --provider-url handling:

1. Don't restore custom/* models from preferences without --provider-url
   - When user runs with --provider-url, model defaults to custom/custom
   - If they switch models, custom/custom gets persisted to preferences
   - On next run without --provider-url, restoring custom/custom fails
   - Now we skip restoring custom/* models when no --provider-url is provided

2. Auto-prefix bare model names with custom/ when --provider-url is set
   - Users often provide just the model name (e.g., qwen3.5-35b-a3b)
   - This failed with 'invalid model format' error
   - Now auto-prefixed with custom/ for OpenAI-compatible endpoints
2026-03-30 16:12:16 +03:00
Ed Zynda 5a71cde5ff fix 2026-03-30 16:05:14 +03:00
Ed Zynda 044d3eb206 style: align Read tool gutter styling with Write tool
- Add block-level indentation (2 spaces) to Read tool output
- Configure herald CodeLineNumber style to use GutterBg background
- Match Write tool's gray gutter appearance
2026-03-30 15:49:57 +03:00
Ed Zynda 80f3a642a3 refactor: migrate markdown rendering from glamour to herald-md
- Replace glamour-based markdown rendering with herald/herald-md
- Update go.mod and go.sum with new dependencies
- Refactor styles.go to use Typography cache instead of TermRenderer
- Update enhanced_styles.go for compatibility
- Update btca.config.jsonc configuration
2026-03-30 15:02:01 +03:00
Ed Zynda 26f0969e3e deps: update all dependencies and refactor Read tool rendering
- Update github.com/indaco/herald v0.9.0 -> v0.10.0
- Update charm.land/bubbles/v2 v2.0.0 -> v2.1.0
- Update AWS SDK v2 packages
- Update google.golang.org/genai v1.51.0 -> v1.52.0
- Update various other dependencies

refactor(ui): use herald.CodeBlock for Read tool output

- Replace manual renderCodeBlock() with herald.CodeBlock()
- Add WithCodeLineNumberOffset() support for correct line numbers
- Extract language hint from file extension for syntax highlighting
- Preserve existing syntax highlighting via WithCodeFormatter()
- Remove unused codeLine struct and renderCodeBlock function
2026-03-30 14:51:23 +03:00
Ed Zynda 4af75901b5 test: add generalized smoke and sanity tests for all example extensions
Add two test files that auto-discover and validate every single-file
extension in examples/extensions/:

- all_extensions_load_test.go: Verifies all 32 extensions load into the
  Yaegi interpreter without errors (syntax, imports, Init signature).

- all_extensions_sanity_test.go: Six generalized sanity checks:
  - Lifecycle: SessionStart → SessionShutdown round-trip
  - CommandSanity: non-empty names/descriptions, no spaces/leading slash,
    non-nil Execute, no duplicates
  - ToolSanity: non-empty names/descriptions, at least one executor,
    valid JSON parameters, no duplicates
  - ZeroValueEvents: all 22 event types fired as zero-value structs
  - WidgetSanity: non-empty IDs, consistent keys, valid placements
  - IdempotentLifecycle: repeated SessionStart/SessionShutdown

Shared extensionFiles() helper auto-discovers extensions so new files
are automatically covered.
2026-03-29 15:12:48 +03:00
Ed Zynda 49ff4c0678 fix: /tree and /fork commands lose context due to leaf reset
performFork() called ClearMessages() after Branch(targetID), but
ClearMessages() calls TreeSession.ResetLeaf() which sets leafID back
to empty — immediately undoing the branch. The in-memory message store
was also never reloaded from the tree session after branching, so the
LLM had zero context.

Add ReloadMessagesFromTree() which clears the store and reloads it
from the tree session's current branch without resetting the leaf
pointer. Use it in performFork() instead of ClearMessages().
2026-03-29 15:02:24 +03:00
Ed Zynda b0802a5c32 fix: properly count existing cache blocks to stay under 4-block limit
The issue was that cache control persisted across turns in conversation
history, causing accumulation beyond Anthropic's 4-block limit.

Changes:
- Count existing cache blocks in message history before adding new ones
- Only add new cache blocks up to the 4-block limit
- Remove tool caching (was adding 1 block per turn)
- Skip messages that already have cache control set

Tested with 5 sequential messages - no errors, proper cache metrics.
2026-03-29 14:48:08 +03:00
Ed Zynda dfe65ca227 chore: remove all Crush references from comments
Remove mentions of Crush from:
- cache_control.go
- agent.go (2 references)
- content.go
- tool_renderers.go
- lsp-diagnostics.go (2 references)
2026-03-29 14:43:51 +03:00
Ed Zynda d4ec756ce5 fix: match Crush's cache_control strategy exactly
Crush's proven 4-block strategy:
1. Last system message (if present)
2. Last 2 conversation messages
3. Last tool definition

This stays exactly at Anthropic's 4-block limit without exceeding it.

Previous implementation could exceed the limit in certain edge cases.
Now matches Crush's battle-tested approach.
2026-03-29 14:42:29 +03:00
Ed Zynda 2971e73ee8 fix: limit Anthropic cache_control blocks to maximum 4
Anthropic API enforces a maximum of 4 blocks with cache_control per request.
The previous implementation could exceed this limit when combining:
- System message caching
- Recent message caching
- Tool definition caching

Changes:
- Add explicit cache block counting (max 4)
- Remove tool cache control to stay under limit
- Prioritize: system message first, then recent messages
- Work backwards from end to cache most recent context first

Fixes: bad request error 'A maximum of 4 blocks with cache_control may be provided'
2026-03-29 14:40:44 +03:00
Ed Zynda 5aa6c9e116 chore: fix all golangci-lint v2 issues
- Fix gofmt formatting issues in 7 files
- Replace atomic.AddUint64 with atomic.Uint64 type (modernize)
- Replace for i := 0; i < count; i++ with for i := range count (modernize)
- Replace strings.Split with strings.SplitSeq (modernize)
- Replace deprecated GetFantasyProviders with GetLLMProviders
- Replace deprecated GetFantasyMessages with GetLLMMessages
- Replace deprecated ConvertFromFantasyMessage with ConvertFromLLMMessage
- Replace deprecated FromFantasyMessage with FromLLMMessage
- Replace deprecated ToFantasyMessages with ToLLMMessages
- Remove 2 unused formatToolArgs functions
2026-03-29 14:36:03 +03:00
Ed Zynda bca08476de chore: fix remaining linting issues in caching code
- Use max() built-in instead of if statement (modernize)
- Remove unused buildAnthropicCacheOptions function
- Remove unused anthropic import
2026-03-29 14:32:28 +03:00
Ed Zynda 6a599d86af chore: fix golangci-lint v2 compatibility
- Upgrade golangci-lint to v2.11.4
- Fix errcheck warnings for os.Setenv/os.Unsetenv in tests
- Use maps.Copy instead of manual loop (modernize lint)
- Add maps import for maps.Copy
2026-03-29 14:31:19 +03:00
Ed Zynda fd6f200659 refactor: clean up self-referential comments in caching code
Remove internal monologue comments that don't add value for readers:
- Remove lengthy explanations of type conflicts that are now resolved
- Remove 'NOTE:' and 'TODO:' comments documenting implementation history
- Remove obvious test comments that just restate what the code does
- Keep only meaningful comments that explain design intent

The code is now cleaner and easier to read without the self-referential
commentary that was useful during development but not for maintenance.
2026-03-29 14:28:29 +03:00
Ed Zynda b295a25946 feat: automatic prompt caching for cost reduction
Implements automatic prompt caching to reduce API costs by 60-90% for
repeated prompts with the same context.

Architecture:
- Provider-level caching for OpenAI (PromptCacheKey)
- Message-level caching for Anthropic (avoids type conflicts)
- Model family detection enables caching regardless of provider

Key Changes:
- Add ModelInfo.Family with SupportsCaching() and CacheType() methods
- Add ProviderConfig.DisableCaching for opt-out
- Implement message-level cache control in agent (like Crush)
  - Last system message gets cache control
  - Last 2 messages get cache control
  - Last tool gets cache control
- Auto-disable caching when thinking is enabled (type conflict avoidance)
- Add KIT_DISABLE_CACHE environment variable for global opt-out

Tested with opencode/claude-sonnet-4-6 showing cacheRead/cacheWrite
tokens in debug output, confirming 60-90% cost savings.

Closes cost optimization for multi-turn conversations.
2026-03-29 14:24:07 +03:00
Ed Zynda f0e4e2f757 refactor: remove Fantasy dependency name leakage from public SDK and docs
Rename public SDK symbols to use generic LLM terminology instead of
exposing the internal dependency name (charm.land/fantasy):

Public API renames (with deprecated wrappers for backward compat):
- ConvertToFantasyMessages() → ConvertToLLMMessages()
- ConvertFromFantasyMessage() → ConvertFromLLMMessage()
- GetFantasyProviders() → GetLLMProviders()

New type alias:
- LLMFilePart = fantasy.FilePart (eliminates need for direct fantasy import)
- PromptResultWithFiles() signature now uses LLMFilePart

Internal renames (with deprecated wrappers):
- ModelsRegistry.GetFantasyProviders() → GetLLMProviders()
- TreeManager.GetFantasyMessages() → GetLLMMessages()
- TreeManager.AppendFantasyMessage() → AppendLLMMessage()
- TreeManager.AddFantasyMessages() → AddLLMMessages()
- Message.ToFantasyMessages() → ToLLMMessages()
- FromFantasyMessage() → FromLLMMessage()
- npmToFantasyProvider → npmToLLMProvider
- isProviderFantasySupported() → isProviderLLMSupported()

All internal callers migrated to new names. ~30 comments updated
to remove Fantasy references across pkg/kit/, internal/agent/,
internal/models/, internal/message/, internal/session/.

Documentation updates:
- AGENTS.md: added Public SDK rules section (no dependency leakage,
  naming conventions, deprecation pattern)
- README.md: removed Fantasy references
- pkg/kit/README.md: full rewrite with current API surface
- skills/kit-sdk/SKILL.md: updated examples and type references
- www/pages/providers.md, www/pages/cli/commands.md: updated
2026-03-29 14:01:57 +03:00
Ed Zynda d25249506a docs: update SKILL.md and README for recent SDK changes
- Add StepUsageEvent and SteerConsumedEvent to event types table
- Add new Extension API section documenting kit.Extensions() sub-API
- Add extension_api.go to Key Files reference list
- Fix Close() error handling in README SDK example
2026-03-29 13:33:19 +03:00
Ed Zynda 971521f534 Group Extension* methods behind ExtensionAPI interface
- Create ExtensionAPI interface with all extension-related methods
- Add extensionAPI type that wraps *Kit and implements the interface
- Add Kit.Extensions() method to access the ExtensionAPI
- Remove ~30 Extension* methods from Kit (breaking SDK change)
- Update all internal callers (cmd/, internal/acpserver/) to use Extensions().Method()
- Extensions themselves unaffected (use kit/ext API via Yaegi)

This cleans up the Kit API surface while maintaining full extension functionality.
2026-03-29 13:19:51 +03:00
Ed Zynda 8c00682367 Rename Fantasy* types to LLM* and remove GenerateResult alias
- FantasyMessage -> LLMMessage
- FantasyUsage -> LLMUsage
- FantasyResponse -> LLMResponse
- Remove confusing GenerateResult = TurnResult alias
- Update documentation in SKILL.md
2026-03-29 13:11:55 +03:00
Ed Zynda 58caf155c1 pkg/: internal cleanups - shared iterator, per-instance skill cache, exported EntryID
kit.go
- Extract iterBranchMessages helper to eliminate ~15 lines of duplicated
  branch-fetch/type-assert boilerplate between GetSessionMessages and
  GetStructuredMessages
- Move skillCache from package-level global to per-Kit field; avoids
  cross-contamination when multiple Kit instances exist in same process

skills.go
- Remove globalSkillCache var and skillCache type definition
- Update DiscoverSkillsForExtension and ClearSkillCache to use m.skillCache
- Remove unused sync import

sessions.go
- Use m.treeSession.EntryID instead of local getEntryID duplicate
- Remove local getEntryID function (was missing LabelEntry, SessionInfoEntry,
  CompactionEntry types that internal/session.TreeManager.EntryID handles)

internal/session/tree_manager.go
- Export entryID -> EntryID so pkg/kit can use it directly
- Update all internal callers to use EntryID

config.go
- Add sync comment for defaultSystemPrompt noting it should be kept in sync
  with CLI default in cmd/root.go

hooks_test.go
- Add newEmptyHookedTool helper for tests that need hookedTool with empty
  hook registries
- Update TestHookedTool_Passthrough and TestHookedTool_InfoDelegates to use
  helper (saves ~6 lines of boilerplate each)
- Merge TestHookRegistry_HasHooks into TestHookRegistry_Unregister (was
  testing same behavior, now just one initial state assertion added)

All changes tested with opencode/kimi-k2.5 exploring the repo in tmux.
6 files changed, 69 insertions(+), 98 deletions(-)
2026-03-29 13:00:33 +03:00
Ed Zynda 3f08bf2424 pkg/kit: SDK quality-of-life improvements
Replace var function aliases with proper func wrappers (types.go)
- ParseModelString, CreateProvider, GetGlobalRegistry, LoadSystemPrompt
  were package-level vars, making them reassignable and rendering oddly
  in go doc. Now plain func wrappers with matching signatures.

Fix Subagent() double-error return convention
- Was returning both (*SubagentResult{Error: err}, err) simultaneously.
  Now returns (nil, err) on failure, consistent with Go conventions.
- Removed SubagentResult.Error field; errors come from the error return.
- Updated all call sites in cmd/root.go, internal/acpserver, and kit.go.

Fix NavigateTo/SummarizeBranch/CollapseBranch string-encoded errors
- All three returned "" or an error string instead of error values,
  making it impossible to distinguish success from failure in SummarizeBranch
  (empty string meant both "no content" and "LLM failed").
- NavigateTo: string -> error
- SummarizeBranch: string -> (string, error)
- CollapseBranch: string -> error
- Updated cmd/root.go bridge closures to use err != nil and err.Error().

Remove duplicate GetSessionFilePath (use GetSessionPath)
- GetSessionPath (sessions.go) and GetSessionFilePath (kit.go) were
  identical. Removed GetSessionFilePath; updated cmd/root.go and
  internal/acpserver to call GetSessionPath directly.
2026-03-29 12:51:04 +03:00
Ed Zynda 9fbbab05f6 pkg/: simplify code without altering public API
events.go
- Delete subagentListenerSet (verbatim duplicate of eventBus); reuse
  *eventBus in SubscribeSubagent and getSubagentListenerSet

hooks.go
- Add early-exit in run() when hooks slice is empty, making all
  hasHooks() guard call sites in kit.go and compaction.go redundant

kit.go
- Remove four if m.X.hasHooks() { m.X.run(...) } outer guards
  (beforeTurn, contextPrepare, afterTurn x2); run() now short-circuits
- Replace goto drained with an idiomatic return inside default: branch
- Replace stdlib log.Printf with charmlog.Debug (charmbracelet/log),
  consistent with the rest of the codebase; remove "log" import

config.go
- Collapse single-element configNames := []string{".kit"} loop into a
  direct viper.SetConfigName call (removes slice, for, break, flag)

auth.go
- Fix GetOpenAIAPIKey: it documented OPENAI_API_KEY env var fallback but
  never called os.Getenv; now it does

compaction.go
- Extract persistAndEmitCompaction helper; eliminates duplicated
  AppendCompaction + events.emit block in compactInternal and
  applyCustomCompaction
- Replace fmt.Errorf("%s", reason) with errors.New(reason)
- Name the 16384 magic number as const defaultReserveTokens

skills.go
- Fix broken double-checked lock in DiscoverSkillsForExtension: the
  read-unlock -> write-lock gap had a TOCTOU race; replaced with a
  single write-lock covering the check and load
- Remove dead nil guard in convertSkills (convertSkill never returns nil)
- Rename convertSkills parameter skills->skillList to avoid shadowing
  the skills package import

extensions_bridge.go
- Delete taskMutex struct (sync.Mutex wrapper with map passed as param);
  replace with inline var taskMu sync.Mutex at the use site
- Simplify AgentEnd double-if into a single combined := declaration

template_bridge.go
- Fix RenderTemplate: use varRegex.ReplaceAllStringFunc instead of
  two-pass strings.ReplaceAll; handles arbitrary whitespace in {{var}}
- Remove dead isFlag function and simplify ParseArguments guard
  (the outer !HasPrefix guard made isFlag always return false)
- Cache matchModelPattern compiled regexps in a sync.Map to avoid
  repeated regexp.Compile on hot streaming paths

pkg/extensions/test/mock.go
- Remove dead local StatusBarEntry type (duplicate of extensions type,
  never referenced)
- Change make([]T, 0) to nil for nine slice fields in NewMockContext

pkg/extensions/test/harness.go
- Remove MustLoad (no callers outside the package)
- Remove extPath field (assigned but never read)
- Remove redundant os.Stat in LoadFile (os.ReadFile already errors)

events_test.go
- Add five missing event types to TestEventTypes table
  (Compaction, ReasoningDelta, ToolOutput, StepUsage, SteerConsumed)
- Expand TestEventOrdering from 11 to 16 events with the same types
- Add a got < 0 assertion to TestEventBusConcurrentSubscribeEmit so the
  test can actually fail rather than only logging
2026-03-29 12:39:19 +03:00
Ed Zynda b0991c7aa6 tui: simplify rendering, fix correctness issues, remove dead code
## Dead code removal
- Delete slash_command_input.go (352 lines, never instantiated)
- Remove FormatCompactLine, StyleCompactSymbol/Label/Content from
  enhanced_styles.go (zero call sites)
- Remove getTheme() alias in messages.go; standardize on GetTheme()
  across compact_renderer.go (8 sites) and tool_renderers.go (14 sites)

## BubbleTea correctness
- Fix child model discards: all m.stream.Update() and m.input.Update()
  calls now store the returned model via type-assertion (13 sites)
- Fix Init(): remove vestigial nil guards; StreamComponent.Init() always
  returns nil so only m.input.Init() is needed
- Fix /clear divergence: remove silent InputComponent /clear handler so
  parent AppModel handles it with the proper system message (one path)

## Architecture / maintainability
- Unify slash-command dispatch from two-pass (exact + prefix) to single
  parse: strings.Cut once, GetCommandByName on name, pass args to
  handleSlashCommand(sc, args); eliminates 3 separate dispatch sites
- Add noopCmd package-level var replacing three inline func()tea.Msg{nil}
  sentinel returns
- Remove stale TAS-15/16/17 comments from interface declarations
- Deduplicate headerProviderForUI / footerProviderForUI in cmd/root.go
  into a shared headerFooterProviderForUI helper (removes ~28 duplicated lines)

## Performance
- Cache glamour.TermRenderer keyed by width in styles.go; invalidate on
  theme change — eliminates full goldmark parser re-init every flush tick
- Add styleMarginBottom1 package-level var replacing 9 per-frame
  lipgloss.NewStyle().MarginBottom(1) allocations
- Add layoutDirty flag: replace 9 distributeHeight() calls in Update()
  with m.layoutDirty=true; flush once in View() — guarantees exactly one
  layout measurement per frame instead of N (reduces double-render)
- Add WidgetUpdateEvent coalescing in app.NotifyWidgetUpdate() via
  atomic.Bool + 16ms debounce; prevents fast extension tickers from
  flooding BubbleTea's message queue with redundant re-render triggers

## Concurrency safety
- Convert all NotifyWidgetUpdate() call sites in cmd/root.go to
  go appInstance.NotifyWidgetUpdate() (16 sites) — eliminates deadlock
  risk when called synchronously from inside BubbleTea's Update() handler
2026-03-29 11:34:16 +03:00
Ed Zynda 9c90563765 refactor: simplify code patterns and reduce duplication
- Extract isShellTool() helper in tool_renderers.go to eliminate
  duplicated shell tool matching logic
- Replace bannedCommands slice with compiled regex in bash.go for
  cleaner security validation
- Extract pathSet helper type in loader.go for reusable path
  deduplication
- Consolidate ac()/acOr() helpers in themes.go for better organization
- Total reduction: ~34 lines across 4 files

All tests pass (go test -race ./...) and build succeeds.
2026-03-29 01:18:27 +03:00
Ed Zynda f36166bee5 rename spawn_subagent tool to subagent; remove redundant toolDisplayNames map
Tool rename (breaking change for ToolName string comparisons in event handlers):
- internal/core/subagent.go: Name field 'spawn_subagent' → 'subagent'
- internal/extensions/wrapper.go: update coreToolKinds map key
- pkg/kit/events.go: update coreToolKinds map key and ToolKindSubagent comment
- pkg/kit/extensions_bridge.go: update three ToolName == ... guards
- internal/ui/tool_renderers.go: update two toolName == ... case guards
- internal/ui/stream.go: remove special-case branch (toolName is now already
  'subagent', so the title-case fallback produces 'Subagent' naturally)

Comments/docs updated everywhere (no logic changes):
- internal/core/tools.go, internal/extensions/api.go, events.go
- pkg/kit/kit.go, tools.go
- examples/extensions/subagent-test.go, kit-telegram/main.go
- README.md, skills/kit-sdk/SKILL.md
- www/pages/advanced/subagents.md, extensions/capabilities.md
- www/pages/index.md, sdk/callbacks.md
- www/public/session/index.html (tracked UI asset)

Redundant toolDisplayNames map removed (item #14):
- internal/ui/messages.go: delete the 7-entry map whose every value was
  identical to what the title-case fallback already produced; simplify
  toolDisplayName() to just the fallback
2026-03-29 00:24:18 +03:00
Ed Zynda 879e81f9b5 remove deprecated API methods: GetExtRunner, GetBufferedLogger, GetAgent, PromptWithCallbacks
These methods have been deprecated since the narrow-accessor and event-
subscriber APIs were introduced. No callers exist in this repository.

- pkg/kit/kit.go: remove GetExtRunner(), GetBufferedLogger(), GetAgent(),
  and PromptWithCallbacks(); update Subscribe() doc comment which still
  mentioned PromptWithCallbacks; tighten section header comment
- pkg/kit/README.md: replace PromptWithCallbacks example with the
  OnToolCall/OnToolResult/OnStreaming subscriber pattern; remove method
  from the quick-reference list
- README.md: same example migration in the SDK section
- www/pages/sdk/callbacks.md: remove the PromptWithCallbacks section
  entirely; the event-based monitoring section that followed it is now
  the lead content
- www/pages/sdk/overview.md: remove PromptWithCallbacks row from the
  prompt-variant table
- skills/kit-sdk/SKILL.md: remove the deprecated legacy callback snippet
2026-03-29 00:05:09 +03:00
Ed Zynda 727b42acfe cleanup: remove unused variable, duplicate condition, and reimplemented stdlib helper
- agent: remove unused currentToolName variable and its compiler-suppressor
  '_ = currentToolName'; currentToolArgs is the field actually used by
  OnToolResult callbacks
- tools/connection_pool: collapse double-nested identical if guard into a
  single check (copy-paste artifact)
- tools/mcp_test: replace hand-rolled contains() helper with strings.Contains;
  add 'strings' import and delete the redundant function
2026-03-29 00:00:33 +03:00
Ed Zynda 4830981570 cleanup: fix dead code, logic bug, duplication, and Unicode fuzzy matching
- config: fix tilde path expansion (filepath.Join result was discarded)
- config: remove dead comment '// base := GetConfigPath()'
- auth: extract oauthTokenExpired/oauthTokenNeedsRefresh helpers to
  eliminate copy-paste duplication across AnthropicCredentials and
  OpenAICredentials
- ui/messages: remove dead RenderToolCallMessage on MessageRenderer
  (not part of Renderer interface, never called)
- ui/compact_renderer: remove dead RenderToolCallMessage on CompactRenderer
  (symmetric duplicate, never called)
- ui/enhanced_styles: remove dead CreateGradientText wrapper
  (one-liner over ApplyGradient, never called)
- ui/fuzzy: fix fuzzyCharacterMatch to use rune iteration instead of
  byte indexing (was silently wrong for multi-byte Unicode input)
- ui/file_suggestions: remove duplicate fuzzyCharMatch; call the now-
  correct shared fuzzyCharacterMatch instead; drop unused utf8 import
- app: replace TODO comment with descriptive note (batch file attachment
  limitation is intentional, not a pending action item)
2026-03-28 23:58:14 +03:00
81 changed files with 3151 additions and 2610 deletions
+45 -19
View File
@@ -1,22 +1,3 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions
These instructions are for AI assistants working in this project.
Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->
# KIT Agent Guidelines
## Build/Test Commands
@@ -42,6 +23,33 @@ Keep this managed block so 'openspec update' can refresh the instructions.
- **Extension system** (`internal/extensions/`): Yaegi-interpreted Go, 13 lifecycle events, custom tools/commands/widgets/overlays/editor interceptors
- **TUI** (`internal/ui/`): Bubble Tea v2 parent-child model (`AppModel``InputComponent`, `StreamComponent`, etc.)
- **Decoupling pattern**: `cmd/root.go` has converter functions (e.g. `widgetProviderForUI()`) that bridge `internal/extensions/` types to `internal/ui/` types — the UI never imports extensions directly
- **Public SDK** (`pkg/kit/`): The public-facing Go SDK for embedding Kit as a library. See rules below.
## Public SDK (`pkg/kit/`) Rules
`pkg/kit/` is the **public API surface** consumed by external Go developers. All exported symbols, types, function names, and godoc comments in this package are part of the SDK contract.
### No Dependency Name Leakage
Internal dependency names (e.g. `charm.land/fantasy`, library-specific jargon) **must not** appear in:
- **Exported function/method names** — use generic terms (`LLM`, `Provider`, `Message`) instead of library names
- **Exported type names** — type aliases should use domain names (e.g. `LLMMessage`, not `FantasyMessage`)
- **Godoc comments** on exported symbols — these are visible in `go doc` output and pkg.go.dev
- **Struct field names and tags** on exported types
Using dependency types directly in **function bodies** (private implementation) is fine — that's invisible to SDK consumers.
### Naming Conventions for SDK Symbols
- Type aliases re-exporting dependency types: use `LLM*` prefix (e.g. `LLMMessage`, `LLMUsage`, `LLMResponse`)
- Conversion helpers: use `ConvertToLLM*` / `ConvertFromLLM*` (not the dependency name)
- Provider queries: use `GetLLMProviders` (not `GetFantasyProviders`)
- When wrapping internal methods, the `pkg/kit/` name should be dependency-agnostic even if the `internal/` method still uses the old name
### Deprecation Pattern
When renaming a public SDK symbol, keep the old name as a deprecated wrapper for one release cycle:
```go
// Deprecated: Use NewName instead.
func OldName() { return NewName() }
```
## Key Patterns
@@ -92,3 +100,21 @@ Positional args are the prompt. `@file` args attach file content. Key flags: `--
- Never guess or manually search the filesystem for external projects
- Example: `btca ask -r https://github.com/user/repo -q "How does X work?"`
- See `.agents/skills/btca-cli/SKILL.md` for full btca usage
## BTCA Configured Resources
The following external repositories are configured in `btca.config.jsonc` for research:
- bubbletea
- lipgloss
- bubbles
- glamour
- fantasy
- catwalk
- crush
- pi
- iteratr
- yaegi
- acp-go-sdk
- opencode
- herald
- herald-md
+22 -19
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, spawn_subagent - no MCP overhead
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls, 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
@@ -209,7 +209,7 @@ kit auth status # Check authentication status
# Model database
kit models [provider] # List available models (optionally filter by provider)
kit models --all # Show all providers (not just Fantasy-compatible)
kit models --all # Show all providers (not just LLM-compatible)
kit update-models [source] # Update model database (from models.dev, URL, file, or 'embedded')
# Extension management
@@ -502,7 +502,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
defer host.Close()
defer func() { _ = host.Close() }()
// Send a prompt
response, err := host.Prompt(ctx, "What is 2+2?")
@@ -543,23 +543,26 @@ host, err := kit.New(ctx, &kit.Options{
### With Callbacks
```go
response, err := host.PromptWithCallbacks(
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(
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)
},
)
```
@@ -723,7 +726,7 @@ Use `custom/custom` when pointing Kit at any OpenAI-compatible endpoint with `--
kit --provider-url "http://localhost:8080/v1" "Hello"
```
This automatically defaults to `custom/custom` without needing to specify a model. The custom provider routes through fantasy's `openaicompat` provider and supports:
This automatically defaults to `custom/custom` without needing to specify a model. The custom provider routes through the `openaicompat` provider and supports:
- Zero cost tracking (input/output = 0)
- 262K context window, 65K output limit
+6
View File
@@ -82,6 +82,12 @@
"name": "herald",
"url": "https://github.com/indaco/herald",
"branch": "main"
},
{
"type": "git",
"name": "herald-md",
"url": "https://github.com/indaco/herald-md",
"branch": "main"
}
],
"model": "claude-haiku-4-5",
+1 -1
View File
@@ -55,7 +55,7 @@ func printAllProviders(showAll bool) error {
if showAll {
providerIDs = kit.GetSupportedProviders()
} else {
providerIDs = kit.GetFantasyProviders()
providerIDs = kit.GetLLMProviders()
}
sort.Strings(providerIDs)
+155 -130
View File
@@ -415,7 +415,7 @@ func runKit(ctx context.Context) error {
// normalised to start with "/" so they integrate with the slash-command
// autocomplete and dispatch pipeline.
func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
defs := k.ExtensionCommands()
defs := k.Extensions().Commands()
if len(defs) == 0 {
return nil
}
@@ -429,12 +429,12 @@ func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
Name: name,
Description: d.Description,
Execute: func(args string) (string, error) {
return d.Execute(args, k.GetExtensionContext())
return d.Execute(args, k.Extensions().GetContext())
},
}
if d.Complete != nil {
ec.Complete = func(prefix string) []string {
return d.Complete(prefix, k.GetExtensionContext())
return d.Complete(prefix, k.Extensions().GetContext())
}
}
cmds = append(cmds, ec)
@@ -446,11 +446,11 @@ func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
// ui.WidgetData for the given placement. Returns nil if extensions are
// disabled, which is safe — the UI treats a nil GetWidgets as "no widgets".
func widgetProviderForUI(k *kit.Kit) func(string) []ui.WidgetData {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return func(placement string) []ui.WidgetData {
configs := k.GetExtensionWidgets(extensions.WidgetPlacement(placement))
configs := k.Extensions().GetWidgets(extensions.WidgetPlacement(placement))
if len(configs) == 0 {
return nil
}
@@ -467,25 +467,34 @@ func widgetProviderForUI(k *kit.Kit) func(string) []ui.WidgetData {
}
}
// headerFooterProviderForUI returns a provider func that maps an
// extensions.HeaderFooterConfig getter into the ui.WidgetData shape
// expected by AppModel. The getter argument selects header vs footer.
func headerFooterProviderForUI(k *kit.Kit, getter func() *extensions.HeaderFooterConfig) func() *ui.WidgetData {
if !k.Extensions().HasExtensions() {
return nil
}
return func() *ui.WidgetData {
cfg := getter()
if cfg == nil {
return nil
}
return &ui.WidgetData{
Text: cfg.Content.Text,
Markdown: cfg.Content.Markdown,
BorderColor: cfg.Style.BorderColor,
NoBorder: cfg.Style.NoBorder,
}
}
}
// headerProviderForUI returns a function that converts the extension header
// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled,
// which is safe — the UI treats a nil GetHeader as "no header".
func headerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
if !k.HasExtensions() {
return nil
}
return func() *ui.WidgetData {
config := k.GetExtensionHeader()
if config == nil {
return nil
}
return &ui.WidgetData{
Text: config.Content.Text,
Markdown: config.Content.Markdown,
BorderColor: config.Style.BorderColor,
NoBorder: config.Style.NoBorder,
}
}
return headerFooterProviderForUI(k, func() *extensions.HeaderFooterConfig {
return k.Extensions().GetHeader()
})
}
// toolRendererProviderForUI returns a function that converts extension tool
@@ -493,11 +502,11 @@ func headerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
// disabled, which is safe — the UI treats a nil GetToolRenderer as "no
// custom renderers".
func toolRendererProviderForUI(k *kit.Kit) func(string) *ui.ToolRendererData {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return func(toolName string) *ui.ToolRendererData {
config := k.GetExtensionToolRenderer(toolName)
config := k.Extensions().GetToolRenderer(toolName)
if config == nil {
return nil
}
@@ -517,11 +526,11 @@ func toolRendererProviderForUI(k *kit.Kit) func(string) *ui.ToolRendererData {
// Returns nil if extensions are disabled, which is safe — the UI treats a
// nil GetEditorInterceptor as "no interceptor".
func editorInterceptorProviderForUI(k *kit.Kit) func() *ui.EditorInterceptor {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return func() *ui.EditorInterceptor {
config := k.GetExtensionEditor()
config := k.Extensions().GetEditor()
if config == nil {
return nil
}
@@ -555,11 +564,11 @@ func editorInterceptorProviderForUI(k *kit.Kit) func() *ui.EditorInterceptor {
// visibility overrides to a *ui.UIVisibility for the TUI. Returns nil if
// extensions are disabled — the UI treats nil as "show everything".
func uiVisibilityProviderForUI(k *kit.Kit) func() *ui.UIVisibility {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return func() *ui.UIVisibility {
v := k.GetExtensionUIVisibility()
v := k.Extensions().GetUIVisibility()
if v == nil {
return nil
}
@@ -576,21 +585,9 @@ func uiVisibilityProviderForUI(k *kit.Kit) func() *ui.UIVisibility {
// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled,
// which is safe — the UI treats a nil GetFooter as "no footer".
func footerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
if !k.HasExtensions() {
return nil
}
return func() *ui.WidgetData {
config := k.GetExtensionFooter()
if config == nil {
return nil
}
return &ui.WidgetData{
Text: config.Content.Text,
Markdown: config.Content.Markdown,
BorderColor: config.Style.BorderColor,
NoBorder: config.Style.NoBorder,
}
}
return headerFooterProviderForUI(k, func() *extensions.HeaderFooterConfig {
return k.Extensions().GetFooter()
})
}
// statusBarProviderForUI returns a function that fetches extension status bar
@@ -598,11 +595,11 @@ func footerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
// if extensions are disabled, which is safe — the TUI treats a nil
// GetStatusBarEntries as "no extension entries".
func statusBarProviderForUI(k *kit.Kit) func() []ui.StatusBarEntryData {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return func() []ui.StatusBarEntryData {
entries := k.GetExtensionStatusEntries()
entries := k.Extensions().GetStatusEntries()
if len(entries) == 0 {
return nil
}
@@ -622,30 +619,36 @@ func statusBarProviderForUI(k *kit.Kit) func() []ui.StatusBarEntryData {
// and returns (cancelled, reason). Returns nil if extensions are disabled —
// the UI treats nil as "no hook".
func beforeForkProviderForUI(k *kit.Kit) func(string, bool, string) (bool, string) {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return k.EmitBeforeFork
return func(targetID string, isUserMsg bool, userText string) (bool, string) {
return k.Extensions().EmitBeforeFork(targetID, isUserMsg, userText)
}
}
// beforeSessionSwitchProviderForUI returns a callback that emits a
// BeforeSessionSwitch event and returns (cancelled, reason). Returns nil
// if extensions are disabled — the UI treats nil as "no hook".
func beforeSessionSwitchProviderForUI(k *kit.Kit) func(string) (bool, string) {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return k.EmitBeforeSessionSwitch
return func(switchReason string) (bool, string) {
return k.Extensions().EmitBeforeSessionSwitch(switchReason)
}
}
// globalShortcutsProviderForUI returns a callback that queries the extension
// runner for registered keyboard shortcuts. Returns nil if extensions are
// disabled — the UI treats nil as "no shortcuts".
func globalShortcutsProviderForUI(k *kit.Kit) func() map[string]func() {
if !k.HasExtensions() {
if !k.Extensions().HasExtensions() {
return nil
}
return k.GetExtensionShortcuts
return func() map[string]func() {
return k.Extensions().GetShortcuts()
}
}
func runNormalMode(ctx context.Context) error {
@@ -677,9 +680,15 @@ func runNormalMode(ctx context.Context) error {
// Restore persisted model preference when no explicit --model flag or
// config file model is set. Precedence: CLI flag > config file > saved
// preference > built-in default. This mirrors how themes are persisted.
// Skip custom/* models unless --provider-url is also provided, since the
// custom provider requires a URL that was only valid for the previous session.
if !modelFlagChanged && !viper.InConfig("model") {
if pref := ui.LoadModelPreference(); pref != "" {
viper.Set("model", pref)
if strings.HasPrefix(pref, "custom/") && viper.GetString("provider-url") == "" {
// Don't restore custom models without a provider URL
} else {
viper.Set("model", pref)
}
}
}
@@ -700,6 +709,15 @@ func runNormalMode(ctx context.Context) error {
viper.Set("model", "custom/custom")
}
// When --provider-url is set with an explicit --model that lacks a provider
// prefix (no "/"), auto-prefix with "custom/" for OpenAI-compatible endpoints.
if viper.GetString("provider-url") != "" && modelFlagChanged {
model := viper.GetString("model")
if model != "" && !strings.Contains(model, "/") {
viper.Set("model", "custom/"+model)
}
}
// Load MCP configuration.
mcpConfig, err := config.LoadAndValidateConfig()
if err != nil {
@@ -776,7 +794,7 @@ func runNormalMode(ctx context.Context) error {
treeSession := kitInstance.GetTreeSession()
var messages []fantasy.Message
if treeSession != nil {
messages = treeSession.GetFantasyMessages()
messages = treeSession.GetLLMMessages()
}
// Create the app.App instance.
@@ -803,9 +821,9 @@ func runNormalMode(ctx context.Context) error {
var startupExtensionMessages []string
// Set up extension context and emit SessionStart.
if kitInstance.HasExtensions() {
if kitInstance.Extensions().HasExtensions() {
cwd, _ := os.Getwd()
kitInstance.SetExtensionContext(extensions.Context{
kitInstance.Extensions().SetContext(extensions.Context{
CWD: cwd,
Model: modelName,
Interactive: positionalPrompt == "",
@@ -824,28 +842,28 @@ func runNormalMode(ctx context.Context) error {
CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) },
Exit: func() { appInstance.QuitFromExtension() },
SetWidget: func(config extensions.WidgetConfig) {
kitInstance.SetExtensionWidget(config)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().SetWidget(config)
go appInstance.NotifyWidgetUpdate()
},
RemoveWidget: func(id string) {
kitInstance.RemoveExtensionWidget(id)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().RemoveWidget(id)
go appInstance.NotifyWidgetUpdate()
},
SetHeader: func(config extensions.HeaderFooterConfig) {
kitInstance.SetExtensionHeader(config)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().SetHeader(config)
go appInstance.NotifyWidgetUpdate()
},
RemoveHeader: func() {
kitInstance.RemoveExtensionHeader()
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().RemoveHeader()
go appInstance.NotifyWidgetUpdate()
},
SetFooter: func(config extensions.HeaderFooterConfig) {
kitInstance.SetExtensionFooter(config)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().SetFooter(config)
go appInstance.NotifyWidgetUpdate()
},
RemoveFooter: func() {
kitInstance.RemoveExtensionFooter()
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().RemoveFooter()
go appInstance.NotifyWidgetUpdate()
},
PromptSelect: func(config extensions.PromptSelectConfig) extensions.PromptSelectResult {
ch := make(chan app.PromptResponse, 1)
@@ -895,8 +913,8 @@ func runNormalMode(ctx context.Context) error {
return extensions.PromptInputResult{Value: resp.Value}
},
SetUIVisibility: func(v extensions.UIVisibility) {
kitInstance.SetExtensionUIVisibility(v)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().SetUIVisibility(v)
go appInstance.NotifyWidgetUpdate()
},
GetContextStats: func() extensions.ContextStats {
s := kitInstance.GetContextStats()
@@ -908,53 +926,52 @@ func runNormalMode(ctx context.Context) error {
}
},
SetEditor: func(config extensions.EditorConfig) {
kitInstance.SetExtensionEditor(config)
// Use a goroutine for NotifyWidgetUpdate because this may be
// called from within an editor HandleKey callback, which runs
// synchronously inside BubbleTea's Update(). Calling prog.Send()
// directly from Update() deadlocks the event loop.
kitInstance.Extensions().SetEditor(config)
// Always use a goroutine for NotifyWidgetUpdate: prog.Send()
// deadlocks if called synchronously from inside BubbleTea's
// Update() handler. All call sites use go-routines uniformly.
go appInstance.NotifyWidgetUpdate()
},
ResetEditor: func() {
kitInstance.ResetExtensionEditor()
kitInstance.Extensions().ResetEditor()
go appInstance.NotifyWidgetUpdate()
},
GetMessages: func() []extensions.SessionMessage {
return kitInstance.GetSessionMessages()
return kitInstance.Extensions().GetSessionMessages()
},
GetSessionPath: func() string {
return kitInstance.GetSessionFilePath()
return kitInstance.GetSessionPath()
},
AppendEntry: func(entryType string, data string) (string, error) {
return kitInstance.AppendExtensionEntry(entryType, data)
return kitInstance.Extensions().AppendEntry(entryType, data)
},
GetEntries: func(entryType string) []extensions.ExtensionEntry {
return kitInstance.GetExtensionEntries(entryType)
return kitInstance.Extensions().GetEntries(entryType)
},
SetEditorText: func(text string) {
appInstance.SetEditorTextFromExtension(text)
},
SetStatus: func(key string, text string, priority int) {
kitInstance.SetExtensionStatus(extensions.StatusBarEntry{
kitInstance.Extensions().SetStatus(extensions.StatusBarEntry{
Key: key,
Text: text,
Priority: priority,
})
appInstance.NotifyWidgetUpdate()
go appInstance.NotifyWidgetUpdate()
},
RemoveStatus: func(key string) {
kitInstance.RemoveExtensionStatus(key)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().RemoveStatus(key)
go appInstance.NotifyWidgetUpdate()
},
GetOption: func(name string) string {
return kitInstance.GetExtensionOption(name)
return kitInstance.Extensions().GetOption(name)
},
SetOption: func(name string, value string) {
kitInstance.SetExtensionOption(name, value)
kitInstance.Extensions().SetOption(name, value)
},
SetModel: func(modelString string) error {
// Capture previous model for the ModelChange event.
previousModel := kitInstance.GetExtensionContext().Model
previousModel := kitInstance.Extensions().GetContext().Model
err := kitInstance.SetModel(context.Background(), modelString)
if err != nil {
return err
@@ -963,9 +980,9 @@ func runNormalMode(ctx context.Context) error {
p, m, _ := models.ParseModelString(modelString)
appInstance.NotifyModelChanged(p, m)
// Update the context's Model field so handlers see it.
kitInstance.UpdateExtensionContextModel(modelString)
kitInstance.Extensions().UpdateContextModel(modelString)
// Fire OnModelChange event to extensions.
kitInstance.EmitModelChange(modelString, previousModel, "extension")
kitInstance.Extensions().EmitModelChange(modelString, previousModel, "extension")
// Update usage tracker with new model info for correct token counting.
if usageTracker != nil {
newProvider, newModel, _ := models.ParseModelString(modelString)
@@ -990,7 +1007,7 @@ func runNormalMode(ctx context.Context) error {
return kitInstance.GetAvailableModels()
},
EmitCustomEvent: func(name string, data string) {
kitInstance.EmitExtensionCustomEvent(name, data)
kitInstance.Extensions().EmitCustomEvent(name, data)
},
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
return kitInstance.ExecuteCompletion(context.Background(), req)
@@ -999,7 +1016,7 @@ func runNormalMode(ctx context.Context) error {
return appInstance.SuspendTUI(callback)
},
RenderMessage: func(rendererName, content string) {
renderer := kitInstance.GetExtensionMessageRenderer(rendererName)
renderer := kitInstance.Extensions().GetMessageRenderer(rendererName)
if renderer == nil || renderer.Render == nil {
appInstance.PrintFromExtension("", content)
return
@@ -1012,19 +1029,19 @@ func runNormalMode(ctx context.Context) error {
appInstance.PrintFromExtension("", rendered)
},
ReloadExtensions: func() error {
err := kitInstance.ReloadExtensions()
err := kitInstance.Extensions().Reload()
if err != nil {
return err
}
// Notify TUI that widgets/status/commands may have changed.
appInstance.NotifyWidgetUpdate()
go appInstance.NotifyWidgetUpdate()
return nil
},
GetAllTools: func() []extensions.ToolInfo {
return kitInstance.GetExtensionToolInfos()
return kitInstance.Extensions().GetToolInfos()
},
SetActiveTools: func(names []string) {
kitInstance.SetExtensionActiveTools(names)
kitInstance.Extensions().SetActiveTools(names)
},
RegisterTheme: func(name string, config extensions.ThemeColorConfig) {
tc := func(c extensions.ThemeColor) [2]string { return [2]string{c.Light, c.Dark} }
@@ -1095,7 +1112,7 @@ func runNormalMode(ctx context.Context) error {
}
extResult := &extensions.SubagentResult{
Response: result.Response,
Error: result.Error,
Error: err,
SessionID: result.SessionID,
Elapsed: result.Elapsed,
}
@@ -1149,16 +1166,19 @@ func runNormalMode(ctx context.Context) error {
GetChildren: kitInstance.GetChildren,
NavigateTo: func(entryID string) extensions.TreeNavigationResult {
err := kitInstance.NavigateTo(entryID)
if err != "" {
return extensions.TreeNavigationResult{Success: false, Error: err}
if err != nil {
return extensions.TreeNavigationResult{Success: false, Error: err.Error()}
}
return extensions.TreeNavigationResult{Success: true}
},
SummarizeBranch: kitInstance.SummarizeBranch,
SummarizeBranch: func(fromID, toID string) string {
summary, _ := kitInstance.SummarizeBranch(fromID, toID)
return summary
},
CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult {
err := kitInstance.CollapseBranch(fromID, toID, summary)
if err != "" {
return extensions.TreeNavigationResult{Success: false, Error: err}
if err != nil {
return extensions.TreeNavigationResult{Success: false, Error: err.Error()}
}
return extensions.TreeNavigationResult{Success: true}
},
@@ -1207,10 +1227,10 @@ func runNormalMode(ctx context.Context) error {
ParseArguments: kit.ParseArguments,
SimpleParseArguments: kit.SimpleParseArguments,
EvaluateModelConditional: func(condition string) bool {
return kit.EvaluateModelConditional(kitInstance.GetExtensionContext().Model, condition)
return kit.EvaluateModelConditional(kitInstance.Extensions().GetContext().Model, condition)
},
RenderWithModelConditionals: func(content string) string {
return kit.RenderWithModelConditionals(content, kitInstance.GetExtensionContext().Model)
return kit.RenderWithModelConditionals(content, kitInstance.Extensions().GetContext().Model)
},
// -------------------------------------------------------------------------
@@ -1222,16 +1242,16 @@ func runNormalMode(ctx context.Context) error {
},
CheckModelAvailable: kit.CheckModelAvailable,
GetCurrentProvider: func() string {
return kit.GetCurrentProvider(kitInstance.GetExtensionContext().Model)
return kit.GetCurrentProvider(kitInstance.Extensions().GetContext().Model)
},
GetCurrentModelID: func() string {
return kit.GetCurrentModelID(kitInstance.GetExtensionContext().Model)
return kit.GetCurrentModelID(kitInstance.Extensions().GetContext().Model)
},
})
kitInstance.EmitSessionStart()
kitInstance.Extensions().EmitSessionStart()
// Restore normal print functions for runtime use.
kitInstance.SetExtensionContext(extensions.Context{
kitInstance.Extensions().SetContext(extensions.Context{
CWD: cwd,
Model: modelName,
Interactive: positionalPrompt == "",
@@ -1243,28 +1263,28 @@ func runNormalMode(ctx context.Context) error {
CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) },
Exit: func() { appInstance.QuitFromExtension() },
SetWidget: func(config extensions.WidgetConfig) {
kitInstance.SetExtensionWidget(config)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().SetWidget(config)
go appInstance.NotifyWidgetUpdate()
},
RemoveWidget: func(id string) {
kitInstance.RemoveExtensionWidget(id)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().RemoveWidget(id)
go appInstance.NotifyWidgetUpdate()
},
SetHeader: func(config extensions.HeaderFooterConfig) {
kitInstance.SetExtensionHeader(config)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().SetHeader(config)
go appInstance.NotifyWidgetUpdate()
},
RemoveHeader: func() {
kitInstance.RemoveExtensionHeader()
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().RemoveHeader()
go appInstance.NotifyWidgetUpdate()
},
SetFooter: func(config extensions.HeaderFooterConfig) {
kitInstance.SetExtensionFooter(config)
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().SetFooter(config)
go appInstance.NotifyWidgetUpdate()
},
RemoveFooter: func() {
kitInstance.RemoveExtensionFooter()
appInstance.NotifyWidgetUpdate()
kitInstance.Extensions().RemoveFooter()
go appInstance.NotifyWidgetUpdate()
},
PromptSelect: func(config extensions.PromptSelectConfig) extensions.PromptSelectResult {
ch := make(chan app.PromptResponse, 1)
@@ -1360,7 +1380,7 @@ func runNormalMode(ctx context.Context) error {
}
extResult := &extensions.SubagentResult{
Response: result.Response,
Error: result.Error,
Error: err,
SessionID: result.SessionID,
Elapsed: result.Elapsed,
}
@@ -1414,16 +1434,19 @@ func runNormalMode(ctx context.Context) error {
GetChildren: kitInstance.GetChildren,
NavigateTo: func(entryID string) extensions.TreeNavigationResult {
err := kitInstance.NavigateTo(entryID)
if err != "" {
return extensions.TreeNavigationResult{Success: false, Error: err}
if err != nil {
return extensions.TreeNavigationResult{Success: false, Error: err.Error()}
}
return extensions.TreeNavigationResult{Success: true}
},
SummarizeBranch: kitInstance.SummarizeBranch,
SummarizeBranch: func(fromID, toID string) string {
summary, _ := kitInstance.SummarizeBranch(fromID, toID)
return summary
},
CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult {
err := kitInstance.CollapseBranch(fromID, toID, summary)
if err != "" {
return extensions.TreeNavigationResult{Success: false, Error: err}
if err != nil {
return extensions.TreeNavigationResult{Success: false, Error: err.Error()}
}
return extensions.TreeNavigationResult{Success: true}
},
@@ -1472,10 +1495,10 @@ func runNormalMode(ctx context.Context) error {
ParseArguments: kit.ParseArguments,
SimpleParseArguments: kit.SimpleParseArguments,
EvaluateModelConditional: func(condition string) bool {
return kit.EvaluateModelConditional(kitInstance.GetExtensionContext().Model, condition)
return kit.EvaluateModelConditional(kitInstance.Extensions().GetContext().Model, condition)
},
RenderWithModelConditionals: func(content string) string {
return kit.RenderWithModelConditionals(content, kitInstance.GetExtensionContext().Model)
return kit.RenderWithModelConditionals(content, kitInstance.Extensions().GetContext().Model)
},
// -------------------------------------------------------------------------
@@ -1487,10 +1510,10 @@ func runNormalMode(ctx context.Context) error {
},
CheckModelAvailable: kit.CheckModelAvailable,
GetCurrentProvider: func() string {
return kit.GetCurrentProvider(kitInstance.GetExtensionContext().Model)
return kit.GetCurrentProvider(kitInstance.Extensions().GetContext().Model)
},
GetCurrentModelID: func() string {
return kit.GetCurrentModelID(kitInstance.GetExtensionContext().Model)
return kit.GetCurrentModelID(kitInstance.Extensions().GetContext().Model)
},
})
}
@@ -1560,7 +1583,7 @@ func runNormalMode(ctx context.Context) error {
return err
}
// Update the extension context's Model field so handlers see it.
kitInstance.UpdateExtensionContextModel(modelString)
kitInstance.Extensions().UpdateContextModel(modelString)
// NOTE: We do NOT call appInstance.NotifyModelChanged() here because
// this callback runs synchronously inside BubbleTea's Update(), and
// NotifyModelChanged calls prog.Send() which deadlocks. The UI layer
@@ -1586,7 +1609,7 @@ func runNormalMode(ctx context.Context) error {
return nil
}
emitModelChangeForUI := func(newModel, previousModel, source string) {
kitInstance.EmitModelChange(newModel, previousModel, source)
kitInstance.Extensions().EmitModelChange(newModel, previousModel, source)
}
// Build thinking level callback.
@@ -1727,7 +1750,7 @@ func buildJSONOutput(result *kit.TurnResult, model string) ([]byte, error) {
}
for _, fmsg := range result.Messages {
converted := kit.ConvertFromFantasyMessage(fmsg)
converted := kit.ConvertFromLLMMessage(fmsg)
m := jsonMessage{Role: string(converted.Role)}
for _, p := range converted.Parts {
switch c := p.(type) {
@@ -1817,7 +1840,9 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
ShowSessionPicker: resumeFlag,
})
// Print startup info to stdout before Bubble Tea takes over the screen.
// Print KIT banner and startup info to stdout before Bubble Tea takes over the screen.
fmt.Println(kitBanner())
fmt.Println()
appModel.PrintStartupInfo()
// Print any extension messages that were captured during startup.
@@ -0,0 +1,27 @@
package main
import (
"testing"
"github.com/mark3labs/kit/pkg/extensions/test"
)
// TestAllExtensions_Load is a smoke test that verifies every single-file
// example extension in this directory can be loaded by the Yaegi interpreter
// without errors. This catches syntax errors, missing symbols, bad imports,
// and Init signature mismatches.
func TestAllExtensions_Load(t *testing.T) {
files := extensionFiles(t)
for _, file := range files {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
ext := harness.LoadFile(file)
if ext == nil {
t.Fatalf("%s: extension should not be nil after loading", file)
}
})
}
t.Logf("successfully loaded %d extensions", len(files))
}
@@ -0,0 +1,253 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/pkg/extensions/test"
)
// extensionFiles returns all single-file extensions in the current directory.
// It skips test files, the test template, and files without an Init function.
func extensionFiles(t *testing.T) []string {
t.Helper()
skip := map[string]bool{
"extension_test_template.go": true,
}
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("failed to read directory: %v", err)
}
var files []string
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || filepath.Ext(name) != ".go" {
continue
}
if strings.HasSuffix(name, "_test.go") || skip[name] {
continue
}
src, err := os.ReadFile(name)
if err != nil {
t.Fatalf("failed to read %s: %v", name, err)
}
if !strings.Contains(string(src), "func Init(") {
continue
}
files = append(files, name)
}
if len(files) == 0 {
t.Fatal("no extensions found — check the directory")
}
return files
}
// TestAllExtensions_Lifecycle verifies that every extension survives a full
// SessionStart → SessionShutdown round-trip without errors.
func TestAllExtensions_Lifecycle(t *testing.T) {
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
_, err := harness.Emit(extensions.SessionStartEvent{
SessionID: "smoke-test-session",
})
if err != nil {
t.Fatalf("SessionStart error: %v", err)
}
_, err = harness.Emit(extensions.SessionShutdownEvent{})
if err != nil {
t.Fatalf("SessionShutdown error: %v", err)
}
})
}
}
// TestAllExtensions_CommandSanity checks that every registered command has
// a non-empty name, a non-empty description, no spaces in the name, no
// leading slash, a non-nil Execute function, and no duplicate names.
func TestAllExtensions_CommandSanity(t *testing.T) {
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
cmds := harness.RegisteredCommands()
seen := make(map[string]bool)
for _, cmd := range cmds {
if cmd.Name == "" {
t.Error("command has empty name")
}
if strings.Contains(cmd.Name, " ") {
t.Errorf("command %q contains spaces", cmd.Name)
}
if strings.HasPrefix(cmd.Name, "/") {
t.Errorf("command %q has leading slash (framework adds it)", cmd.Name)
}
if cmd.Description == "" {
t.Errorf("command %q has empty description", cmd.Name)
}
if cmd.Execute == nil {
t.Errorf("command %q has nil Execute function", cmd.Name)
}
if seen[cmd.Name] {
t.Errorf("duplicate command name %q", cmd.Name)
}
seen[cmd.Name] = true
}
})
}
}
// TestAllExtensions_ToolSanity checks that every registered tool has a
// non-empty name, a non-empty description, at least one executor, valid
// JSON in its Parameters field, and no duplicate names.
func TestAllExtensions_ToolSanity(t *testing.T) {
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
tools := harness.RegisteredTools()
seen := make(map[string]bool)
for _, tool := range tools {
if tool.Name == "" {
t.Error("tool has empty name")
}
if tool.Description == "" {
t.Errorf("tool %q has empty description", tool.Name)
}
if tool.Execute == nil && tool.ExecuteWithContext == nil {
t.Errorf("tool %q has no executor (both Execute and ExecuteWithContext are nil)", tool.Name)
}
if tool.Parameters != "" && !json.Valid([]byte(tool.Parameters)) {
t.Errorf("tool %q has invalid JSON in Parameters: %s", tool.Name, tool.Parameters)
}
if seen[tool.Name] {
t.Errorf("duplicate tool name %q", tool.Name)
}
seen[tool.Name] = true
}
})
}
}
// TestAllExtensions_ZeroValueEvents fires every event type (as zero-value
// structs) at each extension and verifies no errors are returned. Extensions
// should be resilient to events they don't handle and to events with empty
// fields.
func TestAllExtensions_ZeroValueEvents(t *testing.T) {
// Build the set of zero-value events for every event type.
zeroEvents := []extensions.Event{
extensions.ToolCallEvent{},
extensions.ToolExecutionStartEvent{},
extensions.ToolExecutionEndEvent{},
extensions.ToolOutputEvent{},
extensions.ToolResultEvent{},
extensions.InputEvent{},
extensions.BeforeAgentStartEvent{},
extensions.AgentStartEvent{},
extensions.AgentEndEvent{},
extensions.MessageStartEvent{},
extensions.MessageUpdateEvent{},
extensions.MessageEndEvent{},
extensions.SessionStartEvent{},
extensions.SessionShutdownEvent{},
extensions.ModelChangeEvent{},
extensions.ContextPrepareEvent{},
extensions.BeforeForkEvent{},
extensions.BeforeSessionSwitchEvent{},
extensions.BeforeCompactEvent{},
extensions.SubagentStartEvent{},
extensions.SubagentChunkEvent{},
extensions.SubagentEndEvent{},
}
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
for _, ev := range zeroEvents {
_, err := harness.Emit(ev)
if err != nil {
t.Errorf("event %T returned error: %v", ev, err)
}
}
})
}
}
// TestAllExtensions_WidgetSanity emits SessionStart and then checks that
// any widgets set during initialization have non-empty IDs and valid
// placements.
func TestAllExtensions_WidgetSanity(t *testing.T) {
validPlacements := map[extensions.WidgetPlacement]bool{
"above": true,
"below": true,
}
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
// Trigger SessionStart so extensions that set widgets on init do so.
_, _ = harness.Emit(extensions.SessionStartEvent{
SessionID: "widget-sanity-test",
})
// Widgets is an exported field on MockContext; reads are safe
// here because Emit returned synchronously.
for id, w := range harness.Context().Widgets {
if w.ID == "" {
t.Errorf("widget stored with key %q has empty ID", id)
}
if w.ID != id {
t.Errorf("widget key %q doesn't match widget ID %q", id, w.ID)
}
if !validPlacements[w.Placement] {
t.Errorf("widget %q has invalid placement %q (want \"above\" or \"below\")", id, w.Placement)
}
}
})
}
}
// TestAllExtensions_IdempotentLifecycle verifies that receiving SessionStart
// twice and SessionShutdown twice doesn't cause errors — extensions should
// be defensive about repeated lifecycle events.
func TestAllExtensions_IdempotentLifecycle(t *testing.T) {
for _, file := range extensionFiles(t) {
t.Run(file, func(t *testing.T) {
harness := test.New(t)
harness.LoadFile(file)
for i := range 2 {
_, err := harness.Emit(extensions.SessionStartEvent{
SessionID: "idempotent-test",
})
if err != nil {
t.Fatalf("SessionStart #%d error: %v", i+1, err)
}
}
for i := range 2 {
_, err := harness.Emit(extensions.SessionShutdownEvent{})
if err != nil {
t.Fatalf("SessionShutdown #%d error: %v", i+1, err)
}
}
})
}
}
+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 "spawn_subagent":
case "subagent":
return "spawning subagent"
default:
return "using " + toolName
+2 -4
View File
@@ -2,9 +2,7 @@
// lsp-diagnostics.go — LSP-powered diagnostics for Kit's edit tool.
//
// Starts language servers on demand and surfaces diagnostics after file edits,
// following the same pattern used by Charm's crush editor:
//
// Starts language servers on demand and surfaces diagnostics after file edits:
// 1. After an edit, notify the LSP server of the file change
// 2. Wait for the server to publish fresh diagnostics
// 3. Append diagnostic output to the edit tool's result
@@ -412,7 +410,7 @@ func (c *lspClient) changeFile(absPath, content string) {
}
// waitForDiagnostics polls until the server publishes new diagnostics or
// the timeout elapses. Mirrors crush's WaitForDiagnostics pattern.
// the timeout elapses.
func (c *lspClient) waitForDiagnostics(timeout time.Duration) {
c.diagMu.Lock()
startVersion := c.diagVersion
+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 spawn_subagent tool.")
"The LLM can also use the subagent tool.")
})
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
+22 -27
View File
@@ -3,7 +3,7 @@ module github.com/mark3labs/kit
go 1.26.1
require (
charm.land/bubbles/v2 v2.0.0
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.2
charm.land/fantasy v0.17.1
charm.land/huh/v2 v2.0.3
@@ -13,6 +13,8 @@ require (
github.com/charmbracelet/fang v1.0.0
github.com/charmbracelet/log v1.0.0
github.com/coder/acp-go-sdk v0.6.3
github.com/indaco/herald v0.10.0
github.com/indaco/herald-md v0.1.0
github.com/mark3labs/mcp-go v0.46.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
@@ -29,22 +31,21 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.13 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.13 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect
@@ -52,11 +53,11 @@ require (
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260323091123-df7b1bcffcca // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260330094520-2dce04b6f8a4 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260330094520-2dce04b6f8a4 // indirect
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
github.com/charmbracelet/x/json v0.2.0 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
@@ -80,19 +81,15 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
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/go-i18n v0.3.0 // indirect
github.com/kaptinlin/jsonpointer v0.4.17 // indirect
github.com/kaptinlin/jsonschema v0.7.6 // indirect
github.com/kaptinlin/messageformat-go v0.4.18 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/kaptinlin/jsonschema v0.7.7 // indirect
github.com/kaptinlin/messageformat-go v0.4.19 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/mango v0.2.0 // indirect
github.com/muesli/mango-cobra v1.3.0 // indirect
github.com/muesli/mango-pflag v0.2.0 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
@@ -106,7 +103,6 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
@@ -120,7 +116,7 @@ require (
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.273.0 // indirect
google.golang.org/genai v1.51.0 // indirect
google.golang.org/genai v1.52.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
@@ -129,11 +125,10 @@ require (
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/x/ansi v0.11.6
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
+44 -57
View File
@@ -1,5 +1,5 @@
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/fantasy v0.17.1 h1:SQzfnyJPDuQWt6e//KKmQmEEXdqHMC0IZz10XwkLcEM=
@@ -34,42 +34,40 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
github.com/aws/aws-sdk-go-v2/config v1.32.13 h1:5KgbxMaS2coSWRrx9TX/QtWbqzgQkOdEa3sZPhBhCSg=
github.com/aws/aws-sdk-go-v2/config v1.32.13/go.mod h1:8zz7wedqtCbw5e9Mi2doEwDyEgHcEE9YOJp6a8jdSMY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.13 h1:mA59E3fokBvyEGHKFdnpNNrvaR351cqiHgRg+JzOSRI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.13/go.mod h1:yoTXOQKea18nrM69wGF9jBdG4WocSZA1h38A+t/MAsk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 h1:GcLE9ba5ehAQma6wlopUesYg/hbcOhFNWTjELkiWkh4=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 h1:mP49nTpfKtpXLt5SLn8Uv8z6W+03jYVoOSAl/c02nog=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -80,8 +78,6 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
@@ -90,8 +86,8 @@ github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdR
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 h1:BW/sZtyd1JyYy0h5adMm3tzpNyL857LWjuTRET6OhpY=
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502 h1:hzWNs3UQRSUTS6YCbLaQnwqKBFXT5Yh1OOw6+26apqg=
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502/go.mod h1:mkUCcxn9w9j89JJp3pOza5tmDQZPgIB75UfmQlFYvas=
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b h1:ASDO9RT6SNKTQN87jO2bRfxHFJq8cgeYdFzivY2gCeM=
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b/go.mod h1:Vo8TffMf0q7Uho/n8e6XpBZvOWtd3g39yX+9P5rRutA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
@@ -100,14 +96,14 @@ github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260323091123-df7b1bcffcca h1:62yAoS1Ynbuzwcn1LkNBxi3IMF5p0E0cHCoaLOOmN9w=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260323091123-df7b1bcffcca/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260330094520-2dce04b6f8a4 h1:pIj18ZCZO4WOVj7jwjLoUb1lC7rS/I8oC3fZWXugNaY=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260330094520-2dce04b6f8a4/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca h1:QQoyQLgUzojMNWHVHToN6d9qTvT0KWtxUKIRPx/Ox5o=
github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/slice v0.0.0-20260330094520-2dce04b6f8a4 h1:VSd4zShIAf/4FgEDFJpapEcAPrc7h3dyyN7V9JlJpQw=
github.com/charmbracelet/x/exp/slice v0.0.0-20260330094520-2dce04b6f8a4/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
@@ -179,41 +175,38 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.20.0 h1:NIKVuLhDlIV74muWlsMM4CcQZqN6JJ20Qcxd9YMuYcs=
github.com/googleapis/gax-go/v2 v2.20.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
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/indaco/herald v0.10.0 h1:XzahEKX6cr50qZQrUdA3QrQBHg8uGm5jETD0UDi21BI=
github.com/indaco/herald v0.10.0/go.mod h1:T5g1+XLYvpjouhzAGHnAHDCKizhESkoV6+QPZ3DhgWA=
github.com/indaco/herald-md v0.1.0 h1:zmYudYo+uamzKTBcIffJVJYrqk9xDNnVrTh+de2zciw=
github.com/indaco/herald-md v0.1.0/go.mod h1:Z1HxPCbSn+/+TFzOM/UbsmKeEk/28NNI6JOTileKXto=
github.com/kaptinlin/go-i18n v0.3.0 h1:wP76dvYg04bvwTb+8NB+CmdZ2kL7lSSCQ9B/kFv7QHo=
github.com/kaptinlin/go-i18n v0.3.0/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
github.com/kaptinlin/jsonpointer v0.4.17 h1:mY9k8ciWncxbsECyaxKnR0MdmxamNdp2tLQkAKVrtSk=
github.com/kaptinlin/jsonpointer v0.4.17/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU=
github.com/kaptinlin/jsonschema v0.7.6 h1:UUMqZGFAk7nOzQsYAxvgygm4wpDp/nwXxA4VP9mCPCs=
github.com/kaptinlin/jsonschema v0.7.6/go.mod h1:GGk/oE+F1lWUfYrzKaCf4QWZmMdytt0LL4XdFEFB0LE=
github.com/kaptinlin/messageformat-go v0.4.18 h1:RBlHVWgZyoxTcUgGWBsl2AcyScq/urqbLZvzgryTmSI=
github.com/kaptinlin/messageformat-go v0.4.18/go.mod h1:ntI3154RnqJgr7GaC+vZBnIExl2V3sv9selvRNNEM24=
github.com/kaptinlin/jsonschema v0.7.7 h1:41BlQJ9dskH0oE5DSzBUrl/w4JQYIr6N6L0B5GNyDoM=
github.com/kaptinlin/jsonschema v0.7.7/go.mod h1:rKjWfyySHSxAD7Li2ctYkPlOu960igoKBvZ2ADRtd5Q=
github.com/kaptinlin/messageformat-go v0.4.19 h1:A5kuuZ1ybXDQ7kD1aoEWGAOemX7hLsMY0yolgSbgpRI=
github.com/kaptinlin/messageformat-go v0.4.19/go.mod h1:utSDTfiXTxl66OC5RIEuObLH7Ue3YjbA2X86SYMBYWg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mark3labs/mcp-go v0.46.0 h1:8KRibF4wcKejbLsHxCA/QBVUr5fQ9nwz/n8lGqmaALo=
github.com/mark3labs/mcp-go v0.46.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -224,8 +217,6 @@ github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8p
github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E=
github.com/muesli/mango-pflag v0.2.0 h1:QViokgKDZQCzKhYe1zH8D+UlPJzBSGoP9yx0hBG0t5k=
github.com/muesli/mango-pflag v0.2.0/go.mod h1:X9LT1p/pbGA1wjvEbtwnixujKErkP0jVmrxwrw3fL0Y=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
@@ -238,8 +229,6 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -281,8 +270,6 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
@@ -324,8 +311,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ=
google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
google.golang.org/genai v1.51.0 h1:IZGuUqgfx40INv3hLFGCbOSGp0qFqm7LVmDghzNIYqg=
google.golang.org/genai v1.51.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genai v1.52.0 h1:ekVIxWHtLUNbt+v0WWi4j3JT4yrHDEbysMcHQcaCQoI=
google.golang.org/genai v1.52.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
+18 -18
View File
@@ -62,8 +62,8 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
// work in ACP mode. TUI-dependent features (widgets, prompts, editor)
// become no-ops or return cancelled; all data/model/tool APIs work
// identically to interactive mode.
if kitInstance.HasExtensions() {
kitInstance.SetExtensionContext(extensions.Context{
if kitInstance.Extensions().HasExtensions() {
kitInstance.Extensions().SetContext(extensions.Context{
SessionID: sessionID,
CWD: cwd,
Model: kitInstance.GetModelString(),
@@ -121,31 +121,31 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
MessageCount: s.MessageCount,
}
},
GetMessages: func() []extensions.SessionMessage { return kitInstance.GetSessionMessages() },
GetSessionPath: func() string { return kitInstance.GetSessionFilePath() },
GetMessages: func() []extensions.SessionMessage { return kitInstance.Extensions().GetSessionMessages() },
GetSessionPath: func() string { return kitInstance.GetSessionPath() },
AppendEntry: func(entryType, data string) (string, error) {
return kitInstance.AppendExtensionEntry(entryType, data)
return kitInstance.Extensions().AppendEntry(entryType, data)
},
GetEntries: func(entryType string) []extensions.ExtensionEntry {
return kitInstance.GetExtensionEntries(entryType)
return kitInstance.Extensions().GetEntries(entryType)
},
// Options, model, and tool management.
GetOption: func(name string) string { return kitInstance.GetExtensionOption(name) },
SetOption: func(name, value string) { kitInstance.SetExtensionOption(name, value) },
GetOption: func(name string) string { return kitInstance.Extensions().GetOption(name) },
SetOption: func(name, value string) { kitInstance.Extensions().SetOption(name, value) },
SetModel: func(modelString string) error {
previousModel := kitInstance.GetExtensionContext().Model
previousModel := kitInstance.Extensions().GetContext().Model
if err := kitInstance.SetModel(context.Background(), modelString); err != nil {
return err
}
kitInstance.UpdateExtensionContextModel(modelString)
kitInstance.EmitModelChange(modelString, previousModel, "extension")
kitInstance.Extensions().UpdateContextModel(modelString)
kitInstance.Extensions().EmitModelChange(modelString, previousModel, "extension")
return nil
},
GetAvailableModels: func() []extensions.ModelInfoEntry { return kitInstance.GetAvailableModels() },
EmitCustomEvent: func(name, data string) { kitInstance.EmitExtensionCustomEvent(name, data) },
GetAllTools: func() []extensions.ToolInfo { return kitInstance.GetExtensionToolInfos() },
SetActiveTools: func(names []string) { kitInstance.SetExtensionActiveTools(names) },
EmitCustomEvent: func(name, data string) { kitInstance.Extensions().EmitCustomEvent(name, data) },
GetAllTools: func() []extensions.ToolInfo { return kitInstance.Extensions().GetToolInfos() },
SetActiveTools: func(names []string) { kitInstance.Extensions().SetActiveTools(names) },
// LLM completions and subagents.
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
@@ -173,7 +173,7 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
}
extResult := &extensions.SubagentResult{
Response: result.Response,
Error: result.Error,
Error: err,
SessionID: result.SessionID,
Elapsed: result.Elapsed,
}
@@ -188,15 +188,15 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
// Render — fall back to logging.
RenderMessage: func(name, content string) {
renderer := kitInstance.GetExtensionMessageRenderer(name)
renderer := kitInstance.Extensions().GetMessageRenderer(name)
if renderer != nil && renderer.Render != nil {
content = renderer.Render(content, 80)
}
log.Info("extension: message", "renderer", name, "content", content)
},
ReloadExtensions: func() error { return kitInstance.ReloadExtensions() },
ReloadExtensions: func() error { return kitInstance.Extensions().Reload() },
})
kitInstance.EmitSessionStart()
kitInstance.Extensions().EmitSessionStart()
}
sess := &acpSession{
+33 -28
View File
@@ -31,7 +31,7 @@ type AgentConfig struct {
CoreTools []fantasy.AgentTool
// ToolWrapper is an optional function that wraps the combined tool list
// before it is passed to the Fantasy agent. Used by the extensions system
// before it is passed to the LLM agent. Used by the extensions system
// to intercept tool calls/results.
ToolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool
@@ -75,9 +75,9 @@ type ToolOutputHandler = core.ToolOutputCallback
// tracking during long-running tool-calling conversations.
type StepUsageHandler func(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64)
// Agent represents an AI agent with core tool integration using the fantasy library.
// Agent represents an AI agent with core tool integration using the LLM library.
// Core tools (bash, read, write, edit, grep, find, ls) are registered as direct
// fantasy.AgentTool implementations — no MCP layer, no serialization overhead.
// AgentTool implementations — no MCP layer, no serialization overhead.
// Additional tools from external MCP servers can be loaded alongside core tools.
type Agent struct {
toolManager *tools.MCPToolManager
@@ -100,7 +100,7 @@ type GenerateWithLoopResult struct {
FinalResponse *fantasy.Response
// ConversationMessages contains all messages in the conversation including tool calls and results
ConversationMessages []fantasy.Message
// Messages contains the conversation as custom content blocks (crush-style)
// Messages contains the conversation as custom content blocks
Messages []message.Message
// TotalUsage contains aggregate token usage across all steps
TotalUsage fantasy.Usage
@@ -112,13 +112,13 @@ type GenerateWithLoopResult struct {
// Core tools (bash, read, write, edit, grep, find, ls) are always registered.
// External MCP tools are loaded from the config if any MCP servers are configured.
func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
// Create the LLM provider via fantasy
// Create the LLM provider
providerResult, err := models.CreateProvider(ctx, agentConfig.ModelConfig)
if err != nil {
return nil, fmt.Errorf("failed to create model provider: %v", err)
}
// Register core tools (direct fantasy implementations, no MCP overhead).
// Register core tools (direct AgentTool implementations, no MCP overhead).
// Use caller-provided tools if set, otherwise default to all core tools.
coreTools := agentConfig.CoreTools
if len(coreTools) == 0 {
@@ -158,7 +158,7 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
allTools = agentConfig.ToolWrapper(allTools)
}
// Build fantasy agent options
// Build agent options
var agentOpts []fantasy.AgentOption
if agentConfig.SystemPrompt != "" {
@@ -198,7 +198,7 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
}
}
// Create the fantasy agent
// Create the agent
fantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
// Determine provider type from model string
@@ -234,8 +234,8 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message
onResponse, onToolCallContent, nil, nil, nil, nil)
}
// GenerateWithLoopAndStreaming processes messages using the fantasy agent with streaming and callbacks.
// Fantasy handles the tool call loop internally. We map fantasy's rich callback system
// GenerateWithLoopAndStreaming processes messages using the agent with streaming and callbacks.
// The agent handles the tool call loop internally. We map the rich callback system
// to kit's existing callback interface for UI integration.
func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fantasy.Message,
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
@@ -251,18 +251,21 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
ctx = core.ContextWithToolOutputCallback(ctx, onToolOutput)
}
// Fantasy requires the current user input as Prompt, with prior messages as history.
// The agent requires the current user input as Prompt, with prior messages as history.
// Extract the last user message text and files as the prompt, and pass everything
// before it as Messages. Files (e.g. clipboard images) are passed via the Files
// field so Fantasy includes them in the API request.
// field so the agent includes them in the API request.
prompt, files, history := splitPromptAndHistory(messages)
// Track current tool call info for callbacks
var currentToolName string
// Apply message-level cache control for Anthropic models.
// This avoids type conflicts with provider-level options.
history = applyCacheControlToMessages(history)
// Track current tool call args for callbacks
var currentToolArgs string
// Use the streaming path when streaming is enabled OR when any callbacks are
// provided. Fantasy only exposes tool/step callbacks on AgentStreamCall, so
// provided. The agent only exposes tool/step callbacks on AgentStreamCall, so
// Stream is required to observe tool execution in real time. The non-streaming
// Generate path is reserved for the simple case with no callbacks at all.
hasCallbacks := onToolCall != nil || onToolExecution != nil || onToolResult != nil ||
@@ -270,12 +273,12 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
if a.streamingEnabled || hasCallbacks {
// Track completed step messages so we can return partial results
// on cancellation. Fantasy's Stream() discards accumulated steps
// on cancellation. The agent's Stream() discards accumulated steps
// when it returns an error, but the OnStepFinish callback fires
// for every step that completed before the error occurred.
var completedStepMessages []fantasy.Message
// Use fantasy's streaming agent
// Use the streaming agent
streamCall := fantasy.AgentStreamCall{
Prompt: prompt,
Files: files,
@@ -308,7 +311,6 @@ 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
@@ -405,6 +407,11 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
onConsumed(len(steered))
}
}
// Apply message-level cache control for Anthropic models.
// This avoids type conflicts with provider-level options.
result.Messages = applyCacheControlToMessages(result.Messages)
return stepCtx, result, nil
}
}
@@ -452,13 +459,11 @@ 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
}
// splitPromptAndHistory extracts the last user message as the prompt string,
// and returns everything before it as conversation history. Fantasy's agent
// and returns everything before it as conversation history. The agent's
// requires the current turn's input as Prompt (string), with prior messages
// passed separately as Messages (history).
func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.FilePart, []fantasy.Message) {
@@ -501,8 +506,8 @@ func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.FilePa
return "", nil, messages
}
// convertAgentResult converts a fantasy AgentResult to our GenerateWithLoopResult.
// It builds both the legacy fantasy.Message slice and the new custom content blocks.
// convertAgentResult converts an AgentResult to our GenerateWithLoopResult.
// It builds both the message slice and the new custom content blocks.
func convertAgentResult(result *fantasy.AgentResult, originalMessages []fantasy.Message) *GenerateWithLoopResult {
// Collect all conversation messages: original + all step messages
var allFantasyMessages []fantasy.Message
@@ -515,7 +520,7 @@ func convertAgentResult(result *fantasy.AgentResult, originalMessages []fantasy.
// Convert to custom content blocks
var allMessages []message.Message
for _, fm := range allFantasyMessages {
allMessages = append(allMessages, message.FromFantasyMessage(fm))
allMessages = append(allMessages, message.FromLLMMessage(fm))
}
return &GenerateWithLoopResult{
@@ -527,7 +532,7 @@ func convertAgentResult(result *fantasy.AgentResult, originalMessages []fantasy.
}
}
// extractToolResultText extracts the text and error status from a fantasy ToolResultContent.
// extractToolResultText extracts the text and error status from a ToolResultContent.
// For core tools, the result is already clean text (no MCP JSON wrapping).
// For MCP tools, it unwraps the MCP content structure.
func extractToolResultText(tr fantasy.ToolResultContent) (string, bool) {
@@ -540,7 +545,7 @@ func extractToolResultText(tr fantasy.ToolResultContent) (string, bool) {
return errResult.Error.Error(), true
}
// Get text directly from the Fantasy result type.
// Get text directly from the result type.
if textResult, ok := tr.Result.(fantasy.ToolResultOutputContentText); ok {
// Try to unwrap MCP JSON structure (for external MCP tools).
// Core tools return plain text, so this is a no-op for them.
@@ -653,7 +658,7 @@ func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) err
allTools = a.toolWrapper(allTools)
}
// Rebuild fantasy agent options.
// Rebuild agent options.
var agentOpts []fantasy.AgentOption
if a.systemPrompt != "" {
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(a.systemPrompt))
@@ -714,7 +719,7 @@ func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) err
return nil
}
// GetModel returns the underlying fantasy LanguageModel.
// GetModel returns the underlying LanguageModel.
func (a *Agent) GetModel() fantasy.LanguageModel {
return a.model
}
+84
View File
@@ -0,0 +1,84 @@
package agent
import (
"charm.land/fantasy"
"charm.land/fantasy/providers/anthropic"
)
// cacheControlOptions returns provider options for Anthropic cache control.
// This is used at the message level to avoid type conflicts with provider-level options.
func cacheControlOptions() fantasy.ProviderOptions {
return anthropic.NewProviderCacheControlOptions(&anthropic.ProviderCacheControlOptions{
CacheControl: anthropic.CacheControl{
Type: "ephemeral",
},
})
}
// applyCacheControlToMessages adds cache control to specific messages.
// Anthropic allows max 4 cache blocks per request.
// Counts existing cache blocks and only adds new ones up to the limit.
func applyCacheControlToMessages(messages []fantasy.Message) []fantasy.Message {
if len(messages) == 0 {
return messages
}
// Make a copy to avoid modifying the original slice
result := make([]fantasy.Message, len(messages))
copy(result, messages)
cacheOpts := cacheControlOptions()
maxCacheBlocks := 4
// Helper to check if message already has cache control
hasCache := func(msg fantasy.Message) bool {
if msg.ProviderOptions == nil {
return false
}
if _, ok := msg.ProviderOptions["anthropic"]; ok {
return true
}
return false
}
// Count existing cache blocks
existingCacheCount := 0
for _, msg := range result {
if hasCache(msg) {
existingCacheCount++
}
}
// If we're already at or over the limit, don't add more
if existingCacheCount >= maxCacheBlocks {
return result
}
// How many new cache blocks can we add?
remaining := maxCacheBlocks - existingCacheCount
// First: find and cache the last system message (most important)
lastSystemIdx := -1
for i, msg := range result {
if msg.Role == fantasy.MessageRoleSystem {
lastSystemIdx = i
}
}
if lastSystemIdx >= 0 && remaining > 0 && !hasCache(result[lastSystemIdx]) {
result[lastSystemIdx].ProviderOptions = cacheOpts
remaining--
}
// Second: cache the most recent messages (up to remaining limit)
// Work backwards from the end to prioritize recent context
for i := len(result) - 1; i >= 0 && remaining > 0; i-- {
if hasCache(result[i]) {
continue
}
result[i].ProviderOptions = cacheOpts
remaining--
}
return result
}
+1 -1
View File
@@ -39,7 +39,7 @@ type AgentCreationOptions struct {
// CoreTools overrides the default core tool set. If empty, core.AllTools()
// is used.
CoreTools []fantasy.AgentTool
// ToolWrapper wraps the combined tool list before Fantasy agent creation.
// ToolWrapper wraps the combined tool list before agent creation.
ToolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool
// ExtraTools are additional tools to include (e.g. from extensions).
ExtraTools []fantasy.AgentTool
+47 -6
View File
@@ -7,6 +7,7 @@ import (
"os"
"sync"
"sync/atomic"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
@@ -68,6 +69,15 @@ type App struct {
// rootCtx/rootCancel are used to signal shutdown to all goroutines.
rootCtx context.Context
rootCancel context.CancelFunc
// widgetUpdatePending is set to true when a WidgetUpdateEvent has been
// sent to the TUI but not yet consumed by its event loop. While the flag
// is set, subsequent NotifyWidgetUpdate calls are coalesced (dropped) to
// prevent fast extension tickers from flooding the BubbleTea mailbox with
// redundant re-render triggers. The flag is cleared after a short debounce
// (~1 frame) so new updates are always let through once the TUI has had a
// chance to process the pending event.
widgetUpdatePending atomic.Bool
}
// New creates a new App with the provided options and pre-loaded messages.
@@ -261,6 +271,17 @@ func (a *App) ClearMessages() {
}
}
// ReloadMessagesFromTree clears the in-memory message store and reloads it
// from the tree session's current branch. Unlike ClearMessages, this does NOT
// reset the tree session's leaf pointer. Used after Branch() to sync the
// store with the new branch position.
func (a *App) ReloadMessagesFromTree() {
a.store.Clear()
if a.opts.TreeSession != nil {
a.store.Replace(a.opts.TreeSession.GetLLMMessages())
}
}
// GetTreeSession returns the tree session manager, or nil if not configured.
func (a *App) GetTreeSession() *session.TreeManager {
return a.opts.TreeSession
@@ -282,7 +303,7 @@ func (a *App) SwitchTreeSession(ts *session.TreeManager) {
// Reload messages from new session.
a.store.Clear()
if ts != nil {
a.store.Replace(ts.GetFantasyMessages())
a.store.Replace(ts.GetLLMMessages())
}
}
@@ -298,7 +319,7 @@ func (a *App) AddContextMessage(text string) {
// Persist to tree session if active.
if ts := a.opts.TreeSession; ts != nil {
_, _ = ts.AppendFantasyMessage(msg)
_, _ = ts.AppendLLMMessage(msg)
}
}
@@ -348,7 +369,7 @@ func (a *App) CompactConversation(customInstructions string) error {
// Sync in-memory store with the compacted session.
if a.opts.TreeSession != nil {
a.store.Replace(a.opts.TreeSession.GetFantasyMessages())
a.store.Replace(a.opts.TreeSession.GetLLMMessages())
}
a.sendEvent(CompactCompleteEvent{
@@ -569,7 +590,7 @@ func (a *App) runQueueBatch(items []queueItem) {
// call/result pairs; only the in-progress message or tool
// call is discarded. Sync the in-memory store to match.
if ts := a.opts.TreeSession; ts != nil {
a.store.Replace(ts.GetFantasyMessages())
a.store.Replace(ts.GetLLMMessages())
}
a.sendEvent(StepCancelledEvent{})
return
@@ -685,8 +706,8 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(
messages = append(messages, item.Prompt)
}
// TODO: Handle file attachments in batch mode
// For now, files are ignored in batch mode (rare edge case)
// File attachments are not supported in batch mode; fall back to
// processing only the first item that carries files.
if hasFiles {
// If files exist, fall back to processing just the first item with files
for _, item := range items {
@@ -834,12 +855,32 @@ func (a *App) NotifyModelChanged(provider, model string) {
// NotifyWidgetUpdate sends a WidgetUpdateEvent to the TUI so it re-renders
// extension widgets. Called from the extension context's SetWidget/RemoveWidget
// closures. In non-interactive mode this is a no-op (widgets are TUI-only).
//
// Coalescing: if a WidgetUpdateEvent is already queued and not yet consumed
// by the TUI event loop, additional calls within the same ~16 ms window are
// dropped. This prevents fast extension tickers from flooding BubbleTea's
// mailbox with redundant re-render triggers.
func (a *App) NotifyWidgetUpdate() {
// Coalesce: only one pending update at a time.
if !a.widgetUpdatePending.CompareAndSwap(false, true) {
return
}
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(WidgetUpdateEvent{})
// Reset the pending flag after a short debounce so subsequent calls
// within the same render cycle are also coalesced, but new updates
// after the cycle are allowed through.
go func() {
time.Sleep(16 * time.Millisecond) // ~1 frame at 60 fps
a.widgetUpdatePending.Store(false)
}()
} else {
// No program registered (non-interactive mode); clear the flag so
// future calls are never permanently blocked.
a.widgetUpdatePending.Store(false)
}
}
+24 -16
View File
@@ -43,13 +43,30 @@ 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 {
if c.Type != "oauth" || c.ExpiresAt == 0 {
return false
}
return time.Now().Unix() >= c.ExpiresAt
return oauthTokenExpired(c.Type, c.ExpiresAt)
}
// NeedsRefresh checks if the OAuth token needs refresh, returning true if the token
@@ -57,19 +74,13 @@ 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 {
if c.Type != "oauth" || c.ExpiresAt == 0 {
return false
}
return time.Now().Unix() >= (c.ExpiresAt - 300) // 5 minutes buffer
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
}
// 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 {
if c.Type != "oauth" || c.ExpiresAt == 0 {
return false
}
return time.Now().Unix() >= c.ExpiresAt
return oauthTokenExpired(c.Type, c.ExpiresAt)
}
// NeedsRefresh checks if the OAuth token needs refresh, returning true if the token
@@ -77,10 +88,7 @@ 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 {
if c.Type != "oauth" || c.ExpiresAt == 0 {
return false
}
return time.Now().Unix() >= (c.ExpiresAt - 300) // 5 minutes buffer
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
}
// CredentialManager handles secure storage and retrieval of authentication credentials.
+1 -2
View File
@@ -403,10 +403,9 @@ func FilepathOr[T any](key string, value *T) error {
if err != nil {
return err
}
filepath.Join(home, absPath[2:])
absPath = 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 -18
View File
@@ -7,6 +7,7 @@ import (
"io"
"os"
"os/exec"
"regexp"
"strings"
"sync"
"time"
@@ -39,20 +40,8 @@ func toolOutputCallbackFromContext(ctx context.Context) ToolOutputCallback {
const defaultBashTimeout = 120 * time.Second
const maxBashTimeout = 600 * time.Second
var bannedCommands = []string{
"alias ", "bg ", "bind ", "builtin ",
"caller ", "command ", "compgen ",
"complete ", "compopt ", "coproc ",
"dirs ", "disown ", "enable ",
"fc ", "fg ", "hash ", "help ",
"history ", "jobs ", "kill ",
"logout ", "mapfile ", "popd ",
"pushd ", "readonly ", "select ",
"set ", "shopt ", "source ",
"suspend ", "times ", "trap ",
"type ", "typeset ", "ulimit ",
"umask ", "unalias ", "wait ",
}
// bannedCmdRe matches bash builtin commands that are not allowed for security reasons.
var bannedCmdRe = regexp.MustCompile(`^(alias|bg|bind|builtin|caller|command|compgen|complete|compopt|coproc|dirs|disown|enable|fc|fg|hash|help|history|jobs|kill|logout|mapfile|popd|pushd|readonly|select|set|shopt|source|suspend|times|trap|type|typeset|ulimit|umask|unalias|wait)\s`)
type bashArgs struct {
Command string `json:"command"`
@@ -94,10 +83,8 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
}
// Check for banned commands
for _, banned := range bannedCommands {
if strings.HasPrefix(args.Command, banned) {
return fantasy.NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", args.Command)), nil
}
if bannedCmdRe.MatchString(args.Command) {
return fantasy.NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", args.Command)), nil
}
// Determine timeout
+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 spawn_subagent
// The toolCallID parameter is the LLM-assigned ID of the 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
// spawn_subagent core tool can create in-process subagents.
// 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 {
}
// ---------------------------------------------------------------------------
// spawn_subagent tool
// subagent tool
// ---------------------------------------------------------------------------
type subagentArgs struct {
@@ -59,11 +59,11 @@ type subagentArgs struct {
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
}
// NewSubagentTool creates the spawn_subagent core tool.
// NewSubagentTool creates the subagent core tool.
func NewSubagentTool(opts ...ToolOption) fantasy.AgentTool {
return &coreTool{
info: fantasy.ToolInfo{
Name: "spawn_subagent",
Name: "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 spawn_subagent. This prevents
// SubagentTools returns all core tools except subagent. This prevents
// infinite recursion when a subagent is itself a Kit instance.
func SubagentTools(opts ...ToolOption) []fantasy.AgentTool {
return []fantasy.AgentTool{
+5 -5
View File
@@ -1022,7 +1022,7 @@ func (a *API) OnToolResult(handler func(ToolResultEvent, Context) *ToolResultRes
a.onToolResult(handler)
}
// OnSubagentStart registers a handler that fires when a spawn_subagent tool
// OnSubagentStart registers a handler that fires when a 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 +1037,7 @@ func (a *API) OnSubagentChunk(handler func(SubagentChunkEvent, Context)) {
a.onSubagentChunk(handler)
}
// OnSubagentEnd registers a handler that fires when a spawn_subagent call
// OnSubagentEnd registers a handler that fires when a subagent call
// completes. ErrorMsg is non-empty when the subagent failed.
func (a *API) OnSubagentEnd(handler func(SubagentEndEvent, Context)) {
a.onSubagentEnd(handler)
@@ -2046,9 +2046,9 @@ func (BeforeCompactResult) isResult() {}
// Subagent lifecycle events (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// SubagentStartEvent fires when a spawn_subagent tool call begins executing.
// SubagentStartEvent fires when a subagent tool call begins executing.
type SubagentStartEvent struct {
// ToolCallID is the LLM-assigned ID of the spawn_subagent tool call.
// ToolCallID is the LLM-assigned ID of the subagent tool call.
// Use this to correlate SubagentChunkEvent and SubagentEndEvent.
ToolCallID string
// Task is the task description passed to the subagent.
@@ -2088,7 +2088,7 @@ type SubagentChunkEvent struct {
func (e SubagentChunkEvent) Type() EventType { return SubagentChunk }
// SubagentEndEvent fires when a spawn_subagent tool call completes.
// SubagentEndEvent fires when a 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 spawn_subagent tool call begins executing.
// SubagentStart fires when a 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 spawn_subagent tool call completes (success
// SubagentEnd fires when a subagent tool call completes (success
// or error). Carries the final response and any error message.
SubagentEnd EventType = "subagent_end"
)
+31 -21
View File
@@ -47,46 +47,56 @@ func LoadExtensions(extraPaths []string) ([]LoadedExtension, error) {
return loaded, nil
}
// pathSet is a thread-safe helper for deduplicating and ordering file paths.
type pathSet struct {
m map[string]bool
list []string
}
func newPathSet() *pathSet {
return &pathSet{m: make(map[string]bool)}
}
func (ps *pathSet) add(p string) bool {
abs, err := filepath.Abs(p)
if err != nil {
return false
}
if ps.m[abs] {
return false
}
ps.m[abs] = true
ps.list = append(ps.list, abs)
return true
}
// discoverExtensionPaths returns deduplicated paths to extension files in
// load-order (global first, then project-local, then explicit).
func discoverExtensionPaths(extraPaths []string) []string {
seen := make(map[string]bool)
var paths []string
add := func(p string) {
abs, err := filepath.Abs(p)
if err != nil {
return
}
if seen[abs] {
return
}
seen[abs] = true
paths = append(paths, abs)
}
ps := newPathSet()
// Global extensions: $XDG_CONFIG_HOME/kit/extensions/ (default ~/.config/kit/extensions/)
globalDir := globalExtensionsDir()
for _, p := range findExtensionsInDir(globalDir) {
add(p)
ps.add(p)
}
// Global installed git packages: $XDG_DATA_HOME/kit/git/
globalGitDir := globalGitInstallRoot()
for _, p := range findExtensionsInGitPackages(globalGitDir) {
add(p)
ps.add(p)
}
// Project-local extensions: .kit/extensions/
localDir := filepath.Join(".kit", "extensions")
for _, p := range findExtensionsInDir(localDir) {
add(p)
ps.add(p)
}
// Project-local installed git packages: .kit/git/
projectGitDir := filepath.Join(".kit", "git")
for _, p := range findExtensionsInGitPackages(projectGitDir) {
add(p)
ps.add(p)
}
// Explicit paths (highest precedence)
@@ -97,14 +107,14 @@ func discoverExtensionPaths(extraPaths []string) []string {
}
if info.IsDir() {
for _, found := range findExtensionsInDir(p) {
add(found)
ps.add(found)
}
} else if strings.HasSuffix(p, ".go") {
add(p)
ps.add(p)
}
}
return paths
return ps.list
}
// findExtensionsInDir returns .go files in dir and main.go in immediate subdirs.
+2 -2
View File
@@ -173,10 +173,10 @@ type subagentJSONOutput struct {
} `json:"usage,omitempty"`
}
var subagentCounter uint64
var subagentCounter atomic.Uint64
func generateSubagentID() string {
n := atomic.AddUint64(&subagentCounter, 1)
n := subagentCounter.Add(1)
return fmt.Sprintf("sub-%d-%d", time.Now().UnixNano(), n)
}
+8 -8
View File
@@ -42,14 +42,14 @@ func ExtensionToolsAsFantasy(defs []ToolDef, runner *Runner) []fantasy.AgentTool
// coreToolKinds maps built-in tool names to their kind classification.
var coreToolKinds = map[string]string{
"bash": "execute",
"edit": "edit",
"write": "edit",
"read": "read",
"ls": "read",
"grep": "search",
"find": "search",
"spawn_subagent": "agent",
"bash": "execute",
"edit": "edit",
"write": "edit",
"read": "read",
"ls": "read",
"grep": "search",
"find": "search",
"subagent": "agent",
}
// toolKindFor returns the ToolKind for a given tool name, defaulting to
+31 -8
View File
@@ -4,12 +4,18 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"charm.land/fantasy"
)
// thinkTagRegex matches ... tags that some models (Qwen, DeepSeek) wrap
// reasoning content in. Used to strip these tags from text content.
// The (?s) flag makes . match newlines.
var thinkTagRegex = regexp.MustCompile(`(?s)` + `` + `think` + `` + `(.*?)` + `` + `/think` + ``)
// sanitizeToolCallID ensures the ID matches Anthropic's required pattern:
// ^[a-zA-Z0-9_-]+$ (alphanumeric, underscores, and hyphens only).
// Invalid characters are replaced with underscores.
@@ -115,9 +121,9 @@ const (
)
// Message is a single conversation message containing a heterogeneous slice
// of ContentPart blocks. This design (borrowed from crush) enables a single
// assistant message to carry text, reasoning, and multiple tool calls as
// discrete, typed blocks rather than flattening everything into strings.
// of ContentPart blocks. This design enables a single assistant message to
// carry text, reasoning, and multiple tool calls as discrete, typed blocks
// rather than flattening everything into strings.
type Message struct {
ID string `json:"id"`
Role MessageRole `json:"role"`
@@ -312,12 +318,18 @@ func UnmarshalParts(data []byte) ([]ContentPart, error) {
return parts, nil
}
// --- Fantasy bridge ---
// --- LLM bridge ---
// ToFantasyMessages converts a Message to one or more fantasy.Message values.
// An assistant message with tool calls produces a single fantasy message with
// ToLLMMessages converts a Message to one or more LLM message values.
// An assistant message with tool calls produces a single message with
// mixed TextPart and ToolCallPart content. Tool-role messages produce
// ToolResultPart entries.
func (m *Message) ToLLMMessages() []fantasy.Message {
return m.ToFantasyMessages()
}
// Deprecated: Use ToLLMMessages instead.
// ToFantasyMessages converts a Message to one or more LLM message values.
func (m *Message) ToFantasyMessages() []fantasy.Message {
switch m.Role {
case RoleAssistant:
@@ -416,7 +428,14 @@ func (m *Message) ToFantasyMessages() []fantasy.Message {
}
}
// FromFantasyMessage converts a fantasy.Message into our Message type,
// FromLLMMessage converts an LLM message into our Message type,
// extracting all content parts into the appropriate block types.
func FromLLMMessage(msg fantasy.Message) Message {
return FromFantasyMessage(msg)
}
// Deprecated: Use FromLLMMessage instead.
// FromFantasyMessage converts an LLM message into our Message type,
// extracting all content parts into the appropriate block types.
func FromFantasyMessage(msg fantasy.Message) Message {
m := Message{
@@ -430,7 +449,11 @@ func FromFantasyMessage(msg fantasy.Message) Message {
switch p := part.(type) {
case fantasy.TextPart:
if p.Text != "" {
m.Parts = append(m.Parts, TextContent{Text: p.Text})
// Strip ... tags that some models wrap reasoning in
cleanedText := thinkTagRegex.ReplaceAllString(p.Text, "")
if cleanedText != "" {
m.Parts = append(m.Parts, TextContent{Text: cleanedText})
}
}
case fantasy.ToolCallPart:
m.Parts = append(m.Parts, ToolCall{
+87
View File
@@ -0,0 +1,87 @@
package models
import (
"crypto/sha256"
"encoding/hex"
"maps"
"os"
"charm.land/fantasy"
"charm.land/fantasy/providers/openai"
)
// buildCacheProviderOptions returns caching options for supported models.
// Caching is enabled by default for all supported models to reduce costs.
// Set KIT_DISABLE_CACHE=1 or ProviderConfig.DisableCaching=true to opt out.
func buildCacheProviderOptions(modelInfo *ModelInfo, config *ProviderConfig) fantasy.ProviderOptions {
// Check explicit opt-out via config
if config.DisableCaching {
return nil
}
// Check global opt-out via environment
if os.Getenv("KIT_DISABLE_CACHE") != "" {
return nil
}
// Check if model supports caching
if modelInfo == nil || !modelInfo.SupportsCaching() {
return nil
}
switch modelInfo.CacheType() {
case "anthropic-ephemeral":
// Provider-level Anthropic caching disabled - use message-level caching instead.
return nil
case "openai-prompt-cache":
return buildOpenAICacheOptions(config, modelInfo.ID)
case "google-cached-content":
// Google caching not yet implemented.
return nil
default:
return nil
}
}
// buildOpenAICacheOptions enables prompt caching for OpenAI models.
// Uses a deterministic cache key based on system prompt and model ID.
func buildOpenAICacheOptions(config *ProviderConfig, modelID string) fantasy.ProviderOptions {
cacheKey := generateCacheKey(config.SystemPrompt, modelID)
return fantasy.ProviderOptions{
openai.Name: &openai.ProviderOptions{
PromptCacheKey: &cacheKey,
},
}
}
// generateCacheKey creates a deterministic cache key from system prompt and model.
// This ensures the same system prompt + model combination gets cache hits.
func generateCacheKey(systemPrompt, modelID string) string {
if systemPrompt == "" {
systemPrompt = "default"
}
h := sha256.New()
h.Write([]byte(systemPrompt))
h.Write([]byte(modelID))
// Prefix with "kit-" to identify KIT-generated cache keys
return "kit-" + hex.EncodeToString(h.Sum(nil))[:24]
}
// mergeProviderOptions merges multiple ProviderOptions maps.
// Later maps take precedence over earlier ones.
func mergeProviderOptions(opts ...fantasy.ProviderOptions) fantasy.ProviderOptions {
result := make(fantasy.ProviderOptions)
for _, opt := range opts {
maps.Copy(result, opt)
}
if len(result) == 0 {
return nil
}
return result
}
+248
View File
@@ -0,0 +1,248 @@
package models
import (
"os"
"testing"
"charm.land/fantasy"
)
func TestModelInfo_SupportsCaching(t *testing.T) {
tests := []struct {
name string
family string
expected bool
}{
{"Claude model", "claude-3-5-sonnet", true},
{"Claude 4 model", "claude-4-opus", true},
{"GPT model", "gpt-4", true},
{"GPT-5 model", "gpt-5", true},
{"O1 model", "o1", true},
{"O3 model", "o3", true},
{"O4 model", "o4-mini", true},
{"Codex model", "codex", true},
{"Gemini model", "gemini-2.5-pro", true},
{"Gemini 1.5 model", "gemini-1.5-flash", true},
{"Llama model", "llama-3", false},
{"Unknown model", "unknown", false},
{"Empty family", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &ModelInfo{Family: tt.family}
if got := m.SupportsCaching(); got != tt.expected {
t.Errorf("ModelInfo.SupportsCaching() = %v, want %v", got, tt.expected)
}
})
}
}
func TestModelInfo_CacheType(t *testing.T) {
tests := []struct {
name string
family string
expected string
}{
{"Claude model", "claude-3-5-sonnet", "anthropic-ephemeral"},
{"GPT model", "gpt-4", "openai-prompt-cache"},
{"O1 model", "o1", "openai-prompt-cache"},
{"Gemini model", "gemini-2.5-pro", "google-cached-content"},
{"Unknown model", "llama-3", ""},
{"Empty family", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &ModelInfo{Family: tt.family}
if got := m.CacheType(); got != tt.expected {
t.Errorf("ModelInfo.CacheType() = %v, want %v", got, tt.expected)
}
})
}
}
func TestGenerateCacheKey(t *testing.T) {
key1 := generateCacheKey("system prompt", "model-id")
key2 := generateCacheKey("system prompt", "model-id")
if key1 != key2 {
t.Errorf("generateCacheKey should be deterministic: got %q and %q", key1, key2)
}
key3 := generateCacheKey("different prompt", "model-id")
if key1 == key3 {
t.Errorf("generateCacheKey should produce different keys for different inputs")
}
key4 := generateCacheKey("", "model-id")
key5 := generateCacheKey("default", "model-id")
if key4 != key5 {
t.Errorf("generateCacheKey should treat empty prompt as 'default'")
}
if len(key1) < 4 || key1[:4] != "kit-" {
t.Errorf("generateCacheKey should produce keys with 'kit-' prefix, got %q", key1)
}
}
func TestBuildCacheProviderOptions_Disabled(t *testing.T) {
config := &ProviderConfig{DisableCaching: true}
modelInfo := &ModelInfo{Family: "claude-3", ID: "claude-3-opus"}
if opts := buildCacheProviderOptions(modelInfo, config); opts != nil {
t.Errorf("buildCacheProviderOptions should return nil when DisableCaching=true")
}
}
func TestBuildCacheProviderOptions_EnvironmentVariable(t *testing.T) {
_ = os.Setenv("KIT_DISABLE_CACHE", "1")
defer func() { _ = os.Unsetenv("KIT_DISABLE_CACHE") }()
config := &ProviderConfig{DisableCaching: false}
modelInfo := &ModelInfo{Family: "claude-3", ID: "claude-3-opus"}
if opts := buildCacheProviderOptions(modelInfo, config); opts != nil {
t.Errorf("buildCacheProviderOptions should return nil when KIT_DISABLE_CACHE is set")
}
}
func TestBuildCacheProviderOptions_UnsupportedModel(t *testing.T) {
config := &ProviderConfig{DisableCaching: false}
modelInfo := &ModelInfo{Family: "llama-3", ID: "llama-3-70b"}
if opts := buildCacheProviderOptions(modelInfo, config); opts != nil {
t.Errorf("buildCacheProviderOptions should return nil for unsupported model families")
}
}
func TestBuildCacheProviderOptions_NilModelInfo(t *testing.T) {
config := &ProviderConfig{DisableCaching: false}
if opts := buildCacheProviderOptions(nil, config); opts != nil {
t.Errorf("buildCacheProviderOptions should return nil when modelInfo is nil")
}
}
func TestBuildCacheProviderOptions_Anthropic(t *testing.T) {
_ = os.Unsetenv("KIT_DISABLE_CACHE")
config := &ProviderConfig{DisableCaching: false}
modelInfo := &ModelInfo{Family: "claude-3", ID: "claude-3-opus"}
opts := buildCacheProviderOptions(modelInfo, config)
// Provider-level Anthropic caching is disabled; message-level caching is used instead
if opts != nil {
t.Logf("Provider-level Anthropic caching disabled; using message-level caching")
}
}
func TestBuildCacheProviderOptions_OpenAI(t *testing.T) {
_ = os.Unsetenv("KIT_DISABLE_CACHE")
config := &ProviderConfig{
DisableCaching: false,
SystemPrompt: "test system prompt",
}
modelInfo := &ModelInfo{Family: "gpt-4", ID: "gpt-4o"}
opts := buildCacheProviderOptions(modelInfo, config)
if opts == nil {
t.Fatalf("buildCacheProviderOptions should return options for OpenAI models")
}
if _, ok := opts["openai"]; !ok {
t.Errorf("buildCacheProviderOptions should include 'openai' key for GPT models")
}
}
func TestCachingPriorityOverThinking(t *testing.T) {
_ = os.Unsetenv("KIT_DISABLE_CACHE")
// Anthropic uses message-level caching; provider-level returns nil
config1 := &ProviderConfig{
DisableCaching: false,
ThinkingLevel: ThinkingOff,
}
modelInfo1 := &ModelInfo{Family: "claude-3", ID: "claude-3-opus"}
opts1 := buildCacheProviderOptions(modelInfo1, config1)
if opts1 != nil {
t.Logf("Provider-level Anthropic caching disabled; using message-level caching")
}
// OpenAI provider-level caching works with thinking enabled
config2 := &ProviderConfig{
DisableCaching: false,
SystemPrompt: "test prompt",
ThinkingLevel: ThinkingMedium,
}
modelInfo2 := &ModelInfo{Family: "gpt-4", ID: "gpt-4o"}
opts2 := buildCacheProviderOptions(modelInfo2, config2)
if opts2 == nil {
t.Errorf("OpenAI caching should work with thinking enabled")
}
// OpenAI caching also works with thinking disabled
config3 := &ProviderConfig{
DisableCaching: false,
SystemPrompt: "test prompt",
ThinkingLevel: ThinkingOff,
}
opts3 := buildCacheProviderOptions(modelInfo2, config3)
if opts3 == nil {
t.Errorf("OpenAI caching should work when thinking is OFF")
}
}
func TestMergeProviderOptions(t *testing.T) {
opts1 := fantasy.ProviderOptions{
"provider1": &testProviderData{value: "value1"},
}
opts2 := fantasy.ProviderOptions{
"provider2": &testProviderData{value: "value2"},
}
merged := mergeProviderOptions(opts1, opts2)
if len(merged) != 2 {
t.Errorf("mergeProviderOptions should combine options from multiple maps, got %d items", len(merged))
}
if _, ok := merged["provider1"]; !ok {
t.Errorf("merged options should contain 'provider1' key")
}
if _, ok := merged["provider2"]; !ok {
t.Errorf("merged options should contain 'provider2' key")
}
// Later options should override earlier ones
opts3 := fantasy.ProviderOptions{
"provider1": &testProviderData{value: "overridden"},
}
merged2 := mergeProviderOptions(opts1, opts3)
if data, ok := merged2["provider1"].(*testProviderData); ok {
if data.value != "overridden" {
t.Errorf("later options should override earlier ones, got %q", data.value)
}
}
if mergeProviderOptions() != nil {
t.Errorf("mergeProviderOptions with no args should return nil")
}
}
// testProviderData is a simple implementation of ProviderOptionsData for testing
type testProviderData struct {
value string
}
func (t *testProviderData) Options() {}
func (t *testProviderData) MarshalJSON() ([]byte, error) {
return []byte(`"` + t.value + `"`), nil
}
func (t *testProviderData) UnmarshalJSON(data []byte) error {
return nil
}
+2 -2
View File
@@ -48,10 +48,10 @@ type modelsDBLimit struct {
Output int `json:"output"`
}
// npmToFantasyProvider maps npm package names from models.dev to fantasy
// npmToLLMProvider maps npm package names from models.dev to LLM
// provider identifiers. Providers not in this map but with an api URL
// can be auto-routed through openaicompat.
var npmToFantasyProvider = map[string]string{
var npmToLLMProvider = map[string]string{
"@ai-sdk/anthropic": "anthropic",
"@ai-sdk/openai": "openai",
"@ai-sdk/google": "google",
+194 -23
View File
@@ -10,6 +10,7 @@ import (
"maps"
"net/http"
"os"
"regexp"
"strings"
"time"
@@ -22,6 +23,7 @@ import (
"charm.land/fantasy/providers/openaicompat"
"charm.land/fantasy/providers/openrouter"
"charm.land/fantasy/providers/vercel"
openaisdk "github.com/charmbracelet/openai-go"
"github.com/mark3labs/kit/internal/auth"
"github.com/mark3labs/kit/internal/ui/progress"
@@ -155,6 +157,7 @@ type ProviderConfig struct {
MainGPU *int32
TLSSkipVerify bool
ThinkingLevel ThinkingLevel
DisableCaching bool // Opt-out: set to true to disable automatic prompt caching
}
// ProviderResult contains the result of provider creation.
@@ -237,30 +240,59 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
validateModelConfig(config, modelInfo)
}
// Create the base provider
var result *ProviderResult
var createErr error
switch provider {
case "anthropic":
return createAnthropicProvider(ctx, config, modelName)
result, createErr = createAnthropicProvider(ctx, config, modelName)
case "openai":
return createOpenAIProvider(ctx, config, modelName)
result, createErr = createOpenAIProvider(ctx, config, modelName)
case "google", "gemini":
return createGoogleProvider(ctx, config, modelName)
result, createErr = createGoogleProvider(ctx, config, modelName)
case "ollama":
return createOllamaProvider(ctx, config, modelName)
result, createErr = createOllamaProvider(ctx, config, modelName)
case "azure":
return createAzureProvider(ctx, config, modelName)
result, createErr = createAzureProvider(ctx, config, modelName)
case "google-vertex-anthropic":
return createVertexAnthropicProvider(ctx, config, modelName)
result, createErr = createVertexAnthropicProvider(ctx, config, modelName)
case "openrouter":
return createOpenRouterProvider(ctx, config, modelName)
result, createErr = createOpenRouterProvider(ctx, config, modelName)
case "bedrock":
return createBedrockProvider(ctx, config, modelName)
result, createErr = createBedrockProvider(ctx, config, modelName)
case "vercel":
return createVercelProvider(ctx, config, modelName)
result, createErr = createVercelProvider(ctx, config, modelName)
case "custom":
return createCustomProvider(ctx, config, modelName)
result, createErr = createCustomProvider(ctx, config, modelName)
default:
return autoRouteProvider(ctx, config, provider, modelName, registry)
result, createErr = autoRouteProvider(ctx, config, provider, modelName, registry)
}
if createErr != nil {
return nil, createErr
}
// AUTOMATICALLY ENABLE CACHING for supported models (unless disabled).
// This works for BOTH native and auto-routed providers by detecting
// the model family from the model metadata.
if cacheOpts := buildCacheProviderOptions(modelInfo, config); cacheOpts != nil {
if result.ProviderOptions == nil {
result.ProviderOptions = cacheOpts
} else {
// Merge cache options with existing provider options.
// Only add cache options for providers that don't already have
// options set, to avoid type conflicts (e.g., Anthropic has
// different types for regular options vs cache control options).
for k, v := range cacheOpts {
if _, exists := result.ProviderOptions[k]; !exists {
result.ProviderOptions[k] = v
}
}
}
}
return result, nil
}
// autoRouteProvider attempts to create a provider by looking up its npm package
@@ -280,14 +312,14 @@ func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, mo
npmPackage = modelInfo.ProviderNPM
}
// Determine the fantasy provider for this npm package
fantasyProvider := npmToFantasyProvider[npmPackage]
if fantasyProvider == "" && providerInfo.API != "" {
// Determine the LLM provider for this npm package
llmProvider := npmToLLMProvider[npmPackage]
if llmProvider == "" && providerInfo.API != "" {
// Unknown npm but has API URL → route through openaicompat
fantasyProvider = "openaicompat"
llmProvider = "openaicompat"
}
switch fantasyProvider {
switch llmProvider {
case "openaicompat":
return createAutoRoutedOpenAICompatProvider(ctx, config, modelName, providerInfo)
case "anthropic":
@@ -301,7 +333,7 @@ func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, mo
}
return createAutoRoutedOpenAIProvider(ctx, config, modelName, providerInfo)
default:
return nil, fmt.Errorf("unsupported provider: %s (npm: %s has no fantasy mapping)", provider, npmPackage)
return nil, fmt.Errorf("unsupported provider: %s (npm: %s has no LLM provider mapping)", provider, npmPackage)
}
}
@@ -510,10 +542,15 @@ func thinkingLevelToReasoningEffort(level ThinkingLevel) *openai.ReasoningEffort
// SendReasoning to true and configures the thinking budget. For thinking-off
// or non-reasoning models the returned map is nil.
//
// NOTE: With message-level caching, thinking and caching can work together.
// Message-level cache control (ProviderCacheControlOptions) doesn't conflict
// with provider-level thinking options (ProviderOptions).
//
// Anthropic requires max_tokens > thinking.budget_tokens. If the configured
// MaxTokens is too low, it is bumped to budget + 4096 to leave room for the
// actual response.
func buildAnthropicProviderOptions(config *ProviderConfig, modelName string) fantasy.ProviderOptions {
// Thinking is OFF by default. If user hasn't explicitly enabled it, return nil.
if config.ThinkingLevel == "" || config.ThinkingLevel == ThinkingOff {
return nil
}
@@ -963,6 +1000,133 @@ func createVercelProvider(ctx context.Context, config *ProviderConfig, modelName
return &ProviderResult{Model: model}, nil
}
// thinkTagRegex matches <think>...</think> tags for extracting reasoning content
// from models that wrap thinking in XML-like tags (e.g., Qwen, DeepSeek).
var thinkTagRegex = regexp.MustCompile(`(?s)<think>(.*?)</think>`)
// customExtraContentFunc extracts reasoning from <think> tags in the content field.
// This handles models like Qwen and DeepSeek that return reasoning wrapped in XML tags
// rather than using a separate reasoning_content field.
func customExtraContentFunc(choice openaisdk.ChatCompletionChoice) []fantasy.Content {
var content []fantasy.Content
if choice.Message.Content == "" {
return content
}
// Check for <think> tags in the content
matches := thinkTagRegex.FindStringSubmatch(choice.Message.Content)
if len(matches) > 1 {
// Found reasoning content in <think> tags
reasoning := strings.TrimSpace(matches[1])
if reasoning != "" {
content = append(content, fantasy.ReasoningContent{
Text: reasoning,
})
}
}
return content
}
// customStreamExtraFunc handles streaming responses with <think> tags.
// It extracts reasoning content and emits proper reasoning events.
func customStreamExtraFunc(
chunk openaisdk.ChatCompletionChunk,
yield func(fantasy.StreamPart) bool,
ctx map[string]any,
) (map[string]any, bool) {
if len(chunk.Choices) == 0 {
return ctx, true
}
const reasoningStartedKey = "reasoning_started"
const reasoningBufferKey = "reasoning_buffer"
const inThinkTagKey = "in_think_tag"
reasoningStarted, _ := ctx[reasoningStartedKey].(bool)
inThinkTag, _ := ctx[inThinkTagKey].(bool)
reasoningBuffer, _ := ctx[reasoningBufferKey].(string)
for i, choice := range chunk.Choices {
content := choice.Delta.Content
if content == "" {
continue
}
// Check for <think> tag start
if strings.Contains(content, "<think>") {
inThinkTag = true
ctx[inThinkTagKey] = true
// Emit reasoning start event
if !reasoningStarted {
reasoningStarted = true
ctx[reasoningStartedKey] = true
if !yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeReasoningStart,
ID: fmt.Sprintf("%d", i),
}) {
return ctx, false
}
}
// Extract content after <think>
parts := strings.SplitN(content, "<think>", 2)
if len(parts) > 1 && parts[1] != "" {
reasoningBuffer += parts[1]
ctx[reasoningBufferKey] = reasoningBuffer
}
continue
}
// Check for </think> tag end
if strings.Contains(content, "</think>") {
inThinkTag = false
ctx[inThinkTagKey] = false
// Extract content before </think>
parts := strings.SplitN(content, "</think>", 2)
if len(parts) > 0 {
reasoningBuffer += parts[0]
}
// Emit the accumulated reasoning
if reasoningBuffer != "" {
if !yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeReasoningDelta,
ID: fmt.Sprintf("%d", i),
Delta: reasoningBuffer,
}) {
return ctx, false
}
ctx[reasoningBufferKey] = ""
}
// Emit reasoning end
if !yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeReasoningEnd,
ID: fmt.Sprintf("%d", i),
}) {
return ctx, false
}
continue
}
// Accumulate reasoning content while in think tag
if inThinkTag {
reasoningBuffer += content
ctx[reasoningBufferKey] = reasoningBuffer
}
}
return ctx, true
}
// customToPromptFunc converts prompts to OpenAI format using the default conversion.
func customToPromptFunc(prompt fantasy.Prompt, systemPrompt, user string) ([]openaisdk.ChatCompletionMessageParamUnion, []fantasy.CallWarning) {
return openai.DefaultToPrompt(prompt, systemPrompt, user)
}
func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
if config.ProviderURL == "" {
return nil, fmt.Errorf("custom provider requires --provider-url")
@@ -977,16 +1141,23 @@ func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName
apiKey = "custom"
}
var opts []openaicompat.Option
opts = append(opts, openaicompat.WithBaseURL(config.ProviderURL))
opts = append(opts, openaicompat.WithAPIKey(apiKey))
opts = append(opts, openaicompat.WithName("custom"))
// Use the openai provider directly with custom hooks to handle <think> tags
// from models like Qwen and DeepSeek that wrap reasoning in XML tags.
var opts []openai.Option
opts = append(opts, openai.WithBaseURL(config.ProviderURL))
opts = append(opts, openai.WithAPIKey(apiKey))
opts = append(opts, openai.WithName("custom"))
opts = append(opts, openai.WithLanguageModelOptions(
openai.WithLanguageModelExtraContentFunc(customExtraContentFunc),
openai.WithLanguageModelStreamExtraFunc(customStreamExtraFunc),
openai.WithLanguageModelToPromptFunc(customToPromptFunc),
))
if config.TLSSkipVerify {
opts = append(opts, openaicompat.WithHTTPClient(createHTTPClientWithTLSConfig(true)))
opts = append(opts, openai.WithHTTPClient(createHTTPClientWithTLSConfig(true)))
}
p, err := openaicompat.New(opts...)
p, err := openai.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create custom provider: %w", err)
}
+52 -7
View File
@@ -17,6 +17,7 @@ var embeddedModelsJSON []byte
type ModelInfo struct {
ID string
Name string
Family string // Model family (e.g., "claude", "gpt", "gemini")
Attachment bool
Reasoning bool
Temperature bool
@@ -25,6 +26,44 @@ type ModelInfo struct {
ProviderNPM string // Model-specific provider npm override (e.g. "@ai-sdk/anthropic")
}
// SupportsCaching returns true if this model family supports prompt caching.
// This enables automatic cost savings for supported models regardless of provider.
func (m *ModelInfo) SupportsCaching() bool {
switch {
case strings.HasPrefix(m.Family, "claude"):
return true
case strings.HasPrefix(m.Family, "gpt"),
strings.HasPrefix(m.Family, "o1"),
strings.HasPrefix(m.Family, "o3"),
strings.HasPrefix(m.Family, "o4"),
strings.HasPrefix(m.Family, "codex"):
return true
case strings.HasPrefix(m.Family, "gemini"):
return true
default:
return false
}
}
// CacheType returns the appropriate cache mechanism for this model family.
// Returns empty string if caching is not supported.
func (m *ModelInfo) CacheType() string {
switch {
case strings.HasPrefix(m.Family, "claude"):
return "anthropic-ephemeral"
case strings.HasPrefix(m.Family, "gpt"),
strings.HasPrefix(m.Family, "o1"),
strings.HasPrefix(m.Family, "o3"),
strings.HasPrefix(m.Family, "o4"),
strings.HasPrefix(m.Family, "codex"):
return "openai-prompt-cache"
case strings.HasPrefix(m.Family, "gemini"):
return "google-cached-content"
default:
return ""
}
}
// Cost represents the pricing information for a model.
type Cost struct {
Input float64
@@ -86,6 +125,7 @@ func buildFromModelsDB() map[string]ProviderInfo {
modelsMap[modelID] = ModelInfo{
ID: dm.ID,
Name: dm.Name,
Family: dm.Family,
Attachment: dm.Attachment,
Reasoning: dm.Reasoning,
Temperature: dm.Temperature,
@@ -308,27 +348,32 @@ func (r *ModelsRegistry) GetSupportedProviders() []string {
return providers
}
// GetFantasyProviders returns provider IDs that can be used with fantasy,
// GetLLMProviders returns provider IDs that have LLM support,
// either through a native provider or via openaicompat auto-routing.
func (r *ModelsRegistry) GetFantasyProviders() []string {
func (r *ModelsRegistry) GetLLMProviders() []string {
var providers []string
for providerID, info := range r.providers {
if isProviderFantasySupported(providerID, &info) {
if isProviderLLMSupported(providerID, &info) {
providers = append(providers, providerID)
}
}
return providers
}
// isProviderFantasySupported checks if a provider can be used with fantasy.
func isProviderFantasySupported(providerID string, info *ProviderInfo) bool {
// Deprecated: Use GetLLMProviders instead.
func (r *ModelsRegistry) GetFantasyProviders() []string {
return r.GetLLMProviders()
}
// isProviderLLMSupported checks if a provider can be used with the LLM layer.
func isProviderLLMSupported(providerID string, info *ProviderInfo) bool {
// Ollama is always supported (via openaicompat pointed at localhost)
if providerID == "ollama" {
return true
}
// Check if npm maps to a fantasy provider
if _, ok := npmToFantasyProvider[info.NPM]; ok {
// Check if npm maps to an LLM provider
if _, ok := npmToLLMProvider[info.NPM]; ok {
return true
}
+30 -15
View File
@@ -181,7 +181,7 @@ func OpenTreeSession(path string) (*TreeManager, error) {
// Set leaf to the last entry.
if len(tm.entries) > 0 {
tm.leafID = tm.entryID(tm.entries[len(tm.entries)-1])
tm.leafID = tm.EntryID(tm.entries[len(tm.entries)-1])
}
// Open file for appending.
@@ -242,9 +242,14 @@ func (tm *TreeManager) AppendMessage(msg message.Message) (string, error) {
return entry.ID, nil
}
// AppendFantasyMessage converts a fantasy.Message and appends it.
// AppendLLMMessage converts an LLM message and appends it.
func (tm *TreeManager) AppendLLMMessage(msg fantasy.Message) (string, error) {
return tm.AppendMessage(message.FromLLMMessage(msg))
}
// Deprecated: Use AppendLLMMessage instead.
func (tm *TreeManager) AppendFantasyMessage(msg fantasy.Message) (string, error) {
return tm.AppendMessage(message.FromFantasyMessage(msg))
return tm.AppendLLMMessage(msg)
}
// AppendModelChange records a model/provider change.
@@ -521,7 +526,7 @@ func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider stri
for _, entry := range branch {
// Once we reach the first kept entry, stop skipping.
if skipping {
entryID := tm.entryID(entry)
entryID := tm.EntryID(entry)
if entryID == lastCompaction.FirstKeptEntryID {
skipping = false
} else {
@@ -535,7 +540,7 @@ func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider stri
if err != nil {
continue // skip malformed entries
}
msgs := msg.ToFantasyMessages()
msgs := msg.ToLLMMessages()
messages = append(messages, msgs...)
case *BranchSummaryEntry:
@@ -684,7 +689,7 @@ func (tm *TreeManager) GetContextEntryIDs() []string {
skipping := lastCompaction != nil
for _, entry := range branch {
if skipping {
entryID := tm.entryID(entry)
entryID := tm.EntryID(entry)
if entryID == lastCompaction.FirstKeptEntryID {
skipping = false
} else {
@@ -698,7 +703,7 @@ func (tm *TreeManager) GetContextEntryIDs() []string {
if err != nil {
continue
}
msgs := msg.ToFantasyMessages()
msgs := msg.ToLLMMessages()
for range msgs {
ids = append(ids, e.ID)
}
@@ -737,31 +742,41 @@ func (tm *TreeManager) GetLastCompaction() *CompactionEntry {
// --- Legacy bridge ---
// AddFantasyMessages appends multiple fantasy messages as entries. This is
// AddLLMMessages appends multiple LLM messages as entries. This is
// used when syncing from the agent's ConversationMessages after a step.
func (tm *TreeManager) AddFantasyMessages(msgs []fantasy.Message) error {
func (tm *TreeManager) AddLLMMessages(msgs []fantasy.Message) error {
for _, msg := range msgs {
if _, err := tm.AppendFantasyMessage(msg); err != nil {
if _, err := tm.AppendLLMMessage(msg); err != nil {
return err
}
}
return nil
}
// GetFantasyMessages builds the context and returns just the messages.
// Deprecated: Use AddLLMMessages instead.
func (tm *TreeManager) AddFantasyMessages(msgs []fantasy.Message) error {
return tm.AddLLMMessages(msgs)
}
// GetLLMMessages builds the context and returns just the messages.
// This satisfies the same conceptual role as the old Manager.GetMessages().
func (tm *TreeManager) GetFantasyMessages() []fantasy.Message {
func (tm *TreeManager) GetLLMMessages() []fantasy.Message {
msgs, _, _ := tm.BuildContext()
return msgs
}
// Deprecated: Use GetLLMMessages instead.
func (tm *TreeManager) GetFantasyMessages() []fantasy.Message {
return tm.GetLLMMessages()
}
// --- Internal helpers ---
// addEntryToIndex adds an entry to the in-memory indices.
func (tm *TreeManager) addEntryToIndex(entry any) {
tm.entries = append(tm.entries, entry)
id := tm.entryID(entry)
id := tm.EntryID(entry)
parentID := tm.entryParentID(entry)
if id != "" {
@@ -798,8 +813,8 @@ func (tm *TreeManager) writeEntry(entry any) error {
return err
}
// entryID extracts the ID from any entry type.
func (tm *TreeManager) entryID(entry any) string {
// EntryID extracts the ID from any entry type.
func (tm *TreeManager) EntryID(entry any) string {
switch e := entry.(type) {
case *MessageEntry:
return e.ID
+1 -3
View File
@@ -127,9 +127,7 @@ func (p *MCPConnectionPool) GetConnection(ctx context.Context, serverName string
return conn, nil
} else {
if p.debugLogger != nil && p.debugLogger.IsDebugEnabled() {
if p.debugLogger != nil && p.debugLogger.IsDebugEnabled() {
p.debugLogger.LogDebug(fmt.Sprintf("[POOL] Connection %s unhealthy, removing", serverName))
}
p.debugLogger.LogDebug(fmt.Sprintf("[POOL] Connection %s unhealthy, removing", serverName))
}
_ = conn.client.Close()
delete(p.connections, serverName)
+2 -11
View File
@@ -3,6 +3,7 @@ package tools
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
@@ -70,7 +71,7 @@ func TestMCPToolManager_LoadTools_GracefulFailure(t *testing.T) {
}
// The error should mention that all servers failed
if err != nil && !contains(err.Error(), "all MCP servers failed") {
if err != nil && !strings.Contains(err.Error(), "all MCP servers failed") {
t.Errorf("Expected error message to mention all servers failed, got: %v", err)
}
@@ -459,13 +460,3 @@ 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
}
+188 -12
View File
@@ -149,11 +149,13 @@ func TestInputComponent_QuitReturnsTeaQuit(t *testing.T) {
}
// --------------------------------------------------------------------------
// TestInputComponent_ClearCallsClearMessages verifies that /clear (and its
// aliases) calls appCtrl.ClearMessages() and returns no submitMsg.
// TestInputComponent_ClearForwardsAsSubmitMsg verifies that /clear (and its
// aliases) are forwarded as submitMsg to the parent model so that the parent
// can call ClearMessages(), update scrollback, and print the confirmation
// message in one place. InputComponent must NOT call ClearMessages() directly.
// --------------------------------------------------------------------------
func TestInputComponent_ClearCallsClearMessages(t *testing.T) {
func TestInputComponent_ClearForwardsAsSubmitMsg(t *testing.T) {
aliases := []string{"/clear", "/c", "/cls"}
for _, alias := range aliases {
t.Run(alias, func(t *testing.T) {
@@ -164,22 +166,29 @@ func TestInputComponent_ClearCallsClearMessages(t *testing.T) {
_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: tea.KeyEnter})
if ctrl.clearMsgCalled != 1 {
t.Fatalf("%s: expected ClearMessages() called once, got %d", alias, ctrl.clearMsgCalled)
// InputComponent must NOT call ClearMessages() directly.
if ctrl.clearMsgCalled != 0 {
t.Fatalf("%s: InputComponent must not call ClearMessages(), got %d", alias, ctrl.clearMsgCalled)
}
// No cmd should be returned (no submitMsg forwarded to parent).
if cmd != nil {
msg := runCmd(cmd)
if _, ok := msg.(submitMsg); ok {
t.Fatalf("%s: /clear should not emit submitMsg, got submitMsg", alias)
}
// A submitMsg must be emitted so the parent model handles /clear.
if cmd == nil {
t.Fatalf("%s: expected submitMsg cmd, got nil", alias)
}
msg := runCmd(cmd)
sm, ok := msg.(submitMsg)
if !ok {
t.Fatalf("%s: expected submitMsg, got %T", alias, msg)
}
if sm.Text != alias {
t.Fatalf("%s: expected submitMsg text %q, got %q", alias, alias, sm.Text)
}
})
}
}
// TestInputComponent_ClearNilCtrl_NoPanic verifies that /clear with a nil
// appCtrl does not panic.
// appCtrl does not panic. Since /clear is now forwarded to the parent via
// submitMsg, no appCtrl interaction happens in InputComponent at all.
func TestInputComponent_ClearNilCtrl_NoPanic(t *testing.T) {
c := newTestInput(nil)
c.textarea.SetValue("/clear")
@@ -690,3 +699,170 @@ func TestStreamComponent_StaleFlushTick_Discarded(t *testing.T) {
t.Fatalf("expected streamContent='new' after current flush, got %q", got)
}
}
// TestStreamComponent_ConsumeOverflow_NoHeight verifies that when height is
// unconstrained (0), ConsumeOverflow always returns "".
func TestStreamComponent_ConsumeOverflow_NoHeight(t *testing.T) {
c := newTestStream()
// Commit some content directly.
c.streamContent.WriteString("line1\nline2\nline3")
c.phase = streamPhaseActive
c.renderDirty = true
if got := c.ConsumeOverflow(); got != "" {
t.Fatalf("expected empty with height=0, got %q", got)
}
}
// TestStreamComponent_ConsumeOverflow_NoOverflow verifies that when content fits
// within the allocated height, ConsumeOverflow returns "".
func TestStreamComponent_ConsumeOverflow_NoOverflow(t *testing.T) {
c := newTestStream()
c.streamContent.WriteString("line1\nline2")
c.phase = streamPhaseActive
c.renderDirty = true
c.height = 20 // plenty of room
if got := c.ConsumeOverflow(); got != "" {
t.Fatalf("expected empty when content fits, got %q", got)
}
}
// TestStreamComponent_ConsumeOverflow_EmitsTopLines verifies that when the
// rendered content has more lines than the allocated height, ConsumeOverflow
// returns the top overflow lines and advances the internal pointer.
func TestStreamComponent_ConsumeOverflow_EmitsTopLines(t *testing.T) {
c := newTestStream()
c.height = 2
// Build raw content that when "rendered" (plain text for this test)
// is 5 lines — we bypass the markdown renderer by writing directly to
// streamContent and using a nil renderer.
c.renderer = nil
c.streamContent.WriteString("a\nb\nc\nd\ne")
c.phase = streamPhaseActive
c.renderDirty = true
// First call: should return lines a, b, c (5 lines - 2 visible = 3 overflow).
overflow1 := c.ConsumeOverflow()
if overflow1 == "" {
t.Fatal("expected overflow, got empty")
}
overflowLines := strings.Split(overflow1, "\n")
if len(overflowLines) != 3 {
t.Fatalf("expected 3 overflow lines, got %d: %q", len(overflowLines), overflow1)
}
if overflowLines[0] != "a" || overflowLines[1] != "b" || overflowLines[2] != "c" {
t.Fatalf("unexpected overflow lines: %v", overflowLines)
}
// Second call without new content should return "" (pointer already advanced).
overflow2 := c.ConsumeOverflow()
if overflow2 != "" {
t.Fatalf("expected empty on second call, got %q", overflow2)
}
}
// TestStreamComponent_ConsumeOverflow_IncrementalFlush verifies that as new
// content arrives, ConsumeOverflow incrementally returns only newly overflowed
// lines on each call.
func TestStreamComponent_ConsumeOverflow_IncrementalFlush(t *testing.T) {
c := newTestStream()
c.height = 2
c.renderer = nil
c.phase = streamPhaseActive
// Start with 3 lines — 1 overflows.
c.streamContent.WriteString("a\nb\nc")
c.renderDirty = true
overflow1 := c.ConsumeOverflow()
if overflow1 != "a" {
t.Fatalf("expected 'a', got %q", overflow1)
}
// Add 2 more lines — 2 additional overflows.
c.streamContent.WriteString("\nd\ne")
c.renderDirty = true
overflow2 := c.ConsumeOverflow()
want := "b\nc"
if overflow2 != want {
t.Fatalf("expected %q, got %q", want, overflow2)
}
}
// TestStreamComponent_ConsumeOverflow_ResetClearsPointer verifies that Reset()
// resets the scrollback pointer so the next response starts fresh.
func TestStreamComponent_ConsumeOverflow_ResetClearsPointer(t *testing.T) {
c := newTestStream()
c.height = 1
c.renderer = nil
c.phase = streamPhaseActive
c.streamContent.WriteString("a\nb")
c.renderDirty = true
overflow := c.ConsumeOverflow()
if overflow != "a" {
t.Fatalf("expected 'a', got %q", overflow)
}
c.Reset()
if c.scrollbackFlushedLines != 0 {
t.Fatalf("expected scrollbackFlushedLines=0 after Reset, got %d", c.scrollbackFlushedLines)
}
}
// TestStreamComponent_GetRenderedContent_SkipsFlushedLines verifies that
// GetRenderedContent skips lines already emitted via ConsumeOverflow so the
// caller doesn't re-print content already in the terminal scrollback.
func TestStreamComponent_GetRenderedContent_SkipsFlushedLines(t *testing.T) {
c := newTestStream()
c.height = 2
c.renderer = nil
c.phase = streamPhaseActive
// 5 lines → 3 overflow, 2 visible.
c.streamContent.WriteString("a\nb\nc\nd\ne")
c.renderDirty = true
// Consume the overflow: lines a, b, c.
overflow := c.ConsumeOverflow()
if overflow != "a\nb\nc" {
t.Fatalf("expected 'a\\nb\\nc', got %q", overflow)
}
if c.scrollbackFlushedLines != 3 {
t.Fatalf("expected flushedLines=3, got %d", c.scrollbackFlushedLines)
}
// GetRenderedContent should only return the non-flushed portion: d, e.
got := c.GetRenderedContent()
if got != "d\ne" {
t.Fatalf("expected 'd\\ne', got %q", got)
}
}
// TestStreamComponent_GetRenderedContent_AllFlushed verifies that when all
// lines have been pushed via ConsumeOverflow, GetRenderedContent returns "".
func TestStreamComponent_GetRenderedContent_AllFlushed(t *testing.T) {
c := newTestStream()
c.height = 1
c.renderer = nil
c.phase = streamPhaseActive
// 2 lines → height=1, so 1 overflow.
c.streamContent.WriteString("a\nb")
c.renderDirty = true
// Consume overflow (line a), leaving 1 visible line (b).
_ = c.ConsumeOverflow()
// Now bump height so everything overflows — simulate a resize that made
// the viewable area 0, forcing all content to be "flushed".
c.scrollbackFlushedLines = 2 // pretend both lines were flushed
got := c.GetRenderedContent()
if got != "" {
t.Fatalf("expected empty when all lines flushed, got %q", got)
}
}
+15 -58
View File
@@ -43,7 +43,7 @@ func (r *CompactRenderer) SetWidth(width int) {
// while minimizing vertical space usage. Returns a UIMessage with formatted content
// and metadata.
func (r *CompactRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
theme := getTheme()
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Info).Render(">")
label := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render("User")
@@ -96,7 +96,7 @@ func (r *CompactRenderer) RenderAssistantMessage(content string, timestamp time.
}
}
theme := getTheme()
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Primary).Render("<")
// Use the full model name, fallback to "Assistant" if empty
@@ -127,35 +127,11 @@ 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.
func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage {
theme := getTheme()
theme := GetTheme()
// Resolve extension renderer once for all overrides.
var extRd *ToolRendererData
@@ -244,7 +220,7 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
// compact format with a distinctive symbol (*) and "System" label. Content is
// formatted to fit on a single line for minimal space usage.
func (r *CompactRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
theme := getTheme()
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Muted).Render("◇")
label := lipgloss.NewStyle().Foreground(theme.Muted).Bold(true).Render("System")
@@ -264,7 +240,7 @@ func (r *CompactRenderer) RenderSystemMessage(content string, timestamp time.Tim
// distinctive error symbol (!) and styling to ensure visibility. The error
// content is displayed in a single line with appropriate color highlighting.
func (r *CompactRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage {
theme := getTheme()
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Error).Render("!")
label := lipgloss.NewStyle().Foreground(theme.Error).Bold(true).Render("Error")
@@ -284,7 +260,7 @@ func (r *CompactRenderer) RenderErrorMessage(errorMsg string, timestamp time.Tim
// mode is enabled. Messages are truncated if they exceed the available width to
// maintain single-line display.
func (r *CompactRenderer) RenderDebugMessage(message string, timestamp time.Time) UIMessage {
theme := getTheme()
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Tool).Render("*")
label := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render("Debug")
@@ -308,7 +284,7 @@ func (r *CompactRenderer) RenderDebugMessage(message string, timestamp time.Time
// debugging purposes. Config entries are displayed as key=value pairs separated
// by commas, truncated if necessary to fit on a single line.
func (r *CompactRenderer) RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage {
theme := getTheme()
theme := GetTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Tool).Render("*")
label := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render("Debug")
@@ -426,32 +402,6 @@ func (r *CompactRenderer) wrapText(text string, width int) string {
return strings.Join(wrappedLines, "\n")
}
// formatToolArgs formats tool arguments for compact display
func (r *CompactRenderer) formatToolArgs(args string) string {
if args == "" || args == "{}" {
return ""
}
// Remove JSON braces and format compactly
args = strings.TrimSpace(args)
if strings.HasPrefix(args, "{") && strings.HasSuffix(args, "}") {
args = strings.TrimPrefix(args, "{")
args = strings.TrimSuffix(args, "}")
args = strings.TrimSpace(args)
}
// Remove quotes around simple values
args = strings.ReplaceAll(args, `"`, "")
// Remove parameter names (e.g., "command: ls" -> "ls", "path: /home" -> "/home")
// Look for pattern "key: value" and extract just the value
if colonIndex := strings.Index(args, ":"); colonIndex != -1 {
args = strings.TrimSpace(args[colonIndex+1:])
}
return r.formatCompactContent(args)
}
// formatToolResult formats tool results preserving formatting but limiting to 5 lines
func (r *CompactRenderer) formatToolResult(result string) string {
if result == "" {
@@ -490,5 +440,12 @@ func (r *CompactRenderer) formatToolResult(result string) string {
// and styling appropriately. Delegates tag parsing to the shared parseBashOutput
// helper.
func (r *CompactRenderer) formatBashOutput(result string) string {
return parseBashOutput(result, getTheme())
return parseBashOutput(result, GetTheme())
}
// UpdateTheme is a no-op for CompactRenderer since it fetches theme colors
// directly from GetTheme() in each rendering method. This stub satisfies
// the Renderer interface.
func (r *CompactRenderer) UpdateTheme() {
// No-op: theme colors are fetched fresh on each render
}
+3 -42
View File
@@ -35,8 +35,11 @@ func GetTheme() Theme {
// SetTheme updates the global UI theme, affecting all subsequent rendering
// operations. This allows runtime theme switching for different visual preferences.
// It also invalidates the markdownTypographyCache so the next call to
// GetMarkdownTypography picks up the new theme.
func SetTheme(theme Theme) {
currentTheme = theme
markdownTypographyCache = nil // invalidate cached renderer; colors may have changed
}
// MarkdownThemeColors defines colors for markdown rendering and syntax highlighting.
@@ -291,45 +294,3 @@ 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
// compact mode, using bold colored text to distinguish different message categories.
func StyleCompactSymbol(symbol string, c color.Color) lipgloss.Style {
return lipgloss.NewStyle().
Foreground(c).
Bold(true)
}
// StyleCompactLabel creates a lipgloss style for message labels in compact mode
// with fixed width for alignment and bold colored text for readability.
func StyleCompactLabel(c color.Color) lipgloss.Style {
return lipgloss.NewStyle().
Foreground(c).
Bold(true).
Width(8)
}
// StyleCompactContent creates a simple lipgloss style for message content in
// compact mode, applying only color without additional formatting.
func StyleCompactContent(c color.Color) lipgloss.Style {
return lipgloss.NewStyle().
Foreground(c)
}
// FormatCompactLine assembles a complete compact mode message line with consistent
// spacing and styling. Combines a symbol, fixed-width label, and content with their
// respective colors to create a uniform appearance across all message types.
func FormatCompactLine(symbol, label, content string, symbolColor, labelColor, contentColor color.Color) string {
styledSymbol := StyleCompactSymbol(symbol, symbolColor).Render(symbol)
styledLabel := StyleCompactLabel(labelColor).Render(label)
styledContent := StyleCompactContent(contentColor).Render(content)
return fmt.Sprintf("%s %-8s %s", styledSymbol, styledLabel, styledContent)
}
+2 -33
View File
@@ -6,7 +6,6 @@ import (
"path/filepath"
"sort"
"strings"
"unicode/utf8"
)
// FileSuggestion represents a single file or directory suggestion for the @
@@ -345,44 +344,14 @@ func scoreFilePath(query, path string) int {
}
// Fuzzy character match on basename.
if score := fuzzyCharMatch(query, baseNameLower); score > 0 {
if score := fuzzyCharacterMatch(query, baseNameLower); score > 0 {
return score
}
// Fuzzy character match on full path.
if score := fuzzyCharMatch(query, pathLower); score > 0 {
if score := fuzzyCharacterMatch(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
}
+1
View File
@@ -19,6 +19,7 @@ type Renderer interface {
RenderDebugMessage(message string, timestamp time.Time) UIMessage
RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage
SetWidth(width int)
UpdateTheme()
}
// Compile-time checks that both renderers satisfy the Renderer interface.
+11 -7
View File
@@ -113,19 +113,23 @@ func fuzzyScore(query string, cmd *SlashCommand) int {
return 0
}
// fuzzyCharacterMatch performs character-by-character fuzzy matching
// 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.
func fuzzyCharacterMatch(query, target string) int {
if len(query) > len(target) {
qRunes := []rune(query)
tRunes := []rune(target)
if len(qRunes) > len(tRunes) {
return 0
}
queryIdx := 0
qi := 0
score := 100
consecutiveMatches := 0
for i := 0; i < len(target) && queryIdx < len(query); i++ {
if target[i] == query[queryIdx] {
queryIdx++
for ti := 0; ti < len(tRunes) && qi < len(qRunes); ti++ {
if tRunes[ti] == qRunes[qi] {
qi++
consecutiveMatches++
score += consecutiveMatches * 10
} else {
@@ -135,7 +139,7 @@ func fuzzyCharacterMatch(query, target string) int {
}
// Must match all characters in query
if queryIdx < len(query) {
if qi < len(qRunes) {
return 0
}
+4 -11
View File
@@ -409,21 +409,14 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
}
// Resolve via canonical command lookup so aliases are handled uniformly.
// Only /quit and /clear are handled locally — /clear-queue must go
// through the parent model so it can update queueCount directly
// (calling ClearQueue here would skip the UI state update since we
// can't send events from within Update without deadlocking).
// Only /quit is handled locally — all other slash commands (including
// /clear and /clear-queue) are forwarded to the parent model via
// submitMsg so the parent can update its own state (scrollback, queue
// counts, etc.) in one place.
if sc := GetCommandByName(trimmed); sc != nil {
switch sc.Name {
case "/quit":
return tea.Quit
case "/clear":
if s.appCtrl != nil {
s.appCtrl.ClearMessages()
}
// Don't forward to app.Run(); just clear silently.
return nil
}
}
+15 -83
View File
@@ -41,27 +41,9 @@ type UIMessage struct {
Streaming bool
}
// 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",
}
// getTheme returns the current theme (helper for compact_renderer.go)
func getTheme() Theme {
return GetTheme()
}
// toolDisplayName returns a human-friendly display name for a tool.
// toolDisplayName returns a human-friendly display name for a tool,
// title-casing 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:]
}
@@ -176,7 +158,7 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
}
rendered := r.ty.Tip(content)
rendered = lipgloss.NewStyle().MarginBottom(1).Render(rendered)
rendered = styleMarginBottom1.Render(rendered)
return UIMessage{
Type: UserMessage,
@@ -199,7 +181,7 @@ 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)
rendered = styleMarginBottom1.Render(rendered)
return UIMessage{
Type: AssistantMessage,
@@ -216,7 +198,7 @@ func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Tim
}
rendered := r.ty.Note(content)
rendered = lipgloss.NewStyle().MarginBottom(1).Render(rendered)
rendered = styleMarginBottom1.Render(rendered)
return UIMessage{
Type: SystemMessage,
@@ -242,7 +224,7 @@ func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time
header,
r.ty.P(strings.Join(formattedLines, "\n")),
)
content = lipgloss.NewStyle().MarginBottom(1).Render(content)
content = styleMarginBottom1.Render(content)
return UIMessage{
Content: content,
@@ -270,7 +252,7 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
} else {
content = header
}
content = lipgloss.NewStyle().MarginBottom(1).Render(content)
content = styleMarginBottom1.Render(content)
return UIMessage{
Type: SystemMessage,
@@ -283,7 +265,7 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
// RenderErrorMessage renders error notifications
func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage {
rendered := r.ty.Caution(errorMsg)
rendered = lipgloss.NewStyle().MarginBottom(1).Render(rendered)
rendered = styleMarginBottom1.Render(rendered)
return UIMessage{
Type: ErrorMessage,
@@ -293,39 +275,6 @@ func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Tim
}
}
// RenderToolCallMessage renders a notification that a tool is being executed
func (r *MessageRenderer) RenderToolCallMessage(toolName, toolArgs string, timestamp time.Time) UIMessage {
timeStr := timestamp.Local().Format("15:04")
var argsContent string
if toolArgs != "" && toolArgs != "{}" {
argsContent = r.ty.Italic(fmt.Sprintf("Arguments: %s", r.formatToolArgs(toolArgs)))
}
info := fmt.Sprintf(" Executing %s (%s)", toolName, timeStr)
infoStyled := r.ty.Small(info)
var fullContent string
if argsContent != "" {
fullContent = r.ty.Compose(
argsContent,
infoStyled,
)
} else {
fullContent = infoStyled
}
rendered := r.ty.Warning(fullContent)
rendered = lipgloss.NewStyle().MarginBottom(1).Render(rendered)
return UIMessage{
Type: ToolCallMessage,
Content: rendered,
Height: lipgloss.Height(rendered),
Timestamp: timestamp,
}
}
// RenderToolMessage renders a unified tool block
func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage {
var extRd *ToolRendererData
@@ -400,7 +349,7 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
"",
body,
)
fullContent = lipgloss.NewStyle().MarginBottom(1).Render(fullContent)
fullContent = styleMarginBottom1.Render(fullContent)
return UIMessage{
Type: ToolMessage,
@@ -409,29 +358,6 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
}
}
// formatToolArgs formats tool arguments for display
func (r *MessageRenderer) formatToolArgs(args string) string {
args = strings.TrimSpace(args)
if strings.HasPrefix(args, "{") && strings.HasSuffix(args, "}") {
args = strings.TrimPrefix(args, "{")
args = strings.TrimSuffix(args, "}")
args = strings.TrimSpace(args)
}
if args == "" {
return "(no arguments)"
}
if !r.debug {
maxLen := 100
if len(args) > maxLen {
return args[:maxLen] + "..."
}
}
return args
}
// formatToolResult formats tool results based on tool type
func (r *MessageRenderer) formatToolResult(toolName, result string) string {
if !r.debug {
@@ -482,6 +408,12 @@ func createTypography(theme Theme) *herald.Typography {
)
}
// UpdateTheme refreshes the renderer's typography instance with colors from
// the current theme. This is called when the user changes themes via /theme.
func (r *MessageRenderer) UpdateTheme() {
r.ty = createTypography(GetTheme())
}
// removeBlankLines removes lines that are visually blank from rendered output.
func removeBlankLines(s string) string {
lines := strings.Split(s, "\n")
+133 -107
View File
@@ -74,6 +74,11 @@ type AppController interface {
ClearQueue()
// ClearMessages clears the conversation history.
ClearMessages()
// ReloadMessagesFromTree clears the in-memory message store and reloads
// it from the tree session's current branch. Unlike ClearMessages, this
// does NOT reset the tree session's leaf pointer. Used after Branch() to
// sync the store with the new branch position.
ReloadMessagesFromTree()
// CompactConversation summarises older messages to free context space.
// Runs asynchronously; results are delivered via CompactCompleteEvent or
// CompactErrorEvent sent through the registered tea.Program. Returns an
@@ -148,6 +153,22 @@ type ToolRendererData struct {
RenderBody func(toolResult string, isError bool, width int) string
}
// noopCmd is a sentinel tea.Cmd returned by handlers that have consumed an
// event but produce no side-effects. It returns a nil Msg which BubbleTea
// discards, but its non-nil value lets callers distinguish "handled" from
// "not handled" (nil tea.Cmd).
var noopCmd tea.Cmd = func() tea.Msg { return nil }
// Package-level lipgloss styles that are invariant across frames (only depend
// on theme colors, which are updated via SetTheme). Defined at package level
// to avoid allocating new lipgloss.Style structs on every render call.
//
// Note: theme-sensitive styles (those using theme.Warning, theme.Muted, etc.)
// are rebuilt on theme change via ApplyTheme. The cancel warning style
// intentionally reads the theme at render time because themes can change at
// runtime; only truly static styles belong here.
var styleMarginBottom1 = lipgloss.NewStyle().MarginBottom(1)
// ---------------------------------------------------------------------------
// Editor interceptor types (UI-layer, decoupled from extensions package)
// ---------------------------------------------------------------------------
@@ -587,20 +608,26 @@ type AppModel struct {
// (Update/View), so no mutex is required here.
// streamingBashCommand holds the command being executed for display as a header.
streamingBashCommand string
// ---------- Cached layout heights (invalidated by layoutDirty) ----------
// layoutDirty marks that distributeHeight must recompute the stream height
// on the next View() call. Set by any state change that affects sizing
// (resize, queue changes, widget updates, visibility changes, etc.).
// View() calls distributeHeight() when this is true and then clears it.
layoutDirty bool
}
// --------------------------------------------------------------------------
// Child component interfaces (stubs until TAS-15/16/17 implement them)
// Child component interfaces
// --------------------------------------------------------------------------
// inputComponentIface is the interface the parent requires from InputComponent.
// It will be satisfied by the real InputComponent created in TAS-15.
type inputComponentIface interface {
tea.Model
}
// streamComponentIface is the interface the parent requires from StreamComponent.
// It will be satisfied by the real StreamComponent created in TAS-16.
type streamComponentIface interface {
tea.Model
// Reset clears accumulated state between agent steps.
@@ -610,6 +637,10 @@ type streamComponentIface interface {
// GetRenderedContent returns the rendered assistant message from accumulated
// streaming text, or empty string if nothing has been accumulated.
GetRenderedContent() string
// ConsumeOverflow returns lines from the top of the rendered content that
// have overflowed the allocated height and haven't been pushed to the
// terminal scrollback yet. Returns "" when no new overflow exists.
ConsumeOverflow() string
// SpinnerView returns the rendered spinner line (animation + optional label).
// Returns "" when the spinner is not active. The parent renders this in the
// status bar so the spinner never changes the view height.
@@ -618,6 +649,8 @@ type streamComponentIface interface {
SetThinkingVisible(visible bool)
// HasReasoning returns true if any reasoning content has been accumulated.
HasReasoning() bool
// UpdateTheme refreshes typography with colors from the current theme.
UpdateTheme()
}
// --------------------------------------------------------------------------
@@ -753,16 +786,9 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
// Init implements tea.Model. Initialises child components. Startup info is
// printed to stdout before the program starts via PrintStartupInfo().
func (m *AppModel) Init() tea.Cmd {
var cmds []tea.Cmd
if m.input != nil {
cmds = append(cmds, m.input.Init())
}
if m.stream != nil {
cmds = append(cmds, m.stream.Init())
}
return tea.Batch(cmds...)
// m.input is always set by NewAppModel; its Init starts the textarea cursor blink.
// m.stream.Init() always returns nil, so there is nothing to batch.
return m.input.Init()
}
// uiVis returns the current UIVisibility, defaulting to zero value (show all)
@@ -832,7 +858,7 @@ func (m *AppModel) PrintStartupInfo() {
if len(pairs) > 0 {
rendered := ty.KVGroup(pairs)
rendered = lipgloss.NewStyle().MarginBottom(1).Render(rendered)
rendered = styleMarginBottom1.Render(rendered)
fmt.Println(rendered)
}
}
@@ -903,7 +929,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}()
m.treeSelector = nil
m.state = stateInput
return m, func() tea.Msg { return nil }
return m, noopCmd
}
cmds = append(cmds, m.performFork(targetID, msg.IsUser, msg.UserText))
@@ -985,14 +1011,16 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.distributeHeight()
m.layoutDirty = true
// Propagate to children.
if m.input != nil {
_, cmd := m.input.Update(msg)
updated, cmd := m.input.Update(msg)
m.input, _ = updated.(inputComponentIface)
cmds = append(cmds, cmd)
}
if m.stream != nil {
_, cmd := m.stream.Update(msg)
updated, cmd := m.stream.Update(msg)
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
@@ -1116,7 +1144,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
sLen := m.appCtrl.Steer(processedText)
if sLen > 0 {
m.steeringMessages = append(m.steeringMessages, text)
m.distributeHeight()
m.layoutDirty = true
} else {
// Started immediately (agent was idle).
m.pendingUserPrints = append(m.pendingUserPrints, text)
@@ -1187,63 +1215,17 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// ── Input submitted ──────────────────────────────────────────────────────
case submitMsg:
// Handle slash commands locally — they should never reach app.Run().
if sc := GetCommandByName(msg.Text); sc != nil {
if cmd := m.handleSlashCommand(sc); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
// /compact and /model support optional args (e.g. "/compact Focus on API",
// "/model anthropic/claude-haiku-3-5-20241022").
// GetCommandByName won't match the full text, so check the prefix.
if name, args, ok := strings.Cut(msg.Text, " "); ok {
// Parse once: split on the first space so argument-bearing commands
// (e.g. "/model anthropic/foo", "/compact Focus on X") are matched by
// their name and their args are passed through to the handler.
if strings.HasPrefix(msg.Text, "/") {
name, args, _ := strings.Cut(msg.Text, " ")
if sc := GetCommandByName(name); sc != nil {
switch sc.Name {
case "/compact":
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...)
if cmd := m.handleSlashCommand(sc, strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
}
@@ -1300,7 +1282,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// "queued" badge. It will be printed to scrollback when
// the agent picks it up (via SpinnerEvent).
m.queuedMessages = append(m.queuedMessages, displayText)
m.distributeHeight()
m.layoutDirty = true
} else {
// Started immediately. Flush any leftover stream content
// from the previous step first, then print the user
@@ -1321,7 +1303,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Show spinner while the shell command runs.
m.state = stateWorking
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
updated, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
// Execute the shell command asynchronously so the TUI stays responsive.
@@ -1330,7 +1313,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case shellCommandResultMsg:
// Stop spinner now that the command has finished.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
updated, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
m.state = stateInput
@@ -1348,22 +1332,25 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Show {
m.flushStreamAndPendingUserMessages()
m.state = stateWorking
m.distributeHeight()
m.layoutDirty = true
}
if m.stream != nil {
_, cmd := m.stream.Update(msg)
updated, cmd := m.stream.Update(msg)
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
case app.ReasoningChunkEvent:
if m.stream != nil {
_, cmd := m.stream.Update(msg)
updated, cmd := m.stream.Update(msg)
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
case app.StreamChunkEvent:
if m.stream != nil {
_, cmd := m.stream.Update(msg)
updated, cmd := m.stream.Update(msg)
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
@@ -1387,7 +1374,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.ToolExecutionEvent:
// Pass to stream component for execution spinner display.
if m.stream != nil {
_, cmd := m.stream.Update(msg)
updated, cmd := m.stream.Update(msg)
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
@@ -1400,7 +1388,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.streamingBashCommand = ""
// Start spinner again while waiting for the next LLM response.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
updated, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
@@ -1452,7 +1441,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.queuedMessages = m.queuedMessages[1:]
m.pendingUserPrints = append(m.pendingUserPrints, text)
}
m.distributeHeight()
m.layoutDirty = true
case app.SteerConsumedEvent:
// Steering messages were consumed — either injected mid-turn via
@@ -1477,13 +1466,13 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.printUserMessage(text)
}
m.steeringMessages = m.steeringMessages[:0]
m.distributeHeight()
m.layoutDirty = true
cmds = append(cmds, m.drainScrollback())
} else {
// Case 2: post-turn — defer so SpinnerEvent orders correctly.
m.pendingUserPrints = append(m.pendingUserPrints, m.steeringMessages...)
m.steeringMessages = m.steeringMessages[:0]
m.distributeHeight()
m.layoutDirty = true
}
case app.StepCompleteEvent:
@@ -1494,7 +1483,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// scrollback when the next step starts (SpinnerEvent{Show: true}).
// Just stop the spinner and return to input state.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
updated, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
m.state = stateInput
@@ -1504,7 +1494,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// User cancelled the step (double-ESC). Keep partial stream content
// visible (same reasoning as StepCompleteEvent). Just stop the spinner.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
updated, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
m.state = stateInput
@@ -1515,7 +1506,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// StepCompleteEvent). Print the error to scrollback — it appears
// above the view, and the partial response stays visible below.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
updated, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
if msg.Err != nil {
@@ -1548,7 +1540,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Extension widget changed — recalculate height distribution so the
// stream region accounts for widget space. View() will read the
// latest widget state on the next render.
m.distributeHeight()
m.layoutDirty = true
// Refresh extension commands (e.g. after hot-reload). The callback
// returns the current set from the runner which may have changed.
@@ -1693,15 +1685,34 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
default:
// Pass unrecognised messages to all children.
if m.input != nil {
_, cmd := m.input.Update(msg)
updated, cmd := m.input.Update(msg)
m.input, _ = updated.(inputComponentIface)
cmds = append(cmds, cmd)
}
if m.stream != nil {
_, cmd := m.stream.Update(msg)
updated, cmd := m.stream.Update(msg)
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
}
// Flush any stream overflow lines that have grown past the allocated
// height into the terminal's real scrollback buffer. This ensures the
// diagram's invariant: streaming text starts at the top of the viewable
// terminal and overflows upward into the scrollback buffer rather than
// silently discarding the older lines.
//
// IMPORTANT: overflow is emitted directly via tea.Println rather than
// via appendScrollback. Using appendScrollback would cause drainScrollback
// to see a non-empty scrollbackBuf and trigger its auto-flush, which calls
// GetRenderedContent() + Reset() while the stream is still active —
// causing duplication and premature resets.
if m.stream != nil {
if overflow := m.stream.ConsumeOverflow(); overflow != "" {
cmds = append(cmds, tea.Println(overflow))
}
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
@@ -1731,6 +1742,15 @@ func (m *AppModel) View() tea.View {
return tea.NewView(m.overlay.Render())
}
// Recompute layout heights if any Update() changed state that affects
// sizing. Deferring this to View() guarantees exactly one call per frame
// regardless of how many events triggered a layout change in a single
// Update() invocation.
if m.layoutDirty {
m.distributeHeight()
m.layoutDirty = false
}
vis := m.uiVis()
streamView := m.renderStream()
@@ -2238,10 +2258,10 @@ func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) {
// Slash command handlers
// --------------------------------------------------------------------------
// handleSlashCommand executes a recognized slash command and returns a tea.Cmd
// that emits the appropriate output to scrollback. Returns tea.Quit for /quit,
// nil for commands with no visible output, or a tea.Println cmd for display.
func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
// handleSlashCommand executes a recognized slash command and returns a tea.Cmd.
// args contains any text after the command name (may be empty). Returns tea.Quit
// for /quit, nil for commands with no output, or a tea.Println cmd for display.
func (m *AppModel) handleSlashCommand(sc *SlashCommand, args string) tea.Cmd {
switch sc.Name {
case "/quit":
return tea.Quit
@@ -2256,13 +2276,13 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
case "/reset-usage":
m.printResetUsage()
case "/model":
return m.handleModelCommand("")
return m.handleModelCommand(args)
case "/theme":
return m.handleThemeCommand("")
return m.handleThemeCommand(args)
case "/thinking":
return m.handleThinkingCommand("")
return m.handleThinkingCommand(args)
case "/compact":
return m.handleCompactCommand("")
return m.handleCompactCommand(args)
case "/clear":
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
@@ -2274,7 +2294,7 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
}
m.queuedMessages = m.queuedMessages[:0]
m.steeringMessages = m.steeringMessages[:0]
m.distributeHeight()
m.layoutDirty = true
case "/tree":
return m.handleTreeCommand()
@@ -2283,15 +2303,15 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
case "/new":
return m.handleNewCommand()
case "/name":
return m.handleNameCommand("")
return m.handleNameCommand(args)
case "/resume":
return m.handleResumeCommand()
case "/export":
return m.handleExportCommand("")
return m.handleExportCommand(args)
case "/share":
return m.handleShareCommand()
case "/import":
return m.handleImportCommand("")
return m.handleImportCommand(args)
case "/session":
return m.handleSessionInfoCommand()
@@ -2378,7 +2398,7 @@ func (m *AppModel) handleExtensionCommand(text string) tea.Cmd {
// Return a non-nil Cmd so the caller knows the command was handled
// and doesn't fall through to the regular prompt path. The Cmd itself
// is a no-op.
return func() tea.Msg { return nil }
return noopCmd
}
// expandPromptTemplate checks if the submitted text matches a prompt template
@@ -2896,6 +2916,8 @@ func (m *AppModel) handleThemeCommand(args string) tea.Cmd {
return nil
}
m.renderer.UpdateTheme()
m.stream.UpdateTheme()
m.printSystemMessage(fmt.Sprintf("Switched to theme: %s", args))
return nil
}
@@ -3004,7 +3026,7 @@ func (m *AppModel) handleNewCommand() tea.Cmd {
reason: reason,
})
}()
return func() tea.Msg { return nil }
return noopCmd
}
return m.performNewSession()
@@ -3055,8 +3077,12 @@ func (m *AppModel) performFork(targetID string, isUser bool, userText string) te
return nil
}
// Branch the tree session to the target entry. We must NOT call
// ClearMessages() here because it resets the leaf pointer back to "",
// undoing the branch we just set. Instead, branch first and then
// reload the in-memory store from the tree session's current branch.
_ = ts.Branch(targetID)
m.appCtrl.ClearMessages()
m.appCtrl.ReloadMessagesFromTree()
// If it was a user message, populate the input with the text.
if isUser && userText != "" {
+1 -1
View File
@@ -50,7 +50,7 @@ func NewModelSelector(currentModel string, width, height int) *ModelSelectorComp
registry := models.GetGlobalRegistry()
var allModels []ModelEntry
for _, providerID := range registry.GetFantasyProviders() {
for _, providerID := range registry.GetLLMProviders() {
// Only include providers with valid API keys configured.
if err := registry.ValidateEnvironment(providerID, ""); err != nil {
continue
+11 -1
View File
@@ -46,6 +46,10 @@ func (s *stubAppController) ClearMessages() {
s.clearMsgCalled++
}
func (s *stubAppController) ReloadMessagesFromTree() {
// no-op in tests
}
func (s *stubAppController) CompactConversation(_ string) error {
return nil
}
@@ -97,9 +101,11 @@ func (s *stubStreamComponent) View() tea.View { return tea.NewView("
func (s *stubStreamComponent) Reset() { s.resetCalled++; s.renderedContent = "" }
func (s *stubStreamComponent) SetHeight(h int) { s.height = h }
func (s *stubStreamComponent) GetRenderedContent() string { return s.renderedContent }
func (s *stubStreamComponent) ConsumeOverflow() string { return "" }
func (s *stubStreamComponent) SpinnerView() string { return "" }
func (s *stubStreamComponent) SetThinkingVisible(bool) {}
func (s *stubStreamComponent) HasReasoning() bool { return false }
func (s *stubStreamComponent) UpdateTheme() {}
// stubInputComponent satisfies inputComponentIface without rendering anything.
type stubInputComponent struct {
@@ -142,7 +148,11 @@ func newTestAppModel(ctrl AppController) (*AppModel, *stubStreamComponent, *stub
// sendMsg calls m.Update once with the given message and returns the updated model.
func sendMsg(m *AppModel, msg tea.Msg) *AppModel {
updated, _ := m.Update(msg)
return updated.(*AppModel)
result := updated.(*AppModel)
// Simulate BubbleTea's frame cycle: View() is called after every Update().
// This flushes any pending layoutDirty work (e.g. distributeHeight).
_ = result.View()
return result
}
// --------------------------------------------------------------------------
-352
View File
@@ -1,352 +0,0 @@
package ui
import (
"strings"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// SlashCommandInput provides an interactive text input field with intelligent
// slash command autocomplete functionality. It displays a popup menu of matching
// commands as the user types, supporting fuzzy matching and keyboard navigation.
type SlashCommandInput struct {
textarea textarea.Model
commands []SlashCommand
showPopup bool
filtered []FuzzyMatch
selected int
width int
lastValue string
popupHeight int
title string
quitting bool
value string
submitNext bool // Flag to submit on next update
renderedLines int // Track how many lines were rendered
hideHint bool // Suppress the "enter submit · ctrl+j..." hint
}
// NewSlashCommandInput creates and initializes a new slash command input field with
// the specified width and title. The input supports multi-line text entry, command
// autocomplete, and is styled to match the application's theme.
func NewSlashCommandInput(width int, title string) *SlashCommandInput {
ta := textarea.New()
ta.Placeholder = "Type your message..."
ta.ShowLineNumbers = false
ta.Prompt = ""
ta.CharLimit = 5000
ta.SetWidth(width - 8) // Account for container padding, border and internal padding
ta.SetHeight(3) // Default to 3 lines like huh
ta.Focus()
// Override InsertNewline so only ctrl+j and shift+enter insert newlines.
// Enter always submits the input.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "shift+enter"),
key.WithHelp("ctrl+j", "insert newline"),
)
// Style the textarea using theme colors.
theme := GetTheme()
styles := ta.Styles()
styles.Focused.Base = lipgloss.NewStyle()
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(theme.VeryMuted)
styles.Focused.Text = lipgloss.NewStyle().Foreground(theme.Text)
styles.Focused.Prompt = lipgloss.NewStyle()
styles.Focused.CursorLine = lipgloss.NewStyle()
ta.SetStyles(styles)
return &SlashCommandInput{
textarea: ta,
commands: SlashCommands,
width: width,
popupHeight: 7,
title: title,
}
}
// Init implements the tea.Model interface, returning the initial command to start
// the cursor blinking animation for the text input field.
func (s *SlashCommandInput) Init() tea.Cmd {
return textarea.Blink
}
// Update implements the tea.Model interface, handling keyboard input for text entry,
// command selection, and navigation. Manages the autocomplete popup display and
// processes submission or cancellation actions.
func (s *SlashCommandInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
// Check if we need to submit after updating the view
if s.submitNext {
s.value = s.textarea.Value()
s.quitting = true
return s, tea.Quit
}
switch msg := msg.(type) {
case tea.KeyPressMsg: // Check for quit keys first (when popup is not shown)
if !s.showPopup {
switch msg.String() {
case "ctrl+c", "esc":
s.quitting = true
return s, tea.Quit
case "ctrl+d", "enter": // Enter always submits
s.value = s.textarea.Value()
s.quitting = true
return s, tea.Quit
}
}
// Handle popup navigation
if s.showPopup {
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up"))):
if s.selected > 0 {
s.selected--
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down"))):
if s.selected < len(s.filtered)-1 {
s.selected++
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
if s.selected < len(s.filtered) {
// Complete with selected command
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
s.showPopup = false
s.selected = 0
// Move cursor to end
s.textarea.CursorEnd()
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
if s.selected < len(s.filtered) {
// Populate the field with the selected command
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
s.textarea.CursorEnd()
// Hide the popup
s.showPopup = false
s.selected = 0
// Set flag to submit on next update (after view refresh)
s.submitNext = true
// Force a refresh
return s, nil
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
s.showPopup = false
s.selected = 0
return s, nil
}
}
// Update textarea
s.textarea, cmd = s.textarea.Update(msg)
// Check if we should show/update popup
value := s.textarea.Value()
if value != s.lastValue {
s.lastValue = value
// Only show popup if we're on the first line and it starts with /
lines := strings.Split(value, "\n")
if len(lines) > 0 && strings.HasPrefix(lines[0], "/") && !strings.Contains(lines[0], " ") && len(lines) == 1 {
// Show and update popup
s.showPopup = true
s.filtered = FuzzyMatchCommands(lines[0], s.commands)
s.selected = 0
} else {
// Hide popup
s.showPopup = false
}
}
return s, cmd
default:
// Pass through other messages
s.textarea, cmd = s.textarea.Update(msg)
return s, cmd
}
}
// View implements the tea.Model interface, rendering the complete input field
// including the title, text area, autocomplete popup (when active), and help text.
// The view adapts based on whether single or multi-line input is detected.
func (s *SlashCommandInput) View() tea.View {
containerStyle := lipgloss.NewStyle()
theme := GetTheme()
// PaddingLeft(3) aligns with message content: border(1) + paddingLeft(2).
titleStyle := lipgloss.NewStyle().
Foreground(theme.Text).
MarginBottom(1).
PaddingLeft(3)
// Input box with huh-like styling
inputBoxStyle := lipgloss.NewStyle().
Border(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(false).
BorderTop(false).
BorderBottom(false).
BorderForeground(theme.Primary).
PaddingLeft(2). // match message block paddingLeft
Width(s.width - 1) // full width minus left border
// Build the view
var view strings.Builder
view.WriteString(titleStyle.Render(s.title))
view.WriteString("\n")
view.WriteString(inputBoxStyle.Render(s.textarea.View()))
// Count rendered lines
s.renderedLines = 2 + s.textarea.Height() // title + newline + textarea height
// Add popup if visible
if s.showPopup && len(s.filtered) > 0 {
view.WriteString("\n")
view.WriteString(s.renderPopup())
// Add popup lines
visibleItems := min(len(s.filtered), s.popupHeight)
scrollIndicators := 0
if s.selected >= s.popupHeight {
scrollIndicators++ // top indicator
}
if len(s.filtered) > s.popupHeight {
scrollIndicators++ // bottom indicator
}
popupLines := visibleItems + scrollIndicators + 5 // items + scroll + border + padding + footer
s.renderedLines += 1 + popupLines // newline + popup
}
// Add help text at bottom (unless hidden by extension).
if !s.hideHint {
helpStyle := lipgloss.NewStyle().
Foreground(theme.VeryMuted).
MarginTop(1).
PaddingLeft(3)
helpText := "enter submit • ctrl+j / shift+enter new line"
view.WriteString("\n")
view.WriteString(helpStyle.Render(helpText))
s.renderedLines += 2 // newline + help text
}
// Apply container padding to entire view
return tea.NewView(containerStyle.Render(view.String()))
}
// renderPopup renders the autocomplete popup
func (s *SlashCommandInput) renderPopup() string {
theme := GetTheme()
// Popup styling
popupStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme.MutedBorder).
Padding(1, 2).
Width(s.width - 4). // Account for container padding
MarginLeft(0) // No extra margin needed due to container padding
var items []string
// Calculate visible window
visibleItems := min(len(s.filtered), s.popupHeight)
startIdx := 0
// Adjust window to keep selected item visible
if s.selected >= s.popupHeight {
startIdx = s.selected - s.popupHeight + 1
}
endIdx := min(startIdx+visibleItems, len(s.filtered))
for i := startIdx; i < endIdx; i++ {
match := s.filtered[i]
cmd := match.Command
// Create the selection indicator
var indicator string
if i == s.selected {
indicator = lipgloss.NewStyle().
Foreground(theme.Primary).
Render("> ")
} else {
indicator = " "
}
// Format item
nameStyle := lipgloss.NewStyle().
Foreground(theme.Secondary).
Bold(true)
descStyle := lipgloss.NewStyle().
Foreground(theme.Muted)
// Highlight selected item
if i == s.selected {
nameStyle = nameStyle.Foreground(theme.Primary)
descStyle = descStyle.Foreground(theme.Text)
}
// Format with proper spacing
nameWidth := 15
name := nameStyle.Width(nameWidth - 2).Render(cmd.Name)
// Truncate description if needed
desc := cmd.Description
maxDescLen := s.width - nameWidth - 14 // Account for padding and indicator
if len(desc) > maxDescLen && maxDescLen > 3 {
desc = desc[:maxDescLen-3] + "..."
}
line := indicator + name + descStyle.Render(desc)
items = append(items, line)
}
// Add scroll indicators if needed
if startIdx > 0 {
scrollUpStyle := lipgloss.NewStyle().Foreground(theme.VeryMuted)
items = append([]string{scrollUpStyle.Render(" ↑ more above")}, items...)
}
if endIdx < len(s.filtered) {
scrollDownStyle := lipgloss.NewStyle().Foreground(theme.VeryMuted)
items = append(items, scrollDownStyle.Render(" ↓ more below"))
}
// Join items
content := strings.Join(items, "\n")
// Add footer hint
footerStyle := lipgloss.NewStyle().
Foreground(theme.VeryMuted).
Italic(true)
footer := footerStyle.Render("↑↓ navigate • tab complete • ↵ select • esc dismiss")
// Combine content and footer
popupContent := content + "\n\n" + footer
return popupStyle.Render(popupContent)
}
// Value returns the final text value entered by the user after submission.
// This will be empty if the input was cancelled.
func (s *SlashCommandInput) Value() string {
return s.value
}
// Cancelled returns true if the user cancelled the input operation (e.g., by
// pressing ESC or Ctrl+C) without submitting any text.
func (s *SlashCommandInput) Cancelled() bool {
return s.quitting && s.value == ""
}
// RenderedLines returns the total number of terminal lines used by the last
// rendered view, including the title, input area, popup, and help text. This
// is used for proper screen clearing when the input is dismissed.
func (s *SlashCommandInput) RenderedLines() int {
return s.renderedLines
}
+127 -9
View File
@@ -2,6 +2,7 @@ package ui
import (
"fmt"
"regexp"
"strings"
"time"
@@ -11,6 +12,17 @@ import (
"github.com/mark3labs/kit/internal/app"
)
// thinkTagRegex matches ... tags that some models (Qwen, DeepSeek) wrap
// reasoning content in. Used to strip these tags from streaming text content.
// The (?s) flag makes . match newlines.
var thinkTagRegex = regexp.MustCompile(`(?s)` + `` + `think` + `` + `(.*?)` + `` + `/think` + ``)
// thinkTagOpen and thinkTagClose are the opening and closing think tag strings.
const (
thinkTagOpen = "<think>"
thinkTagClose = "</think>"
)
// knightRiderFrames generates a KITT-style scanning animation where a bright
// light bounces back and forth across a row of dots with a trailing glow.
// Colors are derived from the active theme. Used by StreamComponent (TUI
@@ -193,6 +205,14 @@ type StreamComponent struct {
// the cache.
renderDirty bool
// scrollbackFlushedLines is the number of lines from the top of the
// rendered content that have already been emitted to the terminal
// scrollback buffer. On each flush, lines that overflow the allocated
// height and haven't been pushed yet are emitted via tea.Println so
// they appear in the terminal's real scrollback (scrollable with the
// terminal's own scroll mechanism).
scrollbackFlushedLines int
// thinkingVisible controls whether reasoning blocks are expanded or collapsed.
thinkingVisible bool
@@ -202,6 +222,10 @@ type StreamComponent struct {
// reasoningDuration holds the total reasoning time, frozen when streaming text begins.
reasoningDuration time.Duration
// inThinkTag tracks whether we're currently inside a section
// from models that wrap reasoning in XML-like tags (Qwen, DeepSeek).
inThinkTag bool
// renderer renders streaming assistant text in either compact or standard mode.
renderer Renderer
@@ -279,6 +303,42 @@ func (s *StreamComponent) Reset() {
s.timestamp = time.Time{}
s.reasoningStartTime = time.Time{}
s.reasoningDuration = 0
s.scrollbackFlushedLines = 0
}
// ConsumeOverflow returns any lines from the rendered stream content that have
// overflowed the allocated height and have not yet been pushed to the terminal
// scrollback buffer. It advances the internal flushed-line pointer so
// subsequent calls only return newly overflowed lines.
//
// Returns "" when there is no overflow or height is unconstrained (0).
// The caller should emit the returned string via tea.Println so the content
// appears in the terminal's real scrollback (not just discarded).
func (s *StreamComponent) ConsumeOverflow() string {
if s.height <= 0 {
return ""
}
content := s.render()
if content == "" {
return ""
}
lines := strings.Split(content, "\n")
totalLines := len(lines)
// Number of lines that overflow the viewable height.
overflowLines := totalLines - s.height
if overflowLines <= 0 {
return ""
}
// How many overflow lines are new (not yet flushed to scrollback).
newOverflow := overflowLines - s.scrollbackFlushedLines
if newOverflow <= 0 {
return ""
}
// The new overflow is lines [s.scrollbackFlushedLines .. overflowLines).
start := s.scrollbackFlushedLines
end := overflowLines
s.scrollbackFlushedLines = overflowLines
return strings.Join(lines[start:end], "\n")
}
// GetRenderedContent returns the rendered assistant message from the accumulated
@@ -287,6 +347,10 @@ func (s *StreamComponent) Reset() {
//
// This commits any pending chunks first so the output includes all received
// content, not just what has been flushed by the tick.
//
// Lines already pushed to the terminal scrollback buffer via ConsumeOverflow
// are skipped so that callers do not re-emit content that is already visible
// in the terminal's real scrollback.
func (s *StreamComponent) GetRenderedContent() string {
// Commit any pending chunks so the final output is complete.
s.commitPending()
@@ -307,14 +371,28 @@ func (s *StreamComponent) GetRenderedContent() string {
if len(sections) == 0 {
return ""
}
return strings.Join(sections, "\n")
fullContent := strings.Join(sections, "\n")
// Skip lines already emitted to the terminal scrollback via ConsumeOverflow
// so the caller doesn't re-print content that is already there.
if s.scrollbackFlushedLines > 0 {
lines := strings.Split(fullContent, "\n")
if s.scrollbackFlushedLines >= len(lines) {
return "" // everything already in scrollback
}
return strings.Join(lines[s.scrollbackFlushedLines:], "\n")
}
return fullContent
}
// commitPending moves any pending chunks to the committed content builders.
// Called before reading content for scrollback output or on flush tick.
func (s *StreamComponent) commitPending() {
if s.pendingStream.Len() > 0 {
s.streamContent.WriteString(s.pendingStream.String())
// Strip ... tags that some models wrap reasoning in
cleanedText := thinkTagRegex.ReplaceAllString(s.pendingStream.String(), "")
s.streamContent.WriteString(cleanedText)
s.pendingStream.Reset()
s.renderDirty = true
}
@@ -408,8 +486,46 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if s.reasoningDuration == 0 && !s.reasoningStartTime.IsZero() {
s.reasoningDuration = time.Since(s.reasoningStartTime)
}
s.pendingStream.WriteString(msg.Content)
if !s.flushPending {
// Handle models that wrap reasoning in tags (Qwen, DeepSeek)
// Filter out all content between and tags
content := msg.Content
// Check for opening tag
if strings.Contains(content, thinkTagOpen) {
parts := strings.SplitN(content, thinkTagOpen, 2)
// Content before the tag can be written
if !s.inThinkTag && parts[0] != "" {
s.pendingStream.WriteString(parts[0])
}
s.inThinkTag = true
// Content after the opening tag is reasoning - don't write it
if len(parts) > 1 && parts[1] != "" {
// Check if the same chunk contains the closing tag
if strings.Contains(parts[1], thinkTagClose) {
innerParts := strings.SplitN(parts[1], thinkTagClose, 2)
s.inThinkTag = false
// Content after closing tag can be written
if len(innerParts) > 1 && innerParts[1] != "" {
s.pendingStream.WriteString(innerParts[1])
}
}
}
} else if strings.Contains(content, thinkTagClose) {
// Closing tag found
parts := strings.SplitN(content, thinkTagClose, 2)
s.inThinkTag = false
// Content after closing tag can be written
if len(parts) > 1 && parts[1] != "" {
s.pendingStream.WriteString(parts[1])
}
} else if !s.inThinkTag {
// Normal content, not inside think tags
s.pendingStream.WriteString(content)
}
// else: inside think tag, don't write this content
if !s.flushPending && s.pendingStream.Len() > 0 {
s.flushPending = true
return s, streamFlushTickCmd(s.flushGeneration)
}
@@ -567,7 +683,7 @@ func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
} else {
result = strings.Join(parts, "\n")
}
return lipgloss.NewStyle().MarginBottom(1).Render(result)
return styleMarginBottom1.Render(result)
}
// SetThinkingVisible sets whether reasoning blocks are shown or collapsed.
@@ -650,10 +766,12 @@ func removeToolID(ids []string, id string) []string {
}
// formatToolExecutionMessage creates a descriptive spinner message for tool execution.
// For spawn_subagent, it shows simply as "Subagent".
func formatToolExecutionMessage(toolName string) string {
if toolName == "spawn_subagent" {
return "Subagent"
}
return toolName
}
// UpdateTheme refreshes the component's typography instance with colors from
// the current theme. This is called when the user changes themes via /theme.
func (s *StreamComponent) UpdateTheme() {
s.ty = createTypography(GetTheme())
}
+75 -248
View File
@@ -1,19 +1,11 @@
package ui
import (
"fmt"
"image/color"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/ansi"
"github.com/indaco/herald"
heraldmd "github.com/indaco/herald-md"
)
// uintPtr returns a pointer to u. Used by ansi.StyleConfig fields.
//
//go:fix inline
func uintPtr(u uint) *uint { return new(u) }
// BaseStyle returns a new, empty lipgloss style that can be customized with
// additional styling methods. This serves as the foundation for building more
// complex styled components.
@@ -21,248 +13,83 @@ func BaseStyle() lipgloss.Style {
return lipgloss.NewStyle()
}
// colorHex converts a color.Color to a hex string suitable for ansi.StyleConfig.
func colorHex(c color.Color) string {
r, g, b, _ := c.RGBA()
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
}
// markdownTypographyCache holds the last-created Typography instance for
// herald-md rendering. It is cached to avoid re-initialization on every
// streaming flush tick. The cache is invalidated by SetTheme when the
// active theme changes.
// This is only accessed from BubbleTea's single-threaded Update/View cycle,
// so no mutex is required.
var markdownTypographyCache *herald.Typography
// colorHexPtr returns a pointer to the hex string of a color.Color.
func colorHexPtr(c color.Color) *string {
s := colorHex(c)
return &s
}
// GetMarkdownRenderer creates and returns a configured glamour.TermRenderer for
// rendering markdown content with syntax highlighting and proper formatting. The
// renderer is customized with our theme colors and adapted to the specified width.
func GetMarkdownRenderer(width int) *glamour.TermRenderer {
r, _ := glamour.NewTermRenderer(
glamour.WithStyles(generateMarkdownStyleConfig()),
glamour.WithWordWrap(width),
)
return r
}
// generateMarkdownStyleConfig creates an ansi.StyleConfig from the active theme.
func generateMarkdownStyleConfig() ansi.StyleConfig {
md := GetTheme().Markdown
text := colorHexPtr(md.Text)
muted := colorHexPtr(md.Muted)
heading := colorHexPtr(md.Heading)
emph := colorHexPtr(md.Emph)
strong := colorHexPtr(md.Strong)
link := colorHexPtr(md.Link)
code := colorHexPtr(md.Code)
errClr := colorHexPtr(md.Error)
keyword := colorHexPtr(md.Keyword)
str := colorHexPtr(md.String)
number := colorHexPtr(md.Number)
comment := colorHexPtr(md.Comment)
return ansi.StyleConfig{
Document: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "",
BlockSuffix: "",
Color: text,
},
Margin: uintPtr(0),
},
BlockQuote: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: muted,
Italic: new(true),
Prefix: "┃ ",
},
Indent: uintPtr(1),
},
List: ansi.StyleList{
LevelIndent: 0,
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: text,
},
},
},
Heading: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockSuffix: "\n",
Color: heading,
Bold: new(true),
},
},
H1: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "# ",
Color: heading,
Bold: new(true),
},
},
H2: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "## ",
Color: heading,
Bold: new(true),
},
},
H3: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "### ",
Color: heading,
Bold: new(true),
},
},
H4: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "#### ",
Color: heading,
Bold: new(true),
},
},
H5: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "##### ",
Color: heading,
Bold: new(true),
},
},
H6: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "###### ",
Color: heading,
Bold: new(true),
},
},
Strikethrough: ansi.StylePrimitive{
CrossedOut: new(true),
Color: muted,
},
Emph: ansi.StylePrimitive{
Color: emph,
Italic: new(true),
},
Strong: ansi.StylePrimitive{
Bold: new(true),
Color: strong,
},
HorizontalRule: ansi.StylePrimitive{
Color: muted,
Format: "\n─────────────────────────────────────────\n",
},
Item: ansi.StylePrimitive{
BlockPrefix: "• ",
Color: text,
},
Enumeration: ansi.StylePrimitive{
BlockPrefix: ". ",
Color: text,
},
Task: ansi.StyleTask{
StylePrimitive: ansi.StylePrimitive{},
Ticked: "[✓] ",
Unticked: "[ ] ",
},
Link: ansi.StylePrimitive{
Color: link,
Underline: new(true),
},
LinkText: ansi.StylePrimitive{
Color: link,
Bold: new(true),
},
Image: ansi.StylePrimitive{
Color: link,
Underline: new(true),
Format: "🖼 {{.text}}",
},
ImageText: ansi.StylePrimitive{
Color: link,
Format: "{{.text}}",
},
Code: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: code,
Prefix: "",
Suffix: "",
},
},
CodeBlock: ansi.StyleCodeBlock{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "",
Color: code,
},
Margin: uintPtr(0),
},
Chroma: &ansi.Chroma{
Text: ansi.StylePrimitive{Color: text},
Error: ansi.StylePrimitive{Color: errClr},
Comment: ansi.StylePrimitive{Color: comment},
CommentPreproc: ansi.StylePrimitive{Color: keyword},
Keyword: ansi.StylePrimitive{Color: keyword},
KeywordReserved: ansi.StylePrimitive{Color: keyword},
KeywordNamespace: ansi.StylePrimitive{Color: keyword},
KeywordType: ansi.StylePrimitive{Color: keyword},
Operator: ansi.StylePrimitive{Color: text},
Punctuation: ansi.StylePrimitive{Color: text},
Name: ansi.StylePrimitive{Color: text},
NameBuiltin: ansi.StylePrimitive{Color: text},
NameTag: ansi.StylePrimitive{Color: keyword},
NameAttribute: ansi.StylePrimitive{Color: text},
NameClass: ansi.StylePrimitive{Color: keyword},
NameConstant: ansi.StylePrimitive{Color: text},
NameDecorator: ansi.StylePrimitive{Color: text},
NameFunction: ansi.StylePrimitive{Color: text},
LiteralNumber: ansi.StylePrimitive{Color: number},
LiteralString: ansi.StylePrimitive{Color: str},
LiteralStringEscape: ansi.StylePrimitive{
Color: keyword,
},
GenericDeleted: ansi.StylePrimitive{Color: errClr},
GenericEmph: ansi.StylePrimitive{
Color: emph,
Italic: new(true),
},
GenericInserted: ansi.StylePrimitive{Color: str},
GenericStrong: ansi.StylePrimitive{
Color: strong,
Bold: new(true),
},
GenericSubheading: ansi.StylePrimitive{
Color: heading,
},
},
},
Table: ansi.StyleTable{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "\n",
BlockSuffix: "\n",
},
},
CenterSeparator: new("┼"),
ColumnSeparator: new("│"),
RowSeparator: new("─"),
},
DefinitionDescription: ansi.StylePrimitive{
BlockPrefix: "\n ",
Color: link,
},
Text: ansi.StylePrimitive{
Color: text,
},
Paragraph: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: text,
},
},
// GetMarkdownTypography returns a herald.Typography configured with our
// active theme colors. The typography is cached and only rebuilt when
// the theme changes via SetTheme.
func GetMarkdownTypography() *herald.Typography {
if markdownTypographyCache != nil {
return markdownTypographyCache
}
theme := GetTheme()
md := theme.Markdown
// Build herald theme from our theme colors
hty := herald.Theme{
// Headings - use heading color
H1: lipgloss.NewStyle().Foreground(md.Heading).Bold(true),
H2: lipgloss.NewStyle().Foreground(md.Heading).Bold(true),
H3: lipgloss.NewStyle().Foreground(md.Heading).Bold(true),
H4: lipgloss.NewStyle().Foreground(md.Heading).Bold(true),
H5: lipgloss.NewStyle().Foreground(md.Heading).Bold(true),
H6: lipgloss.NewStyle().Foreground(md.Muted).Bold(true),
// Text blocks
Paragraph: lipgloss.NewStyle().Foreground(md.Text),
Blockquote: lipgloss.NewStyle().Foreground(md.Muted).Italic(true),
CodeInline: lipgloss.NewStyle().Foreground(md.Code),
CodeBlock: lipgloss.NewStyle().Foreground(md.Code),
HR: lipgloss.NewStyle().Foreground(md.Muted),
// Lists
ListBullet: lipgloss.NewStyle().Foreground(md.Text),
ListItem: lipgloss.NewStyle().Foreground(md.Text),
// Inline styles
Bold: lipgloss.NewStyle().Foreground(md.Strong).Bold(true),
Italic: lipgloss.NewStyle().Foreground(md.Emph).Italic(true),
Strikethrough: lipgloss.NewStyle().Foreground(md.Muted).Strikethrough(true),
Link: lipgloss.NewStyle().Foreground(md.Link).Underline(true),
// Definition lists
DT: lipgloss.NewStyle().Foreground(md.Text).Bold(true),
DD: lipgloss.NewStyle().Foreground(md.Muted),
// Key-value
KVKey: lipgloss.NewStyle().Foreground(md.Text).Bold(true),
KVValue: lipgloss.NewStyle().Foreground(md.Text),
// Badges/Tags - use semantic colors
Badge: lipgloss.NewStyle().Foreground(md.Text).Bold(true),
SuccessBadge: lipgloss.NewStyle().Foreground(theme.Success).Bold(true),
WarningBadge: lipgloss.NewStyle().Foreground(theme.Warning).Bold(true),
ErrorBadge: lipgloss.NewStyle().Foreground(theme.Error).Bold(true),
InfoBadge: lipgloss.NewStyle().Foreground(theme.Info).Bold(true),
// Heading decorations
H1UnderlineChar: "═",
H2UnderlineChar: "─",
H3UnderlineChar: "·",
}
ty := herald.New(herald.WithTheme(hty))
markdownTypographyCache = ty
return ty
}
// toMarkdown renders markdown content using glamour.
// toMarkdown renders markdown content using herald-md.
// The width parameter is currently unused as herald handles wrapping
// based on terminal width internally.
func toMarkdown(content string, width int) string {
r := GetMarkdownRenderer(width)
rendered, _ := r.Render(content)
ty := GetMarkdownTypography()
rendered := heraldmd.Render(ty, []byte(content))
return rendered
}
+10 -3
View File
@@ -129,13 +129,20 @@ type presetColors struct {
}
func makeTheme(p presetColors) Theme {
ac := func(pair [2]string) color.Color { return AdaptiveColor(pair[0], pair[1]) }
def := DefaultTheme()
acOr := func(pair [2]string, fb color.Color) color.Color {
ac := func(pair [2]string) color.Color {
c := AdaptiveColor(pair[0], pair[1])
if pair[0] == "" && pair[1] == "" {
return nil
}
return c
}
acOr := func(pair [2]string, fb color.Color) color.Color {
c := ac(pair)
if c == nil {
return fb
}
return ac(pair)
return c
}
t := Theme{
Primary: ac(p.primary),
+92 -116
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -15,6 +16,7 @@ import (
"github.com/alecthomas/chroma/v2/styles"
udiff "github.com/aymanbagabas/go-udiff"
xansi "github.com/charmbracelet/x/ansi"
"github.com/indaco/herald"
)
// Maximum visible lines per tool type before truncation.
@@ -26,6 +28,14 @@ const (
maxLsLines = 20 // lines for Ls directory listings
)
// isShellTool reports if the tool name matches a shell-like tool (bash, grep, find, or
// tools with "shell"/"command" in the name). Used by both renderToolBody and
// renderToolBodyCompact to avoid code duplication.
func isShellTool(toolName string) bool {
return toolName == "bash" || toolName == "grep" || toolName == "find" ||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command")
}
// renderToolBody dispatches to tool-specific body renderers based on tool name.
// Returns the styled body string, or empty string to fall back to default rendering.
func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
@@ -46,12 +56,11 @@ 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" ||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"):
case isShellTool(toolName):
if body := renderBashBody(toolResult, width); body != "" {
return body
}
case toolName == "spawn_subagent":
case toolName == "subagent":
if body := renderSubagentBody(toolResult, width); body != "" {
return body
}
@@ -244,7 +253,7 @@ func renderDiffBlock(before, after string, startLine int, width int) string {
gutterWidth := max(len(fmt.Sprintf("%d", maxLineNum)), 3)
contentWidth := max(panelWidth-gutterWidth-4, 10) // gutter + " - " or " + "
theme := getTheme()
theme := GetTheme()
// Styles for each cell type
gutterInsert := lipgloss.NewStyle().Foreground(theme.Muted).Background(theme.DiffInsertBg)
@@ -349,7 +358,7 @@ func renderLsBody(toolResult string, width int) string {
const indent = " "
codeWidth := max(width-len(indent), 20)
theme := getTheme()
theme := GetTheme()
codeStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
var result []string
@@ -374,137 +383,106 @@ func renderLsBody(toolResult string, width int) string {
// Read tool — code block with line numbers + syntax highlighting
// ---------------------------------------------------------------------------
// renderReadBody renders Read tool output with styled line numbers and optional
// syntax highlighting based on file extension.
// renderReadBody renders Read tool output using herald.CodeBlock with line numbers
// and syntax highlighting. Uses WithCodeLineNumberOffset to show correct offsets
// based on the Read tool's offset parameter.
func renderReadBody(toolArgs, toolResult string, width int) string {
if strings.TrimSpace(toolResult) == "" {
return ""
}
// Extract file path for syntax highlighting
// Extract file path and offset from tool args
var fileName string
var offset = 1
var args map[string]any
if err := json.Unmarshal([]byte(toolArgs), &args); err == nil {
if p, ok := args["path"].(string); ok {
fileName = p
}
if o, ok := args["offset"].(float64); ok {
offset = int(o)
}
}
return renderCodeBlock(toolResult, fileName, width)
}
// codeLine holds a parsed line with optional line number.
type codeLine struct {
lineNum string
code string
}
// renderCodeBlock renders content with a styled gutter (line numbers) and
// optional syntax highlighting.
func renderCodeBlock(content, fileName string, width int) string {
rawLines := strings.Split(content, "\n")
// Parse lines: detect "N: content" format from Read tool
var parsed []codeLine
maxNumWidth := 0
var codeOnly []string
// Parse lines to extract pure code content (removing "N: " prefixes)
rawLines := strings.Split(toolResult, "\n")
var codeLines []string
var footerLines []string
var codeHiddenCount int
for _, line := range rawLines {
// Detect "N: content" format from Read tool
if idx := strings.Index(line, ": "); idx > 0 && idx <= 7 {
numPart := line[:idx]
if _, err := strconv.Atoi(strings.TrimSpace(numPart)); err == nil {
parsed = append(parsed, codeLine{lineNum: numPart, code: line[idx+2:]})
if len(numPart) > maxNumWidth {
maxNumWidth = len(numPart)
}
codeOnly = append(codeOnly, line[idx+2:])
codeLines = append(codeLines, line[idx+2:])
continue
}
}
// No line number — treat as metadata/footer
parsed = append(parsed, codeLine{code: line})
codeOnly = append(codeOnly, line)
// No line number — treat as footer/metadata (e.g., truncation notice)
footerLines = append(footerLines, line)
}
if len(parsed) == 0 {
return ""
// Apply maxCodeLines truncation
totalCodeLines := len(codeLines)
if totalCodeLines > maxCodeLines {
codeHiddenCount = totalCodeLines - maxCodeLines
codeLines = codeLines[:maxCodeLines]
}
// Truncate to maxCodeLines visible lines (preserve footer/metadata lines)
var codeHiddenCount int
totalParsed := len(parsed)
if totalParsed > maxCodeLines {
// Check if last line is a footer (no line number) — keep it
var footerLines []codeLine
for totalParsed > 0 && parsed[totalParsed-1].lineNum == "" {
footerLines = append([]codeLine{parsed[totalParsed-1]}, footerLines...)
totalParsed--
}
if totalParsed > maxCodeLines {
codeHiddenCount = totalParsed - maxCodeLines
parsed = append(parsed[:maxCodeLines], footerLines...)
codeOnly = codeOnly[:maxCodeLines]
for _, fl := range footerLines {
codeOnly = append(codeOnly, fl.code)
}
} else {
// Restore — footer trimming was enough
parsed = parsed[:totalParsed]
parsed = append(parsed, footerLines...)
// Build language hint from file extension
lang := ""
if fileName != "" {
// Extract extension without the dot
if ext := strings.TrimPrefix(filepath.Ext(fileName), "."); ext != "" {
lang = ext
}
}
// Syntax highlight the code portion
highlighted := syntaxHighlight(strings.Join(codeOnly, "\n"), fileName)
highlightedLines := strings.Split(highlighted, "\n")
// Layout
const codeIndent = " "
gutterWidth := max(maxNumWidth+2, 5)
codeWidth := max(width-gutterWidth-len(codeIndent), 20)
theme := getTheme()
gutterStyle := lipgloss.NewStyle().Foreground(theme.Muted).Background(theme.GutterBg).PaddingRight(1)
codeStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
var result []string
for i, p := range parsed {
// If this line has no line number, it's a metadata/footer line (e.g. truncation notice).
if p.lineNum == "" {
// Render footer lines with code background but no gutter
truncatedFooter := truncateLine(p.code, codeWidth-1) // account for PaddingLeft(1)
footer := codeStyle.Width(codeWidth).Render(truncatedFooter)
emptyGutter := gutterStyle.Width(gutterWidth).Render("")
result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, emptyGutter, footer))
continue
}
gutter := gutterStyle.Width(gutterWidth).Render(p.lineNum)
var codePart string
if i < len(highlightedLines) {
codePart = highlightedLines[i]
} else {
codePart = p.code
}
// Truncate the (possibly ANSI-highlighted) line to fit within
// the code column, preventing lipgloss from wrapping it.
codePart = truncateLine(codePart, codeWidth-1) // account for PaddingLeft(1)
styledCode := codeStyle.Width(codeWidth).Render(codePart)
result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, gutter, styledCode))
// Create typography with line number offset and custom formatter
// Match Write tool: GutterBg for line numbers, CodeBg for content
codeContent := strings.Join(codeLines, "\n")
theme := GetTheme()
hty := herald.Theme{
CodeBlock: lipgloss.NewStyle().
Background(theme.CodeBg).
PaddingLeft(1),
CodeLineNumber: lipgloss.NewStyle().
Foreground(theme.Muted).
Background(theme.GutterBg),
}
ty := herald.New(
herald.WithTheme(hty),
herald.WithCodeLineNumbers(true),
herald.WithCodeLineNumberOffset(offset),
herald.WithCodeFormatter(func(code, _ string) string {
// Use our syntax highlighter with the filename for lexer detection
return syntaxHighlight(code, fileName)
}),
)
// Truncation hint
// Render the code block
result := ty.CodeBlock(codeContent, lang)
// Add truncation hint if needed
if codeHiddenCount > 0 {
hint := fmt.Sprintf("...(%d more lines)", codeHiddenCount)
emptyGutter := gutterStyle.Width(gutterWidth).Render("")
hintContent := codeStyle.Width(codeWidth).
Foreground(theme.Muted).Italic(true).Render(hint)
result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, emptyGutter, hintContent))
result += "\n" + lipgloss.NewStyle().Foreground(GetTheme().Muted).Italic(true).Render(hint)
}
return strings.Join(result, "\n")
// Add any footer lines
if len(footerLines) > 0 {
footer := strings.Join(footerLines, "\n")
result += "\n" + lipgloss.NewStyle().Foreground(GetTheme().Muted).Render(footer)
}
// Indent entire block to match Write/Edit tools (2 spaces)
const blockIndent = " "
lines := strings.Split(result, "\n")
for i, line := range lines {
lines[i] = blockIndent + line
}
return strings.Join(lines, "\n")
}
// ---------------------------------------------------------------------------
@@ -558,7 +536,7 @@ func renderWriteBlock(content, fileName string, width int) string {
gutterWidth := numDigits + 2
codeWidth := max(width-gutterWidth-len(codeIndent), 20)
theme := getTheme()
theme := GetTheme()
gutterStyle := lipgloss.NewStyle().Foreground(theme.Muted).Background(theme.GutterBg).PaddingRight(1)
writeStyle := lipgloss.NewStyle().Background(theme.WriteBg).PaddingLeft(1)
@@ -610,7 +588,7 @@ func renderBashBody(toolResult string, width int) string {
return ""
}
theme := getTheme()
theme := GetTheme()
outputStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
stderrStyle := lipgloss.NewStyle().Foreground(theme.Error).Background(theme.CodeBg).PaddingLeft(1)
@@ -627,7 +605,6 @@ func renderBashBody(toolResult string, width int) string {
const lineIndent = " "
// Truncate individual lines to the available width so they never wrap.
// This mirrors Crush's approach: truncate, don't wrap.
lineWidth := max(width-len(lineIndent), 20)
// Account for PaddingLeft(1) on the output/stderr styles
maxLineChars := lineWidth - 1
@@ -777,10 +754,9 @@ 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" ||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"):
case isShellTool(toolName):
return renderBashCompact(toolResult, width)
case toolName == "spawn_subagent":
case toolName == "subagent":
return renderSubagentCompact(toolResult)
}
return ""
@@ -809,7 +785,7 @@ func renderReadCompact(toolResult string) string {
return ""
}
theme := getTheme()
theme := GetTheme()
summary := fmt.Sprintf("%d lines", codeLines)
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
@@ -830,7 +806,7 @@ func renderEditCompact(toolArgs, toolResult string) string {
oldCount := len(strings.Split(oldText, "\n"))
newCount := len(strings.Split(newText, "\n"))
theme := getTheme()
theme := GetTheme()
var summary string
if oldCount == newCount {
summary = fmt.Sprintf("%d lines modified", oldCount)
@@ -853,7 +829,7 @@ func renderWriteCompact(toolArgs string) string {
}
count := len(strings.Split(content, "\n"))
theme := getTheme()
theme := GetTheme()
summary := fmt.Sprintf("%d lines written", count)
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
@@ -866,7 +842,7 @@ func renderLsCompact(toolResult string) string {
}
entries := strings.Split(content, "\n")
theme := getTheme()
theme := GetTheme()
summary := fmt.Sprintf("%d entries", len(entries))
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
@@ -904,14 +880,14 @@ func renderBashCompact(toolResult string, width int) string {
if len(outputLines) == 0 {
if exitCode != "" {
theme := getTheme()
theme := GetTheme()
return lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode)
}
return ""
}
const maxLines = 3
theme := getTheme()
theme := GetTheme()
display := outputLines
if len(display) > maxLines {
@@ -942,7 +918,7 @@ func renderBashCompact(toolResult string, width int) string {
// renderSubagentBody renders a clean summary of subagent results with bash-style
// background styling for consistency with other tools.
func renderSubagentBody(toolResult string, width int) string {
theme := getTheme()
theme := GetTheme()
result := strings.TrimSpace(toolResult)
if result == "" {
return ""
@@ -1058,7 +1034,7 @@ func renderSubagentCompact(toolResult string) string {
return ""
}
theme := getTheme()
theme := GetTheme()
// Extract just the first line which contains the status
lines := strings.Split(result, "\n")
+1 -17
View File
@@ -52,7 +52,6 @@ type Harness struct {
t *testing.T
runner *extensions.Runner
context *MockContext
extPath string
}
// New creates a new test harness for the given test.
@@ -72,15 +71,9 @@ func New(t *testing.T) *Harness {
func (h *Harness) LoadFile(path string) *extensions.LoadedExtension {
h.t.Helper()
// Verify file exists
if _, err := os.Stat(path); err != nil {
h.t.Fatalf("extension file not found: %s: %v", path, err)
}
// Read extension source
src, err := os.ReadFile(path)
if err != nil {
h.t.Fatalf("failed to read extension file: %v", err)
h.t.Fatalf("failed to read extension file %s: %v", path, err)
}
return h.loadSource(string(src), path)
@@ -144,7 +137,6 @@ func (h *Harness) loadSource(src string, path string) *extensions.LoadedExtensio
// Create runner with the loaded extension
h.runner = extensions.NewRunner([]extensions.LoadedExtension{*ext})
h.extPath = path
// Wire the mock context
h.runner.SetContext(h.context.ToContext())
@@ -222,11 +214,3 @@ func (h *Harness) RegisteredCommands() []extensions.CommandDef {
}
return h.runner.RegisteredCommands()
}
// MustLoad is like LoadFile but fails the test immediately on error.
// It returns the harness for chaining.
func (h *Harness) MustLoad(path string) *Harness {
h.t.Helper()
h.LoadFile(path)
return h
}
-17
View File
@@ -59,29 +59,12 @@ type MockContext struct {
Overlays []extensions.OverlayConfig
}
// StatusBarEntry represents a recorded status bar entry
type StatusBarEntry struct {
Key string
Text string
Priority int
}
// NewMockContext creates a new mock context with default values.
func NewMockContext() *MockContext {
return &MockContext{
Prints: make([]string, 0),
PrintInfos: make([]string, 0),
PrintErrors: make([]string, 0),
PrintBlocks: make([]extensions.PrintBlockOpts, 0),
Messages: make([]string, 0),
CancelSends: make([]string, 0),
Widgets: make(map[string]extensions.WidgetConfig),
RemovedIDs: make([]string, 0),
StatusEntries: make(map[string]extensions.StatusBarEntry),
RemovedStatus: make([]string, 0),
EditorTexts: make([]string, 0),
Options: make(map[string]string),
Overlays: make([]extensions.OverlayConfig, 0),
Interactive: true,
SessionID: "test-session",
CWD: "/test",
+76 -41
View File
@@ -1,6 +1,6 @@
# KIT SDK
The KIT SDK allows you to use KIT programmatically from Go applications without spawning OS processes.
The KIT SDK (`pkg/kit`) lets you embed Kit's full agent capabilities — LLM interactions, tool execution, session management, streaming, hooks — into any Go application.
## Installation
@@ -17,26 +17,26 @@ import (
"context"
"fmt"
"log"
kit "github.com/mark3labs/kit/pkg/kit"
)
func main() {
ctx := context.Background()
// Create Kit instance with default configuration
host, err := kit.New(ctx, nil)
if err != nil {
log.Fatal(err)
}
defer host.Close()
defer func() { _ = host.Close() }()
// Send a prompt
response, err := host.Prompt(ctx, "What is 2+2?")
if err != nil {
log.Fatal(err)
}
fmt.Println(response)
}
```
@@ -56,11 +56,23 @@ You can override specific settings:
```go
host, err := kit.New(ctx, &kit.Options{
Model: "ollama/llama3", // Override model
SystemPrompt: "You are a helpful bot", // Override system prompt
ConfigFile: "/path/to/config.yml", // Use specific config file
MaxSteps: 10, // Override max steps
Streaming: true, // Enable streaming
Quiet: true, // Suppress debug output
SystemPrompt: "You are a helpful bot", // Override system prompt
ConfigFile: "/path/to/config.yml", // Use specific config file
MaxSteps: 10, // Override max steps
Streaming: true, // Enable streaming
Quiet: true, // Suppress debug output
// Session options
SessionPath: "./session.jsonl", // Open specific session
Continue: true, // Resume most recent session
NoSession: true, // Ephemeral mode
// Tool options
Tools: []kit.Tool{kit.NewBashTool()}, // Replace default tool set
ExtraTools: []kit.Tool{myTool}, // Add alongside defaults
// Compaction
AutoCompact: true, // Auto-compact near context limit
})
```
@@ -71,22 +83,28 @@ host, err := kit.New(ctx, &kit.Options{
Monitor tool execution in real-time:
```go
response, err := host.PromptWithCallbacks(
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(
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
},
)
```
@@ -102,35 +120,52 @@ host.Prompt(ctx, "My name is Alice")
response, _ := host.Prompt(ctx, "What's my name?")
// Response: "Your name is Alice"
// Save session
host.SaveSession("./session.json")
// Load session later
host.LoadSession("./session.json")
// Clear session
// Clear conversation history
host.ClearSession()
```
## Re-exported Types
The SDK re-exports types so you don't need direct internal imports:
```go
// Message types
kit.Message, kit.MessageRole, kit.ContentPart
kit.TextContent, kit.ReasoningContent, kit.ToolCall, kit.ToolResult, kit.Finish
kit.RoleUser, kit.RoleAssistant, kit.RoleTool, kit.RoleSystem
// LLM types (re-exported from the underlying LLM library)
kit.LLMMessage, kit.LLMUsage, kit.LLMResponse, kit.LLMFilePart
// Conversion helpers
msgs := kit.ConvertToLLMMessages(&msg) // SDK message → LLM messages
msg := kit.ConvertFromLLMMessage(fMsg) // LLM message → SDK message
```
## API Reference
### Types
- `Kit` - Main SDK type
- `Options` - Configuration options
- `Message` - Conversation message
- `ToolCall` - Tool invocation details
- `Message` - Conversation message with typed content parts
- `Tool` - Agent tool interface
- `TurnResult` - Full result from a prompt including usage stats
### Methods
### Key Methods
- `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
- `GetSessionManager()` - Get session manager for advanced usage
- `Prompt(ctx, message)` - Send message and get response string
- `PromptResult(ctx, message)` - Send message and get full TurnResult
- `PromptWithOptions(ctx, message, opts)` - Prompt with per-call options
- `Steer(ctx, instruction)` - System-level steering
- `FollowUp(ctx, text)` - Continue without new user input
- `SetModel(ctx, model)` - Switch model at runtime
- `GetModelString()` - Get current model string
- `GetModelInfo()` - Get model capabilities and limits
- `ClearSession()` - Clear conversation history
- `GetSessionPath()` - Get session file path
- `GetSessionID()` - Get session UUID
- `Close()` - Clean up resources
## Environment Variables
+11 -9
View File
@@ -1,6 +1,10 @@
package kit
import "github.com/mark3labs/kit/internal/auth"
import (
"os"
"github.com/mark3labs/kit/internal/auth"
)
// CredentialManager manages API keys and OAuth credentials.
type CredentialManager = auth.CredentialManager
@@ -66,14 +70,12 @@ func HasOpenAICredentials() bool {
// Returns an empty string if no key is found.
func GetOpenAIAPIKey() string {
cm, err := auth.NewCredentialManager()
if err != nil {
return ""
}
// Try to get valid access token (handles OAuth refresh)
token, err := cm.GetValidOpenAIAccessToken()
if err == nil && token != "" {
return token
if err == nil {
// Try to get valid access token (handles OAuth refresh)
if token, err := cm.GetValidOpenAIAccessToken(); err == nil && token != "" {
return token
}
}
// Fall back to environment variable
return ""
return os.Getenv("OPENAI_API_KEY")
}
+46 -42
View File
@@ -2,6 +2,7 @@ package kit
import (
"context"
"errors"
"fmt"
"charm.land/fantasy"
@@ -17,10 +18,14 @@ type ContextStats struct {
MessageCount int // Number of messages in the conversation
}
// defaultReserveTokens is the number of tokens to keep free in the context
// window as a safety margin during compaction checks.
const defaultReserveTokens = 16384
// EstimateContextTokens returns the estimated token count of the current
// conversation based on tree session messages.
func (m *Kit) EstimateContextTokens() int {
messages := m.treeSession.GetFantasyMessages()
messages := m.treeSession.GetLLMMessages()
return compaction.EstimateMessageTokens(messages)
}
@@ -34,12 +39,12 @@ func (m *Kit) ShouldCompact() bool {
return false
}
reserveTokens := 16384
reserveTokens := defaultReserveTokens
if m.compactionOpts != nil && m.compactionOpts.ReserveTokens > 0 {
reserveTokens = m.compactionOpts.ReserveTokens
}
messages := m.treeSession.GetFantasyMessages()
messages := m.treeSession.GetLLMMessages()
return compaction.ShouldCompact(messages, info.Limit.Context, reserveTokens)
}
@@ -52,7 +57,7 @@ func (m *Kit) ShouldCompact() bool {
// because it includes system prompts, tool definitions, and other overhead
// that the heuristic cannot account for.
func (m *Kit) GetContextStats() ContextStats {
messages := m.treeSession.GetFantasyMessages()
messages := m.treeSession.GetLLMMessages()
// Prefer the real API-reported input token count when available.
m.lastInputTokensMu.RLock()
@@ -111,7 +116,7 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust
}
}
messages := m.treeSession.GetFantasyMessages()
messages := m.treeSession.GetLLMMessages()
if len(messages) < 2 {
return nil, fmt.Errorf("cannot compact: need at least 2 messages")
}
@@ -131,7 +136,7 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust
if reason == "" {
reason = "compaction cancelled by extension"
}
return nil, fmt.Errorf("%s", reason)
return nil, errors.New(reason)
}
// Extension provided a custom summary — use it directly.
if hookResult.Summary != "" {
@@ -166,27 +171,10 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust
firstKeptEntryID = entryIDs[result.CutPoint]
}
if _, err := m.treeSession.AppendCompaction(
result.Summary,
firstKeptEntryID,
result.OriginalTokens,
result.CompactedTokens,
result.MessagesRemoved,
result.ReadFiles,
result.ModifiedFiles,
); err != nil {
return nil, fmt.Errorf("failed to persist compaction entry: %w", err)
if err := m.persistAndEmitCompaction(result.Summary, firstKeptEntryID, result.OriginalTokens, result.CompactedTokens, result.MessagesRemoved, result.ReadFiles, result.ModifiedFiles); err != nil {
return nil, err
}
m.events.emit(CompactionEvent{
Summary: result.Summary,
OriginalTokens: result.OriginalTokens,
CompactedTokens: result.CompactedTokens,
MessagesRemoved: result.MessagesRemoved,
ReadFiles: result.ReadFiles,
ModifiedFiles: result.ModifiedFiles,
})
return result, nil
}
@@ -218,17 +206,6 @@ func (m *Kit) applyCustomCompaction(summary string, messages []fantasy.Message,
recentTokens := compaction.EstimateMessageTokens(messages[cutPoint:])
compactedTokens := summaryTokens + recentTokens
if _, err := m.treeSession.AppendCompaction(
summary,
firstKeptEntryID,
originalTokens,
compactedTokens,
cutPoint,
nil, nil, // no file tracking for custom summaries
); err != nil {
return nil, fmt.Errorf("failed to persist compaction entry: %w", err)
}
result := &CompactionResult{
Summary: summary,
OriginalTokens: originalTokens,
@@ -236,12 +213,39 @@ func (m *Kit) applyCustomCompaction(summary string, messages []fantasy.Message,
MessagesRemoved: cutPoint,
}
m.events.emit(CompactionEvent{
Summary: result.Summary,
OriginalTokens: result.OriginalTokens,
CompactedTokens: result.CompactedTokens,
MessagesRemoved: result.MessagesRemoved,
})
if err := m.persistAndEmitCompaction(summary, firstKeptEntryID, originalTokens, compactedTokens, cutPoint, nil, nil); err != nil {
return nil, err
}
return result, nil
}
// persistAndEmitCompaction writes a CompactionEntry to the session tree and
// emits a CompactionEvent. It is the single implementation shared by
// compactInternal and applyCustomCompaction.
func (m *Kit) persistAndEmitCompaction(
summary, firstKeptEntryID string,
originalTokens, compactedTokens, messagesRemoved int,
readFiles, modifiedFiles []string,
) error {
if _, err := m.treeSession.AppendCompaction(
summary,
firstKeptEntryID,
originalTokens,
compactedTokens,
messagesRemoved,
readFiles,
modifiedFiles,
); err != nil {
return fmt.Errorf("failed to persist compaction entry: %w", err)
}
m.events.emit(CompactionEvent{
Summary: summary,
OriginalTokens: originalTokens,
CompactedTokens: compactedTokens,
MessagesRemoved: messagesRemoved,
ReadFiles: readFiles,
ModifiedFiles: modifiedFiles,
})
return nil
}
+11 -11
View File
@@ -12,6 +12,10 @@ import (
// defaultSystemPrompt is the built-in system prompt used when no custom
// prompt is configured. It describes the available core tools and provides
// usage guidelines.
//
// NOTE: Keep this in sync with the CLI default in cmd/root.go (search for
// defaultSystemPrompt or system-prompt flag default). Changes here should
// generally be reflected there, and vice versa.
const defaultSystemPrompt = `You are an expert coding assistant operating inside kit, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.
Available tools:
@@ -78,20 +82,16 @@ func InitConfig(configFile string, debug bool) error {
viper.AddConfigPath(home)
configLoaded := false
configNames := []string{".kit"}
for _, name := range configNames {
viper.SetConfigName(name)
if err := viper.ReadInConfig(); err == nil {
configPath := viper.ConfigFileUsed()
if err := LoadConfigWithEnvSubstitution(configPath); err != nil {
if strings.Contains(err.Error(), "environment variable substitution failed") {
return fmt.Errorf("error reading config file '%s': %w", configPath, err)
}
continue
viper.SetConfigName(".kit")
if err := viper.ReadInConfig(); err == nil {
configPath := viper.ConfigFileUsed()
if err := LoadConfigWithEnvSubstitution(configPath); err != nil {
if strings.Contains(err.Error(), "environment variable substitution failed") {
return fmt.Errorf("error reading config file '%s': %w", configPath, err)
}
} else {
configLoaded = true
break
}
}
+16 -52
View File
@@ -70,20 +70,20 @@ const (
ToolKindEdit = "edit" // File modification (edit, write)
ToolKindRead = "read" // File reading (read, ls)
ToolKindSearch = "search" // Content/file search (grep, find)
ToolKindSubagent = "agent" // Subagent spawning (spawn_subagent)
ToolKindSubagent = "agent" // Subagent spawning (subagent)
)
// coreToolKinds maps built-in tool names to their kind. MCP and extension
// tools without an entry default to ToolKindExecute.
var coreToolKinds = map[string]string{
"bash": ToolKindExecute,
"edit": ToolKindEdit,
"write": ToolKindEdit,
"read": ToolKindRead,
"ls": ToolKindRead,
"grep": ToolKindSearch,
"find": ToolKindSearch,
"spawn_subagent": ToolKindSubagent,
"bash": ToolKindExecute,
"edit": ToolKindEdit,
"write": ToolKindEdit,
"read": ToolKindRead,
"ls": ToolKindRead,
"grep": ToolKindSearch,
"find": ToolKindSearch,
"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 spawn_subagent tool
SubagentSessionID string `json:"subagent_session_id,omitempty"` // Present for subagent tool
}
// FileDiffInfo describes a file modification from an edit or write tool.
@@ -416,68 +416,32 @@ func (m *Kit) OnTurnEnd(handler func(TurnEndEvent)) func() {
// Subagent event subscriptions
// ---------------------------------------------------------------------------
// subagentListenerSet holds per-tool-call listeners for subagent events.
type subagentListenerSet struct {
mu sync.RWMutex
listeners map[int]EventListener
nextID int
}
func newSubagentListenerSet() *subagentListenerSet {
return &subagentListenerSet{listeners: make(map[int]EventListener)}
}
func (s *subagentListenerSet) add(listener EventListener) func() {
s.mu.Lock()
id := s.nextID
s.nextID++
s.listeners[id] = listener
s.mu.Unlock()
return func() {
s.mu.Lock()
delete(s.listeners, id)
s.mu.Unlock()
}
}
func (s *subagentListenerSet) emit(event Event) {
s.mu.RLock()
snapshot := make([]EventListener, 0, len(s.listeners))
for _, l := range s.listeners {
snapshot = append(snapshot, l)
}
s.mu.RUnlock()
for _, l := range snapshot {
l(event)
}
}
// SubscribeSubagent registers a listener for real-time events from a subagent
// identified by its tool call ID. Returns an unsubscribe function.
//
// 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 spawn_subagent call,
// tool call ID doesn't correspond to an active or future subagent call,
// the listener simply never fires.
//
// Typical usage — register inside an OnToolCall handler:
//
// kit.OnToolCall(func(e kit.ToolCallEvent) {
// if e.ToolName == "spawn_subagent" {
// if e.ToolName == "subagent" {
// kit.SubscribeSubagent(e.ToolCallID, func(child kit.Event) {
// // real-time subagent events
// })
// }
// })
func (m *Kit) SubscribeSubagent(toolCallID string, listener EventListener) func() {
actual, _ := m.subagentListeners.LoadOrStore(toolCallID, newSubagentListenerSet())
return actual.(*subagentListenerSet).add(listener)
actual, _ := m.subagentListeners.LoadOrStore(toolCallID, newEventBus())
return actual.(*eventBus).subscribe(listener)
}
// getSubagentListenerSet returns the listener set for a tool call, or nil.
func (m *Kit) getSubagentListenerSet(toolCallID string) *subagentListenerSet {
func (m *Kit) getSubagentListenerSet(toolCallID string) *eventBus {
if v, ok := m.subagentListeners.Load(toolCallID); ok {
return v.(*subagentListenerSet)
return v.(*eventBus)
}
return nil
}
+23 -2
View File
@@ -140,8 +140,14 @@ func TestEventBusConcurrentSubscribeEmit(t *testing.T) {
wg.Wait()
// We can't assert an exact count because subscribe/emit ordering is
// non-deterministic, but it must not panic or deadlock.
t.Logf("total events received across subscribers: %d", total.Load())
// non-deterministic, but we can assert the count is non-negative and
// that no events were lost (each subscriber that registered before an
// emit must have received it at least partially).
got := total.Load()
if got < 0 {
t.Errorf("expected non-negative total event count, got %d", got)
}
t.Logf("total events received across subscribers: %d", got)
}
// TestEventBusEmitNoListeners verifies emit is a no-op with no subscribers.
@@ -169,6 +175,11 @@ func TestEventTypes(t *testing.T) {
{ToolResultEvent{}, EventToolResult},
{ToolCallContentEvent{}, EventToolCallContent},
{ResponseEvent{}, EventResponse},
{CompactionEvent{}, EventCompaction},
{ReasoningDeltaEvent{}, EventReasoningDelta},
{ToolOutputEvent{}, EventToolOutput},
{StepUsageEvent{}, EventStepUsage},
{SteerConsumedEvent{}, EventSteerConsumed},
}
for _, tt := range tests {
@@ -212,26 +223,36 @@ func TestEventOrdering(t *testing.T) {
EventTurnStart,
EventMessageStart,
EventMessageUpdate,
EventReasoningDelta,
EventToolOutput,
EventToolCall,
EventToolExecutionStart,
EventToolExecutionEnd,
EventToolResult,
EventToolCallContent,
EventMessageEnd,
EventStepUsage,
EventResponse,
EventCompaction,
EventSteerConsumed,
EventTurnEnd,
}
bus.emit(TurnStartEvent{})
bus.emit(MessageStartEvent{})
bus.emit(MessageUpdateEvent{Chunk: "hello"})
bus.emit(ReasoningDeltaEvent{Delta: "thinking..."})
bus.emit(ToolOutputEvent{ToolName: "bash", Chunk: "output"})
bus.emit(ToolCallEvent{ToolName: "bash"})
bus.emit(ToolExecutionStartEvent{ToolName: "bash"})
bus.emit(ToolExecutionEndEvent{ToolName: "bash"})
bus.emit(ToolResultEvent{ToolName: "bash", Result: "ok"})
bus.emit(ToolCallContentEvent{Content: "I'll run bash"})
bus.emit(MessageEndEvent{Content: "done"})
bus.emit(StepUsageEvent{InputTokens: 100})
bus.emit(ResponseEvent{Content: "done"})
bus.emit(CompactionEvent{Summary: "compacted"})
bus.emit(SteerConsumedEvent{Count: 1})
bus.emit(TurnEndEvent{Response: "done"})
if len(types) != len(expected) {
+435
View File
@@ -0,0 +1,435 @@
package kit
import (
"fmt"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/session"
)
// ExtensionAPI provides grouped access to all extension-related functionality.
// This cleans up the main Kit API surface while keeping all extension capabilities available.
type ExtensionAPI interface {
// Context management
SetContext(ctx extensions.Context)
GetContext() extensions.Context
UpdateContextModel(model string)
// Widgets
SetWidget(config extensions.WidgetConfig)
RemoveWidget(id string)
GetWidgets(placement extensions.WidgetPlacement) []extensions.WidgetConfig
// Header/Footer
SetHeader(config extensions.HeaderFooterConfig)
RemoveHeader()
GetHeader() *extensions.HeaderFooterConfig
SetFooter(config extensions.HeaderFooterConfig)
RemoveFooter()
GetFooter() *extensions.HeaderFooterConfig
// Editor
SetEditor(config extensions.EditorConfig)
ResetEditor()
GetEditor() *extensions.EditorConfig
// UI Visibility
SetUIVisibility(v extensions.UIVisibility)
GetUIVisibility() *extensions.UIVisibility
// Tool rendering
GetToolRenderer(toolName string) *extensions.ToolRenderConfig
GetMessageRenderer(name string) *extensions.MessageRendererConfig
// Session data
GetSessionMessages() []extensions.SessionMessage
AppendEntry(extType, data string) (string, error)
GetEntries(extType string) []extensions.ExtensionEntry
// Status bar
SetStatus(entry extensions.StatusBarEntry)
RemoveStatus(key string)
GetStatusEntries() []extensions.StatusBarEntry
// Shortcuts
GetShortcuts() map[string]func()
// Tools
GetToolInfos() []extensions.ToolInfo
SetActiveTools(names []string)
// Options
GetOption(name string) string
SetOption(name, value string)
// Events
EmitSessionStart()
EmitModelChange(newModel, previousModel, source string)
EmitCustomEvent(name, data string)
EmitBeforeFork(targetID string, isUserMsg bool, userText string) (cancelled bool, reason string)
EmitBeforeSessionSwitch(switchReason string) (cancelled bool, reason string)
// Commands
Commands() []extensions.CommandDef
// Lifecycle
Reload() error
HasExtensions() bool
}
// extensionAPI implements ExtensionAPI by wrapping a Kit instance.
type extensionAPI struct {
kit *Kit
}
// Extensions returns the ExtensionAPI for accessing all extension-related functionality.
func (m *Kit) Extensions() ExtensionAPI {
return &extensionAPI{kit: m}
}
// Context management
func (e *extensionAPI) SetContext(ctx extensions.Context) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetContext(ctx)
}
}
func (e *extensionAPI) GetContext() extensions.Context {
if e.kit.extRunner != nil {
return e.kit.extRunner.GetContext()
}
return extensions.Context{}
}
func (e *extensionAPI) UpdateContextModel(model string) {
if e.kit.extRunner != nil {
ctx := e.kit.extRunner.GetContext()
ctx.Model = model
e.kit.extRunner.SetContext(ctx)
}
}
// Widgets
func (e *extensionAPI) SetWidget(config extensions.WidgetConfig) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetWidget(config)
}
}
func (e *extensionAPI) RemoveWidget(id string) {
if e.kit.extRunner != nil {
e.kit.extRunner.RemoveWidget(id)
}
}
func (e *extensionAPI) GetWidgets(placement extensions.WidgetPlacement) []extensions.WidgetConfig {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetWidgets(placement)
}
// Header/Footer
func (e *extensionAPI) SetHeader(config extensions.HeaderFooterConfig) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetHeader(config)
}
}
func (e *extensionAPI) RemoveHeader() {
if e.kit.extRunner != nil {
e.kit.extRunner.RemoveHeader()
}
}
func (e *extensionAPI) GetHeader() *extensions.HeaderFooterConfig {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetHeader()
}
func (e *extensionAPI) SetFooter(config extensions.HeaderFooterConfig) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetFooter(config)
}
}
func (e *extensionAPI) RemoveFooter() {
if e.kit.extRunner != nil {
e.kit.extRunner.RemoveFooter()
}
}
func (e *extensionAPI) GetFooter() *extensions.HeaderFooterConfig {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetFooter()
}
// Editor
func (e *extensionAPI) SetEditor(config extensions.EditorConfig) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetEditor(config)
}
}
func (e *extensionAPI) ResetEditor() {
if e.kit.extRunner != nil {
e.kit.extRunner.ResetEditor()
}
}
func (e *extensionAPI) GetEditor() *extensions.EditorConfig {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetEditor()
}
// UI Visibility
func (e *extensionAPI) SetUIVisibility(v extensions.UIVisibility) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetUIVisibility(v)
}
}
func (e *extensionAPI) GetUIVisibility() *extensions.UIVisibility {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetUIVisibility()
}
// Tool rendering
func (e *extensionAPI) GetToolRenderer(toolName string) *extensions.ToolRenderConfig {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetToolRenderer(toolName)
}
func (e *extensionAPI) GetMessageRenderer(name string) *extensions.MessageRendererConfig {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetMessageRenderer(name)
}
// Session data
func (e *extensionAPI) GetSessionMessages() []extensions.SessionMessage {
return iterBranchMessages(e.kit.treeSession, func(me *session.MessageEntry, msg message.Message) extensions.SessionMessage {
return extensions.SessionMessage{
ID: me.ID,
Role: string(msg.Role),
Content: msg.Content(),
Timestamp: me.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
}
})
}
func (e *extensionAPI) AppendEntry(extType, data string) (string, error) {
if e.kit.treeSession == nil {
return "", fmt.Errorf("no session available")
}
return e.kit.treeSession.AppendExtensionData(extType, data)
}
func (e *extensionAPI) GetEntries(extType string) []extensions.ExtensionEntry {
if e.kit.treeSession == nil {
return nil
}
entries := e.kit.treeSession.GetExtensionData(extType)
result := make([]extensions.ExtensionEntry, 0, len(entries))
for _, e := range entries {
result = append(result, extensions.ExtensionEntry{
ID: e.ID,
EntryType: e.ExtType,
Data: e.Data,
Timestamp: e.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
})
}
return result
}
// Status bar
func (e *extensionAPI) SetStatus(entry extensions.StatusBarEntry) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetStatusEntry(entry)
}
}
func (e *extensionAPI) RemoveStatus(key string) {
if e.kit.extRunner != nil {
e.kit.extRunner.RemoveStatusEntry(key)
}
}
func (e *extensionAPI) GetStatusEntries() []extensions.StatusBarEntry {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetStatusEntries()
}
// Shortcuts
func (e *extensionAPI) GetShortcuts() map[string]func() {
if e.kit.extRunner == nil {
return nil
}
entries := e.kit.extRunner.GetShortcuts()
if entries == nil {
return nil
}
result := make(map[string]func(), len(entries))
for key, entry := range entries {
h := entry.Handler
r := e.kit.extRunner
result[key] = func() {
ctx := r.GetContext()
h(ctx)
}
}
return result
}
// Tools
func (e *extensionAPI) GetToolInfos() []extensions.ToolInfo {
agentTools := e.kit.agent.GetTools()
coreCount := e.kit.agent.GetCoreToolCount()
mcpCount := e.kit.agent.GetMCPToolCount()
result := make([]extensions.ToolInfo, 0, len(agentTools))
for i, t := range agentTools {
info := t.Info()
source := "core"
if i >= coreCount && i < coreCount+mcpCount {
source = "mcp"
} else if i >= coreCount+mcpCount {
source = "extension"
}
enabled := true
if e.kit.extRunner != nil && e.kit.extRunner.IsToolDisabled(info.Name) {
enabled = false
}
result = append(result, extensions.ToolInfo{
Name: info.Name,
Description: info.Description,
Source: source,
Enabled: enabled,
})
}
return result
}
func (e *extensionAPI) SetActiveTools(names []string) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetActiveTools(names)
}
}
// Options
func (e *extensionAPI) GetOption(name string) string {
if e.kit.extRunner == nil {
return ""
}
return e.kit.extRunner.GetOption(name)
}
func (e *extensionAPI) SetOption(name, value string) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetOption(name, value)
}
}
// Events
func (e *extensionAPI) EmitSessionStart() {
if e.kit.extRunner != nil && e.kit.extRunner.HasHandlers(extensions.SessionStart) {
_, _ = e.kit.extRunner.Emit(extensions.SessionStartEvent{})
}
}
func (e *extensionAPI) EmitModelChange(newModel, previousModel, source string) {
if e.kit.extRunner != nil && e.kit.extRunner.HasHandlers(extensions.ModelChange) {
_, _ = e.kit.extRunner.Emit(extensions.ModelChangeEvent{
NewModel: newModel,
PreviousModel: previousModel,
Source: source,
})
}
}
func (e *extensionAPI) EmitCustomEvent(name, data string) {
if e.kit.extRunner != nil {
e.kit.extRunner.EmitCustomEvent(name, data)
}
}
func (e *extensionAPI) EmitBeforeFork(targetID string, isUserMsg bool, userText string) (cancelled bool, reason string) {
if e.kit.extRunner == nil || !e.kit.extRunner.HasHandlers(extensions.BeforeFork) {
return false, ""
}
result, _ := e.kit.extRunner.Emit(extensions.BeforeForkEvent{
TargetID: targetID,
IsUserMessage: isUserMsg,
UserText: userText,
})
if r, ok := result.(extensions.BeforeForkResult); ok && r.Cancel {
reason := r.Reason
if reason == "" {
reason = "Fork cancelled by extension."
}
return true, reason
}
return false, ""
}
func (e *extensionAPI) EmitBeforeSessionSwitch(switchReason string) (cancelled bool, reason string) {
if e.kit.extRunner == nil || !e.kit.extRunner.HasHandlers(extensions.BeforeSessionSwitch) {
return false, ""
}
result, _ := e.kit.extRunner.Emit(extensions.BeforeSessionSwitchEvent{
Reason: switchReason,
})
if r, ok := result.(extensions.BeforeSessionSwitchResult); ok && r.Cancel {
reason := r.Reason
if reason == "" {
reason = "Session switch cancelled by extension."
}
return true, reason
}
return false, ""
}
// Commands
func (e *extensionAPI) Commands() []extensions.CommandDef {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.RegisteredCommands()
}
// Lifecycle
func (e *extensionAPI) Reload() error {
return e.kit.ReloadExtensions()
}
func (e *extensionAPI) HasExtensions() bool {
return e.kit.extRunner != nil
}
+20 -40
View File
@@ -104,11 +104,9 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
if runner.HasHandlers(extensions.AgentEnd) {
m.Subscribe(func(e Event) {
if ev, ok := e.(TurnEndEvent); ok {
stopReason := ev.StopReason
response := ev.Response
stopReason, response := ev.StopReason, ev.Response
if ev.Error != nil {
stopReason = "error"
response = ""
stopReason, response = "error", ""
} else if stopReason == "" {
stopReason = "completed"
}
@@ -126,9 +124,9 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
// extension runner.
//
// Flow:
// ToolExecutionStartEvent(spawn_subagent) → emit SubagentStartEvent
// ToolExecutionStartEvent(subagent) → emit SubagentStartEvent
// → SubscribeSubagent → emit SubagentChunkEvents
// ToolResultEvent(spawn_subagent) → emit SubagentEndEvent
// ToolResultEvent(subagent) → emit SubagentEndEvent
//
// We use ToolExecutionStart (not ToolCall) for SubagentStart because that
// is when the subagent actually begins running. We use ToolResult for
@@ -141,12 +139,12 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
// taskByCallID tracks the task description extracted from ToolCall input,
// keyed by toolCallID. Populated on ToolCall, consumed on ToolResult.
taskByCallID := make(map[string]string)
var taskMu = &taskMutex{}
var taskMu sync.Mutex
// Intercept ToolCall to capture the task and subscribe to child events.
m.Subscribe(func(e Event) {
ev, ok := e.(ToolCallEvent)
if !ok || ev.ToolName != "spawn_subagent" {
if !ok || ev.ToolName != "subagent" {
return
}
@@ -157,7 +155,9 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
task = t
}
}
taskMu.set(taskByCallID, ev.ToolCallID, task)
taskMu.Lock()
taskByCallID[ev.ToolCallID] = task
taskMu.Unlock()
// Subscribe to child events so we can forward them as SubagentChunkEvents.
if runner.HasHandlers(extensions.SubagentChunk) {
@@ -201,10 +201,12 @@ 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 != "spawn_subagent" {
if !ok || ev.ToolName != "subagent" {
return
}
task := taskMu.get(taskByCallID, ev.ToolCallID)
taskMu.Lock()
task := taskByCallID[ev.ToolCallID]
taskMu.Unlock()
_, _ = runner.Emit(extensions.SubagentStartEvent{
ToolCallID: ev.ToolCallID,
Task: task,
@@ -216,11 +218,13 @@ 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 != "spawn_subagent" {
if !ok || ev.ToolName != "subagent" {
return
}
task := taskMu.get(taskByCallID, ev.ToolCallID)
taskMu.del(taskByCallID, ev.ToolCallID)
taskMu.Lock()
task := taskByCallID[ev.ToolCallID]
delete(taskByCallID, ev.ToolCallID)
taskMu.Unlock()
errMsg := ""
if ev.IsError {
errMsg = ev.Result
@@ -243,7 +247,7 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
// Extension ContextPrepare → SDK ContextPrepare hook.
if runner.HasHandlers(extensions.ContextPrepare) {
m.OnContextPrepare(HookPriorityNormal, func(h ContextPrepareHook) *ContextPrepareResult {
// Convert fantasy.Message slice to extension ContextMessage slice.
// Convert LLM message slice to extension ContextMessage slice.
extMsgs := make([]extensions.ContextMessage, len(h.Messages))
for i, msg := range h.Messages {
// Extract text from content parts.
@@ -266,7 +270,7 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
return nil
}
// Rebuild fantasy.Message slice from extension result.
// Rebuild LLM message slice from extension result.
rebuilt := make([]fantasy.Message, 0, len(r.Messages))
for _, cm := range r.Messages {
if cm.Index >= 0 && cm.Index < len(h.Messages) {
@@ -324,27 +328,3 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
})
}
}
// taskMutex is a simple mutex-protected map helper used by bridgeExtensions.
// It lives in this file to avoid polluting the kit package with unexported types.
type taskMutex struct {
mu sync.Mutex
}
func (t *taskMutex) set(m map[string]string, key, val string) {
t.mu.Lock()
m[key] = val
t.mu.Unlock()
}
func (t *taskMutex) get(m map[string]string, key string) string {
t.mu.Lock()
defer t.mu.Unlock()
return m[key]
}
func (t *taskMutex) del(m map[string]string, key string) {
t.mu.Lock()
delete(m, key)
t.mu.Unlock()
}
+7 -2
View File
@@ -82,7 +82,7 @@ type AfterTurnResult struct{}
// is assembled from the session tree (including compaction) and before the
// messages are sent to the LLM. Hooks can filter, reorder, or inject messages.
type ContextPrepareHook struct {
// Messages is the current context as fantasy.Message objects.
// Messages is the current context as LLM message objects.
Messages []fantasy.Message
}
@@ -167,8 +167,13 @@ func (hr *hookRegistry[In, Out]) register(p HookPriority, h func(In) *Out) func(
}
// run executes all hooks in priority order. The first non-nil result wins.
// Returns nil immediately if no hooks are registered.
func (hr *hookRegistry[In, Out]) run(input In) *Out {
hr.mu.RLock()
if len(hr.hooks) == 0 {
hr.mu.RUnlock()
return nil
}
snapshot := make([]hookEntry[In, Out], len(hr.hooks))
copy(snapshot, hr.hooks)
hr.mu.RUnlock()
@@ -247,7 +252,7 @@ func (m *Kit) OnBeforeCompact(p HookPriority, h func(BeforeCompactHook) *BeforeC
// Tool wrapping via hooks
// ---------------------------------------------------------------------------
// hookedTool wraps a fantasy.AgentTool to run BeforeToolCall and
// hookedTool wraps an AgentTool to run BeforeToolCall and
// AfterToolResult hooks around each execution. The registries are referenced
// by pointer so hooks added after agent creation are still invoked.
type hookedTool struct {
+13 -26
View File
@@ -107,6 +107,11 @@ func TestHookRegistry_SamePriorityPreservesOrder(t *testing.T) {
func TestHookRegistry_Unregister(t *testing.T) {
hr := newHookRegistry[string, string]()
// Verify initial state (merged from TestHookRegistry_HasHooks).
if hr.hasHooks() {
t.Error("expected hasHooks to be false initially")
}
unregister := hr.register(HookPriorityNormal, func(input string) *string {
result := "should be gone"
return &result
@@ -137,24 +142,6 @@ func TestHookRegistry_NoHooksReturnsNil(t *testing.T) {
}
}
func TestHookRegistry_HasHooks(t *testing.T) {
hr := newHookRegistry[string, string]()
if hr.hasHooks() {
t.Error("expected hasHooks to be false initially")
}
unsub := hr.register(HookPriorityNormal, func(_ string) *string { return nil })
if !hr.hasHooks() {
t.Error("expected hasHooks to be true after registration")
}
unsub()
if hr.hasHooks() {
t.Error("expected hasHooks to be false after unregister")
}
}
func TestHookRegistry_ConcurrentAccess(t *testing.T) {
hr := newHookRegistry[int, int]()
@@ -187,7 +174,7 @@ func TestHookRegistry_ConcurrentAccess(t *testing.T) {
// hookedTool tests
// ---------------------------------------------------------------------------
// mockAgentTool implements fantasy.AgentTool for testing.
// mockAgentTool implements the AgentTool interface for testing.
type mockAgentTool struct {
name string
runFn func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error)
@@ -206,10 +193,14 @@ func (m *mockAgentTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy
return fantasy.NewTextResponse("default output"), nil
}
func TestHookedTool_Passthrough(t *testing.T) {
// newEmptyHookedTool creates a hookedTool with empty hook registries and the given mock tool.
func newEmptyHookedTool(mock *mockAgentTool) *hookedTool {
before := newHookRegistry[BeforeToolCallHook, BeforeToolCallResult]()
after := newHookRegistry[AfterToolResultHook, AfterToolResultResult]()
return &hookedTool{inner: mock, beforeToolCall: before, afterToolResult: after}
}
func TestHookedTool_Passthrough(t *testing.T) {
mock := &mockAgentTool{
name: "test_tool",
runFn: func(_ context.Context, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
@@ -217,7 +208,7 @@ func TestHookedTool_Passthrough(t *testing.T) {
},
}
ht := &hookedTool{inner: mock, beforeToolCall: before, afterToolResult: after}
ht := newEmptyHookedTool(mock)
resp, err := ht.Run(context.Background(), fantasy.ToolCall{Input: "{}"})
if err != nil {
@@ -372,11 +363,7 @@ func TestHookedTool_HookReceivesToolInfo(t *testing.T) {
func TestHookedTool_InfoDelegates(t *testing.T) {
mock := &mockAgentTool{name: "delegate_test"}
ht := &hookedTool{
inner: mock,
beforeToolCall: newHookRegistry[BeforeToolCallHook, BeforeToolCallResult](),
afterToolResult: newHookRegistry[AfterToolResultHook, AfterToolResultResult](),
}
ht := newEmptyHookedTool(mock)
if ht.Info().Name != "delegate_test" {
t.Errorf("expected Info() to delegate to inner tool")
+104 -577
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
@@ -12,6 +11,7 @@ import (
"time"
"charm.land/fantasy"
charmlog "github.com/charmbracelet/log"
"github.com/mark3labs/kit/internal/agent"
"github.com/mark3labs/kit/internal/config"
@@ -68,8 +68,16 @@ type Kit struct {
// SubscribeSubagent(). Keyed by toolCallID → *subagentListenerSet.
subagentListeners sync.Map
// skillCache holds skills discovered for this Kit instance.
// Using a per-instance cache avoids cross-contamination when multiple
// Kit instances exist in the same process.
skillCache struct {
skills []*skills.Skill
mu sync.RWMutex
}
// steerCh is a buffered channel used to inject steering messages into
// the running agent turn via Fantasy's PrepareStep. Created fresh for
// the running agent turn via the LLM library's PrepareStep. Created fresh for
// each generate() call and set to nil when idle. Protected by steerMu.
steerMu sync.Mutex
steerCh chan string
@@ -77,31 +85,14 @@ type Kit struct {
}
// Subscribe registers an EventListener that will be called for every lifecycle
// event emitted during Prompt() and PromptWithCallbacks(). Returns an
// unsubscribe function that removes the listener.
// event emitted during Prompt(). 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 — prefer these over GetAgent/GetExtRunner/GetBufferedLogger
// Narrow accessors
// --------------------------------------------------------------------------
// GetToolNames returns the names of all tools available to the agent.
@@ -145,222 +136,6 @@ func (m *Kit) GetBufferedDebugMessages() []string {
return m.bufferedLogger.GetMessages()
}
// SetExtensionContext configures the extension runner with the given context
// functions. No-op if extensions are disabled.
func (m *Kit) SetExtensionContext(ctx extensions.Context) {
if m.extRunner != nil {
m.extRunner.SetContext(ctx)
}
}
// GetExtensionContext returns the current extension runtime context.
// Returns a zero Context if extensions are disabled.
func (m *Kit) GetExtensionContext() extensions.Context {
if m.extRunner != nil {
return m.extRunner.GetContext()
}
return extensions.Context{}
}
// UpdateExtensionContextModel updates the Model field on the extension
// context so subsequent event handlers see the new model. This is a
// targeted update that avoids replacing the entire Context struct.
func (m *Kit) UpdateExtensionContextModel(model string) {
if m.extRunner != nil {
ctx := m.extRunner.GetContext()
ctx.Model = model
m.extRunner.SetContext(ctx)
}
}
// EmitSessionStart fires the SessionStart event for extensions.
// No-op if extensions are disabled or no handlers are registered.
func (m *Kit) EmitSessionStart() {
if m.extRunner != nil && m.extRunner.HasHandlers(extensions.SessionStart) {
_, _ = m.extRunner.Emit(extensions.SessionStartEvent{})
}
}
// ExtensionCommands returns the slash commands registered by extensions.
// Returns nil if extensions are disabled or no commands are registered.
func (m *Kit) ExtensionCommands() []extensions.CommandDef {
if m.extRunner == nil {
return nil
}
return m.extRunner.RegisteredCommands()
}
// SetExtensionWidget places or updates a persistent extension widget.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) SetExtensionWidget(config extensions.WidgetConfig) {
if m.extRunner != nil {
m.extRunner.SetWidget(config)
}
}
// RemoveExtensionWidget removes a previously placed extension widget by ID.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) RemoveExtensionWidget(id string) {
if m.extRunner != nil {
m.extRunner.RemoveWidget(id)
}
}
// GetExtensionWidgets returns extension widgets matching the given placement.
// Returns nil if extensions are disabled or no widgets match.
func (m *Kit) GetExtensionWidgets(placement extensions.WidgetPlacement) []extensions.WidgetConfig {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetWidgets(placement)
}
// SetExtensionHeader places or replaces the custom header from extensions.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) SetExtensionHeader(config extensions.HeaderFooterConfig) {
if m.extRunner != nil {
m.extRunner.SetHeader(config)
}
}
// RemoveExtensionHeader removes the custom extension header.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) RemoveExtensionHeader() {
if m.extRunner != nil {
m.extRunner.RemoveHeader()
}
}
// GetExtensionHeader returns the current custom header, or nil if none is set.
// Returns nil if extensions are disabled.
func (m *Kit) GetExtensionHeader() *extensions.HeaderFooterConfig {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetHeader()
}
// SetExtensionFooter places or replaces the custom footer from extensions.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) SetExtensionFooter(config extensions.HeaderFooterConfig) {
if m.extRunner != nil {
m.extRunner.SetFooter(config)
}
}
// RemoveExtensionFooter removes the custom extension footer.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) RemoveExtensionFooter() {
if m.extRunner != nil {
m.extRunner.RemoveFooter()
}
}
// GetExtensionFooter returns the current custom footer, or nil if none is set.
// Returns nil if extensions are disabled.
func (m *Kit) GetExtensionFooter() *extensions.HeaderFooterConfig {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetFooter()
}
// GetExtensionToolRenderer returns the custom renderer for the named tool, or
// nil if no extension registered a renderer for it. Returns nil if extensions
// are disabled.
func (m *Kit) GetExtensionToolRenderer(toolName string) *extensions.ToolRenderConfig {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetToolRenderer(toolName)
}
// SetExtensionEditor installs an editor interceptor from extensions.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) SetExtensionEditor(config extensions.EditorConfig) {
if m.extRunner != nil {
m.extRunner.SetEditor(config)
}
}
// ResetExtensionEditor removes the active editor interceptor from extensions.
// Delegates to the extension runner. No-op if extensions are disabled.
func (m *Kit) ResetExtensionEditor() {
if m.extRunner != nil {
m.extRunner.ResetEditor()
}
}
// GetExtensionEditor returns the current editor interceptor, or nil if none
// is set. Returns nil if extensions are disabled.
func (m *Kit) GetExtensionEditor() *extensions.EditorConfig {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetEditor()
}
// SetExtensionUIVisibility stores extension-provided UI visibility overrides.
// No-op if extensions are disabled.
func (m *Kit) SetExtensionUIVisibility(v extensions.UIVisibility) {
if m.extRunner != nil {
m.extRunner.SetUIVisibility(v)
}
}
// GetExtensionUIVisibility returns extension-provided UI visibility overrides,
// or nil if none have been set. Returns nil if extensions are disabled.
func (m *Kit) GetExtensionUIVisibility() *extensions.UIVisibility {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetUIVisibility()
}
// GetSessionMessages returns the conversation messages on the current branch
// as extension-facing SessionMessage structs, ordered root to leaf.
func (m *Kit) GetSessionMessages() []extensions.SessionMessage {
if m.treeSession == nil {
return nil
}
branch := m.treeSession.GetBranch("")
var msgs []extensions.SessionMessage
for _, entry := range branch {
me, ok := entry.(*session.MessageEntry)
if !ok {
continue
}
msg, err := me.ToMessage()
if err != nil {
continue
}
// Flatten content parts into a single text string.
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(%s)]", pt.Name, pt.Input)
case message.ToolResult:
fmt.Fprintf(&content, "[tool_result: %s]", pt.Content)
}
}
msgs = append(msgs, extensions.SessionMessage{
ID: me.ID,
ParentID: me.ParentID,
Role: string(msg.Role),
Content: content.String(),
Model: msg.Model,
Provider: msg.Provider,
Timestamp: me.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
})
}
return msgs
}
// StructuredMessage represents a conversation message with typed content parts
// (tool calls, reasoning, finish markers, etc.) instead of flattened text.
type StructuredMessage struct {
@@ -378,11 +153,29 @@ type StructuredMessage struct {
// flattens all content to a single text string, this preserves tool calls,
// tool results, reasoning blocks, and finish markers as distinct typed parts.
func (m *Kit) GetStructuredMessages() []StructuredMessage {
if m.treeSession == nil {
return iterBranchMessages(m.treeSession, func(me *session.MessageEntry, msg message.Message) StructuredMessage {
return StructuredMessage{
ID: me.ID,
ParentID: me.ParentID,
Role: msg.Role,
Parts: msg.Parts,
Model: msg.Model,
Provider: msg.Provider,
Timestamp: me.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
}
})
}
// iterBranchMessages iterates over the current branch's MessageEntry items,
// converting each to a message.Message and calling fn to build the result.
// Returns nil if there is no tree session. Skips entries that are not
// MessageEntry or that fail conversion.
func iterBranchMessages[T any](tm *session.TreeManager, fn func(*session.MessageEntry, message.Message) T) []T {
if tm == nil {
return nil
}
branch := m.treeSession.GetBranch("")
var msgs []StructuredMessage
branch := tm.GetBranch("")
var results []T
for _, entry := range branch {
me, ok := entry.(*session.MessageEntry)
if !ok {
@@ -392,137 +185,9 @@ func (m *Kit) GetStructuredMessages() []StructuredMessage {
if err != nil {
continue
}
msgs = append(msgs, StructuredMessage{
ID: me.ID,
ParentID: me.ParentID,
Role: msg.Role,
Parts: msg.Parts,
Model: msg.Model,
Provider: msg.Provider,
Timestamp: me.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
})
}
return msgs
}
// GetSessionFilePath returns the JSONL file path of the current session.
func (m *Kit) GetSessionFilePath() string {
if m.treeSession == nil {
return ""
}
return m.treeSession.GetFilePath()
}
// AppendExtensionEntry persists custom extension data in the session tree.
func (m *Kit) AppendExtensionEntry(extType, data string) (string, error) {
if m.treeSession == nil {
return "", fmt.Errorf("no session available")
}
return m.treeSession.AppendExtensionData(extType, data)
}
// GetExtensionEntries retrieves persisted extension data entries for a type.
func (m *Kit) GetExtensionEntries(extType string) []extensions.ExtensionEntry {
if m.treeSession == nil {
return nil
}
entries := m.treeSession.GetExtensionData(extType)
result := make([]extensions.ExtensionEntry, 0, len(entries))
for _, e := range entries {
result = append(result, extensions.ExtensionEntry{
ID: e.ID,
EntryType: e.ExtType,
Data: e.Data,
Timestamp: e.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
})
}
return result
}
// SetExtensionStatus places or updates a keyed status bar entry.
func (m *Kit) SetExtensionStatus(entry extensions.StatusBarEntry) {
if m.extRunner != nil {
m.extRunner.SetStatusEntry(entry)
}
}
// RemoveExtensionStatus removes a keyed status bar entry.
func (m *Kit) RemoveExtensionStatus(key string) {
if m.extRunner != nil {
m.extRunner.RemoveStatusEntry(key)
}
}
// GetExtensionStatusEntries returns all extension status bar entries sorted by priority.
func (m *Kit) GetExtensionStatusEntries() []extensions.StatusBarEntry {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetStatusEntries()
}
// GetExtensionShortcuts returns a map of key bindings to handler functions
// from all loaded extensions. Returns nil if no shortcuts are registered or
// extensions are disabled. Handlers are closures that capture the runner's
// current context, so they can call Print/SetStatus/etc.
func (m *Kit) GetExtensionShortcuts() map[string]func() {
if m.extRunner == nil {
return nil
}
entries := m.extRunner.GetShortcuts()
if entries == nil {
return nil
}
result := make(map[string]func(), len(entries))
for key, entry := range entries {
h := entry.Handler
r := m.extRunner
result[key] = func() {
ctx := r.GetContext()
h(ctx)
}
}
return result
}
// GetExtensionToolInfos returns information about all tools available to the
// agent, including enabled/disabled status from SetActiveTools. Each tool is
// categorized by source: "core", "mcp", or "extension".
func (m *Kit) GetExtensionToolInfos() []extensions.ToolInfo {
agentTools := m.agent.GetTools()
coreCount := m.agent.GetCoreToolCount()
mcpCount := m.agent.GetMCPToolCount()
result := make([]extensions.ToolInfo, 0, len(agentTools))
for i, t := range agentTools {
info := t.Info()
source := "core"
if i >= coreCount && i < coreCount+mcpCount {
source = "mcp"
} else if i >= coreCount+mcpCount {
source = "extension"
}
enabled := true
if m.extRunner != nil && m.extRunner.IsToolDisabled(info.Name) {
enabled = false
}
result = append(result, extensions.ToolInfo{
Name: info.Name,
Description: info.Description,
Source: source,
Enabled: enabled,
})
}
return result
}
// SetExtensionActiveTools restricts the tool set to the named tools. All
// other tools are blocked from execution. Pass nil to re-enable all tools.
// No-op if extensions are disabled.
func (m *Kit) SetExtensionActiveTools(names []string) {
if m.extRunner != nil {
m.extRunner.SetActiveTools(names)
results = append(results, fn(me, msg))
}
return results
}
// SetModel changes the active model at runtime. The existing tools, system
@@ -539,6 +204,10 @@ func (m *Kit) SetModel(ctx context.Context, modelString string) error {
// Build a provider config from current settings, overriding the model.
// Load system prompt properly (handles both file paths and inline content).
systemPrompt, _ := config.LoadSystemPrompt(viper.GetString("system-prompt"))
thinkingLevel := models.ParseThinkingLevel(viper.GetString("thinking-level"))
// With message-level caching, thinking and caching can work together.
// No need to disable caching when thinking is enabled.
config := &models.ProviderConfig{
ModelString: modelString,
SystemPrompt: systemPrompt,
@@ -546,7 +215,8 @@ func (m *Kit) SetModel(ctx context.Context, modelString string) error {
ProviderURL: viper.GetString("provider-url"),
MaxTokens: viper.GetInt("max-tokens"),
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
ThinkingLevel: models.ParseThinkingLevel(viper.GetString("thinking-level")),
ThinkingLevel: thinkingLevel,
DisableCaching: false, // Caching enabled by default, works with thinking
}
temperature := float32(viper.GetFloat64("temperature"))
config.Temperature = &temperature
@@ -578,7 +248,7 @@ func (m *Kit) SetModel(ctx context.Context, modelString string) error {
func (m *Kit) GetAvailableModels() []extensions.ModelInfoEntry {
registry := models.GetGlobalRegistry()
var result []extensions.ModelInfoEntry
for _, providerID := range registry.GetFantasyProviders() {
for _, providerID := range registry.GetLLMProviders() {
modelsMap, err := registry.GetModelsForProvider(providerID)
if err != nil {
continue
@@ -597,50 +267,6 @@ func (m *Kit) GetAvailableModels() []extensions.ModelInfoEntry {
return result
}
// GetExtensionOption resolves a named extension option value.
func (m *Kit) GetExtensionOption(name string) string {
if m.extRunner == nil {
return ""
}
return m.extRunner.GetOption(name)
}
// SetExtensionOption stores a runtime override for a named extension option.
func (m *Kit) SetExtensionOption(name, value string) {
if m.extRunner != nil {
m.extRunner.SetOption(name, value)
}
}
// EmitModelChange fires the ModelChange event for extensions.
// No-op if extensions are disabled or no handlers are registered.
func (m *Kit) EmitModelChange(newModel, previousModel, source string) {
if m.extRunner != nil && m.extRunner.HasHandlers(extensions.ModelChange) {
_, _ = m.extRunner.Emit(extensions.ModelChangeEvent{
NewModel: newModel,
PreviousModel: previousModel,
Source: source,
})
}
}
// EmitExtensionCustomEvent dispatches a named event to all extension handlers.
// No-op if extensions are disabled.
func (m *Kit) EmitExtensionCustomEvent(name, data string) {
if m.extRunner != nil {
m.extRunner.EmitCustomEvent(name, data)
}
}
// GetExtensionMessageRenderer returns the named message renderer, or nil
// if no extension registered a renderer with that name.
func (m *Kit) GetExtensionMessageRenderer(name string) *extensions.MessageRendererConfig {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetMessageRenderer(name)
}
// ReloadExtensions hot-reloads all extensions from disk. Event handlers,
// commands, renderers, and shortcuts update immediately. Extension-defined
// tools are NOT updated (they are baked into the agent at creation time).
@@ -715,7 +341,7 @@ func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequ
}
defer closer()
// Build fantasy agent options (no tools — just a simple completion).
// Build agent options (no tools — just a simple completion).
var agentOpts []fantasy.AgentOption
if req.System != "" {
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(req.System))
@@ -729,7 +355,7 @@ func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequ
completionAgent := fantasy.NewAgent(llmModel, agentOpts...)
// Convert extension SessionMessage history to fantasy.Message slice.
// Convert extension SessionMessage history to LLM message slice.
var messages []fantasy.Message
for _, sm := range req.Messages {
messages = append(messages, fantasy.Message{
@@ -777,53 +403,6 @@ func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequ
}, nil
}
// EmitBeforeFork emits a BeforeFork event to extensions and returns
// whether the fork was cancelled and the reason. No-op if extensions are
// disabled (returns false, "").
func (m *Kit) EmitBeforeFork(targetID string, isUserMsg bool, userText string) (cancelled bool, reason string) {
if m.extRunner == nil || !m.extRunner.HasHandlers(extensions.BeforeFork) {
return false, ""
}
result, _ := m.extRunner.Emit(extensions.BeforeForkEvent{
TargetID: targetID,
IsUserMessage: isUserMsg,
UserText: userText,
})
if r, ok := result.(extensions.BeforeForkResult); ok && r.Cancel {
reason := r.Reason
if reason == "" {
reason = "Fork cancelled by extension."
}
return true, reason
}
return false, ""
}
// EmitBeforeSessionSwitch emits a BeforeSessionSwitch event to extensions
// and returns whether the switch was cancelled and the reason. No-op if
// extensions are disabled (returns false, "").
func (m *Kit) EmitBeforeSessionSwitch(switchReason string) (cancelled bool, reason string) {
if m.extRunner == nil || !m.extRunner.HasHandlers(extensions.BeforeSessionSwitch) {
return false, ""
}
result, _ := m.extRunner.Emit(extensions.BeforeSessionSwitchEvent{
Reason: switchReason,
})
if r, ok := result.(extensions.BeforeSessionSwitchResult); ok && r.Cancel {
reason := r.Reason
if reason == "" {
reason = "Session switch cancelled by extension."
}
return true, reason
}
return false, ""
}
// HasExtensions returns true if the extension runner is configured and active.
func (m *Kit) HasExtensions() bool {
return m.extRunner != nil
}
// Options configures Kit creation with optional overrides for model,
// prompts, configuration, and behavior settings. All fields are optional
// and will use CLI defaults if not specified.
@@ -1066,9 +645,9 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
k.bridgeExtensions(agentResult.ExtRunner)
// Initialize extension context with minimal defaults. SDK users can call
// SetExtensionContext to override with richer implementations (TUI callbacks,
// Extensions().SetContext to override with richer implementations (TUI callbacks,
// prompts, etc.). This ensures extensions never crash on nil function fields.
k.SetExtensionContext(extensions.Context{
k.Extensions().SetContext(extensions.Context{
CWD: cwd,
Model: k.modelString,
Interactive: false, // SDK mode defaults to non-interactive
@@ -1243,16 +822,16 @@ type TurnResult struct {
// TotalUsage is the aggregate token usage across all steps in the turn
// (includes tool-calling loop iterations). Nil if the provider didn't
// report usage.
TotalUsage *FantasyUsage
TotalUsage *LLMUsage
// FinalUsage is the token usage from the last API call only. Use this
// for context window fill estimation (InputTokens + OutputTokens ≈
// current context size). Nil if unavailable.
FinalUsage *FantasyUsage
FinalUsage *LLMUsage
// Messages is the full updated conversation after the turn, including
// any tool call/result messages added during the agent loop.
Messages []FantasyMessage
Messages []LLMMessage
}
// ---------------------------------------------------------------------------
@@ -1273,7 +852,7 @@ type SubagentConfig struct {
SystemPrompt string
// Tools overrides the tool set. If nil, SubagentTools() is used (all
// core tools except spawn_subagent, preventing infinite recursion).
// core tools except subagent, preventing infinite recursion).
Tools []Tool
// NoSession, when true, uses an in-memory ephemeral session. When false
@@ -1291,17 +870,16 @@ type SubagentConfig struct {
}
// SubagentResult contains the outcome of an in-process subagent execution.
// Errors are returned as the error return value of Subagent(), not in this struct.
type SubagentResult struct {
// Response is the subagent's final text response.
Response string
// Error is set if the subagent failed (nil on success).
Error error
// SessionID is the subagent's session identifier (for replay).
SessionID string
// StopReason is the LLM's finish reason for the subagent's final turn.
StopReason string
// Usage contains token usage from the subagent's run.
Usage *FantasyUsage
Usage *LLMUsage
// Elapsed is the total execution time.
Elapsed time.Duration
}
@@ -1347,7 +925,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 spawn_subagent.
// Default tools: everything except subagent.
tools := cfg.Tools
if tools == nil {
tools = SubagentTools()
@@ -1369,10 +947,7 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
childOpts.Model = m.modelString
child, err = New(ctx, childOpts)
if err != nil {
return &SubagentResult{
Error: fmt.Errorf("failed to create subagent: %w", err),
Elapsed: time.Since(start),
}, err
return nil, fmt.Errorf("failed to create subagent: %w", err)
}
// Prepend a note so the agent knows which model is actually running.
cfg.Prompt = fmt.Sprintf(
@@ -1380,10 +955,7 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
model, m.modelString, cfg.Prompt,
)
} else if err != nil {
return &SubagentResult{
Error: fmt.Errorf("failed to create subagent: %w", err),
Elapsed: time.Since(start),
}, err
return nil, fmt.Errorf("failed to create subagent: %w", err)
}
defer func() { _ = child.Close() }()
@@ -1397,11 +969,7 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
elapsed := time.Since(start)
if err != nil {
return &SubagentResult{
Error: err,
SessionID: child.GetSessionID(),
Elapsed: elapsed,
}, err
return nil, err
}
subResult := &SubagentResult{
@@ -1440,14 +1008,13 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
case msg := <-steerCh:
leftover = append(leftover, msg)
default:
goto drained
m.steerMu.Lock()
m.steerCh = nil
m.leftoverSteer = leftover
m.steerMu.Unlock()
return
}
}
drained:
m.steerMu.Lock()
m.steerCh = nil
m.leftoverSteer = leftover
m.steerMu.Unlock()
}()
ctx = agent.ContextWithSteerCh(ctx, steerCh)
ctx = agent.ContextWithSteerConsumed(ctx, func(count int) {
@@ -1455,7 +1022,7 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
})
// Inject the in-process subagent spawner into the context so the
// spawn_subagent core tool can create child Kit instances without
// 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,
@@ -1480,7 +1047,7 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
}
sr := &core.SubagentSpawnResult{
Response: result.Response,
Error: result.Error,
Error: err,
SessionID: result.SessionID,
Elapsed: result.Elapsed,
}
@@ -1543,8 +1110,12 @@ 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)
charmlog.Debug("Kit.generate emitting StepUsageEvent",
"input", inputTokens,
"output", outputTokens,
"cacheRead", cacheReadTokens,
"cacheCreate", cacheCreationTokens,
)
}
m.events.emit(StepUsageEvent{
InputTokens: uint64(inputTokens),
@@ -1585,36 +1156,34 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
}
// Run BeforeTurn hooks — can modify the prompt, inject system/context messages.
if m.beforeTurn.hasHooks() {
if hookResult := m.beforeTurn.run(BeforeTurnHook{Prompt: prompt}); hookResult != nil {
// Override prompt text in the last user message, preserving
// any file parts (e.g. clipboard images).
if hookResult.Prompt != nil {
for i := len(preMessages) - 1; i >= 0; i-- {
if preMessages[i].Role == fantasy.MessageRoleUser {
files := extractFileParts(preMessages[i])
preMessages[i] = fantasy.NewUserMessage(*hookResult.Prompt, files...)
break
}
if hookResult := m.beforeTurn.run(BeforeTurnHook{Prompt: prompt}); hookResult != nil {
// Override prompt text in the last user message, preserving
// any file parts (e.g. clipboard images).
if hookResult.Prompt != nil {
for i := len(preMessages) - 1; i >= 0; i-- {
if preMessages[i].Role == fantasy.MessageRoleUser {
files := extractFileParts(preMessages[i])
preMessages[i] = fantasy.NewUserMessage(*hookResult.Prompt, files...)
break
}
}
// Inject messages before the original preMessages.
var injected []fantasy.Message
if hookResult.SystemPrompt != nil {
injected = append(injected, fantasy.NewSystemMessage(*hookResult.SystemPrompt))
}
if hookResult.InjectText != nil {
injected = append(injected, fantasy.NewUserMessage(*hookResult.InjectText))
}
if len(injected) > 0 {
preMessages = append(injected, preMessages...)
}
}
// Inject messages before the original preMessages.
var injected []fantasy.Message
if hookResult.SystemPrompt != nil {
injected = append(injected, fantasy.NewSystemMessage(*hookResult.SystemPrompt))
}
if hookResult.InjectText != nil {
injected = append(injected, fantasy.NewUserMessage(*hookResult.InjectText))
}
if len(injected) > 0 {
preMessages = append(injected, preMessages...)
}
}
// Persist pre-generation messages to tree session.
for _, msg := range preMessages {
_, _ = m.treeSession.AppendFantasyMessage(msg)
_, _ = m.treeSession.AppendLLMMessage(msg)
}
// Auto-compact if enabled and conversation is near the context limit.
@@ -1623,13 +1192,11 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
}
// Build context from the tree so only the current branch is sent.
messages := m.treeSession.GetFantasyMessages()
messages := m.treeSession.GetLLMMessages()
// Run ContextPrepare hooks — extensions can filter, reorder, or inject messages.
if m.contextPrepare.hasHooks() {
if hookResult := m.contextPrepare.run(ContextPrepareHook{Messages: messages}); hookResult != nil && hookResult.Messages != nil {
messages = hookResult.Messages
}
if hookResult := m.contextPrepare.run(ContextPrepareHook{Messages: messages}); hookResult != nil && hookResult.Messages != nil {
messages = hookResult.Messages
}
sentCount := len(messages)
@@ -1648,14 +1215,12 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
// (pending) message or tool call is discarded.
if result != nil && len(result.ConversationMessages) > sentCount {
for _, msg := range result.ConversationMessages[sentCount:] {
_, _ = m.treeSession.AppendFantasyMessage(msg)
_, _ = m.treeSession.AppendLLMMessage(msg)
}
}
m.events.emit(TurnEndEvent{Error: err})
// Run AfterTurn hooks even on error.
if m.afterTurn.hasHooks() {
m.afterTurn.run(AfterTurnHook{Error: err})
}
m.afterTurn.run(AfterTurnHook{Error: err})
return nil, err
}
@@ -1666,7 +1231,7 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
// GetContextStats() see up-to-date token counts.
if len(result.ConversationMessages) > sentCount {
for _, msg := range result.ConversationMessages[sentCount:] {
_, _ = m.treeSession.AppendFantasyMessage(msg)
_, _ = m.treeSession.AppendLLMMessage(msg)
}
}
@@ -1686,9 +1251,7 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
m.events.emit(TurnEndEvent{Response: responseText, StopReason: stopReason})
// Run AfterTurn hooks.
if m.afterTurn.hasHooks() {
m.afterTurn.run(AfterTurnHook{Response: responseText})
}
m.afterTurn.run(AfterTurnHook{Response: responseText})
// Build TurnResult with usage stats.
turnResult := &TurnResult{
@@ -1750,7 +1313,7 @@ func (m *Kit) Steer(ctx context.Context, instruction string) (string, error) {
// Returns an error if there are no previous messages in the session.
func (m *Kit) FollowUp(ctx context.Context, text string) (string, error) {
// Verify there is conversation history to follow up on.
if len(m.treeSession.GetFantasyMessages()) == 0 {
if len(m.treeSession.GetLLMMessages()) == 0 {
return "", fmt.Errorf("cannot follow up: no previous messages")
}
@@ -1857,45 +1420,6 @@ 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.
@@ -1908,7 +1432,7 @@ func (m *Kit) PromptResult(ctx context.Context, message string) (*TurnResult, er
// PromptResultWithFiles sends a multimodal message (text + images) and returns
// the full turn result. The files parameter carries binary file data (e.g.
// clipboard images) that are included alongside the text in the user message.
func (m *Kit) PromptResultWithFiles(ctx context.Context, message string, files []fantasy.FilePart) (*TurnResult, error) {
func (m *Kit) PromptResultWithFiles(ctx context.Context, message string, files []LLMFilePart) (*TurnResult, error) {
return m.runTurn(ctx, message, message, []fantasy.Message{
fantasy.NewUserMessage(message, files...),
})
@@ -1929,7 +1453,7 @@ func (m *Kit) PromptResultWithMessages(ctx context.Context, messages []string) (
promptLabel = promptLabel[:100] + "..."
}
// Build fantasy messages from all strings
// Build LLM messages from all strings
var preMessages []fantasy.Message
for _, msg := range messages {
preMessages = append(preMessages, fantasy.NewUserMessage(msg))
@@ -1974,6 +1498,9 @@ func (m *Kit) GetThinkingLevel() string {
// SetThinkingLevel changes the thinking level and recreates the agent with
// the new thinking budget. Returns an error if provider recreation fails.
//
// With message-level caching, both thinking and caching work together.
// Caching reduces costs by 60-90% for repeated context.
func (m *Kit) SetThinkingLevel(ctx context.Context, level string) error {
viper.Set("thinking-level", level)
// Recreate agent with new thinking config by re-running SetModel
+7 -2
View File
@@ -16,10 +16,15 @@ func GetSupportedProviders() []string {
return models.GetGlobalRegistry().GetSupportedProviders()
}
// GetFantasyProviders returns provider IDs that can be used with fantasy,
// GetLLMProviders returns provider IDs that have LLM support,
// either through a native provider or via openaicompat auto-routing.
func GetLLMProviders() []string {
return models.GetGlobalRegistry().GetLLMProviders()
}
// Deprecated: Use GetLLMProviders instead.
func GetFantasyProviders() []string {
return models.GetGlobalRegistry().GetFantasyProviders()
return GetLLMProviders()
}
// GetModelsForProvider returns all known models for a provider.
+18 -39
View File
@@ -134,29 +134,27 @@ func (m *Kit) GetChildren(parentID string) []string {
}
// 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 {
// Returns an error if the session is unavailable or the entry ID is not found.
func (m *Kit) NavigateTo(entryID string) error {
if m.treeSession == nil {
return "no tree session available"
return fmt.Errorf("no tree session available")
}
if err := m.treeSession.Branch(entryID); err != nil {
return err.Error()
}
return ""
return m.treeSession.Branch(entryID)
}
// SummarizeBranch uses LLM to summarize a branch range.
// Returns summary text or error string.
func (m *Kit) SummarizeBranch(fromID, toID string) string {
// SummarizeBranch uses the LLM to summarize the conversation between two
// entry IDs. Returns the summary text, or an error if the range is invalid,
// the session is unavailable, or the LLM call fails.
func (m *Kit) SummarizeBranch(fromID, toID string) (string, error) {
if m.treeSession == nil {
return ""
return "", fmt.Errorf("no tree session available")
}
// 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)
id := m.treeSession.EntryID(entry)
if id == fromID {
startIdx = i
}
@@ -166,7 +164,7 @@ func (m *Kit) SummarizeBranch(fromID, toID string) string {
}
if startIdx < 0 || endIdx < 0 || startIdx > endIdx {
return ""
return "", fmt.Errorf("entry IDs not found or out of order in current branch")
}
// Build text to summarize
@@ -179,7 +177,7 @@ func (m *Kit) SummarizeBranch(fromID, toID string) string {
}
if content.Len() == 0 {
return ""
return "", fmt.Errorf("no content found in the specified range")
}
// Use LLM to summarize
@@ -189,22 +187,19 @@ func (m *Kit) SummarizeBranch(fromID, toID string) string {
Prompt: content.String(),
})
if err != nil {
return ""
return "", fmt.Errorf("summarization failed: %w", err)
}
return resp.Text
return resp.Text, nil
}
// 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 {
// Returns an error if the session is unavailable or the operation fails.
func (m *Kit) CollapseBranch(fromID, toID, summary string) error {
if m.treeSession == nil {
return "no tree session available"
return fmt.Errorf("no tree session available")
}
_, err := m.treeSession.AppendBranchSummary(fromID, summary)
if err != nil {
return err.Error()
}
return ""
return err
}
// entryToTreeNode converts a session entry to a TreeNode.
@@ -273,22 +268,6 @@ func (m *Kit) entryToTreeNode(entry any) *TreeNode {
}
}
// 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
+15 -35
View File
@@ -2,7 +2,6 @@ package kit
import (
"os"
"sync"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/skills"
@@ -78,35 +77,18 @@ func NewPromptBuilder(basePrompt string) *PromptBuilder {
// 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.
// Returns skills in the extension-facing format. Results are cached per-Kit
// instance to avoid reloading on every call.
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)
m.skillCache.mu.Lock()
defer m.skillCache.mu.Unlock()
if len(m.skillCache.skills) == 0 {
m.skillCache.skills, _ = skills.LoadSkills(cwd)
}
globalSkillCache.mu.RUnlock()
// Load fresh
skillList, _ := skills.LoadSkills(cwd)
globalSkillCache.mu.Lock()
globalSkillCache.skills = skillList
globalSkillCache.mu.Unlock()
return m.convertSkills(skillList)
return m.convertSkills(m.skillCache.skills)
}
// LoadSkillForExtension loads a single skill file for extensions.
@@ -140,19 +122,17 @@ func (m *Kit) convertSkill(s *skills.Skill) *extensions.Skill {
}
// 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)
}
func (m *Kit) convertSkills(skillList []*skills.Skill) []extensions.Skill {
result := make([]extensions.Skill, 0, len(skillList))
for _, s := range skillList {
result = append(result, *m.convertSkill(s))
}
return result
}
// ClearSkillCache clears the global skill cache (called on reload).
// ClearSkillCache clears the skill cache for this Kit instance.
func (m *Kit) ClearSkillCache() {
globalSkillCache.mu.Lock()
globalSkillCache.skills = nil
globalSkillCache.mu.Unlock()
m.skillCache.mu.Lock()
defer m.skillCache.mu.Unlock()
m.skillCache.skills = nil
}
+32 -37
View File
@@ -3,6 +3,7 @@ package kit
import (
"regexp"
"strings"
"sync"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/models"
@@ -34,16 +35,17 @@ func ParseTemplate(name, content string) extensions.PromptTemplate {
}
// RenderTemplate substitutes variables into template content.
// Handles {{name}} and {{ name }} (any whitespace) placeholders.
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
return varRegex.ReplaceAllStringFunc(tpl.Content, func(m string) string {
sub := varRegex.FindStringSubmatch(m)
if len(sub) > 1 {
if v, ok := vars[sub[1]]; ok {
return v
}
}
return m
})
}
// ParseArguments parses command-line style arguments.
@@ -58,13 +60,10 @@ func ParseArguments(input string, pattern extensions.ArgumentPattern) extensions
return result
}
// First field is the command itself (if present)
// First field is the command itself (if present); skip it.
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
}
startIdx = 1
}
// Parse flags
@@ -166,7 +165,7 @@ func SimpleParseArguments(input string, count int) []string {
result = append(result, input) // [0] = full input
// [1]..[count] = positional args
for i := 0; i < count; i++ {
for i := range count {
if i < len(fields) {
result = append(result, fields[i])
} else {
@@ -224,22 +223,11 @@ func parseFields(input string) []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, ",") {
for c := range strings.SplitSeq(condition, ",") {
c = strings.TrimSpace(c)
if matchModelPattern(currentModel, c) {
return true
@@ -248,17 +236,24 @@ func EvaluateModelConditional(currentModel, condition string) bool {
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 + "$"
// modelPatternCache caches compiled regexps for model glob patterns.
var modelPatternCache sync.Map
re, err := regexp.Compile(pattern)
if err != nil {
// Fallback: exact match
return model == pattern
// matchModelPattern matches a model against a pattern with wildcards.
// Compiled regexps are cached to avoid recompilation on hot paths.
func matchModelPattern(model, pattern string) bool {
rePattern := "^" + strings.ReplaceAll(strings.ReplaceAll(pattern, "*", ".*"), "?", ".") + "$"
var re *regexp.Regexp
if v, ok := modelPatternCache.Load(rePattern); ok {
re = v.(*regexp.Regexp)
} else {
compiled, err := regexp.Compile(rePattern)
if err != nil {
// Fallback: exact match
return model == pattern
}
modelPatternCache.Store(rePattern, compiled)
re = compiled
}
return re.MatchString(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 spawn_subagent. Use this when
// SubagentTools returns all core tools except subagent. Use this when
// creating child Kit instances (in-process subagents) to prevent infinite
// recursion.
func SubagentTools(opts ...ToolOption) []Tool { return core.SubagentTools(opts...) }
+52 -29
View File
@@ -1,6 +1,8 @@
package kit
import (
"context"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/agent"
@@ -76,10 +78,6 @@ type MCPServerConfig = config.MCPServerConfig
// AgentConfig holds configuration options for creating a new Agent.
type AgentConfig = agent.AgentConfig
// GenerateResult contains the result and conversation history from an agent
// interaction.
type GenerateResult = agent.GenerateWithLoopResult
type (
// ToolCallHandler is a function type for handling tool calls as they happen.
ToolCallHandler = agent.ToolCallHandler
@@ -128,18 +126,22 @@ type ModelsRegistry = models.ModelsRegistry
// Ollama model loading. Signature: func(fn func() error) error.
type SpinnerFunc = agent.SpinnerFunc
// ==== Fantasy Types (re-exported) ====
// ==== LLM Types ====
// FantasyMessage is the underlying message type used by the fantasy agent
// library. Re-exported so SDK users can work with fantasy types without a
// direct import of charm.land/fantasy.
type FantasyMessage = fantasy.Message
// LLMMessage is the underlying message type used by the LLM agent
// library. Re-exported so SDK users can work with LLM types without a
// direct import of the underlying LLM library.
type LLMMessage = fantasy.Message
// FantasyUsage contains token usage information from an LLM response.
type FantasyUsage = fantasy.Usage
// LLMUsage contains token usage information from an LLM response.
type LLMUsage = fantasy.Usage
// FantasyResponse is the response type returned by the fantasy agent library.
type FantasyResponse = fantasy.Response
// LLMResponse is the response type returned by the LLM agent library.
type LLMResponse = fantasy.Response
// LLMFilePart represents a file attachment (image, document, etc.) that can
// be included in a prompt via PromptResultWithFiles.
type LLMFilePart = fantasy.FilePart
// ==== Compaction Types (internal/compaction/) ====
@@ -151,27 +153,48 @@ type CompactionOptions = compaction.CompactionOptions
// ==== Constructor & Helper Functions ====
var (
// ParseModelString parses a model string in "provider/model" format.
ParseModelString = models.ParseModelString
// CreateProvider creates a fantasy LanguageModel based on provider config.
CreateProvider = models.CreateProvider
// GetGlobalRegistry returns the global models registry instance.
GetGlobalRegistry = models.GetGlobalRegistry
// LoadSystemPrompt loads system prompt from file or returns string directly.
LoadSystemPrompt = config.LoadSystemPrompt
)
// ParseModelString parses a model string in "provider/model" format.
// Returns provider, modelID, and an error if the format is invalid.
func ParseModelString(model string) (provider, modelID string, err error) {
return models.ParseModelString(model)
}
// CreateProvider creates a LanguageModel based on provider config.
func CreateProvider(ctx context.Context, cfg *ProviderConfig) (*ProviderResult, error) {
return models.CreateProvider(ctx, cfg)
}
// GetGlobalRegistry returns the global models registry instance.
func GetGlobalRegistry() *ModelsRegistry {
return models.GetGlobalRegistry()
}
// LoadSystemPrompt loads a system prompt from a file path, or returns the
// string directly if it is not a valid file path.
func LoadSystemPrompt(pathOrContent string) (string, error) {
return config.LoadSystemPrompt(pathOrContent)
}
// ==== Conversion Helpers ====
// ConvertToFantasyMessages converts an SDK message to the underlying fantasy
// ConvertToLLMMessages converts an SDK message to the underlying LLM
// messages used by the agent for LLM interactions.
func ConvertToFantasyMessages(msg *Message) []fantasy.Message {
return msg.ToFantasyMessages()
func ConvertToLLMMessages(msg *Message) []fantasy.Message {
return msg.ToLLMMessages()
}
// ConvertFromFantasyMessage converts a fantasy message from the agent to an SDK
// ConvertFromLLMMessage converts an LLM message from the agent to an SDK
// message format for use in the SDK API.
func ConvertFromFantasyMessage(msg fantasy.Message) Message {
return message.FromFantasyMessage(msg)
func ConvertFromLLMMessage(msg fantasy.Message) Message {
return message.FromLLMMessage(msg)
}
// Deprecated: Use ConvertToLLMMessages instead.
func ConvertToFantasyMessages(msg *Message) []fantasy.Message {
return ConvertToLLMMessages(msg)
}
// Deprecated: Use ConvertFromLLMMessage instead.
func ConvertFromFantasyMessage(msg fantasy.Message) Message {
return ConvertFromLLMMessage(msg)
}
+4 -4
View File
@@ -49,12 +49,12 @@ func TestTypeExports(t *testing.T) {
Role: kit.RoleUser,
Parts: []kit.ContentPart{kit.TextContent{Text: "test"}},
}
fantasyMsgs := kit.ConvertToFantasyMessages(&userMsg)
if len(fantasyMsgs) == 0 {
t.Error("ConvertToFantasyMessages returned empty slice")
llmMsgs := kit.ConvertToLLMMessages(&userMsg)
if len(llmMsgs) == 0 {
t.Error("ConvertToLLMMessages returned empty slice")
}
roundTrip := kit.ConvertFromFantasyMessage(fantasyMsgs[0])
roundTrip := kit.ConvertFromLLMMessage(llmMsgs[0])
if roundTrip.Content() != "test" {
t.Errorf("round-trip Content() = %q, want %q", roundTrip.Content(), "test")
}
+64 -26
View File
@@ -119,17 +119,15 @@ result, err := host.PromptResult(ctx, "Analyze this file")
// result.Response — assistant's text
// result.StopReason — "stop", "length", "tool-calls", "error", etc.
// result.SessionID — session UUID
// result.TotalUsage — aggregate tokens across all steps (*kit.FantasyUsage)
// result.TotalUsage — aggregate tokens across all steps (*kit.LLMUsage)
// result.FinalUsage — tokens from last API call only
// result.Messages — full updated conversation ([]kit.FantasyMessage)
// result.Messages — full updated conversation ([]kit.LLMMessage)
```
### Multimodal with file attachments
```go
import "charm.land/fantasy"
files := []fantasy.FilePart{{
files := []kit.LLMFilePart{{
Name: "screenshot.png",
MediaType: "image/png",
Data: imageBytes,
@@ -167,16 +165,6 @@ 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
@@ -252,6 +240,8 @@ unsub := host.Subscribe(func(e kit.Event) {
| `response` | `ResponseEvent` | `Content` |
| `compaction` | `CompactionEvent` | `Summary`, `OriginalTokens`, `CompactedTokens`, `MessagesRemoved`, `ReadFiles`, `ModifiedFiles` |
| `reasoning_delta` | `ReasoningDeltaEvent` | `Delta` |
| `step_usage` | `StepUsageEvent` | `InputTokens`, `OutputTokens`, `CacheReadTokens`, `CacheWriteTokens` |
| `steer_consumed` | `SteerConsumedEvent` | `Count` |
### Tool kind constants
@@ -261,7 +251,7 @@ Tools are classified by kind for UI rendering:
- `ToolKindEdit` = `"edit"` — edit, write
- `ToolKindRead` = `"read"` — read, ls
- `ToolKindSearch` = `"search"` — grep, find
- `ToolKindSubagent` = `"agent"`spawn_subagent
- `ToolKindSubagent` = `"agent"` — subagent
---
@@ -318,7 +308,7 @@ host.OnAfterTurn(kit.HookPriorityNormal, func(h kit.AfterTurnHook) {
```go
host.OnContextPrepare(kit.HookPriorityNormal, func(h kit.ContextPrepareHook) *kit.ContextPrepareResult {
// h.Messages — []fantasy.Message (the full context being sent to the LLM)
// h.Messages — []kit.LLMMessage (the full context being sent to the LLM)
// Return nil to pass through, or replace entire context:
return &kit.ContextPrepareResult{Messages: filteredMessages}
})
@@ -368,7 +358,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 spawn_subagent (prevents recursion)
kit.SubagentTools(opts...) // all except subagent (prevents recursion)
```
### Tool options
@@ -467,7 +457,7 @@ err = host.SetThinkingLevel(ctx, "medium") // recreates agent with new thinking
```go
models := host.GetAvailableModels() // []extensions.ModelInfoEntry
providers := kit.GetSupportedProviders() // []string
providers := kit.GetFantasyProviders() // providers usable with fantasy
providers := kit.GetLLMProviders() // providers with LLM support
models, _ := kit.GetModelsForProvider("anthropic") // map[string]kit.ModelInfo
info := kit.LookupModel("anthropic", "claude-sonnet-4-5-20250929") // *kit.ModelInfo
info := kit.GetProviderInfo("openai") // *kit.ProviderInfo (env vars, API URL)
@@ -524,7 +514,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 spawn_subagent)
Tools: nil, // nil = SubagentTools() (all except subagent)
NoSession: true, // ephemeral
Timeout: 2 * time.Minute, // 0 = 5 minute default
OnEvent: func(e kit.Event) {
@@ -535,14 +525,14 @@ result, err := host.Subagent(ctx, kit.SubagentConfig{
},
})
// result.Response, result.Error, result.SessionID, result.StopReason
// result.Usage (*kit.FantasyUsage), result.Elapsed (time.Duration)
// result.Usage (*kit.LLMUsage), result.Elapsed (time.Duration)
```
### Subscribing to subagent events from parent
```go
host.OnToolCall(func(e kit.ToolCallEvent) {
if e.ToolName == "spawn_subagent" {
if e.ToolName == "subagent" {
host.SubscribeSubagent(e.ToolCallID, func(child kit.Event) {
// Real-time events scoped to this subagent
})
@@ -552,6 +542,53 @@ host.OnToolCall(func(e kit.ToolCallEvent) {
---
## Extension API
The `Extensions()` method returns an `ExtensionAPI` interface that groups all extension-related functionality. This is the primary way to interact with extension state from the SDK.
```go
extAPI := host.Extensions()
// Check if extensions are loaded
if extAPI.HasExtensions() {
// Context management
extAPI.SetContext(extensions.Context{...})
ctx := extAPI.GetContext()
extAPI.UpdateContextModel("anthropic/claude-sonnet-4-5-20250929")
// Widgets, headers, footers
extAPI.SetWidget(extensions.WidgetConfig{...})
extAPI.RemoveWidget("widget-id")
extAPI.SetHeader(extensions.HeaderFooterConfig{...})
extAPI.SetFooter(extensions.HeaderFooterConfig{...})
// Status bar
extAPI.SetStatus(extensions.StatusBarEntry{...})
extAPI.RemoveStatus("key")
// Options
extAPI.SetOption("name", "value")
val := extAPI.GetOption("name")
// Tools
tools := extAPI.GetToolInfos()
extAPI.SetActiveTools([]string{"bash", "read"})
// Events
extAPI.EmitSessionStart()
extAPI.EmitModelChange("new/model", "old/model", "extension")
extAPI.EmitCustomEvent("my-event", "data")
// Commands and lifecycle
cmds := extAPI.Commands()
err := extAPI.Reload()
}
```
All methods are no-ops when extensions are disabled (nil runner), so callers don't need nil checks.
---
## Authentication
```go
@@ -603,15 +640,15 @@ kit.Config, kit.MCPServerConfig
// Provider types
kit.ProviderConfig, kit.ProviderResult, kit.ModelInfo, kit.ModelCost, kit.ModelLimit
// Fantasy types (from charm.land/fantasy)
kit.FantasyMessage, kit.FantasyUsage, kit.FantasyResponse
// LLM types (re-exported from the underlying LLM library)
kit.LLMMessage, kit.LLMUsage, kit.LLMResponse, kit.LLMFilePart
// Compaction types
kit.CompactionResult, kit.CompactionOptions
// Conversion helpers
msgs := kit.ConvertToFantasyMessages(&msg) // SDK message → fantasy messages
msg := kit.ConvertFromFantasyMessage(fMsg) // fantasy message → SDK message
msgs := kit.ConvertToLLMMessages(&msg) // SDK message → LLM messages
msg := kit.ConvertFromLLMMessage(fMsg) // LLM message → SDK message
```
---
@@ -759,6 +796,7 @@ kit.LoadConfigWithEnvSubstitution("/path/to/config.yml")
## Key Files for Reference
- [`pkg/kit/kit.go`](https://github.com/mark3labs/kit/blob/main/pkg/kit/kit.go) — Kit struct, New(), Prompt methods, Subagent, Close
- [`pkg/kit/extension_api.go`](https://github.com/mark3labs/kit/blob/main/pkg/kit/extension_api.go) — ExtensionAPI interface, kit.Extensions() accessor
- [`pkg/kit/types.go`](https://github.com/mark3labs/kit/blob/main/pkg/kit/types.go) — Re-exported types from internal packages
- [`pkg/kit/tools.go`](https://github.com/mark3labs/kit/blob/main/pkg/kit/tools.go) — Tool constructors and bundles
- [`pkg/kit/events.go`](https://github.com/mark3labs/kit/blob/main/pkg/kit/events.go) — Event types, EventBus, typed subscribers
+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 spawn_subagent tool
## Built-in subagent tool
Kit includes a built-in `spawn_subagent` tool that the LLM can use to delegate tasks to independent child agents:
Kit includes a built-in `subagent` tool that the LLM can use to delegate tasks to independent child agents:
```
spawn_subagent(
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 `spawn_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 `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 `spawn_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 `subagent` tool). Register inside an `OnToolCall` handler using the tool call ID:
```go
host.OnToolCall(func(e kit.ToolCallEvent) {
if e.ToolName == "spawn_subagent" {
if e.ToolName == "subagent" {
host.SubscribeSubagent(e.ToolCallID, func(event kit.Event) {
switch ev := event.(type) {
case kit.MessageUpdateEvent:
+1 -1
View File
@@ -21,7 +21,7 @@ Manage the local model database that maps provider names to API configurations.
```bash
kit models [provider] # List available models (optionally filter by provider)
kit models --all # Show all providers (not just Fantasy-compatible)
kit models --all # Show all providers (not just LLM-compatible)
kit update-models [source] # Update model database
```
+1 -1
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 `spawn_subagent` tool, extensions can monitor the subagent's activity in real-time using three lifecycle events:
When the LLM uses the built-in `subagent` tool, extensions can monitor the subagent's activity in real-time using three lifecycle events:
```go
// Subagent started
+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, spawn_subagent with no MCP overhead
- **Built-in Core Tools** — bash, read, write, edit, grep, find, ls, 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
+1 -1
View File
@@ -139,7 +139,7 @@ When `--provider-url` is provided without `--model`, Kit automatically defaults
kit --provider-url "http://localhost:8080/v1" "Hello"
```
The `custom/custom` model has zero cost, 262K context window, and supports reasoning. It routes through fantasy's `openaicompat` provider and accepts any OpenAI-compatible API endpoint.
The `custom/custom` model has zero cost, 262K context window, and supports reasoning. It routes through the `openaicompat` provider and accepts any OpenAI-compatible API endpoint.
Optionally set `CUSTOM_API_KEY` environment variable or use `--provider-api-key` for endpoints requiring authentication.
+2 -44
View File
@@ -5,48 +5,6 @@ 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:
@@ -116,11 +74,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 `spawn_subagent` tool):
Monitor real-time events from LLM-initiated subagents (when the model uses the `subagent` tool):
```go
host.OnToolCall(func(e kit.ToolCallEvent) {
if e.ToolName == "spawn_subagent" {
if e.ToolName == "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,7 +62,6 @@ 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': '📂',
'spawn_subagent': '🤖',
'subagent': '🤖',
'fetch': '🌐',
'todo': '✅'
};
@@ -1612,7 +1612,7 @@ a:hover { text-decoration: underline; }
headerLabel = formatLsHeader(input);
bodyHtml = renderGenericBody(input, result);
break;
case 'spawn_subagent':
case 'subagent':
headerLabel = formatSubagentHeader(input);
bodyHtml = renderSubagentBody(input, result);
break;