Compare commits

...

93 Commits

Author SHA1 Message Date
Ed Zynda b70cce4f34 refactor(ui): remove pre-alt-screen dead code and boilerplate
- Remove scrollbackBuf, appendScrollback(), drainScrollback() and all
  call sites — the entire terminal scrollback pipeline was dead code
  since the alt screen migration
- Remove StreamComponent.render(), renderCache, renderDirty,
  scrollbackFlushedLines, viewContent(), and ConsumeOverflow() body —
  rendering is now handled by StreamingMessageItem in the ScrollList
- Remove SetHeight and ConsumeOverflow from streamComponentIface since
  height is managed by ScrollList and overflow is a no-op
- Remove redundant AltScreen/MouseMode/ReportFocus/KeyboardEnhancements
  boilerplate from 6 child View() methods — parent already sets these
- Convert two orphan appendScrollback calls (extension default text,
  shell command output) to proper ScrollList message items
- Update ~30 stale comments referencing tea.Println and scrollback buffer
2026-04-01 01:13:19 +03:00
Ed Zynda 4c566836b2 refactor(ui): move startup banner into ScrollList, fix /resume rendering
- Render ASCII logo and startup info exclusively in the ScrollList
  instead of printing to stdout/terminal scrollback
- Remove PrintStartupInfo() and move kitBanner() to ui.KitBanner()
- Fix separator spacing: use single pre-rendered item with embedded
  blank lines to avoid left-border artifacts on spacing rows
- Rewrite renderSessionHistory() to populate ScrollList with proper
  MessageItems instead of legacy appendScrollback() calls
- Clear m.messages on /clear, /new, and /resume so the ScrollList
  resets correctly when switching sessions
- Add pendingGotoBottom flag to defer scroll-to-bottom until after
  distributeHeight() recalculates the correct viewport height
- Fix pre-existing test failures: initialize scrollList in test helper,
  update 5 tests from tea.Println assertions to ScrollList checks
2026-04-01 00:39:32 +03:00
Ed Zynda bb3261883a Add visual separator after startup info in ScrollList
Added a horizontal rule (────) with blank lines above and below to
visually separate the startup info from the conversation history.

The separator uses theme.Border color and spans 80 characters, providing
a clear visual break between startup messages and the chat content.

This makes it easier to distinguish where the conversation starts when
scrolling back through history.
2026-03-31 19:07:56 +03:00
Ed Zynda 512d0f16ce Show startup info in ScrollList (alt screen mode)
Added AddStartupMessageToScrollList() method that renders startup info
(model, context, skills, extensions, MCP tools) and extension startup
messages as system messages in the ScrollList.

This ensures startup info is visible and scrollable in alt screen mode,
rather than being printed before BubbleTea starts and becoming hidden
when alt screen takes over.

Changes:
- AppModelOptions: Added StartupExtensionMessages field
- AppModel: Store and render startup messages in Init()
- AddStartupMessageToScrollList(): Renders startup info + extension messages
- cmd/root.go: Pass startupExtensionMessages to NewAppModel

The startup info now appears at the top of conversation history and can
be scrolled back to at any time.
2026-03-31 19:03:21 +03:00
Ed Zynda 8159431ce4 Prevent scrolling past bottom of content in ScrollList
Enhanced clampOffset() to detect when the viewport has scrolled past the
bottom of the content (would show empty space) and automatically reposition
to show the last line of content at the bottom of the viewport.

This prevents the 'floating' effect where multiple PgDn or scroll down
operations would push content off the top while showing blank space below.

The clamping logic:
1. Calculates total content height
2. If content fits in viewport, forces position to top
3. Otherwise, checks if remaining content < viewport height
4. If so, repositions to show exactly the last line at viewport bottom

Also updated clampOffset to use rendered height calculation (handles
non-cached items like reasoning blocks) instead of cached Height().
2026-03-31 18:56:18 +03:00
Ed Zynda 9f9f265fb3 Fix autoscroll for streaming messages (iteratr pattern)
Root cause: GotoBottom() was calculating heights using Height() which returns
0 for non-cached items. Reasoning blocks never cache renders due to live
duration updates, causing incorrect scroll calculations during reasoning →
assistant transitions.

Fix: Calculate heights directly from rendered strings instead of relying on
cached Height() values. This ensures accurate scroll positioning for all
message types.

Changes:
- ScrollList.GotoBottom(): Render items and calculate height from string
- ScrollList.AtBottom(): Same pattern for bottom detection
- appendStreamingChunk(): Call GotoBottom() directly for existing messages
- refreshContent(): Remove redundant GotoBottom() (handled by SetItems)

Tested with 'explore this repo' prompt - autoscroll now works correctly
throughout reasoning and assistant streaming phases.
2026-03-31 18:53:18 +03:00
Ed Zynda 9d38349091 fix: resolve all golangci-lint issues
- Use max() instead of if statement for min value
- Use strings.SplitSeq for efficient iteration
- Use range over int instead of explicit loop counter
- Remove unused functions:
  - InputComponent.renderPopup()
  - AppModel.renderStream()
  - AppModel.renderStreamingBashOutput()
  - AppModel.printCompactResult()
2026-03-31 17:49:25 +03:00
Ed Zynda fec8bac800 refactor: remove fallback from flushStreamContent
StreamingMessageItem must exist when flushing - no fallbacks.
2026-03-31 17:45:35 +03:00
Ed Zynda e76f5f3d45 fix: prevent duplicate text when flushing streaming content before tool calls
flushStreamContent() was creating a new StyledMessageItem when tool calls
started, but we already had a StreamingMessageItem with the same content.

Now we:
- Mark the existing StreamingMessageItem as complete
- Only create a new message as fallback if no streaming item exists

This fixes text duplication when assistant messages precede tool calls.
2026-03-31 17:43:50 +03:00
Ed Zynda 1ad493c5c7 feat: cap streaming bash output height and replace with tool result
- Limit streaming bash output to 20 lines max during live display
- Remove streaming bash item when tool completes
- Replace with truncated tool result block
- Expand background color to full terminal width with proper indentation
- Matches renderBashBody styling (lineIndent + width)

This prevents long-running commands from growing the UI forever while
still showing live output up to a reasonable height.
2026-03-31 17:42:32 +03:00
Ed Zynda ea6ddc8792 feat: integrate streaming bash output into ScrollList
- Add StreamingBashOutputItem to message_items.go
- Update ToolOutputEvent handler to append chunks to bash item in ScrollList
- Remove old renderStreamingBashOutput() that broke layout
- Bash output now streams inline with messages instead of separate section
- Auto-scrolls to bottom during streaming
- Marks bash item complete on ToolResultEvent

Fixes layout breaking when bash commands produce streaming output.
2026-03-31 17:38:03 +03:00
Ed Zynda 6d4e8bcec5 feat: add streaming support for compaction summaries
- Add StreamCallback parameter to compaction.Compact() for streaming text deltas
- Update generateSummary() to use fantasy.Agent.Stream() when callback provided
- Fix compactSplitTurn() to stream both history and turn prefix summaries
- Add SDK event subscription in CompactConversation() goroutine
- Update UI to handle streaming compaction like regular assistant messages
- Compaction summaries now stream word-by-word instead of appearing all at once

Fixes issue where compaction would show incomplete context (e.g. only 'nce')
by ensuring both history summary and turn prefix are streamed to the UI.
2026-03-31 17:33:51 +03:00
Ed Zynda e2ed345280 fix: center slash command popup overlay to prevent bottom overflow
- Move popup rendering from inline (below input) to centered overlay
- Add RenderPopupCentered() method to InputComponent
- Implement overlayContent() helper for line-by-line merging
- Popup now appears in center of screen above all content
- Prevents overflow issues when typing / at bottom of terminal
2026-03-31 16:45:57 +03:00
Ed Zynda e542eb797e fix: freeze reasoning duration counter on transition to assistant text
- Detect role transition in appendStreamingChunk (reasoning → assistant)
- Mark reasoning StreamingMessageItem as complete when assistant text starts
- Duration counter now freezes immediately when reasoning ends
- Add live duration counter that updates during reasoning streaming
- Store startTime and finalDuration for proper counter behavior
2026-03-31 16:40:41 +03:00
Ed Zynda e631fc1b17 feat: add live streaming text to ScrollList viewport
- Create StreamingMessageItem that accumulates chunks and re-renders
- Update StreamChunkEvent/ReasoningChunkEvent to append to StreamingMessageItem
- Enable live streaming display within ScrollList (iteratr-style)
- Mark streaming items as complete on ResponseCompleteEvent
- Reasoning and assistant text now stream in real-time in the viewport
2026-03-31 16:35:43 +03:00
Ed Zynda 290c5a4774 chore: disable select/copy functionality but keep plumbing
Disable the mouse selection and keyboard copy features while keeping
all the supporting code infrastructure:

- Comment out MouseClickMsg, MouseMotionMsg, MouseReleaseMsg handlers
- Comment out keyboard shortcuts (c/y keys) for copying
- Keep all ScrollList selection tracking code
- Keep clipboard utilities (clipboard.go)
- Keep highlighting functions in scrolllist.go

This allows the features to be easily re-enabled later while keeping
the codebase clean for now.
2026-03-31 16:29:01 +03:00
Ed Zynda 287d60c31e feat: add visual selection highlighting with theme colors
Implement visual feedback for text selection in the scrollback:

- Add isLineInSelection() to check if a line is within the current selection
- Add applyHighlight() using the theme's Highlight color for selected lines
- Add applyFocusIndicator() using MutedBorder for focused items
- Update View() to apply highlighting during rendering
- Add getItemAndLineAtY() for precise mouse position tracking
- Track both item index and line index within item for selection

Selection highlighting uses the user's selected theme colors for
consistent visual feedback across all themes.
2026-03-31 16:23:46 +03:00
Ed Zynda 3d45d98895 feat: add crush-style copy+paste support
Implement mouse selection and keyboard copy functionality following
crush's patterns:

- Add clipboard.go with dual-write clipboard support (OSC 52 + system)
- Add CopySelection tracking to ScrollList for text selection
- Implement HandleMouseDown/HandleMouseDrag/HandleMouseUp methods
- Add keyboard shortcuts (c/y) for copying messages
- Mouse click+drag to select text, auto-copy on release
- Toast notifications for copy feedback

Note: Full text extraction from selection requires additional work to
properly extract raw text from styled message content.
2026-03-31 16:19:58 +03:00
Ed Zynda db4be4f9a2 feat: implement full alt screen mode with in-memory scrollback
Add ScrollList component for viewport-based message history with lazy
rendering and offset-based scrolling. Implement MessageItem system for
user, assistant, tool, system, and error messages with pre-rendered
styled content from MessageRenderer.

Key changes:
- ScrollList: height-constrained viewport with itemGap support, padding
  to ensure fixed height for sticky bottom layout
- MessageItem implementations with preRendered content from MessageRenderer
- refreshContent() pattern for efficient ScrollList updates
- Mouse wheel scrolling (3 lines per tick) with auto-scroll behavior
- All message types (user, assistant, tool, system, error, extension)
  properly added to in-memory scrollback
- PgUp/PgDn/Alt+Home/Alt+End keybindings for navigation
- Removed tea.Println() calls for alt screen compatibility
- Sticky bottom layout: input, separator, status bar fixed at bottom

Files added:
- internal/ui/scrolllist.go (ScrollList component)
- internal/ui/message_items.go (MessageItem implementations)

