additional tools

This commit is contained in:
Kujtim Hoxha 2025-03-25 13:04:36 +01:00
parent 005b8ac167
commit 904061c243
33 changed files with 3258 additions and 236 deletions

13
README.md Normal file
View file

@ -0,0 +1,13 @@
# TermAI
**⚠️ WORK IN PROGRESS ⚠️**
This project is currently under active development.
## Current Progress
- Initial CLI setup
- Basic functionality implementation
- Working on core features
More details coming soon.

View file

@ -10,6 +10,7 @@ import (
"github.com/kujtimiihoxha/termai/internal/db"
"github.com/kujtimiihoxha/termai/internal/llm/models"
"github.com/kujtimiihoxha/termai/internal/tui"
zone "github.com/lrstanley/bubblezone"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
@ -37,9 +38,11 @@ var rootCmd = &cobra.Command{
app := app.New(ctx, conn)
app.Logger.Info("Starting termai...")
zone.NewGlobal()
tui := tea.NewProgram(
tui.New(app),
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
)
app.Logger.Info("Setting up subscriptions...")
ch, unsub := setupSubscriptions(app)
@ -102,6 +105,16 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
wg.Done()
}()
}
{
sub := app.Permissions.Subscribe(ctx)
wg.Add(1)
go func() {
for ev := range sub {
ch <- ev
}
wg.Done()
}()
}
return ch, func() {
cancel()
wg.Wait()
@ -130,6 +143,7 @@ func loadConfig() {
// LLM
viper.SetDefault("models.big", string(models.DefaultBigModel))
viper.SetDefault("models.small", string(models.DefaultLittleModel))
viper.SetDefault("providers.openai.key", os.Getenv("OPENAI_API_KEY"))
viper.SetDefault("providers.anthropic.key", os.Getenv("ANTHROPIC_API_KEY"))
viper.SetDefault("providers.groq.key", os.Getenv("GROQ_API_KEY"))

6
go.mod
View file

@ -3,6 +3,7 @@ module github.com/kujtimiihoxha/termai
go 1.23.5
require (
github.com/bmatcuk/doublestar/v4 v4.8.1
github.com/catppuccin/go v0.3.0
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.3.4
@ -16,11 +17,13 @@ require (
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0
github.com/kujtimiihoxha/vimtea v0.0.3-0.20250317175717-9d8ba9c69840
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
github.com/mattn/go-runewidth v0.0.16
github.com/mattn/go-sqlite3 v1.14.24
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.16.0
github.com/sergi/go-diff v1.3.1
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.0
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1
@ -112,7 +115,8 @@ require (
golang.org/x/arch v0.11.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/term v0.30.0 // indirect
golang.org/x/text v0.23.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

11
go.sum
View file

@ -46,6 +46,8 @@ github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
@ -175,6 +177,8 @@ 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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@ -241,6 +245,8 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sashabaranov/go-openai v1.32.5 h1:/eNVa8KzlE7mJdKPZDj6886MUzZQjoVHyn0sLvIt5qA=
github.com/sashabaranov/go-openai v1.32.5/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
@ -268,6 +274,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@ -327,8 +334,8 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View file

@ -8,6 +8,7 @@ import (
"github.com/kujtimiihoxha/termai/internal/llm"
"github.com/kujtimiihoxha/termai/internal/logging"
"github.com/kujtimiihoxha/termai/internal/message"
"github.com/kujtimiihoxha/termai/internal/permission"
"github.com/kujtimiihoxha/termai/internal/session"
"github.com/spf13/viper"
)
@ -15,9 +16,10 @@ import (
type App struct {
Context context.Context
Sessions session.Service
Messages message.Service
LLM llm.Service
Sessions session.Service
Messages message.Service
Permissions permission.Service
LLM llm.Service
Logger logging.Interface
}
@ -32,10 +34,11 @@ func New(ctx context.Context, conn *sql.DB) *App {
llm := llm.NewService(ctx, log, sessions, messages)
return &App{
Context: ctx,
Sessions: sessions,
Messages: messages,
LLM: llm,
Logger: log,
Context: ctx,
Sessions: sessions,
Messages: messages,
Permissions: permission.Default,
LLM: llm,
Logger: log,
}
}

View file

@ -17,8 +17,15 @@ import (
)
func coderTools() []tool.BaseTool {
wd := viper.GetString("wd")
return []tool.BaseTool{
tools.NewBashTool(viper.GetString("wd")),
tools.NewAgentTool(wd),
tools.NewBashTool(wd),
tools.NewLsTool(wd),
tools.NewGlobTool(wd),
tools.NewViewTool(wd),
tools.NewWriteTool(wd),
tools.NewEditTool(wd),
}
}

View file

@ -88,7 +88,6 @@ func (s *service) handleRequest(id string, sessionID string, content string) {
return
}
log.Printf("Request: %s", content)
currentAgent, systemMessage, err := agent.GetAgent(s.ctx, viper.GetString("agents.default"))
if err != nil {
s.Publish(AgentErrorEvent, AgentEvent{
@ -172,7 +171,6 @@ func (s *service) handleRequest(id string, sessionID string, content string) {
}
session.PromptTokens += int64(usage.PromptTokens)
session.CompletionTokens += int64(usage.CompletionTokens)
// TODO: calculate cost
model := models.SupportedModels[models.ModelID(viper.GetString("models.big"))]
session.Cost += float64(usage.PromptTokens)*(model.CostPer1MIn/1_000_000) +
float64(usage.CompletionTokens)*(model.CostPer1MOut/1_000_000)

View file

@ -26,8 +26,8 @@ type Model struct {
}
const (
DefaultBigModel = GPT4oMini
DefaultLittleModel = GPT4oMini
DefaultBigModel = Claude37Sonnet
DefaultLittleModel = Claude37Sonnet
)
// Model IDs
@ -118,10 +118,12 @@ var SupportedModels = map[ModelID]Model{
APIModel: "claude-3-haiku",
},
Claude37Sonnet: {
ID: Claude37Sonnet,
Name: "Claude 3.7 Sonnet",
Provider: ProviderAnthropic,
APIModel: "claude-3-7-sonnet-20250219",
ID: Claude37Sonnet,
Name: "Claude 3.7 Sonnet",
Provider: ProviderAnthropic,
APIModel: "claude-3-7-sonnet-20250219",
CostPer1MIn: 3.0,
CostPer1MOut: 15.0,
},
// Google
Gemini20Pro: {

141
internal/llm/tools/agent.go Normal file
View file

@ -0,0 +1,141 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"os"
"runtime"
"time"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/flow/agent/react"
"github.com/kujtimiihoxha/termai/internal/llm/models"
"github.com/spf13/viper"
)
type agentTool struct {
workingDir string
}
const (
AgentToolName = "agent"
)
type AgentParams struct {
Prompt string `json:"prompt"`
}
func taskAgentTools() []tool.BaseTool {
wd := viper.GetString("wd")
return []tool.BaseTool{
NewBashTool(wd),
NewLsTool(wd),
NewGlobTool(wd),
NewViewTool(wd),
NewWriteTool(wd),
NewEditTool(wd),
}
}
func NewTaskAgent(ctx context.Context) (*react.Agent, error) {
model, err := models.GetModel(ctx, models.ModelID(viper.GetString("models.big")))
if err != nil {
return nil, err
}
reactAgent, err := react.NewAgent(ctx, &react.AgentConfig{
Model: model,
ToolsConfig: compose.ToolsNodeConfig{
Tools: taskAgentTools(),
},
MaxStep: 1000,
})
if err != nil {
return nil, err
}
return reactAgent, nil
}
func TaskAgentSystemPrompt() string {
agentPrompt := `You are an agent for Orbitowl. Given the user's prompt, you should use the tools available to you to answer the user's question.
Notes:
1. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
2. When relevant, share file names and code snippets relevant to the query
3. Any file paths you return in your final response MUST be absolute. DO NOT use relative paths.
Here is useful information about the environment you are running in:
<env>
Working directory: %s
Platform: %s
Today's date: %s
</env>`
cwd, err := os.Getwd()
if err != nil {
cwd = "unknown"
}
platform := runtime.GOOS
switch platform {
case "darwin":
platform = "macos"
case "windows":
platform = "windows"
case "linux":
platform = "linux"
}
return fmt.Sprintf(agentPrompt, cwd, platform, time.Now().Format("1/2/2006"))
}
func (b *agentTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: AgentToolName,
Desc: "Launch a new agent that has access to the following tools: GlobTool, GrepTool, LS, View, ReadNotebook. When you are searching for a keyword or file and are not confident that you will find the right match on the first try, use the Agent tool to perform the search for you. For example:\n\n- If you are searching for a keyword like \"config\" or \"logger\", or for questions like \"which file does X?\", the Agent tool is strongly recommended\n- If you want to read a specific file path, use the View or GlobTool tool instead of the Agent tool, to find the match more quickly\n- If you are searching for a specific class definition like \"class Foo\", use the GlobTool tool instead, to find the match more quickly\n\nUsage notes:\n1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses\n2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.\n3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.\n4. The agent's outputs should generally be trusted\n5. IMPORTANT: The agent can not use Bash, Replace, Edit, NotebookEditCell, so can not modify files. If you want to use these tools, use them directly instead of going through the agent.",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"prompt": {
Type: "string",
Desc: "The task for the agent to perform",
Required: true,
},
}),
}, nil
}
func (b *agentTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
var params AgentParams
if err := json.Unmarshal([]byte(args), &params); err != nil {
return "", err
}
if params.Prompt == "" {
return "prompt is required", nil
}
a, err := NewTaskAgent(ctx)
if err != nil {
return "", err
}
out, err := a.Generate(
ctx,
[]*schema.Message{
schema.SystemMessage(TaskAgentSystemPrompt()),
schema.UserMessage(params.Prompt),
},
)
if err != nil {
return "", err
}
return out.Content, nil
}
func NewAgentTool(wd string) tool.InvokableTool {
return &agentTool{
workingDir: wd,
}
}

View file

@ -3,14 +3,13 @@ package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
"github.com/kujtimiihoxha/termai/internal/llm/tools/shell"
"github.com/kujtimiihoxha/termai/internal/permission"
)
type bashTool struct {
@ -36,6 +35,11 @@ var BannedCommands = []string{
"http-prompt", "chrome", "firefox", "safari",
}
var SafeReadOnlyCommands = []string{
"ls", "echo", "pwd", "date", "cal", "uptime", "whoami", "id", "groups", "env", "printenv", "set", "unset", "which", "type", "whereis",
"whatis", //...
}
func (b *bashTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: BashToolName,
@ -56,7 +60,6 @@ func (b *bashTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
// Handle implements Tool.
func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
log.Printf("BashTool InvokableRun: %s", args)
var params BashParams
if err := json.Unmarshal([]byte(args), &params); err != nil {
return "", err
@ -69,13 +72,37 @@ func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
}
if params.Command == "" {
return "", errors.New("missing command")
return "missing command", nil
}
baseCmd := strings.Fields(params.Command)[0]
for _, banned := range BannedCommands {
if strings.EqualFold(baseCmd, banned) {
return "", fmt.Errorf("command '%s' is not allowed", baseCmd)
return fmt.Sprintf("command '%s' is not allowed", baseCmd), nil
}
}
isSafeReadOnly := false
for _, safe := range SafeReadOnlyCommands {
if strings.EqualFold(baseCmd, safe) {
isSafeReadOnly = true
break
}
}
if !isSafeReadOnly {
p := permission.Default.Request(
permission.CreatePermissionRequest{
Path: b.workingDir,
ToolName: BashToolName,
Action: "execute",
Description: fmt.Sprintf("Execute command: %s", params.Command),
Params: map[string]interface{}{
"command": params.Command,
"timeout": params.Timeout,
},
},
)
if !p {
return "", fmt.Errorf("permission denied for command: %s", params.Command)
}
}
@ -125,8 +152,9 @@ func (b *bashTool) InvokableRun(ctx context.Context, args string, opts ...tool.O
stdout += "\n" + errorMessage
}
log.Printf("BashTool InvokableRun: stdout: %s, stderr: %s, exitCode: %d, interrupted: %t", stdout, stderr, exitCode, interrupted)
if stdout == "" {
return "no output", nil
}
return stdout, nil
}
@ -150,7 +178,7 @@ func countLines(s string) int {
return len(strings.Split(s, "\n"))
}
func bashDescriptionBCP() string {
func bashDescription() string {
bannedCommandsStr := strings.Join(BannedCommands, ", ")
return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
@ -299,154 +327,6 @@ Important:
- Never update git config`, bannedCommandsStr, MaxOutputLength)
}
func bashDescription() string {
bannedCommandsStr := strings.Join(BannedCommands, ", ")
return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
Before executing the command, please follow these steps:
1. Directory Verification:
- If the command will create new directories or files, first use the ls command to verify the parent directory exists and is the correct location
- For example, before running "mkdir foo/bar", first use ls command to check that "foo" exists and is the intended parent directory
2. Security Check:
- For security and to limit the threat of a prompt injection attack, some commands are limited or banned. If you use a disallowed command, you will receive an error message explaining the restriction. Explain the error to the User.
- Verify that the command is not one of the banned commands: %s.
3. Command Execution:
- After ensuring proper quoting, execute the command.
- Capture the output of the command.
4. Output Processing:
- If the output exceeds %d characters, output will be truncated before being returned to you.
- Prepare the output for display to the user.
5. Return Result:
- Provide the processed output of the command.
- If any errors occurred during execution, include those in the output.
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 30 minutes.
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
- IMPORTANT: All commands share the same shell session. Shell state (environment variables, virtual environments, current directory, etc.) persist between commands. For example, if you set an environment variable as part of a command, the environment variable will persist for subsequent commands.
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of 'cd'. You may use 'cd' if the User explicitly requests it.
<good-example>
pytest /foo/bar/tests
</good-example>
<bad-example>
cd /foo/bar && pytest tests
</bad-example>
# Committing changes with git
When the user asks you to create a new git commit, follow these steps carefully:
1. Start with a single message that contains exactly three tool_use blocks that do the following (it is VERY IMPORTANT that you send these tool_use blocks in a single message, otherwise it will feel slow to the user!):
- Run a git status command to see all untracked files.
- Run a git diff command to see both staged and unstaged changes that will be committed.
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
2. Use the git context at the start of this conversation to determine which files are relevant to your commit. Add relevant untracked files to the staging area. Do not commit files that were already modified at the start of this conversation, if they are not relevant to your commit.
3. Analyze all staged changes (both previously staged and newly added) and draft a commit message. Wrap your analysis process in <commit_analysis> tags:
<commit_analysis>
- List the files that have been changed or added
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
- Brainstorm the purpose or motivation behind these changes
- Do not use tools to explore code, beyond what is available in the git context
- Assess the impact of these changes on the overall project
- Check for any sensitive information that shouldn't be committed
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
- Ensure your language is clear, concise, and to the point
- Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
- Ensure the message is not generic (avoid words like "Update" or "Fix" without context)
- Review the draft message to ensure it accurately reflects the changes and their purpose
</commit_analysis>
4. Create the commit with a message ending with:
🤖 Generated with termai
Co-Authored-By: termai <noreply@termai.io>
- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
<example>
git commit -m "$(cat <<'EOF'
Commit message here.
🤖 Generated with termai
Co-Authored-By: termai <noreply@termai.io>
EOF
)"
</example>
5. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
6. Finally, run git status to make sure the commit succeeded.
Important notes:
- When possible, combine the "git add" and "git commit" commands into a single "git commit -am" command, to speed things up
- However, be careful not to stage files (e.g. with 'git add .') for commits that aren't part of the change, they may have untracked files they want to keep around, but not commit.
- NEVER update the git config
- DO NOT push to the remote repository
- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them.
- Return an empty response - the user will see the git output directly
# Creating pull requests
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
1. Understand the current state of the branch. Remember to send a single message that contains multiple tool_use blocks (it is VERY IMPORTANT that you do this in a single message, otherwise it will feel slow to the user!):
- Run a git status command to see all untracked files.
- Run a git diff command to see both staged and unstaged changes that will be committed.
- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
- Run a git log command and 'git diff main...HEAD' to understand the full commit history for the current branch (from the time it diverged from the 'main' branch.)
2. Create new branch if needed
3. Commit changes if needed
4. Push to remote with -u flag if needed
5. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (not just the latest commit, but all commits that will be included in the pull request!), and draft a pull request summary. Wrap your analysis process in <pr_analysis> tags:
<pr_analysis>
- List the commits since diverging from the main branch
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
- Brainstorm the purpose or motivation behind these changes
- Assess the impact of these changes on the overall project
- Do not use tools to explore code, beyond what is available in the git context
- Check for any sensitive information that shouldn't be committed
- Draft a concise (1-2 bullet points) pull request summary that focuses on the "why" rather than the "what"
- Ensure the summary accurately reflects all changes since diverging from the main branch
- Ensure your language is clear, concise, and to the point
- Ensure the summary accurately reflects the changes and their purpose (ie. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
- Ensure the summary is not generic (avoid words like "Update" or "Fix" without context)
- Review the draft summary to ensure it accurately reflects the changes and their purpose
</pr_analysis>
6. Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
<example>
gh pr create --title "the pr title" --body "$(cat <<'EOF'
## Summary
<1-3 bullet points>
## Test plan
[Checklist of TODOs for testing the pull request...]
🤖 Generated with termai
EOF
)"
</example>
Important:
- Return an empty response - the user will see the gh output directly
- Never update git config`, bannedCommandsStr, MaxOutputLength)
}
func NewBashTool(workingDir string) tool.InvokableTool {
return &bashTool{
workingDir: workingDir,

420
internal/llm/tools/edit.go Normal file
View file

@ -0,0 +1,420 @@
package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
"github.com/kujtimiihoxha/termai/internal/permission"
"github.com/sergi/go-diff/diffmatchpatch"
)
type editTool struct {
workingDir string
}
const (
EditToolName = "edit"
)
type EditParams struct {
FilePath string `json:"file_path"`
OldString string `json:"old_string"`
NewString string `json:"new_string"`
}
func (b *editTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: EditToolName,
Desc: `This is a tool for editing files. For moving or renaming files, you should generally use the Bash tool with the 'mv' command instead. For larger edits, use the Write tool to overwrite files. F.
Before using this tool:
1. Use the View tool to understand the file's contents and context
2. Verify the directory path is correct (only applicable when creating new files):
- Use the LS tool to verify the parent directory exists and is the correct location
To make a file edit, provide the following:
1. file_path: The absolute path to the file to modify (must be absolute, not relative)
2. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
3. new_string: The edited text to replace the old_string
The tool will replace ONE occurrence of old_string with new_string in the specified file.
CRITICAL REQUIREMENTS FOR USING THIS TOOL:
1. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
- Include AT LEAST 3-5 lines of context BEFORE the change point
- Include AT LEAST 3-5 lines of context AFTER the change point
- Include all whitespace, indentation, and surrounding code exactly as it appears in the file
2. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
- Make separate calls to this tool for each instance
- Each call must uniquely identify its specific instance using extensive context
3. VERIFICATION: Before using this tool:
- Check how many instances of the target text exist in the file
- If multiple instances exist, gather enough context to uniquely identify each one
- Plan separate tool calls for each instance
WARNING: If you do not follow these requirements:
- The tool will fail if old_string matches multiple locations
- The tool will fail if old_string doesn't match exactly (including whitespace)
- You may change the wrong instance if you don't include enough context
When making edits:
- Ensure the edit results in idiomatic, correct code
- Do not leave the code in a broken state
- Always use absolute file paths (starting with /)
If you want to create a new file, use:
- A new file path, including dir name if needed
- An empty old_string
- The new file's contents as new_string
Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`,
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"file_path": {
Type: "string",
Desc: "The absolute path to the file to modify",
Required: true,
},
"old_string": {
Type: "string",
Desc: "The text to replace",
Required: true,
},
"new_string": {
Type: "string",
Desc: "The text to replace it with",
Required: true,
},
}),
}, nil
}
func (b *editTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
var params EditParams
if err := json.Unmarshal([]byte(args), &params); err != nil {
return "", err
}
if params.FilePath == "" {
return "", errors.New("file_path is required")
}
if !filepath.IsAbs(params.FilePath) {
return "", fmt.Errorf("file path must be absolute, got: %s", params.FilePath)
}
if params.OldString == "" {
return createNewFile(params.FilePath, params.NewString)
}
if params.NewString == "" {
return deleteContent(params.FilePath, params.OldString)
}
return replaceContent(params.FilePath, params.OldString, params.NewString)
}
func createNewFile(filePath, content string) (string, error) {
fileInfo, err := os.Stat(filePath)
if err == nil {
if fileInfo.IsDir() {
return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
}
return "", fmt.Errorf("file already exists: %s. Use the Replace tool to overwrite an existing file", filePath)
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("failed to access file: %w", err)
}
dir := filepath.Dir(filePath)
if err = os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("failed to create parent directories: %w", err)
}
p := permission.Default.Request(
permission.CreatePermissionRequest{
Path: filepath.Dir(filePath),
ToolName: EditToolName,
Action: "create",
Description: fmt.Sprintf("Create file %s", filePath),
Params: map[string]interface{}{
"file_path": filePath,
"content": content,
},
},
)
if !p {
return "", fmt.Errorf("permission denied")
}
err = os.WriteFile(filePath, []byte(content), 0o644)
if err != nil {
return "", fmt.Errorf("failed to write file: %w", err)
}
recordFileWrite(filePath)
recordFileRead(filePath)
// result := FileEditResult{
// FilePath: filePath,
// Created: true,
// Updated: false,
// Deleted: false,
// Diff: generateDiff("", content),
// }
//
// resultJSON, err := json.Marshal(result)
// if err != nil {
// return "", fmt.Errorf("failed to serialize result: %w", err)
// }
//
return "File created: " + filePath, nil
}
func deleteContent(filePath, oldString string) (string, error) {
fileInfo, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("file not found: %s", filePath)
}
return "", fmt.Errorf("failed to access file: %w", err)
}
if fileInfo.IsDir() {
return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
}
if getLastReadTime(filePath).IsZero() {
return "", fmt.Errorf("you must read the file before editing it. Use the View tool first")
}
modTime := fileInfo.ModTime()
lastRead := getLastReadTime(filePath)
if modTime.After(lastRead) {
return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
}
content, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read file: %w", err)
}
oldContent := string(content)
index := strings.Index(oldContent, oldString)
if index == -1 {
return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
}
lastIndex := strings.LastIndex(oldContent, oldString)
if index != lastIndex {
return "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
}
newContent := oldContent[:index] + oldContent[index+len(oldString):]
p := permission.Default.Request(
permission.CreatePermissionRequest{
Path: filepath.Dir(filePath),
ToolName: EditToolName,
Action: "delete",
Description: fmt.Sprintf("Delete content from file %s", filePath),
Params: map[string]interface{}{
"file_path": filePath,
"content": content,
},
},
)
if !p {
return "", fmt.Errorf("permission denied")
}
err = os.WriteFile(filePath, []byte(newContent), 0o644)
if err != nil {
return "", fmt.Errorf("failed to write file: %w", err)
}
recordFileWrite(filePath)
// result := FileEditResult{
// FilePath: filePath,
// Created: false,
// Updated: true,
// Deleted: true,
// Diff: generateDiff(oldContent, newContent),
// SnippetBefore: getContextSnippet(oldContent, index, len(oldString)),
// SnippetAfter: getContextSnippet(newContent, index, 0),
// }
//
// resultJSON, err := json.Marshal(result)
// if err != nil {
// return "", fmt.Errorf("failed to serialize result: %w", err)
// }
return "Content deleted from file: " + filePath, nil
}
func replaceContent(filePath, oldString, newString string) (string, error) {
fileInfo, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Sprintf("file not found: %s", filePath), nil
}
return fmt.Sprintf("failed to access file: %s", err), nil
}
if fileInfo.IsDir() {
return fmt.Sprintf("path is a directory, not a file: %s", filePath), nil
}
if getLastReadTime(filePath).IsZero() {
return "you must read the file before editing it. Use the View tool first", nil
}
modTime := fileInfo.ModTime()
lastRead := getLastReadTime(filePath)
if modTime.After(lastRead) {
return fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339)), nil
}
content, err := os.ReadFile(filePath)
if err != nil {
return fmt.Sprintf("failed to read file: %s", err), nil
}
oldContent := string(content)
index := strings.Index(oldContent, oldString)
if index == -1 {
return "old_string not found in file. Make sure it matches exactly, including whitespace and line breaks", nil
}
lastIndex := strings.LastIndex(oldContent, oldString)
if index != lastIndex {
return "old_string appears multiple times in the file. Please provide more context to ensure a unique match", nil
}
newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
p := permission.Default.Request(
permission.CreatePermissionRequest{
Path: filepath.Dir(filePath),
ToolName: EditToolName,
Action: "replace",
Description: fmt.Sprintf("Replace content in file %s", filePath),
Params: map[string]interface{}{
"file_path": filePath,
"old_string": oldString,
"new_string": newString,
},
},
)
if !p {
return "", fmt.Errorf("permission denied")
}
err = os.WriteFile(filePath, []byte(newContent), 0o644)
if err != nil {
return fmt.Sprintf("failed to write file: %s", err), nil
}
recordFileWrite(filePath)
// result := FileEditResult{
// FilePath: filePath,
// Created: false,
// Updated: true,
// Deleted: false,
// Diff: generateDiff(oldContent, newContent),
// SnippetBefore: getContextSnippet(oldContent, index, len(oldString)),
// SnippetAfter: getContextSnippet(newContent, index, len(newString)),
// }
//
// resultJSON, err := json.Marshal(result)
// if err != nil {
// return "", fmt.Errorf("failed to serialize result: %w", err)
// }
return "Content replaced in file: " + filePath, nil
}
func getContextSnippet(content string, position, length int) string {
contextLines := 3
lines := strings.Split(content, "\n")
lineIndex := 0
currentPos := 0
for i, line := range lines {
if currentPos <= position && position < currentPos+len(line)+1 {
lineIndex = i
break
}
currentPos += len(line) + 1 // +1 for the newline
}
startLine := max(0, lineIndex-contextLines)
endLine := min(len(lines), lineIndex+contextLines+1)
var snippetBuilder strings.Builder
for i := startLine; i < endLine; i++ {
if i == lineIndex {
snippetBuilder.WriteString(fmt.Sprintf("> %s\n", lines[i]))
} else {
snippetBuilder.WriteString(fmt.Sprintf(" %s\n", lines[i]))
}
}
return snippetBuilder.String()
}
func generateDiff(oldContent, newContent string) string {
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(oldContent, newContent, false)
patches := dmp.PatchMake(oldContent, diffs)
patchText := dmp.PatchToText(patches)
if patchText == "" && (oldContent != newContent) {
var result strings.Builder
result.WriteString("@@ Diff @@\n")
for _, diff := range diffs {
switch diff.Type {
case diffmatchpatch.DiffInsert:
result.WriteString("+ " + diff.Text + "\n")
case diffmatchpatch.DiffDelete:
result.WriteString("- " + diff.Text + "\n")
case diffmatchpatch.DiffEqual:
if len(diff.Text) > 40 {
result.WriteString(" " + diff.Text[:20] + "..." + diff.Text[len(diff.Text)-20:] + "\n")
} else {
result.WriteString(" " + diff.Text + "\n")
}
}
}
return result.String()
}
return patchText
}
func NewEditTool(workingDir string) tool.InvokableTool {
return &editTool{
workingDir: workingDir,
}
}

