mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06bf6d087a | |||
| fd960921ca | |||
| 0b651a8df9 | |||
| 7315c1dea7 | |||
| 0313fa03ad | |||
| d27022bcfb |
@@ -509,6 +509,8 @@ During an interactive session, use these slash commands:
|
||||
|
||||
| Shortcut | Description |
|
||||
|----------|-------------|
|
||||
| `Ctrl+V` | Paste an image from the clipboard — shows an inline low-res thumbnail preview (tmux/zellij-safe) |
|
||||
| `Ctrl+U` | Clear all pending image attachments |
|
||||
| `Ctrl+X e` | Open `$VISUAL`/`$EDITOR` to compose or edit your prompt |
|
||||
| `Ctrl+X s` | Steer — inject a system-level instruction mid-turn |
|
||||
| `ESC ESC` | Cancel the current operation (tool call or streaming) |
|
||||
|
||||
+152
-45
@@ -1262,9 +1262,57 @@ func runNormalMode(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Bundle all the shared dependencies into a single struct that both
|
||||
// run-mode entry points consume. This keeps the dispatch site and the
|
||||
// function signatures readable.
|
||||
deps := runModeDeps{
|
||||
appInstance: appInstance,
|
||||
cli: cli,
|
||||
modelName: modelName,
|
||||
providerName: parsedProvider,
|
||||
loadingMessage: kitInstance.GetLoadingMessage(),
|
||||
serverNames: serverNames,
|
||||
toolNames: toolNames,
|
||||
mcpToolCount: mcpToolCount,
|
||||
extensionToolCount: extensionToolCount,
|
||||
usageTracker: usageTracker,
|
||||
extCommands: extCommands,
|
||||
promptTemplates: promptTemplates,
|
||||
contextPaths: contextPaths,
|
||||
skillItems: skillItems,
|
||||
extensionItems: extensionItems,
|
||||
getPromptTemplates: getPromptTemplates,
|
||||
getSkillItems: getSkillItems,
|
||||
getExtensionItems: getExtensionItems,
|
||||
getToolNames: getToolNames,
|
||||
getMCPToolCount: getMCPToolCount,
|
||||
mcpPrompts: mcpPrompts,
|
||||
getMCPPrompts: getMCPPrompts,
|
||||
expandMCPPrompt: expandMCPPrompt,
|
||||
getWidgets: getWidgets,
|
||||
getHeader: getHeader,
|
||||
getFooter: getFooter,
|
||||
getToolRenderer: getToolRenderer,
|
||||
getEditorInterceptor: getEditorInterceptor,
|
||||
getUIVisibility: getUIVisibility,
|
||||
getStatusBarEntries: getStatusBarEntries,
|
||||
emitBeforeFork: emitBeforeFork,
|
||||
emitBeforeSessionSwitch: emitBeforeSessionSwitch,
|
||||
getGlobalShortcuts: getGlobalShortcuts,
|
||||
getExtensionCommands: getExtensionCommands,
|
||||
setModel: setModelForUI,
|
||||
emitModelChange: emitModelChangeForUI,
|
||||
isReasoningModel: kitInstance.IsReasoningModel(),
|
||||
thinkingLevel: kitInstance.GetThinkingLevel(),
|
||||
setThinkingLevel: setThinkingLevelForUI,
|
||||
switchSession: switchSessionForUI,
|
||||
reloadExtensions: reloadExtensionsForUI,
|
||||
startupExtensionMessages: startupExtensionMessages,
|
||||
}
|
||||
|
||||
// Check if running in non-interactive mode
|
||||
if positionalPrompt != "" {
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, extensionItems, getPromptTemplates, getSkillItems, getExtensionItems, getToolNames, getMCPToolCount, mcpPrompts, getMCPPrompts, expandMCPPrompt, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI)
|
||||
return runNonInteractiveModeApp(ctx, deps, positionalPrompt, quietFlag, jsonFlag, noExitFlag)
|
||||
}
|
||||
|
||||
// Quiet mode is not allowed in interactive mode
|
||||
@@ -1272,7 +1320,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
return fmt.Errorf("--quiet requires a prompt")
|
||||
}
|
||||
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, extensionItems, getPromptTemplates, getSkillItems, getExtensionItems, getToolNames, getMCPToolCount, mcpPrompts, getMCPPrompts, expandMCPPrompt, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI, startupExtensionMessages)
|
||||
return runInteractiveModeBubbleTea(ctx, deps)
|
||||
}
|
||||
|
||||
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
|
||||
@@ -1285,7 +1333,10 @@ func runNormalMode(ctx context.Context) error {
|
||||
//
|
||||
// When --no-exit is set, after the prompt completes the interactive BubbleTea
|
||||
// TUI is started so the user can continue the conversation.
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, extensionItems []ui.ExtensionItem, getPromptTemplates func() []*prompts.PromptTemplate, getSkillItems func() []ui.SkillItem, getExtensionItems func() []ui.ExtensionItem, getToolNames func() []string, getMCPToolCount func() int, mcpPrompts []ui.MCPPromptInfo, getMCPPrompts func() []ui.MCPPromptInfo, expandMCPPrompt func(string, string, map[string]string) (*ui.MCPPromptExpandResult, error), getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error) error {
|
||||
func runNonInteractiveModeApp(ctx context.Context, deps runModeDeps, prompt string, quiet, jsonOutput, noExit bool) error {
|
||||
appInstance := deps.appInstance
|
||||
cli := deps.cli
|
||||
modelName := deps.modelName
|
||||
// Expand @file references in the prompt before sending to the agent.
|
||||
// Text files are XML-inlined; binary files are extracted as multimodal parts.
|
||||
var fileParts []kit.LLMFilePart
|
||||
@@ -1346,12 +1397,67 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
|
||||
|
||||
// If --no-exit was requested, hand off to the interactive TUI.
|
||||
if noExit {
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, extensionItems, getPromptTemplates, getSkillItems, getExtensionItems, getToolNames, getMCPToolCount, mcpPrompts, getMCPPrompts, expandMCPPrompt, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession, reloadExtensions, nil)
|
||||
// Drop the cli (interactive mode doesn't use it) and clear the
|
||||
// interactive-only fields explicitly; deps carries everything else.
|
||||
interactive := deps
|
||||
interactive.cli = nil
|
||||
interactive.startupExtensionMessages = nil
|
||||
return runInteractiveModeBubbleTea(ctx, interactive)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runModeDeps bundles the shared dependencies that runNormalMode wires up
|
||||
// once and threads to both runNonInteractiveModeApp and
|
||||
// runInteractiveModeBubbleTea. Grouping them into a single struct keeps the
|
||||
// call sites and signatures readable and makes it trivial to add a new
|
||||
// provider callback without touching every call chain.
|
||||
type runModeDeps struct {
|
||||
appInstance *app.App
|
||||
cli *ui.CLI // non-interactive only
|
||||
modelName string
|
||||
providerName string
|
||||
loadingMessage string
|
||||
serverNames []string
|
||||
toolNames []string
|
||||
mcpToolCount int
|
||||
extensionToolCount int
|
||||
usageTracker *ui.UsageTracker
|
||||
extCommands []commands.ExtensionCommand
|
||||
promptTemplates []*prompts.PromptTemplate
|
||||
contextPaths []string
|
||||
skillItems []ui.SkillItem
|
||||
extensionItems []ui.ExtensionItem
|
||||
getPromptTemplates func() []*prompts.PromptTemplate
|
||||
getSkillItems func() []ui.SkillItem
|
||||
getExtensionItems func() []ui.ExtensionItem
|
||||
getToolNames func() []string
|
||||
getMCPToolCount func() int
|
||||
mcpPrompts []ui.MCPPromptInfo
|
||||
getMCPPrompts func() []ui.MCPPromptInfo
|
||||
expandMCPPrompt func(string, string, map[string]string) (*ui.MCPPromptExpandResult, error)
|
||||
getWidgets func(string) []ui.WidgetData
|
||||
getHeader func() *ui.WidgetData
|
||||
getFooter func() *ui.WidgetData
|
||||
getToolRenderer func(string) *ui.ToolRendererData
|
||||
getEditorInterceptor func() *ui.EditorInterceptor
|
||||
getUIVisibility func() *ui.UIVisibility
|
||||
getStatusBarEntries func() []ui.StatusBarEntryData
|
||||
emitBeforeFork func(string, bool, string) (bool, string)
|
||||
emitBeforeSessionSwitch func(string) (bool, string)
|
||||
getGlobalShortcuts func() map[string]func()
|
||||
getExtensionCommands func() []commands.ExtensionCommand
|
||||
setModel func(string) error
|
||||
emitModelChange func(string, string, string)
|
||||
isReasoningModel bool
|
||||
thinkingLevel string
|
||||
setThinkingLevel func(string) error
|
||||
switchSession func(string) error
|
||||
reloadExtensions func() error
|
||||
startupExtensionMessages []string // interactive only
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON output helpers (--json mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1444,7 +1550,8 @@ func writeJSONError(err error) {
|
||||
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
|
||||
//
|
||||
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, extensionItems []ui.ExtensionItem, getPromptTemplates func() []*prompts.PromptTemplate, getSkillItems func() []ui.SkillItem, getExtensionItems func() []ui.ExtensionItem, getToolNames func() []string, getMCPToolCount func() int, mcpPrompts []ui.MCPPromptInfo, getMCPPrompts func() []ui.MCPPromptInfo, expandMCPPrompt func(string, string, map[string]string) (*ui.MCPPromptExpandResult, error), getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error, startupExtensionMessages []string) error {
|
||||
func runInteractiveModeBubbleTea(_ context.Context, deps runModeDeps) error {
|
||||
appInstance := deps.appInstance
|
||||
// Redirect all log output (stdlib and charm) to a file so that log
|
||||
// messages don't write to stderr and corrupt the TUI. Bubble Tea
|
||||
// captures stdout for rendering; any stray stderr output from
|
||||
@@ -1467,49 +1574,49 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
|
||||
ModelName: modelName,
|
||||
ProviderName: providerName,
|
||||
LoadingMessage: loadingMessage,
|
||||
ModelName: deps.modelName,
|
||||
ProviderName: deps.providerName,
|
||||
LoadingMessage: deps.loadingMessage,
|
||||
Cwd: cwd,
|
||||
Width: termWidth,
|
||||
Height: termHeight,
|
||||
ServerNames: serverNames,
|
||||
ToolNames: toolNames,
|
||||
GetToolNames: getToolNames,
|
||||
GetMCPToolCount: getMCPToolCount,
|
||||
MCPToolCount: mcpToolCount,
|
||||
ExtensionToolCount: extensionToolCount,
|
||||
UsageTracker: usageTracker,
|
||||
ExtensionCommands: extCommands,
|
||||
PromptTemplates: promptTemplates,
|
||||
GetPromptTemplates: getPromptTemplates,
|
||||
MCPPrompts: mcpPrompts,
|
||||
GetMCPPrompts: getMCPPrompts,
|
||||
ExpandMCPPrompt: expandMCPPrompt,
|
||||
ContextPaths: contextPaths,
|
||||
SkillItems: skillItems,
|
||||
GetSkillItems: getSkillItems,
|
||||
ExtensionItems: extensionItems,
|
||||
GetExtensionItems: getExtensionItems,
|
||||
StartupExtensionMessages: startupExtensionMessages,
|
||||
GetWidgets: getWidgets,
|
||||
GetHeader: getHeader,
|
||||
GetFooter: getFooter,
|
||||
GetToolRenderer: getToolRenderer,
|
||||
GetEditorInterceptor: getEditorInterceptor,
|
||||
GetUIVisibility: getUIVisibility,
|
||||
GetStatusBarEntries: getStatusBarEntries,
|
||||
EmitBeforeFork: emitBeforeFork,
|
||||
EmitBeforeSessionSwitch: emitBeforeSessionSwitch,
|
||||
GetGlobalShortcuts: getGlobalShortcuts,
|
||||
GetExtensionCommands: getExtensionCommands,
|
||||
SetModel: setModel,
|
||||
EmitModelChange: emitModelChange,
|
||||
ThinkingLevel: thinkingLevel,
|
||||
IsReasoningModel: isReasoningModel,
|
||||
SetThinkingLevel: setThinkingLevel,
|
||||
SwitchSession: switchSession,
|
||||
ReloadExtensions: reloadExtensions,
|
||||
ServerNames: deps.serverNames,
|
||||
ToolNames: deps.toolNames,
|
||||
GetToolNames: deps.getToolNames,
|
||||
GetMCPToolCount: deps.getMCPToolCount,
|
||||
MCPToolCount: deps.mcpToolCount,
|
||||
ExtensionToolCount: deps.extensionToolCount,
|
||||
UsageTracker: deps.usageTracker,
|
||||
ExtensionCommands: deps.extCommands,
|
||||
PromptTemplates: deps.promptTemplates,
|
||||
GetPromptTemplates: deps.getPromptTemplates,
|
||||
MCPPrompts: deps.mcpPrompts,
|
||||
GetMCPPrompts: deps.getMCPPrompts,
|
||||
ExpandMCPPrompt: deps.expandMCPPrompt,
|
||||
ContextPaths: deps.contextPaths,
|
||||
SkillItems: deps.skillItems,
|
||||
GetSkillItems: deps.getSkillItems,
|
||||
ExtensionItems: deps.extensionItems,
|
||||
GetExtensionItems: deps.getExtensionItems,
|
||||
StartupExtensionMessages: deps.startupExtensionMessages,
|
||||
GetWidgets: deps.getWidgets,
|
||||
GetHeader: deps.getHeader,
|
||||
GetFooter: deps.getFooter,
|
||||
GetToolRenderer: deps.getToolRenderer,
|
||||
GetEditorInterceptor: deps.getEditorInterceptor,
|
||||
GetUIVisibility: deps.getUIVisibility,
|
||||
GetStatusBarEntries: deps.getStatusBarEntries,
|
||||
EmitBeforeFork: deps.emitBeforeFork,
|
||||
EmitBeforeSessionSwitch: deps.emitBeforeSessionSwitch,
|
||||
GetGlobalShortcuts: deps.getGlobalShortcuts,
|
||||
GetExtensionCommands: deps.getExtensionCommands,
|
||||
SetModel: deps.setModel,
|
||||
EmitModelChange: deps.emitModelChange,
|
||||
ThinkingLevel: deps.thinkingLevel,
|
||||
IsReasoningModel: deps.isReasoningModel,
|
||||
SetThinkingLevel: deps.setThinkingLevel,
|
||||
SwitchSession: deps.switchSession,
|
||||
ReloadExtensions: deps.reloadExtensions,
|
||||
ShowSessionPicker: resumeFlag,
|
||||
GetMCPResources: mcpGetResources,
|
||||
MCPResourceReader: mcpResourceReader,
|
||||
|
||||
@@ -4,21 +4,22 @@ go 1.26.3
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.6
|
||||
charm.land/bubbletea/v2 v2.0.7
|
||||
charm.land/fantasy v0.25.0
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.3
|
||||
github.com/alecthomas/chroma/v2 v2.26.1
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/fang v1.0.0
|
||||
github.com/charmbracelet/log v1.0.0
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f
|
||||
github.com/charmbracelet/x/editor v0.2.0
|
||||
github.com/clipperhouse/displaywidth v0.11.0
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0
|
||||
github.com/coder/acp-go-sdk v0.13.0
|
||||
github.com/coder/acp-go-sdk v0.13.5
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/indaco/herald v0.13.0
|
||||
github.com/indaco/herald-md v0.3.0
|
||||
@@ -26,6 +27,7 @@ require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/traefik/yaegi v0.16.1
|
||||
golang.org/x/image v0.41.0
|
||||
golang.org/x/term v0.43.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -55,11 +57,10 @@ require (
|
||||
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
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260527151214-009e6338d40d // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260527151214-009e6338d40d // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
|
||||
@@ -113,7 +114,7 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260528193900-50dc527dd6c7 // indirect
|
||||
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
@@ -132,7 +133,7 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
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.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
|
||||
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
|
||||
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
|
||||
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
|
||||
charm.land/fantasy v0.25.0 h1:oXOWY1ivmTSnhYGzAolscF8zKtavWZyBWv0LHRSwN5Q=
|
||||
charm.land/fantasy v0.25.0/go.mod h1:8QrWUzIcKwZQP+aAnC9vLu3iID6hu9/Jt+rPMiieBkc=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
|
||||
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
|
||||
charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||
@@ -16,6 +18,11 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
|
||||
cloud.google.com/go/longrunning v0.5.6/go.mod h1:vUaDrWYOMKRuhiv6JBnn49YxCPz2Ayn9GqyjaBT8/mA=
|
||||
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
|
||||
cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
|
||||
cloud.google.com/go/translate v1.10.3/go.mod h1:GW0vC1qvPtd3pgtypCv4k4U8B7EdgK9/QEF2aJEUovs=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
@@ -24,14 +31,20 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6Xu
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Rhymond/go-money v1.0.15/go.mod h1:iHvCuIvitxu2JIlAlhF0g9jHqjRSr+rpdOs7Omqlupg=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78=
|
||||
github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y=
|
||||
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/ardanlabs/jinja v1.2.0/go.mod h1:aXXzlJfjA+T3XNKA/YT5ZtDq2VJxt5a5siZ8cl9B35Q=
|
||||
github.com/ardanlabs/kronk v1.25.2/go.mod h1:b5Gg4jDqvHDklkeHNB8+7treZRxUiCFsV65zphrTloY=
|
||||
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.8 h1:sRs7nG6/RiEBZ/K5UO2sNw0w40U02Nmz1VtARloTZXk=
|
||||
@@ -48,12 +61,16 @@ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24 h1:u6kJU2i0va1AgtJsH
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24/go.mod h1:7GY+xLcXOFUpCkNwDReft9qOAVg54A4/AnjHIU7sSAY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24 h1:Xhbcf3KugX6vX7SDyUK205Oicyfg7EGuvoVNyP5L6DM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24/go.mod h1:rwDgb2HNOGZsnTHylOUedM7Vnl+bCfnXDqUNPsFWYfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25 h1:54CTMmlJ71Rk2dYvM9qZOob+39wjlVja2zDLxCu69Ew=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25/go.mod h1:BZaHqxsS9vN1fvV5EfEl0OBLOk5+AajWsMu6MjqnZB4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 h1:CQW2FTrflfoslYWLf3fv7vG28Q219+v8YJS5QTQb2+Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24/go.mod h1:Xfx13T+u3nH6EEzgl9fBSO6nDRmze1FvnZNYkctQ2zw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.0 h1:yQo3eZ5qFaL1sJWqs1nL6j3yPHA2/R7c6tQ4T+0IO10=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.0/go.mod h1:3Zzou41Qt/ueXfIzHvTEjDNuR5IjCUBVF01SNhrt1e8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.18 h1:ApLTFdAZfDhZSiY5uskwECKHkSNNF83y2Ru2r7SezWA=
|
||||
@@ -68,8 +85,14 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
|
||||
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/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
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/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4=
|
||||
@@ -86,8 +109,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-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f h1:vKsPSlO4g4jKfJ9enESgNZ45BkbHngTIq3UxNOzic74=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
@@ -98,8 +121,8 @@ github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIR
|
||||
github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY=
|
||||
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-20260527151214-009e6338d40d h1:sMilwx1YIYTrQva6jsB522AoRYAerNaDIKP4ZPtUq0A=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260527151214-009e6338d40d/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a h1:aVvnksCVgxB2igk7jERL9ARIkbDXccp1gXCFqhGlamQ=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a/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=
|
||||
@@ -120,12 +143,13 @@ github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcO
|
||||
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/coder/acp-go-sdk v0.13.0 h1:IAKBDIbe/iBfKAGikeIndzb8fowt4ioD+gCtSU4HwMA=
|
||||
github.com/coder/acp-go-sdk v0.13.0/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
|
||||
github.com/coder/acp-go-sdk v0.13.5 h1:LI9jq5xon7xslaYlnoktvTVyDlE37yIk2daT7N9ASYk=
|
||||
github.com/coder/acp-go-sdk v0.13.5/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
@@ -137,11 +161,16 @@ github.com/dlclark/regexp2/v2 v2.1.1 h1:LCUGyd9Wf+r+VVOl8Ny38JTpWJcAsdVnCIuhhtth
|
||||
github.com/dlclark/regexp2/v2 v2.1.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/dromara/carbon/v2 v2.6.16/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/eliben/go-sentencepiece v0.6.0/go.mod h1:nNYk4aMzgBoI6QFp4LUG8Eu1uO9fHD9L5ZEre93o9+c=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
@@ -150,6 +179,7 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA=
|
||||
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
@@ -165,12 +195,17 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -181,14 +216,23 @@ github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU
|
||||
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
|
||||
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/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.72/go.mod h1:Vn+BBgKQHVQYdVQ4NZDICE1Brb+JfaONyDHr3q07oQc=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-getter v1.8.6/go.mod h1:nVH12eOV2P58dIiL3rsU6Fh3wLeJEKBOJzhMmzlSWoo=
|
||||
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
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/hybridgroup/yzma v1.13.0/go.mod h1:zrzMgv/KVQz23+s6l16b+vJ+9uJVBdWtGcGkwRTMeiQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/indaco/herald v0.13.0 h1:+xVG9Fx5NpuWhwku/9IlRL6I009NnX4VUGKvlZHTRxU=
|
||||
github.com/indaco/herald v0.13.0/go.mod h1:T5g1+XLYvpjouhzAGHnAHDCKizhESkoV6+QPZ3DhgWA=
|
||||
github.com/indaco/herald-md v0.3.0 h1:hN1cKyrexPPM9PeHBsKuaWvIizSi/iYvM9yzRgtdb8M=
|
||||
github.com/indaco/herald-md v0.3.0/go.mod h1:RUHVaDSG45ymJjKyxpDwBocLXrZo93FB4OeYMsw9B9s=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jupiterrider/ffi v0.7.0/go.mod h1:9dauhpOfNqrqk28fxuu0kkdeFtT9Qr4vbfigiuIXN7c=
|
||||
github.com/kaptinlin/go-i18n v0.4.5 h1:9tIlo5A0RXth+yZJO2MG7Bhpu/X9PlzQnGz/qyYWNoY=
|
||||
github.com/kaptinlin/go-i18n v0.4.5/go.mod h1:mU/7BH4molY5lGZYBwBRKAaiJ70dWRHuqmQ0/pFLGno=
|
||||
github.com/kaptinlin/jsonpointer v0.4.25 h1:iJ197e8n+WwqaqBsa53FqG3rPJCg5oijyFXEXNWWC3E=
|
||||
@@ -197,6 +241,7 @@ github.com/kaptinlin/jsonschema v0.7.13 h1:kahVXTy/rURL0XJjyQ9WELm59wEmXi6IY0TWs
|
||||
github.com/kaptinlin/jsonschema v0.7.13/go.mod h1:Uh0aUBusnhXDCEXJ2oimL/hx7YTo7F+sKniE+tM0ERc=
|
||||
github.com/kaptinlin/messageformat-go v0.6.0 h1:D6jiXFsKW4/JG2CMddv/F6Rev9KVbCRKEzzV5QOAcpc=
|
||||
github.com/kaptinlin/messageformat-go v0.6.0/go.mod h1:NKjwS6e9u7DRhAK+vydjDDwJ7UbdHhYjk/yk2WPuZPs=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
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=
|
||||
@@ -205,12 +250,14 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
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/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0=
|
||||
github.com/mark3labs/mcp-go v0.54.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
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=
|
||||
@@ -225,6 +272,7 @@ 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=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
@@ -233,6 +281,10 @@ 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/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
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=
|
||||
@@ -240,8 +292,10 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
@@ -253,6 +307,7 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
@@ -270,20 +325,27 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
|
||||
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
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/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
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/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
@@ -292,12 +354,18 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20260528193900-50dc527dd6c7 h1:cHpkPjp4TILjdZxz/O4ykwCpeS+dDqNuDGse4zgQDCk=
|
||||
golang.org/x/exp v0.0.0-20260528193900-50dc527dd6c7/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||
golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
|
||||
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
@@ -312,16 +380,21 @@ golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I=
|
||||
google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/genai v1.58.0 h1:MNA3ZkRyr7MnRwZ9RNZ60p4+UMKV3yYRw6pyHq4pp0U=
|
||||
google.golang.org/genai v1.58.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 h1:JjVGDZYWkJWZcxveJGzfkXC5myDVWAd4dZdgbzrDUv8=
|
||||
google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348/go.mod h1:95PqD4xM+AdOcBGsmgfaofXsiA37uXDtDufVbntT3TU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 h1:U8orV30l6KpDsi9dxU0CoJZGbjS8EEpw+6ba+XwGPQA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go.mod h1:Yzdzr5OOZFgSsEV2D/Xi9NL3bszpXFAg0hFJiRohcD8=
|
||||
google.golang.org/genproto/googleapis/bytestream v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:6TABGosqSqU2l1+fJ3jdvOYPPVryeKybxYF0cCZkTBE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
@@ -331,6 +404,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY=
|
||||
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -61,6 +61,12 @@ func (a *Agent) Authenticate(_ context.Context, _ acp.AuthenticateRequest) (acp.
|
||||
return acp.AuthenticateResponse{}, nil
|
||||
}
|
||||
|
||||
// Logout handles logout requests. Kit doesn't require auth for local stdio
|
||||
// usage, so this is a no-op.
|
||||
func (a *Agent) Logout(_ context.Context, _ acp.LogoutRequest) (acp.LogoutResponse, error) {
|
||||
return acp.LogoutResponse{}, nil
|
||||
}
|
||||
|
||||
// Initialize negotiates capabilities with the ACP client.
|
||||
func (a *Agent) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) {
|
||||
log.Debug("acp: initialize", "protocol_version", params.ProtocolVersion)
|
||||
|
||||
+3
-41
@@ -169,9 +169,9 @@ type RetryHandler func(attempt int, err error)
|
||||
type PrepareStepHandler func(stepNumber int, messages []fantasy.Message) []fantasy.Message
|
||||
|
||||
// GenerateCallbacks consolidates all callback functions for
|
||||
// GenerateWithLoopAndStreaming into a single struct. This replaces the previous
|
||||
// 16+ positional callback parameters, making it easier to add new callbacks
|
||||
// without breaking existing callers (new fields default to nil).
|
||||
// GenerateWithCallbacks into a single struct, replacing what was previously
|
||||
// 16+ positional callback parameters. New fields default to nil, so adding
|
||||
// new callbacks does not break existing callers.
|
||||
type GenerateCallbacks struct {
|
||||
OnToolCall ToolCallHandler
|
||||
OnToolExecution ToolExecutionHandler
|
||||
@@ -522,44 +522,6 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateWithLoopAndStreaming processes messages using the agent with streaming and callbacks.
|
||||
// The agent handles the tool call loop internally.
|
||||
//
|
||||
// Deprecated: Use GenerateWithCallbacks instead, which takes a GenerateCallbacks
|
||||
// struct and is easier to extend with new callbacks.
|
||||
func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fantasy.Message,
|
||||
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
onStreamingResponse StreamingResponseHandler,
|
||||
onReasoningDelta ReasoningDeltaHandler,
|
||||
onReasoningComplete ReasoningCompleteHandler,
|
||||
onToolOutput ToolOutputHandler,
|
||||
onStepMessages StepMessagesHandler,
|
||||
onStepUsage StepUsageHandler,
|
||||
onPasswordPrompt PasswordPromptHandler,
|
||||
onToolCallStart ToolCallStartHandler,
|
||||
onToolCallDelta ToolCallDeltaHandler,
|
||||
onToolCallEnd ToolCallEndHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
return a.GenerateWithCallbacks(ctx, messages, GenerateCallbacks{
|
||||
OnToolCall: onToolCall,
|
||||
OnToolExecution: onToolExecution,
|
||||
OnToolResult: onToolResult,
|
||||
OnResponse: onResponse,
|
||||
OnToolCallContent: onToolCallContent,
|
||||
OnStreamingResponse: onStreamingResponse,
|
||||
OnReasoningDelta: onReasoningDelta,
|
||||
OnReasoningComplete: onReasoningComplete,
|
||||
OnToolOutput: onToolOutput,
|
||||
OnStepMessages: onStepMessages,
|
||||
OnStepUsage: onStepUsage,
|
||||
OnPasswordPrompt: onPasswordPrompt,
|
||||
OnToolCallStart: onToolCallStart,
|
||||
OnToolCallDelta: onToolCallDelta,
|
||||
OnToolCallEnd: onToolCallEnd,
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateWithCallbacks 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.
|
||||
|
||||
+57
-63
@@ -249,34 +249,37 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
return executeBashBuffered(cmdCtx, call, cmd, sudoPassword)
|
||||
}
|
||||
|
||||
// executeBashBuffered collects all output before returning (original behavior).
|
||||
// It uses explicit pipes (not cmd.Stdout) so that cmd.WaitDelay can forcibly
|
||||
// close them when grandchild processes hold pipe handles open after the
|
||||
// direct child exits.
|
||||
func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, sudoPassword string) (fantasy.ToolResponse, error) {
|
||||
// setupBashPipes opens stdout/stderr pipes (plus an optional sudo stdin),
|
||||
// starts the command, and asynchronously writes the sudo password if any.
|
||||
// Returns the readers ready for the caller to consume. If setup fails,
|
||||
// errResp is non-nil and the readers must not be used; the caller should
|
||||
// return the response directly.
|
||||
func setupBashPipes(cmd *exec.Cmd, sudoPassword string) (stdout, stderr io.Reader, errResp *fantasy.ToolResponse) {
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
|
||||
r := fantasy.NewTextErrorResponse("failed to create stdout pipe")
|
||||
return nil, nil, &r
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
|
||||
r := fantasy.NewTextErrorResponse("failed to create stderr pipe")
|
||||
return nil, nil, &r
|
||||
}
|
||||
|
||||
// If we have a sudo password, create a stdin pipe and write the password
|
||||
var stdinPipe io.WriteCloser
|
||||
if sudoPassword != "" {
|
||||
stdinPipe, err = cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdin pipe"), nil
|
||||
r := fantasy.NewTextErrorResponse("failed to create stdin pipe")
|
||||
return nil, nil, &r
|
||||
}
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
|
||||
r := fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err))
|
||||
return nil, nil, &r
|
||||
}
|
||||
|
||||
// Write password to stdin if needed, then close stdin
|
||||
if sudoPassword != "" && stdinPipe != nil {
|
||||
go func() {
|
||||
defer func() { _ = stdinPipe.Close() }()
|
||||
@@ -284,19 +287,49 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
|
||||
}()
|
||||
}
|
||||
|
||||
return stdoutPipe, stderrPipe, nil
|
||||
}
|
||||
|
||||
// interpretBashExit decodes cmd.Wait()'s error into an exit code, mapping
|
||||
// context-deadline-exceeded to a friendly "command timed out" response.
|
||||
// errResp is non-nil only when the caller should short-circuit and return
|
||||
// it directly (e.g. timeout).
|
||||
func interpretBashExit(waitErr error, cmdCtx context.Context) (exitCode int, errResp *fantasy.ToolResponse) {
|
||||
if waitErr == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
return exitErr.ExitCode(), nil
|
||||
}
|
||||
if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
r := fantasy.NewTextErrorResponse("command timed out")
|
||||
return 0, &r
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// executeBashBuffered collects all output before returning (original behavior).
|
||||
// It uses explicit pipes (not cmd.Stdout) so that cmd.WaitDelay can forcibly
|
||||
// close them when grandchild processes hold pipe handles open after the
|
||||
// direct child exits.
|
||||
func executeBashBuffered(cmdCtx context.Context, _ fantasy.ToolCall, cmd *exec.Cmd, sudoPassword string) (fantasy.ToolResponse, error) {
|
||||
stdoutPipe, stderrPipe, errResp := setupBashPipes(cmd, sudoPassword)
|
||||
if errResp != nil {
|
||||
return *errResp, nil
|
||||
}
|
||||
|
||||
// Read pipes concurrently
|
||||
var wg sync.WaitGroup
|
||||
var stdout, stderr strings.Builder
|
||||
var stdoutErr, stderrErr error
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, stdoutErr = io.Copy(&stdout, stdoutPipe)
|
||||
_, _ = io.Copy(&stdout, stdoutPipe)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, stderrErr = io.Copy(&stderr, stderrPipe)
|
||||
_, _ = io.Copy(&stderr, stderrPipe)
|
||||
}()
|
||||
|
||||
// Wait for the process to exit first. cmd.WaitDelay ensures that if
|
||||
@@ -307,18 +340,9 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
|
||||
// Wait for pipe readers to finish draining.
|
||||
wg.Wait()
|
||||
|
||||
// Ignore pipe read errors caused by WaitDelay force-closing —
|
||||
// we still have whatever was read before the close.
|
||||
_ = stdoutErr
|
||||
_ = stderrErr
|
||||
|
||||
exitCode := 0
|
||||
if waitErr != nil {
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
return fantasy.NewTextErrorResponse("command timed out"), nil
|
||||
}
|
||||
exitCode, errResp := interpretBashExit(waitErr, cmdCtx)
|
||||
if errResp != nil {
|
||||
return *errResp, nil
|
||||
}
|
||||
|
||||
return buildBashResponse(stdout.String(), stderr.String(), exitCode)
|
||||
@@ -326,35 +350,9 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
|
||||
|
||||
// executeBashStreaming streams output as it arrives via the callback.
|
||||
func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, outputCallback ToolOutputCallback, sudoPassword string) (fantasy.ToolResponse, error) {
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
|
||||
}
|
||||
|
||||
// If we have a sudo password, create a stdin pipe
|
||||
var stdinPipe io.WriteCloser
|
||||
if sudoPassword != "" {
|
||||
stdinPipe, err = cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdin pipe"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Start command execution
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
|
||||
}
|
||||
|
||||
// Write password to stdin if needed, then close stdin
|
||||
if sudoPassword != "" && stdinPipe != nil {
|
||||
go func() {
|
||||
defer func() { _ = stdinPipe.Close() }()
|
||||
_, _ = io.WriteString(stdinPipe, sudoPassword+"\n")
|
||||
}()
|
||||
stdoutPipe, stderrPipe, errResp := setupBashPipes(cmd, sudoPassword)
|
||||
if errResp != nil {
|
||||
return *errResp, nil
|
||||
}
|
||||
|
||||
// Stream stdout and stderr concurrently
|
||||
@@ -391,20 +389,16 @@ func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *ex
|
||||
// Wait for the process to exit. cmd.WaitDelay ensures that if pipes
|
||||
// remain open (held by grandchild processes), they'll be forcibly closed
|
||||
// after the grace period, which unblocks the scanners above.
|
||||
err = cmd.Wait()
|
||||
waitErr := cmd.Wait()
|
||||
|
||||
// Wait for the pipe readers to finish draining. This will complete
|
||||
// quickly since cmd.Wait() (with WaitDelay) has already ensured
|
||||
// the pipes are closed.
|
||||
wg.Wait()
|
||||
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
return fantasy.NewTextErrorResponse("command timed out"), nil
|
||||
}
|
||||
exitCode, errResp := interpretBashExit(waitErr, cmdCtx)
|
||||
if errResp != nil {
|
||||
return *errResp, nil
|
||||
}
|
||||
|
||||
return buildBashResponse(strings.Join(stdoutChunks, "\n"), strings.Join(stderrChunks, "\n"), exitCode)
|
||||
|
||||
@@ -83,6 +83,9 @@ func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
}
|
||||
|
||||
func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fantasy.ToolResponse{}, err
|
||||
}
|
||||
var args editArgs
|
||||
if err := parseArgs(call.Input, &args); err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to parse arguments: " + err.Error()), nil
|
||||
|
||||
@@ -42,6 +42,9 @@ func NewLsTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
}
|
||||
|
||||
func executeLs(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fantasy.ToolResponse{}, err
|
||||
}
|
||||
var args lsArgs
|
||||
_ = parseArgs(call.Input, &args) // optional args
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ func NewReadTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
}
|
||||
|
||||
func executeRead(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fantasy.ToolResponse{}, err
|
||||
}
|
||||
var args readArgs
|
||||
if err := parseArgs(call.Input, &args); err != nil {
|
||||
return fantasy.NewTextErrorResponse("path parameter is required"), nil
|
||||
|
||||
@@ -41,6 +41,9 @@ func NewWriteTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
}
|
||||
|
||||
func executeWrite(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fantasy.ToolResponse{}, err
|
||||
}
|
||||
var args writeArgs
|
||||
if err := parseArgs(call.Input, &args); err != nil {
|
||||
return fantasy.NewTextErrorResponse("path and content parameters are required"), nil
|
||||
|
||||
+24
-157
@@ -1,143 +1,32 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/mark3labs/kit/internal/watcher"
|
||||
)
|
||||
|
||||
// Watcher monitors extension directories for file changes and triggers
|
||||
// a reload callback when .go files are created, modified, or removed.
|
||||
// It uses fsnotify for kernel-level file notifications (inotify on Linux,
|
||||
// kqueue on macOS) with debouncing to coalesce rapid editor writes.
|
||||
type Watcher struct {
|
||||
watcher *fsnotify.Watcher
|
||||
onReload func()
|
||||
debounce time.Duration
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
// Watcher monitors extension directories for .go file changes and triggers
|
||||
// a reload callback when changes are detected. It is implemented in terms
|
||||
// of the general-purpose internal/watcher.ContentWatcher.
|
||||
//
|
||||
// Type-aliasing here lets existing call sites (cmd/root.go and the
|
||||
// watcher_test.go suite) keep using `extensions.NewWatcher` / `*Watcher`
|
||||
// without knowing about the underlying implementation.
|
||||
type Watcher = watcher.ContentWatcher
|
||||
|
||||
// NewWatcher creates a file watcher that monitors the given directories
|
||||
// for .go file changes. When a change is detected (after debouncing),
|
||||
// onReload is called. The watcher must be started with Start() and
|
||||
// stopped with Close().
|
||||
func NewWatcher(dirs []string, onReload func()) (*Watcher, error) {
|
||||
fsw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating file watcher: %w", err)
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
// Watch the directory itself.
|
||||
if err := fsw.Add(dir); err != nil {
|
||||
log.Printf("DEBUG watcher: skipping directory: dir=%s err=%v", dir, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Also watch immediate subdirectories (for */main.go pattern).
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
subdir := filepath.Join(dir, entry.Name())
|
||||
if err := fsw.Add(subdir); err != nil {
|
||||
log.Printf("DEBUG watcher: skipping subdirectory: dir=%s err=%v", subdir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &Watcher{
|
||||
watcher: fsw,
|
||||
onReload: onReload,
|
||||
debounce: 300 * time.Millisecond,
|
||||
done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins watching for file changes. It blocks until the context
|
||||
// is cancelled or Close() is called. Typically called in a goroutine.
|
||||
func (w *Watcher) Start(ctx context.Context) {
|
||||
w.mu.Lock()
|
||||
ctx, w.cancel = context.WithCancel(ctx)
|
||||
w.mu.Unlock()
|
||||
|
||||
defer close(w.done)
|
||||
|
||||
var timer *time.Timer
|
||||
var timerC <-chan time.Time
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
return
|
||||
|
||||
case event, ok := <-w.watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Only care about .go files.
|
||||
if !strings.HasSuffix(event.Name, ".go") {
|
||||
continue
|
||||
}
|
||||
|
||||
// React to write, create, remove, rename events.
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("DEBUG watcher: file changed: file=%s op=%s", event.Name, event.Op)
|
||||
|
||||
// Debounce: reset timer on each event.
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
timer = time.NewTimer(w.debounce)
|
||||
timerC = timer.C
|
||||
|
||||
case <-timerC:
|
||||
timerC = nil
|
||||
timer = nil
|
||||
log.Printf("DEBUG watcher: reloading extensions")
|
||||
w.onReload()
|
||||
|
||||
case err, ok := <-w.watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Printf("WARN watcher: error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the watcher and releases resources.
|
||||
func (w *Watcher) Close() error {
|
||||
w.mu.Lock()
|
||||
cancel := w.cancel
|
||||
w.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
|
||||
// Wait for the event loop to finish.
|
||||
<-w.done
|
||||
return w.watcher.Close()
|
||||
return watcher.New(watcher.Options{
|
||||
Dirs: dirs,
|
||||
Extensions: []string{".go"},
|
||||
OnReload: onReload,
|
||||
Label: "extensions",
|
||||
})
|
||||
}
|
||||
|
||||
// WatchedDirs returns the directories to watch for extension changes.
|
||||
@@ -146,47 +35,25 @@ func (w *Watcher) Close() error {
|
||||
// point to directories are also included; explicit file paths cause
|
||||
// their parent directory to be watched instead.
|
||||
func WatchedDirs(extraPaths []string) []string {
|
||||
var dirs []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
add := func(dir string) {
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if seen[abs] {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the directory exists.
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil || !info.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
seen[abs] = true
|
||||
dirs = append(dirs, abs)
|
||||
standard := []string{
|
||||
globalExtensionsDir(),
|
||||
filepath.Join(".kit", "extensions"),
|
||||
}
|
||||
|
||||
// Global extensions dir.
|
||||
add(globalExtensionsDir())
|
||||
|
||||
// Project-local extensions dir.
|
||||
add(filepath.Join(".kit", "extensions"))
|
||||
|
||||
// Explicit paths that are directories.
|
||||
// Filter explicit paths into directories (passed through) and files
|
||||
// (parent dir watched) for CollectDirs to dedupe.
|
||||
var extras []string
|
||||
for _, p := range extraPaths {
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
add(p)
|
||||
extras = append(extras, p)
|
||||
} else {
|
||||
// For explicit files, watch the parent directory.
|
||||
add(filepath.Dir(p))
|
||||
extras = append(extras, filepath.Dir(p))
|
||||
}
|
||||
}
|
||||
|
||||
return dirs
|
||||
return watcher.CollectDirs(standard, extras)
|
||||
}
|
||||
|
||||
@@ -10,15 +10,31 @@ import (
|
||||
)
|
||||
|
||||
// TestNpmToWireProtocol documents the wire protocols that the auto-router
|
||||
// understands. Provider-specific bundles (azure, bedrock, vercel, openrouter,
|
||||
// google-vertex*) are intentionally absent — they have native top-level cases
|
||||
// in CreateProvider and never reach the auto-router.
|
||||
// understands. Provider-specific bundles that need bespoke auth or URL
|
||||
// templating (azure, bedrock, openrouter, google-vertex*, @ai-sdk/gateway)
|
||||
// are intentionally absent — they have native top-level cases in
|
||||
// CreateProvider and never reach the auto-router.
|
||||
func TestNpmToWireProtocol(t *testing.T) {
|
||||
want := map[string]wireProtocol{
|
||||
"@ai-sdk/openai": wireOpenAI,
|
||||
"@ai-sdk/openai-compatible": wireOpenAI,
|
||||
"@ai-sdk/anthropic": wireAnthropic,
|
||||
"@ai-sdk/google": wireGoogle,
|
||||
|
||||
// Thin OpenAI-compatible wrappers — routed via openaicompat using
|
||||
// the SDK's hard-coded default base URL (sdkDefaultBaseURL).
|
||||
"@ai-sdk/groq": wireOpenAI,
|
||||
"@ai-sdk/cerebras": wireOpenAI,
|
||||
"@ai-sdk/perplexity": wireOpenAI,
|
||||
"@ai-sdk/togetherai": wireOpenAI,
|
||||
"@ai-sdk/xai": wireOpenAI,
|
||||
"@ai-sdk/deepinfra": wireOpenAI,
|
||||
"@ai-sdk/mistral": wireOpenAI,
|
||||
"@ai-sdk/cohere": wireOpenAI,
|
||||
"@ai-sdk/vercel": wireOpenAI,
|
||||
"@aihubmix/ai-sdk-provider": wireOpenAI,
|
||||
"venice-ai-sdk-provider": wireOpenAI,
|
||||
"merge-gateway-ai-sdk-provider": wireOpenAI,
|
||||
}
|
||||
for npm, wire := range want {
|
||||
if got := npmToWireProtocol[npm]; got != wire {
|
||||
@@ -26,15 +42,15 @@ func TestNpmToWireProtocol(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Bundle packages must NOT be in the table (regression guard against the
|
||||
// old npmToLLMProvider map that listed 10 entries but only handled 3).
|
||||
// Bundle packages must NOT be in the table — they need bespoke auth or
|
||||
// URL templating that the auto-router cannot satisfy.
|
||||
for _, npm := range []string{
|
||||
"@ai-sdk/google-vertex",
|
||||
"@ai-sdk/google-vertex/anthropic",
|
||||
"@ai-sdk/amazon-bedrock",
|
||||
"@ai-sdk/azure",
|
||||
"@openrouter/ai-sdk-provider",
|
||||
"@ai-sdk/vercel",
|
||||
"@ai-sdk/gateway",
|
||||
} {
|
||||
if _, ok := npmToWireProtocol[npm]; ok {
|
||||
t.Errorf("npmToWireProtocol unexpectedly contains bundle package %q", npm)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -62,14 +62,73 @@ const (
|
||||
)
|
||||
|
||||
// npmToWireProtocol maps npm package names from models.dev to the wire
|
||||
// protocol they speak. Provider-specific bundles (azure, bedrock, vercel,
|
||||
// openrouter, google-vertex, google-vertex-anthropic) are intentionally
|
||||
// absent — they have native top-level cases in CreateProvider and never
|
||||
// reach the auto-router. Providers not in this map but with an api URL
|
||||
// are auto-routed through the OpenAI-compatible wire.
|
||||
// protocol they speak. Provider-specific bundles that need bespoke auth or
|
||||
// URL templating (azure, bedrock, openrouter, google-vertex, google-vertex-
|
||||
// anthropic, and @ai-sdk/gateway which is the Vercel AI Gateway) are
|
||||
// intentionally absent — they have native top-level cases in CreateProvider
|
||||
// and never reach the auto-router. Providers not in this map but with an
|
||||
// api URL are auto-routed through the OpenAI-compatible wire.
|
||||
//
|
||||
// The thin OpenAI-compatible npm wrappers (groq, cerebras, mistral, …) are
|
||||
// listed explicitly so that auto-routing can recover their hard-coded base
|
||||
// URL from sdkDefaultBaseURL when the registry entry has no api field.
|
||||
var npmToWireProtocol = map[string]wireProtocol{
|
||||
// Native wires.
|
||||
"@ai-sdk/openai": wireOpenAI,
|
||||
"@ai-sdk/openai-compatible": wireOpenAI,
|
||||
"@ai-sdk/anthropic": wireAnthropic,
|
||||
"@ai-sdk/google": wireGoogle,
|
||||
|
||||
// Thin OpenAI-compatible wrappers. Each ships with a hard-coded base URL
|
||||
// in its JS SDK (see sdkDefaultBaseURL) but speaks the plain OpenAI chat
|
||||
// completions wire — so we can route them all through fantasy's
|
||||
// openaicompat provider once we supply the URL.
|
||||
"@ai-sdk/groq": wireOpenAI,
|
||||
"@ai-sdk/cerebras": wireOpenAI,
|
||||
"@ai-sdk/perplexity": wireOpenAI,
|
||||
"@ai-sdk/togetherai": wireOpenAI,
|
||||
"@ai-sdk/xai": wireOpenAI,
|
||||
"@ai-sdk/deepinfra": wireOpenAI,
|
||||
"@ai-sdk/mistral": wireOpenAI,
|
||||
"@ai-sdk/cohere": wireOpenAI,
|
||||
"@ai-sdk/vercel": wireOpenAI, // v0 API (api.v0.dev), distinct from @ai-sdk/gateway
|
||||
"@aihubmix/ai-sdk-provider": wireOpenAI,
|
||||
"venice-ai-sdk-provider": wireOpenAI,
|
||||
"merge-gateway-ai-sdk-provider": wireOpenAI,
|
||||
}
|
||||
|
||||
// sdkDefaultBaseURL maps an npm package name to the base URL its JavaScript
|
||||
// SDK uses by default. This lets us recover a working endpoint for providers
|
||||
// whose models.dev entry omits the `api` field because the JS SDK hard-codes
|
||||
// the URL (e.g. groq, cerebras, mistral, x.ai…).
|
||||
//
|
||||
// Only OpenAI-compatible and native-wire SDKs are listed; providers needing
|
||||
// bespoke auth or URL templating (bedrock SigV4, azure resource URLs,
|
||||
// google-vertex project/location, cloudflare gateway account IDs, gitlab,
|
||||
// sap-ai-core) are handled by native CreateProvider cases or surface a
|
||||
// targeted error that asks the user to supply --provider-url.
|
||||
var sdkDefaultBaseURL = map[string]string{
|
||||
// Native wires.
|
||||
"@ai-sdk/openai": "https://api.openai.com/v1",
|
||||
"@ai-sdk/anthropic": "https://api.anthropic.com/v1",
|
||||
"@ai-sdk/google": "https://generativelanguage.googleapis.com/v1beta",
|
||||
|
||||
// Thin OpenAI-compatible wrappers.
|
||||
"@ai-sdk/groq": "https://api.groq.com/openai/v1",
|
||||
"@ai-sdk/cerebras": "https://api.cerebras.ai/v1",
|
||||
"@ai-sdk/perplexity": "https://api.perplexity.ai",
|
||||
"@ai-sdk/togetherai": "https://api.together.xyz/v1",
|
||||
"@ai-sdk/xai": "https://api.x.ai/v1",
|
||||
"@ai-sdk/deepinfra": "https://api.deepinfra.com/v1/openai",
|
||||
"@ai-sdk/mistral": "https://api.mistral.ai/v1",
|
||||
"@ai-sdk/cohere": "https://api.cohere.com/compatibility/v1",
|
||||
"@ai-sdk/vercel": "https://api.v0.dev/v1",
|
||||
"@aihubmix/ai-sdk-provider": "https://aihubmix.com/v1",
|
||||
"venice-ai-sdk-provider": "https://api.venice.ai/api/v1",
|
||||
"merge-gateway-ai-sdk-provider": "https://api-gateway.merge.dev/v1/ai-sdk",
|
||||
|
||||
// Native handlers — included for ResolveProviderBaseURL introspection
|
||||
// even though CreateProvider routes these via dedicated cases.
|
||||
"@ai-sdk/gateway": "https://ai-gateway.vercel.sh/v1",
|
||||
"@openrouter/ai-sdk-provider": "https://openrouter.ai/api/v1",
|
||||
}
|
||||
|
||||
@@ -286,13 +286,15 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
|
||||
result, createErr = createGoogleProvider(ctx, config, modelName)
|
||||
case "ollama":
|
||||
result, createErr = createOllamaProvider(ctx, config, modelName)
|
||||
case "azure":
|
||||
case "azure", "azure-cognitive-services":
|
||||
result, createErr = createAzureProvider(ctx, config, modelName)
|
||||
case "google-vertex-anthropic":
|
||||
result, createErr = createVertexAnthropicProvider(ctx, config, modelName)
|
||||
case "google-vertex":
|
||||
result, createErr = createGoogleVertexProvider(ctx, config, modelName)
|
||||
case "openrouter":
|
||||
result, createErr = createOpenRouterProvider(ctx, config, modelName)
|
||||
case "bedrock":
|
||||
case "bedrock", "amazon-bedrock":
|
||||
result, createErr = createBedrockProvider(ctx, config, modelName)
|
||||
case "vercel":
|
||||
result, createErr = createVercelProvider(ctx, config, modelName)
|
||||
@@ -376,8 +378,27 @@ func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, mo
|
||||
}
|
||||
|
||||
// All three wires use the provider's API URL from models.dev as the base.
|
||||
if config.ProviderURL == "" && providerInfo.API != "" {
|
||||
config.ProviderURL = providerInfo.API
|
||||
// When the registry has none, fall back to the SDK's hard-coded default for
|
||||
// this npm package (covers groq, cerebras, mistral, x.ai, etc. — providers
|
||||
// whose JS SDK ships a built-in baseURL that models.dev doesn't restate).
|
||||
if config.ProviderURL == "" {
|
||||
if providerInfo.API != "" {
|
||||
config.ProviderURL = providerInfo.API
|
||||
} else if defaultURL, ok := sdkDefaultBaseURL[npmPackage]; ok {
|
||||
config.ProviderURL = defaultURL
|
||||
providerInfo.API = defaultURL // for downstream helpers that read info.API
|
||||
}
|
||||
}
|
||||
|
||||
// Provider templates a runtime account/region/deployment segment into the
|
||||
// URL (cloudflare-ai-gateway, databricks, snowflake-cortex, gitlab,
|
||||
// sap-ai-core). Resolve via environment variables, or surface a targeted
|
||||
// error pointing the user at the right knobs.
|
||||
if resolved, err := resolveTemplatedAPIURL(config.ProviderURL, providerInfo); err != nil {
|
||||
return nil, err
|
||||
} else if resolved != "" {
|
||||
config.ProviderURL = resolved
|
||||
providerInfo.API = resolved
|
||||
}
|
||||
|
||||
switch wire {
|
||||
@@ -398,6 +419,24 @@ func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, mo
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAutoRouteAPIKey looks up the API key for an auto-routed provider,
|
||||
// returning a uniform error message when none can be resolved.
|
||||
func resolveAutoRouteAPIKey(config *ProviderConfig, info *ProviderInfo) (string, error) {
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return "", fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
info.Name, strings.Join(info.Env, " / "))
|
||||
}
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
// wrapProviderErr produces the uniform "failed to create X provider/model: %w"
|
||||
// error wrap used by every createXxxProvider path. kind is typically
|
||||
// "provider" or "model".
|
||||
func wrapProviderErr(name, kind string, err error) error {
|
||||
return fmt.Errorf("failed to create %s %s: %w", name, kind, err)
|
||||
}
|
||||
|
||||
// createAutoRoutedOpenAICompatProvider creates an openaicompat provider using
|
||||
// the api URL and env vars from models.dev.
|
||||
func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
|
||||
@@ -409,10 +448,9 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
|
||||
return nil, fmt.Errorf("provider %s requires --provider-url (no API URL in database)", info.ID)
|
||||
}
|
||||
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
info.Name, strings.Join(info.Env, " / "))
|
||||
apiKey, err := resolveAutoRouteAPIKey(config, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts []openaicompat.Option
|
||||
@@ -426,12 +464,12 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
|
||||
|
||||
p, err := openaicompat.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -442,10 +480,9 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
|
||||
func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
|
||||
clearConflictingAnthropicSamplingParams(config)
|
||||
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
info.Name, strings.Join(info.Env, " / "))
|
||||
apiKey, err := resolveAutoRouteAPIKey(config, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts []anthropic.Option
|
||||
@@ -464,12 +501,12 @@ func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConf
|
||||
|
||||
p, err := anthropic.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -478,10 +515,9 @@ func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConf
|
||||
// createAutoRoutedOpenAIProvider creates an openai provider for
|
||||
// third-party providers with openai-compatible APIs.
|
||||
func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
info.Name, strings.Join(info.Env, " / "))
|
||||
apiKey, err := resolveAutoRouteAPIKey(config, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts []openai.Option
|
||||
@@ -498,12 +534,12 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
|
||||
|
||||
p, err := openai.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "model", err)
|
||||
}
|
||||
|
||||
providerOpts := buildOpenAIProviderOptions(config, modelName)
|
||||
@@ -522,10 +558,9 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
|
||||
// path that the proxy rejects. In that case we install a transport that
|
||||
// strips the injected segment so the proxy's own version is used.
|
||||
func createAutoRoutedGoogleProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
info.Name, strings.Join(info.Env, " / "))
|
||||
apiKey, err := resolveAutoRouteAPIKey(config, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := []google.Option{
|
||||
@@ -550,12 +585,12 @@ func createAutoRoutedGoogleProvider(ctx context.Context, config *ProviderConfig,
|
||||
|
||||
p, err := google.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
|
||||
return nil, wrapProviderErr(info.Name, "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -859,12 +894,12 @@ func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelN
|
||||
|
||||
provider, err := anthropic.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Anthropic provider: %w", err)
|
||||
return nil, wrapProviderErr("Anthropic", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Anthropic model: %w", err)
|
||||
return nil, wrapProviderErr("Anthropic", "model", err)
|
||||
}
|
||||
|
||||
// Build provider options for extended thinking (reasoning budget).
|
||||
@@ -901,12 +936,12 @@ func createVertexAnthropicProvider(ctx context.Context, config *ProviderConfig,
|
||||
|
||||
provider, err := anthropic.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Vertex Anthropic provider: %w", err)
|
||||
return nil, wrapProviderErr("Vertex Anthropic", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Vertex Anthropic model: %w", err)
|
||||
return nil, wrapProviderErr("Vertex Anthropic", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -974,12 +1009,12 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
provider, err := openai.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenAI provider: %w", err)
|
||||
return nil, wrapProviderErr("OpenAI", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenAI model: %w", err)
|
||||
return nil, wrapProviderErr("OpenAI", "model", err)
|
||||
}
|
||||
|
||||
// Build provider options for OpenAI Responses API reasoning models.
|
||||
@@ -1015,12 +1050,12 @@ func createOpenAICodexProvider(ctx context.Context, config *ProviderConfig, mode
|
||||
|
||||
provider, err := openai.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenAI Codex provider: %w", err)
|
||||
return nil, wrapProviderErr("OpenAI Codex", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenAI Codex model: %w", err)
|
||||
return nil, wrapProviderErr("OpenAI Codex", "model", err)
|
||||
}
|
||||
|
||||
providerOpts := buildCodexProviderOptions(config, modelName)
|
||||
@@ -1133,12 +1168,12 @@ func createGoogleProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
provider, err := google.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Google provider: %w", err)
|
||||
return nil, wrapProviderErr("Google", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Google model: %w", err)
|
||||
return nil, wrapProviderErr("Google", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -1171,12 +1206,12 @@ func createAzureProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
provider, err := azure.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Azure OpenAI provider: %w", err)
|
||||
return nil, wrapProviderErr("Azure OpenAI", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Azure OpenAI model: %w", err)
|
||||
return nil, wrapProviderErr("Azure OpenAI", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -1196,12 +1231,12 @@ func createOpenRouterProvider(ctx context.Context, config *ProviderConfig, model
|
||||
|
||||
provider, err := openrouter.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenRouter provider: %w", err)
|
||||
return nil, wrapProviderErr("OpenRouter", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenRouter model: %w", err)
|
||||
return nil, wrapProviderErr("OpenRouter", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -1213,12 +1248,12 @@ func createBedrockProvider(ctx context.Context, config *ProviderConfig, modelNam
|
||||
// Bedrock uses AWS SDK default credential chain (env vars, shared config, etc.)
|
||||
provider, err := bedrock.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Bedrock provider: %w", err)
|
||||
return nil, wrapProviderErr("Bedrock", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Bedrock model: %w", err)
|
||||
return nil, wrapProviderErr("Bedrock", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -1242,12 +1277,12 @@ func createVercelProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
provider, err := vercel.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Vercel provider: %w", err)
|
||||
return nil, wrapProviderErr("Vercel", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Vercel model: %w", err)
|
||||
return nil, wrapProviderErr("Vercel", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -1300,12 +1335,12 @@ func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
p, err := openai.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create custom provider: %w", err)
|
||||
return nil, wrapProviderErr("custom", "provider", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create custom model: %w", err)
|
||||
return nil, wrapProviderErr("custom", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
@@ -1349,12 +1384,12 @@ func createOllamaProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
provider, err := openaicompat.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Ollama provider: %w", err)
|
||||
return nil, wrapProviderErr("Ollama", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Ollama model: %w", err)
|
||||
return nil, wrapProviderErr("Ollama", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy/providers/google"
|
||||
)
|
||||
|
||||
// templatePlaceholderRe matches "${NAME}" placeholders in URL templates from
|
||||
// models.dev (e.g. "https://${DATABRICKS_HOST}/ai-gateway/mlflow/v1").
|
||||
var templatePlaceholderRe = regexp.MustCompile(`\$\{([A-Z0-9_]+)\}`)
|
||||
|
||||
// templateEnvVarOverrides supplies fallback environment variable names for
|
||||
// placeholders that providers commonly use under non-obvious env names.
|
||||
// The placeholder name itself is always tried first; this map adds extra
|
||||
// names to try when the placeholder doesn't match the canonical env var.
|
||||
var templateEnvVarOverrides = map[string][]string{
|
||||
"CLOUDFLARE_ACCOUNT_ID": {"CF_ACCOUNT_ID"},
|
||||
"CLOUDFLARE_GATEWAY_NAME": {"CF_GATEWAY", "CLOUDFLARE_GATEWAY"},
|
||||
"DATABRICKS_HOST": {"DATABRICKS_WORKSPACE_URL"},
|
||||
"SNOWFLAKE_ACCOUNT": {"SNOWFLAKE_ACCOUNT_ID"},
|
||||
}
|
||||
|
||||
// resolveTemplatedAPIURL substitutes "${VAR}" placeholders in apiURL with the
|
||||
// values of the named environment variables. Returns:
|
||||
// - ("", nil) when apiURL contains no placeholders (caller keeps current URL),
|
||||
// - (resolved, nil) when every placeholder was resolved,
|
||||
// - ("", error) when one or more placeholders are unset, with a message that
|
||||
// names the missing env vars and points at the relevant provider.
|
||||
//
|
||||
// The info parameter is used purely for error messaging (provider name).
|
||||
func resolveTemplatedAPIURL(apiURL string, info *ProviderInfo) (string, error) {
|
||||
if apiURL == "" || !strings.Contains(apiURL, "${") {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var missing []string
|
||||
resolved := templatePlaceholderRe.ReplaceAllStringFunc(apiURL, func(match string) string {
|
||||
// match is "${NAME}". Extract NAME.
|
||||
name := match[2 : len(match)-1]
|
||||
if v := os.Getenv(name); v != "" {
|
||||
return v
|
||||
}
|
||||
for _, alt := range templateEnvVarOverrides[name] {
|
||||
if v := os.Getenv(alt); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
missing = append(missing, name)
|
||||
return match
|
||||
})
|
||||
|
||||
if len(missing) > 0 {
|
||||
providerName := info.ID
|
||||
if info.Name != "" {
|
||||
providerName = info.Name
|
||||
}
|
||||
return "", fmt.Errorf(
|
||||
"provider %s requires environment variable(s) %s to construct its API URL (%s); "+
|
||||
"set them or pass --provider-url to override",
|
||||
providerName, strings.Join(missing, ", "), apiURL,
|
||||
)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// ResolveProviderBaseURL returns the base API URL kit will use when talking to
|
||||
// the given provider, applying the same resolution order as CreateProvider:
|
||||
//
|
||||
// 1. The provider's `api` field from the models.dev registry.
|
||||
// 2. The hard-coded default base URL of its npm SDK package (e.g.
|
||||
// @ai-sdk/groq → https://api.groq.com/openai/v1).
|
||||
// 3. Template substitution against the current process environment when the
|
||||
// URL contains "${VAR}" placeholders (e.g. cloudflare-workers-ai needs
|
||||
// CLOUDFLARE_ACCOUNT_ID).
|
||||
//
|
||||
// It returns an error when the provider is unknown, when no URL can be derived,
|
||||
// or when a templated URL has unset placeholders. The error message is suitable
|
||||
// for direct display to end users.
|
||||
//
|
||||
// Note: providers handled by bespoke auth schemes (amazon-bedrock SigV4,
|
||||
// azure resource URLs, google-vertex project/location, sap-ai-core customer
|
||||
// deployments) may return either an empty URL or a regional/templated URL —
|
||||
// the actual endpoint is finalised inside their native handlers and depends on
|
||||
// runtime credentials.
|
||||
func ResolveProviderBaseURL(providerID string) (string, error) {
|
||||
registry := GetGlobalRegistry()
|
||||
info := registry.GetProviderInfo(providerID)
|
||||
if info == nil {
|
||||
return "", fmt.Errorf("unknown provider: %s", providerID)
|
||||
}
|
||||
|
||||
apiURL := info.API
|
||||
if apiURL == "" {
|
||||
if defaultURL, ok := sdkDefaultBaseURL[info.NPM]; ok {
|
||||
apiURL = defaultURL
|
||||
}
|
||||
}
|
||||
|
||||
if apiURL == "" {
|
||||
return "", fmt.Errorf(
|
||||
"provider %s has no default API URL: its npm package %q does not "+
|
||||
"ship a built-in baseURL (likely Bedrock SigV4, Azure deployment, "+
|
||||
"Vertex project/location, or a customer-hosted endpoint). "+
|
||||
"Pass --provider-url or set the provider's URL env var",
|
||||
providerID, info.NPM,
|
||||
)
|
||||
}
|
||||
|
||||
if strings.Contains(apiURL, "${") {
|
||||
resolved, err := resolveTemplatedAPIURL(apiURL, info)
|
||||
if err != nil {
|
||||
return apiURL, err
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
return apiURL, nil
|
||||
}
|
||||
|
||||
// createGoogleVertexProvider creates a Google Gemini provider that targets the
|
||||
// Vertex AI backend (rather than the public generativelanguage.googleapis.com
|
||||
// endpoint). It requires the same project/region environment variables as
|
||||
// google-vertex-anthropic.
|
||||
func createGoogleVertexProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
projectID := firstNonEmpty(
|
||||
os.Getenv("GOOGLE_VERTEX_PROJECT"),
|
||||
os.Getenv("GOOGLE_CLOUD_PROJECT"),
|
||||
os.Getenv("GCLOUD_PROJECT"),
|
||||
os.Getenv("CLOUDSDK_CORE_PROJECT"),
|
||||
)
|
||||
if projectID == "" {
|
||||
return nil, fmt.Errorf(
|
||||
"google Vertex project ID not provided, set GOOGLE_VERTEX_PROJECT, " +
|
||||
"GOOGLE_CLOUD_PROJECT, or GCLOUD_PROJECT environment variable",
|
||||
)
|
||||
}
|
||||
|
||||
region := firstNonEmpty(
|
||||
os.Getenv("GOOGLE_VERTEX_LOCATION"),
|
||||
os.Getenv("CLOUD_ML_REGION"),
|
||||
)
|
||||
if region == "" {
|
||||
region = "global"
|
||||
}
|
||||
|
||||
opts := []google.Option{
|
||||
google.WithVertex(projectID, region),
|
||||
google.WithName("google-vertex"),
|
||||
}
|
||||
|
||||
if config.TLSSkipVerify {
|
||||
opts = append(opts, google.WithHTTPClient(createHTTPClientWithTLSConfig(true)))
|
||||
}
|
||||
|
||||
provider, err := google.New(opts...)
|
||||
if err != nil {
|
||||
return nil, wrapProviderErr("Google Vertex", "provider", err)
|
||||
}
|
||||
|
||||
model, err := provider.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, wrapProviderErr("Google Vertex", "model", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSDKDefaultBaseURL_CoversAllWireMappedPackages enforces the invariant
|
||||
// that every npm package recognised by the auto-router has a corresponding
|
||||
// default base URL — otherwise a provider that omits its `api` field in the
|
||||
// registry would silently fail to route at runtime.
|
||||
func TestSDKDefaultBaseURL_CoversAllWireMappedPackages(t *testing.T) {
|
||||
for npm := range npmToWireProtocol {
|
||||
// @ai-sdk/openai-compatible is a wire family, not a single SDK with
|
||||
// a default URL — providers using it always supply their own `api`.
|
||||
if npm == "@ai-sdk/openai-compatible" {
|
||||
continue
|
||||
}
|
||||
if _, ok := sdkDefaultBaseURL[npm]; !ok {
|
||||
t.Errorf("npm %q is in npmToWireProtocol but has no sdkDefaultBaseURL entry — "+
|
||||
"providers using this npm with no `api` field cannot be routed", npm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSDKDefaultBaseURL_AllURLsAreAbsolute sanity-checks that every default
|
||||
// URL is a well-formed absolute https endpoint (catches typos in the table).
|
||||
func TestSDKDefaultBaseURL_AllURLsAreAbsolute(t *testing.T) {
|
||||
for npm, url := range sdkDefaultBaseURL {
|
||||
if !strings.HasPrefix(url, "https://") {
|
||||
t.Errorf("sdkDefaultBaseURL[%q] = %q is not an absolute https URL", npm, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveProviderBaseURL_RegistryFirst verifies that the registry's `api`
|
||||
// field wins over any SDK default.
|
||||
func TestResolveProviderBaseURL_RegistryFirst(t *testing.T) {
|
||||
// xai is in the registry with no `api` field — its URL comes from the
|
||||
// SDK default. Use a synthetic registry-backed provider to test the
|
||||
// priority via the public registry instead.
|
||||
url, err := ResolveProviderBaseURL("openai")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProviderBaseURL(openai): %v", err)
|
||||
}
|
||||
if url != "https://api.openai.com/v1" {
|
||||
t.Errorf("openai URL = %q, want https://api.openai.com/v1", url)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveProviderBaseURL_SDKDefaultFallback verifies that providers
|
||||
// without an `api` field (groq, cerebras, xai, …) resolve to their SDK
|
||||
// hard-coded default URL.
|
||||
func TestResolveProviderBaseURL_SDKDefaultFallback(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"groq": "https://api.groq.com/openai/v1",
|
||||
"cerebras": "https://api.cerebras.ai/v1",
|
||||
"xai": "https://api.x.ai/v1",
|
||||
"mistral": "https://api.mistral.ai/v1",
|
||||
"perplexity": "https://api.perplexity.ai",
|
||||
"togetherai": "https://api.together.xyz/v1",
|
||||
"deepinfra": "https://api.deepinfra.com/v1/openai",
|
||||
"cohere": "https://api.cohere.com/compatibility/v1",
|
||||
"v0": "https://api.v0.dev/v1",
|
||||
"aihubmix": "https://aihubmix.com/v1",
|
||||
"venice": "https://api.venice.ai/api/v1",
|
||||
"openrouter": "https://openrouter.ai/api/v1",
|
||||
}
|
||||
for providerID, wantURL := range tests {
|
||||
t.Run(providerID, func(t *testing.T) {
|
||||
got, err := ResolveProviderBaseURL(providerID)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProviderBaseURL(%s): %v", providerID, err)
|
||||
}
|
||||
if got != wantURL {
|
||||
t.Errorf("%s URL = %q, want %q", providerID, got, wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveProviderBaseURL_TemplatedURL_MissingEnv verifies that providers
|
||||
// whose URL contains "${VAR}" placeholders surface a targeted error when the
|
||||
// environment variables are unset.
|
||||
func TestResolveProviderBaseURL_TemplatedURL_MissingEnv(t *testing.T) {
|
||||
// cloudflare-workers-ai's api URL contains ${CLOUDFLARE_ACCOUNT_ID}.
|
||||
// Ensure the variable is unset for this test.
|
||||
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "")
|
||||
t.Setenv("CF_ACCOUNT_ID", "")
|
||||
|
||||
_, err := ResolveProviderBaseURL("cloudflare-workers-ai")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unset CLOUDFLARE_ACCOUNT_ID, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CLOUDFLARE_ACCOUNT_ID") {
|
||||
t.Errorf("error should name the missing env var, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--provider-url") {
|
||||
t.Errorf("error should suggest --provider-url override, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveProviderBaseURL_TemplatedURL_Resolved verifies env-var
|
||||
// substitution succeeds when the placeholder is set.
|
||||
func TestResolveProviderBaseURL_TemplatedURL_Resolved(t *testing.T) {
|
||||
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-acct-123")
|
||||
got, err := ResolveProviderBaseURL("cloudflare-workers-ai")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProviderBaseURL: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "test-acct-123") {
|
||||
t.Errorf("resolved URL %q should contain test-acct-123", got)
|
||||
}
|
||||
if strings.Contains(got, "${") {
|
||||
t.Errorf("resolved URL %q still contains template placeholder", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveProviderBaseURL_UnknownProvider verifies the not-in-registry error.
|
||||
func TestResolveProviderBaseURL_UnknownProvider(t *testing.T) {
|
||||
_, err := ResolveProviderBaseURL("does-not-exist")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown provider, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown provider") {
|
||||
t.Errorf("error should say 'unknown provider', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoRouteProvider_SDKDefaultURLFallback verifies that providers whose
|
||||
// registry entry omits the `api` field (groq, mistral, xai, etc.) are still
|
||||
// auto-routed by falling back to the SDK's hard-coded default URL.
|
||||
func TestAutoRouteProvider_SDKDefaultURLFallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
npmPackage string
|
||||
wantInURL string
|
||||
}{
|
||||
{"groq", "@ai-sdk/groq", "groq.com"},
|
||||
{"cerebras", "@ai-sdk/cerebras", "cerebras.ai"},
|
||||
{"xai", "@ai-sdk/xai", "x.ai"},
|
||||
{"mistral", "@ai-sdk/mistral", "mistral.ai"},
|
||||
{"v0", "@ai-sdk/vercel", "v0.dev"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &ModelsRegistry{
|
||||
providers: map[string]ProviderInfo{
|
||||
"testfallback": {
|
||||
ID: "testfallback",
|
||||
Name: "Test Fallback",
|
||||
Env: []string{"TESTFALLBACK_API_KEY"},
|
||||
NPM: tt.npmPackage,
|
||||
// API intentionally omitted — must fall back to SDK default.
|
||||
Models: map[string]ModelInfo{
|
||||
"any-model": {ID: "any-model", Name: "any-model"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
config := &ProviderConfig{ProviderAPIKey: "test-key"}
|
||||
|
||||
result, err := autoRouteProvider(context.Background(), config, "testfallback", "any-model", reg)
|
||||
if err != nil {
|
||||
t.Fatalf("autoRouteProvider returned error: %v", err)
|
||||
}
|
||||
if result == nil || result.Model == nil {
|
||||
t.Fatal("autoRouteProvider returned nil model")
|
||||
}
|
||||
// Verify the SDK default URL was picked up.
|
||||
if !strings.Contains(config.ProviderURL, tt.wantInURL) {
|
||||
t.Errorf("config.ProviderURL = %q, want substring %q (SDK default)",
|
||||
config.ProviderURL, tt.wantInURL)
|
||||
}
|
||||
// All these wrappers route through the openai-compat wire.
|
||||
gotType := reflect.TypeOf(result.Model).String()
|
||||
if gotType != "openai.languageModel" {
|
||||
t.Errorf("model type = %q, want openai.languageModel", gotType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTemplatedAPIURL_NoPlaceholders verifies that URLs without
|
||||
// placeholders are returned as-is (the caller keeps using the original).
|
||||
func TestResolveTemplatedAPIURL_NoPlaceholders(t *testing.T) {
|
||||
got, err := resolveTemplatedAPIURL("https://api.example.com/v1", &ProviderInfo{ID: "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("got %q, want empty string for URL with no placeholders", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTemplatedAPIURL_AltEnvVar verifies that the alternative env-var
|
||||
// names (e.g. CF_ACCOUNT_ID for CLOUDFLARE_ACCOUNT_ID) are honoured.
|
||||
func TestResolveTemplatedAPIURL_AltEnvVar(t *testing.T) {
|
||||
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "")
|
||||
t.Setenv("CF_ACCOUNT_ID", "alt-name-123")
|
||||
|
||||
got, err := resolveTemplatedAPIURL(
|
||||
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||
&ProviderInfo{ID: "cloudflare-workers-ai"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "alt-name-123") {
|
||||
t.Errorf("resolved URL %q should have picked up CF_ACCOUNT_ID alternative", got)
|
||||
}
|
||||
}
|
||||
@@ -458,11 +458,6 @@ 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.AppendLLMMessage(msg)
|
||||
}
|
||||
|
||||
// AppendModelChange records a model/provider change.
|
||||
func (tm *TreeManager) AppendModelChange(provider, modelID string) (string, error) {
|
||||
tm.mu.Lock()
|
||||
@@ -1170,11 +1165,6 @@ func (tm *TreeManager) AddLLMMessages(msgs []fantasy.Message) error {
|
||||
return tm.flushLocked()
|
||||
}
|
||||
|
||||
// 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) GetLLMMessages() []fantasy.Message {
|
||||
@@ -1182,11 +1172,6 @@ func (tm *TreeManager) GetLLMMessages() []fantasy.Message {
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// Package imagepreview renders low-resolution, in-terminal thumbnails of
|
||||
// images using Unicode upper half-block characters (U+2580, "▀") combined
|
||||
// with SGR foreground/background color codes.
|
||||
//
|
||||
// The technique stacks two vertical pixels into a single character cell: the
|
||||
// foreground color paints the top pixel and the background color paints the
|
||||
// bottom pixel. This produces pure styled text — no graphics escape sequences
|
||||
// — so the output survives terminal multiplexers (tmux, zellij) untouched.
|
||||
//
|
||||
// The Kitty graphics protocol, Sixel, and iTerm2 inline images are
|
||||
// deliberately NOT used: those are graphics escape-sequence protocols that
|
||||
// tmux and zellij strip or mangle by default.
|
||||
package imagepreview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
// Register the standard image decoders so image.Decode can handle the
|
||||
// common clipboard / attachment formats.
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
// upperHalfBlock is U+2580 ("▀"). The glyph fills the top half of a cell,
|
||||
// letting the foreground color render the top pixel and the cell's background
|
||||
// color render the bottom pixel.
|
||||
const upperHalfBlock = "▀"
|
||||
|
||||
// reset is the SGR reset sequence appended after each rendered row.
|
||||
const reset = "\x1b[0m"
|
||||
|
||||
// maxImageDimension is the largest width or height, in pixels, that Render will
|
||||
// fully decode. Images larger than this in either axis are rejected before the
|
||||
// expensive image.Decode call to guard against decompression bombs (small
|
||||
// encoded payloads that expand to enormous pixel buffers).
|
||||
const maxImageDimension = 20000
|
||||
|
||||
// Render returns a half-block ANSI thumbnail of the image, scaled to fit
|
||||
// within maxCols x maxRows terminal cells while preserving aspect ratio.
|
||||
//
|
||||
// Each terminal cell encodes two vertically-stacked pixels, so the effective
|
||||
// pixel resolution of the thumbnail is up to maxCols x (maxRows*2).
|
||||
//
|
||||
// Colors are emitted at the fidelity of the detected terminal color profile:
|
||||
// truecolor (24-bit) when available, degrading to 256-color. When the
|
||||
// terminal supports neither (no truecolor and no 256-color), Render returns
|
||||
// an empty string and a nil error so the caller can fall back to a text
|
||||
// indicator. A non-nil error is only returned when the image data cannot be
|
||||
// decoded.
|
||||
//
|
||||
// bg is the color used to composite transparent pixels (typically the
|
||||
// terminal background). A nil bg defaults to black.
|
||||
func Render(data []byte, mediaType string, maxCols, maxRows int, bg color.Color) (string, error) {
|
||||
profile := colorprofile.Env(os.Environ())
|
||||
return renderWithProfile(data, maxCols, maxRows, bg, profile)
|
||||
}
|
||||
|
||||
// renderWithProfile is the testable core of Render. It accepts an explicit
|
||||
// color profile instead of detecting one from the environment.
|
||||
func renderWithProfile(data []byte, maxCols, maxRows int, bg color.Color, profile colorprofile.Profile) (string, error) {
|
||||
// Half-block fidelity needs at least 256-color support. Anything less
|
||||
// degrades to the caller's text fallback.
|
||||
if profile < colorprofile.ANSI256 {
|
||||
return "", nil
|
||||
}
|
||||
if maxCols < 1 || maxRows < 1 {
|
||||
return "", nil
|
||||
}
|
||||
if bg == nil {
|
||||
bg = color.Black
|
||||
}
|
||||
|
||||
// Guard against decompression bombs: inspect the header dimensions before
|
||||
// fully decoding, so a small malicious payload cannot expand into an
|
||||
// enormous pixel buffer.
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode image config: %w", err)
|
||||
}
|
||||
if cfg.Width > maxImageDimension || cfg.Height > maxImageDimension {
|
||||
return "", fmt.Errorf("decode image: dimensions %dx%d exceed limit %d", cfg.Width, cfg.Height, maxImageDimension)
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode image: %w", err)
|
||||
}
|
||||
|
||||
// Target pixel dimensions: one pixel per column horizontally and two
|
||||
// pixels per row vertically (the half-block trick).
|
||||
cols, rows := fitDimensions(img.Bounds().Dx(), img.Bounds().Dy(), maxCols, maxRows)
|
||||
if cols < 1 || rows < 1 {
|
||||
return "", nil
|
||||
}
|
||||
pxW, pxH := cols, rows*2
|
||||
|
||||
scaled := image.NewRGBA(image.Rect(0, 0, pxW, pxH))
|
||||
xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), img, img.Bounds(), xdraw.Over, nil)
|
||||
|
||||
var b strings.Builder
|
||||
for y := 0; y < pxH; y += 2 {
|
||||
for x := range pxW {
|
||||
top := composite(scaled.At(x, y), bg)
|
||||
bottom := composite(scaled.At(x, y+1), bg)
|
||||
b.WriteString(sgr(top, bottom, profile))
|
||||
b.WriteString(upperHalfBlock)
|
||||
}
|
||||
b.WriteString(reset)
|
||||
if y+2 < pxH {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// fitDimensions returns the largest cell dimensions (cols, rows) that fit a
|
||||
// srcW x srcH image inside a maxCols x maxRows box while preserving aspect
|
||||
// ratio. Because each cell stacks two vertical pixels, a terminal cell is
|
||||
// treated as roughly twice as tall as it is wide, which keeps the thumbnail's
|
||||
// aspect ratio visually correct.
|
||||
func fitDimensions(srcW, srcH, maxCols, maxRows int) (cols, rows int) {
|
||||
if srcW <= 0 || srcH <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
// Work in pixel space: the box is maxCols wide and maxRows*2 tall.
|
||||
maxPxW := float64(maxCols)
|
||||
maxPxH := float64(maxRows * 2)
|
||||
scale := maxPxW / float64(srcW)
|
||||
if h := maxPxH / float64(srcH); h < scale {
|
||||
scale = h
|
||||
}
|
||||
if scale > 1 {
|
||||
scale = 1 // never upscale; keep the low-res look
|
||||
}
|
||||
pxW := int(float64(srcW) * scale)
|
||||
pxH := int(float64(srcH) * scale)
|
||||
if pxW < 1 {
|
||||
pxW = 1
|
||||
}
|
||||
if pxH < 2 {
|
||||
pxH = 2
|
||||
}
|
||||
// Convert back to cells; round the row count up to an even pixel height.
|
||||
cols = pxW
|
||||
rows = (pxH + 1) / 2
|
||||
if cols > maxCols {
|
||||
cols = maxCols
|
||||
}
|
||||
if rows > maxRows {
|
||||
rows = maxRows
|
||||
}
|
||||
return cols, rows
|
||||
}
|
||||
|
||||
// composite blends a (possibly translucent) pixel over the background color,
|
||||
// returning an opaque color. Fully opaque pixels are returned unchanged.
|
||||
func composite(c, bg color.Color) color.Color {
|
||||
r, g, b, a := c.RGBA()
|
||||
if a == 0xffff {
|
||||
return c
|
||||
}
|
||||
br, bgc, bb, _ := bg.RGBA()
|
||||
// Standard "over" alpha compositing in 16-bit space.
|
||||
inv := 0xffff - a
|
||||
out := color.RGBA64{
|
||||
R: uint16(r + br*inv/0xffff),
|
||||
G: uint16(g + bgc*inv/0xffff),
|
||||
B: uint16(b + bb*inv/0xffff),
|
||||
A: 0xffff,
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sgr builds the SGR escape sequence that sets the foreground (top pixel) and
|
||||
// background (bottom pixel) colors at the fidelity of the given profile.
|
||||
func sgr(fg, bg color.Color, profile colorprofile.Profile) string {
|
||||
if profile >= colorprofile.TrueColor {
|
||||
fr, fgc, fb := rgb8(fg)
|
||||
br, bgc, bb := rgb8(bg)
|
||||
return fmt.Sprintf("\x1b[38;2;%d;%d;%d;48;2;%d;%d;%dm", fr, fgc, fb, br, bgc, bb)
|
||||
}
|
||||
return fmt.Sprintf("\x1b[38;5;%d;48;5;%dm", index256(fg, profile), index256(bg, profile))
|
||||
}
|
||||
|
||||
// rgb8 reduces a color to 8-bit RGB components.
|
||||
func rgb8(c color.Color) (r, g, b uint8) {
|
||||
cr, cg, cb, _ := c.RGBA()
|
||||
return uint8(cr >> 8), uint8(cg >> 8), uint8(cb >> 8)
|
||||
}
|
||||
|
||||
// index256 converts a color to its nearest 256-color palette index using the
|
||||
// supplied profile.
|
||||
func index256(c color.Color, profile colorprofile.Profile) uint8 {
|
||||
cc := profile.Convert(c)
|
||||
if idx, ok := cc.(ansi.IndexedColor); ok {
|
||||
return uint8(idx)
|
||||
}
|
||||
if idx, ok := cc.(ansi.BasicColor); ok {
|
||||
return uint8(idx)
|
||||
}
|
||||
// Fallback: derive an index directly if conversion produced an
|
||||
// unexpected type.
|
||||
r, g, b := rgb8(c)
|
||||
return ansi256FromRGB(r, g, b)
|
||||
}
|
||||
|
||||
// ansi256FromRGB maps an 8-bit RGB color to the xterm 256-color cube. It is a
|
||||
// best-effort fallback used only when profile.Convert does not yield a known
|
||||
// indexed color type.
|
||||
func ansi256FromRGB(r, g, b uint8) uint8 {
|
||||
q := func(v uint8) int {
|
||||
switch {
|
||||
case v < 48:
|
||||
return 0
|
||||
case v < 115:
|
||||
return 1
|
||||
default:
|
||||
return int((v - 35) / 40)
|
||||
}
|
||||
}
|
||||
ri, gi, bi := q(r), q(g), q(b)
|
||||
return uint8(16 + 36*ri + 6*gi + bi)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package imagepreview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
// makePNG builds a simple w x h PNG filled with the given color and returns
|
||||
// its encoded bytes.
|
||||
func makePNG(t *testing.T, w, h int, c color.Color) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestRenderTrueColor(t *testing.T) {
|
||||
data := makePNG(t, 20, 20, color.RGBA{R: 255, A: 255})
|
||||
out, err := renderWithProfile(data, 10, 5, color.Black, colorprofile.TrueColor)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if out == "" {
|
||||
t.Fatal("expected non-empty thumbnail for truecolor profile")
|
||||
}
|
||||
if !strings.Contains(out, upperHalfBlock) {
|
||||
t.Error("output should contain upper half block glyphs")
|
||||
}
|
||||
if !strings.Contains(out, "\x1b[38;2;") || !strings.Contains(out, "48;2;") {
|
||||
t.Errorf("expected truecolor SGR sequences, got %q", out)
|
||||
}
|
||||
// Red fill should appear as 255;0;0 somewhere.
|
||||
if !strings.Contains(out, "255;0;0") {
|
||||
t.Errorf("expected red color in output, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderANSI256(t *testing.T) {
|
||||
data := makePNG(t, 20, 20, color.RGBA{G: 255, A: 255})
|
||||
out, err := renderWithProfile(data, 8, 4, color.Black, colorprofile.ANSI256)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if out == "" {
|
||||
t.Fatal("expected non-empty thumbnail for ANSI256 profile")
|
||||
}
|
||||
if !strings.Contains(out, "\x1b[38;5;") || !strings.Contains(out, "48;5;") {
|
||||
t.Errorf("expected 256-color SGR sequences, got %q", out)
|
||||
}
|
||||
if strings.Contains(out, "38;2;") {
|
||||
t.Errorf("ANSI256 output should not contain truecolor sequences, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDegradesBelowANSI256(t *testing.T) {
|
||||
data := makePNG(t, 20, 20, color.RGBA{B: 255, A: 255})
|
||||
for _, p := range []colorprofile.Profile{colorprofile.ANSI, colorprofile.ASCII, colorprofile.NoTTY} {
|
||||
out, err := renderWithProfile(data, 10, 5, color.Black, p)
|
||||
if err != nil {
|
||||
t.Fatalf("profile %v: unexpected error: %v", p, err)
|
||||
}
|
||||
if out != "" {
|
||||
t.Errorf("profile %v: expected empty fallback, got %q", p, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderInvalidImage(t *testing.T) {
|
||||
out, err := renderWithProfile([]byte("not an image"), 10, 5, color.Black, colorprofile.TrueColor)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid image data")
|
||||
}
|
||||
if out != "" {
|
||||
t.Errorf("expected empty output on decode error, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRejectsOversizedImage(t *testing.T) {
|
||||
// A header advertising dimensions beyond maxImageDimension must be
|
||||
// rejected before full decode (decompression-bomb guard). image.RGBA
|
||||
// allocation is avoided by only checking the config path here.
|
||||
w := maxImageDimension + 1
|
||||
data := makePNG(t, w, 1, color.White)
|
||||
out, err := renderWithProfile(data, 10, 5, color.Black, colorprofile.TrueColor)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for oversized image dimensions")
|
||||
}
|
||||
if out != "" {
|
||||
t.Errorf("expected empty output for oversized image, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderZeroBox(t *testing.T) {
|
||||
data := makePNG(t, 20, 20, color.White)
|
||||
out, err := renderWithProfile(data, 0, 0, color.Black, colorprofile.TrueColor)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if out != "" {
|
||||
t.Errorf("expected empty output for zero-sized box, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderNilBackgroundDefaults(t *testing.T) {
|
||||
data := makePNG(t, 10, 10, color.RGBA{R: 10, G: 20, B: 30, A: 255})
|
||||
out, err := renderWithProfile(data, 6, 3, nil, colorprofile.TrueColor)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if out == "" {
|
||||
t.Fatal("expected output with nil background (defaults to black)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRowCountWithinBounds(t *testing.T) {
|
||||
// A tall image should be capped at maxRows cells.
|
||||
data := makePNG(t, 10, 100, color.White)
|
||||
out, err := renderWithProfile(data, 20, 6, color.Black, colorprofile.TrueColor)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
rows := strings.Count(out, "\n") + 1
|
||||
if rows > 6 {
|
||||
t.Errorf("expected at most 6 rows, got %d", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColumnCountWithinBounds(t *testing.T) {
|
||||
// A wide image should be capped at maxCols cells per row.
|
||||
data := makePNG(t, 100, 10, color.White)
|
||||
out, err := renderWithProfile(data, 8, 20, color.Black, colorprofile.TrueColor)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
firstRow := strings.SplitN(out, "\n", 2)[0]
|
||||
cols := strings.Count(firstRow, upperHalfBlock)
|
||||
if cols > 8 {
|
||||
t.Errorf("expected at most 8 columns, got %d", cols)
|
||||
}
|
||||
if cols == 0 {
|
||||
t.Error("expected at least one column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitDimensionsPreservesAspect(t *testing.T) {
|
||||
// 2:1 (wide) image into a 40x20 box. Pixel box is 40x40; width-bound.
|
||||
cols, rows := fitDimensions(200, 100, 40, 20)
|
||||
if cols != 40 {
|
||||
t.Errorf("expected 40 cols, got %d", cols)
|
||||
}
|
||||
// pxH = 100 * (40/200) = 20 → 10 rows.
|
||||
if rows != 10 {
|
||||
t.Errorf("expected 10 rows, got %d", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitDimensionsNeverUpscales(t *testing.T) {
|
||||
cols, rows := fitDimensions(4, 4, 40, 20)
|
||||
if cols != 4 || rows != 2 {
|
||||
t.Errorf("expected 4x2 (no upscale), got %dx%d", cols, rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeOpaquePassthrough(t *testing.T) {
|
||||
c := color.RGBA{R: 1, G: 2, B: 3, A: 255}
|
||||
got := composite(c, color.White)
|
||||
if got != color.Color(c) {
|
||||
t.Errorf("opaque color should pass through unchanged, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeTransparentOverBackground(t *testing.T) {
|
||||
// Fully transparent pixel over red background should yield red.
|
||||
got := composite(color.RGBA{}, color.RGBA{R: 255, A: 255})
|
||||
r, g, b, a := got.RGBA()
|
||||
if r>>8 != 255 || g>>8 != 0 || b>>8 != 0 || a != 0xffff {
|
||||
t.Errorf("expected opaque red, got r=%d g=%d b=%d a=%d", r>>8, g>>8, b>>8, a)
|
||||
}
|
||||
}
|
||||
+99
-2
@@ -2,6 +2,7 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/mark3labs/kit/internal/clipboard"
|
||||
"github.com/mark3labs/kit/internal/ui/commands"
|
||||
"github.com/mark3labs/kit/internal/ui/core"
|
||||
"github.com/mark3labs/kit/internal/ui/imagepreview"
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
)
|
||||
|
||||
@@ -80,6 +82,23 @@ type InputComponent struct {
|
||||
// Images are added via Ctrl+V and cleared on submit or Ctrl+U.
|
||||
pendingImages []core.ImageAttachment
|
||||
|
||||
// imageThumbs caches the rendered half-block thumbnail for each entry in
|
||||
// pendingImages (1:1 index correspondence). Thumbnails are rendered
|
||||
// asynchronously off the Bubble Tea event loop (decode + resample is too
|
||||
// slow to run inside Update), so an entry starts as the empty string
|
||||
// placeholder and is filled in when the matching thumbnailReadyMsg
|
||||
// arrives. An entry stays empty when the terminal cannot display a
|
||||
// half-block preview, in which case the text pill is shown alone.
|
||||
// See internal/ui/imagepreview.
|
||||
imageThumbs []string
|
||||
|
||||
// imageGen is a monotonic generation counter incremented whenever the
|
||||
// pending image set is cleared. Async thumbnail results carry the
|
||||
// generation they were enqueued under and are discarded if it no longer
|
||||
// matches, preventing a stale thumbnail from landing on the wrong slot
|
||||
// after a clear + re-attach.
|
||||
imageGen int
|
||||
|
||||
// history stores previously submitted prompts (most recent last).
|
||||
// Limited to maxHistory entries; duplicates of the previous entry are
|
||||
// skipped. Empty strings are never stored.
|
||||
@@ -105,6 +124,16 @@ type clipboardImageMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// thumbnailReadyMsg carries the result of an async thumbnail render back to
|
||||
// the Update loop. gen and index identify the pendingImages slot the
|
||||
// thumbnail belongs to; the result is dropped if the generation no longer
|
||||
// matches (the pending set was cleared) or the index is out of range.
|
||||
type thumbnailReadyMsg struct {
|
||||
gen int
|
||||
index int
|
||||
thumb string
|
||||
}
|
||||
|
||||
// NewInputComponent creates a new InputComponent with the given width and
|
||||
// optional AppController. If appCtrl is nil the component still works but
|
||||
// /clear and /clear-queue are no-ops.
|
||||
@@ -193,7 +222,23 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return s, nil
|
||||
}
|
||||
if msg.image != nil {
|
||||
s.pendingImages = append(s.pendingImages, *msg.image)
|
||||
img := *msg.image
|
||||
index := len(s.pendingImages)
|
||||
s.pendingImages = append(s.pendingImages, img)
|
||||
// Reserve a placeholder; the async render fills it in via
|
||||
// thumbnailReadyMsg so Update never blocks on decode/resample.
|
||||
s.imageThumbs = append(s.imageThumbs, "")
|
||||
cols := s.thumbCols()
|
||||
if cols < 1 {
|
||||
return s, nil
|
||||
}
|
||||
return s, renderThumbnailCmd(img, cols, thumbMaxRows, style.GetTheme().Background, s.imageGen, index)
|
||||
}
|
||||
return s, nil
|
||||
|
||||
case thumbnailReadyMsg:
|
||||
if msg.gen == s.imageGen && msg.index >= 0 && msg.index < len(s.imageThumbs) {
|
||||
s.imageThumbs[msg.index] = msg.thumb
|
||||
}
|
||||
return s, nil
|
||||
|
||||
@@ -250,6 +295,8 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Clear all pending image attachments.
|
||||
if len(s.pendingImages) > 0 {
|
||||
s.pendingImages = nil
|
||||
s.imageThumbs = nil
|
||||
s.imageGen++
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
@@ -486,6 +533,8 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
|
||||
// images and clear them.
|
||||
images := s.pendingImages
|
||||
s.pendingImages = nil
|
||||
s.imageThumbs = nil
|
||||
s.imageGen++
|
||||
return func() tea.Msg {
|
||||
return core.SubmitMsg{Text: trimmed, Images: images}
|
||||
}
|
||||
@@ -519,6 +568,42 @@ func (s *InputComponent) resetHistoryBrowsing() {
|
||||
s.savedInput = ""
|
||||
}
|
||||
|
||||
// thumbMaxCols and thumbMaxRows cap the size, in terminal cells, of pending
|
||||
// image previews. Kept small for the low-res look and to keep scrollback
|
||||
// light.
|
||||
const (
|
||||
thumbMaxCols = 40
|
||||
thumbMaxRows = 12
|
||||
)
|
||||
|
||||
// thumbCols returns the thumbnail width in terminal cells given the current
|
||||
// input width, or 0 when there is no room to render a preview.
|
||||
func (s *InputComponent) thumbCols() int {
|
||||
if s.width <= 6 {
|
||||
return 0
|
||||
}
|
||||
cols := min(thumbMaxCols, s.width-6)
|
||||
if cols < 1 {
|
||||
return 0
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// renderThumbnailCmd returns a tea.Cmd that renders a half-block ANSI preview
|
||||
// off the Bubble Tea event loop. The decode + resample work runs in the Cmd
|
||||
// goroutine, and the result is delivered as a thumbnailReadyMsg tagged with
|
||||
// the generation and slot index it was enqueued for. An empty thumbnail
|
||||
// (terminal unsupported or render error) leaves the text pill in place.
|
||||
func renderThumbnailCmd(img core.ImageAttachment, cols, rows int, bg color.Color, gen, index int) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
thumb, err := imagepreview.Render(img.Data, img.MediaType, cols, rows, bg)
|
||||
if err != nil {
|
||||
thumb = ""
|
||||
}
|
||||
return thumbnailReadyMsg{gen: gen, index: index, thumb: thumb}
|
||||
}
|
||||
}
|
||||
|
||||
// View implements tea.Model. Renders the textarea, autocomplete popup
|
||||
// (if visible), and help text.
|
||||
func (s *InputComponent) View() tea.View {
|
||||
@@ -544,7 +629,9 @@ func (s *InputComponent) View() tea.View {
|
||||
// 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.
|
||||
// Show image attachment previews when images are pending. A cached
|
||||
// half-block thumbnail is rendered when the terminal supports it;
|
||||
// otherwise the text pill alone is shown.
|
||||
if len(s.pendingImages) > 0 {
|
||||
imgStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.Secondary).
|
||||
@@ -553,6 +640,14 @@ func (s *InputComponent) View() tea.View {
|
||||
label := fmt.Sprintf("[%d image(s) attached] ctrl+u to clear", len(s.pendingImages))
|
||||
view.WriteString("\n")
|
||||
view.WriteString(imgStyle.Render(label))
|
||||
|
||||
thumbStyle := lipgloss.NewStyle().PaddingLeft(3)
|
||||
for i := range s.pendingImages {
|
||||
if i < len(s.imageThumbs) && s.imageThumbs[i] != "" {
|
||||
view.WriteString("\n")
|
||||
view.WriteString(thumbStyle.Render(s.imageThumbs[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !s.hideHint {
|
||||
@@ -844,6 +939,8 @@ func readClipboardImageCmd() tea.Cmd {
|
||||
func (s *InputComponent) ClearPendingImages() []core.ImageAttachment {
|
||||
images := s.pendingImages
|
||||
s.pendingImages = nil
|
||||
s.imageThumbs = nil
|
||||
s.imageGen++
|
||||
return images
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/mark3labs/kit/internal/ui/commands"
|
||||
uicore "github.com/mark3labs/kit/internal/ui/core"
|
||||
"github.com/mark3labs/kit/internal/ui/fileutil"
|
||||
"github.com/mark3labs/kit/internal/ui/imagepreview"
|
||||
"github.com/mark3labs/kit/internal/ui/prefs"
|
||||
"github.com/mark3labs/kit/internal/ui/style"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
@@ -1794,14 +1795,27 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// messages stay in chronological order.
|
||||
m.pendingUserPrints = append(m.pendingUserPrints, displayText)
|
||||
m.flushStreamAndPendingUserMessages()
|
||||
// Insert inline thumbnail previews after the user message.
|
||||
cmds = append(cmds, m.transcriptPreviewCmd(msg.Images, m.lastMessageID()))
|
||||
}
|
||||
} else {
|
||||
m.printUserMessage(displayText)
|
||||
// Insert inline thumbnail previews after the user message.
|
||||
cmds = append(cmds, m.transcriptPreviewCmd(msg.Images, m.lastMessageID()))
|
||||
}
|
||||
if m.state != stateWorking {
|
||||
m.state = stateWorking
|
||||
}
|
||||
|
||||
// ── Async transcript image preview ───────────────────────────────────────
|
||||
case imagePreviewReadyMsg:
|
||||
if msg.block != "" {
|
||||
item := NewStyledMessageItem(generateMessageID(), "user", "", msg.block)
|
||||
m.insertMessageAfter(msg.anchorID, item)
|
||||
m.refreshContent()
|
||||
m.layoutDirty = true
|
||||
}
|
||||
|
||||
// ── Shell command (! / !!) ───────────────────────────────────────────────
|
||||
case uicore.ShellCommandMsg:
|
||||
// Show spinner while the shell command runs.
|
||||
@@ -2447,6 +2461,19 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.printSystemMessage(msg.Text)
|
||||
}
|
||||
|
||||
// ── Clipboard image attached / thumbnail rendered ────────────────────────
|
||||
// Both messages change the input region's rendered height (the pill and
|
||||
// the async half-block preview), so forward them to the input and mark the
|
||||
// layout dirty — otherwise distributeHeight keeps a stale, too-short input
|
||||
// height and the preview is clipped off the bottom of the screen.
|
||||
case clipboardImageMsg, thumbnailReadyMsg:
|
||||
if m.input != nil {
|
||||
updated, cmd := m.input.Update(msg)
|
||||
m.input, _ = updated.(inputComponentIface)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
m.layoutDirty = true
|
||||
|
||||
default:
|
||||
// Pass unrecognised messages to all children.
|
||||
if m.input != nil {
|
||||
@@ -3046,6 +3073,85 @@ func truncateMessageForBlock(msg string, maxLines, width int) string {
|
||||
// Print helpers — add content to ScrollList
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// imagePreviewReadyMsg carries an asynchronously rendered transcript image
|
||||
// preview block back to the Update loop, where it is inserted into the
|
||||
// ScrollList directly after the originating user message (identified by
|
||||
// anchorID). Inserting by anchor — rather than appending — keeps the preview
|
||||
// next to its message even when the agent's streamed reply has already been
|
||||
// appended while the thumbnail was being decoded off the event loop.
|
||||
type imagePreviewReadyMsg struct {
|
||||
block string
|
||||
anchorID string
|
||||
}
|
||||
|
||||
// transcriptPreviewCmd returns a tea.Cmd that renders half-block thumbnail
|
||||
// previews for the given clipboard images off the Bubble Tea event loop
|
||||
// (decode + resample must not block Update). The rendered block is delivered
|
||||
// via imagePreviewReadyMsg, tagged with anchorID so the consumer can place it
|
||||
// directly after the originating user message. Returns nil when there is
|
||||
// nothing to render or no room for a preview; an empty result (terminal lacks
|
||||
// color support) yields a nil message that Bubble Tea ignores.
|
||||
func (m *AppModel) transcriptPreviewCmd(images []uicore.ImageAttachment, anchorID string) tea.Cmd {
|
||||
if len(images) == 0 {
|
||||
return nil
|
||||
}
|
||||
cols := thumbMaxCols
|
||||
if m.width > 6 && m.width-6 < cols {
|
||||
cols = m.width - 6
|
||||
}
|
||||
if cols < 1 {
|
||||
return nil
|
||||
}
|
||||
bg := style.GetTheme().Background
|
||||
imgs := images
|
||||
return func() tea.Msg {
|
||||
pad := lipgloss.NewStyle().PaddingLeft(2)
|
||||
var blocks []string
|
||||
for _, img := range imgs {
|
||||
thumb, err := imagepreview.Render(img.Data, img.MediaType, cols, thumbMaxRows, bg)
|
||||
if err != nil || thumb == "" {
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, pad.Render(thumb))
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil
|
||||
}
|
||||
return imagePreviewReadyMsg{block: strings.Join(blocks, "\n"), anchorID: anchorID}
|
||||
}
|
||||
}
|
||||
|
||||
// lastMessageID returns the ID of the most recently added ScrollList message,
|
||||
// or "" when there are none. Used to anchor an async transcript preview to the
|
||||
// user message that was just printed.
|
||||
func (m *AppModel) lastMessageID() string {
|
||||
if len(m.messages) == 0 {
|
||||
return ""
|
||||
}
|
||||
return m.messages[len(m.messages)-1].ID()
|
||||
}
|
||||
|
||||
// insertMessageAfter inserts item immediately after the message whose ID
|
||||
// matches anchorID. If anchorID is empty or not found, item is appended.
|
||||
func (m *AppModel) insertMessageAfter(anchorID string, item MessageItem) {
|
||||
idx := -1
|
||||
if anchorID != "" {
|
||||
for i, msgItem := range m.messages {
|
||||
if msgItem.ID() == anchorID {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
m.messages = append(m.messages, item)
|
||||
return
|
||||
}
|
||||
m.messages = append(m.messages, nil)
|
||||
copy(m.messages[idx+2:], m.messages[idx+1:])
|
||||
m.messages[idx+1] = item
|
||||
}
|
||||
|
||||
// printUserMessage renders a user message into the ScrollList.
|
||||
func (m *AppModel) printUserMessage(text string) {
|
||||
// Check if this exact message was just added (prevents duplicates)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
uicore "github.com/mark3labs/kit/internal/ui/core"
|
||||
)
|
||||
|
||||
// drainCmds runs a tea.Cmd chain back through m.Update like the BubbleTea
|
||||
// event loop, expanding batches, until no further messages are produced.
|
||||
func drainCmds(t *testing.T, m *AppModel, cmd tea.Cmd) *AppModel {
|
||||
t.Helper()
|
||||
queue := []tea.Cmd{cmd}
|
||||
for i := 0; i < 50 && len(queue) > 0; i++ {
|
||||
c := queue[0]
|
||||
queue = queue[1:]
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
msg := c()
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
if batch, ok := msg.(tea.BatchMsg); ok {
|
||||
queue = append(queue, batch...)
|
||||
continue
|
||||
}
|
||||
updated, nc := m.Update(msg)
|
||||
m = updated.(*AppModel)
|
||||
_ = m.View()
|
||||
if nc != nil {
|
||||
queue = append(queue, nc)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func measuredInputHeight(m *AppModel) int {
|
||||
rendered := m.renderInput()
|
||||
if rendered == "" {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(rendered, "\n") + 1
|
||||
}
|
||||
|
||||
// TestPendingThumbnailTriggersLayoutRecompute is a regression test for the bug
|
||||
// where a pasted image's async half-block preview rendered but was clipped off
|
||||
// the bottom of the screen: the thumbnail arrives via thumbnailReadyMsg after
|
||||
// distributeHeight already measured the input region without it. The parent
|
||||
// must mark the layout dirty so the (now taller) input is re-measured.
|
||||
func TestPendingThumbnailTriggersLayoutRecompute(t *testing.T) {
|
||||
// Force a truecolor profile so imagepreview.Render deterministically
|
||||
// produces a thumbnail regardless of the CI terminal's color support.
|
||||
// Without this, a low-color test environment yields an empty preview and
|
||||
// the glyph / height assertions below would flake.
|
||||
t.Setenv("TERM", "xterm-256color")
|
||||
t.Setenv("COLORTERM", "truecolor")
|
||||
t.Setenv("NO_COLOR", "")
|
||||
|
||||
real := NewInputComponent(80, nil)
|
||||
m, _, _ := newTestAppModel(nil)
|
||||
m.input = real
|
||||
m = sendMsg(m, tea.WindowSizeMsg{Width: 80, Height: 24})
|
||||
|
||||
heightBefore := measuredInputHeight(m)
|
||||
|
||||
updated, cmd := m.Update(clipboardImageMsg{image: &uicore.ImageAttachment{
|
||||
Data: makeTestPNG(t, 16, 16),
|
||||
MediaType: "image/png",
|
||||
}})
|
||||
m = updated.(*AppModel)
|
||||
_ = m.View()
|
||||
m = drainCmds(t, m, cmd)
|
||||
|
||||
heightAfter := measuredInputHeight(m)
|
||||
if heightAfter <= heightBefore {
|
||||
t.Errorf("input region should grow to fit the thumbnail (before=%d after=%d)", heightBefore, heightAfter)
|
||||
}
|
||||
|
||||
if !strings.Contains(m.View().Content, "▀") {
|
||||
t.Error("parent View should contain the half-block thumbnail (was clipped or not rendered)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
uicore "github.com/mark3labs/kit/internal/ui/core"
|
||||
)
|
||||
|
||||
// makeTestPNG builds a small solid-color PNG for transcript preview tests.
|
||||
func makeTestPNG(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
img.Set(x, y, color.RGBA{R: 200, G: 40, B: 90, A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestTranscriptPreviewCmdNoImages(t *testing.T) {
|
||||
m, _, _ := newTestAppModel(nil)
|
||||
if cmd := m.transcriptPreviewCmd(nil, ""); cmd != nil {
|
||||
t.Error("expected nil cmd when there are no images")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptPreviewCmdRendersBlock(t *testing.T) {
|
||||
m, _, _ := newTestAppModel(nil)
|
||||
images := []uicore.ImageAttachment{
|
||||
{Data: makeTestPNG(t, 16, 16), MediaType: "image/png"},
|
||||
}
|
||||
cmd := m.transcriptPreviewCmd(images, "anchor-1")
|
||||
if cmd == nil {
|
||||
t.Fatal("expected a non-nil cmd for a valid image")
|
||||
}
|
||||
msg := cmd()
|
||||
// The result depends on the test process color profile. When the
|
||||
// terminal supports color the cmd yields a preview block; otherwise it
|
||||
// yields nil (caller keeps the text badge). Both are valid — assert the
|
||||
// shape only when a block is produced.
|
||||
if msg == nil {
|
||||
t.Skip("color profile below ANSI256 in test env; preview correctly skipped")
|
||||
}
|
||||
ready, ok := msg.(imagePreviewReadyMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected imagePreviewReadyMsg, got %T", msg)
|
||||
}
|
||||
if !strings.Contains(ready.block, "▀") {
|
||||
t.Errorf("preview block should contain half-block glyphs, got %q", ready.block)
|
||||
}
|
||||
if ready.anchorID != "anchor-1" {
|
||||
t.Errorf("preview should carry the originating anchorID, got %q", ready.anchorID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagePreviewReadyMsgAppendsItem(t *testing.T) {
|
||||
m, _, _ := newTestAppModel(nil)
|
||||
before := len(m.messages)
|
||||
m = sendMsg(m, imagePreviewReadyMsg{block: "\x1b[38;2;1;2;3;48;2;4;5;6m▀\x1b[0m"})
|
||||
if len(m.messages) != before+1 {
|
||||
t.Fatalf("expected one appended message item, got %d (was %d)", len(m.messages), before)
|
||||
}
|
||||
last, ok := m.messages[len(m.messages)-1].(*TextMessageItem)
|
||||
if !ok {
|
||||
t.Fatalf("expected last item to be *TextMessageItem, got %T", m.messages[len(m.messages)-1])
|
||||
}
|
||||
if !strings.Contains(last.Render(0), "▀") {
|
||||
t.Error("appended preview item should render the half-block block verbatim")
|
||||
}
|
||||
}
|
||||
|
||||
// TestImagePreviewReadyMsgInsertsAfterAnchor verifies the preview is placed
|
||||
// directly after its originating user message even when a later message (e.g.
|
||||
// a streamed assistant reply) was already appended while the thumbnail was
|
||||
// being decoded asynchronously.
|
||||
func TestImagePreviewReadyMsgInsertsAfterAnchor(t *testing.T) {
|
||||
m, _, _ := newTestAppModel(nil)
|
||||
userItem := NewStyledMessageItem("user-anchor", "user", "hi", "hi")
|
||||
assistantItem := NewStyledMessageItem("assistant-1", "assistant", "reply", "reply")
|
||||
m.messages = append(m.messages, userItem, assistantItem)
|
||||
|
||||
m = sendMsg(m, imagePreviewReadyMsg{
|
||||
block: "\x1b[38;2;1;2;3;48;2;4;5;6m▀\x1b[0m",
|
||||
anchorID: "user-anchor",
|
||||
})
|
||||
|
||||
// Expect order: user, preview, assistant.
|
||||
if len(m.messages) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d", len(m.messages))
|
||||
}
|
||||
if m.messages[0].ID() != "user-anchor" {
|
||||
t.Errorf("messages[0] should be the user message, got %q", m.messages[0].ID())
|
||||
}
|
||||
if m.messages[2].ID() != "assistant-1" {
|
||||
t.Errorf("messages[2] should be the assistant message, got %q", m.messages[2].ID())
|
||||
}
|
||||
if !strings.Contains(m.messages[1].Render(0), "▀") {
|
||||
t.Errorf("messages[1] should be the inserted preview, got %q", m.messages[1].Render(0))
|
||||
}
|
||||
}
|
||||
|
||||
// TestImagePreviewReadyMsgUnknownAnchorAppends verifies that when the anchor
|
||||
// is missing (e.g. the message was cleared), the preview falls back to append.
|
||||
func TestImagePreviewReadyMsgUnknownAnchorAppends(t *testing.T) {
|
||||
m, _, _ := newTestAppModel(nil)
|
||||
m.messages = append(m.messages, NewStyledMessageItem("only", "user", "hi", "hi"))
|
||||
m = sendMsg(m, imagePreviewReadyMsg{
|
||||
block: "\x1b[38;2;1;2;3;48;2;4;5;6m▀\x1b[0m",
|
||||
anchorID: "does-not-exist",
|
||||
})
|
||||
if len(m.messages) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(m.messages))
|
||||
}
|
||||
if !strings.Contains(m.messages[1].Render(0), "▀") {
|
||||
t.Error("preview should be appended as the last item when anchor is unknown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagePreviewReadyMsgEmptyBlockIgnored(t *testing.T) {
|
||||
m, _, _ := newTestAppModel(nil)
|
||||
before := len(m.messages)
|
||||
m = sendMsg(m, imagePreviewReadyMsg{block: ""})
|
||||
if len(m.messages) != before {
|
||||
t.Errorf("empty preview block should not append an item; got %d (was %d)", len(m.messages), before)
|
||||
}
|
||||
}
|
||||
+43
-150
@@ -571,67 +571,56 @@ func (eb *eventBus) emit(event Event) {
|
||||
// Typed convenience subscribers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// subscribeTyped is the generic backbone of all the typed `On<EventName>`
|
||||
// convenience methods on *Kit. It wraps Subscribe with a type assertion
|
||||
// against E so handlers receive a strongly-typed event without each
|
||||
// public method having to repeat the boilerplate. Returns an unsubscribe
|
||||
// function.
|
||||
func subscribeTyped[E Event](k *Kit, handler func(E)) func() {
|
||||
return k.Subscribe(func(e Event) {
|
||||
if tev, ok := e.(E); ok {
|
||||
handler(tev)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// OnToolCall registers a handler that fires only for ToolCallEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnToolCall(handler func(ToolCallEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if tc, ok := e.(ToolCallEvent); ok {
|
||||
handler(tc)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnToolCallStart registers a handler that fires only for ToolCallStartEvent.
|
||||
// This fires when the LLM begins generating tool call arguments — before the
|
||||
// full argument JSON is available. Returns an unsubscribe function.
|
||||
func (m *Kit) OnToolCallStart(handler func(ToolCallStartEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if tcs, ok := e.(ToolCallStartEvent); ok {
|
||||
handler(tcs)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnToolCallDelta registers a handler that fires only for ToolCallDeltaEvent.
|
||||
// Each delta contains a JSON fragment of tool call arguments as they stream in.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnToolCallDelta(handler func(ToolCallDeltaEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if tcd, ok := e.(ToolCallDeltaEvent); ok {
|
||||
handler(tcd)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnToolCallEnd registers a handler that fires only for ToolCallEndEvent.
|
||||
// This fires when tool argument streaming is complete, before the tool call
|
||||
// is parsed and execution begins. Returns an unsubscribe function.
|
||||
func (m *Kit) OnToolCallEnd(handler func(ToolCallEndEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if tce, ok := e.(ToolCallEndEvent); ok {
|
||||
handler(tce)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnToolResult registers a handler that fires only for ToolResultEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnToolResult(handler func(ToolResultEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if tr, ok := e.(ToolResultEvent); ok {
|
||||
handler(tr)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnToolOutput registers a handler that fires only for ToolOutputEvent
|
||||
// (streaming tool output chunks, e.g., from bash). Returns an unsubscribe function.
|
||||
func (m *Kit) OnToolOutput(handler func(ToolOutputEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if to, ok := e.(ToolOutputEvent); ok {
|
||||
handler(to)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnStreaming registers a handler that fires only for MessageUpdateEvent
|
||||
@@ -646,41 +635,25 @@ func (m *Kit) OnStreaming(handler func(MessageUpdateEvent)) func() {
|
||||
// OnMessageUpdate registers a handler that fires only for MessageUpdateEvent
|
||||
// (streaming text chunks). Returns an unsubscribe function.
|
||||
func (m *Kit) OnMessageUpdate(handler func(MessageUpdateEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if mu, ok := e.(MessageUpdateEvent); ok {
|
||||
handler(mu)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnResponse registers a handler that fires only for ResponseEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnResponse(handler func(ResponseEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if r, ok := e.(ResponseEvent); ok {
|
||||
handler(r)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnTurnStart registers a handler that fires only for TurnStartEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnTurnStart(handler func(TurnStartEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if ts, ok := e.(TurnStartEvent); ok {
|
||||
handler(ts)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnTurnEnd registers a handler that fires only for TurnEndEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnTurnEnd(handler func(TurnEndEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if te, ok := e.(TurnEndEvent); ok {
|
||||
handler(te)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -690,101 +663,61 @@ func (m *Kit) OnTurnEnd(handler func(TurnEndEvent)) func() {
|
||||
// OnMessageStart registers a handler that fires only for MessageStartEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnMessageStart(handler func(MessageStartEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if ms, ok := e.(MessageStartEvent); ok {
|
||||
handler(ms)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnMessageEnd registers a handler that fires only for MessageEndEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnMessageEnd(handler func(MessageEndEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if me, ok := e.(MessageEndEvent); ok {
|
||||
handler(me)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnReasoningDelta registers a handler that fires only for ReasoningDeltaEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnReasoningDelta(handler func(ReasoningDeltaEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if rd, ok := e.(ReasoningDeltaEvent); ok {
|
||||
handler(rd)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnReasoningComplete registers a handler that fires only for ReasoningCompleteEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnReasoningComplete(handler func(ReasoningCompleteEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if rc, ok := e.(ReasoningCompleteEvent); ok {
|
||||
handler(rc)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnToolExecutionStart registers a handler that fires only for ToolExecutionStartEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnToolExecutionStart(handler func(ToolExecutionStartEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if tes, ok := e.(ToolExecutionStartEvent); ok {
|
||||
handler(tes)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnToolExecutionEnd registers a handler that fires only for ToolExecutionEndEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnToolExecutionEnd(handler func(ToolExecutionEndEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if tee, ok := e.(ToolExecutionEndEvent); ok {
|
||||
handler(tee)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnToolCallContent registers a handler that fires only for ToolCallContentEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnToolCallContent(handler func(ToolCallContentEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if tcc, ok := e.(ToolCallContentEvent); ok {
|
||||
handler(tcc)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnStepUsage registers a handler that fires only for StepUsageEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnStepUsage(handler func(StepUsageEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if su, ok := e.(StepUsageEvent); ok {
|
||||
handler(su)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnCompaction registers a handler that fires only for CompactionEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnCompaction(handler func(CompactionEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if ce, ok := e.(CompactionEvent); ok {
|
||||
handler(ce)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnSteerConsumed registers a handler that fires only for SteerConsumedEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnSteerConsumed(handler func(SteerConsumedEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if sc, ok := e.(SteerConsumedEvent); ok {
|
||||
handler(sc)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -794,101 +727,61 @@ func (m *Kit) OnSteerConsumed(handler func(SteerConsumedEvent)) func() {
|
||||
// OnStepStart registers a handler that fires only for StepStartEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnStepStart(handler func(StepStartEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if ss, ok := e.(StepStartEvent); ok {
|
||||
handler(ss)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnStepFinish registers a handler that fires only for StepFinishEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnStepFinish(handler func(StepFinishEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if sf, ok := e.(StepFinishEvent); ok {
|
||||
handler(sf)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnTextStart registers a handler that fires only for TextStartEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnTextStart(handler func(TextStartEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if ts, ok := e.(TextStartEvent); ok {
|
||||
handler(ts)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnTextEnd registers a handler that fires only for TextEndEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnTextEnd(handler func(TextEndEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if te, ok := e.(TextEndEvent); ok {
|
||||
handler(te)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnReasoningStart registers a handler that fires only for ReasoningStartEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnReasoningStart(handler func(ReasoningStartEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if rs, ok := e.(ReasoningStartEvent); ok {
|
||||
handler(rs)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnWarnings registers a handler that fires only for WarningsEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnWarnings(handler func(WarningsEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if w, ok := e.(WarningsEvent); ok {
|
||||
handler(w)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnSource registers a handler that fires only for SourceEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnSource(handler func(SourceEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if s, ok := e.(SourceEvent); ok {
|
||||
handler(s)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnStreamFinish registers a handler that fires only for StreamFinishEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnStreamFinish(handler func(StreamFinishEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if sf, ok := e.(StreamFinishEvent); ok {
|
||||
handler(sf)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnError registers a handler that fires only for ErrorEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnError(handler func(ErrorEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if ee, ok := e.(ErrorEvent); ok {
|
||||
handler(ee)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// OnRetry registers a handler that fires only for RetryEvent.
|
||||
// Returns an unsubscribe function.
|
||||
func (m *Kit) OnRetry(handler func(RetryEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if r, ok := e.(RetryEvent); ok {
|
||||
handler(r)
|
||||
}
|
||||
})
|
||||
return subscribeTyped(m, handler)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+29
-29
@@ -155,17 +155,17 @@ func (m *Kit) Extensions() ExtensionAPI {
|
||||
|
||||
// Context management
|
||||
|
||||
func (e *extensionAPI) SetContext(ctx extensions.Context) {
|
||||
func (e *extensionAPI) SetContext(ctx ExtensionContext) {
|
||||
if e.kit.extRunner != nil {
|
||||
e.kit.extRunner.SetContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *extensionAPI) GetContext() extensions.Context {
|
||||
func (e *extensionAPI) GetContext() ExtensionContext {
|
||||
if e.kit.extRunner != nil {
|
||||
return e.kit.extRunner.GetContext()
|
||||
}
|
||||
return extensions.Context{}
|
||||
return ExtensionContext{}
|
||||
}
|
||||
|
||||
func (e *extensionAPI) UpdateContextModel(model string) {
|
||||
@@ -178,7 +178,7 @@ func (e *extensionAPI) UpdateContextModel(model string) {
|
||||
|
||||
// Widgets
|
||||
|
||||
func (e *extensionAPI) SetWidget(config extensions.WidgetConfig) {
|
||||
func (e *extensionAPI) SetWidget(config ExtensionWidgetConfig) {
|
||||
if e.kit.extRunner != nil {
|
||||
e.kit.extRunner.SetWidget(config)
|
||||
}
|
||||
@@ -190,7 +190,7 @@ func (e *extensionAPI) RemoveWidget(id string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *extensionAPI) GetWidgets(placement extensions.WidgetPlacement) []extensions.WidgetConfig {
|
||||
func (e *extensionAPI) GetWidgets(placement ExtensionWidgetPlacement) []ExtensionWidgetConfig {
|
||||
if e.kit.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func (e *extensionAPI) GetWidgets(placement extensions.WidgetPlacement) []extens
|
||||
|
||||
// Header/Footer
|
||||
|
||||
func (e *extensionAPI) SetHeader(config extensions.HeaderFooterConfig) {
|
||||
func (e *extensionAPI) SetHeader(config ExtensionHeaderFooterConfig) {
|
||||
if e.kit.extRunner != nil {
|
||||
e.kit.extRunner.SetHeader(config)
|
||||
}
|
||||
@@ -211,14 +211,14 @@ func (e *extensionAPI) RemoveHeader() {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *extensionAPI) GetHeader() *extensions.HeaderFooterConfig {
|
||||
func (e *extensionAPI) GetHeader() *ExtensionHeaderFooterConfig {
|
||||
if e.kit.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return e.kit.extRunner.GetHeader()
|
||||
}
|
||||
|
||||
func (e *extensionAPI) SetFooter(config extensions.HeaderFooterConfig) {
|
||||
func (e *extensionAPI) SetFooter(config ExtensionHeaderFooterConfig) {
|
||||
if e.kit.extRunner != nil {
|
||||
e.kit.extRunner.SetFooter(config)
|
||||
}
|
||||
@@ -230,7 +230,7 @@ func (e *extensionAPI) RemoveFooter() {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *extensionAPI) GetFooter() *extensions.HeaderFooterConfig {
|
||||
func (e *extensionAPI) GetFooter() *ExtensionHeaderFooterConfig {
|
||||
if e.kit.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -239,7 +239,7 @@ func (e *extensionAPI) GetFooter() *extensions.HeaderFooterConfig {
|
||||
|
||||
// Editor
|
||||
|
||||
func (e *extensionAPI) SetEditor(config extensions.EditorConfig) {
|
||||
func (e *extensionAPI) SetEditor(config ExtensionEditorConfig) {
|
||||
if e.kit.extRunner != nil {
|
||||
e.kit.extRunner.SetEditor(config)
|
||||
}
|
||||
@@ -251,7 +251,7 @@ func (e *extensionAPI) ResetEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *extensionAPI) GetEditor() *extensions.EditorConfig {
|
||||
func (e *extensionAPI) GetEditor() *ExtensionEditorConfig {
|
||||
if e.kit.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -260,13 +260,13 @@ func (e *extensionAPI) GetEditor() *extensions.EditorConfig {
|
||||
|
||||
// UI Visibility
|
||||
|
||||
func (e *extensionAPI) SetUIVisibility(v extensions.UIVisibility) {
|
||||
func (e *extensionAPI) SetUIVisibility(v ExtensionUIVisibility) {
|
||||
if e.kit.extRunner != nil {
|
||||
e.kit.extRunner.SetUIVisibility(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *extensionAPI) GetUIVisibility() *extensions.UIVisibility {
|
||||
func (e *extensionAPI) GetUIVisibility() *ExtensionUIVisibility {
|
||||
if e.kit.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -275,14 +275,14 @@ func (e *extensionAPI) GetUIVisibility() *extensions.UIVisibility {
|
||||
|
||||
// Tool rendering
|
||||
|
||||
func (e *extensionAPI) GetToolRenderer(toolName string) *extensions.ToolRenderConfig {
|
||||
func (e *extensionAPI) GetToolRenderer(toolName string) *ExtensionToolRenderConfig {
|
||||
if e.kit.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return e.kit.extRunner.GetToolRenderer(toolName)
|
||||
}
|
||||
|
||||
func (e *extensionAPI) GetMessageRenderer(name string) *extensions.MessageRendererConfig {
|
||||
func (e *extensionAPI) GetMessageRenderer(name string) *ExtensionMessageRendererConfig {
|
||||
if e.kit.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -291,7 +291,7 @@ func (e *extensionAPI) GetMessageRenderer(name string) *extensions.MessageRender
|
||||
|
||||
// Session data
|
||||
|
||||
func (e *extensionAPI) GetSessionMessages() []extensions.SessionMessage {
|
||||
func (e *extensionAPI) GetSessionMessages() []ExtensionSessionMessage {
|
||||
if e.kit.session == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -299,8 +299,8 @@ func (e *extensionAPI) GetSessionMessages() []extensions.SessionMessage {
|
||||
// Try to use the legacy iterBranchMessages for backward compatibility
|
||||
// with the default TreeManager adapter
|
||||
if adapter, ok := e.kit.session.(*treeManagerAdapter); ok {
|
||||
return iterBranchMessages(adapter.inner, func(me *session.MessageEntry, msg message.Message) extensions.SessionMessage {
|
||||
return extensions.SessionMessage{
|
||||
return iterBranchMessages(adapter.inner, func(me *session.MessageEntry, msg message.Message) ExtensionSessionMessage {
|
||||
return ExtensionSessionMessage{
|
||||
ID: me.ID,
|
||||
Role: string(msg.Role),
|
||||
Content: msg.Content(),
|
||||
@@ -311,10 +311,10 @@ func (e *extensionAPI) GetSessionMessages() []extensions.SessionMessage {
|
||||
|
||||
// For custom SessionManagers, use the public interface
|
||||
branch := e.kit.session.GetCurrentBranch()
|
||||
var result []extensions.SessionMessage
|
||||
var result []ExtensionSessionMessage
|
||||
for _, entry := range branch {
|
||||
if entry.Type == EntryTypeMessage {
|
||||
result = append(result, extensions.SessionMessage{
|
||||
result = append(result, ExtensionSessionMessage{
|
||||
ID: entry.ID,
|
||||
Role: entry.Role,
|
||||
Content: entry.Content,
|
||||
@@ -332,14 +332,14 @@ func (e *extensionAPI) AppendEntry(extType, data string) (string, error) {
|
||||
return e.kit.session.AppendExtensionData(extType, data)
|
||||
}
|
||||
|
||||
func (e *extensionAPI) GetEntries(extType string) []extensions.ExtensionEntry {
|
||||
func (e *extensionAPI) GetEntries(extType string) []ExtensionEntry {
|
||||
if e.kit.session == nil {
|
||||
return nil
|
||||
}
|
||||
entries := e.kit.session.GetExtensionData(extType)
|
||||
result := make([]extensions.ExtensionEntry, 0, len(entries))
|
||||
result := make([]ExtensionEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
result = append(result, extensions.ExtensionEntry{
|
||||
result = append(result, ExtensionEntry{
|
||||
ID: e.ID,
|
||||
EntryType: e.ExtType,
|
||||
Data: e.Data,
|
||||
@@ -351,7 +351,7 @@ func (e *extensionAPI) GetEntries(extType string) []extensions.ExtensionEntry {
|
||||
|
||||
// Status bar
|
||||
|
||||
func (e *extensionAPI) SetStatus(entry extensions.StatusBarEntry) {
|
||||
func (e *extensionAPI) SetStatus(entry ExtensionStatusBarEntry) {
|
||||
if e.kit.extRunner != nil {
|
||||
e.kit.extRunner.SetStatusEntry(entry)
|
||||
}
|
||||
@@ -363,7 +363,7 @@ func (e *extensionAPI) RemoveStatus(key string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *extensionAPI) GetStatusEntries() []extensions.StatusBarEntry {
|
||||
func (e *extensionAPI) GetStatusEntries() []ExtensionStatusBarEntry {
|
||||
if e.kit.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -394,12 +394,12 @@ func (e *extensionAPI) GetShortcuts() map[string]func() {
|
||||
|
||||
// Tools
|
||||
|
||||
func (e *extensionAPI) GetToolInfos() []extensions.ToolInfo {
|
||||
func (e *extensionAPI) GetToolInfos() []ExtensionToolInfo {
|
||||
agentTools := e.kit.agent.GetTools()
|
||||
coreCount := e.kit.agent.GetCoreToolCount()
|
||||
mcpCount := e.kit.agent.GetMCPToolCount()
|
||||
|
||||
result := make([]extensions.ToolInfo, 0, len(agentTools))
|
||||
result := make([]ExtensionToolInfo, 0, len(agentTools))
|
||||
for i, t := range agentTools {
|
||||
info := t.Info()
|
||||
source := "core"
|
||||
@@ -412,7 +412,7 @@ func (e *extensionAPI) GetToolInfos() []extensions.ToolInfo {
|
||||
if e.kit.extRunner != nil && e.kit.extRunner.IsToolDisabled(info.Name) {
|
||||
enabled = false
|
||||
}
|
||||
result = append(result, extensions.ToolInfo{
|
||||
result = append(result, ExtensionToolInfo{
|
||||
Name: info.Name,
|
||||
Description: info.Description,
|
||||
Source: source,
|
||||
@@ -505,7 +505,7 @@ func (e *extensionAPI) EmitBeforeSessionSwitch(switchReason string) (cancelled b
|
||||
|
||||
// Commands
|
||||
|
||||
func (e *extensionAPI) Commands() []extensions.CommandDef {
|
||||
func (e *extensionAPI) Commands() []ExtensionCommandDef {
|
||||
if e.kit.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -61,3 +61,23 @@ func CheckProviderReady(provider string) error {
|
||||
}
|
||||
return models.GetGlobalRegistry().ValidateEnvironment(provider, "")
|
||||
}
|
||||
|
||||
// ResolveProviderBaseURL returns the base API URL kit will use when talking to
|
||||
// the given provider, applying the same resolution order that CreateProvider
|
||||
// uses internally:
|
||||
//
|
||||
// 1. The provider's `api` field from the models.dev registry.
|
||||
// 2. The hard-coded default base URL of its npm SDK package (e.g.
|
||||
// @ai-sdk/groq → https://api.groq.com/openai/v1).
|
||||
// 3. Template substitution against the current process environment when the
|
||||
// URL contains "${VAR}" placeholders.
|
||||
//
|
||||
// Returns a non-nil error when the provider is unknown, when no URL can be
|
||||
// derived, or when a templated URL has unset placeholders.
|
||||
//
|
||||
// Use this from your SDK integration to surface the effective endpoint before
|
||||
// instantiating a Kit, or to validate that a provider is reachable without
|
||||
// running an actual request.
|
||||
func ResolveProviderBaseURL(providerID string) (string, error) {
|
||||
return models.ResolveProviderBaseURL(providerID)
|
||||
}
|
||||
|
||||
@@ -110,6 +110,23 @@ Press **Ctrl+X s** during streaming to inject a system-level instruction mid-tur
|
||||
|
||||
Example: While the model is writing code, press Ctrl+X s and type "Use async/await instead" to change the implementation approach.
|
||||
|
||||
### Image attachments
|
||||
|
||||
Attach images to your next prompt straight from the clipboard:
|
||||
|
||||
- Copy an image (e.g. a screenshot) to the system clipboard, then press **Ctrl+V** in the input to attach it.
|
||||
- Press **Ctrl+U** to clear all pending image attachments.
|
||||
- Attachments are sent alongside your text when you submit, and cleared afterward.
|
||||
|
||||
When a terminal supports color, Kit renders a small low-resolution **thumbnail preview** of each pending image directly in the input, below the `[N image(s) attached]` indicator, so you can confirm the right image was attached before sending.
|
||||
|
||||
The preview is drawn with Unicode half-block characters and ordinary terminal colors — not a graphics protocol — so it renders correctly inside terminal multiplexers like **tmux** and **zellij**. Thumbnails are capped to a small cell box for a glanceable, low-res look.
|
||||
|
||||
- Best fidelity needs a **truecolor** terminal (`COLORTERM=truecolor`); Kit degrades to 256-color where truecolor is unavailable.
|
||||
- On terminals with neither, the preview is skipped and the `[N image(s) attached]` text indicator is shown alone.
|
||||
|
||||
You can also attach image files by referencing them with `@path/to/image.png` — binary files are auto-detected by MIME type. See [Quick Start](/quick-start) for the `@` attachment syntax.
|
||||
|
||||
## Prompt templates
|
||||
|
||||
### Creating templates
|
||||
|
||||
Reference in New Issue
Block a user