Files modified:
- internal/ui/model.go (main integration)
- internal/ui/*.go (alt screen config for components)
2026-03-31 16:12:30 +03:00
Ed Zynda 80093e69ed remove 2026-03-31 15:08:46 +03:00
Ed Zynda ef519ba517 feat(acpserver): implement session/set_model ACP method
Add SetSessionModel method to the ACP agent, allowing clients to change
the active LLM model for a session at runtime. The method looks up the
session in the registry and delegates to kit.SetModel().

Verified with smoke test: session/set_model now returns success instead
of 'Method not found' error.
2026-03-31 15:05:23 +03:00
Ed Zynda d79eb1f0fa refactor(pkg/kit): use fantasy type aliases for LLM types with clean SDK names
Replace concrete LLMMessage/LLMUsage/LLMResponse/LLMFilePart structs with
type aliases to charm.land/fantasy types, exposing them under clean
LLM-prefixed names. This gives SDK consumers full access to rich message
parts (tool calls, reasoning, tool results) without importing fantasy
directly.

Key changes:
- LLM types are now aliases: LLMMessage=fantasy.Message, etc.
- Added aliases for all part types: LLMTextPart, LLMToolCallPart, etc.
- Re-exported constructors: NewLLMUserMessage, NewLLMSystemMessage
- Removed lossy conversion helpers (llm_convert.go, fantasyMsgsToKit)
- Updated all internal packages to use aliases consistently
- Added ACP smoke test script and prompt template
- Fixed lint issues: unused vars, modernize min() usage
2026-03-31 14:26:49 +03:00
Ed Zynda ac8ee6525d refactor(pkg/kit): replace fantasy type aliases with concrete LLM* structs
Remove charm.land/fantasy from the public API surface of pkg/kit by
replacing the four type aliases with concrete Kit-owned structs:

- LLMMessage  {Role LLMMessageRole, Content string}
- LLMUsage    {InputTokens, OutputTokens, TotalTokens, ...}
- LLMResponse {Content, FinishReason, Usage}
- LLMFilePart {Filename, Data []byte, MediaType}

Add LLMMessageRole type with user/assistant/system/tool constants.

Introduce pkg/kit/llm_convert.go as the single boundary layer where
Kit types convert to/from fantasy types internally. All callers in
pkg/kit, pkg/kit/compaction.go, pkg/kit/extensions_bridge.go, and
internal/app/app.go cross through this layer.

ContextPrepareHook.Messages and ContextPrepareResult.Messages change
from []fantasy.Message to []LLMMessage. extensions_bridge.go drops
its fantasy and strings imports entirely.

internal/app/app_test.go switches &fantasy.Usage{} to &kit.LLMUsage{}.

Add seven new tests in types_test.go covering concrete construction,
role constants, JSON snake_case tags, and round-trip conversion.
2026-03-31 13:44:05 +03:00
Ed Zynda e35e8382d6 fix(app): correct drainQueue QueueUpdatedEvent emission
- Remove always-zero queueLen variable: len() was measured after
  clearing the queue, so it was unconditionally 0 and the variable
  was dead code
- Emit QueueUpdatedEvent{Length: 0} explicitly to make intent clear
- Also emit QueueUpdatedEvent when a second batch is pulled mid-loop;
  previously the queue was silently cleared without notifying the UI,
  leaving queuedMessages stuck in the displayed-queued state forever
2026-03-31 13:19:09 +03:00
Ed Zynda fbb3408a25 chore(prompts): add new-prompt template
/new-prompt <description> scaffolds a new .kit/prompts/ template.
Explains the file format, argument substitution syntax, naming
conventions, and writing guidelines.
2026-03-31 13:04:11 +03:00
Ed Zynda 44fed9a647 chore(prompts): add commit-push prompt template
Provides a /commit-push slash command that reviews git status and diff,
stages all changes, writes a conventional commit message, commits, and
pushes to the current branch.
2026-03-31 13:03:14 +03:00
Ed Zynda e7f11487b9 remove CompactRenderer and --compact flag
The compact display mode was purely a UI concern that added complexity
without providing unique value. Anyone wanting compact-style formatting
can implement it as an extension using the Renderer interface.

- Delete internal/ui/compact_renderer.go
- Remove renderToolBodyCompact and all compact tool body renderers from
  tool_renderers.go
- Simplify NewCLI(debug bool) — drop compact parameter
- Simplify NewStreamComponent(width, modelName) — drop compactMode parameter
- Remove CompactMode from AppModelOptions, app.Options, CLISetupOptions
- Remove Compact from internal/config/config.go
- Remove --compact flag, var, and viper binding from cmd/root.go
- Update format.go: remove CompactRenderer interface compile-time check
  and clean up comments
2026-03-31 13:01:30 +03:00
Ed Zynda 054c417603 fix: render reasoning blocks when resuming sessions
When using /resume to resume a session, reasoning/thinking content
was not being displayed even though it was saved in the session file.

Changes:
- Add RenderReasoningBlock to Renderer interface
- Implement RenderReasoningBlock for MessageRenderer with muted italic
  styling matching live streaming output
- Implement RenderReasoningBlock for CompactRenderer with same styling
- Update renderSessionHistory to render reasoning content before
  assistant message text

Fixes: reasoning blocks now populate correctly when resuming sessions
2026-03-31 10:34:10 +03:00
Ed Zynda 94d62a6ef0 Fix ACP thinking tag parsing to handle format
The Qwen model outputs thinking content wrapped in  tags
(not <thinking>). Updated parseThinkingTags to detect and handle
both formats:
- <thinking>...</thinking> (long format)
-   (short format)

Also removed the hasProperReasoningEvents logic that was preventing
thinking tag parsing from working correctly. Now both ReasoningDeltaEvent
(from models with proper reasoning APIs) and thinking tags in text
(from models like Qwen) are handled together, matching the TUI behavior.
2026-03-30 20:38:49 +03:00
Ed Zynda 91e6dfd2c8 Prevent double-sending of thinking content in ACP
Track whether a model sends proper ReasoningDeltaEvent events. If so,
skip parsing <thinking> tags from text to avoid sending reasoning content
twice (once as proper reasoning, once parsed from text).

Also reset the tracking state at the start of each new prompt turn.
2026-03-30 20:33:46 +03:00
Ed Zynda b6a0c4b44c Add thinking tag parsing for ACP
Parse <thinking>...</thinking> tags from models (Qwen, DeepSeek) that
wrap reasoning content in XML-style tags instead of using proper
reasoning events.

When text chunks contain thinking tags:
- Extract content between tags and send as reasoning/thought updates
- Send content outside tags as regular message text
- Track state across chunks to handle streaming properly

This mirrors the TUI's thinking tag parsing behavior.
2026-03-30 20:30:22 +03:00
Ed Zynda 8eb0fa855a Fix ACP file attachment support
- Implement proper handling for all ACP content block types:
  - ContentBlockText: extracts text content
  - ContentBlockImage: decodes base64 to LLMFilePart
  - ContentBlockAudio: decodes base64 to LLMFilePart
  - ContentBlockResource: handles text and binary embedded resources
  - ContentBlockResourceLink: reads files from disk

- Text files are now included inline in the message (not as FilePart)
  to avoid OpenAI API errors. Only binary files (images, audio, PDFs)
  are sent as FilePart attachments.

- Add fallback MIME types when not provided by client
- Add default prompt text when user attaches files without text
- Add comprehensive debug logging for content extraction
- Enable debug logging in ACP command when --debug flag is used
2026-03-30 20:28:14 +03:00
Ed Zynda 3bf696c546 prompts 2026-03-30 18:30:53 +03:00
Ed Zynda 3e461a0539 chore: unignore .kit/prompts directory 2026-03-30 18:30:21 +03:00
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
Ed Zynda dcfebafcc5 fix: correct token usage and cost tracking for multi-step tool calls
This commit fixes several issues with token usage tracking:

1. Fix InputTokens-only validation bug - now checks any token field > 0
   to handle OpenAI-compatible providers where cached prompts result in
   InputTokens=0 while OutputTokens>0

2. Remove per-step context token updates from recordStepUsage() - context
   fill is now set once at turn completion via updateUsageFromTurnResult
   using FinalUsage.InputTokens, preventing display jumps during multi-step
   tool calls

3. Track maximum context seen in SetContextTokens() - prevents the status
   bar from showing decreasing token counts when FinalUsage.InputTokens
   reflects only the last step's input

4. Add comprehensive debug logging for token tracking at key points:
   - StepUsageEvent emission
   - recordStepUsage processing
   - updateUsageFromTurnResult processing

5. Update tests to reflect new behavior:
   - TestRecordStepUsage_updatesTracker: no longer expects context updates
   - TestUpdateUsageFromTurnResult_contextTokensUsesInputOnly: verifies
     InputTokens-only tracking

All tests pass. Token tracking now correctly accumulates costs and shows
monotonically increasing context size.
2026-03-28 17:49:31 +03:00
Ed Zynda 1f5c103667 fix: rock-solid token tracking - /new resets usage, remove estimation for costs
- /new command now properly resets usageTracker stats when starting fresh session
- Remove EstimateAndUpdateUsage fallback in updateUsageFromTurnResult()
- Remove EstimateAndUpdateUsage fallback in UpdateUsageFromResponse()
- Only use actual API-reported tokens for cost tracking (following opencode pattern)
- Estimation is inaccurate and should never be used for billing

Fixes issues with kimi-k2.5 and opencode token tracking where:
1. /new didn't reset token count/cost
2. Tokens never updated correctly due to estimation fallback
2026-03-28 12:15:45 +03:00
Ed Zynda 4caa8ba3dc Bridge SDK features to extension system: tree navigation, skills, templates, model resolution
This commit bridges 4 categories of internal SDK capabilities to the extension
system, enabling extensions like pi-prompt-template-model to be built with
minimal custom code.

New Extension APIs:

Tree Navigation (Phase 1):
- GetTreeNode, GetCurrentBranch, GetChildren - Navigate conversation tree
- NavigateTo - Branch/fork to specific entries
- SummarizeBranch - LLM-based branch summarization
- CollapseBranch - Fresh context primitive for context management

Skill Loading (Phase 2):
- LoadSkill, LoadSkillsFromDir - Load skill files with YAML frontmatter
- DiscoverSkills - Auto-discover from standard locations
- InjectSkillAsContext, InjectRawSkillAsContext - Pre-load skills

Template Parsing (Phase 3):
- ParseTemplate, RenderTemplate - {{variable}} substitution
- ParseArguments, SimpleParseArguments - CLI-style arg parsing (, , )
- EvaluateModelConditional, RenderWithModelConditionals - Model conditionals

Model Resolution (Phase 4):
- ResolveModelChain - Fallback chain resolution
- GetModelCapabilities - Query model specs
- CheckModelAvailable, GetCurrentProvider, GetCurrentModelID

Files Modified:
- internal/extensions/api.go - New types and Context methods
- internal/extensions/symbols.go - Export to Yaegi
- internal/extensions/runner.go - No-op stubs
- pkg/kit/sessions.go - Tree navigation bridge
- pkg/kit/skills.go - Skill loading bridge
- pkg/kit/template_bridge.go - NEW - Template & model resolution
- cmd/root.go - Wire to extension Context

Examples Added:
- conversation-manager.go - Tree nav, branch collapse, fresh context loops
- prompt-templates.go - Frontmatter templates with model switching
- bridge_demo.go - All new APIs demonstration

Documentation Updated:
- README.md - New capabilities and examples
- www/pages/extensions/capabilities.md - Full API docs
- www/pages/extensions/examples.md - New example category
- skills/kit-extensions/SKILL.md - Extension developer docs
2026-03-28 12:00:19 +03:00
Ed Zynda 15ef8ad78b fix theming 2026-03-27 23:41:32 +03:00
Ed Zynda 551f2710d9 refactor(ui): trim leading whitespace from thinking content
Use strings.TrimLeft to remove leading spaces, tabs, and newlines
from thinking/reasoning content for cleaner left alignment.
2026-03-27 21:41:16 +03:00
Ed Zynda 67bda5cad5 refactor(ui): color-code thinking block elements
- Thinking content: Italic + Muted color
- 'Thought for' label: VeryMuted color
- Duration (Xms/Xs): Accent color

Creates visual hierarchy: content > label > duration highlight
2026-03-27 21:39:06 +03:00
Ed Zynda 01d7d754ef refactor(ui): apply subdued color to thinking block text
Wrap italic thinking content with VeryMuted foreground color
for secondary visual hierarchy - less prominent than main response.
2026-03-27 21:37:42 +03:00
Ed Zynda c6304f1e92 refactor(ui): use Italic typography for thinking blocks
Change thinking content from H6 to Italic for more subdued,
secondary visual appearance. Makes reasoning text less prominent
than main assistant responses.
2026-03-27 21:36:21 +03:00
Ed Zynda bc3c733ae3 refactor(ui): use H6 instead of blockquote for thinking blocks
Change thinking/reasoning content from Blockquote to H6 (subtitle)
for cleaner visual styling without left border.
2026-03-27 21:34:26 +03:00
Ed Zynda 428ee2b8be refactor(ui): remove indentation from thinking block footer
Remove PaddingLeft(2) from 'Thought for...' duration text
so it aligns without extra indentation.
2026-03-27 21:32:50 +03:00
Ed Zynda eb1d7fd07e fix(ui): set Tip alert label to "You" for user messages 2026-03-27 21:31:05 +03:00
Ed Zynda 1e3e5cafd3 refactor(ui): use herald Tip alert for user messages
Update RenderUserMessage to use r.ty.Tip() for consistent
herald-based styling with green/success color indicator.
2026-03-27 21:30:33 +03:00
Ed Zynda 0b93e58fb9 fix(ui): correct labels for user and info messages
- Change AlertNote label from "You" to "Info" for system/extension messages
- Update RenderUserMessage to use custom styling with "You" label
- This separates user messages ("You") from info messages ("Info")
2026-03-27 21:23:17 +03:00
Ed Zynda 2bb01ed72c refactor(ui): use herald Note alert for system messages
Update RenderSystemMessage to use r.ty.Note() instead of r.ty.P()
for visual consistency with other herald-based message rendering.
This affects extension PrintInfo output and system messages.
2026-03-27 21:21:49 +03:00
Ed Zynda b6ecc36ea1 refactor(ui): improve message spacing and styling consistency
- Add bottom margin to startup header (KVGroup)
- Add bottom margin to thinking/reasoning blocks
- Fix thinking block footer to appear on new line without extra spacing
- Update spawn_subagent tool output to use bash-style formatting
- Add blank line after extension startup messages for visual separation
2026-03-27 21:15:41 +03:00
Ed Zynda d4f27bc912 revert(ui): restore original Read tool renderer without herald
The herald-based CodeBlock implementation didn't match the custom
styling we had for line numbers and gutters. Restoring the original
renderReadBody and renderCodeBlock functions with:
- Custom line number gutter styling
- Chroma syntax highlighting
- Truncation handling with footer preservation
2026-03-27 20:57:34 +03:00
Ed Zynda f12e195390 refactor(ui): replace custom message rendering with herald typography library
- Replace MessageRenderer with herald-based implementation
- Use herald alerts (Note, Tip, Warning, Caution) for message types
- Use blockquote for thinking/reasoning content
- Use KVGroup for startup info display
- Add margin-bottom to all message types for visual separation
- Simplify Read tool with herald CodeBlock and line numbers
- Add detectLanguage helper for syntax highlighting
- Capture extension startup messages and print after startup banner
- Remove ~200 lines of custom rendering code
2026-03-27 20:54:43 +03:00
Ed Zynda b68b3dd0bf Fix usage widget startup visibility and stop-path updates 2026-03-27 18:21:11 +03:00
Ed Zynda 48521bf76d ui: drop unused tool args from spinner label formatter 2026-03-27 17:54:53 +03:00
Ed Zynda 16df3a738c ui: polish stream/tool tracking comments and event-loop notes 2026-03-27 17:51:41 +03:00
Ed Zynda 9d0b8c8cef ui: simplify stream rendering state and harden stream ticks 2026-03-27 17:49:45 +03:00
Ed Zynda d9326fcf21 fix: auto-initialize extension context in kit.New()
Extensions were being loaded automatically by SetupAgent but the context
was never initialized unless the SDK user explicitly called
SetExtensionContext. This left extensions with a zero-value Context where
all function fields are nil.

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

Combined with the normalizeContext() safety net in the runner, extensions
are now guaranteed to work in SDK mode without explicit context wiring.
2026-03-27 15:54:56 +03:00
Ed Zynda 22c479277e fix: normalize nil Context function fields to no-ops in SetContext
Extensions running via the SDK (without a fully-wired SetExtensionContext
call) would panic with 'reflect.Value.Call: call of nil function' when
calling any ctx method like ctx.PrintBlock().

normalizeContext() now replaces every nil function field in Context with
a safe no-op stub before storing it in the runner, so extension handlers
can never crash on a missing callback regardless of how Kit is embedded.
2026-03-27 15:54:54 +03:00
Ed Zynda 8ae204f12f fix: preserve full content in scrollback by separating render cache from viewport
The StreamComponent was truncating content to fit the viewport height before
caching it in renderCache. This caused GetRenderedContent() to return truncated
content when flushing to scrollback.

Changes:
- render() now caches FULL content without height clamping
- New viewContent() helper applies height clamping only for display
- View() calls both: render() for full content, viewContent() for visible slice

This follows the Pi TUI pattern: full buffer in memory, viewport slicing only
at display time. Long assistant messages are now fully preserved in scrollback.
2026-03-27 12:13:04 +03:00
Ed Zynda 8b1665a4ce feat: add multi-edit support to edit tool
Implement multi-edit functionality matching Pi's approach:
- Add 'edits' array parameter for multiple disjoint replacements
- All edits matched against original content (non-incremental)
- Overlap detection prevents conflicting edits
- Duplicate detection ensures unique matches
- Atomic operations: all succeed or none applied
- Detailed error messages with edit indices (edits[0], etc.)
- Fuzzy matching works with multi-edit mode
- Backward compatible with single-edit mode (old_text/new_text)

Changes:
- internal/core/edit.go: Multi-edit logic, validation, overlap detection
- internal/ui/messages.go: Add 'edits' to body keys
- internal/ui/tool_renderers.go: Render multi-edit diffs
- internal/core/edit_test.go: 9 comprehensive multi-edit tests
2026-03-27 10:34:43 +03:00
118 changed files with 10064 additions and 4732 deletions
+1
View File
@@ -3,6 +3,7 @@
.env
.kit/*
!.kit/extensions/
!.kit/prompts/
aidocs/
*.log
/kit
+37
View File
@@ -0,0 +1,37 @@
---
description: Run ACP smoke test against opencode/kimi-k2.5 to verify JSON-RPC stdio works
---
Run the ACP smoke test to verify the Kit ACP server works correctly over JSON-RPC stdio with streaming responses.
## Steps
1. Build the kit binary:
```bash
go build -o output/kit ./cmd/kit
```
2. Run the smoke test Python script against opencode/kimi-k2.5:
```bash
python3 scripts/acp_smoke_test.py
```
3. Verify the output shows:
- `session/new` returns a valid `sessionId`
- `session/prompt` streams `agent_thought_chunk` notifications (reasoning)
- `session/prompt` streams `agent_message_chunk` notifications (response)
- Final result has `stopReason: "end_turn"`
- `✓ SMOKE TEST PASSED` at the end
4. If the test fails, check:
- `output/kit` binary exists and is executable
- `OPENCODE_API_KEY` or `OPENCODE_ZEN_API_KEY` environment variable is set
- `scripts/acp_smoke_test.py` exists
- The model `opencode/kimi-k2.5` is available (`kit models opencode | grep kimi-k2.5`)
5. For testing with a different model, edit the script or set the `MODEL` variable:
```bash
MODEL=anthropic/claude-sonnet-4-5 python3 scripts/acp_smoke_test.py
```
The smoke test exercises the full ACP protocol: session lifecycle, streaming notifications, and tool-free prompt completion.
+30
View File
@@ -0,0 +1,30 @@
---
description: Stage, commit, and push changes with an auto-generated conventional commit message
---
Review the current git status and diff, then stage all changes, write a concise conventional commit message, commit, and push to the current branch.
## Steps
1. **Check status**: `git status` — understand what has changed
2. **Review the diff**: `git diff` (and `git diff --cached` if anything is already staged) — read the actual changes
3. **Stage everything**: `git add -A`
4. **Craft the commit message** following Conventional Commits:
- Format: `<type>(<scope>): <short summary>`
- Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `perf`, `build`
- Scope: optional, the subsystem affected (e.g. `ui`, `cmd`, `config`)
- Summary: imperative mood, lowercase, no trailing period, ≤72 chars
- Body: add a blank line then bullet points for non-trivial changes
- Do **not** include "Generated by" or similar noise
5. **Commit**: `git commit -m "<message>"`
6. **Push**: `git push`
## Guidelines
- Read the actual diff — do not guess from filenames alone
- Prefer one well-scoped commit; do not split unless the changes are clearly unrelated
- Keep the subject line under 72 characters
- Use the body to explain *what* and *why*, not *how*
- If there is nothing to commit, say so and stop
$@
+47
View File
@@ -0,0 +1,47 @@
---
description: Scaffold a new prompt template in .kit/prompts/
---
Create a new kit prompt template. The user wants a prompt that does: $@
## What a prompt template is
A prompt template is a `.md` file in `.kit/prompts/` (project-local) or `~/.kit/prompts/` (global).
It becomes a `/slug` slash command in the kit input box — typed as `/filename` with optional arguments.
## File format
```
---
description: One-line description shown in autocomplete
---
Body text of the prompt. Use $@ for all user-supplied arguments,
$1 $2 etc. for positional arguments.
```
- **Filename** → slug: `commit-push.md` becomes `/commit-push`
- **Frontmatter**: only `description` is recognised; keep it under ~80 chars
- **Body**: plain markdown; the full text is submitted as the user's message when the template fires
- **Arguments**: `$@` expands to everything the user typed after the slash command name;
`$1`, `$2` for individual positional args; omit entirely if no arguments are needed
## Steps
1. **Understand the workflow** the user described in `$@` — ask a clarifying question if the intent is ambiguous
2. **Choose a filename**: short, lowercase, hyphen-separated, descriptive (e.g. `code-review.md`)
3. **Write the description**: one sentence, imperative, fits in autocomplete
4. **Draft the body**:
- Open with a single sentence stating the goal
- Use `## Steps` for multi-step workflows; use plain prose for simple prompts
- Be specific: name commands, flags, and file paths where relevant
- End with `$@` on its own line if the user might want to pass context or a hint; omit if the prompt is self-contained
5. **Write the file** to `.kit/prompts/<slug>.md`
6. **Confirm** by showing the final file content and the slash command that activates it
## Guidelines
- Keep prompts action-oriented — they should tell kit *what to do*, not just *what to think about*
- Prefer concrete steps over vague instructions
- A prompt that does one thing well beats one that tries to cover every edge case
- If the workflow already exists as a prompt, suggest extending it instead of duplicating
+70
View File
@@ -0,0 +1,70 @@
---
description: Semantic version tagging workflow - analyzes commits and tags releases
---
# Release Tagging Workflow
Tag a new version of this Go project following semantic versioning.
## Steps
1. **Fetch remote tags**: `git fetch --tags origin`
2. **Find latest version**: `git tag -l | sort -V | tail -5` to see recent tags
3. **Analyze changes since last tag**:
- `git log <latest-tag>..HEAD --oneline` - list commits
- `git diff <latest-tag>..HEAD --stat` - see file stats
- `git diff <latest-tag>..HEAD --name-only` - see changed files
4. **Determine version bump** (Semantic Versioning):
- **MAJOR (X.0.0)**: Breaking API changes, incompatible modifications
- **MINOR (0.X.0)**: New features, backward-compatible additions
- **PATCH (0.0.X)**: Bug fixes, backward-compatible fixes
Look for indicators:
- `feat:` or `feature:` commits → MINOR
- `fix:` or `bugfix:` commits → PATCH
- `breaking:` or `BREAKING CHANGE:` → MAJOR
- Breaking API changes in `pkg/` or public interfaces → MAJOR
- New commands, flags, or features → MINOR
- Documentation-only changes → PATCH (or skip)
5. **Calculate new version**: Increment appropriate segment, reset lower segments to 0
6. **Draft tag message**:
- Summarize key changes from commits
- Group by type (Features, Fixes, Breaking Changes)
- Keep concise but informative
7. **Create annotated tag**: `git tag -a vX.Y.Z -m "vX.Y.Z - <summary>\n\n<detailed list>"`
8. **Push tag**: `git push origin vX.Y.Z`
## Guidelines
- Always fetch remote tags first to avoid conflicts
- Use annotated tags (`-a`) with descriptive messages
- Follow semver strictly - when in doubt, prefer conservative bump (patch over minor)
- For Go projects, changes to `pkg/` or exported APIs warrant careful version consideration
- If no changes since last tag, suggest skipping the release
- Include commit summaries in the tag message body
## Example Tag Message Format
```
v0.30.1 - Bug fixes for model handling and UI improvements
Fixes:
- Properly handle think tags from Qwen/DeepSeek models
- Handle custom provider model persistence and bare model names
Improvements:
- UI style refactoring and cleanup
```
Wait for the user to confirm the version and message before executing tag commands.
---
$@
+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
+80
View File
@@ -0,0 +1,80 @@
# Autoscroll Fix - Final Summary
## Root Cause
The autoscroll was failing for streaming assistant messages due to a bug in how `GotoBottom()` calculated item heights.
### The Problem
1. **Reasoning blocks** (`StreamingMessageItem` with `role="reasoning"`) are **never cached** because they have live duration counters that update every render
2. The `Height()` method returns `0` when `cachedRender == ""`
3. `GotoBottom()` was calling:
```go
itemHeight := item.Height() // Returns 0 for reasoning
if itemHeight == 0 {
item.Render(s.width) // Renders but doesn't cache (reasoning)
itemHeight = item.Height() // Still returns 0!
}
```
4. This caused incorrect scroll position calculations, especially during reasoning → assistant transitions
## The Solution
Changed `GotoBottom()` and `AtBottom()` to calculate height **directly from the rendered string** instead of relying on the cached height:
```go
// OLD: item.Height() which checks cached render
itemHeight := item.Height()
if itemHeight == 0 {
item.Render(s.width)
itemHeight = item.Height() // Still might be 0!
}
// NEW: Calculate from rendered string directly
rendered := item.Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
```
This works for **all** items regardless of whether they cache their render or not.
## Files Changed
### `internal/ui/scrolllist.go`
- **`GotoBottom()`**: Calculate height from rendered string (2 loops)
- **`AtBottom()`**: Calculate height from rendered string (1 loop)
### `internal/ui/model.go`
- **`appendStreamingChunk()`**: For existing messages, call `GotoBottom()` directly (iteratr pattern)
- **`refreshContent()`**: Simplified to only call `SetItems()` (removed redundant `GotoBottom()`)
- **Bash streaming handler**: Removed redundant `GotoBottom()` after `refreshContent()`
## Testing Results
✅ **Test prompt**: "explore this repo"
**Before fix**:
- Autoscroll stopped after reasoning block completed
- Viewport stuck showing end of reasoning ("Thought for 203ms")
- Assistant response streamed off-screen below
**After fix**:
- Autoscroll works throughout reasoning block
- Autoscroll continues during reasoning → assistant transition
- Viewport stays at bottom showing latest assistant content
- Final position shows end of response (build commands section)
## Behavior Verified
1. ✅ Streaming text auto-scrolls to bottom
2. ✅ Works across reasoning → assistant transition
3. ✅ Manual scroll up (PgUp) disables autoscroll
4. ✅ Scroll to bottom (Alt+End) re-enables autoscroll
5. ✅ Accurate positioning with no offset errors
## Performance Note
The fix calls `Render()` on all items during `GotoBottom()` calculations. This is acceptable because:
- `Render()` is already optimized with caching for non-reasoning items
- `GotoBottom()` is only called during content updates (not every frame)
- Reasoning blocks need to render anyway for live duration updates
- This matches iteratr's approach of ensuring items are rendered before height calculations
+30 -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
@@ -307,6 +307,12 @@ kit -e examples/extensions/minimal.go
- **Themes**: Register and switch color themes via `RegisterTheme`, `SetTheme`, `ListThemes`
- **Custom Events**: Inter-extension communication via `EmitCustomEvent`
**Bridged SDK APIs** (NEW): Extensions can now access internal SDK capabilities:
- **Tree Navigation**: Navigate conversation history (`GetTreeNode`, `GetCurrentBranch`, `NavigateTo`), summarize branches (`SummarizeBranch`), and implement fresh context loops (`CollapseBranch`)
- **Skill Loading**: Dynamically load and inject skills at runtime (`LoadSkill`, `DiscoverSkills`, `InjectSkillAsContext`)
- **Template Parsing**: Parse and render templates with `{{variables}}` (`ParseTemplate`, `RenderTemplate`), parse CLI-style arguments (`ParseArguments`, `SimpleParseArguments`), and evaluate model conditionals (`EvaluateModelConditional`, `RenderWithModelConditionals`)
- **Model Resolution**: Resolve model fallback chains (`ResolveModelChain`), query model capabilities (`GetModelCapabilities`, `CheckModelAvailable`), and extract provider/model ID (`GetCurrentProvider`, `GetCurrentModelID`)
### Extension Examples
See the `examples/extensions/` directory:
@@ -318,6 +324,7 @@ See the `examples/extensions/` directory:
- `compact-notify.go` - Notification on compaction
- `confirm-destructive.go` - Confirm destructive operations
- `context-inject.go` - Inject context into conversations
- `conversation-manager.go` - **NEW** Tree navigation, branch summarization, and fresh context loops
- `custom-editor-demo.go` - Vim-like modal editor
- `dev-reload.go` - Development live-reload
- `header-footer-demo.go` - Custom headers and footers
@@ -332,6 +339,7 @@ See the `examples/extensions/` directory:
- `plan-mode.go` - Read-only planning mode
- `project-rules.go` - Project-specific rules
- `prompt-demo.go` - Interactive prompts (select/confirm/input)
- `prompt-templates.go` - **NEW** Frontmatter-driven templates with model switching and skill injection
- `protected-paths.go` - Path protection for sensitive files
- `subagent-widget.go` - Multi-agent orchestration with status widget
- `subagent-test.go` - Subagent testing utilities
@@ -494,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?")
@@ -535,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)
},
)
```
@@ -715,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
+12
View File
@@ -76,6 +76,18 @@
"name": "opencode",
"url": "https://github.com/anomalyco/opencode",
"branch": "dev"
},
{
"type": "git",
"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",
+3
View File
@@ -11,6 +11,7 @@ import (
"os/signal"
"syscall"
"github.com/charmbracelet/log"
acp "github.com/coder/acp-go-sdk"
"github.com/mark3labs/kit/internal/acpserver"
@@ -54,6 +55,8 @@ func runACP(cmd *cobra.Command, _ []string) error {
conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
})))
// Also set charmbracelet/log level for acpserver package logging
log.SetLevel(log.DebugLevel)
}
// Wait for either the client to disconnect or a signal.
+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)
+562 -173
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -41,7 +41,6 @@ func BuildAppOptions(mcpConfig *config.Config, modelName string, serverNames, to
StreamingEnabled: viper.GetBool("stream"),
Quiet: quietFlag,
Debug: viper.GetBool("debug"),
CompactMode: viper.GetBool("compact"),
}
}
@@ -131,7 +130,6 @@ func SetupCLIForNonInteractive(k *kit.Kit) (*ui.CLI, error) {
Agent: agentAdapter,
ModelString: viper.GetString("model"),
Debug: viper.GetBool("debug"),
Compact: viper.GetBool("compact"),
Quiet: quietFlag,
ShowDebug: false,
ProviderAPIKey: viper.GetString("provider-api-key"),
@@ -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)
}
}
})
}
}
+170
View File
@@ -0,0 +1,170 @@
//go:build ignore
// bridge_demo.go - Demonstrates the new bridged SDK APIs for extensions.
// This extension showcases tree navigation, skill loading, template parsing,
// and model resolution capabilities.
package main
import (
"encoding/json"
"fmt"
"strings"
"kit/ext"
)
var (
discoveredSkills []ext.Skill
currentBranch []ext.TreeNode
)
func Init(api ext.API) {
// Register /tree-info command to demonstrate tree navigation
api.RegisterCommand(ext.CommandDef{
Name: "tree-info",
Description: "Show current conversation tree information",
Execute: func(args string, ctx ext.Context) (string, error) {
branch := ctx.GetCurrentBranch()
info := fmt.Sprintf("Current branch has %d nodes:\n", len(branch))
for i, node := range branch {
info += fmt.Sprintf(" [%d] %s (%s): %s...\n", i, node.Type, node.ID[:8], truncate(node.Content, 40))
}
ctx.PrintInfo(info)
return "", nil
},
})
// Register /discover-skills command
api.RegisterCommand(ext.CommandDef{
Name: "discover-skills",
Description: "Discover and list available skills",
Execute: func(args string, ctx ext.Context) (string, error) {
result := ctx.DiscoverSkills()
if result.Error != "" {
return "", fmt.Errorf("discovery failed: %s", result.Error)
}
discoveredSkills = result.Skills
info := fmt.Sprintf("Discovered %d skills:\n", len(result.Skills))
for _, s := range result.Skills {
info += fmt.Sprintf(" - %s: %s\n", s.Name, s.Description)
}
ctx.PrintInfo(info)
return "", nil
},
})
// Register /parse-template command
api.RegisterCommand(ext.CommandDef{
Name: "parse-template",
Description: "Parse a template and show extracted variables",
Execute: func(args string, ctx ext.Context) (string, error) {
if args == "" {
args = "Hello {{name}}, welcome to {{place}}!"
}
tpl := ctx.ParseTemplate("demo", args)
info := fmt.Sprintf("Template: %s\nVariables: %v", tpl.Content, tpl.Variables)
ctx.PrintInfo(info)
return "", nil
},
})
// Register /render-template command
api.RegisterCommand(ext.CommandDef{
Name: "render-template",
Description: "Render a template with variables (usage: /render-template name=John place=Kit)",
Execute: func(args string, ctx ext.Context) (string, error) {
tpl := ctx.ParseTemplate("demo", "Hello {{name}}, welcome to {{place}}!")
vars := ctx.ParseArguments(args, ext.ArgumentPattern{
Flags: map[string]string{"name": "name", "place": "place"},
})
rendered := ctx.RenderTemplate(tpl, vars.Vars)
ctx.PrintInfo("Rendered: " + rendered)
return "", nil
},
})
// Register /check-model command
api.RegisterCommand(ext.CommandDef{
Name: "check-model",
Description: "Check model capabilities and availability",
Execute: func(args string, ctx ext.Context) (string, error) {
model := args
if model == "" {
model = ctx.Model
}
available := ctx.CheckModelAvailable(model)
caps, err := ctx.GetModelCapabilities(model)
info := fmt.Sprintf("Model: %s\n", model)
info += fmt.Sprintf("Available: %v\n", available)
if err == "" {
info += fmt.Sprintf("Provider: %s\n", caps.Provider)
info += fmt.Sprintf("Context Limit: %d\n", caps.ContextLimit)
info += fmt.Sprintf("Reasoning: %v\n", caps.Reasoning)
} else {
info += fmt.Sprintf("Error: %s\n", err)
}
ctx.PrintInfo(info)
return "", nil
},
})
// Register /resolve-chain command
api.RegisterCommand(ext.CommandDef{
Name: "resolve-chain",
Description: "Resolve a model chain (usage: /resolve-chain claude-opus,gpt-4o,claude-sonnet)",
Execute: func(args string, ctx ext.Context) (string, error) {
if args == "" {
args = "anthropic/claude-opus-4,anthropic/claude-sonnet-4,openai/gpt-4o"
}
prefs := ctx.SimpleParseArguments(args, 1)
chain := []string{}
if len(prefs) > 1 {
// Split the first arg by comma
for _, p := range strings.Split(prefs[1], ",") {
p = strings.TrimSpace(p)
if p != "" {
chain = append(chain, p)
}
}
}
result := ctx.ResolveModelChain(chain)
info, _ := json.MarshalIndent(result, "", " ")
ctx.PrintInfo("Resolution Result:\n" + string(info))
return "", nil
},
})
// Register /test-conditional command
api.RegisterCommand(ext.CommandDef{
Name: "test-conditional",
Description: "Test model conditional rendering",
Execute: func(args string, ctx ext.Context) (string, error) {
content := `<if-model is="claude-*">This is for Claude models<else>This is for other models</if-model>`
rendered := ctx.RenderWithModelConditionals(content)
ctx.PrintInfo("Input: " + content)
ctx.PrintInfo("Output: " + rendered)
ctx.PrintInfo(fmt.Sprintf("Current model matches 'claude-*': %v", ctx.EvaluateModelConditional("claude-*")))
return "", nil
},
})
// OnSessionStart: discover skills automatically
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
result := ctx.DiscoverSkills()
if result.Error == "" && len(result.Skills) > 0 {
discoveredSkills = result.Skills
ctx.SetStatus("bridge-demo", fmt.Sprintf("%d skills", len(result.Skills)), 50)
}
})
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}
+406
View File
@@ -0,0 +1,406 @@
//go:build ignore
// conversation-manager.go - Advanced conversation tree navigation and management.
// This extension demonstrates:
// - Tree navigation (GetTreeNode, GetCurrentBranch, NavigateTo)
// - Branch summarization and collapsing
// - Interactive tree exploration
//
// Commands:
// /tree - Show conversation tree structure
// /branch - Show current branch path
// /goto <entry-id> - Navigate to a specific entry
// /summarize <n> - Summarize last N messages
// /fresh-context - Collapse branch and start fresh
// /loop <n> <prompt> - Execute prompt N times with fresh context each iteration
package main
import (
"fmt"
"strconv"
"strings"
"time"
"kit/ext"
)
var (
loopActive bool
loopCount int
loopCurrent int
loopPrompt string
loopStartNode string
)
func Init(api ext.API) {
// /tree - Show tree structure
api.RegisterCommand(ext.CommandDef{
Name: "tree",
Description: "Show conversation tree structure",
Execute: func(args string, ctx ext.Context) (string, error) {
showTree(ctx)
return "", nil
},
})
// /branch - Show current branch
api.RegisterCommand(ext.CommandDef{
Name: "branch",
Description: "Show current conversation branch",
Execute: func(args string, ctx ext.Context) (string, error) {
showBranch(ctx)
return "", nil
},
})
// /goto - Navigate to entry
api.RegisterCommand(ext.CommandDef{
Name: "goto",
Description: "Navigate to a specific entry ID (usage: /goto <entry-id>)",
Execute: func(args string, ctx ext.Context) (string, error) {
if args == "" {
ctx.PrintError("Usage: /goto <entry-id>")
return "", nil
}
result := ctx.NavigateTo(args)
if !result.Success {
ctx.PrintError(fmt.Sprintf("Navigation failed: %s", result.Error))
return "", nil
}
ctx.PrintInfo(fmt.Sprintf("Navigated to entry: %s", args))
// Show the node we navigated to
node := ctx.GetTreeNode(args)
if node != nil {
ctx.PrintInfo(fmt.Sprintf("Entry type: %s, Role: %s", node.Type, node.Role))
}
return "", nil
},
})
// /summarize - Summarize recent messages
api.RegisterCommand(ext.CommandDef{
Name: "summarize",
Description: "Summarize last N messages (usage: /summarize [n=5])",
Execute: func(args string, ctx ext.Context) (string, error) {
n := 5
if args != "" {
if parsed, err := strconv.Atoi(args); err == nil && parsed > 0 {
n = parsed
}
}
branch := ctx.GetCurrentBranch()
if len(branch) < 2 {
ctx.PrintError("Not enough messages to summarize")
return "", nil
}
// Find range to summarize
startIdx := len(branch) - n - 1
if startIdx < 0 {
startIdx = 0
}
endIdx := len(branch) - 1
fromID := branch[startIdx].ID
toID := branch[endIdx].ID
ctx.PrintInfo(fmt.Sprintf("Summarizing messages %d to %d...", startIdx, endIdx))
summary := ctx.SummarizeBranch(fromID, toID)
if summary == "" {
ctx.PrintError("Failed to generate summary")
return "", nil
}
ctx.PrintBlock(ext.PrintBlockOpts{
Text: summary,
BorderColor: "#89b4fa",
Subtitle: "conversation-manager · Summary",
})
return "", nil
},
})
// /fresh-context - Collapse and restart
api.RegisterCommand(ext.CommandDef{
Name: "fresh-context",
Description: "Collapse conversation to summary and start fresh",
Execute: func(args string, ctx ext.Context) (string, error) {
branch := ctx.GetCurrentBranch()
if len(branch) < 3 {
ctx.PrintError("Not enough context to collapse")
return "", nil
}
// Keep first message (system), summarize rest
fromID := branch[1].ID
toID := branch[len(branch)-1].ID
ctx.PrintInfo("Generating summary for context collapse...")
summary := ctx.SummarizeBranch(fromID, toID)
if summary == "" {
ctx.PrintError("Failed to generate summary")
return "", nil
}
// Collapse the branch
result := ctx.CollapseBranch(fromID, toID, summary)
if !result.Success {
ctx.PrintError(fmt.Sprintf("Collapse failed: %s", result.Error))
return "", nil
}
ctx.PrintInfo("Context collapsed. Starting fresh with summary.")
ctx.PrintBlock(ext.PrintBlockOpts{
Text: summary,
BorderColor: "#a6e3a1",
Subtitle: "conversation-manager · Collapsed Context",
})
// Set a widget showing we're in fresh mode
ctx.SetWidget(ext.WidgetConfig{
ID: "fresh-context",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: "🌱 Fresh Context Mode - Previous conversation collapsed"},
Style: ext.WidgetStyle{BorderColor: "#a6e3a1"},
})
return "", nil
},
})
// /loop - Execute with fresh context each iteration
api.RegisterCommand(ext.CommandDef{
Name: "loop",
Description: "Execute prompt N times with fresh context (usage: /loop 5 analyze this code)",
Execute: func(args string, ctx ext.Context) (string, error) {
if loopActive {
ctx.PrintError("Loop already in progress. Wait for completion.")
return "", nil
}
// Parse arguments
parts := strings.SplitN(args, " ", 2)
if len(parts) < 2 {
ctx.PrintError("Usage: /loop <count> <prompt>")
return "", nil
}
count, err := strconv.Atoi(parts[0])
if err != nil || count <= 0 || count > 10 {
ctx.PrintError("Invalid count (must be 1-10)")
return "", nil
}
loopCount = count
loopCurrent = 0
loopPrompt = parts[1]
loopActive = true
// Store current branch position
branch := ctx.GetCurrentBranch()
if len(branch) > 0 {
loopStartNode = branch[len(branch)-1].ID
}
ctx.PrintInfo(fmt.Sprintf("Starting loop: %d iterations", loopCount))
ctx.SetWidget(ext.WidgetConfig{
ID: "loop-progress",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: fmt.Sprintf("🔄 Loop: 0/%d - %s", loopCount, loopPrompt)},
Style: ext.WidgetStyle{BorderColor: "#fab387"},
})
// Start first iteration
executeLoopIteration(ctx)
return "", nil
},
})
// OnAgentEnd handles loop continuation
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
if !loopActive {
return
}
loopCurrent++
if loopCurrent >= loopCount {
// Loop complete
loopActive = false
ctx.RemoveWidget("loop-progress")
ctx.PrintInfo(fmt.Sprintf("✅ Loop complete: %d/%d iterations", loopCurrent, loopCount))
// Show final summary
branch := ctx.GetCurrentBranch()
if len(branch) > 0 && loopStartNode != "" {
summary := ctx.SummarizeBranch(loopStartNode, branch[len(branch)-1].ID)
if summary != "" {
ctx.PrintBlock(ext.PrintBlockOpts{
Text: summary,
BorderColor: "#a6e3a1",
Subtitle: "conversation-manager · Loop Summary",
})
}
}
return
}
// Update progress
ctx.SetWidget(ext.WidgetConfig{
ID: "loop-progress",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: fmt.Sprintf("🔄 Loop: %d/%d - %s", loopCurrent, loopCount, loopPrompt)},
Style: ext.WidgetStyle{BorderColor: "#fab387"},
})
// Collapse previous iteration for fresh context
branch := ctx.GetCurrentBranch()
if len(branch) >= 2 {
// Find the user messages (look for the one before the last assistant message)
// We want to collapse from the user message that started this iteration
// to the last assistant response
var collapseStartIdx = -1
for i := len(branch) - 1; i >= 0; i-- {
if branch[i].Role == "assistant" {
// Found the last assistant message, now find the user message before it
for j := i - 1; j >= 0; j-- {
if branch[j].Role == "user" {
collapseStartIdx = j
break
}
}
break
}
}
if collapseStartIdx >= 0 {
fromID := branch[collapseStartIdx].ID
toID := branch[len(branch)-1].ID
ctx.PrintInfo(fmt.Sprintf("Collapsing iteration %d for fresh context...", loopCurrent))
summary := ctx.SummarizeBranch(fromID, toID)
if summary != "" {
result := ctx.CollapseBranch(fromID, toID, summary)
if result.Success {
ctx.PrintInfo("Context collapsed successfully")
} else {
ctx.PrintError(fmt.Sprintf("Collapse failed: %s", result.Error))
}
}
}
}
// Small delay to let UI update
time.Sleep(500 * time.Millisecond)
// Trigger next iteration
executeLoopIteration(ctx)
})
}
// showTree displays the conversation tree structure
func showTree(ctx ext.Context) {
branch := ctx.GetCurrentBranch()
if len(branch) == 0 {
ctx.PrintInfo("Tree is empty")
return
}
var output strings.Builder
output.WriteString(fmt.Sprintf("Conversation Tree (%d nodes):\n\n", len(branch)))
for i, node := range branch {
prefix := " "
if i == len(branch)-1 {
prefix = "▶ " // Current node
} else {
prefix = " "
}
roleIcon := "💬"
switch node.Role {
case "user":
roleIcon = "👤"
case "assistant":
roleIcon = "🤖"
case "system":
roleIcon = "⚙️"
}
content := truncate(node.Content, 50)
if node.Type == "branch_summary" {
roleIcon = "📋"
content = "[Summary] " + truncate(node.Content, 40)
}
output.WriteString(fmt.Sprintf("%s%s %s: %s (%s...)\n", prefix, roleIcon, node.Role, node.ID[:8], content))
// Show children count if any
children := ctx.GetChildren(node.ID)
if len(children) > 0 {
output.WriteString(fmt.Sprintf(" └─ %d branch(es)\n", len(children)))
}
}
ctx.PrintBlock(ext.PrintBlockOpts{
Text: output.String(),
BorderColor: "#89b4fa",
Subtitle: "conversation-manager · Tree View",
})
}
// showBranch displays the current branch path
func showBranch(ctx ext.Context) {
branch := ctx.GetCurrentBranch()
if len(branch) == 0 {
ctx.PrintInfo("No active branch")
return
}
var output strings.Builder
output.WriteString(fmt.Sprintf("Current Branch (%d nodes from root to leaf):\n\n", len(branch)))
for i, node := range branch {
marker := " "
if i == len(branch)-1 {
marker = "▶ " // Current leaf
}
output.WriteString(fmt.Sprintf("%s[%d] %s (%s): %s\n",
marker, i, node.Type, node.ID[:8], truncate(node.Content, 40)))
}
// Show current node details
leaf := branch[len(branch)-1]
output.WriteString(fmt.Sprintf("\nCurrent Leaf:\n"))
output.WriteString(fmt.Sprintf(" ID: %s\n", leaf.ID))
output.WriteString(fmt.Sprintf(" Type: %s\n", leaf.Type))
output.WriteString(fmt.Sprintf(" Role: %s\n", leaf.Role))
output.WriteString(fmt.Sprintf(" Model: %s\n", leaf.Model))
output.WriteString(fmt.Sprintf(" Children: %d\n", len(leaf.Children)))
ctx.PrintBlock(ext.PrintBlockOpts{
Text: output.String(),
BorderColor: "#cba6f7",
Subtitle: "conversation-manager · Branch View",
})
}
// executeLoopIteration triggers the next loop iteration
func executeLoopIteration(ctx ext.Context) {
iterationPrompt := fmt.Sprintf("[%d/%d] %s", loopCurrent+1, loopCount, loopPrompt)
ctx.SendMessage(iterationPrompt)
}
// truncate helper
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}
+1 -1
View File
@@ -908,7 +908,7 @@ func summarizeToolAction(toolName string, inputJSON string) string {
return "searching " + getStr("pattern", "text")
case "ls":
return "listing " + getStr("path", "directory")
case "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
+269
View File
@@ -0,0 +1,269 @@
//go:build ignore
// prompt-templates.go - Frontmatter-driven prompt templates with model switching.
// This extension demonstrates the new bridged SDK APIs:
// - Tree navigation for conversation management
// - Template parsing with {{variable}} substitution
// - Model resolution with fallback chains
// - Skill injection
//
// Usage:
// 1. Create ~/.config/kit/prompts/debug.md with frontmatter:
// ---
// description: Debug Python code
// model: claude-sonnet-4-20250514
// skill: python
// ---
// Help me debug this Python code: {{input}}
//
// 2. In Kit: /debug my_script.py
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"kit/ext"
)
// PromptTemplate represents a loaded template with frontmatter
type PromptTemplate struct {
Name string
Description string
Model string
Skill string
Content string
Variables []string
Path string
}
var (
templates = make(map[string]PromptTemplate)
templateDir string
)
func Init(api ext.API) {
// Determine template directory
home, _ := os.UserHomeDir()
templateDir = filepath.Join(home, ".config", "kit", "prompts")
// Ensure directory exists
os.MkdirAll(templateDir, 0755)
// Register commands
api.RegisterCommand(ext.CommandDef{
Name: "reload-templates",
Description: "Reload prompt templates from disk",
Execute: func(args string, ctx ext.Context) (string, error) {
loadTemplates(ctx)
ctx.PrintInfo(fmt.Sprintf("Loaded %d templates from %s", len(templates), templateDir))
return "", nil
},
})
// Dynamic template commands are registered after loading
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
loadTemplates(ctx)
registerTemplateCommands(api, ctx)
})
}
// loadTemplates discovers and loads all template files
func loadTemplates(ctx ext.Context) {
templates = make(map[string]PromptTemplate)
entries, err := os.ReadDir(templateDir)
if err != nil {
return
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
path := filepath.Join(templateDir, entry.Name())
tpl, err := loadTemplateFile(path)
if err != nil {
continue
}
name := strings.TrimSuffix(entry.Name(), ".md")
templates[name] = tpl
}
}
// loadTemplateFile parses a template with YAML frontmatter
func loadTemplateFile(path string) (PromptTemplate, error) {
data, err := os.ReadFile(path)
if err != nil {
return PromptTemplate{}, err
}
content := string(data)
tpl := PromptTemplate{Path: path}
// Parse frontmatter
if strings.HasPrefix(content, "---") {
parts := strings.SplitN(content[3:], "---", 2)
if len(parts) == 2 {
frontmatter := strings.TrimSpace(parts[0])
body := strings.TrimSpace(parts[1])
// Simple line-by-line frontmatter parsing
for _, line := range strings.Split(frontmatter, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, ":")
if found {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
switch key {
case "description":
tpl.Description = value
case "model":
tpl.Model = value
case "skill":
tpl.Skill = value
}
}
}
tpl.Content = body
} else {
tpl.Content = content
}
} else {
tpl.Content = content
}
// Parse {{variables}} using simple string parsing
// (Can't use ctx.ParseTemplate here since we're in Init, not a handler)
var vars []string
for {
start := strings.Index(tpl.Content, "{{")
if start == -1 {
break
}
end := strings.Index(tpl.Content[start:], "}}")
if end == -1 {
break
}
varName := strings.TrimSpace(tpl.Content[start+2 : start+end])
vars = append(vars, varName)
tpl.Content = tpl.Content[:start] + "{{" + varName + "}}" + tpl.Content[start+end+2:]
}
tpl.Variables = vars
return tpl, nil
}
// registerTemplateCommands dynamically registers commands for each template
func registerTemplateCommands(api ext.API, ctx ext.Context) {
for name, tpl := range templates {
// Skip if already registered (we'd need to track this)
tplCopy := tpl // Capture for closure
nameCopy := name
// Build description with metadata
desc := tplCopy.Description
if desc == "" {
desc = fmt.Sprintf("Run %s template", nameCopy)
}
if tplCopy.Model != "" {
desc += fmt.Sprintf(" [%s", tplCopy.Model)
if tplCopy.Skill != "" {
desc += fmt.Sprintf(" +%s", tplCopy.Skill)
}
desc += "]"
}
api.RegisterCommand(ext.CommandDef{
Name: nameCopy,
Description: desc,
Execute: func(args string, ctx ext.Context) (string, error) {
return executeTemplate(ctx, tplCopy, args)
},
})
}
}
// executeTemplate runs a template with the given arguments
func executeTemplate(ctx ext.Context, tpl PromptTemplate, args string) (string, error) {
// Store original model for restoration
originalModel := ctx.Model
// 1. Resolve and switch model if specified
if tpl.Model != "" {
// Parse model chain (comma-separated)
preferences := strings.Split(tpl.Model, ",")
for i := range preferences {
preferences[i] = strings.TrimSpace(preferences[i])
}
result := ctx.ResolveModelChain(preferences)
if result.Error != "" {
ctx.PrintError(fmt.Sprintf("Model resolution failed: %s", result.Error))
// Continue with current model
} else {
ctx.PrintInfo(fmt.Sprintf("Switching to model: %s", result.Model))
if err := ctx.SetModel(result.Model); err != nil {
ctx.PrintError(fmt.Sprintf("Failed to switch model: %s", err.Error()))
}
}
}
// 2. Inject skill if specified
if tpl.Skill != "" {
err := ctx.InjectSkillAsContext(tpl.Skill)
if err != "" {
ctx.PrintError(fmt.Sprintf("Skill injection failed: %s", err))
} else {
ctx.PrintInfo(fmt.Sprintf("Injected skill: %s", tpl.Skill))
}
}
// 3. Parse and render template
parsed := ctx.ParseTemplate(tpl.Name, tpl.Content)
// Build variable map
vars := make(map[string]string)
// Simple argument parsing: first arg is $1 (input), rest is $@
if len(parsed.Variables) > 0 {
argsList := ctx.SimpleParseArguments(args, len(parsed.Variables))
for i, varName := range parsed.Variables {
if i < len(parsed.Variables) && i+1 < len(argsList) {
vars[varName] = argsList[i+1]
}
}
// If single variable, use full args
if len(parsed.Variables) == 1 && vars[parsed.Variables[0]] == "" {
vars[parsed.Variables[0]] = args
}
}
// Render with model conditionals
content := ctx.RenderWithModelConditionals(tpl.Content)
rendered := ctx.RenderTemplate(ext.PromptTemplate{Name: tpl.Name, Content: content, Variables: parsed.Variables}, vars)
// 4. Send the rendered prompt
ctx.SendMessage(rendered)
// 5. Schedule model restoration after turn completes
// We use a goroutine to wait and restore
if tpl.Model != "" && originalModel != "" {
go func() {
// Note: In a real implementation, we'd use OnAgentEnd event
// For now, the user can manually switch back
ctx.SetStatus("template-mode", fmt.Sprintf("Template: %s (model will restore)", tpl.Name), 20)
}()
}
return fmt.Sprintf("Executing template: %s", tpl.Name), nil
}
+1 -1
View File
@@ -37,7 +37,7 @@ func Init(api ext.API) {
"Subagent Test Extension loaded\n\n" +
"/subtest <task> Spawn blocking subagent\n" +
"/subbg <task> Spawn background subagent\n\n" +
"The LLM can also use the spawn_subagent tool.")
"The LLM can also use the subagent tool.")
})
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
+22 -26
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,18 +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/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
@@ -105,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
@@ -119,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
@@ -128,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 -55
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,39 +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/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=
@@ -222,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=
@@ -236,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=
@@ -279,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=
@@ -322,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=
+337 -17
View File
@@ -7,8 +7,11 @@ package acpserver
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"sync/atomic"
"github.com/charmbracelet/log"
@@ -20,6 +23,17 @@ import (
// Version is injected at build time; fallback to "dev".
var Version = "dev"
// thinkingTagOpen and thinkingTagClose are the XML-style tags that some models
// (Qwen, DeepSeek) wrap reasoning content in. We parse these to extract
// reasoning/thinking content and send it as ACP thought updates.
// Also support <think> format used by some models.
const (
thinkingTagOpen = "<thinking>"
thinkingTagClose = "</thinking>"
shortThinkTagOpen = "<think>"
shortThinkTagClose = "</think>"
)
// Agent implements the acp.Agent interface, delegating to Kit for LLM
// execution, tool calls, and session management.
type Agent struct {
@@ -28,6 +42,10 @@ type Agent struct {
// toolCallCounter provides unique IDs for tool calls within a turn.
toolCallCounter atomic.Int64
// inThinkingTag tracks whether we're currently inside a <thinking> tag
// when parsing streaming content from models that wrap reasoning in XML tags.
inThinkingTag bool
}
// NewAgent creates a new ACP agent backed by Kit.
@@ -111,13 +129,23 @@ func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.Promp
)
}
// Extract text from prompt content blocks.
promptText := extractPromptText(params.Prompt)
if promptText == "" {
// Extract text and file attachments from prompt content blocks.
promptText, files := extractPromptContent(params.Prompt)
if promptText == "" && len(files) == 0 {
return acp.PromptResponse{}, acp.NewInvalidParams("empty prompt")
}
log.Debug("acp: prompt", "session", sessionID, "prompt_len", len(promptText))
// If we have files but no text prompt, add a default prompt
// This is required because the underlying LLM library needs a non-empty prompt
// when there are no previous messages in the conversation.
if promptText == "" && len(files) > 0 {
promptText = "Please analyze the attached file."
}
log.Debug("acp: prompt", "session", sessionID, "prompt_len", len(promptText), "files", len(files))
// Reset thinking tag state for this new prompt turn
a.inThinkingTag = false
// Create a cancellable context for this prompt turn.
promptCtx, cancel := context.WithCancel(ctx)
@@ -129,7 +157,13 @@ func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.Promp
defer unsub()
// Run the prompt through Kit's full turn lifecycle.
_, err := sess.kit.PromptResult(promptCtx, promptText)
// Use PromptResultWithFiles when file attachments are present.
var err error
if len(files) > 0 {
_, err = sess.kit.PromptResultWithFiles(promptCtx, promptText, files)
} else {
_, err = sess.kit.PromptResult(promptCtx, promptText)
}
if err != nil {
if promptCtx.Err() != nil {
return acp.PromptResponse{
@@ -162,6 +196,24 @@ func (a *Agent) SetSessionMode(_ context.Context, _ acp.SetSessionModeRequest) (
return acp.SetSessionModeResponse{}, nil
}
// SetSessionModel changes the active model for a session.
func (a *Agent) SetSessionModel(ctx context.Context, params acp.SetSessionModelRequest) (acp.SetSessionModelResponse, error) {
sessionID := string(params.SessionId)
sess, ok := a.registry.get(sessionID)
if !ok {
return acp.SetSessionModelResponse{}, acp.NewInvalidParams(fmt.Sprintf("session not found: %s", sessionID))
}
modelID := string(params.ModelId)
log.Debug("acp: set_session_model", "session", sessionID, "model", modelID)
if err := sess.kit.SetModel(ctx, modelID); err != nil {
return acp.SetSessionModelResponse{}, fmt.Errorf("set model: %w", err)
}
return acp.SetSessionModelResponse{}, nil
}
// ---------------------------------------------------------------------------
// Event streaming: Kit events → ACP SessionUpdate notifications
// ---------------------------------------------------------------------------
@@ -178,8 +230,24 @@ func (a *Agent) subscribeEvents(ctx context.Context, k *kit.Kit, sessionID acp.S
var update *acp.SessionUpdate
switch ev := e.(type) {
case kit.MessageUpdateEvent:
u := acp.UpdateAgentMessageText(ev.Chunk)
update = &u
// Handle models that wrap reasoning in <thinking> tags (Qwen, DeepSeek)
// Parse the chunk and separate reasoning from regular text
reasoning, text := a.parseThinkingTags(ev.Chunk)
// Send reasoning update if we have reasoning content
if reasoning != "" {
u := acp.UpdateAgentThoughtText(reasoning)
_ = a.conn.SessionUpdate(ctx, acp.SessionNotification{
SessionId: sessionID,
Update: u,
})
}
// Send text update if we have text content
if text != "" {
u := acp.UpdateAgentMessageText(text)
update = &u
}
case kit.ReasoningDeltaEvent:
u := acp.UpdateAgentThoughtText(ev.Delta)
@@ -231,19 +299,271 @@ func (a *Agent) subscribeEvents(ctx context.Context, k *kit.Kit, sessionID acp.S
// Helpers
// ---------------------------------------------------------------------------
// extractPromptText extracts the concatenated text content from ACP content
// blocks. Non-text blocks are ignored for now.
func extractPromptText(blocks []acp.ContentBlock) string {
var text string
for _, block := range blocks {
if block.Text != nil {
if text != "" {
text += "\n"
// extractPromptContent extracts text and file attachments from ACP content blocks.
// It converts supported content blocks (image, audio, resource) to Kit's LLMFilePart.
func extractPromptContent(blocks []acp.ContentBlock) (string, []kit.LLMFilePart) {
var textParts []string
var files []kit.LLMFilePart
log.Debug("acp: extracting content", "blocks", len(blocks))
for i, block := range blocks {
switch {
// Text content
case block.Text != nil:
log.Debug("acp: content block", "index", i, "type", "text", "len", len(block.Text.Text))
textParts = append(textParts, block.Text.Text)
// Image data (base64)
case block.Image != nil:
mimeType := block.Image.MimeType
if mimeType == "" {
mimeType = "image/png" // Default fallback
}
text += block.Text.Text
log.Debug("acp: content block", "index", i, "type", "image", "mime", mimeType, "data_len", len(block.Image.Data))
if data, err := base64.StdEncoding.DecodeString(block.Image.Data); err == nil {
files = append(files, kit.LLMFilePart{
Filename: "image.png",
Data: data,
MediaType: mimeType,
})
} else {
log.Debug("acp: failed to decode image", "error", err)
}
// Audio data (base64)
case block.Audio != nil:
mimeType := block.Audio.MimeType
if mimeType == "" {
mimeType = "audio/wav" // Default fallback
}
log.Debug("acp: content block", "index", i, "type", "audio", "mime", mimeType)
if data, err := base64.StdEncoding.DecodeString(block.Audio.Data); err == nil {
files = append(files, kit.LLMFilePart{
Filename: "audio.wav",
Data: data,
MediaType: mimeType,
})
} else {
log.Debug("acp: failed to decode audio", "error", err)
}
// Embedded resource (text or binary file content)
case block.Resource != nil:
log.Debug("acp: content block", "index", i, "type", "resource")
res := block.Resource.Resource
// Text resource - append as text content with file reference
if res.TextResourceContents != nil {
uri := res.TextResourceContents.Uri
content := res.TextResourceContents.Text
mimeType := "text/plain"
if res.TextResourceContents.MimeType != nil {
mimeType = *res.TextResourceContents.MimeType
}
log.Debug("acp: text resource", "uri", uri, "mime", mimeType, "len", len(content))
// Text files are included as formatted text, NOT as FilePart
// FilePart is for binary files (images, audio, PDFs) only
textParts = append(textParts, fmt.Sprintf("[File: %s]\n```\n%s\n```", uri, content))
}
// Binary resource (base64 blob) - these become FilePart
if res.BlobResourceContents != nil {
uri := res.BlobResourceContents.Uri
mimeType := "application/octet-stream"
if res.BlobResourceContents.MimeType != nil {
mimeType = *res.BlobResourceContents.MimeType
}
log.Debug("acp: binary resource", "uri", uri, "mime", mimeType, "blob_len", len(res.BlobResourceContents.Blob))
if data, err := base64.StdEncoding.DecodeString(res.BlobResourceContents.Blob); err == nil {
files = append(files, kit.LLMFilePart{
Filename: extractFilenameFromURI(uri),
Data: data,
MediaType: mimeType,
})
} else {
log.Debug("acp: failed to decode binary resource", "error", err)
}
}
// Resource link (file reference without embedded content)
case block.ResourceLink != nil:
uri := block.ResourceLink.Uri
name := block.ResourceLink.Name
log.Debug("acp: content block", "index", i, "type", "resource_link", "uri", uri, "name", name)
// For resource links, we'll try to read the file from disk
// This requires the file URI to be accessible (file:// scheme)
if content, err := readResourceFromURI(uri); err == nil {
// Detect if it's a text file or binary file
mimeType := "text/plain"
if block.ResourceLink.MimeType != nil {
mimeType = *block.ResourceLink.MimeType
}
log.Debug("acp: resource link loaded", "uri", uri, "mime", mimeType, "size", len(content))
// Only create FilePart for binary files (images, audio, PDFs, etc.)
// Text files are included as formatted text in the message
if isTextMimeType(mimeType) || looksLikeText(content) {
textParts = append(textParts, fmt.Sprintf("[File: %s]\n```\n%s\n```", uri, string(content)))
} else {
// Binary file - create FilePart for models that support it
files = append(files, kit.LLMFilePart{
Filename: extractFilenameFromURI(uri),
Data: content,
MediaType: mimeType,
})
}
} else {
// If we can't read it, include as a text reference
log.Debug("acp: resource link failed to load", "uri", uri, "error", err)
textParts = append(textParts, fmt.Sprintf("[Referenced file: %s]", uri))
}
default:
log.Debug("acp: content block", "index", i, "type", "unknown/unhandled")
}
}
return text
// Debug log the extracted content
for i, f := range files {
log.Debug("acp: extracted file", "index", i, "filename", f.Filename, "mime", f.MediaType, "size", len(f.Data))
}
return strings.Join(textParts, "\n"), files
}
// parseThinkingTags parses a text chunk for <thinking> or tags and separates
// reasoning content from regular text. This handles models (Qwen, DeepSeek)
// that wrap reasoning in XML-style tags instead of using proper reasoning events.
// Returns (reasoningContent, textContent).
func (a *Agent) parseThinkingTags(chunk string) (reasoning string, text string) {
// Handle empty chunk
if chunk == "" {
return "", ""
}
// Determine which tag format to use (long or short)
openTag := thinkingTagOpen
closeTag := thinkingTagClose
if strings.Contains(chunk, shortThinkTagOpen) || strings.Contains(chunk, shortThinkTagClose) {
openTag = shortThinkTagOpen
closeTag = shortThinkTagClose
} else if !strings.Contains(chunk, thinkingTagOpen) && !strings.Contains(chunk, thinkingTagClose) && !a.inThinkingTag {
// No tags at all and not in thinking mode - return as text
return "", chunk
}
// Check for opening tag
if strings.Contains(chunk, openTag) {
parts := strings.SplitN(chunk, openTag, 2)
// Content before the opening tag is regular text
if !a.inThinkingTag && parts[0] != "" {
text = parts[0]
}
a.inThinkingTag = true
// Content after the opening tag is reasoning
if len(parts) > 1 {
// Check if the same chunk contains the closing tag
if strings.Contains(parts[1], closeTag) {
innerParts := strings.SplitN(parts[1], closeTag, 2)
reasoning = innerParts[0]
a.inThinkingTag = false
// Content after closing tag is regular text
if len(innerParts) > 1 && innerParts[1] != "" {
text += innerParts[1]
}
} else if parts[1] != "" {
// No closing tag yet, all remaining content is reasoning
reasoning = parts[1]
}
}
return reasoning, text
}
// Check for closing tag
if strings.Contains(chunk, closeTag) {
parts := strings.SplitN(chunk, closeTag, 2)
a.inThinkingTag = false
// Content before closing tag is reasoning
reasoning = parts[0]
// Content after closing tag is regular text
if len(parts) > 1 && parts[1] != "" {
text = parts[1]
}
return reasoning, text
}
// No tags found - content goes to current mode
if a.inThinkingTag {
return chunk, ""
}
return "", chunk
}
// isTextMimeType returns true if the MIME type indicates text content.
func isTextMimeType(mimeType string) bool {
return strings.HasPrefix(mimeType, "text/") ||
mimeType == "application/json" ||
mimeType == "application/xml" ||
mimeType == "application/javascript" ||
mimeType == "application/typescript" ||
mimeType == "application/x-sh" ||
mimeType == "application/x-python" ||
mimeType == "application/x-yaml" ||
mimeType == "application/x-toml"
}
// looksLikeText checks if the content appears to be text (not binary).
// It samples the first 512 bytes and checks for null bytes or high
// concentration of non-printable characters.
func looksLikeText(data []byte) bool {
if len(data) == 0 {
return true
}
// Check first 512 bytes (or less if file is smaller)
sampleSize := min(len(data), 512)
sample := data[:sampleSize]
// Count non-printable characters
nonPrintable := 0
for _, b := range sample {
// Null byte indicates binary
if b == 0 {
return false
}
// Count control characters (except common whitespace)
if b < 32 && b != '\n' && b != '\r' && b != '\t' {
nonPrintable++
}
}
// If more than 30% non-printable, consider it binary
return float64(nonPrintable)/float64(sampleSize) < 0.3
}
// extractFilenameFromURI extracts a filename from a file URI or path.
func extractFilenameFromURI(uri string) string {
// Handle file:// URIs
uri = strings.TrimPrefix(uri, "file://")
// Extract basename
if idx := strings.LastIndex(uri, "/"); idx >= 0 {
return uri[idx+1:]
}
return uri
}
// readResourceFromURI attempts to read file content from a file:// URI.
func readResourceFromURI(uri string) ([]byte, error) {
if !strings.HasPrefix(uri, "file://") {
return nil, fmt.Errorf("unsupported URI scheme: %s", uri)
}
path := uri[7:] // Remove file:// prefix
return os.ReadFile(path)
}
// parseToolArgs attempts to parse a JSON tool args string into a map for
+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
+175 -46
View File
@@ -3,8 +3,11 @@ package app
import (
"context"
"fmt"
"log"
"os"
"sync"
"sync/atomic"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
@@ -17,7 +20,7 @@ import (
// queueItem holds a prompt and optional image attachments for the execution queue.
type queueItem struct {
Prompt string
Files []fantasy.FilePart
Files []kit.LLMFilePart
}
// App is the application-layer orchestrator. It owns the agentic loop,
@@ -66,11 +69,20 @@ 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.
// initialMessages may be nil or empty for a fresh session.
func New(opts Options, initialMessages []fantasy.Message) *App {
func New(opts Options, initialMessages []kit.LLMMessage) *App {
rootCtx, rootCancel := context.WithCancel(context.Background())
return &App{
opts: opts,
@@ -114,9 +126,8 @@ func (a *App) Run(prompt string) int {
// If the app is idle the prompt executes immediately; otherwise it is queued.
// Returns the current queue depth (0 = started immediately, >0 = queued).
//
// Satisfies ui.AppController (via RunWithImages which converts ImageAttachment
// to fantasy.FilePart).
func (a *App) RunWithFiles(prompt string, files []fantasy.FilePart) int {
// Satisfies ui.AppController.
func (a *App) RunWithFiles(prompt string, files []kit.LLMFilePart) int {
a.mu.Lock()
if a.closed {
@@ -259,6 +270,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
@@ -280,7 +302,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())
}
}
@@ -291,12 +313,12 @@ func (a *App) SwitchTreeSession(ts *session.TreeManager) {
//
// Satisfies ui.AppController.
func (a *App) AddContextMessage(text string) {
msg := fantasy.NewUserMessage(text)
a.store.Add(msg)
kitMsg := fantasy.NewUserMessage(text)
a.store.Add(kitMsg)
// Persist to tree session if active.
if ts := a.opts.TreeSession; ts != nil {
_, _ = ts.AppendFantasyMessage(msg)
_, _ = ts.AppendLLMMessage(fantasy.NewUserMessage(text))
}
}
@@ -334,6 +356,15 @@ func (a *App) CompactConversation(customInstructions string) error {
a.mu.Unlock()
}()
// Subscribe to SDK events for streaming compaction summary to the TUI.
sendFn := func(msg tea.Msg) {
if a.program != nil {
a.program.Send(msg)
}
}
unsub := a.subscribeSDKEvents(sendFn, nil)
defer unsub()
result, err := a.opts.Kit.Compact(a.rootCtx, nil, customInstructions)
if err != nil {
a.sendEvent(CompactErrorEvent{Err: err})
@@ -346,7 +377,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{
@@ -483,11 +514,10 @@ func (a *App) drainQueue(first queueItem) {
a.mu.Lock()
items = append(items, a.queue...)
a.queue = a.queue[:0] // Clear the queue
queueLen := len(a.queue)
a.mu.Unlock()
// Send queue updated event (queue is now empty)
a.sendEvent(QueueUpdatedEvent{Length: queueLen})
// Notify UI: all queued messages have been consumed into this batch.
a.sendEvent(QueueUpdatedEvent{Length: 0})
// Process all collected items as a single batch
a.runQueueBatch(items)
@@ -520,6 +550,11 @@ func (a *App) drainQueue(first queueItem) {
}
a.mu.Unlock()
if hasMore {
// Notify UI: these newly queued messages have been consumed into the next batch.
a.sendEvent(QueueUpdatedEvent{Length: 0})
}
if !hasMore {
// No more items, we're done
break
@@ -567,7 +602,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
@@ -586,7 +621,7 @@ func (a *App) runQueueBatch(items []queueItem) {
// executeStep runs a single agentic step by delegating to the SDK's
// PromptResult() (or PromptResultWithFiles for multimodal), which handles
// session persistence, hooks, extension events, and the generation loop.
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg), files []fantasy.FilePart) (*kit.TurnResult, error) {
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg), files []kit.LLMFilePart) (*kit.TurnResult, error) {
// Test hook: bypass SDK entirely.
if a.opts.PromptFunc != nil {
return a.opts.PromptFunc(ctx, prompt)
@@ -598,9 +633,10 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
}
}
// Subscribe to SDK events for TUI rendering. The subscription is
// temporary — it lives only for the duration of this step.
unsub := a.subscribeSDKEvents(sendFn)
// Subscribe to SDK events for TUI rendering and per-step usage updates.
// The subscription is temporary — it lives only for the duration of this step.
var sawStepUsage atomic.Bool
unsub := a.subscribeSDKEvents(sendFn, &sawStepUsage)
defer unsub()
// Show spinner while the agent works.
@@ -620,8 +656,9 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
// Sync in-memory store with the SDK's authoritative conversation.
a.store.Replace(result.Messages)
// Update usage tracker.
a.updateUsageFromTurnResult(result, prompt)
// Update usage tracker. If per-step usage was already recorded from
// StepUsageEvent callbacks, avoid double-counting totals.
a.updateUsageFromTurnResult(result, prompt, sawStepUsage.Load())
return result, nil
}
@@ -645,9 +682,10 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(
}
}
// Subscribe to SDK events for TUI rendering. The subscription is
// temporary — it lives only for the duration of this step.
unsub := a.subscribeSDKEvents(sendFn)
// Subscribe to SDK events for TUI rendering and per-step usage updates.
// The subscription is temporary — it lives only for the duration of this step.
var sawStepUsage atomic.Bool
unsub := a.subscribeSDKEvents(sendFn, &sawStepUsage)
defer unsub()
// Show spinner while the agent works.
@@ -680,8 +718,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 {
@@ -702,8 +740,10 @@ func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(
// Sync in-memory store with the SDK's authoritative conversation.
a.store.Replace(result.Messages)
// Update usage tracker (using last item's prompt for tracking).
a.updateUsageFromTurnResult(result, items[len(items)-1].Prompt)
// Update usage tracker (using last item's prompt for fallback estimation).
// If per-step usage was already recorded from StepUsageEvent callbacks,
// avoid double-counting totals.
a.updateUsageFromTurnResult(result, items[len(items)-1].Prompt, sawStepUsage.Load())
return result, nil
}
@@ -720,9 +760,10 @@ func (a *App) sendEvent(msg tea.Msg) {
}
// subscribeSDKEvents registers temporary SDK event subscribers that convert
// SDK events to tea.Msg events and dispatch them via sendFn. Returns an
// unsubscribe function that removes all listeners.
func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
// SDK events to tea.Msg events and dispatch them via sendFn. When stepUsageSeen
// is provided, it is set to true after any non-zero StepUsageEvent is observed.
// Returns an unsubscribe function that removes all listeners.
func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Bool) func() {
k := a.opts.Kit
var unsubs []func()
@@ -756,6 +797,8 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
})
case kit.SteerConsumedEvent:
sendFn(SteerConsumedEvent{})
case kit.StepUsageEvent:
a.recordStepUsage(ev, stepUsageSeen)
}
}))
@@ -824,12 +867,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)
}
}
@@ -925,40 +988,106 @@ func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
}
}
// recordStepUsage applies token/cost usage reported for a completed step.
// Step usage events arrive even when a turn is later cancelled, so this keeps
// the usage widget accurate on all stop paths.
func (a *App) recordStepUsage(ev kit.StepUsageEvent, stepUsageSeen *atomic.Bool) {
hasUsage := ev.InputTokens > 0 || ev.OutputTokens > 0 || ev.CacheReadTokens > 0 || ev.CacheWriteTokens > 0
if a.opts.Debug {
log.Printf("[DEBUG] recordStepUsage: hasUsage=%v input=%d output=%d cacheRead=%d cacheWrite=%d",
hasUsage, ev.InputTokens, ev.OutputTokens, ev.CacheReadTokens, ev.CacheWriteTokens)
}
if !hasUsage {
return
}
if stepUsageSeen != nil {
stepUsageSeen.Store(true)
}
if a.opts.UsageTracker == nil {
return
}
a.opts.UsageTracker.UpdateUsage(
int(ev.InputTokens),
int(ev.OutputTokens),
int(ev.CacheReadTokens),
int(ev.CacheWriteTokens),
)
// NOTE: We do NOT call SetContextTokens here. Context fill is set once
// at turn completion via updateUsageFromTurnResult using FinalUsage.InputTokens,
// which reflects the full accumulated context. Per-step context tokens would
// cause the display to jump around during multi-step tool calls.
}
// updateUsageFromTurnResult records token usage from an SDK TurnResult into the
// configured UsageTracker. Called once per turn after the turn completes.
//
// Cost/token accumulation uses TotalUsage (sum across all tool-calling steps in
// the turn). Context-window fill uses FinalUsage.InputTokens only — that is the
// number of tokens sent to the model on the last API call, which equals the
// actual context window occupation (all accumulated messages + tool results).
// OutputTokens are not added here because they are the response length, not
// context fill.
func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string) {
// When sawStepUsage is true, totals were already accumulated incrementally via
// StepUsageEvent callbacks; in that case this method only updates context fill.
// Otherwise it falls back to TotalUsage from the API response.
//
// NOTE: We only use ACTUAL token counts from API responses for cost tracking.
// Estimation is never used for costs - only API-reported tokens are accurate.
func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string, sawStepUsage bool) {
if a.opts.UsageTracker == nil || result == nil {
return
}
// Debug logging for token tracking
if a.opts.Debug {
if result.TotalUsage != nil {
log.Printf("[DEBUG] updateUsageFromTurnResult TotalUsage: input=%d output=%d cacheRead=%d cacheCreate=%d",
result.TotalUsage.InputTokens, result.TotalUsage.OutputTokens,
result.TotalUsage.CacheReadTokens, result.TotalUsage.CacheCreationTokens)
} else {
log.Printf("[DEBUG] updateUsageFromTurnResult: TotalUsage=nil")
}
if result.FinalUsage != nil {
log.Printf("[DEBUG] updateUsageFromTurnResult FinalUsage: input=%d output=%d cacheRead=%d cacheCreate=%d",
result.FinalUsage.InputTokens, result.FinalUsage.OutputTokens,
result.FinalUsage.CacheReadTokens, result.FinalUsage.CacheCreationTokens)
} else {
log.Printf("[DEBUG] updateUsageFromTurnResult: FinalUsage=nil")
}
log.Printf("[DEBUG] updateUsageFromTurnResult: sawStepUsage=%v", sawStepUsage)
}
// --- Accumulate cost/token totals for the session ---
if result.TotalUsage != nil && result.TotalUsage.InputTokens > 0 {
// Only use actual API-reported tokens for cost tracking.
// If sawStepUsage is true, totals were already updated via StepUsageEvent.
// Check any token field > 0 (not just InputTokens) because cached prompts
// can result in InputTokens=0 while OutputTokens>0 (OpenAI-compatible behavior).
hasTotalUsage := result.TotalUsage != nil &&
(result.TotalUsage.InputTokens > 0 ||
result.TotalUsage.OutputTokens > 0 ||
result.TotalUsage.CacheReadTokens > 0 ||
result.TotalUsage.CacheCreationTokens > 0)
if a.opts.Debug {
log.Printf("[DEBUG] updateUsageFromTurnResult: hasTotalUsage=%v", hasTotalUsage)
}
if !sawStepUsage && hasTotalUsage {
if a.opts.Debug {
log.Printf("[DEBUG] updateUsageFromTurnResult: calling UpdateUsage input=%d output=%d cacheRead=%d cacheCreate=%d",
result.TotalUsage.InputTokens, result.TotalUsage.OutputTokens,
result.TotalUsage.CacheReadTokens, result.TotalUsage.CacheCreationTokens)
}
a.opts.UsageTracker.UpdateUsage(
int(result.TotalUsage.InputTokens),
int(result.TotalUsage.OutputTokens),
int(result.TotalUsage.CacheReadTokens),
int(result.TotalUsage.CacheCreationTokens),
)
} else {
// Provider didn't report token counts — fall back to character-based
// estimates so the footer shows something rather than nothing.
a.opts.UsageTracker.EstimateAndUpdateUsage(userPrompt, result.Response)
}
// --- Context window fill (drives the % bar) ---
// Use FinalUsage.InputTokens: the input token count of the last API call
// equals the number of tokens currently occupying the context window.
// Adding OutputTokens would overstate fill since the response is not part
// of the context that was *sent* to the model.
// Use FinalUsage.InputTokens as the context window fill. The API's InputTokens
// already includes the full conversation history (system prompt + all previous
// messages + current user message). Adding OutputTokens would double-count since
// the output becomes part of the input for the next turn.
if result.FinalUsage != nil && result.FinalUsage.InputTokens > 0 {
if a.opts.Debug {
log.Printf("[DEBUG] updateUsageFromTurnResult: calling SetContextTokens=%d (FinalUsage.InputTokens)",
result.FinalUsage.InputTokens)
}
a.opts.UsageTracker.SetContextTokens(int(result.FinalUsage.InputTokens))
}
}
+171
View File
@@ -14,6 +14,47 @@ import (
// Helpers
// --------------------------------------------------------------------------
type usageUpdaterStub struct {
mu sync.Mutex
updateCalls int
estimateCalls int
contextCalls int
lastUpdateInput int
lastUpdateOutput int
lastUpdateCacheRead int
lastUpdateCacheWrite int
lastContextTokens int
lastEstimateInput string
lastEstimateOutput string
}
func (s *usageUpdaterStub) UpdateUsage(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int) {
s.mu.Lock()
defer s.mu.Unlock()
s.updateCalls++
s.lastUpdateInput = inputTokens
s.lastUpdateOutput = outputTokens
s.lastUpdateCacheRead = cacheReadTokens
s.lastUpdateCacheWrite = cacheWriteTokens
}
func (s *usageUpdaterStub) EstimateAndUpdateUsage(inputText, outputText string) {
s.mu.Lock()
defer s.mu.Unlock()
s.estimateCalls++
s.lastEstimateInput = inputText
s.lastEstimateOutput = outputText
}
func (s *usageUpdaterStub) SetContextTokens(tokens int) {
s.mu.Lock()
defer s.mu.Unlock()
s.contextCalls++
s.lastContextTokens = tokens
}
// turnResult builds a minimal TurnResult with response text t.
func turnResult(t string) *kit.TurnResult {
return &kit.TurnResult{Response: t}
@@ -489,3 +530,133 @@ func TestQueueLength_reflects(t *testing.T) {
t.Fatalf("expected 3, got %d", got)
}
}
// TestRecordStepUsage_updatesTracker verifies that per-step usage updates are
// recorded immediately for cost tracking. Context tokens are NOT updated here
// (only via updateUsageFromTurnResult) to avoid display jumps during multi-step
// tool calls.
func TestRecordStepUsage_updatesTracker(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
app.recordStepUsage(kit.StepUsageEvent{
InputTokens: 120,
OutputTokens: 45,
CacheReadTokens: 5,
CacheWriteTokens: 2,
}, nil)
usage.mu.Lock()
defer usage.mu.Unlock()
if usage.updateCalls != 1 {
t.Fatalf("expected 1 update call, got %d", usage.updateCalls)
}
if usage.lastUpdateInput != 120 || usage.lastUpdateOutput != 45 || usage.lastUpdateCacheRead != 5 || usage.lastUpdateCacheWrite != 2 {
t.Fatalf("unexpected usage update payload: in=%d out=%d cache_read=%d cache_write=%d",
usage.lastUpdateInput, usage.lastUpdateOutput, usage.lastUpdateCacheRead, usage.lastUpdateCacheWrite)
}
// Context tokens should NOT be updated by recordStepUsage (only by updateUsageFromTurnResult)
if usage.contextCalls != 0 {
t.Fatalf("expected 0 context token updates from recordStepUsage, got %d", usage.contextCalls)
}
}
// TestUpdateUsageFromTurnResult_skipsTotalsWhenStepUsageSeen ensures we avoid
// double-counting totals once StepUsageEvent-based updates were already applied.
func TestUpdateUsageFromTurnResult_skipsTotalsWhenStepUsageSeen(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &kit.LLMUsage{
InputTokens: 999,
OutputTokens: 111,
CacheReadTokens: 7,
CacheCreationTokens: 3,
},
FinalUsage: &kit.LLMUsage{InputTokens: 456},
}, "prompt", true)
usage.mu.Lock()
defer usage.mu.Unlock()
if usage.updateCalls != 0 {
t.Fatalf("expected no total usage update when sawStepUsage=true, got %d", usage.updateCalls)
}
if usage.estimateCalls != 0 {
t.Fatalf("expected no estimate update when sawStepUsage=true, got %d", usage.estimateCalls)
}
// Context tokens should be InputTokens only (456)
if usage.contextCalls != 1 || usage.lastContextTokens != 456 {
t.Fatalf("expected final context tokens=456 (InputTokens only), got calls=%d tokens=%d", usage.contextCalls, usage.lastContextTokens)
}
}
// TestUpdateUsageFromTurnResult_recordsWhenInputTokensZero verifies that usage
// is recorded when InputTokens=0 but OutputTokens>0 (OpenAI-compatible cache behavior).
func TestUpdateUsageFromTurnResult_recordsWhenInputTokensZero(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
// Simulate OpenAI-compatible behavior: all prompt tokens cached, InputTokens=0
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &kit.LLMUsage{
InputTokens: 0, // All cached - subtracted from prompt
OutputTokens: 150, // Actual generated tokens
CacheReadTokens: 500, // Cache hit
CacheCreationTokens: 0,
},
FinalUsage: &kit.LLMUsage{InputTokens: 0, OutputTokens: 150},
}, "prompt", false)
usage.mu.Lock()
defer usage.mu.Unlock()
if usage.updateCalls != 1 {
t.Fatalf("expected 1 update call when InputTokens=0 but OutputTokens>0, got %d", usage.updateCalls)
}
if usage.lastUpdateInput != 0 || usage.lastUpdateOutput != 150 {
t.Fatalf("expected input=0 output=150, got input=%d output=%d",
usage.lastUpdateInput, usage.lastUpdateOutput)
}
if usage.lastUpdateCacheRead != 500 {
t.Fatalf("expected cache_read=500, got %d", usage.lastUpdateCacheRead)
}
}
// TestUpdateUsageFromTurnResult_contextTokensUsesInputOnly verifies that context
// window fill uses InputTokens only (not input+output). The API's InputTokens
// already includes the full conversation history; adding output would double-count.
func TestUpdateUsageFromTurnResult_contextTokensUsesInputOnly(t *testing.T) {
usage := &usageUpdaterStub{}
app := New(Options{UsageTracker: usage}, nil)
defer app.Close()
app.updateUsageFromTurnResult(&kit.TurnResult{
Response: "ok",
TotalUsage: &kit.LLMUsage{
InputTokens: 1000,
OutputTokens: 200,
},
FinalUsage: &kit.LLMUsage{
InputTokens: 1000, // Full context including history
OutputTokens: 200,
},
}, "prompt", false)
usage.mu.Lock()
defer usage.mu.Unlock()
// Context tokens should be InputTokens only (1000), not input+output (1200)
// because InputTokens already includes the full conversation history
if usage.contextCalls != 1 || usage.lastContextTokens != 1000 {
t.Fatalf("expected context tokens=1000 (InputTokens only), got calls=%d tokens=%d",
usage.contextCalls, usage.lastContextTokens)
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
package app
import "charm.land/fantasy"
import kit "github.com/mark3labs/kit/pkg/kit"
// StreamChunkEvent is sent by the app layer when a streaming text delta arrives
// from the LLM. Each chunk contains an incremental portion of the response.
@@ -118,8 +118,8 @@ type SpinnerEvent struct {
// MessageCreatedEvent is sent when a new message is added to the message store.
// This allows the TUI to stay in sync with the conversation history.
type MessageCreatedEvent struct {
// Message is the fantasy message that was added to the store.
Message fantasy.Message
// Message is the message that was added to the store.
Message kit.LLMMessage
}
// CompactCompleteEvent is sent when a /compact operation finishes successfully.
+9 -9
View File
@@ -3,14 +3,14 @@ package app
import (
"sync"
"charm.land/fantasy"
kit "github.com/mark3labs/kit/pkg/kit"
)
// MessageStore is a thread-safe in-memory store for the conversation history.
// On-disk persistence is handled by the TreeManager at the app/SDK layer.
type MessageStore struct {
mu sync.RWMutex
messages []fantasy.Message
messages []kit.LLMMessage
}
// NewMessageStore creates an empty MessageStore.
@@ -20,14 +20,14 @@ func NewMessageStore() *MessageStore {
// NewMessageStoreWithMessages creates a MessageStore pre-populated with the
// given messages. This is used when loading an existing session at startup.
func NewMessageStoreWithMessages(msgs []fantasy.Message) *MessageStore {
cp := make([]fantasy.Message, len(msgs))
func NewMessageStoreWithMessages(msgs []kit.LLMMessage) *MessageStore {
cp := make([]kit.LLMMessage, len(msgs))
copy(cp, msgs)
return &MessageStore{messages: cp}
}
// Add appends a single message to the store.
func (s *MessageStore) Add(msg fantasy.Message) {
func (s *MessageStore) Add(msg kit.LLMMessage) {
s.mu.Lock()
defer s.mu.Unlock()
s.messages = append(s.messages, msg)
@@ -36,22 +36,22 @@ func (s *MessageStore) Add(msg fantasy.Message) {
// Replace replaces the entire message history with the given slice. This is
// used after an agent step returns the full updated conversation (including
// tool calls and results).
func (s *MessageStore) Replace(msgs []fantasy.Message) {
func (s *MessageStore) Replace(msgs []kit.LLMMessage) {
s.mu.Lock()
defer s.mu.Unlock()
cp := make([]fantasy.Message, len(msgs))
cp := make([]kit.LLMMessage, len(msgs))
copy(cp, msgs)
s.messages = cp
}
// GetAll returns a snapshot copy of the current message slice.
// The returned slice is safe to modify without affecting the store.
func (s *MessageStore) GetAll() []fantasy.Message {
func (s *MessageStore) GetAll() []kit.LLMMessage {
s.mu.RLock()
defer s.mu.RUnlock()
cp := make([]fantasy.Message, len(s.messages))
cp := make([]kit.LLMMessage, len(s.messages))
copy(cp, s.messages)
return cp
}
+33 -27
View File
@@ -4,16 +4,29 @@ import (
"testing"
"charm.land/fantasy"
kit "github.com/mark3labs/kit/pkg/kit"
)
// makeTextMsg builds a minimal fantasy.Message with a single TextPart.
func makeTextMsg(role, text string) fantasy.Message {
return fantasy.Message{
Role: fantasy.MessageRole(role),
// makeTextMsg builds a minimal kit.LLMMessage using fantasy.NewUserMessage
// or constructing with the given role.
func makeTextMsg(role, text string) kit.LLMMessage {
return kit.LLMMessage{
Role: kit.LLMMessageRole(role),
Content: []fantasy.MessagePart{fantasy.TextPart{Text: text}},
}
}
// textOf extracts the plain text from an LLMMessage for assertions.
func textOf(msg kit.LLMMessage) string {
for _, part := range msg.Content {
if tp, ok := part.(fantasy.TextPart); ok {
return tp.Text
}
}
return ""
}
// --------------------------------------------------------------------------
// NewMessageStore / NewMessageStoreWithMessages
// --------------------------------------------------------------------------
@@ -29,7 +42,7 @@ func TestNewMessageStore_empty(t *testing.T) {
}
func TestNewMessageStoreWithMessages_preloaded(t *testing.T) {
msgs := []fantasy.Message{
msgs := []kit.LLMMessage{
makeTextMsg("user", "hello"),
makeTextMsg("assistant", "hi"),
}
@@ -42,7 +55,7 @@ func TestNewMessageStoreWithMessages_preloaded(t *testing.T) {
// NewMessageStoreWithMessages must deep-copy the slice so that external
// modifications don't affect the store.
func TestNewMessageStoreWithMessages_isolatesInput(t *testing.T) {
msgs := []fantasy.Message{makeTextMsg("user", "hello")}
msgs := []kit.LLMMessage{makeTextMsg("user", "hello")}
s := NewMessageStoreWithMessages(msgs)
// Mutate the source slice.
@@ -52,9 +65,8 @@ func TestNewMessageStoreWithMessages_isolatesInput(t *testing.T) {
if len(got) != 1 {
t.Fatalf("expected 1 message, got %d", len(got))
}
tp, ok := got[0].Content[0].(fantasy.TextPart)
if !ok || tp.Text != "hello" {
t.Fatalf("store was mutated by external slice change; got %q", tp.Text)
if textOf(got[0]) != "hello" {
t.Fatalf("store was mutated by external slice change; got %q", textOf(got[0]))
}
}
@@ -80,9 +92,8 @@ func TestAdd_preservesOrder(t *testing.T) {
}
got := s.GetAll()
for i, expected := range texts {
tp, ok := got[i].Content[0].(fantasy.TextPart)
if !ok || tp.Text != expected {
t.Fatalf("message[%d]: expected %q, got %q", i, expected, tp.Text)
if textOf(got[i]) != expected {
t.Fatalf("message[%d]: expected %q, got %q", i, expected, textOf(got[i]))
}
}
}
@@ -95,7 +106,7 @@ func TestReplace_swapsHistory(t *testing.T) {
s := NewMessageStore()
s.Add(makeTextMsg("user", "old"))
replacement := []fantasy.Message{
replacement := []kit.LLMMessage{
makeTextMsg("user", "new1"),
makeTextMsg("assistant", "new2"),
}
@@ -105,25 +116,22 @@ func TestReplace_swapsHistory(t *testing.T) {
t.Fatalf("expected 2 messages after replace, got %d", s.Len())
}
got := s.GetAll()
tp0, _ := got[0].Content[0].(fantasy.TextPart)
tp1, _ := got[1].Content[0].(fantasy.TextPart)
if tp0.Text != "new1" || tp1.Text != "new2" {
t.Fatalf("unexpected messages after replace: %q %q", tp0.Text, tp1.Text)
if textOf(got[0]) != "new1" || textOf(got[1]) != "new2" {
t.Fatalf("unexpected messages after replace: %q %q", textOf(got[0]), textOf(got[1]))
}
}
// Replace must deep-copy the incoming slice.
func TestReplace_isolatesInput(t *testing.T) {
s := NewMessageStore()
replacement := []fantasy.Message{makeTextMsg("user", "original")}
replacement := []kit.LLMMessage{makeTextMsg("user", "original")}
s.Replace(replacement)
replacement[0] = makeTextMsg("user", "mutated")
got := s.GetAll()
tp, _ := got[0].Content[0].(fantasy.TextPart)
if tp.Text != "original" {
t.Fatalf("store was mutated by external slice change after Replace; got %q", tp.Text)
if textOf(got[0]) != "original" {
t.Fatalf("store was mutated by external slice change after Replace; got %q", textOf(got[0]))
}
}
@@ -140,9 +148,8 @@ func TestGetAll_returnsCopy(t *testing.T) {
got[0] = makeTextMsg("user", "mutated")
internal := s.GetAll()
tp, _ := internal[0].Content[0].(fantasy.TextPart)
if tp.Text != "hello" {
t.Fatalf("GetAll returned non-copy; store was mutated to %q", tp.Text)
if textOf(internal[0]) != "hello" {
t.Fatalf("GetAll returned non-copy; store was mutated to %q", textOf(internal[0]))
}
}
@@ -179,9 +186,8 @@ func TestClear_allowsSubsequentAdds(t *testing.T) {
t.Fatalf("expected 1 message after Clear+Add, got %d", s.Len())
}
got := s.GetAll()
tp, _ := got[0].Content[0].(fantasy.TextPart)
if tp.Text != "after" {
t.Fatalf("expected %q, got %q", "after", tp.Text)
if textOf(got[0]) != "after" {
t.Fatalf("expected %q, got %q", "after", textOf(got[0]))
}
}
-4
View File
@@ -67,10 +67,6 @@ type Options struct {
// Debug enables verbose debug logging.
Debug bool
// CompactMode selects the compact renderer instead of the block renderer for
// message formatting.
CompactMode bool
// UsageTracker is an optional callback for recording token usage after each
// agent step. When non-nil, the app layer calls UpdateUsage (or
// EstimateAndUpdateUsage as a fallback) using the usage data returned by the
+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.
+49 -12
View File
@@ -428,6 +428,10 @@ type PreviousCompaction struct {
ModifiedFiles []string
}
// StreamCallback is called for each chunk of text during streaming compaction.
// Return a non-nil error to cancel the stream.
type StreamCallback func(delta string) error
// Compact summarises older messages using the LLM, returning the compaction
// result and a new message slice (summary message + preserved recent
// messages).
@@ -442,6 +446,8 @@ type PreviousCompaction struct {
//
// prev carries file tracking from a previous compaction for cumulative
// tracking. Pass nil if there is no prior compaction.
// onChunk is an optional callback for streaming summary text. Pass nil for
// non-streaming compaction.
func Compact(
ctx context.Context,
model fantasy.LanguageModel,
@@ -449,6 +455,7 @@ func Compact(
opts CompactionOptions,
customInstructions string,
prev *PreviousCompaction,
onChunk StreamCallback,
) (*CompactionResult, []fantasy.Message, error) {
opts.defaults()
@@ -487,9 +494,9 @@ func Compact(
var err error
if IsSplitTurn(messages, cutPoint) {
summaryText, err = compactSplitTurn(ctx, model, oldMessages, messages, cutPoint, opts, customInstructions)
summaryText, err = compactSplitTurn(ctx, model, oldMessages, messages, cutPoint, opts, customInstructions, onChunk)
} else {
summaryText, err = compactNormal(ctx, model, oldMessages, opts, customInstructions)
summaryText, err = compactNormal(ctx, model, oldMessages, opts, customInstructions, onChunk)
}
if err != nil {
return nil, nil, err
@@ -527,15 +534,17 @@ func Compact(
}
// compactNormal generates a summary for a clean turn-boundary cut.
// If onChunk is provided, text deltas are streamed to it.
func compactNormal(
ctx context.Context,
model fantasy.LanguageModel,
oldMessages []fantasy.Message,
opts CompactionOptions,
customInstructions string,
onChunk StreamCallback,
) (string, error) {
conversationText := serializeMessages(oldMessages)
return generateSummary(ctx, model, conversationText, opts, customInstructions)
return generateSummary(ctx, model, conversationText, opts, customInstructions, onChunk)
}
// compactSplitTurn handles the case where the cut point lands mid-turn.
@@ -546,6 +555,7 @@ func compactNormal(
//
// The merged result preserves context from both the older history and the
// beginning of the current long turn.
// If onChunk is provided, both summaries and the separator are streamed.
func compactSplitTurn(
ctx context.Context,
model fantasy.LanguageModel,
@@ -554,6 +564,7 @@ func compactSplitTurn(
cutPoint int,
opts CompactionOptions,
customInstructions string,
onChunk StreamCallback,
) (string, error) {
// Find where the split turn starts.
turnStart := findTurnStart(allMessages, cutPoint)
@@ -573,12 +584,19 @@ func compactSplitTurn(
// Generate history summary if there are complete turns before the split.
if len(historyMessages) >= 2 {
historySummary, err = generateSummary(ctx, model,
serializeMessages(historyMessages), opts, "")
serializeMessages(historyMessages), opts, "", onChunk)
if err != nil {
return "", fmt.Errorf("split turn history summary failed: %w", err)
}
}
// Stream the separator between history and turn prefix summaries.
if onChunk != nil && historySummary != "" {
if err := onChunk("\n\n---\n\n## Current Turn (in progress)\n\n"); err != nil {
return "", fmt.Errorf("streaming separator failed: %w", err)
}
}
// Generate turn prefix summary.
turnPrefixText := serializeMessages(turnPrefixMessages)
turnPrefixPrompt := "The messages above are the BEGINNING of a long turn that was split. " +
@@ -588,16 +606,10 @@ func compactSplitTurn(
turnPrefixPrompt += "\n\nAdditional instructions: " + customInstructions
}
summaryAgent := fantasy.NewAgent(model,
fantasy.WithSystemPrompt(defaultSystemPrompt),
)
result, err := summaryAgent.Generate(ctx, fantasy.AgentCall{
Prompt: turnPrefixText + "\n\n" + turnPrefixPrompt,
})
turnPrefixSummary, err := generateSummary(ctx, model, turnPrefixText, opts, turnPrefixPrompt, onChunk)
if err != nil {
return "", fmt.Errorf("split turn prefix summary failed: %w", err)
}
turnPrefixSummary := result.Response.Content.Text()
// Merge the two summaries.
if historySummary != "" && turnPrefixSummary != "" {
@@ -610,12 +622,14 @@ func compactSplitTurn(
}
// generateSummary calls the LLM to produce a structured summary.
// If onChunk is provided, the summary is streamed using Agent.Stream().
func generateSummary(
ctx context.Context,
model fantasy.LanguageModel,
conversationText string,
opts CompactionOptions,
customInstructions string,
onChunk StreamCallback,
) (string, error) {
userPrompt := opts.SummaryPrompt
if userPrompt == "" {
@@ -628,8 +642,31 @@ func generateSummary(
summaryAgent := fantasy.NewAgent(model,
fantasy.WithSystemPrompt(defaultSystemPrompt),
)
prompt := conversationText + "\n\n" + userPrompt
// Use streaming if onChunk is provided.
if onChunk != nil {
var fullText strings.Builder
_, err := summaryAgent.Stream(ctx, fantasy.AgentStreamCall{
Prompt: prompt,
OnTextDelta: func(_, delta string) error {
if delta != "" {
fullText.WriteString(delta)
return onChunk(delta)
}
return nil
},
})
if err != nil {
return "", fmt.Errorf("compaction summarisation (streaming) failed: %w", err)
}
return fullText.String(), nil
}
// Non-streaming path.
result, err := summaryAgent.Generate(ctx, fantasy.AgentCall{
Prompt: conversationText + "\n\n" + userPrompt,
Prompt: prompt,
})
if err != nil {
return "", fmt.Errorf("compaction summarisation failed: %w", err)
+2 -2
View File
@@ -243,7 +243,7 @@ func TestCompact_TooFewMessages(t *testing.T) {
makeTextMessageN(fantasy.MessageRoleUser, 400),
}
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil)
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -262,7 +262,7 @@ func TestCompact_WithinBudget(t *testing.T) {
makeTextMessageN(fantasy.MessageRoleAssistant, 400),
}
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil)
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
+1 -3
View File
@@ -191,7 +191,6 @@ type Config struct {
Model string `json:"model,omitempty" yaml:"model,omitempty"`
MaxSteps int `json:"max-steps,omitempty" yaml:"max-steps,omitempty"`
Debug bool `json:"debug,omitempty" yaml:"debug,omitempty"`
Compact bool `json:"compact,omitempty" yaml:"compact,omitempty"`
SystemPrompt string `json:"system-prompt,omitempty" yaml:"system-prompt,omitempty"`
ProviderAPIKey string `json:"provider-api-key,omitempty" yaml:"provider-api-key,omitempty"`
ProviderURL string `json:"provider-url,omitempty" yaml:"provider-url,omitempty"`
@@ -403,10 +402,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
+234 -44
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"sort"
"strings"
"unicode"
"unicode/utf8"
@@ -13,19 +14,45 @@ import (
udiff "github.com/aymanbagabas/go-udiff"
)
type editArgs struct {
Path string `json:"path"`
// Edit represents a single replacement in a multi-edit operation.
type Edit struct {
OldText string `json:"old_text"`
NewText string `json:"new_text"`
}
// editArgs holds the arguments for the edit tool.
// Supports both single-edit mode (old_text/new_text) and multi-edit mode (edits array).
type editArgs struct {
Path string `json:"path"`
OldText string `json:"old_text"` // Single-edit mode
NewText string `json:"new_text"` // Single-edit mode
Edits []Edit `json:"edits"` // Multi-edit mode
}
// replacement represents a normalized edit ready for processing.
type replacement struct {
oldText string // normalized old text for matching
newText string // normalized new text
originalOld string // original old text for metadata
originalNew string // original new text for metadata
index int // index in the original edits array (for error messages)
}
// matchedReplacement represents a replacement with its match location.
type matchedReplacement struct {
replacement
start int // start index in normalized content
end int // end index in normalized content
usedFuzzyMatch bool // true if fuzzy matching was used
}
// NewEditTool creates the edit core tool.
func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
cfg := ApplyOptions(opts)
return &coreTool{
info: fantasy.ToolInfo{
Name: "edit",
Description: "Edit a file by replacing exact text. The old_text must match exactly (including whitespace). Use this for precise, surgical edits. Fails if old_text is not found or matches multiple locations.",
Description: "Edit a file by replacing exact text. Supports single edit via old_text/new_text, or multiple edits via the edits array. All edits in the array are matched against the original file content (non-incremental) and must be non-overlapping.",
Parameters: map[string]any{
"path": map[string]any{
"type": "string",
@@ -33,14 +60,32 @@ func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
},
"old_text": map[string]any{
"type": "string",
"description": "Exact text to find and replace (must match exactly)",
"description": "Exact text to find and replace (single-edit mode). Must not be used with 'edits' array.",
},
"new_text": map[string]any{
"type": "string",
"description": "New text to replace the old text with",
"description": "New text to replace the old text with (single-edit mode). Must not be used with 'edits' array.",
},
"edits": map[string]any{
"type": "array",
"description": "Array of edits for multi-region replacement. Each edit must have unique, non-overlapping old_text. All matches are against the original file content.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"old_text": map[string]any{
"type": "string",
"description": "Exact text to find and replace for this edit",
},
"new_text": map[string]any{
"type": "string",
"description": "New text for this edit",
},
},
"required": []string{"old_text", "new_text"},
},
},
},
Required: []string{"path", "old_text", "new_text"},
Required: []string{"path"},
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeEdit(ctx, call, cfg.WorkDir)
@@ -51,7 +96,7 @@ func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
var args editArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("path, old_text, and new_text parameters are required"), nil
return fantasy.NewTextErrorResponse("failed to parse arguments: " + err.Error()), nil
}
if args.Path == "" {
return fantasy.NewTextErrorResponse("path parameter is required"), nil
@@ -69,56 +114,201 @@ func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
content := string(contentBytes)
// Normalize line endings for matching
normalized := strings.ReplaceAll(content, "\r\n", "\n")
normalizedOld := strings.ReplaceAll(args.OldText, "\r\n", "\n")
// Try exact match first
count := strings.Count(normalized, normalizedOld)
// If no exact match, try fuzzy matching
if count == 0 {
if idx, matchLen := fuzzyMatch(normalized, normalizedOld); idx >= 0 {
// Apply fuzzy match — the matched text is the original content slice
matchedText := normalized[idx : idx+matchLen]
newContent := normalized[:idx] + args.NewText + normalized[idx+matchLen:]
if err := os.WriteFile(absPath, []byte(newContent), 0644); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
}
diff := generateDiff(absPath, normalized, newContent)
resp := fantasy.NewTextResponse(fmt.Sprintf("Applied edit (fuzzy match) to %s\n%s", args.Path, diff))
return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, matchedText, args.NewText)), nil
}
return fantasy.NewTextErrorResponse(fmt.Sprintf("old_text not found in %s", args.Path)), nil
// Normalize and validate input
replacements, err := normalizeEditInput(args)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
if count > 1 {
return fantasy.NewTextErrorResponse(fmt.Sprintf("found %d matches for old_text in %s. Provide more context to identify the correct match.", count, args.Path)), nil
// Apply all edits
newContent, applied, err := applyEdits(content, replacements)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
// Apply the edit
newContent := strings.Replace(normalized, normalizedOld, args.NewText, 1)
// Write the file
if err := os.WriteFile(absPath, []byte(newContent), 0644); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
}
diff := generateDiff(absPath, normalized, newContent)
resp := fantasy.NewTextResponse(fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff))
return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, normalizedOld, args.NewText)), nil
// Generate diff
normalizedContent := strings.ReplaceAll(content, "\r\n", "\n")
diff := generateDiff(absPath, normalizedContent, newContent)
// Build response with fuzzy match indication
fuzzyCount := 0
for _, m := range applied {
if m.usedFuzzyMatch {
fuzzyCount++
}
}
var msg string
if len(applied) == 1 {
if fuzzyCount > 0 {
msg = fmt.Sprintf("Applied edit (fuzzy match) to %s\n%s", args.Path, diff)
} else {
msg = fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff)
}
} else {
if fuzzyCount > 0 {
msg = fmt.Sprintf("Applied %d edits (%d fuzzy) to %s\n%s", len(applied), fuzzyCount, args.Path, diff)
} else {
msg = fmt.Sprintf("Applied %d edits to %s\n%s", len(applied), args.Path, diff)
}
}
resp := fantasy.NewTextResponse(msg)
return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, applied)), nil
}
// normalizeEditInput validates and normalizes the edit input.
// Returns error if both single-edit and multi-edit modes are used.
func normalizeEditInput(args editArgs) ([]replacement, error) {
singleMode := args.OldText != "" || args.NewText != ""
multiMode := len(args.Edits) > 0
if singleMode && multiMode {
return nil, fmt.Errorf("cannot use old_text/new_text together with edits array")
}
if !singleMode && !multiMode {
return nil, fmt.Errorf("must provide either old_text/new_text or edits array")
}
if singleMode {
if args.OldText == "" {
return nil, fmt.Errorf("old_text is required when using single-edit mode")
}
if args.NewText == "" {
return nil, fmt.Errorf("new_text is required when using single-edit mode")
}
return []replacement{{
oldText: strings.ReplaceAll(args.OldText, "\r\n", "\n"),
newText: strings.ReplaceAll(args.NewText, "\r\n", "\n"),
originalOld: args.OldText,
originalNew: args.NewText,
index: 0,
}}, nil
}
// Multi-edit mode
var reps []replacement
for i, edit := range args.Edits {
if edit.OldText == "" {
return nil, fmt.Errorf("edits[%d].old_text is required", i)
}
reps = append(reps, replacement{
oldText: strings.ReplaceAll(edit.OldText, "\r\n", "\n"),
newText: strings.ReplaceAll(edit.NewText, "\r\n", "\n"),
originalOld: edit.OldText,
originalNew: edit.NewText,
index: i,
})
}
return reps, nil
}
// applyEdits applies multiple replacements to the content.
// All matches are against the original content (non-incremental).
// Returns the new content, the applied matches, and any error.
func applyEdits(content string, edits []replacement) (string, []matchedReplacement, error) {
normalizedContent := strings.ReplaceAll(content, "\r\n", "\n")
// Find all matches
var matched []matchedReplacement
for _, edit := range edits {
m, err := findMatch(normalizedContent, edit)
if err != nil {
return "", nil, err
}
matched = append(matched, *m)
}
// Sort by position
sort.Slice(matched, func(i, j int) bool {
return matched[i].start < matched[j].start
})
// Check for overlaps
for i := 1; i < len(matched); i++ {
if matched[i-1].end > matched[i].start {
return "", nil, fmt.Errorf("edits[%d] and edits[%d] overlap; merge them into a single edit",
matched[i-1].index, matched[i].index)
}
}
// Apply edits in reverse order (end to start) to maintain stable offsets
result := normalizedContent
for i := len(matched) - 1; i >= 0; i-- {
m := matched[i]
result = result[:m.start] + m.newText + result[m.end:]
}
return result, matched, nil
}
// findMatch finds a unique match for the edit in the content.
// Returns error if not found or ambiguous.
func findMatch(content string, edit replacement) (*matchedReplacement, error) {
// Try exact match first
count := strings.Count(content, edit.oldText)
if count == 0 {
// Try fuzzy match
idx, matchLen := fuzzyMatch(content, edit.oldText)
if idx < 0 {
return nil, fmt.Errorf("edits[%d]: could not find old_text in file. The text must match exactly (including whitespace)", edit.index)
}
// Use the matched text from content for the replacement
matchedText := content[idx : idx+matchLen]
return &matchedReplacement{
replacement: replacement{
oldText: matchedText,
newText: edit.newText,
originalOld: edit.originalOld,
originalNew: edit.originalNew,
index: edit.index,
},
start: idx,
end: idx + matchLen,
usedFuzzyMatch: true,
}, nil
}
if count > 1 {
return nil, fmt.Errorf("found %d matches for edits[%d].old_text; each old_text must be unique, provide more context to identify the correct match", count, edit.index)
}
// Single exact match
idx := strings.Index(content, edit.oldText)
return &matchedReplacement{
replacement: edit,
start: idx,
end: idx + len(edit.oldText),
}, nil
}
// editDiffMeta builds the structured metadata attached to edit tool responses.
func editDiffMeta(path, oldText, newText string) map[string]any {
func editDiffMeta(path string, applied []matchedReplacement) map[string]any {
var diffBlocks []map[string]any
totalAdditions, totalDeletions := 0, 0
for _, m := range applied {
diffBlocks = append(diffBlocks, map[string]any{
"old_text": m.originalOld,
"new_text": m.originalNew,
})
totalAdditions += strings.Count(m.originalNew, "\n") + 1
totalDeletions += strings.Count(m.originalOld, "\n") + 1
}
return map[string]any{
"file_diffs": []map[string]any{{
"path": path,
"additions": strings.Count(newText, "\n") + 1,
"deletions": strings.Count(oldText, "\n") + 1,
"diff_blocks": []map[string]any{{
"old_text": oldText,
"new_text": newText,
}},
"path": path,
"additions": totalAdditions,
"deletions": totalDeletions,
"diff_blocks": diffBlocks,
}},
}
}
+312
View File
@@ -715,3 +715,315 @@ func TestExecuteEdit_MetadataContainsFileDiffs(t *testing.T) {
t.Fatal("file_diffs should be a non-empty array")
}
}
// ---------------------------------------------------------------------------
// Multi-edit tests
// ---------------------------------------------------------------------------
func TestExecuteEdit_MultiEdit_Basic(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "multi.txt")
writeFileOrFail(t, path, "line1\nline2\nline3\nline4\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{
{OldText: "line1", NewText: "LINE1"},
{OldText: "line3", NewText: "LINE3"},
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if resp.IsError {
t.Fatalf("tool returned error: %s", resp.Content)
}
got, _ := os.ReadFile(path)
gotStr := string(got)
if !strings.Contains(gotStr, "LINE1") {
t.Error("first edit not applied: missing LINE1")
}
if !strings.Contains(gotStr, "LINE3") {
t.Error("second edit not applied: missing LINE3")
}
if !strings.Contains(gotStr, "line2") {
t.Error("line2 was modified but should be untouched")
}
if !strings.Contains(gotStr, "line4") {
t.Error("line4 was modified but should be untouched")
}
// Check response mentions multiple edits
if !strings.Contains(resp.Content, "2 edits") {
t.Errorf("response should mention '2 edits', got: %s", resp.Content)
}
}
func TestExecuteEdit_MultiEdit_NonIncrementalMatching(t *testing.T) {
// All edits are matched against the original content, not incrementally
dir := t.TempDir()
path := filepath.Join(dir, "noninc.txt")
writeFileOrFail(t, path, "aaa\nbbb\nccc\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{
{OldText: "aaa", NewText: "AAA"},
{OldText: "bbb", NewText: "BBB"},
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if resp.IsError {
t.Fatalf("tool returned error: %s", resp.Content)
}
got, _ := os.ReadFile(path)
gotStr := string(got)
want := "AAA\nBBB\nccc\n"
if gotStr != want {
t.Errorf("got %q, want %q", gotStr, want)
}
}
func TestExecuteEdit_MultiEdit_OverlapDetection(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "overlap.txt")
writeFileOrFail(t, path, "hello world\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{
{OldText: "hello", NewText: "HELLO"},
{OldText: "hello world", NewText: "GOODBYE"}, // Overlaps with first edit
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if !resp.IsError {
t.Error("expected error for overlapping edits")
}
if !strings.Contains(resp.Content, "overlap") {
t.Errorf("expected 'overlap' in error, got: %s", resp.Content)
}
// File should be untouched
got, _ := os.ReadFile(path)
if string(got) != "hello world\n" {
t.Error("file was modified despite error")
}
}
func TestExecuteEdit_MultiEdit_DuplicateDetection(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "dup.txt")
writeFileOrFail(t, path, "hello\nworld\nhello\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{
{OldText: "hello", NewText: "HELLO"},
{OldText: "world", NewText: "WORLD"},
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if !resp.IsError {
t.Error("expected error for ambiguous old_text (duplicate matches)")
}
if !strings.Contains(resp.Content, "unique") {
t.Errorf("expected 'unique' in error, got: %s", resp.Content)
}
// File should be untouched
got, _ := os.ReadFile(path)
if string(got) != "hello\nworld\nhello\n" {
t.Error("file was modified despite error")
}
}
func TestExecuteEdit_MultiEdit_NotFound(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "notfound.txt")
writeFileOrFail(t, path, "hello world\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{
{OldText: "nonexistent", NewText: "REPLACEMENT"},
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if !resp.IsError {
t.Error("expected error for not found")
}
if !strings.Contains(resp.Content, "edits[0]") {
t.Errorf("expected 'edits[0]' in error, got: %s", resp.Content)
}
// File should be untouched
got, _ := os.ReadFile(path)
if string(got) != "hello world\n" {
t.Error("file was modified despite error")
}
}
func TestExecuteEdit_MultiEdit_EmptyArray(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.txt")
writeFileOrFail(t, path, "hello\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if !resp.IsError {
t.Error("expected error for empty edits array")
}
}
func TestExecuteEdit_MultiEdit_MixedWithSingleMode(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mixed.txt")
writeFileOrFail(t, path, "hello\n")
input, _ := json.Marshal(map[string]any{
"path": path,
"old_text": "hello",
"new_text": "HELLO",
"edits": []Edit{
{OldText: "hello", NewText: "HI"},
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if !resp.IsError {
t.Error("expected error when mixing single and multi-edit modes")
}
if !strings.Contains(resp.Content, "cannot use") {
t.Errorf("expected 'cannot use' in error, got: %s", resp.Content)
}
}
func TestExecuteEdit_MultiEdit_FuzzyMatch(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "fuzzy_multi.txt")
// File has trailing whitespace
original := "func foo() { \n\treturn 1 \n}\nfunc bar() { \n\treturn 2 \n}\n"
writeFileOrFail(t, path, original)
// Search without trailing whitespace (common LLM behavior)
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{
{OldText: "func foo() {\n\treturn 1\n}", NewText: "func foo() {\n\treturn 10\n}"},
{OldText: "func bar() {\n\treturn 2\n}", NewText: "func bar() {\n\treturn 20\n}"},
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("executeEdit error: %v", err)
}
if resp.IsError {
t.Fatalf("tool returned error: %s", resp.Content)
}
got, _ := os.ReadFile(path)
gotStr := string(got)
if !strings.Contains(gotStr, "return 10") {
t.Error("first edit not applied")
}
if !strings.Contains(gotStr, "return 20") {
t.Error("second edit not applied")
}
// Response should mention fuzzy match
if !strings.Contains(resp.Content, "fuzzy") {
t.Errorf("response should mention 'fuzzy', got: %s", resp.Content)
}
}
func TestExecuteEdit_MultiEdit_Metadata(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "meta_multi.txt")
writeFileOrFail(t, path, "aaa\nbbb\nccc\n")
input, _ := json.Marshal(editArgs{
Path: path,
Edits: []Edit{
{OldText: "aaa", NewText: "AAA"},
{OldText: "bbb", NewText: "BBB"},
},
})
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
if err != nil {
t.Fatalf("error: %v", err)
}
if resp.IsError {
t.Fatalf("tool returned error: %s", resp.Content)
}
var meta map[string]any
if err := json.Unmarshal([]byte(resp.Metadata), &meta); err != nil {
t.Fatalf("metadata is not valid JSON: %v", err)
}
diffs, ok := meta["file_diffs"].([]any)
if !ok || len(diffs) == 0 {
t.Fatal("metadata missing file_diffs")
}
firstDiff, ok := diffs[0].(map[string]any)
if !ok {
t.Fatal("first diff is not an object")
}
// Check that diff_blocks contains both edits
diffBlocks, ok := firstDiff["diff_blocks"].([]any)
if !ok || len(diffBlocks) != 2 {
t.Fatalf("expected 2 diff_blocks, got %d", len(diffBlocks))
}
// Verify each block has old_text and new_text
for i, block := range diffBlocks {
b, ok := block.(map[string]any)
if !ok {
t.Fatalf("diff_block[%d] is not an object", i)
}
if _, ok := b["old_text"]; !ok {
t.Fatalf("diff_block[%d] missing old_text", i)
}
if _, ok := b["new_text"]; !ok {
t.Fatalf("diff_block[%d] missing new_text", i)
}
}
}
+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{
+243 -5
View File
@@ -572,6 +572,102 @@ type Context struct {
// })
// // handle.Kill() to cancel, handle.Wait() to block
SpawnSubagent func(SubagentConfig) (*SubagentHandle, *SubagentResult, error)
// -------------------------------------------------------------------------
// Tree Navigation API (Phase 1 Bridge)
// -------------------------------------------------------------------------
// GetTreeNode returns a node by ID with full metadata and children.
// Returns nil if entry not found.
GetTreeNode func(entryID string) *TreeNode
// GetCurrentBranch returns the path from root to current leaf.
// Each node contains full metadata (unlike GetMessages which flattens).
GetCurrentBranch func() []TreeNode
// GetChildren returns direct child IDs of an entry.
GetChildren func(entryID string) []string
// NavigateTo branches/forks the session to the specified entry ID.
// Equivalent to SDK's Branch() but for extensions.
NavigateTo func(entryID string) TreeNavigationResult
// SummarizeBranch uses LLM to summarize a branch range.
// Returns summary text or error string (empty if success).
SummarizeBranch func(fromID, toID string) string
// CollapseBranch replaces a branch range with a summary entry.
// This is the "fresh context" primitive for context window management.
CollapseBranch func(fromID, toID, summary string) TreeNavigationResult
// -------------------------------------------------------------------------
// Skill Loading API (Phase 2 Bridge)
// -------------------------------------------------------------------------
// LoadSkill loads a single skill file from path.
// Parses YAML frontmatter, returns skill with content ready for injection.
LoadSkill func(path string) (*Skill, string)
// LoadSkillsFromDir discovers and loads all skills from a directory.
LoadSkillsFromDir func(dir string) SkillLoadResult
// DiscoverSkills finds skills in standard locations.
// Checks ~/.config/kit/skills/, .kit/skills/, .agents/skills/
DiscoverSkills func() SkillLoadResult
// InjectSkillAsContext sends a skill's content as a system message.
// Looks up skill by name from discovered skills.
InjectSkillAsContext func(skillName string) string
// InjectRawSkillAsContext loads and immediately injects a skill file.
InjectRawSkillAsContext func(path string) string
// GetAvailableSkills returns all currently loaded/discovered skills.
GetAvailableSkills func() []Skill
// -------------------------------------------------------------------------
// Template Parsing API (Phase 3 Bridge)
// -------------------------------------------------------------------------
// ParseTemplate extracts {{variables}} from template content.
ParseTemplate func(name, content string) PromptTemplate
// RenderTemplate substitutes variables into template content.
RenderTemplate func(tpl PromptTemplate, vars map[string]string) string
// ParseArguments parses command-line style arguments.
ParseArguments func(input string, pattern ArgumentPattern) ParseResult
// SimpleParseArguments parses $1, $2, $@ style arguments.
// Returns slice where [0]=full input, [1]=$1, [2]=$2, ... [n]=$@
SimpleParseArguments func(input string, count int) []string
// EvaluateModelConditional checks if condition matches current model.
// Condition supports wildcards: * matches any, ? matches single char.
EvaluateModelConditional func(condition string) bool
// RenderWithModelConditionals processes <if-model> blocks in content.
RenderWithModelConditionals func(content string) string
// -------------------------------------------------------------------------
// Model Resolution API (Phase 4 Bridge)
// -------------------------------------------------------------------------
// ResolveModelChain attempts each model in order until one is available.
ResolveModelChain func(preferences []string) ModelResolutionResult
// GetModelCapabilities returns capabilities for a specific model.
// If model is empty, uses current model.
GetModelCapabilities func(model string) (ModelCapabilities, string)
// CheckModelAvailable verifies if a model string is valid.
CheckModelAvailable func(model string) bool
// GetCurrentProvider returns just the provider part of current model.
GetCurrentProvider func() string
// GetCurrentModelID returns just the model ID part of current model.
GetCurrentModelID func() string
}
// ---------------------------------------------------------------------------
@@ -598,6 +694,148 @@ type SessionMessage struct {
Timestamp string
}
// ---------------------------------------------------------------------------
// Tree navigation types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// TreeNode represents a node in the session tree for navigation.
// Extensions use this to traverse conversation history and implement
// features like "fresh context" loops and branch summarization.
type TreeNode struct {
// ID is the unique entry identifier.
ID string
// ParentID links this entry to its parent (empty if root).
ParentID string
// Type is the entry type: "message", "branch_summary", "model_change", "extension_data", "tool_execution".
Type string
// Role is the message role for message entries: "user", "assistant", "system", "tool".
Role string
// Content is the text content or summary.
Content string
// Model is the model that generated this (for assistant messages).
Model string
// Provider is the provider used.
Provider string
// Timestamp is the RFC3339-formatted creation time.
Timestamp string
// Children is the list of child entry IDs for tree traversal.
Children []string
}
// TreeNavigationResult reports success or failure of tree operations.
type TreeNavigationResult struct {
// Success is true if the operation completed.
Success bool
// Error describes what went wrong (empty if success).
Error string
}
// ---------------------------------------------------------------------------
// Skill types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// Skill represents a loaded skill file with parsed YAML frontmatter.
type Skill struct {
// Name is the human-readable identifier.
Name string
// Description summarizes what this skill provides.
Description string
// Content is the markdown body (frontmatter stripped).
Content string
// Path is the absolute filesystem path.
Path string
// Tags are optional labels for categorization.
Tags []string
// When controls automatic inclusion: "always", "on-demand", or file-glob.
When string
}
// SkillLoadResult reports skills loaded from a directory.
type SkillLoadResult struct {
// Skills is the list of loaded skills.
Skills []Skill
// Error describes loading failures (empty if success).
Error string
}
// ---------------------------------------------------------------------------
// Template parsing types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// PromptTemplate represents a parsed template with variable placeholders.
type PromptTemplate struct {
// Name is the template identifier.
Name string
// Content is the original template content.
Content string
// Variables are the extracted {{variable}} names.
Variables []string
}
// ArgumentPattern defines how to parse command arguments.
type ArgumentPattern struct {
// Positional names for $1, $2, etc.
Positional []string
// Rest is the variable name for $@ (all remaining).
Rest string
// Flags maps flag names to variable names (e.g., "--loop" -> "loop").
Flags map[string]string
}
// ParseResult reports argument parsing outcome.
type ParseResult struct {
// Vars maps variable names to values for positional args.
Vars map[string]string
// Flags maps flag names to values.
Flags map[string]string
// Rest is remaining unparsed text.
Rest string
// Error describes parsing failures (empty if success).
Error string
}
// ModelConditional represents an <if-model> block for evaluation.
type ModelConditional struct {
// Condition is the model pattern (e.g., "claude-*", "anthropic/*").
Condition string
// Content is rendered if condition matches.
Content string
// Else is rendered if condition doesn't match.
Else string
}
// ---------------------------------------------------------------------------
// Model resolution types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// ModelCapabilities describes what a model supports.
type ModelCapabilities struct {
// Provider is the provider ID (e.g., "anthropic").
Provider string
// ModelID is the model identifier (e.g., "claude-sonnet-4-20250929").
ModelID string
// ContextLimit is the maximum context window in tokens.
ContextLimit int
// OutputLimit is the maximum output tokens.
OutputLimit int
// Reasoning indicates if the model supports reasoning/thinking.
Reasoning bool
// Streaming indicates if the model supports streaming.
Streaming bool
}
// ModelResolutionResult reports model chain resolution outcome.
type ModelResolutionResult struct {
// Model is the selected model in "provider/model" format.
Model string
// Capabilities describes the selected model.
Capabilities ModelCapabilities
// Attempted lists models tried before success.
Attempted []string
// Error describes resolution failures (empty if success).
Error string
}
// ExtensionEntry represents persisted extension data stored in the session.
// Extensions use AppendEntry to save custom state and GetEntries to retrieve
// it on session resume.
@@ -784,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)) {
@@ -799,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)
@@ -1808,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.
@@ -1850,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.
+252 -2
View File
@@ -56,11 +56,261 @@ func NewRunner(exts []LoadedExtension) *Runner {
}
// SetContext updates the runtime context (session ID, model, etc.) that is
// passed to every handler invocation. Thread-safe.
// passed to every handler invocation. Nil function fields are replaced with
// safe no-ops so extension handlers never panic on a missing callback.
// Thread-safe.
func (r *Runner) SetContext(ctx Context) {
r.mu.Lock()
defer r.mu.Unlock()
r.ctx = ctx
r.ctx = normalizeContext(ctx)
}
// normalizeContext replaces nil function fields in ctx with no-op stubs so
// that extension handlers can call any ctx method without a nil-function panic.
func normalizeContext(ctx Context) Context {
if ctx.Print == nil {
ctx.Print = func(string) {}
}
if ctx.PrintInfo == nil {
ctx.PrintInfo = func(string) {}
}
if ctx.PrintError == nil {
ctx.PrintError = func(string) {}
}
if ctx.PrintBlock == nil {
ctx.PrintBlock = func(PrintBlockOpts) {}
}
if ctx.SendMessage == nil {
ctx.SendMessage = func(string) {}
}
if ctx.CancelAndSend == nil {
ctx.CancelAndSend = func(string) {}
}
if ctx.SetWidget == nil {
ctx.SetWidget = func(WidgetConfig) {}
}
if ctx.RemoveWidget == nil {
ctx.RemoveWidget = func(string) {}
}
if ctx.SetHeader == nil {
ctx.SetHeader = func(HeaderFooterConfig) {}
}
if ctx.RemoveHeader == nil {
ctx.RemoveHeader = func() {}
}
if ctx.SetFooter == nil {
ctx.SetFooter = func(HeaderFooterConfig) {}
}
if ctx.RemoveFooter == nil {
ctx.RemoveFooter = func() {}
}
if ctx.PromptSelect == nil {
ctx.PromptSelect = func(PromptSelectConfig) PromptSelectResult {
return PromptSelectResult{Cancelled: true}
}
}
if ctx.PromptConfirm == nil {
ctx.PromptConfirm = func(PromptConfirmConfig) PromptConfirmResult {
return PromptConfirmResult{Cancelled: true}
}
}
if ctx.PromptInput == nil {
ctx.PromptInput = func(PromptInputConfig) PromptInputResult {
return PromptInputResult{Cancelled: true}
}
}
if ctx.PromptMultiSelect == nil {
ctx.PromptMultiSelect = func(PromptMultiSelectConfig) PromptMultiSelectResult {
return PromptMultiSelectResult{Cancelled: true}
}
}
if ctx.ShowOverlay == nil {
ctx.ShowOverlay = func(OverlayConfig) OverlayResult {
return OverlayResult{Cancelled: true, Index: -1}
}
}
if ctx.SetEditor == nil {
ctx.SetEditor = func(EditorConfig) {}
}
if ctx.ResetEditor == nil {
ctx.ResetEditor = func() {}
}
if ctx.SetEditorText == nil {
ctx.SetEditorText = func(string) {}
}
if ctx.SetUIVisibility == nil {
ctx.SetUIVisibility = func(UIVisibility) {}
}
if ctx.SetStatus == nil {
ctx.SetStatus = func(string, string, int) {}
}
if ctx.RemoveStatus == nil {
ctx.RemoveStatus = func(string) {}
}
if ctx.GetContextStats == nil {
ctx.GetContextStats = func() ContextStats { return ContextStats{} }
}
if ctx.GetMessages == nil {
ctx.GetMessages = func() []SessionMessage { return nil }
}
if ctx.GetSessionPath == nil {
ctx.GetSessionPath = func() string { return "" }
}
if ctx.AppendEntry == nil {
ctx.AppendEntry = func(string, string) (string, error) { return "", nil }
}
if ctx.GetEntries == nil {
ctx.GetEntries = func(string) []ExtensionEntry { return nil }
}
if ctx.GetOption == nil {
ctx.GetOption = func(string) string { return "" }
}
if ctx.SetOption == nil {
ctx.SetOption = func(string, string) {}
}
if ctx.SetModel == nil {
ctx.SetModel = func(string) error { return nil }
}
if ctx.GetAvailableModels == nil {
ctx.GetAvailableModels = func() []ModelInfoEntry { return nil }
}
if ctx.EmitCustomEvent == nil {
ctx.EmitCustomEvent = func(string, string) {}
}
if ctx.GetAllTools == nil {
ctx.GetAllTools = func() []ToolInfo { return nil }
}
if ctx.SetActiveTools == nil {
ctx.SetActiveTools = func([]string) {}
}
if ctx.Exit == nil {
ctx.Exit = func() {}
}
if ctx.Complete == nil {
ctx.Complete = func(CompleteRequest) (CompleteResponse, error) {
return CompleteResponse{}, nil
}
}
if ctx.SuspendTUI == nil {
ctx.SuspendTUI = func(callback func()) error { callback(); return nil }
}
if ctx.RenderMessage == nil {
ctx.RenderMessage = func(string, string) {}
}
if ctx.RegisterTheme == nil {
ctx.RegisterTheme = func(string, ThemeColorConfig) {}
}
if ctx.SetTheme == nil {
ctx.SetTheme = func(string) error { return nil }
}
if ctx.ListThemes == nil {
ctx.ListThemes = func() []string { return nil }
}
if ctx.ReloadExtensions == nil {
ctx.ReloadExtensions = func() error { return nil }
}
if ctx.SpawnSubagent == nil {
ctx.SpawnSubagent = func(SubagentConfig) (*SubagentHandle, *SubagentResult, error) {
return nil, nil, nil
}
}
// -------------------------------------------------------------------------
// Tree Navigation API no-ops
// -------------------------------------------------------------------------
if ctx.GetTreeNode == nil {
ctx.GetTreeNode = func(string) *TreeNode { return nil }
}
if ctx.GetCurrentBranch == nil {
ctx.GetCurrentBranch = func() []TreeNode { return nil }
}
if ctx.GetChildren == nil {
ctx.GetChildren = func(string) []string { return nil }
}
if ctx.NavigateTo == nil {
ctx.NavigateTo = func(string) TreeNavigationResult {
return TreeNavigationResult{Success: false, Error: "not implemented"}
}
}
if ctx.SummarizeBranch == nil {
ctx.SummarizeBranch = func(string, string) string {
return ""
}
}
if ctx.CollapseBranch == nil {
ctx.CollapseBranch = func(string, string, string) TreeNavigationResult {
return TreeNavigationResult{Success: false, Error: "not implemented"}
}
}
// -------------------------------------------------------------------------
// Skill Loading API no-ops
// -------------------------------------------------------------------------
if ctx.LoadSkill == nil {
ctx.LoadSkill = func(string) (*Skill, string) { return nil, "" }
}
if ctx.LoadSkillsFromDir == nil {
ctx.LoadSkillsFromDir = func(string) SkillLoadResult { return SkillLoadResult{} }
}
if ctx.DiscoverSkills == nil {
ctx.DiscoverSkills = func() SkillLoadResult { return SkillLoadResult{} }
}
if ctx.InjectSkillAsContext == nil {
ctx.InjectSkillAsContext = func(string) string { return "" }
}
if ctx.InjectRawSkillAsContext == nil {
ctx.InjectRawSkillAsContext = func(string) string { return "" }
}
if ctx.GetAvailableSkills == nil {
ctx.GetAvailableSkills = func() []Skill { return nil }
}
// -------------------------------------------------------------------------
// Template Parsing API no-ops
// -------------------------------------------------------------------------
if ctx.ParseTemplate == nil {
ctx.ParseTemplate = func(string, string) PromptTemplate { return PromptTemplate{} }
}
if ctx.RenderTemplate == nil {
ctx.RenderTemplate = func(PromptTemplate, map[string]string) string { return "" }
}
if ctx.ParseArguments == nil {
ctx.ParseArguments = func(string, ArgumentPattern) ParseResult { return ParseResult{} }
}
if ctx.SimpleParseArguments == nil {
ctx.SimpleParseArguments = func(string, int) []string { return nil }
}
if ctx.EvaluateModelConditional == nil {
ctx.EvaluateModelConditional = func(string) bool { return false }
}
if ctx.RenderWithModelConditionals == nil {
ctx.RenderWithModelConditionals = func(string) string { return "" }
}
// -------------------------------------------------------------------------
// Model Resolution API no-ops
// -------------------------------------------------------------------------
if ctx.ResolveModelChain == nil {
ctx.ResolveModelChain = func([]string) ModelResolutionResult {
return ModelResolutionResult{Error: "not implemented"}
}
}
if ctx.GetModelCapabilities == nil {
ctx.GetModelCapabilities = func(string) (ModelCapabilities, string) {
return ModelCapabilities{}, "not implemented"
}
}
if ctx.CheckModelAvailable == nil {
ctx.CheckModelAvailable = func(string) bool { return false }
}
if ctx.GetCurrentProvider == nil {
ctx.GetCurrentProvider = func() string { return "" }
}
if ctx.GetCurrentModelID == nil {
ctx.GetCurrentModelID = func() string { return "" }
}
return ctx
}
// GetContext returns a snapshot of the current runtime context. Thread-safe.
+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)
}
+18
View File
@@ -128,6 +128,24 @@ func Symbols() interp.Exports {
"ThemeColor": reflect.ValueOf((*ThemeColor)(nil)),
"ThemeColorConfig": reflect.ValueOf((*ThemeColorConfig)(nil)),
// Tree navigation types
"TreeNode": reflect.ValueOf((*TreeNode)(nil)),
"TreeNavigationResult": reflect.ValueOf((*TreeNavigationResult)(nil)),
// Skill types
"Skill": reflect.ValueOf((*Skill)(nil)),
"SkillLoadResult": reflect.ValueOf((*SkillLoadResult)(nil)),
// Template parsing types
"PromptTemplate": reflect.ValueOf((*PromptTemplate)(nil)),
"ArgumentPattern": reflect.ValueOf((*ArgumentPattern)(nil)),
"ParseResult": reflect.ValueOf((*ParseResult)(nil)),
"ModelConditional": reflect.ValueOf((*ModelConditional)(nil)),
// Model resolution types
"ModelCapabilities": reflect.ValueOf((*ModelCapabilities)(nil)),
"ModelResolutionResult": reflect.ValueOf((*ModelResolutionResult)(nil)),
// Event structs
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
+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
}
+137 -25
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")
@@ -266,10 +275,9 @@ func TestInputComponent_UnknownSlashCommand_ForwardsAsSubmit(t *testing.T) {
// Helpers
// --------------------------------------------------------------------------
// newTestStream creates a StreamComponent with a fixed width and model name,
// in non-compact mode.
// newTestStream creates a StreamComponent with a fixed width and model name.
func newTestStream() *StreamComponent {
return NewStreamComponent(false, 80, "test-model")
return NewStreamComponent(80, "test-model")
}
// sendStreamMsg calls component.Update and returns the updated component.
@@ -349,7 +357,7 @@ func TestStreamComponent_SpinnerKeepsRunningDuringStreaming(t *testing.T) {
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "hello"})
// Flush pending chunks (simulates the 16ms tick firing).
c = sendStreamMsg(c, streamFlushTickMsg{})
c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
if !c.spinning {
t.Fatal("expected spinning=true after first chunk")
@@ -376,7 +384,7 @@ func TestStreamComponent_ChunkAccumulation(t *testing.T) {
}
// Flush pending chunks (simulates the 16ms tick firing).
c = sendStreamMsg(c, streamFlushTickMsg{})
c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
got := c.streamContent.String()
want := "Hello, world!"
@@ -396,6 +404,7 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
c := newTestStream()
_, cmd := c.Update(app.ToolExecutionEvent{
ToolCallID: "call-exec-1",
ToolName: "exec_tool",
IsStarting: true,
})
@@ -403,8 +412,9 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
if !c.spinning {
t.Fatal("expected spinning=true during tool execution")
}
if len(c.activeTools) != 1 || !strings.Contains(c.activeTools[0], "exec_tool") {
t.Fatalf("expected activeTools to contain tool name, got %v", c.activeTools)
tools := c.activeToolDisplays()
if len(tools) != 1 || !strings.Contains(tools[0], "exec_tool") {
t.Fatalf("expected activeTools to contain tool name, got %v", tools)
}
if cmd == nil {
t.Fatal("expected tick cmd from ToolExecutionEvent{IsStarting:true}")
@@ -418,11 +428,13 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
// Simulate a tool starting
c = sendStreamMsg(c, app.ToolExecutionEvent{
ToolCallID: "call-some-1",
ToolName: "some_tool",
IsStarting: true,
})
c = sendStreamMsg(c, app.ToolExecutionEvent{
ToolCallID: "call-some-1",
ToolName: "some_tool",
IsStarting: false,
})
@@ -440,9 +452,9 @@ func TestStreamComponent_ParallelToolExecution(t *testing.T) {
c := newTestStream()
// Start three tools in parallel
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read", ToolName: "read", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-grep", ToolName: "grep", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-find", ToolName: "find", IsStarting: true})
if len(c.activeTools) != 3 {
t.Fatalf("expected 3 active tools, got %d: %v", len(c.activeTools), c.activeTools)
@@ -455,19 +467,44 @@ func TestStreamComponent_ParallelToolExecution(t *testing.T) {
}
// Finish one tool
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-grep", ToolName: "grep", IsStarting: false})
if len(c.activeTools) != 2 {
t.Fatalf("expected 2 active tools after one finished, got %d: %v", len(c.activeTools), c.activeTools)
}
// Finish remaining tools
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read", ToolName: "read", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-find", ToolName: "find", IsStarting: false})
if len(c.activeTools) != 0 {
t.Fatalf("expected 0 active tools after all finished, got %d: %v", len(c.activeTools), c.activeTools)
}
}
// TestStreamComponent_ParallelSameToolName_UsesToolCallID verifies finishing one
// tool call does not remove another concurrent call with the same tool name.
func TestStreamComponent_ParallelSameToolName_UsesToolCallID(t *testing.T) {
c := newTestStream()
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-1", ToolName: "read", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-2", ToolName: "read", IsStarting: true})
tools := c.activeToolDisplays()
if len(tools) != 2 {
t.Fatalf("expected 2 active read calls, got %d (%v)", len(tools), tools)
}
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-1", ToolName: "read", IsStarting: false})
tools = c.activeToolDisplays()
if len(tools) != 1 {
t.Fatalf("expected 1 active read call after finishing one ID, got %d (%v)", len(tools), tools)
}
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolCallID: "call-read-2", ToolName: "read", IsStarting: false})
if len(c.activeToolDisplays()) != 0 {
t.Fatalf("expected no active tools after finishing both IDs, got %v", c.activeToolDisplays())
}
}
// --------------------------------------------------------------------------
// TestStreamComponent_GetRenderedContent verifies the method returns rendered
// text when content is accumulated, and empty string when not.
@@ -621,3 +658,78 @@ func TestStreamComponent_StaleTick_Discarded(t *testing.T) {
t.Fatal("current-gen tick should reschedule")
}
}
// TestStreamComponent_StaleFlushTick_Discarded verifies that flush ticks from a
// previous generation (e.g. pre-Reset) are ignored.
func TestStreamComponent_StaleFlushTick_Discarded(t *testing.T) {
c := newTestStream()
// Start a pending flush and capture its generation.
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "old"})
staleGen := c.flushGeneration
if !c.flushPending {
t.Fatal("precondition: expected flushPending=true after first chunk")
}
// Reset should invalidate in-flight flush ticks.
c.Reset()
if c.flushGeneration == staleGen {
t.Fatal("expected flushGeneration to change after Reset")
}
// New content in a new generation.
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "new"})
if got := c.pendingStream.String(); got != "new" {
t.Fatalf("expected pendingStream='new', got %q", got)
}
// Stale flush tick should be ignored.
c = sendStreamMsg(c, streamFlushTickMsg{generation: staleGen})
if got := c.pendingStream.String(); got != "new" {
t.Fatalf("stale flush tick should not commit pending stream, got %q", got)
}
// Current generation flush should commit.
c = sendStreamMsg(c, streamFlushTickMsg{generation: c.flushGeneration})
if got := c.pendingStream.String(); got != "" {
t.Fatalf("expected pendingStream empty after current flush, got %q", got)
}
if got := c.streamContent.String(); got != "new" {
t.Fatalf("expected streamContent='new' after current flush, got %q", got)
}
}
// TestStreamComponent_ConsumeOverflow_NoHeight verifies that when height is
// unconstrained (0), ConsumeOverflow always returns "".
func TestStreamComponent_ConsumeOverflow_NoOp(t *testing.T) {
c := newTestStream()
// Commit some content directly.
c.streamContent.WriteString("line1\nline2\nline3")
c.phase = streamPhaseActive
// ConsumeOverflow is a no-op in alt screen mode — always returns "".
if got := c.ConsumeOverflow(); got != "" {
t.Fatalf("expected empty from no-op ConsumeOverflow, got %q", got)
}
// Also returns "" with a height set.
c.height = 2
if got := c.ConsumeOverflow(); got != "" {
t.Fatalf("expected empty from no-op ConsumeOverflow with height, got %q", got)
}
}
// TestStreamComponent_GetRenderedContent_ReturnsAll verifies that
// GetRenderedContent returns all accumulated content.
func TestStreamComponent_GetRenderedContent_ReturnsAll(t *testing.T) {
c := newTestStream()
c.renderer = nil
c.phase = streamPhaseActive
c.streamContent.WriteString("a\nb\nc\nd\ne")
got := c.GetRenderedContent()
if got != "a\nb\nc\nd\ne" {
t.Fatalf("expected full content, got %q", got)
}
}
+15 -24
View File
@@ -11,33 +11,26 @@ import (
)
// CLI manages the command-line interface for KIT, providing message rendering,
// user input handling, and display management. It supports both standard and compact
// display modes, handles streaming responses, tracks token usage, and manages the
// overall conversation flow between the user and AI assistants.
// user input handling, and display management. It handles streaming responses,
// tracks token usage, and manages the overall conversation flow between the
// user and AI assistants.
type CLI struct {
renderer Renderer
usageTracker *UsageTracker
width int
compactMode bool
debug bool
modelName string
}
// NewCLI creates and initializes a new CLI instance with the specified display modes.
// The debug parameter enables debug message rendering, while compact enables a more
// condensed display format. Returns an initialized CLI ready for interaction or an
// NewCLI creates and initializes a new CLI instance. The debug parameter enables
// debug message rendering. Returns an initialized CLI ready for interaction or an
// error if initialization fails.
func NewCLI(debug bool, compact bool) (*CLI, error) {
func NewCLI(debug bool) (*CLI, error) {
cli := &CLI{
compactMode: compact,
debug: debug,
debug: debug,
}
cli.updateSize()
if compact {
cli.renderer = NewCompactRenderer(cli.width, debug)
} else {
cli.renderer = newMessageRenderer(cli.width, debug)
}
cli.renderer = newMessageRenderer(cli.width, debug)
return cli, nil
}
@@ -179,9 +172,8 @@ func (c *CLI) DisplayDebugConfig(config map[string]any) {
}
// UpdateUsageFromResponse records token usage using metadata from the fantasy
// response when available. Falls back to text-based estimation if the metadata is
// missing or appears unreliable. This provides more accurate usage tracking when
// providers supply token count information.
// response. Only actual API-reported tokens are used for cost tracking.
// If the provider doesn't report token counts, no usage is recorded.
func (c *CLI) UpdateUsageFromResponse(response *fantasy.Response, inputText string) {
if c.usageTracker == nil {
return
@@ -191,8 +183,9 @@ func (c *CLI) UpdateUsageFromResponse(response *fantasy.Response, inputText stri
inputTokens := int(usage.InputTokens)
outputTokens := int(usage.OutputTokens)
// Validate that the metadata seems reasonable
// Use API-reported tokens if input tokens are available (output may be 0 in some cases)
// Only use actual API-reported tokens for cost tracking.
// We intentionally do NOT estimate tokens - estimation is inaccurate
// and should never be used for cost calculations.
if inputTokens > 0 {
cacheReadTokens := int(usage.CacheReadTokens)
cacheWriteTokens := int(usage.CacheCreationTokens)
@@ -200,11 +193,9 @@ func (c *CLI) UpdateUsageFromResponse(response *fantasy.Response, inputText stri
// Per-response usage is a single API call, so it represents the
// actual context window fill level.
c.usageTracker.SetContextTokens(inputTokens + outputTokens)
} else {
// Fallback to estimation if no metadata is available.
// EstimateAndUpdateUsage sets context tokens internally.
c.usageTracker.EstimateAndUpdateUsage(inputText, response.Content.Text())
}
// If inputTokens is 0, the provider didn't report usage - we skip recording
// rather than estimating, to ensure cost accuracy.
}
// DisplayUsageAfterResponse renders and displays token usage information immediately
+96
View File
@@ -0,0 +1,96 @@
package ui
import (
"fmt"
"runtime"
tea "charm.land/bubbletea/v2"
"github.com/atotto/clipboard"
)
// CopyToClipboard writes text to both the system clipboard and via OSC 52.
// Returns a tea.Cmd that can be used in Bubble Tea's Update flow.
func CopyToClipboard(text string) tea.Cmd {
if text == "" {
return nil
}
return tea.Sequence(
// Method 1: OSC 52 escape sequence (works in modern terminals)
tea.SetClipboard(text),
// Method 2: Native system clipboard (atotto/clipboard)
func() tea.Msg {
// Best effort - ignore errors
_ = clipboard.WriteAll(text)
return nil
},
)
}
// CopyToClipboardWithMessage writes text to clipboard and returns a toast notification.
func CopyToClipboardWithMessage(text string, message string) tea.Cmd {
if text == "" {
return nil
}
return tea.Sequence(
CopyToClipboard(text),
func() tea.Msg {
return ToastMsg{Message: message, Type: ToastInfo}
},
)
}
// ToastType represents the type of toast notification.
type ToastType int
const (
ToastInfo ToastType = iota
ToastSuccess
ToastWarning
ToastError
)
// ToastMsg is a message to display a toast notification.
type ToastMsg struct {
Message string
Type ToastType
}
// IsClipboardSupported returns true if the clipboard is supported on this platform.
func IsClipboardSupported() bool {
// atotto/clipboard supports Linux (with xclip or xsel), macOS, Windows
switch runtime.GOOS {
case "darwin", "windows":
return true
case "linux":
// Check if xclip or xsel is available
// This is a best-effort check
return true
default:
return false
}
}
// CopySelection represents a text selection with start/end positions.
type CopySelection struct {
StartItemIdx int // Index of item where selection starts
StartLine int // Line within item where selection starts
StartCol int // Column where selection starts
EndItemIdx int // Index of item where selection ends
EndLine int // Line within item where selection ends
EndCol int // Column where selection ends
Active bool // Whether selection is currently active
}
// IsEmpty returns true if the selection has no content.
func (s CopySelection) IsEmpty() bool {
return !s.Active || (s.StartItemIdx == s.EndItemIdx && s.StartLine == s.EndLine && s.StartCol == s.EndCol)
}
// String returns a string representation for debugging.
func (s CopySelection) String() string {
return fmt.Sprintf("Selection{item:%d-%d, line:%d-%d, col:%d-%d, active:%v}",
s.StartItemIdx, s.EndItemIdx, s.StartLine, s.EndLine, s.StartCol, s.EndCol, s.Active)
}
-494
View File
@@ -1,494 +0,0 @@
package ui
import (
"fmt"
"strings"
"time"
"charm.land/lipgloss/v2"
)
// CompactRenderer handles rendering messages in a space-efficient compact format,
// optimized for terminals with limited vertical space. It displays messages with
// minimal decorations while maintaining readability and essential information.
type CompactRenderer struct {
width int
debug bool
// getToolRenderer returns extension-provided rendering overrides for a
// specific tool. May be nil if no extensions are loaded. Used in
// RenderToolMessage to check for custom header/body formatting before
// falling back to builtin renderers.
getToolRenderer func(toolName string) *ToolRendererData
}
// NewCompactRenderer creates and initializes a new CompactRenderer with the specified
// terminal width and debug mode setting. The width parameter determines line wrapping,
// while debug enables additional diagnostic output in rendered messages.
func NewCompactRenderer(width int, debug bool) *CompactRenderer {
return &CompactRenderer{
width: width,
debug: debug,
}
}
// SetWidth updates the terminal width for the renderer, affecting how content
// is wrapped and formatted in subsequent render operations.
func (r *CompactRenderer) SetWidth(width int) {
r.width = width
}
// RenderUserMessage renders a user's input message in compact format with a
// distinctive symbol (>) and label. The content is formatted to preserve structure
// 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()
symbol := lipgloss.NewStyle().Foreground(theme.Info).Render(">")
label := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render("User")
// Only run markdown rendering when the message contains code spans or
// fenced code blocks. Plain text is rendered directly so that newlines
// are preserved without the extra paragraph spacing glamour adds.
var compactContent string
if strings.Contains(content, "`") {
mdContent := strings.ReplaceAll(content, "\n", "\n\n")
compactContent = r.formatUserAssistantContent(mdContent)
compactContent = removeBlankLines(compactContent)
} else {
compactContent = content
}
// Handle multi-line content
lines := strings.Split(compactContent, "\n")
var formattedLines []string
for i, line := range lines {
if i == 0 {
// First line includes symbol and label
formattedLines = append(formattedLines, fmt.Sprintf("%s %s %s", symbol, label, line))
} else {
// Subsequent lines without indentation for compact mode
formattedLines = append(formattedLines, line)
}
}
return UIMessage{
Type: UserMessage,
Content: strings.Join(formattedLines, "\n"),
Height: len(formattedLines),
Timestamp: timestamp,
}
}
// RenderAssistantMessage renders an AI assistant's response in compact format with
// a distinctive symbol (<) and the model name as label. Empty content is ignored
// and returns an empty message. Returns a UIMessage with formatted content and metadata.
func (r *CompactRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage {
// Ignore empty responses - don't render anything
compactContent := r.formatUserAssistantContent(content)
if compactContent == "" {
return UIMessage{
Type: AssistantMessage,
Content: "",
Height: 0,
Timestamp: timestamp,
}
}
theme := getTheme()
symbol := lipgloss.NewStyle().Foreground(theme.Primary).Render("<")
// Use the full model name, fallback to "Assistant" if empty
if modelName == "" {
modelName = "Assistant"
}
label := lipgloss.NewStyle().Foreground(theme.Primary).Bold(true).Render(modelName)
// Handle multi-line content
lines := strings.Split(compactContent, "\n")
var formattedLines []string
for i, line := range lines {
if i == 0 {
// First line includes symbol and label
formattedLines = append(formattedLines, fmt.Sprintf("%s %s %s", symbol, label, line))
} else {
// Subsequent lines without indentation for compact mode
formattedLines = append(formattedLines, line)
}
}
return UIMessage{
Type: AssistantMessage,
Content: strings.Join(formattedLines, "\n"),
Height: len(formattedLines),
Timestamp: timestamp,
}
}
// 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()
// Resolve extension renderer once for all overrides.
var extRd *ToolRendererData
if r.getToolRenderer != nil {
extRd = r.getToolRenderer(toolName)
}
// Status icon
var icon string
iconColor := theme.Success
if isError {
icon = "×"
iconColor = theme.Error
} else {
icon = "✓"
}
iconStr := lipgloss.NewStyle().Foreground(iconColor).Bold(true).Render(icon)
// Extension can override display name.
displayName := toolDisplayName(toolName)
if extRd != nil && extRd.DisplayName != "" {
displayName = extRd.DisplayName
}
nameStr := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render(displayName)
// Format params — check extension renderer first.
paramBudget := max(r.width-10-len(displayName), 20)
var params string
if extRd != nil && extRd.RenderHeader != nil {
params = extRd.RenderHeader(toolArgs, paramBudget)
}
if params == "" {
params = formatToolParams(toolArgs, paramBudget)
}
// Build header line
header := iconStr + " " + nameStr
if params != "" {
header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
}
// Format body: check extension renderer first, then compact builtin, then default.
var body string
if extRd != nil && extRd.RenderBody != nil {
body = extRd.RenderBody(toolResult, isError, r.width-4)
// Apply markdown rendering if requested and body is non-empty.
if body != "" && extRd.BodyMarkdown {
body = strings.TrimSuffix(toMarkdown(body, r.width-4), "\n")
}
}
if body == "" {
if isError {
body = lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatToolResult(toolResult))
} else {
// Use compact summary renderers instead of full tool body renderers.
body = renderToolBodyCompact(toolName, toolArgs, toolResult, r.width-4)
if body == "" {
formatted := r.formatToolResult(toolResult)
if formatted == "" {
body = lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render("(no output)")
} else {
body = lipgloss.NewStyle().Foreground(theme.Muted).Render(formatted)
}
}
}
}
// Combine header + indented body
var lines []string
lines = append(lines, header)
if body != "" {
for line := range strings.SplitSeq(body, "\n") {
lines = append(lines, " "+line)
}
}
return UIMessage{
Type: ToolMessage,
Content: strings.Join(lines, "\n"),
Height: len(lines),
}
}
// RenderSystemMessage renders a system notification or informational message in
// 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()
symbol := lipgloss.NewStyle().Foreground(theme.Muted).Render("◇")
label := lipgloss.NewStyle().Foreground(theme.Muted).Bold(true).Render("System")
compactContent := r.formatCompactContent(content)
line := fmt.Sprintf("%s %-8s %s", symbol, label, compactContent)
return UIMessage{
Type: SystemMessage,
Content: line,
Height: 1,
Timestamp: timestamp,
}
}
// RenderErrorMessage renders an error notification in compact format with a
// 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()
symbol := lipgloss.NewStyle().Foreground(theme.Error).Render("!")
label := lipgloss.NewStyle().Foreground(theme.Error).Bold(true).Render("Error")
compactContent := lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatCompactContent(errorMsg))
line := fmt.Sprintf("%s %-8s %s", symbol, label, compactContent)
return UIMessage{
Type: ErrorMessage,
Content: line,
Height: 1,
Timestamp: timestamp,
}
}
// RenderDebugMessage renders diagnostic information in compact format when debug
// 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()
symbol := lipgloss.NewStyle().Foreground(theme.Tool).Render("*")
label := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render("Debug")
// Truncate message if too long
content := message
if len(content) > r.width-20 {
content = content[:r.width-23] + "..."
}
line := fmt.Sprintf("%s %-8s %s", symbol, label, content)
return UIMessage{
Type: SystemMessage,
Content: line,
Height: 1,
Timestamp: timestamp,
}
}
// RenderDebugConfigMessage renders configuration settings in compact format for
// 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()
symbol := lipgloss.NewStyle().Foreground(theme.Tool).Render("*")
label := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render("Debug")
// Format config as compact key=value pairs
var configPairs []string
for key, value := range config {
if value != nil {
configPairs = append(configPairs, fmt.Sprintf("%s=%v", key, value))
}
}
content := strings.Join(configPairs, ", ")
if len(content) > r.width-20 {
content = content[:r.width-23] + "..."
}
line := fmt.Sprintf("%s %-8s %s", symbol, label, content)
return UIMessage{
Type: SystemMessage,
Content: line,
Height: 1,
Timestamp: timestamp,
}
}
// formatCompactContent formats content for compact single-line display
func (r *CompactRenderer) formatCompactContent(content string) string {
if content == "" {
return ""
}
// Remove markdown formatting for compact display
content = strings.ReplaceAll(content, "\n", " ")
content = strings.ReplaceAll(content, "\t", " ")
// Collapse multiple spaces
for strings.Contains(content, " ") {
content = strings.ReplaceAll(content, " ", " ")
}
content = strings.TrimSpace(content)
// Truncate if too long (unless in debug mode)
maxLen := max(
// Reserve space for symbol and label more conservatively
r.width-28,
// Minimum width for readability
40)
if !r.debug && len(content) > maxLen {
content = content[:maxLen-3] + "..."
}
return content
}
// formatUserAssistantContent formats user and assistant content using glamour markdown rendering
func (r *CompactRenderer) formatUserAssistantContent(content string) string {
if content == "" {
return ""
}
// Calculate available width more conservatively
// Account for: symbol (1) + spaces (2) + label (up to 20 chars) + space (1) + margin (4)
availableWidth := max(r.width-28,
// Minimum width for readability
40)
// Use glamour to render markdown content with proper width
rendered := toMarkdown(content, availableWidth)
return strings.TrimSuffix(rendered, "\n")
}
// wrapText wraps text to the specified width, preserving existing line breaks
func (r *CompactRenderer) wrapText(text string, width int) string {
if width <= 0 {
return text
}
lines := strings.Split(text, "\n")
var wrappedLines []string
for _, line := range lines {
if len(line) <= width {
wrappedLines = append(wrappedLines, line)
continue
}
// Wrap long lines
words := strings.Fields(line)
if len(words) == 0 {
wrappedLines = append(wrappedLines, line)
continue
}
currentLine := ""
for _, word := range words {
// If adding this word would exceed the width, start a new line
if len(currentLine)+len(word)+1 > width && currentLine != "" {
wrappedLines = append(wrappedLines, currentLine)
currentLine = word
} else {
if currentLine == "" {
currentLine = word
} else {
currentLine += " " + word
}
}
}
if currentLine != "" {
wrappedLines = append(wrappedLines, currentLine)
}
}
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 == "" {
return ""
}
// Check if this is bash output with stdout/stderr tags
if strings.Contains(result, "<stdout>") || strings.Contains(result, "<stderr>") {
result = r.formatBashOutput(result)
}
// Calculate available width more conservatively
availableWidth := max(r.width-28,
// Minimum width for readability
40)
// First wrap the text to prevent long lines (tool results are usually plain text, not markdown)
wrappedResult := r.wrapText(result, availableWidth)
// Then limit to 5 lines
lines := strings.Split(wrappedResult, "\n")
if len(lines) > 5 {
lines = lines[:5]
// Add truncation indicator
if len(lines) == 5 && lines[4] != "" {
lines[4] = lines[4] + "..."
} else {
lines = append(lines, "...")
}
}
return strings.Join(lines, "\n")
}
// formatBashOutput formats bash command output by removing stdout/stderr tags
// and styling appropriately. Delegates tag parsing to the shared parseBashOutput
// helper.
func (r *CompactRenderer) formatBashOutput(result string) string {
return parseBashOutput(result, getTheme())
}
+26 -40
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.
@@ -292,44 +295,27 @@ 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)
// KitBanner returns the KIT ASCII art title with KITT scanner lights,
// rendered with a KITT red gradient.
func KitBanner() string {
kittDark := lipgloss.Color("#8B0000")
kittBright := lipgloss.Color("#FF2200")
lines := []string{
" ██╗ ██╗ ██╗ ████████╗",
" ██║ ██╔╝ ██║ ╚══██╔══╝",
" █████╔╝ ██║ ██║",
" ██╔═██╗ ██║ ██║",
" ██║ ██╗ ██║ ██║",
" ╚═╝ ╚═╝ ╚═╝ ╚═╝",
" ░░░░░░▒▒▒▒▒▓▓▓▓███████████████▓▓▓▓▒▒▒▒▒░░░░░░",
}
var result strings.Builder
for i, line := range lines {
if i > 0 {
result.WriteString("\n")
}
result.WriteString(ApplyGradient(line, kittDark, kittBright))
}
return result.String()
}
+1 -2
View File
@@ -25,7 +25,6 @@ type CLISetupOptions struct {
Agent AgentInterface
ModelString string
Debug bool
Compact bool
Quiet bool
ShowDebug bool // Whether to show debug config
ProviderAPIKey string // For OAuth detection
@@ -76,7 +75,7 @@ func SetupCLI(opts *CLISetupOptions) (*CLI, error) {
return nil, nil // No CLI in quiet mode
}
cli, err := NewCLI(opts.Debug, opts.Compact)
cli, err := NewCLI(opts.Debug)
if err != nil {
return nil, fmt.Errorf("failed to create CLI: %v", err)
}
+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
}
+6 -6
View File
@@ -7,29 +7,29 @@ import (
"charm.land/lipgloss/v2"
)
// Renderer is the interface satisfied by both MessageRenderer and
// CompactRenderer. It allows model.go and cli.go to call rendering methods
// without branching on compact mode.
// Renderer is the interface satisfied by MessageRenderer. It allows model.go
// and cli.go to call rendering methods uniformly.
type Renderer interface {
RenderUserMessage(content string, timestamp time.Time) UIMessage
RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage
RenderReasoningBlock(content string, timestamp time.Time) UIMessage
RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage
RenderSystemMessage(content string, timestamp time.Time) UIMessage
RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage
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.
// Compile-time check that MessageRenderer satisfies the Renderer interface.
var _ Renderer = (*MessageRenderer)(nil)
var _ Renderer = (*CompactRenderer)(nil)
// parseBashOutput parses <stdout>/<stderr> tagged output from bash tool
// results, styling stderr with the theme's error color. Returns the
// combined, styled output string with tags stripped.
//
// Shared by both MessageRenderer and CompactRenderer.
// Shared by MessageRenderer.
func parseBashOutput(result string, theme Theme) string {
var formattedResult strings.Builder
remaining := result
+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
}
+29 -16
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 (ScrollList, 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
}
}
@@ -493,10 +486,8 @@ func (s *InputComponent) View() tea.View {
view.WriteString("\n")
view.WriteString(inputBoxStyle.Render(s.textarea.View()))
if s.showPopup && len(s.filtered) > 0 {
view.WriteString("\n")
view.WriteString(s.renderPopup())
}
// Popup is now rendered as a centered overlay in AppModel.View()
// instead of inline here to prevent bottom overflow
// Show image attachment indicator when images are pending.
if len(s.pendingImages) > 0 {
@@ -544,7 +535,29 @@ func (s *InputComponent) View() tea.View {
}
// renderPopup renders the autocomplete popup for slash command suggestions.
func (s *InputComponent) renderPopup() string {
// When rendered inline (not centered), returns the styled popup content.
// RenderPopupCentered renders the popup as a centered overlay.
func (s *InputComponent) RenderPopupCentered(termWidth, termHeight int) string {
if !s.showPopup || len(s.filtered) == 0 {
return ""
}
popupContent := s.renderPopupWithOptions(true)
// Center popup using lipgloss.Place
positioned := lipgloss.Place(
termWidth,
termHeight,
lipgloss.Center,
lipgloss.Center,
popupContent,
)
return positioned
}
// renderPopupWithOptions renders the popup content with optional center styling.
func (s *InputComponent) renderPopupWithOptions(centered bool) string {
theme := GetTheme()
popupWidth := max(s.width-4, 20)
popupStyle := lipgloss.NewStyle().
+398
View File
@@ -0,0 +1,398 @@
package ui
import (
"fmt"
"strings"
"time"
"charm.land/lipgloss/v2"
)
// --------------------------------------------------------------------------
// MessageItem implementations for ScrollList
// --------------------------------------------------------------------------
// TextMessageItem represents a completed text message (user or assistant)
// in the scrollback. It uses pre-rendered styled content from MessageRenderer.
type TextMessageItem struct {
id string
role string // "user" or "assistant"
content string // Raw content (for re-rendering if needed)
preRendered string // Pre-rendered styled content from MessageRenderer
timestamp time.Time
}
// NewTextMessageItem creates a new text message for the scrollback.
// The content should be pre-rendered using MessageRenderer for proper styling.
func NewTextMessageItem(id string, role string, content string) *TextMessageItem {
return &TextMessageItem{
id: id,
role: role,
content: content,
timestamp: time.Now(),
}
}
// NewStyledMessageItem creates a message item with pre-rendered styled content.
// This is the preferred way to create messages when you have styled content from MessageRenderer.
func NewStyledMessageItem(id string, role string, rawContent string, preRendered string) *TextMessageItem {
return &TextMessageItem{
id: id,
role: role,
content: rawContent,
preRendered: preRendered,
timestamp: time.Now(),
}
}
func (m *TextMessageItem) ID() string {
return m.id
}
func (m *TextMessageItem) Render(width int) string {
// If we have pre-rendered styled content, return it
if m.preRendered != "" {
return m.preRendered
}
// Fallback to simple formatting if no pre-rendered content
return m.renderContent(width)
}
func (m *TextMessageItem) Height() int {
rendered := m.Render(0) // Width doesn't matter since we use pre-rendered
if rendered == "" {
return 0
}
return strings.Count(rendered, "\n") + 1
}
func (m *TextMessageItem) renderContent(width int) string {
var parts []string
// Role indicator
if m.role == "user" {
parts = append(parts, "│ ▸ You")
} else {
parts = append(parts, "") // Assistant messages start without role
}
// Content with simple wrapping
contentWidth := max(width-4, 20)
for line := range strings.SplitSeq(m.content, "\n") {
if len(line) <= contentWidth {
parts = append(parts, "│ "+line)
} else {
// Basic wrap
for len(line) > contentWidth {
parts = append(parts, "│ "+line[:contentWidth])
line = line[contentWidth:]
}
if len(line) > 0 {
parts = append(parts, "│ "+line)
}
}
}
return strings.Join(parts, "\n")
}
// --------------------------------------------------------------------------
// StreamingMessageItem - Live streaming assistant/reasoning text
// --------------------------------------------------------------------------
// StreamingMessageItem represents actively streaming assistant or reasoning text.
// It accumulates content chunks and re-renders on each update for live display.
type StreamingMessageItem struct {
id string
role string // "assistant" or "reasoning"
content string // Accumulated streaming content
timestamp time.Time
startTime time.Time // When streaming started (for live duration counter)
modelName string
streaming bool // true while actively streaming
finalDuration time.Duration // Frozen duration when complete
cachedRender string
cachedWidth int
}
// NewStreamingMessageItem creates a new streaming message item.
func NewStreamingMessageItem(id, role string, modelName string) *StreamingMessageItem {
now := time.Now()
return &StreamingMessageItem{
id: id,
role: role,
timestamp: now,
startTime: now,
modelName: modelName,
streaming: true,
}
}
// ID returns the unique identifier.
func (s *StreamingMessageItem) ID() string {
return s.id
}
// Render renders the streaming message with live content.
func (s *StreamingMessageItem) Render(width int) string {
// For reasoning, never cache - we need live duration updates
// For assistant, cache is OK
if s.role != "reasoning" && s.cachedWidth == width && s.cachedRender != "" {
return s.cachedRender
}
// Get renderer from context
renderer := newMessageRenderer(width, false)
var rendered string
if s.role == "reasoning" {
// Render as reasoning/thinking block with live duration counter
theme := GetTheme()
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
ty := createTypography(theme)
content := strings.TrimLeft(s.content, " \t\n")
var parts []string
parts = append(parts, mutedStyle.Render(ty.Italic(content)))
// Add live duration counter (updates on each render)
var duration time.Duration
if s.finalDuration > 0 {
// Streaming complete, show frozen duration
duration = s.finalDuration
} else if !s.startTime.IsZero() {
// Still streaming, show live duration
duration = time.Since(s.startTime)
}
if duration > 0 {
var durationStr string
if duration < time.Second {
durationStr = fmt.Sprintf("%dms", duration.Milliseconds())
} else {
durationStr = fmt.Sprintf("%.1fs", duration.Seconds())
}
label := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ")
durationStyled := lipgloss.NewStyle().Foreground(theme.Accent).Render(durationStr)
parts = append(parts, label+durationStyled)
}
rendered = styleMarginBottom1.Render(strings.Join(parts, "\n"))
} else {
// Render as assistant message
msg := renderer.RenderAssistantMessage(s.content, s.timestamp, s.modelName)
rendered = msg.Content
}
// Cache and return (but reasoning is never cached due to live duration)
if s.role != "reasoning" {
s.cachedRender = rendered
s.cachedWidth = width
}
return rendered
}
// Height returns the number of lines.
func (s *StreamingMessageItem) Height() int {
if s.cachedRender == "" {
return 0
}
return strings.Count(s.cachedRender, "\n") + 1
}
// AppendChunk adds a content chunk and invalidates the render cache.
func (s *StreamingMessageItem) AppendChunk(chunk string) {
s.content += chunk
s.cachedWidth = 0 // Invalidate cache
}
// MarkComplete marks the streaming message as complete and freezes the duration.
func (s *StreamingMessageItem) MarkComplete() {
s.streaming = false
// Freeze the duration for reasoning blocks
if s.role == "reasoning" && !s.startTime.IsZero() {
s.finalDuration = time.Since(s.startTime)
}
}
// --------------------------------------------------------------------------
// StreamingBashOutputItem - Live bash command output
// --------------------------------------------------------------------------
// StreamingBashOutputItem represents live bash command output.
type StreamingBashOutputItem struct {
id string
command string
stdoutLines []string
stderrLines []string
maxLines int
complete bool
cachedRender string
cachedWidth int
}
// NewStreamingBashOutputItem creates a new streaming bash output item.
func NewStreamingBashOutputItem(id string, command string) *StreamingBashOutputItem {
return &StreamingBashOutputItem{
id: id,
command: command,
stdoutLines: make([]string, 0),
stderrLines: make([]string, 0),
maxLines: 100, // Cap lines to prevent memory issues
complete: false,
}
}
func (m *StreamingBashOutputItem) ID() string {
return m.id
}
func (m *StreamingBashOutputItem) Render(width int) string {
// Return cached if width matches and complete
if m.complete && m.cachedWidth == width && m.cachedRender != "" {
return m.cachedRender
}
theme := GetTheme()
var parts []string
// Header with command
if m.command != "" {
headerStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true)
parts = append(parts, headerStyle.Render(fmt.Sprintf("▸ %s", m.command)))
}
const lineIndent = " "
lineWidth := width - len(lineIndent)
// Stdout lines
if len(m.stdoutLines) > 0 {
outputStyle := lipgloss.NewStyle().
Foreground(theme.Text).
Background(theme.CodeBg).
PaddingLeft(1).
Width(lineWidth)
for _, line := range m.stdoutLines {
parts = append(parts, lineIndent+outputStyle.Render(line))
}
}
// Stderr lines
if len(m.stderrLines) > 0 {
stderrStyle := lipgloss.NewStyle().
Foreground(theme.Error).
Background(theme.CodeBg).
PaddingLeft(1).
Width(lineWidth)
for _, line := range m.stderrLines {
parts = append(parts, lineIndent+stderrStyle.Render(line))
}
}
result := strings.Join(parts, "\n")
if m.complete {
m.cachedRender = result
m.cachedWidth = width
}
return result
}
func (m *StreamingBashOutputItem) Height() int {
if m.cachedRender != "" {
return strings.Count(m.cachedRender, "\n") + 1
}
// Estimate: command header + stdout + stderr
return 1 + len(m.stdoutLines) + len(m.stderrLines)
}
// AppendStdout adds a stdout line to the output.
func (m *StreamingBashOutputItem) AppendStdout(line string) {
m.stdoutLines = append(m.stdoutLines, line)
// Cap lines
if len(m.stdoutLines) > m.maxLines {
m.stdoutLines = m.stdoutLines[len(m.stdoutLines)-m.maxLines:]
}
m.cachedWidth = 0 // Invalidate cache
}
// AppendStderr adds a stderr line to the output.
func (m *StreamingBashOutputItem) AppendStderr(line string) {
m.stderrLines = append(m.stderrLines, line)
// Cap lines
if len(m.stderrLines) > m.maxLines {
m.stderrLines = m.stderrLines[len(m.stderrLines)-m.maxLines:]
}
m.cachedWidth = 0 // Invalidate cache
}
// MarkComplete marks the bash output as complete.
func (m *StreamingBashOutputItem) MarkComplete() {
m.complete = true
}
// --------------------------------------------------------------------------
// SystemMessageItem - System messages (commands, info, errors)
// --------------------------------------------------------------------------
// SystemMessageItem represents a system message (commands, info, errors).
type SystemMessageItem struct {
id string
content string
timestamp time.Time
cachedRender string
cachedWidth int
}
// NewSystemMessageItem creates a new system message for the scrollback.
func NewSystemMessageItem(id, content string) *SystemMessageItem {
return &SystemMessageItem{
id: id,
content: content,
timestamp: time.Now(),
}
}
func (m *SystemMessageItem) ID() string {
return m.id
}
func (m *SystemMessageItem) Render(width int) string {
// Return cached render if width matches
if m.cachedWidth == width && m.cachedRender != "" {
return m.cachedRender
}
// Simple system message formatting
rendered := "│ " + strings.ReplaceAll(m.content, "\n", "\n│ ")
// Cache and return
m.cachedRender = rendered
m.cachedWidth = width
return rendered
}
func (m *SystemMessageItem) Height() int {
if m.cachedRender != "" {
return strings.Count(m.cachedRender, "\n") + 1
}
// Estimate
if m.cachedWidth > 0 {
return (len(m.content) / max(m.cachedWidth-10, 40)) + 3
}
return 3
}
// --------------------------------------------------------------------------
// Helper: generateMessageID
// --------------------------------------------------------------------------
var messageCounter = 0
func generateMessageID() string {
messageCounter++
return fmt.Sprintf("msg-%d-%d", time.Now().UnixNano(), messageCounter)
}
+156 -377
View File
@@ -3,17 +3,14 @@ package ui
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"time"
"charm.land/lipgloss/v2"
"github.com/indaco/herald"
)
// ansiEscapeRe matches ANSI escape sequences used for terminal styling.
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// MessageType represents different categories of messages displayed in the UI,
// each with distinct visual styling and formatting rules.
type MessageType int
@@ -22,9 +19,9 @@ const (
UserMessage MessageType = iota
AssistantMessage
ToolMessage
ToolCallMessage // New type for showing tool calls in progress
SystemMessage // New type for KIT system messages (help, tools, etc.)
ErrorMessage // New type for error messages
ToolCallMessage
SystemMessage
ErrorMessage
)
// UIMessage encapsulates a fully rendered message ready for display in the UI,
@@ -40,29 +37,9 @@ type UIMessage struct {
Streaming bool
}
// Helper functions to get theme colors
func getTheme() Theme {
return GetTheme()
}
// toolDisplayNames maps raw tool names to human-friendly display names.
var toolDisplayNames = map[string]string{
"bash": "Bash",
"read": "Read",
"write": "Write",
"edit": "Edit",
"grep": "Grep",
"find": "Find",
"ls": "Ls",
"run_shell_cmd": "Bash",
}
// toolDisplayName returns a human-friendly display name for a tool.
// Falls back to capitalizing the first letter of the raw name.
// 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:]
}
@@ -70,8 +47,6 @@ func toolDisplayName(rawName string) string {
}
// formatToolParams formats tool input parameters for inline header display.
// Extracts the primary parameter (command/filePath) first, then shows
// remaining params as (key=val, ...). Truncates to maxWidth.
func formatToolParams(toolArgs string, maxWidth int) string {
args := strings.TrimSpace(toolArgs)
if args == "" || args == "{}" {
@@ -80,7 +55,6 @@ func formatToolParams(toolArgs string, maxWidth int) string {
var params map[string]any
if err := json.Unmarshal([]byte(args), &params); err != nil {
// Fallback: strip braces and return raw content
args = strings.TrimPrefix(args, "{")
args = strings.TrimSuffix(args, "}")
args = strings.TrimSpace(args)
@@ -94,7 +68,6 @@ func formatToolParams(toolArgs string, maxWidth int) string {
return ""
}
// Identify primary parameter by checking known keys in priority order
primaryKeys := []string{"command", "filePath", "path", "pattern", "query", "url"}
var primaryKey string
var primaryVal string
@@ -111,14 +84,13 @@ func formatToolParams(toolArgs string, maxWidth int) string {
result.WriteString(primaryVal)
}
// Collect remaining parameters, skipping body-content keys (already
// rendered in the tool body) and any values that are too large.
bodyKeys := map[string]bool{
"content": true,
"old_text": true,
"new_text": true,
"oldText": true,
"newText": true,
"edits": true,
"todos": true,
}
var remaining []string
@@ -154,65 +126,35 @@ func formatToolParams(toolArgs string, maxWidth int) string {
}
// MessageRenderer handles the formatting and rendering of different message types
// with consistent styling, markdown support, and appropriate visual hierarchies
// for the standard (non-compact) display mode.
type MessageRenderer struct {
width int
debug bool
// getToolRenderer returns extension-provided rendering overrides for a
// specific tool. May be nil if no extensions are loaded. Used in
// RenderToolMessage to check for custom header/body formatting before
// falling back to builtin renderers.
width int
debug bool
ty *herald.Typography
getToolRenderer func(toolName string) *ToolRendererData
}
// newMessageRenderer creates and initializes a new MessageRenderer with the specified
// terminal width and debug mode setting. The width parameter determines line wrapping
// and layout calculations.
// newMessageRenderer creates and initializes a new MessageRenderer
func newMessageRenderer(width int, debug bool) *MessageRenderer {
return &MessageRenderer{
width: width,
debug: debug,
ty: createTypography(GetTheme()),
}
}
// SetWidth updates the terminal width for the renderer, affecting how content
// is wrapped and formatted in subsequent render operations.
// SetWidth updates the terminal width for the renderer
func (r *MessageRenderer) SetWidth(width int) {
r.width = width
}
// RenderUserMessage renders a user's input message with distinctive right-aligned
// formatting, including the system username, timestamp, and markdown-rendered content.
// The message is displayed with a colored right border for visual distinction.
// RenderUserMessage renders a user's input message using herald Tip alert
func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
theme := getTheme()
// Only run markdown rendering when the message contains code spans or
// fenced code blocks. Plain text is rendered directly so that newlines
// are preserved without the extra paragraph spacing glamour adds.
var messageContent string
if strings.Contains(content, "`") {
// Glamour treats single \n as a soft break, so convert to paragraph
// breaks and collapse the resulting blank lines after rendering.
mdContent := strings.ReplaceAll(content, "\n", "\n\n")
messageContent = r.renderMarkdown(mdContent, r.width-8)
messageContent = removeBlankLines(messageContent)
} else {
messageContent = content
if strings.TrimSpace(content) == "" {
content = "(empty message)"
}
fullContent := strings.TrimSuffix(messageContent, "\n")
// Left border with Blue color for user messages.
rendered := renderContentBlock(
fullContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Info),
WithMarginBottom(1),
)
rendered := r.ty.Tip(content)
rendered = styleMarginBottom1.Render(rendered)
return UIMessage{
Type: UserMessage,
@@ -222,12 +164,8 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
}
}
// RenderAssistantMessage renders an AI assistant's response with left-aligned formatting,
// including the model name, timestamp, and markdown-rendered content. Empty responses
// are ignored and return an empty message. The message features a colored left border
// for visual distinction.
// RenderAssistantMessage renders an AI assistant's response
func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage {
// Ignore empty responses - don't render anything
if strings.TrimSpace(content) == "" {
return UIMessage{
Type: AssistantMessage,
@@ -237,17 +175,9 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
}
}
theme := getTheme()
messageContent := r.renderMarkdown(content, r.width-8)
fullContent := strings.TrimSuffix(messageContent, "\n")
// Left border with Primary (Mauve) color for assistant messages.
rendered := renderContentBlock(
fullContent,
r.width,
WithBorderColor(theme.Primary),
WithMarginBottom(1),
)
// Use markdown rendering with Chroma syntax highlighting
rendered := toMarkdown(content, r.width-4)
rendered = styleMarginBottom1.Render(rendered)
return UIMessage{
Type: AssistantMessage,
@@ -257,30 +187,44 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
}
}
// RenderSystemMessage renders KIT system messages such as help text, command outputs,
// and informational notifications. These messages are displayed with a distinctive system
// color border and "KIT System" label to differentiate them from user and AI content.
func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
theme := getTheme()
var messageContent string
// RenderReasoningBlock renders a reasoning/thinking block with the same styling
// as live streaming: muted italic text with margin. This is used when resuming
// sessions to display saved reasoning content.
func (r *MessageRenderer) RenderReasoningBlock(content string, timestamp time.Time) UIMessage {
if strings.TrimSpace(content) == "" {
messageContent = "No content available"
} else if strings.Contains(content, "`") {
messageContent = r.renderMarkdown(content, r.width-8)
} else {
messageContent = content
return UIMessage{
Type: AssistantMessage,
Content: "",
Height: 0,
Timestamp: timestamp,
}
}
fullContent := "◇ " + strings.TrimSuffix(messageContent, "\n")
theme := GetTheme()
// Match live streaming styling: muted italic text
// Same as stream.go renderReasoningBlock()
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
contentStr := strings.TrimLeft(strings.Join(lines, "\n"), " \t\n")
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
rendered := mutedStyle.Render(r.ty.Italic(contentStr))
rendered = styleMarginBottom1.Render(rendered)
rendered := renderContentBlock(
fullContent,
r.width,
WithNoBorder(),
WithForeground(theme.Muted),
WithMarginBottom(1),
)
return UIMessage{
Type: AssistantMessage,
Content: rendered,
Height: lipgloss.Height(rendered),
Timestamp: timestamp,
}
}
// RenderSystemMessage renders KIT system messages using herald Note alert
func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
if strings.TrimSpace(content) == "" {
content = "No content available"
}
rendered := r.ty.Note(content)
rendered = styleMarginBottom1.Render(rendered)
return UIMessage{
Type: SystemMessage,
@@ -290,27 +234,9 @@ func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Tim
}
}
// RenderDebugMessage renders diagnostic and debugging information with special formatting
// including a debug icon, colored border, and structured layout. Debug messages are only
// displayed when debug mode is enabled and help developers troubleshoot issues.
// RenderDebugMessage renders diagnostic and debugging information
func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time) UIMessage {
baseStyle := lipgloss.NewStyle()
theme := getTheme()
style := baseStyle.
Width(r.width - 3).
BorderLeft(true).
Foreground(theme.Muted).
BorderForeground(theme.Tool).
BorderStyle(lipgloss.ThickBorder()).
PaddingLeft(1).
MarginLeft(2).
MarginBottom(1)
header := baseStyle.
Foreground(theme.Tool).
Bold(true).
Render("🔍 Debug Output")
header := r.ty.H6("🔍 Debug Output")
lines := strings.Split(message, "\n")
var formattedLines []string
@@ -320,87 +246,52 @@ func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time
}
}
content := baseStyle.
Foreground(theme.Muted).
Render(strings.Join(formattedLines, "\n"))
fullContent := lipgloss.JoinVertical(lipgloss.Left,
content := r.ty.Compose(
header,
content,
r.ty.P(strings.Join(formattedLines, "\n")),
)
content = styleMarginBottom1.Render(content)
return UIMessage{
Content: style.Render(fullContent),
Height: lipgloss.Height(style.Render(fullContent)),
Content: content,
Height: lipgloss.Height(content),
}
}
// RenderDebugConfigMessage renders configuration settings in a formatted debug display
// with key-value pairs shown in a structured layout. Used to display runtime configuration
// for debugging purposes with a distinctive icon and border styling.
// RenderDebugConfigMessage renders configuration settings
func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage {
baseStyle := lipgloss.NewStyle()
theme := getTheme()
style := baseStyle.
Width(r.width - 1).
BorderLeft(true).
Foreground(theme.Muted).
BorderForeground(theme.Tool).
BorderStyle(lipgloss.ThickBorder()).
PaddingLeft(1)
header := baseStyle.
Foreground(theme.Tool).
Bold(true).
Render("🔧 Debug Configuration")
header := r.ty.H6("🔧 Debug Configuration")
var configLines []string
for key, value := range config {
if value != nil {
configLines = append(configLines, fmt.Sprintf(" %s: %v", key, value))
configLines = append(configLines, fmt.Sprintf("%s: %v", key, value))
}
}
configContent := baseStyle.
Foreground(theme.Muted).
Render(strings.Join(configLines, "\n"))
parts := []string{header}
var content string
if len(configLines) > 0 {
parts = append(parts, configContent)
content = r.ty.Compose(
header,
r.ty.P(strings.Join(configLines, "\n")),
)
} else {
content = header
}
rendered := style.Render(
lipgloss.JoinVertical(lipgloss.Left, parts...),
)
content = styleMarginBottom1.Render(content)
return UIMessage{
Type: SystemMessage,
Content: rendered,
Height: lipgloss.Height(rendered),
Content: content,
Height: lipgloss.Height(content),
Timestamp: timestamp,
}
}
// RenderErrorMessage renders error notifications with distinctive red coloring and
// bold text to ensure visibility. Error messages include timestamp information and
// are displayed with an error-colored border for immediate recognition.
// RenderErrorMessage renders error notifications
func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage {
theme := getTheme()
errorContent := lipgloss.NewStyle().
Foreground(theme.Error).
Bold(true).
Render(errorMsg)
rendered := renderContentBlock(
errorContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Error),
WithMarginBottom(1),
)
rendered := r.ty.Caution(errorMsg)
rendered = styleMarginBottom1.Render(rendered)
return UIMessage{
Type: ErrorMessage,
@@ -410,93 +301,18 @@ func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Tim
}
}
// RenderToolCallMessage renders a notification that a tool is being executed, showing
// the tool name, formatted arguments (if any), and execution timestamp. The message
// uses tool-specific coloring to distinguish it from regular conversation messages.
func (r *MessageRenderer) RenderToolCallMessage(toolName, toolArgs string, timestamp time.Time) UIMessage {
// Format timestamp
timeStr := timestamp.Local().Format("15:04")
// Format arguments with better presentation
theme := getTheme()
var argsContent string
if toolArgs != "" && toolArgs != "{}" {
argsContent = lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true).
Render(fmt.Sprintf("Arguments: %s", r.formatToolArgs(toolArgs)))
}
// Create info line
info := fmt.Sprintf(" Executing %s (%s)", toolName, timeStr)
// Combine parts
var fullContent string
if argsContent != "" {
fullContent = argsContent + "\n" +
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
} else {
fullContent = lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
}
// Use the new block renderer
rendered := renderContentBlock(
fullContent,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Tool),
WithMarginBottom(1),
)
return UIMessage{
Type: ToolCallMessage,
Content: rendered,
Height: lipgloss.Height(rendered),
Timestamp: timestamp,
}
}
// RenderToolMessage renders a unified tool block combining the tool invocation
// header (icon + display name + params) with the execution result body. The
// border color indicates status: green for success, red for error. This replaces
// the previous two-block approach (separate call + result blocks).
// RenderToolMessage renders a unified tool block
func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage {
theme := getTheme()
// Resolve extension renderer once for all overrides.
var extRd *ToolRendererData
if r.getToolRenderer != nil {
extRd = r.getToolRenderer(toolName)
}
// --- Header: [icon] [name] [params] ---
var icon string
borderColor := theme.Success
iconColor := theme.Success
if isError {
icon = "×"
borderColor = theme.Error
iconColor = theme.Error
} else {
icon = "✓"
}
// Extension can override border color (applies to both success and error).
if extRd != nil && extRd.BorderColor != "" {
borderColor = lipgloss.Color(extRd.BorderColor)
}
iconStr := lipgloss.NewStyle().Foreground(iconColor).Bold(true).Render(icon)
// Extension can override display name.
displayName := toolDisplayName(toolName)
if extRd != nil && extRd.DisplayName != "" {
displayName = extRd.DisplayName
}
nameStr := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render(displayName)
// Format params with width budget for the header line.
// Check extension renderer for custom header params first.
paramBudget := max(r.width-10-len(displayName), 20)
var params string
if extRd != nil && extRd.RenderHeader != nil {
@@ -506,97 +322,70 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
params = formatToolParams(toolArgs, paramBudget)
}
header := iconStr + " " + nameStr
if params != "" {
header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
var icon string
iconColor := GetTheme().Success
if isError {
icon = "×"
iconColor = GetTheme().Error
} else {
icon = "✓"
}
// --- Body: check extension renderer first, then builtin, then default ---
// Style the tool name with color
theme := GetTheme()
nameColor := theme.Info
if isError {
nameColor = theme.Error
}
styledName := lipgloss.NewStyle().Foreground(nameColor).Bold(true).Render(displayName)
styledIcon := lipgloss.NewStyle().Foreground(iconColor).Render(icon)
// Build the content: icon + name + params on first line, then body
headerLine := styledIcon + " " + styledName
if params != "" {
headerLine += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
}
// Get body content
var body string
if extRd != nil && extRd.RenderBody != nil {
body = extRd.RenderBody(toolResult, isError, r.width-8)
// Apply markdown rendering if requested and body is non-empty.
if body != "" && extRd.BodyMarkdown {
body = strings.TrimSuffix(toMarkdown(body, r.width-8), "\n")
}
}
if body == "" {
if isError {
body = lipgloss.NewStyle().
Foreground(theme.Error).
Render(toolResult)
body = r.formatToolResult(toolName, toolResult)
} else {
body = renderToolBody(toolName, toolArgs, toolResult, r.width-8)
if body == "" {
body = r.formatToolResult(toolName, toolResult, r.width-8)
body = r.formatToolResult(toolName, toolResult)
}
}
}
if strings.TrimSpace(body) == "" {
body = lipgloss.NewStyle().
Italic(true).
Foreground(theme.Muted).
Render("(no output)")
body = r.ty.Italic("(no output)")
}
// Combine header + body into a single block.
fullContent := header + "\n\n" + strings.TrimSuffix(body, "\n")
// Build rendering options; extension can override background.
blockOpts := []renderingOption{
WithAlign(lipgloss.Left),
WithBorderColor(borderColor),
WithMarginBottom(1),
}
if extRd != nil && extRd.Background != "" {
blockOpts = append(blockOpts, WithBackground(lipgloss.Color(extRd.Background)))
}
rendered := renderContentBlock(
fullContent,
r.width,
blockOpts...,
// Compose: icon + name + params, then body
fullContent := r.ty.Compose(
headerLine,
"",
body,
)
fullContent = styleMarginBottom1.Render(fullContent)
return UIMessage{
Type: ToolMessage,
Content: rendered,
Height: lipgloss.Height(rendered),
Content: fullContent,
Height: lipgloss.Height(fullContent),
}
}
// formatToolArgs formats tool arguments for display
func (r *MessageRenderer) formatToolArgs(args string) string {
// Remove outer braces and clean up JSON formatting
args = strings.TrimSpace(args)
if strings.HasPrefix(args, "{") && strings.HasSuffix(args, "}") {
args = strings.TrimPrefix(args, "{")
args = strings.TrimSuffix(args, "}")
args = strings.TrimSpace(args)
}
// If it's empty after cleanup, return a placeholder
if args == "" {
return "(no arguments)"
}
// Truncate if too long, but skip truncation in debug mode
if !r.debug {
maxLen := 100
if len(args) > maxLen {
return args[:maxLen] + "..."
}
}
return args
}
// formatToolResult formats tool results based on tool type
func (r *MessageRenderer) formatToolResult(toolName, result string, width int) string {
baseStyle := lipgloss.NewStyle()
// Truncate very long results only if not in debug mode
func (r *MessageRenderer) formatToolResult(toolName, result string) string {
if !r.debug {
maxLines := 10
lines := strings.Split(result, "\n")
@@ -605,58 +394,48 @@ func (r *MessageRenderer) formatToolResult(toolName, result string, width int) s
}
}
// Format bash/command output with better formatting
if strings.Contains(toolName, "bash") || strings.Contains(toolName, "command") || strings.Contains(toolName, "shell") || toolName == "run_shell_cmd" {
theme := getTheme()
// Split result into sections if it contains both stdout and stderr
if strings.Contains(toolName, "bash") || strings.Contains(toolName, "command") ||
strings.Contains(toolName, "shell") {
if strings.Contains(result, "<stdout>") || strings.Contains(result, "<stderr>") {
return r.formatBashOutput(result, width, theme)
}
// For simple output, just render as monospace text with proper line breaks
return baseStyle.
Width(width).
Foreground(theme.Muted).
Render(result)
}
// For other tools, render as muted text
theme := getTheme()
return baseStyle.
Width(width).
Foreground(theme.Muted).
Render(result)
}
// formatBashOutput formats bash command output with proper section handling.
// Delegates tag parsing to the shared parseBashOutput helper.
func (r *MessageRenderer) formatBashOutput(result string, width int, theme Theme) string {
parsed := parseBashOutput(result, theme)
return lipgloss.NewStyle().
Width(width).
Foreground(theme.Muted).
Render(parsed)
}
// renderMarkdown renders markdown content using glamour
func (r *MessageRenderer) renderMarkdown(content string, width int) string {
rendered := toMarkdown(content, width)
return strings.TrimSuffix(rendered, "\n")
}
// removeBlankLines removes lines that are visually blank from rendered output.
// Glamour wraps every character (including padding spaces) with ANSI color
// codes, so we must strip escape sequences before checking whether a line is
// empty. This collapses paragraph spacing so user messages render without
// extra vertical gaps.
func removeBlankLines(s string) string {
lines := strings.Split(s, "\n")
filtered := lines[:0]
for _, line := range lines {
if strings.TrimSpace(ansiEscapeRe.ReplaceAllString(line, "")) != "" {
filtered = append(filtered, line)
return parseBashOutput(result, GetTheme())
}
}
return strings.Join(filtered, "\n")
return result
}
// createTypography creates a typography instance from theme
func createTypography(theme Theme) *herald.Typography {
return herald.New(
herald.WithPalette(herald.ColorPalette{
Primary: theme.Primary,
Secondary: theme.Secondary,
Tertiary: theme.Info,
Accent: theme.Accent,
Highlight: theme.Highlight,
Muted: theme.Muted,
Text: theme.Text,
Surface: theme.Background,
Base: theme.CodeBg,
}),
herald.WithAlertPalette(herald.AlertPalette{
Note: theme.Info,
Tip: theme.Success,
Important: theme.Accent,
Warning: theme.Warning,
Caution: theme.Error,
}),
herald.WithCodeLineNumbers(true),
// Customize alert labels
herald.WithAlertLabel(herald.AlertNote, "Info"),
herald.WithAlertLabel(herald.AlertTip, "You"),
herald.WithAlertLabel(herald.AlertWarning, "Working"),
herald.WithAlertLabel(herald.AlertCaution, "Error"),
)
}
// 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())
}
+691 -442
View File
File diff suppressed because it is too large Load Diff
+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
+105 -48
View File
@@ -5,9 +5,9 @@ import (
"testing"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/session"
kit "github.com/mark3labs/kit/pkg/kit"
)
// --------------------------------------------------------------------------
@@ -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
}
@@ -66,7 +70,7 @@ func (s *stubAppController) AddContextMessage(_ string) {
// no-op in tests
}
func (s *stubAppController) RunWithFiles(prompt string, _ []fantasy.FilePart) int {
func (s *stubAppController) RunWithFiles(prompt string, _ []kit.LLMFilePart) int {
s.runCalls = append(s.runCalls, prompt)
return s.queueLen
}
@@ -83,7 +87,6 @@ func (s *stubAppController) Steer(prompt string) int {
// stubStreamComponent satisfies streamComponentIface without rendering anything.
type stubStreamComponent struct {
resetCalled int
height int
lastMsg tea.Msg
renderedContent string // returned by GetRenderedContent
}
@@ -95,11 +98,11 @@ func (s *stubStreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
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) 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 {
@@ -126,11 +129,12 @@ func newTestAppModel(ctrl AppController) (*AppModel, *stubStreamComponent, *stub
stream: stream,
input: input,
renderer: newMessageRenderer(80, false),
compactMode: false,
modelName: "test-model",
width: 80,
height: 24,
streamingBashMaxLines: 50, // Initialize buffer cap like NewAppModel does
scrollList: NewScrollList(80, 20),
messages: []MessageItem{},
}
return m, stream, input
}
@@ -142,7 +146,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
}
// --------------------------------------------------------------------------
@@ -408,7 +416,7 @@ func TestQueuedMessages_storedOnQueuedSubmit(t *testing.T) {
if m.queuedMessages[0] != "queued prompt" {
t.Fatalf("expected queued message text 'queued prompt', got %q", m.queuedMessages[0])
}
// Should NOT produce a tea.Println cmd (message is anchored, not in scrollback).
// Should NOT flush (message is anchored in ScrollList).
if cmd != nil {
t.Fatal("expected nil cmd for queued submit (message should not print to scrollback)")
}
@@ -498,19 +506,19 @@ func TestWindowResize_propagatesToStream(t *testing.T) {
// sets the stream height after a resize.
func TestWindowResize_distributeHeight(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m, _, _ := newTestAppModel(ctrl)
// With height=30, stream height = 30 - 1 (separator) - 9 (input) - 1 (statusBar) = 19
// With height=30, scroll height = 30 - 1 (separator) - 9 (input) - 1 (statusBar) = 19
m = sendMsg(m, tea.WindowSizeMsg{Width: 80, Height: 30})
_ = m
if stream.height != 19 {
t.Fatalf("expected stream height=19, got %d", stream.height)
if m.scrollList.height != 19 {
t.Fatalf("expected scroll list height=19, got %d", m.scrollList.height)
}
}
// --------------------------------------------------------------------------
// tea.Println on step complete
// Step complete behavior
// --------------------------------------------------------------------------
// TestStepComplete_preservesStreamContent verifies that StepCompleteEvent
@@ -543,65 +551,87 @@ func TestStepComplete_noStreamContent_noCmd(t *testing.T) {
}
}
// TestSubmitMsg_printsUserMessage verifies that submitMsg produces a tea.Println
// cmd for the user message.
// TestSubmitMsg_printsUserMessage verifies that submitMsg adds the user message
// to the ScrollList messages and triggers a layout update.
func TestSubmitMsg_printsUserMessage(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
_, cmd := m.Update(submitMsg{Text: "user query"})
m = sendMsg(m, submitMsg{Text: "user query"})
if cmd == nil {
t.Fatal("expected non-nil cmd (tea.Println) for user message on submitMsg")
// In alt screen mode, user messages are added to the in-memory ScrollList
// rather than printed separately. Verify the message was added.
found := false
for _, msg := range m.messages {
if tm, ok := msg.(*TextMessageItem); ok && tm.role == "user" && tm.content == "user query" {
found = true
break
}
}
if !found {
t.Fatal("expected user message 'user query' in ScrollList messages")
}
}
// TestToolCallStarted_flushesOnly verifies that ToolCallStartedEvent flushes
// accumulated stream content but does NOT print a tool call block (the unified
// block is printed later on ToolResultEvent).
// TestToolCallStarted_flushesOnly verifies that ToolCallStartedEvent marks
// any active StreamingMessageItem as complete and resets the stream.
func TestToolCallStarted_flushesOnly(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m.state = stateWorking
// With no stream content, flush returns nil → cmd should be nil.
_, cmd := m.Update(app.ToolCallStartedEvent{
// With no stream content, nothing should change.
initialCount := len(m.messages)
m = sendMsg(m, app.ToolCallStartedEvent{
ToolName: "bash",
ToolArgs: `{"cmd":"ls"}`,
})
if cmd != nil {
t.Fatal("expected nil cmd on ToolCallStartedEvent with no stream content")
if len(m.messages) != initialCount {
t.Fatal("expected no new messages on ToolCallStartedEvent with no stream content")
}
// With stream content, flush returns tea.Println → cmd should be non-nil.
// Simulate a StreamingMessageItem already in messages (as if appendStreamingChunk was called)
// plus the stream component having rendered content.
streamItem := NewStreamingMessageItem("stream-1", "assistant", "test-model")
streamItem.AppendChunk("partial text")
m.messages = append(m.messages, streamItem)
stream.renderedContent = "partial text"
_, cmd = m.Update(app.ToolCallStartedEvent{
_ = sendMsg(m, app.ToolCallStartedEvent{
ToolName: "bash",
ToolArgs: `{"cmd":"ls"}`,
})
if cmd == nil {
t.Fatal("expected non-nil cmd on ToolCallStartedEvent with stream content to flush")
// The StreamingMessageItem should have been marked complete.
if streamItem.streaming {
t.Fatal("expected StreamingMessageItem to be marked complete after ToolCallStartedEvent")
}
// Stream should have been reset.
if stream.resetCalled == 0 {
t.Fatal("expected stream.Reset() to be called")
}
}
// TestToolResult_printsAndStartsSpinner verifies that ToolResultEvent produces
// a non-nil cmd and the stream receives a SpinnerEvent.
// TestToolResult_printsAndStartsSpinner verifies that ToolResultEvent adds
// the tool result to the ScrollList and the stream receives a SpinnerEvent.
func TestToolResult_printsAndStartsSpinner(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m.state = stateWorking
_, cmd := m.Update(app.ToolResultEvent{
initialCount := len(m.messages)
m = sendMsg(m, app.ToolResultEvent{
ToolName: "bash",
ToolArgs: "{}",
Result: "output",
IsError: false,
})
if cmd == nil {
t.Fatal("expected non-nil cmd on ToolResultEvent")
// Tool result should have been added to ScrollList messages.
if len(m.messages) <= initialCount {
t.Fatal("expected tool result message added to ScrollList")
}
// Stream should have received a SpinnerEvent to start spinner for next LLM call.
if stream.lastMsg == nil {
@@ -613,7 +643,7 @@ func TestToolResult_printsAndStartsSpinner(t *testing.T) {
}
// TestToolOutputEvent_accumulatesBashOutput verifies that ToolOutputEvent
// accumulates stdout and stderr lines into the streaming bash output buffers.
// accumulates stdout and stderr lines into a StreamingBashOutputItem in the ScrollList.
func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
@@ -627,11 +657,22 @@ func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
IsStderr: false,
})
if len(m.streamingBashOutput) != 1 || m.streamingBashOutput[0] != "line one\n" {
t.Fatalf("expected streamingBashOutput=['line one\\n'], got %v", m.streamingBashOutput)
// Should have created a StreamingBashOutputItem in messages.
var bashItem *StreamingBashOutputItem
for _, msg := range m.messages {
if item, ok := msg.(*StreamingBashOutputItem); ok {
bashItem = item
break
}
}
if len(m.streamingBashStderr) != 0 {
t.Fatalf("expected empty streamingBashStderr, got %v", m.streamingBashStderr)
if bashItem == nil {
t.Fatal("expected StreamingBashOutputItem in messages after ToolOutputEvent")
}
if len(bashItem.stdoutLines) != 1 || bashItem.stdoutLines[0] != "line one\n" {
t.Fatalf("expected stdout=['line one\\n'], got %v", bashItem.stdoutLines)
}
if len(bashItem.stderrLines) != 0 {
t.Fatalf("expected empty stderr, got %v", bashItem.stderrLines)
}
// Send another stdout chunk.
@@ -642,8 +683,15 @@ func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
IsStderr: false,
})
if len(m.streamingBashOutput) != 2 {
t.Fatalf("expected 2 stdout lines, got %d", len(m.streamingBashOutput))
// Re-find the bash item (same item, updated)
bashItem = nil
for _, msg := range m.messages {
if item, ok := msg.(*StreamingBashOutputItem); ok {
bashItem = item
}
}
if bashItem == nil || len(bashItem.stdoutLines) != 2 {
t.Fatalf("expected 2 stdout lines, got %d", len(bashItem.stdoutLines))
}
// Send stderr chunk.
@@ -654,11 +702,17 @@ func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
IsStderr: true,
})
if len(m.streamingBashStderr) != 1 {
t.Fatalf("expected 1 stderr line, got %d", len(m.streamingBashStderr))
bashItem = nil
for _, msg := range m.messages {
if item, ok := msg.(*StreamingBashOutputItem); ok {
bashItem = item
}
}
if m.streamingBashStderr[0] != "error: something failed\n" {
t.Fatalf("expected stderr 'error: something failed\\n', got %q", m.streamingBashStderr[0])
if bashItem == nil || len(bashItem.stderrLines) != 1 {
t.Fatalf("expected 1 stderr line, got %d", len(bashItem.stderrLines))
}
if bashItem.stderrLines[0] != "error: something failed\n" {
t.Fatalf("expected stderr 'error: something failed\\n', got %q", bashItem.stderrLines[0])
}
}
@@ -740,16 +794,19 @@ func TestToolCallStarted_nonBashTool_doesNotSetCommand(t *testing.T) {
}
// TestStepError_printCmd verifies that StepErrorEvent with a non-nil error
// produces a non-nil cmd (the tea.Println call for the error message).
// adds an error message to the ScrollList.
func TestStepError_printCmd(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
_, cmd := m.Update(app.StepErrorEvent{Err: errors.New("agent failed")})
initialCount := len(m.messages)
if cmd == nil {
t.Fatal("expected non-nil cmd (tea.Println) on StepErrorEvent with error")
m = sendMsg(m, app.StepErrorEvent{Err: errors.New("agent failed")})
// Error should have been added to ScrollList messages.
if len(m.messages) <= initialCount {
t.Fatal("expected error message added to ScrollList on StepErrorEvent")
}
}
+16 -5
View File
@@ -118,22 +118,33 @@ func (m ProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// status information and help text. Displays error messages if present or
// a completion message when the download finishes.
func (m ProgressModel) View() tea.View {
var v tea.View
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
if m.err != nil {
return tea.NewView(fmt.Sprintf("Error: %s\n", m.err.Error()))
v.Content = fmt.Sprintf("Error: %s\n", m.err.Error())
return v
}
if m.complete {
return tea.NewView(fmt.Sprintf("\n%s%s\n\n%sComplete!\n",
v.Content = fmt.Sprintf("\n%s%s\n\n%sComplete!\n",
strings.Repeat(" ", padding),
m.progress.View(),
strings.Repeat(" ", padding)))
strings.Repeat(" ", padding))
return v
}
pad := strings.Repeat(" ", padding)
return tea.NewView(fmt.Sprintf("\n%s%s\n%s%s\n\n%s",
v.Content = fmt.Sprintf("\n%s%s\n%s%s\n\n%s",
pad, m.progress.View(),
pad, m.status,
pad+helpStyle("Press 'q' or Ctrl+C to cancel")))
pad+helpStyle("Press 'q' or Ctrl+C to cancel"))
return v
}
// ProgressReader wraps an io.Reader to intercept and parse Ollama pull operation
+698
View File
@@ -0,0 +1,698 @@
package ui
import (
"strings"
"charm.land/lipgloss/v2"
)
// highlightStyle is lazily initialized to avoid creating it on every render
var highlightStyle lipgloss.Style
// initHighlightStyle creates the highlight style with proper colors
func initHighlightStyle() lipgloss.Style {
if highlightStyle.String() == "" {
theme := GetTheme()
highlightStyle = lipgloss.NewStyle().
Background(theme.Secondary).
Foreground(theme.Background).
Bold(true)
}
return highlightStyle
}
// MessageItem is the interface all scrollback messages must implement.
// This allows lazy rendering - messages are only rendered when visible.
type MessageItem interface {
// Render returns the styled content for this message at the given width.
// Implementations should cache the result to avoid re-rendering.
Render(width int) string
// Height returns the number of lines this message occupies when rendered.
Height() int
// ID returns a unique identifier for this message (for tracking).
ID() string
}
// ScrollList manages a viewport over a list of MessageItems.
// It handles offset-based scrolling and lazy rendering. Only visible
// items are rendered on each View() call.
type ScrollList struct {
items []MessageItem
offsetIdx int // Index of first visible item
offsetLine int // Lines to skip from first visible item
width int
height int // Viewport height in lines
autoScroll bool // Whether to auto-scroll to bottom on new content
itemGap int // Number of blank lines between items (0 = no gap)
focusedIdx int // Index of focused/selected item (-1 = none)
selectable bool // Whether items can be selected via mouse/keyboard
// Selection tracking for copy+paste (crush-style)
selection CopySelection // Current text selection
mouseDown bool // Whether mouse button is currently down
mouseDownX int // X coordinate where mouse was pressed
mouseDownY int // Y coordinate where mouse was pressed
mouseDownItem int // Item index where mouse was pressed
}
// NewScrollList creates a new ScrollList with the given dimensions.
func NewScrollList(width, height int) *ScrollList {
return &ScrollList{
items: []MessageItem{},
offsetIdx: 0,
offsetLine: 0,
width: width,
height: height,
autoScroll: true, // Start with auto-scroll enabled
}
}
// SetItems replaces the items in the scroll list. If auto-scroll is enabled,
// the viewport will scroll to the bottom to show the latest content.
func (s *ScrollList) SetItems(items []MessageItem) {
s.items = items
if s.autoScroll {
s.GotoBottom()
}
}
// SetHeight updates the viewport height. Called when the terminal is resized.
func (s *ScrollList) SetHeight(height int) {
s.height = height
s.clampOffset()
}
// SetWidth updates the viewport width. Called when the terminal is resized.
// This may invalidate cached renders in MessageItems.
func (s *ScrollList) SetWidth(width int) {
s.width = width
s.clampOffset()
}
// SetItemGap sets the number of blank lines between items (0 = no gap).
func (s *ScrollList) SetItemGap(gap int) {
s.itemGap = gap
}
// ItemGap returns the current gap between items.
func (s *ScrollList) ItemGap() int {
return s.itemGap
}
// SetSelectable enables or disables item selection.
func (s *ScrollList) SetSelectable(selectable bool) {
s.selectable = selectable
}
// FocusedIdx returns the currently focused item index (-1 if none).
func (s *ScrollList) FocusedIdx() int {
return s.focusedIdx
}
// SetFocused sets the focused item by index.
func (s *ScrollList) SetFocused(idx int) {
if idx < -1 {
s.focusedIdx = -1
} else if idx >= len(s.items) {
s.focusedIdx = len(s.items) - 1
} else {
s.focusedIdx = idx
}
}
// SelectItemAtY selects the item at the given Y coordinate (relative to viewport).
// Returns the selected item index or -1 if no item at that position.
func (s *ScrollList) SelectItemAtY(y int) int {
if !s.selectable || len(s.items) == 0 || y < 0 || y >= s.height {
return -1
}
// Calculate which item is at the given Y position
currentY := 0
for idx := s.offsetIdx; idx < len(s.items); idx++ {
item := s.items[idx]
itemHeight := item.Height()
// Check if y falls within this item
if y >= currentY && y < currentY+itemHeight {
s.focusedIdx = idx
return idx
}
currentY += itemHeight
// Add gap after item (except last)
if s.itemGap > 0 && idx < len(s.items)-1 {
currentY += s.itemGap
}
// Stop if we've passed the viewport
if currentY >= s.height {
break
}
}
return -1
}
// HandleMouseDown handles mouse button press for selection (crush-style).
// Returns true if the click was handled.
func (s *ScrollList) HandleMouseDown(x, y int) bool {
if !s.selectable || len(s.items) == 0 {
return false
}
s.mouseDown = true
s.mouseDownX = x
s.mouseDownY = y
// Find which item and line was clicked
itemIdx, lineIdx := s.getItemAndLineAtY(y)
s.mouseDownItem = itemIdx
// Start a new selection at click position
if itemIdx >= 0 {
s.selection = CopySelection{
StartItemIdx: itemIdx,
StartLine: lineIdx,
StartCol: x,
EndItemIdx: itemIdx,
EndLine: lineIdx,
EndCol: x,
Active: true,
}
return true
}
return false
}
// HandleMouseDrag handles mouse drag for selection (crush-style).
// Updates the selection end point. Returns true if selection changed.
func (s *ScrollList) HandleMouseDrag(x, y int) bool {
if !s.mouseDown || !s.selectable {
return false
}
// Find which item and line we're dragging over
itemIdx, lineIdx := s.getItemAndLineAtY(y)
if itemIdx < 0 {
return false
}
// Update selection end point
s.selection.EndItemIdx = itemIdx
s.selection.EndLine = lineIdx
s.selection.EndCol = x
s.selection.Active = true
return true
}
// getItemAndLineAtY converts a Y coordinate to item index and line index within that item.
// Returns (-1, -1) if Y is outside the viewport or beyond all items.
func (s *ScrollList) getItemAndLineAtY(y int) (itemIdx, lineIdx int) {
if y < 0 || y >= s.height || len(s.items) == 0 {
return -1, -1
}
currentY := 0
for idx := s.offsetIdx; idx < len(s.items); idx++ {
item := s.items[idx]
itemHeight := item.Height()
// Check if y falls within this item
if y >= currentY && y < currentY+itemHeight {
return idx, y - currentY
}
currentY += itemHeight
// Add gap after item (except last)
if s.itemGap > 0 && idx < len(s.items)-1 {
currentY += s.itemGap
}
// Stop if we've passed the viewport
if currentY >= s.height {
break
}
}
return -1, -1
}
// HandleMouseUp handles mouse button release (crush-style).
// Finalizes selection and returns true if there was an active selection.
func (s *ScrollList) HandleMouseUp(x, y int) bool {
if !s.mouseDown {
return false
}
s.mouseDown = false
// Check if we have a valid selection
if s.selection.Active && !s.selection.IsEmpty() {
return true
}
return false
}
// GetSelection returns the current text selection.
func (s *ScrollList) GetSelection() CopySelection {
return s.selection
}
// ClearSelection clears the current text selection.
func (s *ScrollList) ClearSelection() {
s.selection = CopySelection{}
s.mouseDown = false
}
// HasSelection returns true if there is an active non-empty selection.
func (s *ScrollList) HasSelection() bool {
return s.selection.Active && !s.selection.IsEmpty()
}
// ScrollBy scrolls the viewport by the given number of lines.
// Positive = scroll down, negative = scroll up.
func (s *ScrollList) ScrollBy(lines int) {
if lines > 0 {
// Scroll down
for lines > 0 && s.offsetIdx < len(s.items) {
if s.offsetIdx >= len(s.items) {
break
}
currentItem := s.items[s.offsetIdx]
itemHeight := currentItem.Height()
remainingLines := itemHeight - s.offsetLine
if lines >= remainingLines {
// Move to next item
s.offsetIdx++
s.offsetLine = 0
lines -= remainingLines
// Consume gap lines between items
if s.itemGap > 0 && s.offsetIdx < len(s.items) {
if lines >= s.itemGap {
lines -= s.itemGap
} else {
lines = 0
}
}
} else {
// Stay on current item, skip more lines
s.offsetLine += lines
lines = 0
}
}
} else if lines < 0 {
// Scroll up
lines = -lines
for lines > 0 && (s.offsetIdx > 0 || s.offsetLine > 0) {
if s.offsetLine > 0 {
// Scroll within current item
if lines >= s.offsetLine {
lines -= s.offsetLine
s.offsetLine = 0
} else {
s.offsetLine -= lines
lines = 0
}
} else if s.offsetIdx > 0 {
// Consume gap lines between items
if s.itemGap > 0 {
if lines > s.itemGap {
lines -= s.itemGap
} else {
lines = 0
continue
}
}
// Move to previous item
s.offsetIdx--
if s.offsetIdx < len(s.items) {
currentItem := s.items[s.offsetIdx]
itemHeight := currentItem.Height()
if lines >= itemHeight {
lines -= itemHeight
s.offsetLine = 0
} else {
s.offsetLine = itemHeight - lines
lines = 0
}
}
}
}
}
s.clampOffset()
}
// GotoBottom scrolls to the end of the list.
func (s *ScrollList) GotoBottom() {
if len(s.items) == 0 {
s.offsetIdx = 0
s.offsetLine = 0
return
}
// Calculate total height including gaps
// Ensure items are rendered before checking height (iteratr pattern)
totalHeight := 0
for i, item := range s.items {
// Render to get actual content (handles non-cached items like reasoning blocks)
rendered := item.Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
totalHeight += itemHeight
// Add gap after each item except the last
if s.itemGap > 0 && i < len(s.items)-1 {
totalHeight += s.itemGap
}
}
// If content fits in viewport, start at top
if totalHeight <= s.height {
s.offsetIdx = 0
s.offsetLine = 0
return
}
// Otherwise, position viewport at bottom
remaining := totalHeight - s.height
for idx := 0; idx < len(s.items); idx++ {
// Render to get actual content
rendered := s.items[idx].Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
if remaining < itemHeight {
s.offsetIdx = idx
s.offsetLine = remaining
return
}
remaining -= itemHeight
// Subtract gap after item (except last)
if s.itemGap > 0 && idx < len(s.items)-1 {
remaining -= s.itemGap
}
}
// Fallback: show last item
s.offsetIdx = max(0, len(s.items)-1)
s.offsetLine = 0
}
// GotoTop scrolls to the beginning of the list.
func (s *ScrollList) GotoTop() {
s.offsetIdx = 0
s.offsetLine = 0
}
// AtBottom returns true if the viewport is at the bottom of the list.
func (s *ScrollList) AtBottom() bool {
if len(s.items) == 0 {
return true
}
// Calculate visible height from current position including gaps
// Calculate height directly from rendered content (handles non-cached items)
visibleHeight := 0
for idx := s.offsetIdx; idx < len(s.items); idx++ {
item := s.items[idx]
// Render to get actual content
rendered := item.Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
if idx == s.offsetIdx {
visibleHeight += itemHeight - s.offsetLine
} else {
visibleHeight += itemHeight
}
// Add gap after item (except last)
if s.itemGap > 0 && idx < len(s.items)-1 {
visibleHeight += s.itemGap
}
if visibleHeight >= s.height {
return false
}
}
return true
}
// AtTop returns true if the viewport is at the top of the list.
func (s *ScrollList) AtTop() bool {
return s.offsetIdx == 0 && s.offsetLine == 0
}
// View renders the visible portion of the scrollback.
// Only items that fit within the viewport height are rendered.
// ALWAYS returns exactly s.height lines (padded with empty lines if needed)
// to ensure the input/footer stay fixed at the bottom.
func (s *ScrollList) View() string {
if s.height <= 0 {
return ""
}
var lines []string
remainingHeight := s.height
// Render visible items
if len(s.items) > 0 {
for idx := s.offsetIdx; idx < len(s.items) && remainingHeight > 0; idx++ {
item := s.items[idx]
content := item.Render(s.width)
contentLines := strings.Split(content, "\n")
startLine := 0
if idx == s.offsetIdx {
startLine = s.offsetLine
}
// Check if this item is focused (for visual indicator)
isFocused := idx == s.focusedIdx
for i := startLine; i < len(contentLines) && remainingHeight > 0; i++ {
line := contentLines[i]
// Apply selection highlighting if this line is within selection
if s.selection.Active && s.isLineInSelection(idx, i) {
line = s.applyHighlight(line)
} else if isFocused && s.selectable {
// Apply subtle focus indicator when item is focused but not in selection
line = s.applyFocusIndicator(line)
}
lines = append(lines, line)
remainingHeight--
}
// Add gap lines between items (but not after the last visible item)
if remainingHeight > 0 && idx < len(s.items)-1 && s.itemGap > 0 {
for g := 0; g < s.itemGap && remainingHeight > 0; g++ {
lines = append(lines, "")
remainingHeight--
}
}
}
}
// Pad with empty lines to ensure exactly s.height lines
// This keeps the input/footer fixed at the bottom of the screen
for remainingHeight > 0 {
lines = append(lines, "")
remainingHeight--
}
return strings.Join(lines, "\n")
}
// isLineInSelection checks if a specific line within an item is part of the current selection.
func (s *ScrollList) isLineInSelection(itemIdx, lineIdx int) bool {
if !s.selection.Active {
return false
}
// Normalize selection (start <= end)
startItem := s.selection.StartItemIdx
startLine := s.selection.StartLine
endItem := s.selection.EndItemIdx
endLine := s.selection.EndLine
if startItem > endItem || (startItem == endItem && startLine > endLine) {
startItem, endItem = endItem, startItem
startLine, endLine = endLine, startLine
}
// Check if item is within selection range
if itemIdx < startItem || itemIdx > endItem {
return false
}
// For single item selection
if startItem == endItem {
return itemIdx == startItem && lineIdx >= startLine && lineIdx <= endLine
}
// For multi-item selection
if itemIdx == startItem {
return lineIdx >= startLine
}
if itemIdx == endItem {
return lineIdx <= endLine
}
// Middle items are fully selected
return itemIdx > startItem && itemIdx < endItem
}
// applyHighlight applies the highlight style to a line.
// Uses the theme's Highlight color for the background.
func (s *ScrollList) applyHighlight(line string) string {
if line == "" {
return line
}
// Apply background/foreground color change for selection
style := initHighlightStyle()
return style.Render(line)
}
// applyFocusIndicator applies a subtle visual indicator for focused items.
func (s *ScrollList) applyFocusIndicator(line string) string {
if line == "" {
return line
}
// Just return the line as-is - no visual indicator for focus
// The selection highlighting is enough
return line
}
// ScrollPercent returns the current scroll position as a percentage (0.0-1.0).
// 0.0 = at top, 1.0 = at bottom. Useful for scroll indicators.
func (s *ScrollList) ScrollPercent() float64 {
if len(s.items) == 0 {
return 0.0
}
totalHeight := 0
for _, item := range s.items {
totalHeight += item.Height()
}
if totalHeight <= s.height {
return 1.0 // All content fits, consider it "at bottom"
}
// Calculate how many lines are above the viewport
linesAbove := 0
for i := 0; i < s.offsetIdx && i < len(s.items); i++ {
linesAbove += s.items[i].Height()
}
linesAbove += s.offsetLine
scrollableHeight := totalHeight - s.height
if scrollableHeight <= 0 {
return 1.0
}
percent := float64(linesAbove) / float64(scrollableHeight)
if percent > 1.0 {
percent = 1.0
}
if percent < 0.0 {
percent = 0.0
}
return percent
}
// clampOffset ensures the offset values are within valid bounds after
// resizing or scrolling operations. Prevents scrolling past the bottom
// of content (showing empty space when there's content above).
func (s *ScrollList) clampOffset() {
if len(s.items) == 0 {
s.offsetIdx = 0
s.offsetLine = 0
return
}
// First, clamp offsetIdx to valid item range
if s.offsetIdx >= len(s.items) {
s.offsetIdx = len(s.items) - 1
}
if s.offsetIdx < 0 {
s.offsetIdx = 0
}
// Clamp offsetLine within current item
if s.offsetIdx < len(s.items) {
// Calculate height from rendered content (handles non-cached items)
rendered := s.items[s.offsetIdx].Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
if s.offsetLine >= itemHeight {
s.offsetLine = max(0, itemHeight-1)
}
}
if s.offsetLine < 0 {
s.offsetLine = 0
}
// Prevent scrolling past the bottom (showing empty space at bottom when there's content above)
// Calculate total content height
totalHeight := 0
for i, item := range s.items {
rendered := item.Render(s.width)
totalHeight += strings.Count(rendered, "\n") + 1
if s.itemGap > 0 && i < len(s.items)-1 {
totalHeight += s.itemGap
}
}
// If content fits in viewport, force start at top
if totalHeight <= s.height {
s.offsetIdx = 0
s.offsetLine = 0
return
}
// Calculate how many lines are currently above the viewport
linesAbove := 0
for i := 0; i < s.offsetIdx; i++ {
rendered := s.items[i].Render(s.width)
linesAbove += strings.Count(rendered, "\n") + 1
if s.itemGap > 0 && i < len(s.items)-1 {
linesAbove += s.itemGap
}
}
linesAbove += s.offsetLine
// Calculate how many lines are visible from current position to end
linesFromCurrentToEnd := totalHeight - linesAbove
// If there's less content remaining than the viewport height,
// we've scrolled past the bottom - need to back up
if linesFromCurrentToEnd < s.height {
// Position viewport so the last line of content is at the bottom
targetLine := totalHeight - s.height
currentLine := 0
for idx := 0; idx < len(s.items); idx++ {
rendered := s.items[idx].Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
if currentLine+itemHeight > targetLine {
// This item contains the target line
s.offsetIdx = idx
s.offsetLine = targetLine - currentLine
return
}
currentLine += itemHeight
if s.itemGap > 0 && idx < len(s.items)-1 {
currentLine += s.itemGap
}
}
}
}
-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
}
+191 -170
View File
@@ -2,14 +2,27 @@ package ui
import (
"fmt"
"regexp"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/indaco/herald"
"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
@@ -79,7 +92,12 @@ func streamSpinnerTickCmd(generation uint64) tea.Cmd {
// streamFlushTickMsg fires when it's time to commit pending chunks to the
// main content builders and trigger a re-render. This coalesces rapid
// streaming chunks into fewer expensive markdown re-renders.
type streamFlushTickMsg struct{}
//
// generation ties the tick to the pending flush session that created it so
// stale ticks from a prior Reset() are discarded.
type streamFlushTickMsg struct {
generation uint64
}
// streamFlushInterval is the coalescing window for stream chunks. Chunks
// arriving within this window are batched into a single render pass.
@@ -89,9 +107,9 @@ const streamFlushInterval = 16 * time.Millisecond
// streamFlushTickCmd returns a tea.Cmd that fires streamFlushTickMsg after
// the coalescing interval.
func streamFlushTickCmd() tea.Cmd {
func streamFlushTickCmd(generation uint64) tea.Cmd {
return tea.Tick(streamFlushInterval, func(_ time.Time) tea.Msg {
return streamFlushTickMsg{}
return streamFlushTickMsg{generation: generation}
})
}
@@ -113,13 +131,13 @@ const (
// alongside streaming text until the step completes and Reset() is called.
//
// Tool calls, tool results, user messages, and other non-streaming content
// are printed immediately by the parent AppModel via tea.Println(). The
// StreamComponent only handles the live streaming text and spinner display.
// are added to the ScrollList by the parent AppModel. The StreamComponent
// only handles the live streaming text and spinner display.
//
// Lifecycle is managed entirely by the parent AppModel:
// - Parent calls Reset() between agent steps to clear state.
// - Parent emits completed responses above the BT region via tea.Println()
// then calls Reset(); StreamComponent never calls tea.Quit.
// - Content is displayed via StreamingMessageItem in the ScrollList.
// - StreamComponent never calls tea.Quit.
//
// Events handled:
// - app.SpinnerEvent{Show:true} → start spinner tick loop
@@ -149,9 +167,11 @@ type StreamComponent struct {
// spinnerFrame is the current frame index.
spinnerFrame int
// activeTools tracks the names of tools currently executing in parallel.
// When multiple tools run concurrently, all are displayed in the spinner.
activeTools []string
// activeTools maps ToolCallID -> display label for currently running tools.
activeTools map[string]string
// activeToolOrder preserves deterministic display order for active tools.
activeToolOrder []string
// streamContent holds committed streaming text (flushed from pending).
streamContent strings.Builder
@@ -172,14 +192,9 @@ type StreamComponent struct {
// the same coalescing window.
flushPending bool
// renderCache holds the last rendered output string. Reused by View()
// between flush ticks to avoid redundant markdown re-parsing.
renderCache string
// renderDirty is true when committed content has changed since the
// last render. Set on flush tick; cleared after render() rebuilds
// the cache.
renderDirty bool
// flushGeneration is incremented when stream state resets so stale flush
// ticks from a previous step can be discarded.
flushGeneration uint64
// thinkingVisible controls whether reasoning blocks are expanded or collapsed.
thinkingVisible bool
@@ -190,14 +205,12 @@ type StreamComponent struct {
// reasoningDuration holds the total reasoning time, frozen when streaming text begins.
reasoningDuration time.Duration
// messageRenderer renders assistant messages in standard mode.
messageRenderer *MessageRenderer
// inThinkTag tracks whether we're currently inside a section
// from models that wrap reasoning in XML-like tags (Qwen, DeepSeek).
inThinkTag bool
// compactRenderer renders assistant messages in compact mode.
compactRenderer *CompactRenderer
// compactMode selects which renderer to use.
compactMode bool
// renderer renders streaming assistant text.
renderer Renderer
// modelName is displayed in the streaming text header.
modelName string
@@ -211,20 +224,25 @@ type StreamComponent struct {
// height constrains the render output to at most this many lines.
// 0 means unconstrained.
height int
// ty provides typography functions for rendering text.
ty *herald.Typography
}
// NewStreamComponent creates a new StreamComponent ready to be embedded in AppModel.
func NewStreamComponent(compactMode bool, width int, modelName string) *StreamComponent {
func NewStreamComponent(width int, modelName string) *StreamComponent {
if width == 0 {
width = 80
}
renderer := newMessageRenderer(width, false)
return &StreamComponent{
spinnerFrames: knightRiderFrames(),
compactMode: compactMode,
modelName: modelName,
messageRenderer: newMessageRenderer(width, false),
compactRenderer: NewCompactRenderer(width, false),
width: width,
spinnerFrames: knightRiderFrames(),
modelName: modelName,
renderer: renderer,
width: width,
ty: createTypography(GetTheme()),
}
}
@@ -237,9 +255,6 @@ func (s *StreamComponent) SetHeight(h int) {
}
if s.height != h {
s.height = h
// Invalidate cache — height clamp affects output.
s.renderCache = ""
s.renderDirty = true
}
}
@@ -251,21 +266,27 @@ func (s *StreamComponent) Reset() {
s.spinnerGeneration++ // invalidate any in-flight tick commands
s.spinnerFrame = 0
s.activeTools = nil
s.activeToolOrder = nil
s.streamContent.Reset()
s.reasoningContent.Reset()
s.pendingStream.Reset()
s.pendingReasoning.Reset()
s.flushPending = false
s.renderCache = ""
s.renderDirty = false
s.flushGeneration++
s.timestamp = time.Time{}
s.reasoningStartTime = time.Time{}
s.reasoningDuration = 0
}
// ConsumeOverflow is a no-op in alt screen mode. Overflow is handled by the
// ScrollList viewport. Retained to satisfy streamComponentIface.
func (s *StreamComponent) ConsumeOverflow() string {
return ""
}
// GetRenderedContent returns the rendered assistant message from the accumulated
// streaming text. Returns empty string if no text has been accumulated. Used by
// the parent AppModel to flush content via tea.Println() before resetting.
// the parent AppModel to flush stream content before resetting.
//
// This commits any pending chunks first so the output includes all received
// content, not just what has been flushed by the tick.
@@ -282,7 +303,8 @@ func (s *StreamComponent) GetRenderedContent() string {
text := s.streamContent.String()
if text != "" {
sections = append(sections, s.renderStreamingText(text))
rendered := s.renderStreamingText(text)
sections = append(sections, rendered)
}
if len(sections) == 0 {
@@ -292,17 +314,17 @@ func (s *StreamComponent) GetRenderedContent() string {
}
// commitPending moves any pending chunks to the committed content builders.
// Called before reading content for scrollback output or on flush tick.
// Called before reading content for 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
}
if s.pendingReasoning.Len() > 0 {
s.reasoningContent.WriteString(s.pendingReasoning.String())
s.pendingReasoning.Reset()
s.renderDirty = true
}
}
@@ -322,11 +344,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
s.width = msg.Width
s.messageRenderer.SetWidth(s.width)
s.compactRenderer.SetWidth(s.width)
// Invalidate render cache — width change affects wrapping/styling.
s.renderCache = ""
s.renderDirty = true
if s.renderer != nil {
s.renderer.SetWidth(s.width)
}
case streamSpinnerTickMsg:
// Only continue the tick loop if this tick belongs to the current
@@ -359,6 +379,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case streamFlushTickMsg:
if msg.generation != s.flushGeneration {
break
}
s.flushPending = false
s.commitPending()
@@ -373,7 +396,7 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.pendingReasoning.WriteString(msg.Delta)
if !s.flushPending {
s.flushPending = true
return s, streamFlushTickCmd()
return s, streamFlushTickCmd(s.flushGeneration)
}
case app.StreamChunkEvent:
@@ -385,17 +408,66 @@ 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()
return s, streamFlushTickCmd(s.flushGeneration)
}
case app.ToolExecutionEvent:
toolID := msg.ToolCallID
if toolID == "" {
// Defensive fallback for older/third-party emitters that may omit
// ToolCallID. Best-effort only: same-name+args concurrent calls can
// still collide without a stable ID.
toolID = fmt.Sprintf("%s|%s", msg.ToolName, msg.ToolArgs)
}
if msg.IsStarting {
// Add tool to active list for parallel execution display.
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
s.activeTools = append(s.activeTools, toolDisplay)
if s.activeTools == nil {
s.activeTools = make(map[string]string)
}
if _, exists := s.activeTools[toolID]; !exists {
s.activeToolOrder = append(s.activeToolOrder, toolID)
}
s.activeTools[toolID] = formatToolExecutionMessage(msg.ToolName)
s.spinnerFrame = 0
if !s.spinning {
s.phase = streamPhaseActive
@@ -404,108 +476,46 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, streamSpinnerTickCmd(s.spinnerGeneration)
}
} else {
// Tool finished — remove from active list but keep spinning if others remain.
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
s.activeTools = removeFromSlice(s.activeTools, toolDisplay)
if s.activeTools != nil {
delete(s.activeTools, toolID)
}
s.activeToolOrder = removeToolID(s.activeToolOrder, toolID)
}
}
return s, nil
}
// View implements tea.Model. Renders the current stream region content.
// View implements tea.Model. Returns an empty view since rendering is handled
// by StreamingMessageItem in the ScrollList. Retained to satisfy tea.Model.
func (s *StreamComponent) View() tea.View {
return tea.NewView(s.render())
return tea.NewView("")
}
// --------------------------------------------------------------------------
// Internal rendering
// --------------------------------------------------------------------------
// render builds the full content string for the stream region. Uses a render
// cache to avoid redundant markdown re-parsing between flush ticks. The cache
// is invalidated when committed content changes (flush tick), terminal width
// changes, or height/thinking visibility changes.
func (s *StreamComponent) render() string {
if s.phase == streamPhaseIdle {
return ""
}
// Return cached render if committed content hasn't changed.
if !s.renderDirty {
return s.renderCache
}
var sections []string
// Render reasoning/thinking block above the main text if present.
if reasoning := s.reasoningContent.String(); reasoning != "" {
sections = append(sections, s.renderReasoningBlock(reasoning))
}
// Render streaming text only. The spinner is rendered in the status bar
// by the parent so it never changes the stream region height.
text := s.streamContent.String()
if text != "" {
sections = append(sections, s.renderStreamingText(text))
}
if len(sections) == 0 {
s.renderCache = ""
s.renderDirty = false
return ""
}
content := strings.Join(sections, "\n")
// Clamp to height if constrained: keep the last h lines so the most
// recent output is always visible.
if s.height > 0 && content != "" {
lines := strings.Split(content, "\n")
if len(lines) > s.height {
lines = lines[len(lines)-s.height:]
content = strings.Join(lines, "\n")
}
}
s.renderCache = content
s.renderDirty = false
return content
}
// renderReasoningBlock renders the reasoning/thinking content in a surface-tinted
// box. When collapsed, shows the last 10 lines with a truncation hint. When
// renderReasoningBlock renders the reasoning/thinking content using blockquote.
// When collapsed, shows the last 10 lines with a truncation hint. When
// expanded, shows all lines. Includes a "Thought for Xs" duration footer.
func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
theme := GetTheme()
maxWidth := max(s.width-4, 20)
lines := strings.Split(strings.TrimRight(reasoning, "\n"), "\n")
contentStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Background(theme.MutedBorder).
Italic(true)
var parts []string
// When collapsed and content exceeds 10 lines, show only the last 10
// with a truncation hint (matching iteratr's thinking block pattern).
// with a truncation hint.
const maxCollapsedLines = 10
if !s.thinkingVisible && len(lines) > maxCollapsedLines {
hidden := len(lines) - maxCollapsedLines
hintStyle := lipgloss.NewStyle().
Foreground(theme.VeryMuted).
Background(theme.MutedBorder).
Italic(true)
parts = append(parts, hintStyle.Render(fmt.Sprintf("... (%d lines hidden)", hidden)))
parts = append(parts, s.ty.Italic(fmt.Sprintf("... (%d lines hidden)", hidden)))
lines = lines[len(lines)-maxCollapsedLines:]
}
// Render reasoning text.
parts = append(parts, contentStyle.Width(maxWidth).Render(strings.Join(lines, "\n")))
// Main content using Italic with Muted color for visual distinction.
content := strings.TrimLeft(strings.Join(lines, "\n"), " \t\n")
theme := GetTheme()
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
parts = append(parts, mutedStyle.Render(s.ty.Italic(content)))
// Duration footer.
// Duration footer with VeryMuted label and Accent duration.
var duration time.Duration
if s.reasoningDuration > 0 {
duration = s.reasoningDuration
@@ -519,30 +529,27 @@ func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
} else {
durationStr = fmt.Sprintf("%.1fs", duration.Seconds())
}
footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Background(theme.MutedBorder).Render("Thought for ") +
lipgloss.NewStyle().Foreground(theme.Info).Background(theme.MutedBorder).Render(durationStr)
parts = append(parts, footer)
label := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ")
durationStyled := lipgloss.NewStyle().Foreground(theme.Accent).Render(durationStr)
parts = append(parts, label+durationStyled)
}
innerContent := strings.Join(parts, "\n")
// Wrap in box with surface background for visual distinction.
boxStyle := lipgloss.NewStyle().
Background(theme.MutedBorder). // Surface0 (#313244)
PaddingLeft(1).
Width(maxWidth + 2).
MarginBottom(1)
return boxStyle.Render(innerContent)
// Concatenate parts with newline between blockquote and footer
var result string
if len(parts) == 1 {
result = parts[0]
} else if len(parts) == 2 {
result = parts[0] + "\n" + parts[1]
} else {
result = strings.Join(parts, "\n")
}
return styleMarginBottom1.Render(result)
}
// SetThinkingVisible sets whether reasoning blocks are shown or collapsed.
func (s *StreamComponent) SetThinkingVisible(visible bool) {
if s.thinkingVisible != visible {
s.thinkingVisible = visible
// Invalidate cache — thinking visibility affects rendered output.
s.renderCache = ""
s.renderDirty = true
}
}
@@ -559,7 +566,8 @@ func (s *StreamComponent) SpinnerView() string {
return ""
}
frame := s.spinnerFrames[s.spinnerFrame%len(s.spinnerFrames)]
if len(s.activeTools) == 0 {
tools := s.activeToolDisplays()
if len(tools) == 0 {
return " " + frame
}
theme := GetTheme()
@@ -569,10 +577,10 @@ func (s *StreamComponent) SpinnerView() string {
// Format active tools list
var toolsMsg string
if len(s.activeTools) == 1 {
toolsMsg = s.activeTools[0]
if len(tools) == 1 {
toolsMsg = tools[0]
} else {
toolsMsg = "Running: " + strings.Join(s.activeTools, ", ")
toolsMsg = "Running: " + strings.Join(tools, ", ")
}
return " " + frame + " " + msgStyle.Render(toolsMsg)
}
@@ -584,30 +592,43 @@ func (s *StreamComponent) renderStreamingText(text string) string {
if ts.IsZero() {
ts = time.Now()
}
if s.compactMode {
msg := s.compactRenderer.RenderAssistantMessage(text, ts, s.modelName)
return msg.Content
if s.renderer == nil {
return text
}
msg := s.messageRenderer.RenderAssistantMessage(text, ts, s.modelName)
msg := s.renderer.RenderAssistantMessage(text, ts, s.modelName)
return msg.Content
}
// removeFromSlice removes the first occurrence of a string from a slice.
func removeFromSlice(slice []string, s string) []string {
for i, v := range slice {
if v == s {
return append(slice[:i], slice[i+1:]...)
func (s *StreamComponent) activeToolDisplays() []string {
if len(s.activeTools) == 0 {
return nil
}
out := make([]string, 0, len(s.activeToolOrder))
for _, id := range s.activeToolOrder {
if display, ok := s.activeTools[id]; ok {
out = append(out, display)
}
}
return slice
return out
}
// removeToolID removes the first occurrence of a tool ID from a slice.
func removeToolID(ids []string, id string) []string {
for i, v := range ids {
if v == id {
return append(ids[:i], ids[i+1:]...)
}
}
return ids
}
// formatToolExecutionMessage creates a descriptive spinner message for tool execution.
// For spawn_subagent, it shows simply as "Subagent" with optional task preview.
func formatToolExecutionMessage(toolName, toolArgs string) string {
if toolName == "spawn_subagent" {
return "Subagent"
}
func formatToolExecutionMessage(toolName string) string {
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),
+1
View File
@@ -86,6 +86,7 @@ func (t *ToolApprovalInput) View() tea.View {
if t.done {
return tea.NewView("we are done")
}
containerStyle := lipgloss.NewStyle()
theme := GetTheme()
+157 -336
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,13 @@ 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 renderToolBody.
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 +55,11 @@ func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
if body := renderWriteBody(toolArgs, toolResult, width); body != "" {
return body
}
case toolName == "bash" || toolName == "run_shell_cmd" ||
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
}
@@ -64,21 +72,44 @@ func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
// ---------------------------------------------------------------------------
// renderEditBody renders a side-by-side diff from old_text/new_text in toolArgs.
// Supports both single-edit mode and multi-edit mode (edits array).
func renderEditBody(toolArgs, toolResult string, width int) string {
var args map[string]any
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
// Try to extract the starting line number from the unified diff in the result
startLine := extractDiffStartLine(toolResult)
// Check for multi-edit mode (edits array)
if editsArr, ok := args["edits"].([]any); ok && len(editsArr) > 0 {
var results []string
for _, edit := range editsArr {
if e, ok := edit.(map[string]any); ok {
oldText, _ := e["old_text"].(string)
newText, _ := e["new_text"].(string)
if oldText != "" || newText != "" {
diff := renderDiffBlock(oldText, newText, startLine, width)
if diff != "" {
results = append(results, diff)
}
}
}
}
if len(results) > 0 {
return strings.Join(results, "\n")
}
return ""
}
// Single-edit mode (legacy)
oldText, _ := args["old_text"].(string)
newText, _ := args["new_text"].(string)
if oldText == "" && newText == "" {
return ""
}
// Try to extract the starting line number from the unified diff in the result
startLine := extractDiffStartLine(toolResult)
return renderDiffBlock(oldText, newText, startLine, width)
}
@@ -221,7 +252,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)
@@ -326,7 +357,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
@@ -351,137 +382,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")
}
// ---------------------------------------------------------------------------
@@ -535,7 +535,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)
@@ -587,7 +587,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)
@@ -604,7 +604,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
@@ -738,188 +737,10 @@ func truncateLine(s string, maxWidth int) string {
return xansi.Truncate(s, maxWidth, "…")
}
// ---------------------------------------------------------------------------
// Compact tool body renderers — one-line summaries for compact mode
// ---------------------------------------------------------------------------
// renderToolBodyCompact returns a brief summary string for tool results in
// compact display mode. Returns empty string to fall back to default.
func renderToolBodyCompact(toolName, toolArgs, toolResult string, width int) string {
switch {
case toolName == "edit":
return renderEditCompact(toolArgs, toolResult)
case toolName == "ls":
return renderLsCompact(toolResult)
case toolName == "read":
return renderReadCompact(toolResult)
case toolName == "write":
return renderWriteCompact(toolArgs)
case toolName == "bash" || toolName == "run_shell_cmd" ||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"):
return renderBashCompact(toolResult, width)
case toolName == "spawn_subagent":
return renderSubagentCompact(toolResult)
}
return ""
}
// renderReadCompact returns a line-count summary for Read tool output.
func renderReadCompact(toolResult string) string {
content := strings.TrimSpace(toolResult)
if content == "" {
return ""
}
lines := strings.Split(content, "\n")
// Count actual code lines (those with "N: " line-number prefix)
codeLines := 0
for _, line := range lines {
if idx := strings.Index(line, ": "); idx > 0 && idx <= 7 {
numPart := line[:idx]
if _, err := strconv.Atoi(strings.TrimSpace(numPart)); err == nil {
codeLines++
}
}
}
if codeLines == 0 {
return ""
}
theme := getTheme()
summary := fmt.Sprintf("%d lines", codeLines)
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
// renderEditCompact returns a change-count summary for Edit tool output.
func renderEditCompact(toolArgs, toolResult string) string {
var args map[string]any
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
oldText, _ := args["old_text"].(string)
newText, _ := args["new_text"].(string)
if oldText == "" && newText == "" {
return ""
}
oldCount := len(strings.Split(oldText, "\n"))
newCount := len(strings.Split(newText, "\n"))
theme := getTheme()
var summary string
if oldCount == newCount {
summary = fmt.Sprintf("%d lines modified", oldCount)
} else {
summary = fmt.Sprintf("-%d/+%d lines", oldCount, newCount)
}
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
// renderWriteCompact returns a line-count summary for Write tool output.
func renderWriteCompact(toolArgs string) string {
var args map[string]any
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
content, _ := args["content"].(string)
if content == "" {
return ""
}
count := len(strings.Split(content, "\n"))
theme := getTheme()
summary := fmt.Sprintf("%d lines written", count)
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
// renderLsCompact returns an entry-count summary for Ls tool output.
func renderLsCompact(toolResult string) string {
content := strings.TrimSpace(toolResult)
if content == "" {
return ""
}
entries := strings.Split(content, "\n")
theme := getTheme()
summary := fmt.Sprintf("%d entries", len(entries))
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
// renderBashCompact returns the first few lines of bash output as a compact
// summary. Shows up to 3 meaningful output lines.
func renderBashCompact(toolResult string, width int) string {
result := strings.TrimSpace(toolResult)
if result == "" {
return ""
}
lines := strings.Split(result, "\n")
// Filter to meaningful output lines (skip STDERR: label, keep exit codes separate)
var outputLines []string
var exitCode string
inStderr := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "STDERR:" {
inStderr = true
continue
}
if strings.HasPrefix(trimmed, "Exit code:") {
exitCode = trimmed
continue
}
if trimmed == "" {
continue
}
outputLines = append(outputLines, line)
_ = inStderr // stderr lines are included in output
}
if len(outputLines) == 0 {
if exitCode != "" {
theme := getTheme()
return lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode)
}
return ""
}
const maxLines = 3
theme := getTheme()
display := outputLines
if len(display) > maxLines {
display = display[:maxLines]
}
// Truncate each line to available width (ANSI-aware)
lineMax := max(width-4, 20)
for i, line := range display {
display[i] = truncateLine(line, lineMax)
}
summary := strings.Join(display, "\n")
if len(outputLines) > maxLines {
summary += fmt.Sprintf("\n...(%d more lines)", len(outputLines)-maxLines)
}
if exitCode != "" {
summary += "\n" + lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode)
}
return lipgloss.NewStyle().Foreground(theme.Muted).Render(summary)
}
// ---------------------------------------------------------------------------
// Subagent tool renderers — show only summary, not full output
// ---------------------------------------------------------------------------
// renderSubagentBody renders a clean summary of subagent results.
// Extracts timing/token info and shows only a brief summary instead of raw output.
// 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 ""
@@ -937,9 +758,19 @@ func renderSubagentBody(toolResult string, width int) string {
// First line is always the status summary
statusLine := lines[0]
// Build a clean summary
var summary strings.Builder
summary.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(statusLine))
// Build content lines for display with bash-style background
outputStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1)
errorStyle := lipgloss.NewStyle().Foreground(theme.Error).Background(theme.CodeBg).PaddingLeft(1)
const lineIndent = " "
lineWidth := max(width-len(lineIndent), 20)
maxLineChars := lineWidth - 1 // account for PaddingLeft(1)
var contentLines []string
// Add status line
styledStatus := outputStyle.Width(lineWidth).Render(truncateLine(statusLine, maxLineChars))
contentLines = append(contentLines, lineIndent+styledStatus)
// For successful results, extract a brief preview of the actual result
if strings.Contains(statusLine, "successfully") {
@@ -947,25 +778,45 @@ func renderSubagentBody(toolResult string, width int) string {
if _, resultContent, found := strings.Cut(result, "Result:\n"); found {
resultContent = strings.TrimSpace(resultContent)
if resultContent != "" {
// Show first 3 meaningful lines as preview
preview := extractSubagentPreview(resultContent, 3, width-4)
if preview != "" {
summary.WriteString("\n\n")
summary.WriteString(lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true).
Render(preview))
// Show first few meaningful lines as preview
previewLines := extractSubagentPreviewLines(resultContent, 5, maxLineChars)
if len(previewLines) > 0 {
// Add blank separator line
blankLine := outputStyle.Width(lineWidth).Render("")
contentLines = append(contentLines, lineIndent+blankLine)
for _, line := range previewLines {
styled := outputStyle.Width(lineWidth).Render(line)
contentLines = append(contentLines, lineIndent+styled)
}
}
}
}
} else {
// For failed results, show error info
if _, errorContent, found := strings.Cut(result, "Error:\n"); found {
errorContent = strings.TrimSpace(errorContent)
if errorContent != "" {
previewLines := extractSubagentPreviewLines(errorContent, 3, maxLineChars)
if len(previewLines) > 0 {
blankLine := outputStyle.Width(lineWidth).Render("")
contentLines = append(contentLines, lineIndent+blankLine)
for _, line := range previewLines {
styled := errorStyle.Width(lineWidth).Render(line)
contentLines = append(contentLines, lineIndent+styled)
}
}
}
}
}
return summary.String()
return strings.Join(contentLines, "\n")
}
// extractSubagentPreview extracts the first N non-empty lines from content,
// truncating each line to maxWidth.
func extractSubagentPreview(content string, maxLines, maxWidth int) string {
// extractSubagentPreviewLines extracts the first N non-empty lines from content,
// truncating each line to maxWidth. Returns as a slice of strings.
func extractSubagentPreviewLines(content string, maxLines, maxWidth int) []string {
lines := strings.Split(content, "\n")
var preview []string
@@ -984,12 +835,6 @@ func extractSubagentPreview(content string, maxLines, maxWidth int) string {
}
}
if len(preview) == 0 {
return ""
}
result := strings.Join(preview, "\n")
// Count remaining lines for "more" indicator
totalLines := 0
for _, line := range lines {
@@ -998,32 +843,8 @@ func extractSubagentPreview(content string, maxLines, maxWidth int) string {
}
}
if totalLines > maxLines {
result += fmt.Sprintf("\n...(%d more lines)", totalLines-maxLines)
preview = append(preview, fmt.Sprintf("...(%d more lines)", totalLines-maxLines))
}
return result
}
// renderSubagentCompact returns a brief one-line summary for subagent results.
func renderSubagentCompact(toolResult string) string {
result := strings.TrimSpace(toolResult)
if result == "" {
return ""
}
theme := getTheme()
// Extract just the first line which contains the status
lines := strings.Split(result, "\n")
if len(lines) == 0 {
return ""
}
statusLine := lines[0]
// Make it more compact by removing redundant words
statusLine = strings.Replace(statusLine, "Subagent completed successfully in ", "Completed in ", 1)
statusLine = strings.Replace(statusLine, "Subagent failed", "Failed", 1)
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(statusLine)
return preview
}
+13 -7
View File
@@ -134,13 +134,23 @@ func (ut *UsageTracker) EstimateAndUpdateUsage(inputText, outputText string) {
}
// SetContextTokens records the approximate current context window utilization.
// This should be set from the final API call's input + output tokens (i.e.
// FinalResponse.Usage) rather than the aggregate TotalUsage, because TotalUsage
// This should be set from FinalUsage.InputTokens, which already includes the
// full conversation history (system prompt + all previous messages). Do NOT
// add OutputTokens as that would double-count (output becomes input next turn).
// Use FinalResponse.Usage rather than aggregate TotalUsage, because TotalUsage
// sums across all tool-calling steps and overstates the actual window fill level.
func (ut *UsageTracker) SetContextTokens(tokens int) {
ut.mu.Lock()
defer ut.mu.Unlock()
ut.contextTokens = tokens
// Track the maximum context seen so far. In multi-step tool calls,
// FinalUsage.InputTokens may reflect only the last step's input, which
// can be smaller than previous steps. We want to show the largest context
// the model has processed in this session.
if tokens > ut.contextTokens {
ut.contextTokens = tokens
}
// If tokens < current, we keep the larger value (no-op)
// This prevents the display from dropping during multi-step tool calls.
}
// RenderUsageInfo generates a formatted string displaying current usage statistics
@@ -151,10 +161,6 @@ func (ut *UsageTracker) RenderUsageInfo() string {
ut.mu.RLock()
defer ut.mu.RUnlock()
if ut.sessionStats.RequestCount == 0 {
return ""
}
baseStyle := lipgloss.NewStyle()
// Display the current context window token count (from the last API call),
+59
View File
@@ -67,3 +67,62 @@ func TestUsageTracker_RenderUsageInfo_OAuth(t *testing.T) {
t.Errorf("Expected regular rendered output to show actual cost, got: %s", regularRendered)
}
}
func TestUsageTracker_RenderUsageInfo_StartupState(t *testing.T) {
// Create a mock model info with costs and context limit
modelInfo := &models.ModelInfo{
ID: "claude-3-5-sonnet-20241022",
Name: "Claude 3.5 Sonnet v2",
Cost: models.Cost{
Input: 3.0,
Output: 15.0,
},
Limit: models.Limit{
Context: 200000,
Output: 8192,
},
}
// Test startup state (no requests made yet) - Regular API key
regularTracker := NewUsageTracker(modelInfo, "anthropic", 80, false)
rendered := stripAnsi(regularTracker.RenderUsageInfo())
// Should NOT return empty string on startup
if rendered == "" {
t.Errorf("Expected non-empty output on startup, got empty string")
}
// Should show 0 tokens
if !strings.Contains(rendered, "Tokens: 0") {
t.Errorf("Expected 'Tokens: 0' on startup, got: %s", rendered)
}
// Should NOT show percentage when tokens are 0
if strings.Contains(rendered, "(%") {
t.Errorf("Expected no percentage on startup with 0 tokens, got: %s", rendered)
}
// Should show $0.0000 cost for regular API key
if !strings.Contains(rendered, "Cost: $0.0000") {
t.Errorf("Expected 'Cost: $0.0000' on startup, got: %s", rendered)
}
// Test startup state (no requests made yet) - OAuth
oauthTracker := NewUsageTracker(modelInfo, "anthropic", 80, true)
oauthRendered := stripAnsi(oauthTracker.RenderUsageInfo())
// Should NOT return empty string on startup
if oauthRendered == "" {
t.Errorf("Expected non-empty output on startup for OAuth, got empty string")
}
// Should show 0 tokens for OAuth
if !strings.Contains(oauthRendered, "Tokens: 0") {
t.Errorf("Expected 'Tokens: 0' on startup for OAuth, got: %s", oauthRendered)
}
// Should show $0.00 cost for OAuth
if !strings.Contains(oauthRendered, "Cost: $0.00") {
t.Errorf("Expected 'Cost: $0.00' on startup for OAuth, got: %s", oauthRendered)
}
}
+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",
+80 -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,56 @@ 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 — concrete Kit-owned structs, no external library dependency
kit.LLMMessage // {Role LLMMessageRole, Content string}
kit.LLMMessageRole // "user" | "assistant" | "system" | "tool"
kit.LLMUsage // {InputTokens, OutputTokens, TotalTokens, ...}
kit.LLMResponse // {Content, FinishReason, Usage}
kit.LLMFilePart // {Filename, Data []byte, MediaType}
// Conversion helpers
msgs := kit.ConvertToLLMMessages(&msg) // SDK Message → []LLMMessage
msg := kit.ConvertFromLLMMessage(lMsg) // LLMMessage → 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")
}
+61 -49
View File
@@ -2,10 +2,9 @@ package kit
import (
"context"
"errors"
"fmt"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/compaction"
)
@@ -17,10 +16,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 +37,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 +55,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 +114,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 +134,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 != "" {
@@ -150,7 +153,15 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust
}
model := m.agent.GetModel()
result, _, err := compaction.Compact(ctx, model, messages, *opts, customInstructions, prev)
// Create a streaming callback to emit chunks as events.
streamCallback := func(delta string) error {
// Emit MessageUpdateEvent to the UI for streaming display.
m.events.emit(MessageUpdateEvent{Chunk: delta})
return nil
}
result, _, err := compaction.Compact(ctx, model, messages, *opts, customInstructions, prev, streamCallback)
if err != nil {
return nil, err
}
@@ -166,34 +177,17 @@ 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
}
// applyCustomCompaction handles compaction when an extension provides a
// custom summary. It still determines the cut point and persists a
// CompactionEntry.
func (m *Kit) applyCustomCompaction(summary string, messages []fantasy.Message, opts *CompactionOptions) (*CompactionResult, error) {
func (m *Kit) applyCustomCompaction(summary string, messages []LLMMessage, opts *CompactionOptions) (*CompactionResult, error) {
originalTokens := compaction.EstimateMessageTokens(messages)
cutPoint := compaction.FindCutPoint(messages, opts.KeepRecentTokens)
@@ -211,24 +205,13 @@ func (m *Kit) applyCustomCompaction(summary string, messages []fantasy.Message,
}
// Estimate new token count.
summaryTokens := compaction.EstimateMessageTokens([]fantasy.Message{{
Role: "system",
Content: []fantasy.MessagePart{fantasy.TextPart{Text: summary}},
summaryTokens := compaction.EstimateMessageTokens([]LLMMessage{{
Role: LLMRoleSystem,
Content: []LLMMessagePart{LLMTextPart{Text: summary}},
}})
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 +219,41 @@ 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
}
// Conversion helpers are in llm_convert.go.
+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
}
+35 -58
View File
@@ -4,7 +4,6 @@ import (
"strings"
"sync"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/extensions"
)
@@ -104,11 +103,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 +123,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 +138,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 +154,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 +200,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 +217,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,20 +246,20 @@ 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.
// Extract plain text from each message for the extension API.
extMsgs := make([]extensions.ContextMessage, len(h.Messages))
for i, msg := range h.Messages {
// Extract text from content parts.
var text strings.Builder
var sb strings.Builder
for _, part := range msg.Content {
if tp, ok := part.(fantasy.TextPart); ok {
text.WriteString(tp.Text)
if tp, ok := part.(LLMTextPart); ok {
sb.WriteString(tp.Text)
}
}
extMsgs[i] = extensions.ContextMessage{
Index: i,
Role: string(msg.Role),
Content: text.String(),
Content: sb.String(),
}
}
@@ -266,28 +269,26 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
return nil
}
// Rebuild fantasy.Message slice from extension result.
rebuilt := make([]fantasy.Message, 0, len(r.Messages))
// Rebuild LLM message slice from extension result.
rebuilt := make([]LLMMessage, 0, len(r.Messages))
for _, cm := range r.Messages {
if cm.Index >= 0 && cm.Index < len(h.Messages) {
// Reuse original message (preserves tool calls, reasoning, etc.)
// Reuse original message (preserves original role and content).
rebuilt = append(rebuilt, h.Messages[cm.Index])
} else {
// New message injected by extension.
role := fantasy.MessageRoleUser
// New message injected by extension — construct from role + text.
role := LLMRoleUser
switch cm.Role {
case "assistant":
role = fantasy.MessageRoleAssistant
role = LLMRoleAssistant
case "system":
role = fantasy.MessageRoleSystem
role = LLMRoleSystem
case "tool":
role = fantasy.MessageRoleTool
role = LLMRoleTool
}
rebuilt = append(rebuilt, fantasy.Message{
Role: role,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: cm.Content},
},
rebuilt = append(rebuilt, LLMMessage{
Role: role,
Content: []LLMMessagePart{LLMTextPart{Text: cm.Content}},
})
}
}
@@ -324,27 +325,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()
}
+9 -4
View File
@@ -82,15 +82,15 @@ 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 []fantasy.Message
// Messages is the current context as LLM message objects.
Messages []LLMMessage
}
// ContextPrepareResult can replace the context window.
type ContextPrepareResult struct {
// Messages replaces the entire context window. If nil, the original
// messages are used.
Messages []fantasy.Message
Messages []LLMMessage
}
// BeforeCompactHook is the input for hooks that fire before compaction runs.
@@ -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")
+114 -572
View File
@@ -11,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"
@@ -67,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
@@ -76,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.
@@ -144,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 {
@@ -377,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 {
@@ -391,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
@@ -538,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,
@@ -545,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
@@ -577,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
@@ -596,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).
@@ -714,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))
@@ -728,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{
@@ -776,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.
@@ -1063,6 +643,15 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
// Bridge extension events to SDK hooks.
if agentResult.ExtRunner != nil {
k.bridgeExtensions(agentResult.ExtRunner)
// Initialize extension context with minimal defaults. SDK users can call
// Extensions().SetContext to override with richer implementations (TUI callbacks,
// prompts, etc.). This ensures extensions never crash on nil function fields.
k.Extensions().SetContext(extensions.Context{
CWD: cwd,
Model: k.modelString,
Interactive: false, // SDK mode defaults to non-interactive
})
}
return k, nil
@@ -1233,16 +822,17 @@ 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
// Each message carries role and plain-text content.
Messages []LLMMessage
}
// ---------------------------------------------------------------------------
@@ -1263,7 +853,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
@@ -1281,17 +871,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
}
@@ -1337,7 +926,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()
@@ -1359,10 +948,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(
@@ -1370,10 +956,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() }()
@@ -1387,11 +970,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{
@@ -1430,14 +1009,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) {
@@ -1445,7 +1023,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,
@@ -1470,7 +1048,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,
}
@@ -1532,6 +1110,14 @@ 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") {
charmlog.Debug("Kit.generate emitting StepUsageEvent",
"input", inputTokens,
"output", outputTokens,
"cacheRead", cacheReadTokens,
"cacheCreate", cacheCreationTokens,
)
}
m.events.emit(StepUsageEvent{
InputTokens: uint64(inputTokens),
OutputTokens: uint64(outputTokens),
@@ -1571,36 +1157,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.
@@ -1609,13 +1193,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)
@@ -1634,14 +1216,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
}
@@ -1652,7 +1232,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)
}
}
@@ -1672,9 +1252,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{
@@ -1736,7 +1314,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")
}
@@ -1843,45 +1421,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.
@@ -1894,7 +1433,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...),
})
@@ -1915,7 +1454,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))
@@ -1960,6 +1499,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.
+194
View File
@@ -1,9 +1,14 @@
package kit
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/session"
)
@@ -86,3 +91,192 @@ func (m *Kit) SetSessionName(name string) error {
_, err := m.treeSession.AppendSessionInfo(name)
return err
}
// ---------------------------------------------------------------------------
// Tree Navigation Bridge for Extensions (Phase 1)
// ---------------------------------------------------------------------------
// GetTreeNode returns a node by ID with full metadata and children.
// Returns nil if entry not found or no tree session.
func (m *Kit) GetTreeNode(entryID string) *TreeNode {
if m.treeSession == nil {
return nil
}
entry := m.treeSession.GetEntry(entryID)
if entry == nil {
return nil
}
return m.entryToTreeNode(entry)
}
// GetCurrentBranch returns the path from root to current leaf as TreeNodes.
func (m *Kit) GetCurrentBranch() []TreeNode {
if m.treeSession == nil {
return nil
}
branch := m.treeSession.GetBranch("")
var nodes []TreeNode
for _, entry := range branch {
node := m.entryToTreeNode(entry)
if node != nil {
nodes = append(nodes, *node)
}
}
return nodes
}
// GetChildren returns direct child IDs of an entry.
func (m *Kit) GetChildren(parentID string) []string {
if m.treeSession == nil {
return nil
}
return m.treeSession.GetChildren(parentID)
}
// NavigateTo branches/forks the session to the specified entry ID.
// Returns 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 fmt.Errorf("no tree session available")
}
return m.treeSession.Branch(entryID)
}
// 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 "", 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.treeSession.EntryID(entry)
if id == fromID {
startIdx = i
}
if id == toID {
endIdx = i
}
}
if startIdx < 0 || endIdx < 0 || startIdx > endIdx {
return "", fmt.Errorf("entry IDs not found or out of order in current branch")
}
// Build text to summarize
var content strings.Builder
for i := startIdx; i <= endIdx; i++ {
node := m.entryToTreeNode(branch[i])
if node != nil && node.Content != "" {
fmt.Fprintf(&content, "[%s] %s\n\n", node.Role, node.Content)
}
}
if content.Len() == 0 {
return "", fmt.Errorf("no content found in the specified range")
}
// Use LLM to summarize
resp, err := m.ExecuteCompletion(context.Background(), extensions.CompleteRequest{
Model: "", // Use current model
System: "You are a concise summarization assistant. Summarize the conversation in 2-3 sentences.",
Prompt: content.String(),
})
if err != nil {
return "", fmt.Errorf("summarization failed: %w", err)
}
return resp.Text, nil
}
// CollapseBranch replaces a branch range with a summary entry.
// 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 fmt.Errorf("no tree session available")
}
_, err := m.treeSession.AppendBranchSummary(fromID, summary)
return err
}
// entryToTreeNode converts a session entry to a TreeNode.
func (m *Kit) entryToTreeNode(entry any) *TreeNode {
switch e := entry.(type) {
case *session.MessageEntry:
msg, err := e.ToMessage()
if err != nil {
return nil
}
var content strings.Builder
for _, p := range msg.Parts {
switch pt := p.(type) {
case message.TextContent:
content.WriteString(pt.Text)
case message.ReasoningContent:
content.WriteString(pt.Thinking)
case message.ToolCall:
fmt.Fprintf(&content, "[tool_call: %s]", pt.Name)
case message.ToolResult:
fmt.Fprintf(&content, "[tool_result: %s]", pt.Content)
}
}
return &TreeNode{
ID: e.ID,
ParentID: e.ParentID,
Type: "message",
Role: string(msg.Role),
Content: content.String(),
Model: msg.Model,
Provider: msg.Provider,
Timestamp: e.Timestamp.Format(time.RFC3339),
Children: m.treeSession.GetChildren(e.ID),
}
case *session.BranchSummaryEntry:
return &TreeNode{
ID: e.ID,
ParentID: e.ParentID,
Type: "branch_summary",
Content: e.Summary,
Timestamp: e.Timestamp.Format(time.RFC3339),
Children: m.treeSession.GetChildren(e.ID),
}
case *session.ModelChangeEntry:
return &TreeNode{
ID: e.ID,
ParentID: e.ParentID,
Type: "model_change",
Content: fmt.Sprintf("Model changed to %s/%s", e.Provider, e.ModelID),
Model: e.Provider + "/" + e.ModelID,
Provider: e.Provider,
Timestamp: e.Timestamp.Format(time.RFC3339),
Children: m.treeSession.GetChildren(e.ID),
}
case *session.ExtensionDataEntry:
return &TreeNode{
ID: e.ID,
ParentID: e.ParentID,
Type: "extension_data",
Content: fmt.Sprintf("Extension data: %s", e.ExtType),
Timestamp: e.Timestamp.Format(time.RFC3339),
Children: m.treeSession.GetChildren(e.ID),
}
default:
return nil
}
}
// TreeNode represents a node in the session tree for SDK consumers.
type TreeNode struct {
ID string
ParentID string
Type string // "message", "branch_summary", "model_change", "extension_data"
Role string // for messages: "user", "assistant", "system", "tool"
Content string
Model string
Provider string
Timestamp string
Children []string
}
+70 -1
View File
@@ -1,6 +1,11 @@
package kit
import "github.com/mark3labs/kit/internal/skills"
import (
"os"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/skills"
)
// ==== Skills Types ====
@@ -67,3 +72,67 @@ func LoadPromptTemplate(path string) (*PromptTemplate, error) {
func NewPromptBuilder(basePrompt string) *PromptBuilder {
return skills.NewPromptBuilder(basePrompt)
}
// ---------------------------------------------------------------------------
// Skill Bridge for Extensions (Phase 2)
// ---------------------------------------------------------------------------
// DiscoverSkillsForExtension finds skills in standard locations for extensions.
// 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()
m.skillCache.mu.Lock()
defer m.skillCache.mu.Unlock()
if len(m.skillCache.skills) == 0 {
m.skillCache.skills, _ = skills.LoadSkills(cwd)
}
return m.convertSkills(m.skillCache.skills)
}
// LoadSkillForExtension loads a single skill file for extensions.
func (m *Kit) LoadSkillForExtension(path string) (*extensions.Skill, string) {
s, err := skills.LoadSkill(path)
if err != nil {
return nil, err.Error()
}
return m.convertSkill(s), ""
}
// LoadSkillsFromDirForExtension loads all skills from a directory for extensions.
func (m *Kit) LoadSkillsFromDirForExtension(dir string) extensions.SkillLoadResult {
skillList, err := skills.LoadSkillsFromDir(dir)
if err != nil {
return extensions.SkillLoadResult{Error: err.Error()}
}
return extensions.SkillLoadResult{Skills: m.convertSkills(skillList)}
}
// convertSkill converts internal skill to extension-facing format.
func (m *Kit) convertSkill(s *skills.Skill) *extensions.Skill {
return &extensions.Skill{
Name: s.Name,
Description: s.Description,
Content: s.Content,
Path: s.Path,
Tags: s.Tags,
When: s.When,
}
}
// convertSkills converts a slice of skills.
func (m *Kit) convertSkills(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 skill cache for this Kit instance.
func (m *Kit) ClearSkillCache() {
m.skillCache.mu.Lock()
defer m.skillCache.mu.Unlock()
m.skillCache.skills = nil
}

Some files were not shown because too many files have changed in this diff Show More