View file

@ -0,0 +1,53 @@
package tools
import (
"sync"
"time"
)
// File record to track when files were read/written
type fileRecord struct {
path string
readTime time.Time
writeTime time.Time
}
var (
fileRecords = make(map[string]fileRecord)
fileRecordMutex sync.RWMutex
)
func recordFileRead(path string) {
fileRecordMutex.Lock()
defer fileRecordMutex.Unlock()
record, exists := fileRecords[path]
if !exists {
record = fileRecord{path: path}
}
record.readTime = time.Now()
fileRecords[path] = record
}
func getLastReadTime(path string) time.Time {
fileRecordMutex.RLock()
defer fileRecordMutex.RUnlock()
record, exists := fileRecords[path]
if !exists {
return time.Time{}
}
return record.readTime
}
func recordFileWrite(path string) {
fileRecordMutex.Lock()
defer fileRecordMutex.Unlock()
record, exists := fileRecords[path]
if !exists {
record = fileRecord{path: path}
}
record.writeTime = time.Now()
fileRecords[path] = record
}

174
internal/llm/tools/glob.go Normal file
View file

@ -0,0 +1,174 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
"github.com/bmatcuk/doublestar/v4"
)
type globTool struct {
workingDir string
}
const (
GlobToolName = "glob"
)
type fileInfo struct {
path string
modTime time.Time
}
type GlobParams struct {
Pattern string `json:"pattern"`
Path string `json:"path"`
}
func (b *globTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: GlobToolName,
Desc: `- Fast file pattern matching tool that works with any codebase size
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
- Returns matching file paths sorted by modification time
- Use this tool when you need to find files by name patterns
- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead`,
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"pattern": {
Type: "string",
Desc: "The glob pattern to match files against",
Required: true,
},
"path": {
Type: "string",
Desc: "The directory to search in. Defaults to the current working directory.",
},
}),
}, nil
}
func (b *globTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
var params GlobParams
if err := json.Unmarshal([]byte(args), &params); err != nil {
return fmt.Sprintf("error parsing parameters: %s", err), nil
}
// If path is empty, use current working directory
searchPath := params.Path
if searchPath == "" {
searchPath = b.workingDir
}
files, truncated, err := globFiles(params.Pattern, searchPath, 100)
if err != nil {
return fmt.Sprintf("error performing glob search: %s", err), nil
}
// Format the output for the assistant
var output string
if len(files) == 0 {
output = "No files found"
} else {
output = strings.Join(files, "\n")
if truncated {
output += "\n(Results are truncated. Consider using a more specific path or pattern.)"
}
}
return output, nil
}
func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
// Make sure pattern starts with the search path if not absolute
if !strings.HasPrefix(pattern, "/") && !strings.HasPrefix(pattern, searchPath) {
// If searchPath doesn't end with a slash, add one before appending the pattern
if !strings.HasSuffix(searchPath, "/") {
searchPath += "/"
}
pattern = searchPath + pattern
}
// Open the filesystem for walking
fsys := os.DirFS("/")
// Convert the absolute pattern to a relative one for the DirFS
// DirFS uses the root directory ("/") so we should strip leading "/"
relPattern := strings.TrimPrefix(pattern, "/")
// Collect matching files
var matches []fileInfo
// Use doublestar to walk the filesystem and find matches
err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
// Skip directories from results
if d.IsDir() {
return nil
}
if skipHidden(path) {
return nil
}
// Get file info for modification time
info, err := d.Info()
if err != nil {
return nil // Skip files we can't access
}
// Add to matches
absPath := "/" + path // Restore absolute path
matches = append(matches, fileInfo{
path: absPath,
modTime: info.ModTime(),
})
// Check limit
if len(matches) >= limit*2 { // Collect more than needed for sorting
return fs.SkipAll
}
return nil
})
if err != nil {
return nil, false, fmt.Errorf("glob walk error: %w", err)
}
// Sort files by modification time (newest first)
sort.Slice(matches, func(i, j int) bool {
return matches[i].modTime.After(matches[j].modTime)
})
// Check if we need to truncate the results
truncated := len(matches) > limit
if truncated {
matches = matches[:limit]
}
// Extract just the paths
results := make([]string, len(matches))
for i, m := range matches {
results[i] = m.path
}
return results, truncated, nil
}
func skipHidden(path string) bool {
base := filepath.Base(path)
return base != "." && strings.HasPrefix(base, ".")
}
func NewGlobTool(workingDir string) tool.InvokableTool {
return &globTool{
workingDir,
}
}

