diff --git a/.gitignore b/.gitignore index 74ded6c..54b842d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,6 @@ clue AGENT.md dist/ -refs/ bin/ +refs/ +prototypes/ diff --git a/.zed/tasks.json b/.zed/tasks.json deleted file mode 100644 index adbcdb0..0000000 --- a/.zed/tasks.json +++ /dev/null @@ -1,47 +0,0 @@ -[ - { - "label": "Run server", - "command": "make serve", - //"args": [], - // Env overrides for the command, will be appended to the terminal's environment from the settings. - "env": { "foo": "bar" }, - // Current working directory to spawn the command into, defaults to current project root. - //"cwd": "/path/to/working/directory", - // Whether to use a new terminal tab or reuse the existing one to spawn the process, defaults to `false`. - "use_new_terminal": false, - // Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish, defaults to `false`. - "allow_concurrent_runs": false, - // What to do with the terminal pane and tab, after the command was started: - // * `always` — always show the task's pane, and focus the corresponding tab in it (default) - // * `no_focus` — always show the task's pane, add the task's tab in it, but don't focus it - // * `never` — do not alter focus, but still add/reuse the task's tab in its pane - "reveal": "always", - // Where to place the task's terminal item after starting the task: - // * `dock` — in the terminal dock, "regular" terminal items' place (default) - // * `center` — in the central pane group, "main" editor area - "reveal_target": "dock", - // What to do with the terminal pane and tab, after the command had finished: - // * `never` — Do nothing when the command finishes (default) - // * `always` — always hide the terminal tab, hide the pane also if it was the last tab in it - // * `on_success` — hide the terminal tab on task success only, otherwise behaves similar to `always` - "hide": "never", - // Which shell to use when running a task inside the terminal. - // May take 3 values: - // 1. (default) Use the system's default terminal configuration in /etc/passwd - // "shell": "system" - // 2. A program: - // "shell": { - // "program": "sh" - // } - // 3. A program with arguments: - // "shell": { - // "with_arguments": { - // "program": "/bin/bash", - // "args": ["--login"] - // } - // } - "shell": "system", - // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. - "tags": [] - } -] diff --git a/Makefile b/Makefile index 3dabaf4..9ddccdf 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,49 @@ -run/new: - go run ./main.go -run/latest: - go run ./main.go -n=false -run/gemini: - go run ./main.go --provider=google -debug/cli: - go run ./main.go --tui=false -serve: - go run ./main.go serve -list/models: - go run ./main.go list -list/conversations: - go run ./main.go conversation -l -build: - $(eval VERSION := $(shell cat VERSION)) - go build -ldflags="-s -X 'github.com/honganh1206/tinker/cmd.Version=$(VERSION)'" -o bin/tinker main.go +GOCMD=go +GOBUILD=$(GOCMD) build +GORUN=$(GOCMD) run +GOTOOL=$(GOCMD) tool +GOCLEAN=$(GOCMD) clean +GOTEST=$(GOCMD) test +GOGET=$(GOCMD) get +BINARY_NAME=tinker +BINARY_UNIX=$(BINARY_NAME)_unix +BIN_DIR=./bin + +all: test build +build: + $(GOBUILD) -o $(BIN_DIR)/$(BINARY_NAME) -v +test: + $(GOTEST) ./... coverage: - go test ./... -coverprofile=coverage.out - go tool cover -html=coverage.out + $(GOTEST) ./... -coverprofile=coverage.out + $(GOTOOL) cover -html=coverage.out benchmark: - go test ./... -bench=. -benchmem + $(GOTEST) ./... -bench=. -benchmem +clean: + $(GOCLEAN) + rm -f $(BIN_DIR)/$(BINARY_NAME) + rm -f $(BIN_DIR)/$(BINARY_UNIX) +# run: +# $(GORUN) + +# Cross compilation +build-linux: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BIN_DIR)/$(BINARY_UNIX) -v +# run/new: +# go run ./main.go +# run/latest: +# go run ./main.go -n=false +# run/gemini: +# go run ./main.go --provider=google +# debug/cli: +# go run ./main.go --tui=false +# serve: +# go run ./main.go serve +# list/models: +# go run ./main.go list +# list/conversations: +# go run ./main.go conversation -l +# build: +# $(eval VERSION := $(shell cat VERSION)) +# go build -ldflags="-s -X 'github.com/honganh1206/tinker/cmd.Version=$(VERSION)'" -o bin/tinker main.go + diff --git a/agent/agent.go b/agent/agent.go index 6dd8f03..c546c92 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -10,6 +10,7 @@ import ( "github.com/honganh1206/tinker/inference" "github.com/honganh1206/tinker/mcp" "github.com/honganh1206/tinker/message" + "github.com/honganh1206/tinker/schema" "github.com/honganh1206/tinker/server/api" "github.com/honganh1206/tinker/server/data" "github.com/honganh1206/tinker/tools" @@ -145,17 +146,69 @@ func (a *Agent) executeTool(id, name string, input json.RawMessage, onDelta func result = a.executeLocalTool(id, name, input) } - // TODO: Shorten the relative/absolute path and underline it. - // For content to edit, remove it from the display? + isError := false if toolResult, ok := result.(message.ToolResultBlock); ok && toolResult.IsError { - onDelta(fmt.Sprintf("[red::]\u2717 %s failed[-]\n\n", name)) - } else { - onDelta(fmt.Sprintf("[green::]\u2713 %s %s[-]\n\n", name, input)) + isError = true } + onDelta(FormatToolResultMessage(name, input, isError)) return result } +func FormatToolResultMessage(name string, input json.RawMessage, isError bool) string { + var detail string + + switch name { + case tools.ToolNameReadFile: + i, err := schema.DecodeRaw[tools.ReadFileInput](input) + if err == nil { + detail = i.Path + } + return ui.FormatToolResult(ui.ToolResultFormat{Name: "Read", Detail: detail, IsError: isError}) + + case tools.ToolNameEditFile: + i, err := schema.DecodeRaw[tools.EditFileInput](input) + if err == nil { + detail = i.Path + } + return ui.FormatToolResult(ui.ToolResultFormat{Name: "Edit", Detail: detail, IsError: isError}) + + case tools.ToolNameListFiles: + i, err := schema.DecodeRaw[tools.ListFilesInput](input) + if err == nil { + detail = i.Path + } + return ui.FormatListFilesToolResult(ui.ToolResultFormat{Name: "List", Detail: detail, IsError: isError}) + + case tools.ToolNameBash: + i, err := schema.DecodeRaw[tools.BashInput](input) + if err == nil { + detail = i.Command + } + return ui.FormatToolResult(ui.ToolResultFormat{Name: "Bash", Detail: detail, IsError: isError}) + + case tools.ToolNameFinder: + i, err := schema.DecodeRaw[tools.FinderInput](input) + if err == nil { + detail = i.Query + } + return ui.FormatToolResult(ui.ToolResultFormat{Name: "Finder", Detail: detail, IsError: isError}) + + case tools.ToolNameGrepSearch: + i, err := schema.DecodeRaw[tools.GrepSearchInput](input) + if err == nil { + detail = i.Pattern + } + return ui.FormatToolResult(ui.ToolResultFormat{Name: "Grep", Detail: detail, IsError: isError}) + + case tools.ToolNamePlanRead, tools.ToolNamePlanWrite: + return ui.FormatToolResult(ui.ToolResultFormat{Name: "Plan", IsError: isError}) + + default: + return ui.FormatToolResult(ui.ToolResultFormat{Name: name, IsError: isError}) + } +} + func (a *Agent) executeMCPTool(id, name string, input json.RawMessage, toolDetails mcp.ToolDetails) message.ContentBlock { var args map[string]any @@ -298,9 +351,7 @@ func (a *Agent) runSubagent(id, name, toolDescription string, rawInput json.RawM // The OG input from the user gets processed by the main agent // and the subagent will consume the processed input. // This is for the maybe future of task delegation - var input struct { - Query string `json:"query"` - } + var input tools.FinderInput err := json.Unmarshal(rawInput, &input) if err != nil { @@ -349,3 +400,4 @@ func (a *Agent) streamResponse(ctx context.Context, onDelta func(string)) (*mess return msg, nil } + diff --git a/cmd/cmd.go b/cmd/cmd.go index 42ce70b..0ed87e1 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -101,6 +101,8 @@ func RunServer(cmd *cobra.Command, args []string) error { return err } fmt.Printf("Running background server on %s\n", ln.Addr().String()) + // TODO: Can this be on a separate goroutine? + // so when I execute the command I return to my current shell session? err = server.Serve(ln) if errors.Is(err, http.ErrServerClosed) { return nil diff --git a/cmd/tui.go b/cmd/tui.go index 06e9bfd..b2aee87 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -3,6 +3,7 @@ package cmd import ( "context" _ "embed" + "encoding/json" "fmt" "math/rand" "os" @@ -42,7 +43,7 @@ func tui(ctx context.Context, agent *agent.Agent, ctl *ui.Controller) error { relPath := displayRelativePath() questionInput := tview.NewTextArea() - questionInput.SetTitle("[blue::]Enter to send (ESC to focus conversation)"). + questionInput.SetTitle("[blue]Enter to send (ESC to focus conversation)"). SetTitleAlign(tview.AlignLeft). SetBorder(true). SetDrawFunc(renderRelativePath(relPath)) @@ -77,8 +78,8 @@ func tui(ctx context.Context, agent *agent.Agent, ctl *ui.Controller) error { return event }) + // TODO: This should be in a separate function renderPlan := func(s *ui.State) { - // app.QueueUpdateDraw(func() { inputFlex.Clear() plan := s.Plan if plan == nil || len(plan.Steps) == 0 { @@ -93,7 +94,6 @@ func tui(ctx context.Context, agent *agent.Agent, ctl *ui.Controller) error { newHeight := max(5, len(plan.Steps)+2) mainLayout.ResizeItem(inputFlex, newHeight, 0) } - // }) } initialState := &ui.State{Plan: agent.Plan} @@ -128,70 +128,10 @@ func tui(ctx context.Context, agent *agent.Agent, ctl *ui.Controller) error { questionInput.SetDisabled(true) // User input - fmt.Fprintf(conversationView, "[blue::]> %s\n\n", content) - - spinner := ui.NewSpinner(getRandomSpinnerMessage()) - firstDelta := true - spinCh := make(chan bool, 1) - - go func() { - ticker := time.NewTicker(50 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case stop := <-spinCh: - if stop { - // Clear the spinner text to hide it from the UI when the agent finishes processing - spinnerView.SetText("") - app.Draw() - return - } - case <-ticker.C: - if spinner != nil { - spinnerView.SetText(spinner.String()) - app.Draw() - } - } - } - }() - - go func() { - defer func() { - if spinner != nil { - spinner.Stop() - } - spinCh <- true - questionInput.SetDisabled(false) - app.Draw() - }() - - onDelta := func(delta string) { - // Run spinner on tool result delta - isToolResult := strings.Contains(delta, "\u2717") || strings.Contains(delta, "\u2713") - - if firstDelta && !isToolResult && spinner != nil { - // Only stop spinner on actual LLM text response, not tool use - spinner.Stop() - // Signal the spinner goroutine to clear the spinner text (SetText("")) since the LLM has started responding - spinCh <- true - firstDelta = false - } - - // Display LLM response - fmt.Fprintf(conversationView, "[white::]%s", delta) - } - - err := agent.Run(ctx, content, onDelta) - if err != nil { - fmt.Fprintf(conversationView, "[red::]Error: %v[-]\n\n", err) - return - } - - fmt.Fprintf(conversationView, "\n\n") - conversationView.ScrollToEnd() - }() + fmt.Fprintf(conversationView, "[blue::i]> %s\n\n", content) + + // Should call this only + go streamContent(app, ctx, conversationView, questionInput, spinnerView, content, agent) return nil } @@ -205,7 +145,7 @@ func tui(ctx context.Context, agent *agent.Agent, ctl *ui.Controller) error { return nil } -func formatMessage(msg *message.Message) string { +func formatMessage(msg *message.Message, nextMsg *message.Message) string { var result strings.Builder switch msg.Role { @@ -215,12 +155,23 @@ func formatMessage(msg *message.Message) string { result.WriteString("\n[white::]") } + toolErrors := make(map[string]bool) + if nextMsg != nil && nextMsg.Role == message.UserRole { + for _, block := range nextMsg.Content { + if tr, ok := block.(message.ToolResultBlock); ok && tr.IsError { + toolErrors[tr.ToolUseID] = true + } + } + } + for _, block := range msg.Content { switch b := block.(type) { case message.TextBlock: result.WriteString(b.Text + "\n") case message.ToolUseBlock: - result.WriteString(fmt.Sprintf("[green:]\u2713 %s %s\n", b.Name, b.Input)) + isError := toolErrors[b.ID] + inputBytes, _ := json.Marshal(b.Input) + result.WriteString(agent.FormatToolResultMessage(b.Name, inputBytes, isError)) } } @@ -253,13 +204,17 @@ func displayConversationHistory(conversationView *tview.TextView, conv *data.Con return } - for _, msg := range conv.Messages { - // This works, but is there a more efficient way? - if msg.Role == message.UserRole && msg.Content[0].Type() == message.ToolResultType { + for i, msg := range conv.Messages { + if msg.Role == message.UserRole && len(msg.Content) > 0 && msg.Content[0].Type() == message.ToolResultType { continue } - formattedMsg := formatMessage(msg) + var nextMsg *message.Message + if i+1 < len(conv.Messages) { + nextMsg = conv.Messages[i+1] + } + + formattedMsg := formatMessage(msg, nextMsg) fmt.Fprintf(conversationView, "%s", formattedMsg) } @@ -351,3 +306,55 @@ func formatPlanSteps(plan *data.Plan) string { return result.String() } + +// TODO: The number + order of arguments passed in here are atrocious. +// Are we going to make it C-like? Can we make it better? +func streamContent(app *tview.Application, ctx context.Context, conversationView *tview.TextView, questionInput *tview.TextArea, spinnerView *tview.TextView, content string, agent *agent.Agent) { + spinner := ui.NewSpinner(getRandomSpinnerMessage(), ui.SpinnerStar) + + stop := startSpinner(app, ctx, spinner, spinnerView) + go func() { + defer func() { + stop <- true + questionInput.SetDisabled(false) + app.Draw() + }() + + onDelta := func(delta string) { + // conversationView is append only, meaning we can replace the text that has already printed out + // so bye bye printing out tool being executed + fmt.Fprintf(conversationView, "[white]%s", delta) + } + + err := agent.Run(ctx, content, onDelta) + if err != nil { + fmt.Fprintf(conversationView, "[red::]Error: %v[-]\n\n", err) + return + } + + fmt.Fprintf(conversationView, "\n\n") + conversationView.ScrollToEnd() + }() +} + +func startSpinner(app *tview.Application, ctx context.Context, spinner *ui.Spinner, spinnerView *tview.TextView) chan bool { + stop := make(chan bool) + go func() { + for { + select { + case <-ctx.Done(): + return + case <-stop: + spinner.Stop() + spinnerView.SetText("") + return + default: + spinnerView.SetText(spinner.String()) + app.Draw() + } + } + }() + + return stop +} + diff --git a/devlogs/DevLogNov2025.md b/devlogs/DevLogNov2025.md index ecc5d65..9d0c432 100644 --- a/devlogs/DevLogNov2025.md +++ b/devlogs/DevLogNov2025.md @@ -49,3 +49,11 @@ We also need to know how to modify it. There is an in-memory object of the plan, Now that the plan state manangement is quite done using the channel approach, the next big challenge is to get rid of the `ToolMetadata` and the `client` object inside the plan tools. Both are just super glue code. Also we need to get rid of the if case for `plan_write` tool in `agent.go`. The first idea is to reuse the `agent.Client` field inside `agent.go`, meaning we execute CRUD operations on the plan entity inside the `agent.go`, not inside the tool files. + +--- + +We need to display the tool being executed on the TUI. + +The idea is that the name of the tool being executed will be displayed on the `conversationView`, together with a spinner at the start. When the tool is done with the execution i.e., there is tool result, the display of tool being executed will be replaced by the tool result as usual. + +I think I could centralize all the things happening on the TUI in `state.go`. That is, there should be a display of tools being executed, and when it is done, the text + spinner should disappear, giving space for the tool result. diff --git a/go.mod b/go.mod index 8e11ebd..ebbddd5 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/honganh1206/tinker go 1.24.4 require ( - github.com/anthropics/anthropic-sdk-go v1.14.0 + github.com/anthropics/anthropic-sdk-go v1.19.0 github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 github.com/mattn/go-sqlite3 v1.14.28 diff --git a/go.sum b/go.sum index 51d20c5..6c07f26 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842Bg cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/anthropics/anthropic-sdk-go v1.14.0 h1:EzNQvnZlaDHe2UPkoUySDz3ixRgNbwKdH8KtFpv7pi4= -github.com/anthropics/anthropic-sdk-go v1.14.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE= +github.com/anthropics/anthropic-sdk-go v1.19.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= diff --git a/inference/anthropic.go b/inference/anthropic.go index 520781d..4cf2dae 100644 --- a/inference/anthropic.go +++ b/inference/anthropic.go @@ -50,6 +50,8 @@ func (c *AnthropicClient) TruncateMessage(msg *message.Message, threshold int) * func getAnthropicModel(model ModelVersion) anthropic.Model { switch model { + case Claude45Opus: + return anthropic.ModelClaudeOpus4_5_20251101 case Claude41Opus: return anthropic.ModelClaudeOpus4_1_20250805 case Claude4Opus: @@ -58,12 +60,8 @@ func getAnthropicModel(model ModelVersion) anthropic.Model { return anthropic.ModelClaudeSonnet4_5 case Claude4Sonnet: return anthropic.ModelClaudeSonnet4_0 - case Claude37Sonnet: - return anthropic.ModelClaude3_7SonnetLatest case Claude45Haiku: return anthropic.ModelClaudeHaiku4_5 - case Claude35Sonnet: - return anthropic.ModelClaude3_5SonnetLatest case Claude35Haiku: return anthropic.ModelClaude3_5HaikuLatest case Claude3Opus: @@ -86,7 +84,8 @@ func (c *AnthropicClient) RunInference(ctx context.Context, onDelta func(string) Messages: c.history, Tools: c.tools, System: []anthropic.TextBlockParam{ - {Text: c.systemPrompt, CacheControl: c.cache}}, + {Text: c.systemPrompt, CacheControl: c.cache}, + }, } var resp *message.Message @@ -271,7 +270,8 @@ func toAnthropicBlocks(blocks []message.ContentBlock) []anthropic.ContentBlockPa func toGenericMessage(anthropicMsg anthropic.Message) (*message.Message, error) { msg := &message.Message{ Role: message.AssistantRole, - Content: make([]message.ContentBlock, 0)} + Content: make([]message.ContentBlock, 0), + } for _, block := range anthropicMsg.Content { switch variant := block.AsAny().(type) { diff --git a/inference/inference.go b/inference/inference.go index 5089705..6eb9979 100644 --- a/inference/inference.go +++ b/inference/inference.go @@ -61,7 +61,6 @@ func ListAvailableModels(provider ProviderName) []ModelVersion { return []ModelVersion{ Claude4Opus, Claude4Sonnet, - Claude37Sonnet, Claude35Sonnet, Claude35Haiku, Claude3Opus, @@ -86,7 +85,7 @@ func ListAvailableModels(provider ProviderName) []ModelVersion { func GetDefaultModel(provider ProviderName) ModelVersion { switch provider { case AnthropicProvider: - return Claude37Sonnet + return Claude45Opus case GoogleProvider: return Gemini3Pro default: diff --git a/inference/inference_test.go b/inference/inference_test.go index 2f0bd5d..3d12214 100644 --- a/inference/inference_test.go +++ b/inference/inference_test.go @@ -109,47 +109,6 @@ func TestInit_UnknownProvider(t *testing.T) { assert.Contains(t, err.Error(), "unknown model provider") } -func TestListAvailableModels_AnthropicProvider(t *testing.T) { - models := ListAvailableModels(AnthropicProvider) - - expectedModels := []ModelVersion{ - Claude4Opus, - Claude4Sonnet, - Claude37Sonnet, - Claude35Sonnet, - Claude35Haiku, - Claude3Opus, - Claude3Sonnet, - Claude3Haiku, - } - - assert.Equal(t, expectedModels, models) - assert.Len(t, models, 8) -} - -func TestListAvailableModels_GoogleProvider(t *testing.T) { - models := ListAvailableModels(GoogleProvider) - - expectedModels := []ModelVersion{ - Gemini3Pro, - Gemini25Pro, - Gemini25Flash, - Gemini20Flash, - Gemini20FlashLite, - Gemini15Pro, - Gemini15Flash, - } - - assert.Equal(t, expectedModels, models) - assert.Len(t, models, 7) -} - -func TestListAvailableModels_UnknownProvider(t *testing.T) { - models := ListAvailableModels("unknown_provider") - - assert.Empty(t, models) -} - func TestBaseLLMClient_BaseSummarizeHistory_BelowThreshold(t *testing.T) { client := &BaseLLMClient{} messages := createTestMessages(5) @@ -341,44 +300,3 @@ func TestBaseLLMClient_BaseTruncateMessage_ExactThresholdLength(t *testing.T) { toolResult := result.Content[0].(message.ToolResultBlock) assert.Equal(t, content, toolResult.Content) // Should not be truncated } - -// Table-driven tests for multiple scenarios -func TestListAvailableModels_AllProviders(t *testing.T) { - tests := []struct { - name string - provider ProviderName - expectedCount int - shouldContain []ModelVersion - }{ - { - name: "Anthropic provider", - provider: AnthropicProvider, - expectedCount: 8, - shouldContain: []ModelVersion{Claude4Sonnet, Claude35Haiku}, - }, - { - name: "Google provider", - provider: GoogleProvider, - expectedCount: 7, - shouldContain: []ModelVersion{Gemini25Pro, Gemini15Flash}, - }, - { - name: "Unknown provider", - provider: "nonexistent", - expectedCount: 0, - shouldContain: []ModelVersion{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - models := ListAvailableModels(tt.provider) - - assert.Len(t, models, tt.expectedCount) - - for _, expectedModel := range tt.shouldContain { - assert.Contains(t, models, expectedModel) - } - }) - } -} diff --git a/inference/types.go b/inference/types.go index 8ba899e..6869cd4 100644 --- a/inference/types.go +++ b/inference/types.go @@ -17,12 +17,12 @@ type ( const ( // Claude + Claude45Opus ModelVersion = "claude-4-5-opus" Claude41Opus ModelVersion = "claude-4-1-opus" Claude4Opus ModelVersion = "claude-4-opus" Claude3Opus ModelVersion = "claude-3-opus" Claude45Sonnet ModelVersion = "claude-4-5-sonnet" Claude4Sonnet ModelVersion = "claude-4-sonnet" - Claude37Sonnet ModelVersion = "claude-3-7-sonnet" Claude35Sonnet ModelVersion = "claude-3-5-sonnet" Claude3Sonnet ModelVersion = "claude-3-sonnet" Claude45Haiku ModelVersion = "claude-4-5-haiku" diff --git a/schema/schema.go b/schema/schema.go index c5ce17a..87fa9fd 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -23,16 +23,10 @@ func Generate[T any]() *jsonschema.Schema { return rawSchema } -// TODO: Use when migrating from jsonschema to json.RawMessage? -func ConvertStructToJSONRawMessage[T any]() json.RawMessage { - var v T - b, err := json.Marshal(v) - - if err != nil { - return nil - } - - return json.RawMessage(b) +func DecodeRaw[T any](raw json.RawMessage) (T, error) { + var out T + err := json.Unmarshal(raw, &out) + return out, err } func ConvertToGeminiSchema(inputSchema any) (*genai.Schema, error) { diff --git a/secret-file.txt b/secret-file.txt deleted file mode 100644 index 829b700..0000000 --- a/secret-file.txt +++ /dev/null @@ -1,14 +0,0 @@ -what animal is the most disagreeable because it always says neigh? -A horse, of course! - -Why don't scientists trust atoms? -Because they make up everything! - -What do you call a fake noodle? -An impasta! - -Why did the scarecrow win an award? -Because he was outstanding in his field! - -What do you call a bear with no teeth? -A gummy bear! diff --git a/ui/format.go b/ui/format.go new file mode 100644 index 0000000..260d64d --- /dev/null +++ b/ui/format.go @@ -0,0 +1,44 @@ +package ui + +import ( + "fmt" +) + +const ( + SuccessSymbol = "✓" + ErrorSymbol = "✗" + FolderSymbol = "🖿" +) + +type ToolResultFormat struct { + Name string + Detail string + IsError bool +} + +func FormatToolResult(f ToolResultFormat) string { + if f.IsError { + if f.Detail != "" { + return fmt.Sprintf("[red]%s [white::-]%s [blue]%s[white::-]\n\n", ErrorSymbol, f.Name, f.Detail) + } + return fmt.Sprintf("[red]%s [white::-]%s\n\n", ErrorSymbol, f.Name) + } + + if f.Detail != "" { + return fmt.Sprintf("[green]%s [white::-]%s [blue]%s[white::-]\n\n", SuccessSymbol, f.Name, f.Detail) + } + + return fmt.Sprintf("[green]%s [white::-]%s\n\n", SuccessSymbol, f.Name) +} + +func FormatListFilesToolResult(f ToolResultFormat) string { + if f.IsError { + if f.Detail != "" { + return fmt.Sprintf("[red]%s [white::-]%s [blue]%s[white::-]\n\n", ErrorSymbol, FolderSymbol, f.Detail) + } + return fmt.Sprintf("[red]%s [white::-]%s\n\n", ErrorSymbol, FolderSymbol) + } + + return fmt.Sprintf("[green]%s [white::-]%s\n\n", SuccessSymbol, FolderSymbol) +} + diff --git a/ui/spinner.go b/ui/spinner.go index 03871d6..ed7a305 100644 --- a/ui/spinner.go +++ b/ui/spinner.go @@ -7,6 +7,35 @@ import ( "time" ) +var ( + SpinnerBinary = []string{ + "010010", + "001100", + "100101", + "111010", + "111101", + "010111", + "101011", + "111000", + "110011", + "110101", + } + + SpinnerDots = []string{ + "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", + } + + SpinnerStar = []string{ + "+", + "x", + "*", + } + + SpinnerLines = []string{ + "|", "/", "-", "\\", + } +) + type Spinner struct { message atomic.Value messageWidth int @@ -17,30 +46,12 @@ type Spinner struct { stopped time.Time } -func NewSpinner(message string) *Spinner { +func NewSpinner(message string, parts []string) *Spinner { + if len(parts) == 0 { + parts = SpinnerBinary + } s := &Spinner{ - parts: []string{ - // Noise - // "▓", "▒", "░", - // Stars - // "✶", - // "✸", - // "✹", - // "✺", - // "✹", - // "✷", - // Binary - "010010", - "001100", - "100101", - "111010", - "111101", - "010111", - "101011", - "111000", - "110011", - "110101", - }, + parts: parts, started: time.Now(), } s.SetMessage(message)