Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 45 additions & 8 deletions cmd/late/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,13 @@ func main() {
Model: resolvedOpenAIConfig.Model,
EnableImages: *enableImagesReq,
}
if appConfig != nil {
if setting, ok := appConfig.GetModelForAgent("orchestrator"); ok {
resolvedClientConfig.BaseURL = setting.URL
resolvedClientConfig.APIKey = setting.Key
resolvedClientConfig.Model = setting.Model
}
}
c := client.NewClient(resolvedClientConfig)
c.DiscoverBackend(context.Background())

Expand Down Expand Up @@ -286,16 +293,30 @@ func main() {
// We'll add middlewares later once the program is started
rootAgent := orchestrator.NewBaseOrchestrator("main", sess, nil, 0)

model := tui.NewModel(rootAgent, renderer)
model.ModelName = resolvedOpenAIConfig.Model
model.ShowCWD = *showCWDReq
model := tui.NewModel(rootAgent, renderer, appConfig)
if appConfig != nil {
if orchestratorModel, ok := appConfig.AgentModels["orchestrator"]; ok {
model.ModelName = orchestratorModel
} else {
model.ModelName = resolvedOpenAIConfig.Model
}

// Detect if subagents use a different model/backend
if resolvedSubagentConfig.BaseURL != resolvedOpenAIConfig.BaseURL ||
resolvedSubagentConfig.APIKey != resolvedOpenAIConfig.APIKey ||
resolvedSubagentConfig.Model != resolvedOpenAIConfig.Model {
var subagentInfos []string
for _, sub := range assets.GetSubagents() {
if m, ok := appConfig.AgentModels[sub.Name]; ok {
subagentInfos = append(subagentInfos, fmt.Sprintf("%s:%s", sub.Name, m))
}
}
if len(subagentInfos) > 0 {
model.SubagentInfo = strings.Join(subagentInfos, ", ")
} else {
model.SubagentInfo = resolvedSubagentConfig.Model
}
} else {
model.ModelName = resolvedOpenAIConfig.Model
model.SubagentInfo = resolvedSubagentConfig.Model
}
model.ShowCWD = *showCWDReq

p := tea.NewProgram(model)

Expand All @@ -322,7 +343,23 @@ func main() {

if *enableSubagentsReq {
runner := func(ctx context.Context, goal string, ctxFiles []string, agentType string) (string, error) {
child, err := agent.NewSubagentOrchestrator(subagentClient, goal, ctxFiles, agentType, enabledTools, *injectCWDReq, *gemmaThinkingReq, *subagentMaxTurns, rootAgent, p)
var currentSubagentClient *client.Client
if appConfig != nil {
if setting, ok := appConfig.GetModelForAgent(agentType); ok {
currentSubagentClient = client.NewClient(client.Config{
BaseURL: setting.URL,
APIKey: setting.Key,
Model: setting.Model,
EnableImages: *enableImagesReq,
})
currentSubagentClient.DiscoverBackend(ctx)
}
}
if currentSubagentClient == nil {
currentSubagentClient = subagentClient
}

child, err := agent.NewSubagentOrchestrator(currentSubagentClient, goal, ctxFiles, agentType, enabledTools, *injectCWDReq, *gemmaThinkingReq, *subagentMaxTurns, rootAgent, p)
if err != nil {
return "", err
}
Expand Down
44 changes: 44 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ type SubagentSettings struct {
Model string
}

type ModelSetting struct {
URL string `json:"url"`
Key string `json:"key"`
Model string `json:"model"`
}

const (
configDirPerm os.FileMode = 0o700
configFilePerm os.FileMode = 0o600
Expand All @@ -46,6 +52,9 @@ type Config struct {
SubagentModel string `json:"subagent_model,omitempty"`

SkillsDir string `json:"skills_dir,omitempty"`

Models []ModelSetting `json:"models,omitempty"`
AgentModels map[string]string `json:"agent_models,omitempty"`
}

func defaultConfig() Config {
Expand Down Expand Up @@ -240,3 +249,38 @@ func tightenPermission(path string, required os.FileMode) error {

return os.Chmod(path, required)
}

// GetModelForAgent returns the ModelSetting for a given agent type.
// If not found, it returns false.
func (cfg *Config) GetModelForAgent(agentType string) (ModelSetting, bool) {
if cfg == nil || cfg.AgentModels == nil || cfg.Models == nil {
return ModelSetting{}, false
}
modelName, exists := cfg.AgentModels[agentType]
if !exists {
return ModelSetting{}, false
}
for _, m := range cfg.Models {
if m.Model == modelName {
return m, true
}
}
return ModelSetting{}, false
}

// SaveConfig writes the configuration back to config.json.
func SaveConfig(cfg *Config) error {
lateConfigDir, err := pathutil.LateConfigDir()
if err != nil {
return err
}
configPath := filepath.Join(lateConfigDir, "config.json")
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(configPath, data, configFilePerm); err != nil {
return err
}
return ensureSecureConfigPermissions(lateConfigDir, configPath)
}
37 changes: 37 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -527,3 +527,40 @@ func TestResolveSubagentSettings(t *testing.T) {
})
}
}

func TestConfig_GetModelForAgent(t *testing.T) {
cfg := &Config{
Models: []ModelSetting{
{URL: "http://localhost:8080", Key: "key-1", Model: "model-1"},
{URL: "http://localhost:9090", Key: "key-2", Model: "model-2"},
},
AgentModels: map[string]string{
"orchestrator": "model-1",
"coder": "model-2",
"unknown": "model-3",
},
}

tests := []struct {
agentType string
wantModel string
wantOk bool
}{
{"orchestrator", "model-1", true},
{"coder", "model-2", true},
{"unknown", "", false},
{"missing", "", false},
}

for _, tt := range tests {
t.Run(tt.agentType, func(t *testing.T) {
got, ok := cfg.GetModelForAgent(tt.agentType)
if ok != tt.wantOk {
t.Errorf("GetModelForAgent(%q) ok = %v, want %v", tt.agentType, ok, tt.wantOk)
}
if ok && got.Model != tt.wantModel {
t.Errorf("GetModelForAgent(%q) got model = %q, want %q", tt.agentType, got.Model, tt.wantModel)
}
})
}
}
4 changes: 3 additions & 1 deletion internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tui

import (
"late/internal/common"
"late/internal/config"
"os"

"charm.land/bubbles/v2/filepicker"
Expand All @@ -13,7 +14,7 @@ import (
"charm.land/lipgloss/v2"
)

func NewModel(root common.Orchestrator, renderer *glamour.TermRenderer) Model {
func NewModel(root common.Orchestrator, renderer *glamour.TermRenderer, cfg *config.Config) Model {
ti := textarea.New()
ti.Placeholder = "Ask Late anything..."
ti.Focus()
Expand Down Expand Up @@ -84,6 +85,7 @@ func NewModel(root common.Orchestrator, renderer *glamour.TermRenderer) Model {
ShowCWD: true,
cachedRendererWidth: -1, // Force first creation
Pastes: make(map[string]string),
AppConfig: cfg,
}

fp := filepicker.New()
Expand Down
6 changes: 3 additions & 3 deletions internal/tui/paste_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func (k mockKey) Key() tea.Key {

func TestPastePlaceholderReplacement(t *testing.T) {
orch := &mockOrchestrator{}
model := NewModel(orch, nil)
model := NewModel(orch, nil, nil)

// Simulate PasteMsg of 5 lines
pasteText := "line1\nline2\nline3\nline4\nline5"
Expand Down Expand Up @@ -99,7 +99,7 @@ func TestPastePlaceholderReplacement(t *testing.T) {

func TestPasteBinaryIgnored(t *testing.T) {
orch := &mockOrchestrator{}
model := NewModel(orch, nil)
model := NewModel(orch, nil, nil)

// Set initial state
model.Input.SetValue("> hello")
Expand All @@ -123,7 +123,7 @@ func TestPasteBinaryIgnored(t *testing.T) {

func TestPastePlaceholderSubmitNoCollision(t *testing.T) {
orch := &mockOrchestrator{}
model := NewModel(orch, nil)
model := NewModel(orch, nil, nil)

// Two multi-line pastes. The second paste's CONTENT contains a string
// that looks exactly like a placeholder; it must survive submission
Expand Down
12 changes: 12 additions & 0 deletions internal/tui/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tui
import (
"late/internal/client"
"late/internal/common"
"late/internal/config"
"late/internal/git"

"charm.land/bubbles/v2/filepicker"
Expand Down Expand Up @@ -36,6 +37,7 @@ const (
ViewFilePicker
ViewCommitLog
ViewRewind
ViewModelPicker
)

// Fixed layout heights (crush-style)
Expand All @@ -57,6 +59,7 @@ var AvailableCommands = []CommandDef{
{Name: "/compose", Description: "Compose a message with an editor"},
{Name: "/help", Description: "Show help and shortcuts"},
{Name: "/log", Description: "View git commit log"},
{Name: "/model", Description: "Select AI model for agents"},
{Name: "/quit", Description: "Exit the application"},
{Name: "/rewind", Description: "Rewind conversation history"},
}
Expand Down Expand Up @@ -151,6 +154,15 @@ type Model struct {
CWD string // Current working directory, shown in status bar
ShowCWD bool // Whether to show current working directory in status bar

// Configuration
AppConfig *config.Config

// Model picker fields
ModelPickerAgents []string
ModelPickerModels []string
ModelPickerAgentIndex int
ModelPickerAgentSelections map[string]int

// Esc confirmation
EscConfirmPending bool // Show "are you sure?" when Esc pressed at main view
escBgContent string // Saved viewport content to show underneath the dialog
Expand Down
Loading