260
internal/llm/tools/grep.go Normal file
View file

@ -0,0 +1,260 @@
package tools
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
type grepTool struct {
workingDir string
}
const (
GrepToolName = "grep"
MaxGrepResults = 100
)
type GrepParams struct {
Pattern string `json:"pattern"`
Path string `json:"path"`
Include string `json:"include"`
}
type grepMatch struct {
path string
modTime time.Time
}
func (b *grepTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: GrepToolName,
Desc: `- Fast content search tool that works with any codebase size
- Searches file contents using regular expressions
- Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.)
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
- Returns matching file paths sorted by modification time
- Use this tool when you need to find files containing specific patterns
- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead`,
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"command": {
Type: "string",
Desc: "The command to execute",
Required: true,
},
"timeout": {
Type: "number",
Desc: "Optional timeout in milliseconds (max 600000)",
},
}),
}, nil
}
func (b *grepTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
var params GrepParams
if err := json.Unmarshal([]byte(args), &params); err != nil {
return "", err
}
searchPath := params.Path
if searchPath == "" {
var err error
searchPath, err = os.Getwd()
if err != nil {
return fmt.Sprintf("unable to get current working directory: %s", err), nil
}
}
matches, err := searchWithRipgrep(params.Pattern, searchPath, params.Include)
if err != nil {
matches, err = searchFilesWithRegex(params.Pattern, searchPath, params.Include)
if err != nil {
return fmt.Sprintf("error searching files: %s", err), nil
}
}
sort.Slice(matches, func(i, j int) bool {
return matches[i].modTime.After(matches[j].modTime)
})
truncated := false
if len(matches) > MaxGrepResults {
truncated = true
matches = matches[:MaxGrepResults]
}
filenames := make([]string, len(matches))
for i, m := range matches {
filenames[i] = m.path
}
var output string
if len(filenames) == 0 {
output = "No files found"
} else {
output = fmt.Sprintf("Found %d file%s\n%s",
len(filenames),
pluralize(len(filenames)),
strings.Join(filenames, "\n"))
if truncated {
output += "\n(Results are truncated. Consider using a more specific path or pattern.)"
}
}
return output, nil
}
func pluralize(count int) string {
if count == 1 {
return ""
}
return "s"
}
func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
_, err := exec.LookPath("rg")
if err != nil {
return nil, fmt.Errorf("ripgrep not found: %w", err)
}
args := []string{"-l", pattern}
if include != "" {
args = append(args, "--glob", include)
}
args = append(args, path)
cmd := exec.Command("rg", args...)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return []grepMatch{}, nil
}
return nil, err
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
matches := make([]grepMatch, 0, len(lines))
for _, line := range lines {
if line == "" {
continue
}
fileInfo, err := os.Stat(line)
if err != nil {
continue
}
matches = append(matches, grepMatch{
path: line,
modTime: fileInfo.ModTime(),
})
}
return matches, nil
}
func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
matches := []grepMatch{}
regex, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("invalid regex pattern: %w", err)
}
var includePattern *regexp.Regexp
if include != "" {
regexPattern := globToRegex(include)
includePattern, err = regexp.Compile(regexPattern)
if err != nil {
return nil, fmt.Errorf("invalid include pattern: %w", err)
}
}
err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
if includePattern != nil && !includePattern.MatchString(path) {
return nil
}
match, err := fileContainsPattern(path, regex)
if err != nil {
return nil
}
if match {
matches = append(matches, grepMatch{
path: path,
modTime: info.ModTime(),
})
}
return nil
})
if err != nil {
return nil, err
}
return matches, nil
}
func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, error) {
file, err := os.Open(filePath)
if err != nil {
return false, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
if pattern.MatchString(scanner.Text()) {
return true, nil
}
}
if err := scanner.Err(); err != nil {
return false, err
}
return false, nil
}
func globToRegex(glob string) string {
regexPattern := strings.ReplaceAll(glob, ".", "\\.")
regexPattern = strings.ReplaceAll(regexPattern, "*", ".*")
regexPattern = strings.ReplaceAll(regexPattern, "?", ".")
re := regexp.MustCompile(`\{([^}]+)\}`)
regexPattern = re.ReplaceAllStringFunc(regexPattern, func(match string) string {
inner := match[1 : len(match)-1]
return "(" + strings.ReplaceAll(inner, ",", "|") + ")"
})
return "^" + regexPattern + "$"
}
func NewGrepTool(workingDir string) tool.InvokableTool {
return &grepTool{
workingDir,
}
}

229
internal/llm/tools/ls.go Normal file
View file

@ -0,0 +1,229 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
type lsTool struct {
workingDir string
}
const (
LSToolName = "ls"
MaxFiles = 1000
TruncatedMessage = "There are more than 1000 files in the repository. Use the LS tool (passing a specific path), Bash tool, and other tools to explore nested directories. The first 1000 files and directories are included below:\n\n"
)
type LSParams struct {
Path string `json:"path"`
Ignore []string `json:"ignore"`
}
func (b *lsTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: LSToolName,
Desc: "Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search.",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"path": {
Type: "string",
Desc: "The absolute path to the directory to list (must be absolute, not relative)",
Required: true,
},
"ignore": {
Type: "array",
ElemInfo: &schema.ParameterInfo{
Type: schema.String,
Desc: "List of glob patterns to ignore",
},
},
}),
}, nil
}
func (b *lsTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
var params LSParams
if err := json.Unmarshal([]byte(args), &params); err != nil {
return "", err
}
if !filepath.IsAbs(params.Path) {
return fmt.Sprintf("path must be absolute, got: %s", params.Path), nil
}
files, err := b.listDirectory(params.Path)
if err != nil {
return fmt.Sprintf("error listing directory: %s", err), nil
}
tree := createFileTree(files)
output := printTree(tree, params.Path)
if len(files) >= MaxFiles {
output = TruncatedMessage + output
}
return output, nil
}
func (b *lsTool) listDirectory(initialPath string) ([]string, error) {
var results []string
err := filepath.Walk(initialPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip files we don't have permission to access
}
if shouldSkip(path) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
if path != initialPath {
if info.IsDir() {
path = path + string(filepath.Separator)
}
relPath, err := filepath.Rel(b.workingDir, path)
if err == nil {
results = append(results, relPath)
} else {
results = append(results, path)
}
}
if len(results) >= MaxFiles {
return fmt.Errorf("max files reached")
}
return nil
})
if err != nil && err.Error() != "max files reached" {
return nil, err
}
return results, nil
}
func shouldSkip(path string) bool {
base := filepath.Base(path)
if base != "." && strings.HasPrefix(base, ".") {
return true
}
if strings.Contains(path, filepath.Join("__pycache__", "")) {
return true
}
return false
}
type TreeNode struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"` // "file" or "directory"
Children []TreeNode `json:"children,omitempty"`
}
func createFileTree(sortedPaths []string) []TreeNode {
root := []TreeNode{}
for _, path := range sortedPaths {
parts := strings.Split(path, string(filepath.Separator))
currentLevel := &root
currentPath := ""
for i, part := range parts {
if part == "" {
continue
}
if currentPath == "" {
currentPath = part
} else {
currentPath = filepath.Join(currentPath, part)
}
isLastPart := i == len(parts)-1
isDir := !isLastPart || strings.HasSuffix(path, string(filepath.Separator))
found := false
for i := range *currentLevel {
if (*currentLevel)[i].Name == part {
found = true
if (*currentLevel)[i].Children != nil {
currentLevel = &(*currentLevel)[i].Children
}
break
}
}
if !found {
nodeType := "file"
if isDir {
nodeType = "directory"
}
newNode := TreeNode{
Name: part,
Path: currentPath,
Type: nodeType,
}
if isDir {
newNode.Children = []TreeNode{}
*currentLevel = append(*currentLevel, newNode)
currentLevel = &(*currentLevel)[len(*currentLevel)-1].Children
} else {
*currentLevel = append(*currentLevel, newNode)
}
}
}
}
return root
}
func printTree(tree []TreeNode, rootPath string) string {
var result strings.Builder
result.WriteString(fmt.Sprintf("- %s%s\n", rootPath, string(filepath.Separator)))
printTreeRecursive(&result, tree, 0, " ")
return result.String()
}
func printTreeRecursive(builder *strings.Builder, tree []TreeNode, level int, prefix string) {
for _, node := range tree {
linePrefix := prefix + "- "
nodeName := node.Name
if node.Type == "directory" {
nodeName += string(filepath.Separator)
}
fmt.Fprintf(builder, "%s%s\n", linePrefix, nodeName)
if node.Type == "directory" && len(node.Children) > 0 {
printTreeRecursive(builder, node.Children, level+1, prefix+" ")
}
}
}
func NewLsTool(workingDir string) tool.InvokableTool {
return &lsTool{
workingDir,
}
}

247
internal/llm/tools/view.go Normal file
View file

@ -0,0 +1,247 @@
package tools
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
type viewTool struct {
workingDir string
}
const (
ViewToolName = "view"
MaxReadSize = 250 * 1024
DefaultReadLimit = 2000
MaxLineLength = 2000
)
type ViewPatams struct {
FilePath string `json:"file_path"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
func (b *viewTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: ViewToolName,
Desc: `Reads a file from the local filesystem. The file_path parameter must be an absolute path, not a relative path. By default, it reads up to 2000 lines starting from the beginning of the file. You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters. Any lines longer than 2000 characters will be truncated. For image files, the tool will display the image for you.`,
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"file_path": {
Type: "string",
Desc: "The absolute path to the file to read",
Required: true,
},
"offset": {
Type: "int",
Desc: "The line number to start reading from. Only provide if the file is too large to read at once",
},
"limit": {
Type: "int",
Desc: "The number of lines to read. Only provide if the file is too large to read at once.",
},
}),
}, nil
}
func (b *viewTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
var params ViewPatams
if err := json.Unmarshal([]byte(args), &params); err != nil {
return fmt.Sprintf("failed to parse parameters: %s", err), nil
}
if params.FilePath == "" {
return "file_path is required", nil
}
if !filepath.IsAbs(params.FilePath) {
return fmt.Sprintf("file path must be absolute, got: %s", params.FilePath), nil
}
fileInfo, err := os.Stat(params.FilePath)
if err != nil {
if os.IsNotExist(err) {
dir := filepath.Dir(params.FilePath)
base := filepath.Base(params.FilePath)
dirEntries, dirErr := os.ReadDir(dir)
if dirErr == nil {
var suggestions []string
for _, entry := range dirEntries {
if strings.Contains(entry.Name(), base) || strings.Contains(base, entry.Name()) {
suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
if len(suggestions) >= 3 {
break
}
}
}
if len(suggestions) > 0 {
return fmt.Sprintf("file not found: %s. Did you mean one of these?\n%s",
params.FilePath, strings.Join(suggestions, "\n")), nil
}
}
return fmt.Sprintf("file not found: %s", params.FilePath), nil
}
return fmt.Sprintf("failed to access file: %s", err), nil
}
if fileInfo.IsDir() {
return fmt.Sprintf("path is a directory, not a file: %s", params.FilePath), nil
}
if fileInfo.Size() > MaxReadSize {
return fmt.Sprintf("file is too large (%d bytes). Maximum size is %d bytes",
fileInfo.Size(), MaxReadSize), nil
}
if params.Limit <= 0 {
params.Limit = DefaultReadLimit
}
isImage, _ := isImageFile(params.FilePath)
if isImage {
// TODO: Implement image reading
return "reading images is not supported", nil
}
content, _, err := readTextFile(params.FilePath, params.Offset, params.Limit)
if err != nil {
return fmt.Sprintf("failed to read file: %s", err), nil
}
recordFileRead(params.FilePath)
return addLineNumbers(content, params.Offset+1), nil
}
func addLineNumbers(content string, startLine int) string {
if content == "" {
return ""
}
lines := strings.Split(content, "\n")
var result []string
for i, line := range lines {
line = strings.TrimSuffix(line, "\r")
lineNum := i + startLine
numStr := fmt.Sprintf("%d", lineNum)
if len(numStr) >= 6 {
result = append(result, fmt.Sprintf("%s\t%s", numStr, line))
} else {
paddedNum := fmt.Sprintf("%6s", numStr)
result = append(result, fmt.Sprintf("%s\t|%s", paddedNum, line))
}
}
return strings.Join(result, "\n")
}
func readTextFile(filePath string, offset, limit int) (string, int, error) {
file, err := os.Open(filePath)
if err != nil {
return "", 0, err
}
defer file.Close()
lineCount := 0
if offset > 0 {
scanner := NewLineScanner(file)
for lineCount < offset && scanner.Scan() {
lineCount++
}
if err = scanner.Err(); err != nil {
return "", 0, err
}
}
if offset == 0 {
_, err = file.Seek(0, io.SeekStart)
if err != nil {
return "", 0, err
}
}
var lines []string
lineCount = offset
scanner := NewLineScanner(file)
for scanner.Scan() && len(lines) < limit {
lineCount++
lineText := scanner.Text()
if len(lineText) > MaxLineLength {
lineText = lineText[:MaxLineLength] + "..."
}
lines = append(lines, lineText)
}
if err := scanner.Err(); err != nil {
return "", 0, err
}
return strings.Join(lines, "\n"), lineCount, nil
}
func isImageFile(filePath string) (bool, string) {
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".jpg", ".jpeg":
return true, "jpeg"
case ".png":
return true, "png"
case ".gif":
return true, "gif"
case ".bmp":
return true, "bmp"
case ".svg":
return true, "svg"
case ".webp":
return true, "webp"
default:
return false, ""
}
}
type LineScanner struct {
scanner *bufio.Scanner
}
func NewLineScanner(r io.Reader) *LineScanner {
return &LineScanner{
scanner: bufio.NewScanner(r),
}
}
func (s *LineScanner) Scan() bool {
return s.scanner.Scan()
}
func (s *LineScanner) Text() string {
return s.scanner.Text()
}
func (s *LineScanner) Err() error {
return s.scanner.Err()
}
func NewViewTool(workingDir string) tool.InvokableTool {
return &viewTool{
workingDir,
}
}

165
internal/llm/tools/write.go Normal file
View file

@ -0,0 +1,165 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
"github.com/kujtimiihoxha/termai/internal/permission"
)
type writeTool struct {
workingDir string
}
const (
WriteToolName = "write"
)
type WriteParams struct {
FilePath string `json:"file_path"`
Content string `json:"content"`
}
func (b *writeTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: WriteToolName,
Desc: "Write a file to the local filesystem. Overwrites the existing file if there is one.\n\nBefore using this tool:\n\n1. Use the ReadFile tool to understand the file's contents and context\n\n2. Directory Verification (only applicable when creating new files):\n - Use the LS tool to verify the parent directory exists and is the correct location",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"file_path": {
Type: "string",
Desc: "The absolute path to the file to write (must be absolute, not relative)",
Required: true,
},
"content": {
Type: "string",
Desc: "The content to write to the file",
Required: true,
},
}),
}, nil
}
func (b *writeTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
var params WriteParams
if err := json.Unmarshal([]byte(args), &params); err != nil {
return "", fmt.Errorf("failed to parse parameters: %w", err)
}
if params.FilePath == "" {
return "file_path is required", nil
}
if !filepath.IsAbs(params.FilePath) {
return fmt.Sprintf("file path must be absolute, got: %s", params.FilePath), nil
}
// fileExists := false
// oldContent := ""
fileInfo, err := os.Stat(params.FilePath)
if err == nil {
if fileInfo.IsDir() {
return fmt.Sprintf("path is a directory, not a file: %s", params.FilePath), nil
}
modTime := fileInfo.ModTime()
lastRead := getLastReadTime(params.FilePath)
if modTime.After(lastRead) {
return fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339)), nil
}
// oldContentBytes, readErr := os.ReadFile(params.FilePath)
// if readErr != nil {
// oldContent = string(oldContentBytes)
// }
} else if !os.IsNotExist(err) {
return fmt.Sprintf("failed to access file: %s", err), nil
}
p := permission.Default.Request(
permission.CreatePermissionRequest{
Path: b.workingDir,
ToolName: WriteToolName,
Action: "write",
Description: fmt.Sprintf("Write to file %s", params.FilePath),
Params: map[string]interface{}{
"file_path": params.FilePath,
"contnet": params.Content,
},
},
)
if !p {
return "", fmt.Errorf("permission denied")
}
dir := filepath.Dir(params.FilePath)
if err = os.MkdirAll(dir, 0o755); err != nil {
return fmt.Sprintf("failed to create parent directories: %s", err), nil
}
err = os.WriteFile(params.FilePath, []byte(params.Content), 0o644)
if err != nil {
return fmt.Sprintf("failed to write file: %s", err), nil
}
recordFileWrite(params.FilePath)
output := "File written: " + params.FilePath
// if fileExists && oldContent != params.Content {
// output = generateSimpleDiff(oldContent, params.Content)
// }
return output, nil
}
func generateSimpleDiff(oldContent, newContent string) string {
if oldContent == newContent {
return "[No changes]"
}
oldLines := strings.Split(oldContent, "\n")
newLines := strings.Split(newContent, "\n")
var diffBuilder strings.Builder
diffBuilder.WriteString(fmt.Sprintf("@@ -%d,+%d @@\n", len(oldLines), len(newLines)))
maxLines := max(len(oldLines), len(newLines))
for i := range maxLines {
oldLine := ""
newLine := ""
if i < len(oldLines) {
oldLine = oldLines[i]
}
if i < len(newLines) {
newLine = newLines[i]
}
if oldLine != newLine {
if i < len(oldLines) {
diffBuilder.WriteString(fmt.Sprintf("- %s\n", oldLine))
}
if i < len(newLines) {
diffBuilder.WriteString(fmt.Sprintf("+ %s\n", newLine))
}
} else {
diffBuilder.WriteString(fmt.Sprintf(" %s\n", oldLine))
}
}
return diffBuilder.String()
}
func NewWriteTool(workingDir string) tool.InvokableTool {
return &writeTool{
workingDir: workingDir,
}
}

View file

@ -0,0 +1,103 @@
package permission
import (
"sync"
"time"
"github.com/google/uuid"
"github.com/kujtimiihoxha/termai/internal/pubsub"
)
type CreatePermissionRequest struct {
ToolName string `json:"tool_name"`
Description string `json:"description"`
Action string `json:"action"`
Params any `json:"params"`
Path string `json:"path"`
}
type PermissionRequest struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
ToolName string `json:"tool_name"`
Description string `json:"description"`
Action string `json:"action"`
Params any `json:"params"`
Path string `json:"path"`
}
type Service interface {
pubsub.Suscriber[PermissionRequest]
GrantPersistant(permission PermissionRequest)
Grant(permission PermissionRequest)
Deny(permission PermissionRequest)
Request(opts CreatePermissionRequest) bool
}
type permissionService struct {
*pubsub.Broker[PermissionRequest]
sessionPermissions []PermissionRequest
pendingRequests sync.Map
}
func (s *permissionService) GrantPersistant(permission PermissionRequest) {
respCh, ok := s.pendingRequests.Load(permission.ID)
if ok {
respCh.(chan bool) <- true
}
s.sessionPermissions = append(s.sessionPermissions, permission)
}
func (s *permissionService) Grant(permission PermissionRequest) {
respCh, ok := s.pendingRequests.Load(permission.ID)
if ok {
respCh.(chan bool) <- true
}
}
func (s *permissionService) Deny(permission PermissionRequest) {
respCh, ok := s.pendingRequests.Load(permission.ID)
if ok {
respCh.(chan bool) <- false
}
}
func (s *permissionService) Request(opts CreatePermissionRequest) bool {
permission := PermissionRequest{
ID: uuid.New().String(),
ToolName: opts.ToolName,
Description: opts.Description,
Action: opts.Action,
Params: opts.Params,
}
for _, p := range s.sessionPermissions {
if p.ToolName == permission.ToolName && p.Action == permission.Action {
return true
}
}
respCh := make(chan bool, 1)
s.pendingRequests.Store(permission.ID, respCh)
defer s.pendingRequests.Delete(permission.ID)
s.Publish(pubsub.CreatedEvent, permission)
// Wait for the response with a timeout
select {
case resp := <-respCh:
return resp
case <-time.After(10 * time.Minute):
return false
}
}
func NewPermissionService() Service {
return &permissionService{
Broker: pubsub.NewBroker[PermissionRequest](),
sessionPermissions: make([]PermissionRequest, 0),
}
}
var Default Service = NewPermissionService()

View file

@ -5,7 +5,7 @@ import (
"sync"
)
const bufferSize = 1024
const bufferSize = 1024 * 1024
type Logger interface {
Debug(msg string, args ...any)

View file

@ -0,0 +1,287 @@
package core
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
)
// ButtonKeyMap defines key bindings for the button component
type ButtonKeyMap struct {
Enter key.Binding
}
// DefaultButtonKeyMap returns default key bindings for the button
func DefaultButtonKeyMap() ButtonKeyMap {
return ButtonKeyMap{
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select"),
),
}
}
// ShortHelp returns keybinding help
func (k ButtonKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Enter}
}
// FullHelp returns full help info for keybindings
func (k ButtonKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Enter},
}
}
// ButtonState represents the state of a button
type ButtonState int
const (
// ButtonNormal is the default state
ButtonNormal ButtonState = iota
// ButtonHovered is when the button is focused/hovered
ButtonHovered
// ButtonPressed is when the button is being pressed
ButtonPressed
// ButtonDisabled is when the button is disabled
ButtonDisabled
)
// ButtonVariant defines the visual style variant of a button
type ButtonVariant int
const (
// ButtonPrimary uses primary color styling
ButtonPrimary ButtonVariant = iota
// ButtonSecondary uses secondary color styling
ButtonSecondary
// ButtonDanger uses danger/error color styling
ButtonDanger
// ButtonWarning uses warning color styling
ButtonWarning
// ButtonNeutral uses neutral color styling
ButtonNeutral
)
// ButtonMsg is sent when a button is clicked
type ButtonMsg struct {
ID string
Payload interface{}
}
// ButtonCmp represents a clickable button component
type ButtonCmp struct {
id string
label string
width int
height int
state ButtonState
variant ButtonVariant
keyMap ButtonKeyMap
payload interface{}
style lipgloss.Style
hoverStyle lipgloss.Style
}
// NewButtonCmp creates a new button component
func NewButtonCmp(id, label string) *ButtonCmp {
b := &ButtonCmp{
id: id,
label: label,
state: ButtonNormal,
variant: ButtonPrimary,
keyMap: DefaultButtonKeyMap(),
width: len(label) + 4, // add some padding
height: 1,
}
b.updateStyles()
return b
}
// WithVariant sets the button variant
func (b *ButtonCmp) WithVariant(variant ButtonVariant) *ButtonCmp {
b.variant = variant
b.updateStyles()
return b
}
// WithPayload sets the payload sent with button events
func (b *ButtonCmp) WithPayload(payload interface{}) *ButtonCmp {
b.payload = payload
return b
}
// WithWidth sets a custom width
func (b *ButtonCmp) WithWidth(width int) *ButtonCmp {
b.width = width
b.updateStyles()
return b
}
// updateStyles recalculates styles based on current state and variant
func (b *ButtonCmp) updateStyles() {
// Base styles
b.style = styles.Regular.
Padding(0, 1).
Width(b.width).
Align(lipgloss.Center).
BorderStyle(lipgloss.RoundedBorder())
b.hoverStyle = b.style.
Bold(true)
// Variant-specific styling
switch b.variant {
case ButtonPrimary:
b.style = b.style.
Foreground(styles.Base).
Background(styles.Primary).
BorderForeground(styles.Primary)
b.hoverStyle = b.hoverStyle.
Foreground(styles.Base).
Background(styles.Blue).
BorderForeground(styles.Blue)
case ButtonSecondary:
b.style = b.style.
Foreground(styles.Base).
Background(styles.Secondary).
BorderForeground(styles.Secondary)
b.hoverStyle = b.hoverStyle.
Foreground(styles.Base).
Background(styles.Mauve).
BorderForeground(styles.Mauve)
case ButtonDanger:
b.style = b.style.
Foreground(styles.Base).
Background(styles.Error).
BorderForeground(styles.Error)
b.hoverStyle = b.hoverStyle.
Foreground(styles.Base).
Background(styles.Red).
BorderForeground(styles.Red)
case ButtonWarning:
b.style = b.style.
Foreground(styles.Text).
Background(styles.Warning).
BorderForeground(styles.Warning)
b.hoverStyle = b.hoverStyle.
Foreground(styles.Text).
Background(styles.Peach).
BorderForeground(styles.Peach)
case ButtonNeutral:
b.style = b.style.
Foreground(styles.Text).
Background(styles.Grey).
BorderForeground(styles.Grey)
b.hoverStyle = b.hoverStyle.
Foreground(styles.Text).
Background(styles.DarkGrey).
BorderForeground(styles.DarkGrey)
}
// Disabled style override
if b.state == ButtonDisabled {
b.style = b.style.
Foreground(styles.SubText0).
Background(styles.LightGrey).
BorderForeground(styles.LightGrey)
}
}
// SetSize sets the button size
func (b *ButtonCmp) SetSize(width, height int) {
b.width = width
b.height = height
b.updateStyles()
}
// Focus sets the button to focused state
func (b *ButtonCmp) Focus() tea.Cmd {
if b.state != ButtonDisabled {
b.state = ButtonHovered
}
return nil
}
// Blur sets the button to normal state
func (b *ButtonCmp) Blur() tea.Cmd {
if b.state != ButtonDisabled {
b.state = ButtonNormal
}
return nil
}
// Disable sets the button to disabled state
func (b *ButtonCmp) Disable() {
b.state = ButtonDisabled
b.updateStyles()
}
// Enable enables the button if disabled
func (b *ButtonCmp) Enable() {
if b.state == ButtonDisabled {
b.state = ButtonNormal
b.updateStyles()
}
}
// IsDisabled returns whether the button is disabled
func (b *ButtonCmp) IsDisabled() bool {
return b.state == ButtonDisabled
}
// IsFocused returns whether the button is focused
func (b *ButtonCmp) IsFocused() bool {
return b.state == ButtonHovered
}
// Init initializes the button
func (b *ButtonCmp) Init() tea.Cmd {
return nil
}
// Update handles messages and user input
func (b *ButtonCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Skip updates if disabled
if b.state == ButtonDisabled {
return b, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
// Handle key presses when focused
if b.state == ButtonHovered {
switch {
case key.Matches(msg, b.keyMap.Enter):
b.state = ButtonPressed
return b, func() tea.Msg {
return ButtonMsg{
ID: b.id,
Payload: b.payload,
}
}
}
}
}
return b, nil
}
// View renders the button
func (b *ButtonCmp) View() string {
if b.state == ButtonHovered || b.state == ButtonPressed {
return b.hoverStyle.Render(b.label)
}
return b.style.Render(b.label)
}

View file

@ -0,0 +1,167 @@
package dialog
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/kujtimiihoxha/termai/internal/permission"
"github.com/kujtimiihoxha/termai/internal/tui/components/core"
"github.com/kujtimiihoxha/termai/internal/tui/layout"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
"github.com/kujtimiihoxha/termai/internal/tui/util"
"github.com/charmbracelet/huh"
)
type PermissionAction string
// Permission responses
const (
PermissionAllow PermissionAction = "allow"
PermissionAllowForSession PermissionAction = "allow_session"
PermissionDeny PermissionAction = "deny"
)
// PermissionResponseMsg represents the user's response to a permission request
type PermissionResponseMsg struct {
Permission permission.PermissionRequest
Action PermissionAction
}
// Width and height constants for the dialog
var (
permissionWidth = 60
permissionHeight = 10
)
// PermissionDialog interface for permission dialog component
type PermissionDialog interface {
tea.Model
layout.Sizeable
layout.Bindings
}
// permissionDialogCmp is the implementation of PermissionDialog
type permissionDialogCmp struct {
form *huh.Form
content string
width int
height int
permission permission.PermissionRequest
}
func (p *permissionDialogCmp) Init() tea.Cmd {
return nil
}
func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
// Process the form
form, cmd := p.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
p.form = f
cmds = append(cmds, cmd)
}
if p.form.State == huh.StateCompleted {
// Get the selected action
action := p.form.GetString("action")
// Close the dialog and return the response
return p, tea.Batch(
util.CmdHandler(core.DialogCloseMsg{}),
util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
)
}
return p, tea.Batch(cmds...)
}
func (p *permissionDialogCmp) View() string {
contentStyle := lipgloss.NewStyle().
Width(p.width).
Padding(1, 0).
Foreground(styles.Text).
Align(lipgloss.Center)
return lipgloss.JoinVertical(
lipgloss.Center,
contentStyle.Render(p.content),
p.form.View(),
)
}
func (p *permissionDialogCmp) GetSize() (int, int) {
return p.width, p.height
}
func (p *permissionDialogCmp) SetSize(width int, height int) {
p.width = width
p.height = height
}
func (p *permissionDialogCmp) BindingKeys() []key.Binding {
return p.form.KeyBinds()
}
func newPermissionDialogCmp(permission permission.PermissionRequest, content string) PermissionDialog {
// Create a note field for displaying the content
// Create select field for the permission options
selectOption := huh.NewSelect[string]().
Key("action").
Options(
huh.NewOption("Allow", string(PermissionAllow)),
huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
huh.NewOption("Deny", string(PermissionDeny)),
).
Title("Permission Request")
// Apply theme
theme := styles.HuhTheme()
// Setup form width and height
form := huh.NewForm(huh.NewGroup(selectOption)).
WithWidth(permissionWidth - 2).
WithShowHelp(false).
WithTheme(theme).
WithShowErrors(false)
// Focus the form for immediate interaction
selectOption.Focus()
return &permissionDialogCmp{
permission: permission,
form: form,
content: content,
width: permissionWidth,
height: permissionHeight,
}
}
// NewPermissionDialogCmd creates a new permission dialog command
func NewPermissionDialogCmd(permission permission.PermissionRequest, content string) tea.Cmd {
permDialog := newPermissionDialogCmp(permission, content)
// Create the dialog layout
dialogPane := layout.NewSinglePane(
permDialog.(*permissionDialogCmp),
layout.WithSignlePaneSize(permissionWidth+2, permissionHeight+2),
layout.WithSinglePaneBordered(true),
layout.WithSinglePaneFocusable(true),
layout.WithSinglePaneActiveColor(styles.Blue),
layout.WithSignlePaneBorderText(map[layout.BorderPosition]string{
layout.TopMiddleBorder: " Permission Required ",
}),
)
// Focus the dialog
dialogPane.Focus()
// Return the dialog command
return util.CmdHandler(core.DialogMsg{
Content: dialogPane,
})
}

View file

@ -0,0 +1,108 @@
package messages
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/cloudwego/eino/schema"
"github.com/kujtimiihoxha/termai/internal/message"
"github.com/kujtimiihoxha/termai/internal/tui/layout"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
)
const (
maxHeight = 10
)
type MessagesCmp interface {
tea.Model
layout.Focusable
layout.Bordered
layout.Sizeable
}
type messageCmp struct {
message message.Message
width int
height int
focused bool
expanded bool
}
func (m *messageCmp) Init() tea.Cmd {
return nil
}
func (m *messageCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *messageCmp) View() string {
wrapper := layout.NewSinglePane(
m,
layout.WithSinglePaneBordered(true),
layout.WithSinglePaneFocusable(true),
layout.WithSinglePanePadding(1),
layout.WithSinglePaneActiveColor(m.borderColor()),
)
if m.focused {
wrapper.Focus()
}
wrapper.SetSize(m.width, m.height)
return wrapper.View()
}
func (m *messageCmp) Blur() tea.Cmd {
m.focused = false
return nil
}
func (m *messageCmp) borderColor() lipgloss.TerminalColor {
switch m.message.MessageData.Role {
case schema.Assistant:
return styles.Mauve
case schema.User:
return styles.Flamingo
}
return styles.Blue
}
func (m *messageCmp) BorderText() map[layout.BorderPosition]string {
role := ""
icon := ""
switch m.message.MessageData.Role {
case schema.Assistant:
role = "Assistant"
icon = styles.BotIcon
case schema.User:
role = "User"
icon = styles.UserIcon
}
return map[layout.BorderPosition]string{
layout.TopLeftBorder: fmt.Sprintf("%s %s ", role, icon),
}
}
func (m *messageCmp) Focus() tea.Cmd {
m.focused = true
return nil
}
func (m *messageCmp) IsFocused() bool {
return m.focused
}
func (m *messageCmp) GetSize() (int, int) {
return m.width, 0
}
func (m *messageCmp) SetSize(width int, height int) {
m.width = width
}
func NewMessageCmp(msg message.Message) MessagesCmp {
return &messageCmp{
message: msg,
}
}

View file

@ -5,9 +5,11 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/cloudwego/eino/schema"
"github.com/kujtimiihoxha/termai/internal/app"
"github.com/kujtimiihoxha/termai/internal/tui/layout"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
"github.com/kujtimiihoxha/vimtea"
)
@ -105,8 +107,12 @@ func (m *editorCmp) Blur() tea.Cmd {
}
func (m *editorCmp) BorderText() map[layout.BorderPosition]string {
title := "New Message"
if m.focused {
title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
}
return map[layout.BorderPosition]string{
layout.TopLeftBorder: "New Message",
layout.TopLeftBorder: title,
}
}
@ -148,7 +154,9 @@ func (m *editorCmp) BindingKeys() []key.Binding {
func NewEditorCmp(app *app.App) EditorCmp {
return &editorCmp{
app: app,
editor: vimtea.NewEditor(),
app: app,
editor: vimtea.NewEditor(
vimtea.WithFileName("message.md"),
),
}
}

View file

@ -1,15 +1,22 @@
package repl
import (
"fmt"
"slices"
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"github.com/cloudwego/eino/schema"
"github.com/kujtimiihoxha/termai/internal/app"
"github.com/kujtimiihoxha/termai/internal/message"
"github.com/kujtimiihoxha/termai/internal/pubsub"
"github.com/kujtimiihoxha/termai/internal/session"
"github.com/kujtimiihoxha/termai/internal/tui/layout"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
)
type MessagesCmp interface {
@ -21,13 +28,15 @@ type MessagesCmp interface {
}
type messagesCmp struct {
app *app.App
messages []message.Message
session session.Session
viewport viewport.Model
width int
height int
focused bool
app *app.App
messages []message.Message
session session.Session
viewport viewport.Model
mdRenderer *glamour.TermRenderer
width int
height int
focused bool
cachedView string
}
func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@ -35,6 +44,8 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case pubsub.Event[message.Message]:
if msg.Type == pubsub.CreatedEvent {
m.messages = append(m.messages, msg.Payload)
m.renderView()
m.viewport.GotoBottom()
}
case pubsub.Event[session.Session]:
if msg.Type == pubsub.UpdatedEvent {
@ -45,60 +56,182 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case SelectedSessionMsg:
m.session, _ = m.app.Sessions.Get(msg.SessionID)
m.messages, _ = m.app.Messages.List(m.session.ID)
m.renderView()
m.viewport.GotoBottom()
}
if m.focused {
u, cmd := m.viewport.Update(msg)
m.viewport = u
return m, cmd
}
return m, nil
}
func (i *messagesCmp) View() string {
stringMessages := make([]string, len(i.messages))
for idx, msg := range i.messages {
stringMessages[idx] = msg.MessageData.Content
func borderColor(role schema.RoleType) lipgloss.TerminalColor {
switch role {
case schema.Assistant:
return styles.Mauve
case schema.User:
return styles.Rosewater
case schema.Tool:
return styles.Peach
}
return lipgloss.JoinVertical(lipgloss.Top, stringMessages...)
return styles.Blue
}
func borderText(msgRole schema.RoleType, currentMessage int) map[layout.BorderPosition]string {
role := ""
icon := ""
switch msgRole {
case schema.Assistant:
role = "Assistant"
icon = styles.BotIcon
case schema.User:
role = "User"
icon = styles.UserIcon
}
return map[layout.BorderPosition]string{
layout.TopLeftBorder: lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(styles.Crust).
Background(borderColor(msgRole)).
Render(fmt.Sprintf("%s %s ", role, icon)),
layout.TopRightBorder: lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(styles.Crust).
Background(borderColor(msgRole)).
Render(fmt.Sprintf("#%d ", currentMessage)),
}
}
func (m *messagesCmp) renderView() {
stringMessages := make([]string, 0)
r, _ := glamour.NewTermRenderer(
glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
glamour.WithWordWrap(m.width-10),
glamour.WithEmoji(),
)
textStyle := lipgloss.NewStyle().Width(m.width - 4)
currentMessage := 1
for _, msg := range m.messages {
if msg.MessageData.Role == schema.Tool {
continue
}
content := msg.MessageData.Content
if content != "" {
content, _ = r.Render(msg.MessageData.Content)
stringMessages = append(stringMessages, layout.Borderize(
textStyle.Render(content),
layout.BorderOptions{
InactiveBorder: lipgloss.DoubleBorder(),
ActiveBorder: lipgloss.DoubleBorder(),
ActiveColor: borderColor(msg.MessageData.Role),
InactiveColor: borderColor(msg.MessageData.Role),
EmbeddedText: borderText(msg.MessageData.Role, currentMessage),
},
))
currentMessage++
}
for _, toolCall := range msg.MessageData.ToolCalls {
resultInx := slices.IndexFunc(m.messages, func(m message.Message) bool {
return m.MessageData.ToolCallID == toolCall.ID
})
content := fmt.Sprintf("**Arguments**\n```json\n%s\n```\n", toolCall.Function.Arguments)
if resultInx == -1 {
content += "Running..."
} else {
result := m.messages[resultInx].MessageData.Content
if result != "" {
lines := strings.Split(result, "\n")
if len(lines) > 15 {
result = strings.Join(lines[:15], "\n")
}
content += fmt.Sprintf("**Result**\n```\n%s\n```\n", result)
if len(lines) > 15 {
content += fmt.Sprintf("\n\n *...%d lines are truncated* ", len(lines)-15)
}
}
}
content, _ = r.Render(content)
stringMessages = append(stringMessages, layout.Borderize(
textStyle.Render(content),
layout.BorderOptions{
InactiveBorder: lipgloss.DoubleBorder(),
ActiveBorder: lipgloss.DoubleBorder(),
ActiveColor: borderColor(schema.Tool),
InactiveColor: borderColor(schema.Tool),
EmbeddedText: map[layout.BorderPosition]string{
layout.TopLeftBorder: lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(styles.Crust).
Background(borderColor(schema.Tool)).
Render(
fmt.Sprintf("Tool [%s] %s ", toolCall.Function.Name, styles.ToolIcon),
),
layout.TopRightBorder: lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(styles.Crust).
Background(borderColor(schema.Tool)).
Render(fmt.Sprintf("#%d ", currentMessage)),
},
},
))
currentMessage++
}
}
m.viewport.SetContent(lipgloss.JoinVertical(lipgloss.Top, stringMessages...))
}
func (m *messagesCmp) View() string {
return lipgloss.NewStyle().Padding(1).Render(m.viewport.View())
}
// BindingKeys implements MessagesCmp.
func (m *messagesCmp) BindingKeys() []key.Binding {
return []key.Binding{}
return layout.KeyMapToSlice(m.viewport.KeyMap)
}
// Blur implements MessagesCmp.
func (m *messagesCmp) Blur() tea.Cmd {
m.focused = false
return nil
}
// BorderText implements MessagesCmp.
func (m *messagesCmp) BorderText() map[layout.BorderPosition]string {
title := m.session.Title
if len(title) > 20 {
title = title[:20] + "..."
titleWidth := m.width / 2
if len(title) > titleWidth {
title = title[:titleWidth] + "..."
}
if m.focused {
title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
}
return map[layout.BorderPosition]string{
layout.TopLeftBorder: title,
layout.TopLeftBorder: title,
layout.BottomRightBorder: formatTokensAndCost(m.session.CompletionTokens+m.session.PromptTokens, m.session.Cost),
}
}
// Focus implements MessagesCmp.
func (m *messagesCmp) Focus() tea.Cmd {
m.focused = true
return nil
}
// GetSize implements MessagesCmp.
func (m *messagesCmp) GetSize() (int, int) {
return m.width, m.height
}
// IsFocused implements MessagesCmp.
func (m *messagesCmp) IsFocused() bool {
return m.focused
}
// SetSize implements MessagesCmp.
func (m *messagesCmp) SetSize(width int, height int) {
m.width = width
m.height = height
m.viewport.Width = width - 2 // padding
m.viewport.Height = height - 2 // padding
}
func (m *messagesCmp) Init() tea.Cmd {
@ -109,5 +242,6 @@ func NewMessagesCmp(app *app.App) MessagesCmp {
return &messagesCmp{
app: app,
messages: []message.Message{},
viewport: viewport.New(0, 0),
}
}

View file

@ -7,6 +7,7 @@ import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/kujtimiihoxha/termai/internal/app"
"github.com/kujtimiihoxha/termai/internal/pubsub"
"github.com/kujtimiihoxha/termai/internal/session"
@ -160,8 +161,13 @@ func (i *sessionsCmp) BorderText() map[layout.BorderPosition]string {
current,
totalCount,
)
title := "Sessions"
if i.focused {
title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
}
return map[layout.BorderPosition]string{
layout.TopMiddleBorder: "Sessions",
layout.TopMiddleBorder: title,
layout.BottomMiddleBorder: pageInfo,
}
}

View file

@ -24,24 +24,43 @@ var (
InactivePreviewBorder = styles.Grey
)
func Borderize(content string, active bool, embeddedText map[BorderPosition]string, activeColor lipgloss.TerminalColor) string {
if embeddedText == nil {
embeddedText = make(map[BorderPosition]string)
type BorderOptions struct {
Active bool
EmbeddedText map[BorderPosition]string
ActiveColor lipgloss.TerminalColor
InactiveColor lipgloss.TerminalColor
ActiveBorder lipgloss.Border
InactiveBorder lipgloss.Border
}
func Borderize(content string, opts BorderOptions) string {
if opts.EmbeddedText == nil {
opts.EmbeddedText = make(map[BorderPosition]string)
}
if activeColor == nil {
activeColor = ActiveBorder
if opts.ActiveColor == nil {
opts.ActiveColor = ActiveBorder
}
if opts.InactiveColor == nil {
opts.InactiveColor = InactivePreviewBorder
}
if opts.ActiveBorder == (lipgloss.Border{}) {
opts.ActiveBorder = lipgloss.ThickBorder()
}
if opts.InactiveBorder == (lipgloss.Border{}) {
opts.InactiveBorder = lipgloss.NormalBorder()
}
var (
thickness = map[bool]lipgloss.Border{
true: lipgloss.Border(lipgloss.ThickBorder()),
false: lipgloss.Border(lipgloss.NormalBorder()),
true: opts.ActiveBorder,
false: opts.InactiveBorder,
}
color = map[bool]lipgloss.TerminalColor{
true: activeColor,
false: InactivePreviewBorder,
true: opts.ActiveColor,
false: opts.InactiveColor,
}
border = thickness[active]
style = lipgloss.NewStyle().Foreground(color[active])
border = thickness[opts.Active]
style = lipgloss.NewStyle().Foreground(color[opts.Active])
width = lipgloss.Width(content)
)
@ -80,20 +99,20 @@ func Borderize(content string, active bool, embeddedText map[BorderPosition]stri
// Stack top border, content and horizontal borders, and bottom border.
return strings.Join([]string{
buildHorizontalBorder(
embeddedText[TopLeftBorder],
embeddedText[TopMiddleBorder],
embeddedText[TopRightBorder],
opts.EmbeddedText[TopLeftBorder],
opts.EmbeddedText[TopMiddleBorder],
opts.EmbeddedText[TopRightBorder],
border.TopLeft,
border.Top,
border.TopRight,
),
lipgloss.NewStyle().
BorderForeground(color[active]).
BorderForeground(color[opts.Active]).
Border(border, false, true, false, true).Render(content),
buildHorizontalBorder(
embeddedText[BottomLeftBorder],
embeddedText[BottomMiddleBorder],
embeddedText[BottomRightBorder],
opts.EmbeddedText[BottomLeftBorder],
opts.EmbeddedText[BottomMiddleBorder],
opts.EmbeddedText[BottomRightBorder],
border.BottomLeft,
border.Bottom,
border.BottomRight,

254
internal/tui/layout/grid.go Normal file
View file

@ -0,0 +1,254 @@
package layout
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type GridLayout interface {
tea.Model
Sizeable
Bindings
Panes() [][]tea.Model
}
type gridLayout struct {
width int
height int
rows int
columns int
panes [][]tea.Model
gap int
bordered bool
focusable bool
currentRow int
currentColumn int
activeColor lipgloss.TerminalColor
}
type GridOption func(*gridLayout)
func (g *gridLayout) Init() tea.Cmd {
var cmds []tea.Cmd
for i := range g.panes {
for j := range g.panes[i] {
if g.panes[i][j] != nil {
cmds = append(cmds, g.panes[i][j].Init())
}
}
}
return tea.Batch(cmds...)
}
func (g *gridLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
g.SetSize(msg.Width, msg.Height)
return g, nil
case tea.KeyMsg:
if key.Matches(msg, g.nextPaneBinding()) {
return g.focusNextPane()
}
}
// Update all panes
for i := range g.panes {
for j := range g.panes[i] {
if g.panes[i][j] != nil {
var cmd tea.Cmd
g.panes[i][j], cmd = g.panes[i][j].Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
}
}
return g, tea.Batch(cmds...)
}
func (g *gridLayout) focusNextPane() (tea.Model, tea.Cmd) {
if !g.focusable {
return g, nil
}
var cmds []tea.Cmd
// Blur current pane
if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
if currentPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
cmds = append(cmds, currentPane.Blur())
}
}
// Find next valid pane
g.currentColumn++
if g.currentColumn >= len(g.panes[g.currentRow]) {
g.currentColumn = 0
g.currentRow++
if g.currentRow >= len(g.panes) {
g.currentRow = 0
}
}
// Focus next pane
if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
if nextPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
cmds = append(cmds, nextPane.Focus())
}
}
return g, tea.Batch(cmds...)
}
func (g *gridLayout) nextPaneBinding() key.Binding {
return key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "next pane"),
)
}
func (g *gridLayout) View() string {
if len(g.panes) == 0 {
return ""
}
// Calculate dimensions for each cell
cellWidth := (g.width - (g.columns-1)*g.gap) / g.columns
cellHeight := (g.height - (g.rows-1)*g.gap) / g.rows
// Render each row
rows := make([]string, g.rows)
for i := 0; i < g.rows; i++ {
// Render each column in this row
cols := make([]string, len(g.panes[i]))
for j := 0; j < len(g.panes[i]); j++ {
if g.panes[i][j] == nil {
cols[j] = ""
continue
}
// Set size for each pane
if sizable, ok := g.panes[i][j].(Sizeable); ok {
effectiveWidth, effectiveHeight := cellWidth, cellHeight
if g.bordered {
effectiveWidth -= 2
effectiveHeight -= 2
}
sizable.SetSize(effectiveWidth, effectiveHeight)
}
// Render the pane
content := g.panes[i][j].View()
// Apply border if needed
if g.bordered {
isFocused := false
if focusable, ok := g.panes[i][j].(Focusable); ok {
isFocused = focusable.IsFocused()
}
borderText := map[BorderPosition]string{}
if bordered, ok := g.panes[i][j].(Bordered); ok {
borderText = bordered.BorderText()
}
content = Borderize(content, BorderOptions{
Active: isFocused,
EmbeddedText: borderText,
})
}
cols[j] = content
}
// Join columns with gap
rows[i] = lipgloss.JoinHorizontal(lipgloss.Top, cols...)
}
// Join rows with gap
return lipgloss.JoinVertical(lipgloss.Left, rows...)
}
func (g *gridLayout) SetSize(width, height int) {
g.width = width
g.height = height
}
func (g *gridLayout) GetSize() (int, int) {
return g.width, g.height
}
func (g *gridLayout) BindingKeys() []key.Binding {
var bindings []key.Binding
bindings = append(bindings, g.nextPaneBinding())
// Collect bindings from all panes
for i := range g.panes {
for j := range g.panes[i] {
if g.panes[i][j] != nil {
if bindable, ok := g.panes[i][j].(Bindings); ok {
bindings = append(bindings, bindable.BindingKeys()...)
}
}
}
}
return bindings
}
func (g *gridLayout) Panes() [][]tea.Model {
return g.panes
}
// NewGridLayout creates a new grid layout with the given number of rows and columns
func NewGridLayout(rows, cols int, panes [][]tea.Model, opts ...GridOption) GridLayout {
grid := &gridLayout{
rows: rows,
columns: cols,
panes: panes,
gap: 1,
}
for _, opt := range opts {
opt(grid)
}
return grid
}
// WithGridGap sets the gap between cells
func WithGridGap(gap int) GridOption {
return func(g *gridLayout) {
g.gap = gap
}
}
// WithGridBordered sets whether cells should have borders
func WithGridBordered(bordered bool) GridOption {
return func(g *gridLayout) {
g.bordered = bordered
}
}
// WithGridFocusable sets whether the grid supports focus navigation
func WithGridFocusable(focusable bool) GridOption {
return func(g *gridLayout) {
g.focusable = focusable
}
}
// WithGridActiveColor sets the active border color
func WithGridActiveColor(color lipgloss.TerminalColor) GridOption {
return func(g *gridLayout) {
g.activeColor = color
}
}

View file

@ -64,7 +64,10 @@ func (s *singlePaneLayout) View() string {
if bordered, ok := s.content.(Bordered); ok {
s.borderText = bordered.BorderText()
}
return Borderize(content, s.focused, s.borderText, s.activeColor)
return Borderize(content, BorderOptions{
Active: s.focused,
EmbeddedText: s.borderText,
})
}
return content
}

View file

@ -1,36 +1,307 @@
package page
import (
"fmt"
"os"
"path/filepath"
"strconv"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/kujtimiihoxha/termai/internal/llm/models"
"github.com/kujtimiihoxha/termai/internal/tui/layout"
"github.com/kujtimiihoxha/termai/internal/tui/styles"
"github.com/kujtimiihoxha/termai/internal/tui/util"
"github.com/spf13/viper"
)
var InitPage PageID = "init"
type configSaved struct{}
type initPage struct {
layout layout.SinglePaneLayout
form *huh.Form
width int
height int
saved bool
errorMsg string
statusMsg string
modelOpts []huh.Option[string]
bigModel string
smallModel string
openAIKey string
anthropicKey string
groqKey string
maxTokens string
dataDir string
agent string
}
func (i initPage) Init() tea.Cmd {
return nil
func (i *initPage) Init() tea.Cmd {
return i.form.Init()
}
func (i initPage) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
return i, nil
func (i *initPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
i.width = msg.Width - 4 // Account for border
i.height = msg.Height - 4
i.form = i.form.WithWidth(i.width).WithHeight(i.height)
return i, nil
case configSaved:
i.saved = true
i.statusMsg = "Configuration saved successfully. Press any key to continue."
return i, nil
}
if i.saved {
switch msg.(type) {
case tea.KeyMsg:
return i, util.CmdHandler(PageChangeMsg{ID: ReplPage})
}
return i, nil
}
// Process the form
form, cmd := i.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
i.form = f
cmds = append(cmds, cmd)
}
if i.form.State == huh.StateCompleted {
// Save configuration to file
configPath := filepath.Join(os.Getenv("HOME"), ".termai.yaml")
maxTokens, _ := strconv.Atoi(i.maxTokens)
config := map[string]interface{}{
"models": map[string]string{
"big": i.bigModel,
"small": i.smallModel,
},
"providers": map[string]interface{}{
"openai": map[string]string{
"key": i.openAIKey,
},
"anthropic": map[string]string{
"key": i.anthropicKey,
},
"groq": map[string]string{
"key": i.groqKey,
},
"common": map[string]int{
"max_tokens": maxTokens,
},
},
"data": map[string]string{
"dir": i.dataDir,
},
"agents": map[string]string{
"default": i.agent,
},
"log": map[string]string{
"level": "info",
},
}
// Write config to viper
for k, v := range config {
viper.Set(k, v)
}
// Save configuration
err := viper.WriteConfigAs(configPath)
if err != nil {
i.errorMsg = fmt.Sprintf("Failed to save configuration: %s", err)
return i, nil
}
// Return to main page
return i, util.CmdHandler(configSaved{})
}
return i, tea.Batch(cmds...)
}
func (i initPage) View() string {
return "Initializing..."
func (i *initPage) View() string {
if i.saved {
return lipgloss.NewStyle().
Width(i.width).
Height(i.height).
Align(lipgloss.Center, lipgloss.Center).
Render(lipgloss.JoinVertical(
lipgloss.Center,
lipgloss.NewStyle().Foreground(styles.Green).Render("✓ Configuration Saved"),
"",
lipgloss.NewStyle().Foreground(styles.Blue).Render(i.statusMsg),
))
}
view := i.form.View()
if i.errorMsg != "" {
errorBox := lipgloss.NewStyle().
Padding(1).
Border(lipgloss.RoundedBorder()).
BorderForeground(styles.Red).
Width(i.width - 4).
Render(i.errorMsg)
view = lipgloss.JoinVertical(lipgloss.Left, errorBox, view)
}
return view
}
func (i *initPage) GetSize() (int, int) {
return i.width, i.height
}
func (i *initPage) SetSize(width int, height int) {
i.width = width
i.height = height
i.form = i.form.WithWidth(width).WithHeight(height)
}
func (i *initPage) BindingKeys() []key.Binding {
if i.saved {
return []key.Binding{
key.NewBinding(
key.WithKeys("enter", "space", "esc"),
key.WithHelp("any key", "continue"),
),
}
}
return i.form.KeyBinds()
}
func NewInitPage() tea.Model {
// Create model options
var modelOpts []huh.Option[string]
for id, model := range models.SupportedModels {
modelOpts = append(modelOpts, huh.NewOption(model.Name, string(id)))
}
// Create agent options
agentOpts := []huh.Option[string]{
huh.NewOption("Coder", "coder"),
huh.NewOption("Assistant", "assistant"),
}
// Init page with form
initModel := &initPage{
modelOpts: modelOpts,
bigModel: string(models.DefaultBigModel),
smallModel: string(models.DefaultLittleModel),
maxTokens: "4000",
dataDir: ".termai",
agent: "coder",
}
// API Keys group
apiKeysGroup := huh.NewGroup(
huh.NewNote().
Title("API Keys").
Description("You need to provide at least one API key to use termai"),
huh.NewInput().
Title("OpenAI API Key").
Placeholder("sk-...").
Key("openai_key").
Value(&initModel.openAIKey),
huh.NewInput().
Title("Anthropic API Key").
Placeholder("sk-ant-...").
Key("anthropic_key").
Value(&initModel.anthropicKey),
huh.NewInput().
Title("Groq API Key").
Placeholder("gsk_...").
Key("groq_key").
Value(&initModel.groqKey),
)
// Model configuration group
modelsGroup := huh.NewGroup(
huh.NewNote().
Title("Model Configuration").
Description("Select which models to use"),
huh.NewSelect[string]().
Title("Big Model").
Options(modelOpts...).
Key("big_model").
Value(&initModel.bigModel),
huh.NewSelect[string]().
Title("Small Model").
Options(modelOpts...).
Key("small_model").
Value(&initModel.smallModel),
huh.NewInput().
Title("Max Tokens").
Placeholder("4000").
Key("max_tokens").
CharLimit(5).
Validate(func(s string) error {
var n int
_, err := fmt.Sscanf(s, "%d", &n)
if err != nil || n <= 0 {
return fmt.Errorf("must be a positive number")
}
initModel.maxTokens = s
return nil
}).
Value(&initModel.maxTokens),
)
// General settings group
generalGroup := huh.NewGroup(
huh.NewNote().
Title("General Settings").
Description("Configure general termai settings"),
huh.NewInput().
Title("Data Directory").
Placeholder(".termai").
Key("data_dir").
Value(&initModel.dataDir),
huh.NewSelect[string]().
Title("Default Agent").
Options(agentOpts...).
Key("agent").
Value(&initModel.agent),
huh.NewConfirm().
Title("Save Configuration").
Affirmative("Save").
Negative("Cancel"),
)
// Create form with theme
form := huh.NewForm(
apiKeysGroup,
modelsGroup,
generalGroup,
).WithTheme(styles.HuhTheme()).
WithShowHelp(true).
WithShowErrors(true)
// Set the form in the model
initModel.form = form
return layout.NewSinglePane(
&initPage{},
initModel,
layout.WithSinglePaneFocusable(true),
layout.WithSinglePaneBordered(true),
layout.WithSignlePaneBorderText(
map[layout.BorderPosition]string{
layout.TopMiddleBorder: "Welcome to termai",
layout.TopMiddleBorder: "Welcome to termai - Initial Setup",
},
),
)

View file

@ -1,3 +1,8 @@
package page
type PageID string
// PageChangeMsg is used to change the current page
type PageChangeMsg struct {
ID PageID
}

View file

@ -146,8 +146,8 @@ var catppuccinDark = ansi.StyleConfig{
Code: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: stringPtr(dark.Green().Hex),
Prefix: " ",
Suffix: " ",
Prefix: "",
Suffix: "",
},
},
CodeBlock: ansi.StyleCodeBlock{

View file

@ -20,6 +20,7 @@ var (
DoubleBorder = Regular.Border(lipgloss.DoubleBorder())
// Colors
White = lipgloss.Color("#ffffff")
Surface0 = lipgloss.AdaptiveColor{
Dark: dark.Surface0().Hex,

View file

@ -1,13 +1,17 @@
package tui
import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/kujtimiihoxha/termai/internal/app"
"github.com/kujtimiihoxha/termai/internal/llm"
"github.com/kujtimiihoxha/termai/internal/permission"
"github.com/kujtimiihoxha/termai/internal/pubsub"
"github.com/kujtimiihoxha/termai/internal/tui/components/core"
"github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
@ -48,6 +52,11 @@ var keys = keyMap{
),
}
var editorKeyMap = key.NewBinding(
key.WithKeys("i"),
key.WithHelp("i", "insert mode"),
)
type appModel struct {
width, height int
currentPage page.PageID
@ -71,8 +80,27 @@ func (a appModel) Init() tea.Cmd {
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case pubsub.Event[llm.AgentEvent]:
log.Println("Event received")
log.Println("AgentEvent")
log.Println(msg)
case pubsub.Event[permission.PermissionRequest]:
return a, dialog.NewPermissionDialogCmd(
msg.Payload,
fmt.Sprintf(
"Tool: %s\nAction: %s\nParams: %v",
msg.Payload.ToolName,
msg.Payload.Action,
msg.Payload.Params,
),
)
case dialog.PermissionResponseMsg:
switch msg.Action {
case dialog.PermissionAllow:
permission.Default.Grant(msg.Permission)
case dialog.PermissionAllowForSession:
permission.Default.GrantPersistant(msg.Permission)
case dialog.PermissionDeny:
permission.Default.Deny(msg.Permission)
}
case vimtea.EditorModeMsg:
a.editorMode = msg.Mode
case tea.WindowSizeMsg:
@ -97,6 +125,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.dialog = d.(core.DialogCmp)
a.dialogVisible = false
return a, cmd
case page.PageChangeMsg:
return a, a.moveToPage(msg.ID)
case util.InfoMsg:
a.status, _ = a.status.Update(msg)
case util.ErrorMsg:
@ -201,8 +231,17 @@ func (a appModel) View() string {
}
func New(app *app.App) tea.Model {
// Check if config file exists, if not, start with init page
homedir, _ := os.UserHomeDir()
configPath := filepath.Join(homedir, ".termai.yaml")
startPage := page.ReplPage
if _, err := os.Stat(configPath); os.IsNotExist(err) {
startPage = page.InitPage
}
return &appModel{
currentPage: page.ReplPage,
currentPage: startPage,
loadedPages: make(map[page.PageID]bool),
status: core.NewStatusCmp(),
help: core.NewHelpCmp(),