From 8dd299219e47c5d9030ee6b81df7453ac0f82944 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 30 Mar 2026 14:16:59 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(tui):=20=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E8=A1=8C=E8=BE=93=E5=85=A5=E4=B8=8E=E4=BB=A3=E7=A0=81=E5=9D=97?= =?UTF-8?q?=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/app.go | 23 +++-- internal/tui/interaction.go | 117 +++++++++++++++++++++++++ internal/tui/keymap.go | 32 +++---- internal/tui/state.go | 21 +++++ internal/tui/styles.go | 13 ++- internal/tui/transcript.go | 110 ++++++++++++++++++++++++ internal/tui/update.go | 29 ++++++- internal/tui/update_test.go | 107 ++++++++++++++++++++++- internal/tui/view.go | 165 ++++++++++++++++++++++++------------ 9 files changed, 532 insertions(+), 85 deletions(-) create mode 100644 internal/tui/interaction.go create mode 100644 internal/tui/transcript.go diff --git a/internal/tui/app.go b/internal/tui/app.go index fbfcd4bd..37c4204b 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -6,7 +6,7 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -28,8 +28,10 @@ type App struct { providerPicker list.Model modelPicker list.Model transcript viewport.Model - input textinput.Model + input textarea.Model activeMessages []provider.Message + codeBlocks []codeBlockTarget + transcriptRect rect focus panel width int height int @@ -63,13 +65,18 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime sessionList.FilterInput.Prompt = "Filter: " sessionList.FilterInput.Placeholder = "Type to search sessions" - input := textinput.New() + input := textarea.New() input.Placeholder = "Ask NeoCode to inspect, edit, or build. Type / to browse commands." - input.Prompt = "" + input.Prompt = "> " input.CharLimit = 24000 - input.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) - input.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) - input.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorSubtle)) + input.ShowLineNumbers = false + input.EndOfBufferCharacter = ' ' + input.SetHeight(1) + input.FocusedStyle.Base = lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) + input.FocusedStyle.Text = lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) + input.FocusedStyle.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color(colorSubtle)) + input.FocusedStyle.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)).Bold(true) + input.BlurredStyle = input.FocusedStyle input.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) input.Focus() @@ -130,5 +137,5 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime } func (a App) Init() tea.Cmd { - return tea.Batch(ListenForRuntimeEvent(a.runtime.Events()), textinput.Blink, a.spinner.Tick) + return tea.Batch(ListenForRuntimeEvent(a.runtime.Events()), textarea.Blink, a.spinner.Tick) } diff --git a/internal/tui/interaction.go b/internal/tui/interaction.go new file mode 100644 index 00000000..1ea5c992 --- /dev/null +++ b/internal/tui/interaction.go @@ -0,0 +1,117 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var writeClipboard = clipboard.WriteAll + +func (a *App) shouldSendInput(msg tea.KeyMsg) bool { + return key.Matches(msg, a.keys.Send) || msg.String() == "ctrl+enter" +} + +func (a *App) handleMouse(msg tea.MouseMsg) { + if !a.transcriptRect.contains(msg.X, msg.Y) { + return + } + + switch msg.Button { //nolint:exhaustive + case tea.MouseButtonWheelUp: + if msg.Action == tea.MouseActionPress { + a.focus = panelTranscript + a.applyFocus() + a.transcript.LineUp(3) + } + case tea.MouseButtonWheelDown: + if msg.Action == tea.MouseActionPress { + a.focus = panelTranscript + a.applyFocus() + a.transcript.LineDown(3) + } + case tea.MouseButtonLeft: + if msg.Action != tea.MouseActionPress { + return + } + if target := a.hitCodeBlockTarget(msg.X, msg.Y); target != nil { + a.copyCodeBlock(*target) + return + } + a.focus = panelTranscript + a.applyFocus() + } +} + +func (a *App) hitCodeBlockTarget(mouseX int, mouseY int) *codeBlockTarget { + if !a.transcriptRect.contains(mouseX, mouseY) { + return nil + } + + contentLine := a.transcript.YOffset + (mouseY - a.transcriptRect.Y) + contentX := mouseX - a.transcriptRect.X + for i := range a.codeBlocks { + target := &a.codeBlocks[i] + if target.Line != contentLine { + continue + } + if contentX >= target.X && contentX < target.X+target.Width { + return target + } + } + return nil +} + +func (a *App) copyCodeBlock(target codeBlockTarget) { + if err := writeClipboard(target.Content); err != nil { + notice := fmt.Sprintf("Copy failed: %v", err) + a.state.ExecutionError = notice + a.state.StatusText = notice + a.appendInlineMessage(roleError, notice) + a.rebuildTranscript() + return + } + + label := "code block" + if trimmed := strings.TrimSpace(target.Language); trimmed != "" && !strings.EqualFold(trimmed, "text") { + label = trimmed + " code block" + } + notice := fmt.Sprintf("Copied %s.", label) + a.state.ExecutionError = "" + a.state.StatusText = notice + a.appendInlineMessage(roleSystem, notice) + a.rebuildTranscript() +} + +func (a *App) composerRows(availableWidth int) int { + width := max(8, availableWidth-lipgloss.Width(a.input.Prompt)) + lines := wrappedLineCount(a.input.Value(), width) + if strings.TrimSpace(a.input.Value()) == "" { + lines = max(lines, 1) + } + return clamp(lines, 1, 8) +} + +func (a *App) updateTranscriptRect(lay layout) { + docX := a.styles.doc.GetPaddingLeft() + docY := a.styles.doc.GetPaddingTop() + headerHeight := lipgloss.Height(a.renderHeader(lay.contentWidth)) + rightX := docX + rightY := docY + headerHeight + if lay.stacked { + rightY += lay.sidebarHeight + } else { + rightX += lay.sidebarWidth + lay.bodyGap + } + + a.transcriptRect = rect{ + X: rightX, + Y: rightY, + Width: a.transcript.Width, + Height: a.transcript.Height, + } +} diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index 891be1a8..d17afad9 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -23,64 +23,64 @@ type keyMap struct { func newKeyMap() keyMap { return keyMap{ Send: key.NewBinding( - key.WithKeys("enter", "ctrl+s"), - key.WithHelp("Enter/Ctrl+S", "发送(输入框)"), + key.WithKeys("ctrl+enter", "ctrl+s"), + key.WithHelp("Ctrl+Enter/Ctrl+S", "send"), ), CancelAgent: key.NewBinding( key.WithKeys("ctrl+w"), - key.WithHelp("Ctrl+w", "中止"), + key.WithHelp("Ctrl+W", "cancel"), ), NewSession: key.NewBinding( key.WithKeys("ctrl+n"), - key.WithHelp("Ctrl+N", "新会话"), + key.WithHelp("Ctrl+N", "new"), ), NextPanel: key.NewBinding( key.WithKeys("tab"), - key.WithHelp("Tab", "下个面板"), + key.WithHelp("Tab", "next panel"), ), PrevPanel: key.NewBinding( key.WithKeys("shift+tab"), - key.WithHelp("Shift+Tab", "上个面板"), + key.WithHelp("Shift+Tab", "prev panel"), ), FocusInput: key.NewBinding( key.WithKeys("esc"), - key.WithHelp("Esc", "聚焦输入框"), + key.WithHelp("Esc", "focus input"), ), OpenSession: key.NewBinding( key.WithKeys("enter"), - key.WithHelp("Enter", "打开会话"), + key.WithHelp("Enter", "open session"), ), ToggleHelp: key.NewBinding( key.WithKeys("ctrl+q"), - key.WithHelp("Ctrl+Q", "帮助"), + key.WithHelp("Ctrl+Q", "help"), ), Quit: key.NewBinding( key.WithKeys("ctrl+u"), - key.WithHelp("Ctrl+U", "退出"), + key.WithHelp("Ctrl+U", "quit"), ), ScrollUp: key.NewBinding( key.WithKeys("up", "k"), - key.WithHelp("Up/k", "向上滚动"), + key.WithHelp("Up/k", "scroll up"), ), ScrollDown: key.NewBinding( key.WithKeys("down", "j"), - key.WithHelp("Down/j", "向下滚动"), + key.WithHelp("Down/j", "scroll down"), ), PageUp: key.NewBinding( key.WithKeys("pgup", "b"), - key.WithHelp("PgUp/b", "向上翻页"), + key.WithHelp("PgUp/b", "page up"), ), PageDown: key.NewBinding( key.WithKeys("pgdown", "f"), - key.WithHelp("PgDn/f", "向下翻页"), + key.WithHelp("PgDn/f", "page down"), ), Top: key.NewBinding( key.WithKeys("g", "home"), - key.WithHelp("g/Home", "跳到顶部"), + key.WithHelp("g/Home", "top"), ), Bottom: key.NewBinding( key.WithKeys("G", "end"), - key.WithHelp("G/End", "跳到底部"), + key.WithHelp("G/End", "bottom"), ), } } diff --git a/internal/tui/state.go b/internal/tui/state.go index 2bd56b35..0639cf9c 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -56,6 +56,27 @@ type activityEntry struct { IsError bool } +type rect struct { + X int + Y int + Width int + Height int +} + +func (r rect) contains(x int, y int) bool { + return x >= r.X && x < r.X+r.Width && y >= r.Y && y < r.Y+r.Height +} + +type codeBlockTarget struct { + MessageIndex int + BlockIndex int + Language string + Content string + Line int + X int + Width int +} + type sessionItem struct { Summary agentruntime.SessionSummary Active bool diff --git a/internal/tui/styles.go b/internal/tui/styles.go index a0326dcb..db50b771 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -55,6 +55,9 @@ type styles struct { inlineNotice lipgloss.Style inlineError lipgloss.Style inlineSystem lipgloss.Style + codeHeader lipgloss.Style + codeLanguage lipgloss.Style + codeCopy lipgloss.Style codeBlock lipgloss.Style codeText lipgloss.Style commandMenu lipgloss.Style @@ -176,8 +179,16 @@ func newStyles() styles { Foreground(lipgloss.Color(colorSubtle)). Background(lipgloss.Color(colorPanel)). Padding(0, 1), + codeHeader: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)). + Background(lipgloss.Color(colorPanelAlt)), + codeLanguage: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorPrimary)), + codeCopy: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorSuccess)), codeBlock: lipgloss.NewStyle(). - MarginLeft(1). Padding(0, 1). Background(lipgloss.Color(colorCode)). BorderLeft(true). diff --git a/internal/tui/transcript.go b/internal/tui/transcript.go new file mode 100644 index 00000000..0b622e36 --- /dev/null +++ b/internal/tui/transcript.go @@ -0,0 +1,110 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +type messageSegmentKind int + +const ( + segmentText messageSegmentKind = iota + segmentCode +) + +type messageSegment struct { + Kind messageSegmentKind + Content string + Language string +} + +type renderedContent struct { + View string + Height int + CodeBlocks []codeBlockTarget +} + +type renderedMessageBlock struct { + View string + Height int + CodeBlocks []codeBlockTarget +} + +func parseMessageSegments(content string) []messageSegment { + normalized := strings.ReplaceAll(content, "\r\n", "\n") + lines := strings.Split(normalized, "\n") + segments := make([]messageSegment, 0, 4) + buffer := make([]string, 0, len(lines)) + inCode := false + language := "" + + flush := func(kind messageSegmentKind, lang string) { + if len(buffer) == 0 { + return + } + segments = append(segments, messageSegment{ + Kind: kind, + Content: strings.Join(buffer, "\n"), + Language: lang, + }) + buffer = buffer[:0] + } + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") { + if inCode { + flush(segmentCode, language) + inCode = false + language = "" + } else { + flush(segmentText, "") + inCode = true + language = strings.TrimSpace(strings.TrimPrefix(trimmed, "```")) + } + continue + } + buffer = append(buffer, line) + } + + switch { + case len(buffer) == 0: + case inCode: + flush(segmentCode, language) + default: + flush(segmentText, "") + } + + if len(segments) == 0 { + return []messageSegment{{Kind: segmentText, Content: content}} + } + return segments +} + +func wrappedLineCount(text string, width int) int { + if width <= 0 { + return 1 + } + lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + total := 0 + for _, line := range lines { + runes := []rune(line) + if len(runes) == 0 { + total++ + continue + } + total += (len(runes)-1)/width + 1 + } + if total == 0 { + return 1 + } + return total +} + +func padBetween(width int, left string, right string) string { + leftWidth := lipgloss.Width(left) + rightWidth := lipgloss.Width(right) + spaceWidth := max(1, width-leftWidth-rightWidth) + return left + strings.Repeat(" ", spaceWidth) + right +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 37692d10..fe6a9117 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -99,6 +99,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } a.rebuildTranscript() return a, tea.Batch(cmds...) + case tea.MouseMsg: + if a.state.ActivePicker == pickerNone { + a.handleMouse(typed) + } + return a, tea.Batch(cmds...) case tea.KeyMsg: if key.Matches(typed, a.keys.Quit) { return a, tea.Quit @@ -177,7 +182,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (tea.Model, tea.Cmd) { - if key.Matches(typed, a.keys.Send) { + if a.shouldSendInput(typed) { input := strings.TrimSpace(a.input.Value()) if input == "" || a.state.IsAgentRunning { return a, tea.Batch(cmds...) @@ -524,17 +529,20 @@ func (a *App) resizeComponents() { menuHeight := a.commandMenuHeight(max(24, lay.rightWidth)) a.transcript.Width = max(24, lay.rightWidth) promptInnerWidth := max(8, lay.rightWidth-a.styles.inputBoxFocused.GetHorizontalFrameSize()) - a.input.Width = max(4, promptInnerWidth-lipgloss.Width("> ")) + a.input.SetWidth(promptInnerWidth) + a.input.SetHeight(a.composerRows(promptInnerWidth)) promptHeight := lipgloss.Height(a.renderPrompt(a.transcript.Width)) a.transcript.Height = max(6, lay.rightHeight-menuHeight-promptHeight) a.providerPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) a.modelPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) + a.updateTranscriptRect(lay) a.rebuildTranscript() } func (a *App) rebuildTranscript() { width := max(24, a.transcript.Width) if len(a.activeMessages) == 0 { + a.codeBlocks = nil a.transcript.SetContent(a.styles.empty.Width(width).Render(emptyConversationText)) a.transcript.GotoTop() return @@ -542,10 +550,23 @@ func (a *App) rebuildTranscript() { atBottom := a.transcript.AtBottom() blocks := make([]string, 0, len(a.activeMessages)) - for _, message := range a.activeMessages { - blocks = append(blocks, a.renderMessageBlock(message, width)) + targets := make([]codeBlockTarget, 0, 4) + lineOffset := 0 + for i, message := range a.activeMessages { + rendered := a.renderMessageBlockDetailed(message, width) + for _, target := range rendered.CodeBlocks { + target.MessageIndex = i + target.Line += lineOffset + targets = append(targets, target) + } + blocks = append(blocks, rendered.View) + lineOffset += rendered.Height + if i < len(a.activeMessages)-1 { + lineOffset += 2 + } } + a.codeBlocks = targets a.transcript.SetContent(strings.Join(blocks, "\n\n")) if atBottom || a.state.IsAgentRunning { a.transcript.GotoBottom() diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 5a62ad72..52649574 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -138,7 +138,7 @@ func TestAppUpdateComposerCommands(t *testing.T) { app.input.SetValue(tt.input) app.state.InputText = tt.input - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) app = model.(App) for _, msg := range collectTeaMessages(cmd) { @@ -687,6 +687,23 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { } }, }, + { + name: "input enter inserts newline instead of sending", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.input.SetValue("hello") + app.state.InputText = "hello" + }, + msg: tea.KeyMsg{Type: tea.KeyEnter}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if len(runtime.runInputs) != 0 { + t.Fatalf("expected enter to stay local, got %+v", runtime.runInputs) + } + if !strings.Contains(app.state.InputText, "\n") { + t.Fatalf("expected enter to insert a newline, got %q", app.state.InputText) + } + }, + }, { name: "input typing updates composer text", msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}, @@ -703,7 +720,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { app.input.SetValue("inspect repo") app.state.InputText = "inspect repo" }, - msg: tea.KeyMsg{Type: tea.KeyEnter}, + msg: tea.KeyMsg{Type: tea.KeyCtrlS}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if !app.state.IsAgentRunning { @@ -1031,6 +1048,92 @@ func TestAppRefreshErrorPaths(t *testing.T) { }) } +func TestRenderMessageContentPreservesStructureAndCodeTargets(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + rendered := app.renderMessageContentDetailed("line1\n\nline2\n```go\nfmt.Println(\"x\")\n```\nnext", 48, app.styles.messageBody) + if !strings.Contains(rendered.View, "line1") || !strings.Contains(rendered.View, "line2") || !strings.Contains(rendered.View, "next") { + t.Fatalf("expected rendered output to keep plain-text sections") + } + if !strings.Contains(rendered.View, "[ Copy ]") { + t.Fatalf("expected rendered output to include copy affordance") + } + if len(rendered.CodeBlocks) != 1 { + t.Fatalf("expected one code block target, got %d", len(rendered.CodeBlocks)) + } + if rendered.CodeBlocks[0].Language != "go" { + t.Fatalf("expected go language label, got %q", rendered.CodeBlocks[0].Language) + } + if !strings.Contains(rendered.CodeBlocks[0].Content, "fmt.Println") { + t.Fatalf("expected code block content to be preserved, got %q", rendered.CodeBlocks[0].Content) + } +} + +func TestMouseCopyCodeBlockAndWheelScroll(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + originalWriteClipboard := writeClipboard + defer func() { writeClipboard = originalWriteClipboard }() + + var copied string + writeClipboard = func(text string) error { + copied = text + return nil + } + + app.activeMessages = []provider.Message{ + {Role: roleAssistant, Content: "```go\nfmt.Println(\"copy\")\n```"}, + } + app.rebuildTranscript() + if len(app.codeBlocks) != 1 { + t.Fatalf("expected one clickable code block, got %d", len(app.codeBlocks)) + } + + target := app.codeBlocks[0] + mouseCopy := tea.MouseMsg{ + X: app.transcriptRect.X + target.X + 1, + Y: app.transcriptRect.Y + target.Line, + Action: tea.MouseActionPress, + Button: tea.MouseButtonLeft, + } + model, _ := app.Update(mouseCopy) + app = model.(App) + + if !strings.Contains(copied, "fmt.Println(\"copy\")") { + t.Fatalf("expected code block to be copied, got %q", copied) + } + if !strings.Contains(app.state.StatusText, "Copied") { + t.Fatalf("expected copy notice, got %q", app.state.StatusText) + } + + app.transcript.SetContent(strings.Repeat("line\n", 80)) + app.transcript.Height = 5 + app.transcript.GotoTop() + app.transcriptRect = rect{X: 4, Y: 3, Width: app.transcript.Width, Height: 5} + mouseScroll := tea.MouseMsg{ + X: app.transcriptRect.X + 1, + Y: app.transcriptRect.Y + 1, + Action: tea.MouseActionPress, + Button: tea.MouseButtonWheelDown, + } + model, _ = app.Update(mouseScroll) + app = model.(App) + + if app.transcript.YOffset == 0 { + t.Fatalf("expected mouse wheel to scroll transcript") + } +} + func newTestConfigManager(t *testing.T) *config.Manager { t.Helper() manager := config.NewManager(config.NewLoader(t.TempDir(), builtin.DefaultConfig())) diff --git a/internal/tui/view.go b/internal/tui/view.go index a20bc2c4..32ec3273 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -24,7 +24,16 @@ func (a App) View() string { docWidth := max(0, a.width-a.styles.doc.GetHorizontalFrameSize()) docHeight := max(0, a.height-a.styles.doc.GetVerticalFrameSize()) if docWidth < 84 || docHeight < 24 { - return strings.TrimRight(a.styles.doc.Render(lipgloss.Place(docWidth, docHeight, lipgloss.Left, lipgloss.Top, "Window too small.\nPlease resize to at least 84x24.")), "\n") + return strings.TrimRight( + a.styles.doc.Render(lipgloss.Place( + docWidth, + docHeight, + lipgloss.Left, + lipgloss.Top, + "Window too small.\nPlease resize to at least 84x24.", + )), + "\n", + ) } lay := a.computeLayout() @@ -39,7 +48,10 @@ func (a App) View() string { } parts = append(parts, helpView) content := lipgloss.JoinVertical(lipgloss.Left, parts...) - return strings.TrimRight(a.styles.doc.Render(lipgloss.Place(docWidth, docHeight, lipgloss.Left, lipgloss.Top, content)), "\n") + return strings.TrimRight( + a.styles.doc.Render(lipgloss.Place(docWidth, docHeight, lipgloss.Left, lipgloss.Top, content)), + "\n", + ) } func (a App) renderHeader(width int) string { @@ -167,19 +179,8 @@ func (a App) renderPrompt(width int) string { box = a.styles.inputBoxFocused } - // 计算边框和内边距占用的空间 boxWidth := max(8, width-2) - - prefix := a.styles.inputPrefix.Render(">") - inputContent := a.input.View() - - content := lipgloss.JoinHorizontal( - lipgloss.Left, - prefix, - lipgloss.NewStyle().Width(1).Render(" "), - a.styles.inputLine.Width(max(4, boxWidth-2-lipgloss.Width(prefix)-1)).Render(inputContent), - ) - return box.Width(boxWidth).Render(content) + return box.Width(boxWidth).Render(a.input.View()) } func (a App) renderSidebarHeader(width int) string { @@ -223,35 +224,42 @@ func (a App) renderPanel(title string, subtitle string, body string, width int, } func (a App) renderMessageBlock(message provider.Message, width int) string { + return a.renderMessageBlockDetailed(message, width).View +} + +func (a App) renderMessageBlockDetailed(message provider.Message, width int) renderedMessageBlock { switch message.Role { case roleEvent: - return a.styles.inlineNotice.Width(width).Render(" > " + wrapPlain(message.Content, max(16, width-6))) + view := a.styles.inlineNotice.Width(width).Render(" > " + wrapPlain(message.Content, max(16, width-6))) + return renderedMessageBlock{View: view, Height: lipgloss.Height(view)} case roleError: - return a.styles.inlineError.Width(width).Render(" ! " + wrapPlain(message.Content, max(16, width-6))) + view := a.styles.inlineError.Width(width).Render(" ! " + wrapPlain(message.Content, max(16, width-6))) + return renderedMessageBlock{View: view, Height: lipgloss.Height(view)} case roleSystem: - return a.styles.inlineSystem.Width(width).Render(" - " + wrapPlain(message.Content, max(16, width-6))) + view := a.styles.inlineSystem.Width(width).Render(" - " + wrapPlain(message.Content, max(16, width-6))) + return renderedMessageBlock{View: view, Height: lipgloss.Height(view)} } - maxMessageWidth := clamp(int(float64(width)*0.84), 24, width) + contentWidth := clamp(int(float64(width)*0.84), 24, width) tag := messageTagAgent tagStyle := a.styles.messageAgentTag bodyStyle := a.styles.messageBody - blockAlign := lipgloss.Left + userAligned := false switch message.Role { case roleUser: - maxMessageWidth = clamp(int(float64(width)*0.68), 24, width) + contentWidth = clamp(int(float64(width)*0.68), 24, width) tag = messageTagUser tagStyle = a.styles.messageUserTag bodyStyle = a.styles.messageUserBody - blockAlign = lipgloss.Right + userAligned = true case roleTool: tag = messageTagTool tagStyle = a.styles.messageToolTag bodyStyle = a.styles.messageToolBody } - content := strings.TrimSpace(message.Content) + content := message.Content if content == "" && len(message.ToolCalls) > 0 { names := make([]string, 0, len(message.ToolCalls)) for _, call := range message.ToolCalls { @@ -259,25 +267,40 @@ func (a App) renderMessageBlock(message provider.Message, width int) string { } content = "Tool calls: " + strings.Join(names, ", ") } - if content == "" { + if strings.TrimSpace(content) == "" { content = emptyMessageText } - contentBlock := a.renderMessageContent(content, maxMessageWidth-2, bodyStyle) - if message.Role == roleUser { - contentBlock = lipgloss.PlaceHorizontal(maxMessageWidth, lipgloss.Right, contentBlock) + contentRender := a.renderMessageContentDetailed(content, contentWidth, bodyStyle) + tagView := tagStyle.Render(tag) + if userAligned { + tagView = lipgloss.PlaceHorizontal(contentWidth, lipgloss.Right, tagView) } block := lipgloss.JoinVertical( - blockAlign, - tagStyle.Render(tag), - contentBlock, + lipgloss.Left, + tagView, + contentRender.View, ) + block = lipgloss.NewStyle().Width(contentWidth).Render(block) - if message.Role == roleUser { - return lipgloss.PlaceHorizontal(width, lipgloss.Right, block) + targets := make([]codeBlockTarget, 0, len(contentRender.CodeBlocks)) + targetXOffset := 0 + if userAligned { + targetXOffset = width - contentWidth + block = lipgloss.PlaceHorizontal(width, lipgloss.Right, block) + } + for _, target := range contentRender.CodeBlocks { + target.X += targetXOffset + target.Line++ + targets = append(targets, target) + } + + return renderedMessageBlock{ + View: block, + Height: lipgloss.Height(block), + CodeBlocks: targets, } - return block } func (a App) renderCommandMenu(width int) string { @@ -315,40 +338,74 @@ func (a App) commandMenuHeight(width int) int { func (a App) renderHelp(width int) string { a.help.ShowAll = a.state.ShowHelp helpContent := a.help.View(a.keys) - // 确保帮助视图填充整个宽度,避免边框断裂 return a.styles.footer.Width(width).Render(helpContent) } func (a App) renderMessageContent(content string, width int, bodyStyle lipgloss.Style) string { - parts := strings.Split(content, "```") - if len(parts) == 1 { - return bodyStyle.Render(wrapPlain(content, max(16, width-2))) + return a.renderMessageContentDetailed(content, width, bodyStyle).View +} + +func (a App) renderMessageContentDetailed(content string, width int, bodyStyle lipgloss.Style) renderedContent { + segments := parseMessageSegments(content) + if len(segments) == 0 { + view := bodyStyle.Width(width).Render(emptyMessageText) + return renderedContent{View: view, Height: lipgloss.Height(view)} } - blocks := make([]string, 0, len(parts)) - for i, part := range parts { - if i%2 == 0 { - trimmed := strings.Trim(part, "\n") - if trimmed == "" { - continue - } - blocks = append(blocks, bodyStyle.Render(wrapPlain(trimmed, max(16, width-2)))) - continue + blocks := make([]string, 0, len(segments)) + targets := make([]codeBlockTarget, 0, 2) + lineOffset := 0 + codeIndex := 0 + bodyInnerWidth := max(16, width-bodyStyle.GetHorizontalFrameSize()) + + for _, segment := range segments { + switch segment.Kind { + case segmentCode: + codeView, target := a.renderCodeBlock(segment, width, codeIndex) + blocks = append(blocks, codeView) + target.Line += lineOffset + targets = append(targets, target) + lineOffset += lipgloss.Height(codeView) + codeIndex++ + default: + view := bodyStyle.Width(width).Render(wrapPlain(segment.Content, bodyInnerWidth)) + blocks = append(blocks, view) + lineOffset += lipgloss.Height(view) } + } - code := strings.Trim(part, "\n") - lines := strings.Split(code, "\n") - if len(lines) > 1 && !strings.Contains(lines[0], " ") && !strings.Contains(lines[0], "\t") { - code = strings.Join(lines[1:], "\n") - } - blocks = append(blocks, a.styles.codeBlock.Width(width).Render(a.styles.codeText.Width(max(10, width-4)).Render(code))) + view := lipgloss.JoinVertical(lipgloss.Left, blocks...) + return renderedContent{ + View: view, + Height: lipgloss.Height(view), + CodeBlocks: targets, } +} - if len(blocks) == 0 { - return bodyStyle.Render(emptyMessageText) +func (a App) renderCodeBlock(segment messageSegment, width int, codeIndex int) (string, codeBlockTarget) { + language := strings.TrimSpace(segment.Language) + if language == "" { + language = "text" } - return lipgloss.JoinVertical(lipgloss.Left, blocks...) + copyLabel := "[ Copy ]" + headerLeft := a.styles.codeLanguage.Render(language) + headerRight := a.styles.codeCopy.Render(copyLabel) + headerText := padBetween(width, headerLeft, headerRight) + header := a.styles.codeHeader.Width(width).Render(headerText) + + bodyInnerWidth := max(10, width-a.styles.codeBlock.GetHorizontalFrameSize()) + code := strings.TrimSuffix(segment.Content, "\n") + body := a.styles.codeBlock.Width(width).Render(a.styles.codeText.Width(bodyInnerWidth).Render(code)) + + return lipgloss.JoinVertical(lipgloss.Left, header, body), codeBlockTarget{ + BlockIndex: codeIndex, + Language: language, + Content: segment.Content, + Line: 0, + X: max(0, width-lipgloss.Width(headerRight)), + Width: lipgloss.Width(headerRight), + } } func (a App) statusBadge(text string) string { From 275f3bda9fb9852139b99d3730503fcd576b2d62 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 30 Mar 2026 14:53:22 +0800 Subject: [PATCH 02/11] =?UTF-8?q?docs:=E6=94=B9=E6=88=90=E6=9B=B4=E9=80=82?= =?UTF-8?q?=E5=90=88=E7=90=86=E8=A7=A3=E7=9A=84readme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 260 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 227 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 4398146a..9b6f69c3 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,257 @@ # NeoCode -NeoCode 是一个基于 Go 和 Bubble Tea 的本地编码 Agent。它在终端中运行 ReAct 闭环,能够对话、调用工具、持久化会话,并以流式方式展示模型输出。 +NeoCode 是一个基于 Go 和 Bubble Tea 的本地 Coding Agent MVP。 -## 当前 Provider 策略 +它当前聚焦一条最重要的主链路: -- 内建 provider 定义随代码版本发布。 -- `config.yaml` 不再持久化完整 `providers` 列表。 -- `config.yaml` 只保存当前选择状态和通用运行配置。 -- 运行时的 `providers` 完全来自代码内建定义。 -- API Key 只从环境变量读取,不写入 YAML。 -- 当前内建 provider 包括 `openai` 和 `gemini`。 -- `gemini` 复用 OpenAI-compatible driver,请求地址指向 Gemini 的兼容接口。 -- provider 实例自己定义 `base_url`、默认模型、可选模型列表和 `api_key_env`。 -- `base_url` 不在 TUI 中展示给用户。 -- driver 只负责协议构造与响应解析,不决定 `models`、`base_url` 或 `api_key_env`。 +`用户输入 -> Agent 推理 -> 调用工具 -> 获取结果 -> 继续推理 -> UI 展示` -这意味着: +如果你是第一次打开这个仓库,可以先把它理解成一个运行在终端里的本地 AI 编码助手。它会在 TUI 中接收你的问题,调用模型进行推理,在需要时使用本地工具,再把过程和结果实时展示出来。 -- 新用户启动后会自动拿到当前版本最新的内建 provider。 -- 未来代码新增 provider 时,新用户不需要修改 YAML。 -- 老配置文件中的 `providers` / `provider_overrides` 会在加载时被清理为新的最小状态格式。 +## 这份 README 适合谁 -## 配置文件 +- 想先把项目跑起来的新同学 +- 想快速看懂仓库分层的贡献者 +- 想知道配置文件、Provider、Tools 应该放在哪一层的协作者 -默认路径: -[`~/.neocode/config.yaml`](~/.neocode/config.yaml) +如果你想直接看详细设计,可以跳到本文末尾的“文档索引”。 -当前落盘结构示例: +## 当前能力 + +当前仓库已经围绕 MVP 主链路组织,重点在于把边界理顺,而不是一次性做很多功能。 + +- 终端 TUI 交互界面 +- Runtime 驱动的 ReAct 主循环 +- Provider 抽象与内建 Provider 配置 +- Tool Registry 和统一工具协议 +- 本地 Session 持久化 +- 流式事件回传到 TUI + +当前内建工具: + +- `filesystem_read_file` +- `filesystem_write_file` +- `filesystem_grep` +- `filesystem_glob` +- `filesystem_edit` +- `bash` +- `webfetch` + +当前内建 Provider 配置: + +| Provider | 默认模型 | API Key 环境变量 | 说明 | +| --- | --- | --- | --- | +| `openai` | `gpt-4.1` | `OPENAI_API_KEY` | 默认配置 | +| `gemini` | `gemini-2.5-flash` | `GEMINI_API_KEY` | 走 OpenAI-compatible 接口 | +| `openll` | `gpt-4.1` | `AI_API_KEY` | 自定义 OpenAI-compatible 入口 | + +## 快速开始 + +### 1. 准备环境 + +- Go `1.25.0` +- 一个可用的模型 API Key +- 可正常联网的终端环境 + +### 2. 设置 API Key + +NeoCode 不会把明文 API Key 写入 `config.yaml`。你可以直接设置系统环境变量,也可以放到 `.env` 文件中。 + +常见方式: + +```powershell +$env:OPENAI_API_KEY="your-api-key" +``` + +或在项目根目录 / `~/.neocode/.env` 中写入: + +```env +OPENAI_API_KEY=your-api-key +``` + +可选环境变量名取决于你使用的 Provider: + +- `OPENAI_API_KEY` +- `GEMINI_API_KEY` +- `AI_API_KEY` + +### 3. 启动应用 + +```bash +go run ./cmd/neocode +``` + +首次启动时,程序会自动创建默认配置文件: + +`~/.neocode/config.yaml` + +### 4. 在界面里开始使用 + +进入 TUI 后,你可以直接输入需求,例如: + +- “帮我阅读当前仓库结构并总结” +- “查看 `internal/runtime` 的主循环逻辑” +- “搜索项目里和 provider 相关的实现” + +常用 Slash Command: + +- `/provider`:切换当前 Provider +- `/model`:切换当前模型 + +## 新手先理解这 4 层 + +如果你只想快速看懂项目,先抓住下面这条链路: + +`TUI -> Runtime -> Provider / Tools` + +### TUI + +`internal/tui` + +- 负责界面展示、输入处理、Slash Command 和状态切换 +- 只消费 Runtime 事件,不直接执行工具,不直接调用厂商 API + +### Runtime + +`internal/runtime` + +- 是整个 Agent 的编排中心 +- 负责维护会话、组织上下文、驱动模型调用、执行工具回灌、控制停止条件 + +### Provider + +`internal/provider` + +- 负责抹平不同模型厂商之间的协议差异 +- 对 Runtime 暴露统一的 `ChatRequest / ChatResponse / ToolCall / StreamEvent` + +### Tools + +`internal/tools` + +- 负责所有可被模型调用的本地能力 +- 包括 schema 定义、参数校验、执行、错误包装和结果收敛 + +一句话记忆: + +不要跨层直连。UI 不碰工具执行,Runtime 不写厂商协议细节,工具能力统一进入 `internal/tools`。 + +## 配置说明 + +默认配置文件路径: + +`~/.neocode/config.yaml` + +当前落盘配置是“最小状态配置”,只保存当前选择和通用运行参数,不保存完整 Provider 元数据。 + +示例: ```yaml selected_provider: openai -current_model: gpt-5.4 -workdir: . +current_model: gpt-4.1 +workdir: /absolute/path/to/workspace shell: powershell max_loops: 8 tool_timeout_sec: 20 +tools: + webfetch: + max_response_bytes: 262144 + supported_content_types: + - text/html + - application/xhtml+xml + - text/plain + - application/json + - application/xml + - text/xml ``` -其中: +几个关键点: + +- `selected_provider`:当前选中的 Provider +- `current_model`:当前 Provider 下使用的模型 +- `workdir`:工具默认工作目录;启动后会被规范化为绝对路径 +- `shell`:Windows 默认是 `powershell`,其他系统默认是 `bash` +- `max_loops`:Runtime 最大推理轮数 +- `tool_timeout_sec`:工具超时时间 + +说明: + +- 示例里的 `workdir` 使用绝对路径是为了贴近真实落盘结果 +- 如果你首次启动时还没有手动配置,程序会根据当前工作目录生成默认值 + +Provider 的 `base_url`、默认模型列表和 `api_key_env` 由代码内建定义提供,不需要你在 YAML 中手动维护。 + +## 工具与安全边界 + +NeoCode 当前默认遵守这些约束: + +- `filesystem` 工具限制在工作目录内 +- `bash` 工具有超时控制,避免长时间阻塞 +- `webfetch` 限制响应大小和内容类型 +- API Key 通过环境变量读取,不写入聊天记录和配置文件 -- `selected_provider` 和 `current_model` 是用户当前选择。 -- provider 的 `base_url`、`models`、`api_key_env` 和 `driver` 都由开发者在代码中预设。 -- `openai` 默认读取 `OPENAI_API_KEY`,`gemini` 默认读取 `GEMINI_API_KEY`。 -- 完整 provider 列表不落盘,用户不需要在 YAML 中维护供应商元数据。 +如果你要新增一个可被模型调用的能力,优先放进 `internal/tools`,不要直接写到 `runtime` 或 `tui` 里。 -## Slash Commands +## 仓库结构 -- `/provider`:打开 provider 选择器。 -- `/model`:打开当前 provider 的模型选择器。 +```text +. +├── cmd/neocode # CLI 入口 +├── internal/app # 应用装配与依赖注入 +├── internal/config # 配置加载、保存、校验、并发安全访问 +├── internal/provider # Provider 抽象、驱动注册、厂商适配 +├── internal/runtime # ReAct 主循环、事件、Session 管理 +├── internal/tools # 工具协议、注册表、具体工具实现 +├── internal/tui # Bubble Tea 界面与交互状态机 +└── docs # 架构与细节设计文档 +``` + +## 开发命令 -## 运行 +启动应用: ```bash go run ./cmd/neocode ``` -## 开发 +编译全部包: + +```bash +go build ./... +``` + +运行测试: ```bash -gofmt -w ./cmd ./internal go test ./... ``` + +格式化代码: + +```bash +gofmt -w ./cmd ./internal +``` + +## 贡献时建议先看 + +这个仓库最重要的协作原则是: + +- 优先保证主链路可运行 +- 优先保持模块边界清晰 +- 新能力默认沿 `TUI -> Runtime -> Provider / Tool Manager` 接入 +- 修改 `config`、`provider`、`runtime`、`tools` 时要同步评估测试 + +在开始改代码前,建议先阅读仓库根目录的 `AGENTS.md`。 + +## 文档索引 + +- `docs/neocode-coding-agent-mvp-architecture.md` +- `docs/runtime-provider-event-flow.md` +- `docs/config-management-detail-design.md` +- `docs/provider-schema-strategy.md` +- `docs/tools-and-tui-integration.md` +- `docs/session-persistence-design.md` + +## 一句话总结 + +NeoCode 当前不是一个“功能很多”的 Agent,而是一个把主链路、分层和可验证性打磨清楚的 Coding Agent MVP。对新同学来说,最好的阅读顺序是:先跑起来,再看 `README` 的分层说明,最后按需进入 `docs/` 深入细节。 From ca35f0cf831f21c6d59de6f635016242a5a16c94 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 30 Mar 2026 14:53:48 +0800 Subject: [PATCH 03/11] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E5=86=B2=E7=AA=81?= =?UTF-8?q?=E5=BF=AB=E6=8D=B7=E9=94=AE=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/interaction.go | 2 +- internal/tui/keymap.go | 4 ++-- internal/tui/transcript.go | 13 ++++++++++--- internal/tui/update_test.go | 15 ++++++++++++--- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/internal/tui/interaction.go b/internal/tui/interaction.go index 1ea5c992..af4d231a 100644 --- a/internal/tui/interaction.go +++ b/internal/tui/interaction.go @@ -13,7 +13,7 @@ import ( var writeClipboard = clipboard.WriteAll func (a *App) shouldSendInput(msg tea.KeyMsg) bool { - return key.Matches(msg, a.keys.Send) || msg.String() == "ctrl+enter" + return key.Matches(msg, a.keys.Send) } func (a *App) handleMouse(msg tea.MouseMsg) { diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index d17afad9..6c211bd6 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -23,8 +23,8 @@ type keyMap struct { func newKeyMap() keyMap { return keyMap{ Send: key.NewBinding( - key.WithKeys("ctrl+enter", "ctrl+s"), - key.WithHelp("Ctrl+Enter/Ctrl+S", "send"), + key.WithKeys("ctrl+j"), + key.WithHelp("Ctrl+J", "send"), ), CancelAgent: key.NewBinding( key.WithKeys("ctrl+w"), diff --git a/internal/tui/transcript.go b/internal/tui/transcript.go index 0b622e36..4c08491d 100644 --- a/internal/tui/transcript.go +++ b/internal/tui/transcript.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" ) type messageSegmentKind int @@ -86,16 +87,22 @@ func wrappedLineCount(text string, width int) int { if width <= 0 { return 1 } + lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") total := 0 for _, line := range lines { - runes := []rune(line) - if len(runes) == 0 { + if line == "" { total++ continue } - total += (len(runes)-1)/width + 1 + displayWidth := runewidth.StringWidth(line) + if displayWidth <= 0 { + total++ + continue + } + total += (displayWidth-1)/width + 1 } + if total == 0 { return 1 } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 52649574..314d3611 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -138,7 +138,7 @@ func TestAppUpdateComposerCommands(t *testing.T) { app.input.SetValue(tt.input) app.state.InputText = tt.input - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) app = model.(App) for _, msg := range collectTeaMessages(cmd) { @@ -379,7 +379,7 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if !strings.Contains(app.renderSidebar(26, 12), sidebarTitle) || !strings.Contains(app.renderSidebar(26, 12), sidebarOpenHint) { t.Fatalf("expected updated sidebar header text") } - if strings.Contains(app.renderPrompt(80), "Enter/Ctrl+S") { + if strings.Contains(app.renderPrompt(80), "Ctrl+S") || strings.Contains(app.renderPrompt(80), "Ctrl+Enter") { t.Fatalf("expected composer hint line to be removed") } if strings.TrimSpace(app.renderPrompt(80)) == "" { @@ -448,6 +448,15 @@ func TestTUIStandaloneHelpers(t *testing.T) { if preview("line1\nline2\nline3", 8, 2) == "" { t.Fatalf("expected preview output") } + if newKeyMap().Send.Help().Key != "Ctrl+J" { + t.Fatalf("expected send help to advertise Ctrl+J, got %+v", newKeyMap().Send.Help()) + } + if wrappedLineCount("你好世界", 4) != 2 { + t.Fatalf("expected CJK text to consume full-width columns") + } + if wrappedLineCount("ab你cd", 4) != 2 { + t.Fatalf("expected mixed-width text to wrap by display width") + } if clamp(10, 0, 5) != 5 || max(2, 3) != 3 { t.Fatalf("expected numeric helpers to work") } @@ -720,7 +729,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { app.input.SetValue("inspect repo") app.state.InputText = "inspect repo" }, - msg: tea.KeyMsg{Type: tea.KeyCtrlS}, + msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if !app.state.IsAgentRunning { From e774444a1c387fe011e9fbe80c3b03832ee76228 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 30 Mar 2026 16:17:29 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat:=E5=A2=9E=E5=8A=A0=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8Fapikey=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- docs/config-management-detail-design.md | 30 +++--- internal/config/config_test.go | 95 +++++++++++++++++ internal/config/loader.go | 45 +++++---- internal/config/manager.go | 3 +- internal/config/model.go | 76 ++++++++++++-- internal/provider/registry_test.go | 55 ++++++++++ internal/provider/service.go | 11 ++ internal/tui/app.go | 10 ++ internal/tui/commands.go | 33 ++++++ internal/tui/provider_service.go | 1 + internal/tui/state.go | 3 + internal/tui/update.go | 22 ++++ internal/tui/update_test.go | 129 ++++++++++++++++++++++++ internal/tui/view.go | 4 + 15 files changed, 477 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 9b6f69c3..b5ab65b2 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ OPENAI_API_KEY=your-api-key - `GEMINI_API_KEY` - `AI_API_KEY` +你也可以在进入 TUI 后通过 `/apikey` 打开输入框,自定义当前运行时优先读取的 API Key 环境变量名。留空确认会恢复为当前 Provider 的默认环境变量名。 + ### 3. 启动应用 ```bash @@ -97,6 +99,7 @@ go run ./cmd/neocode - `/provider`:切换当前 Provider - `/model`:切换当前模型 +- `/apikey`:设置全局 API Key 环境变量名覆盖 ## 新手先理解这 4 层 @@ -149,6 +152,7 @@ go run ./cmd/neocode ```yaml selected_provider: openai current_model: gpt-4.1 +api_key_env_override: CUSTOM_OPENAI_KEY workdir: /absolute/path/to/workspace shell: powershell max_loops: 8 @@ -179,7 +183,7 @@ tools: - 示例里的 `workdir` 使用绝对路径是为了贴近真实落盘结果 - 如果你首次启动时还没有手动配置,程序会根据当前工作目录生成默认值 -Provider 的 `base_url`、默认模型列表和 `api_key_env` 由代码内建定义提供,不需要你在 YAML 中手动维护。 +Provider 的 `base_url`、默认模型列表和 `api_key_env` 由代码内建定义提供,不需要你在 YAML 中手动维护;如果你想全局覆盖当前运行时读取的环境变量名,可以设置顶层的 `api_key_env_override`,留空则回退到当前 Provider 的默认值。 ## 工具与安全边界 diff --git a/docs/config-management-detail-design.md b/docs/config-management-detail-design.md index 8975eb90..d27d258e 100644 --- a/docs/config-management-detail-design.md +++ b/docs/config-management-detail-design.md @@ -3,32 +3,32 @@ `config` 模块主要负责四类事情: - 加载和保存 YAML 配置文件 - 从环境变量解析真实密钥 -- 管理 NeoCode 托管目录中的配置与 `.env` +- 管理 NeoCode 托管目录中的 `.env` - 向运行中的系统提供并发安全的配置读写能力 ## 核心类型 -- `Config`:顶层应用配置,运行时包含 Provider 列表、当前选中 Provider、当前模型、工作目录、Shell 和循环限制等信息 +- `Config`:顶层应用配置,运行时包含 Provider 列表、当前选中的 Provider、当前模型、全局 `api_key_env_override`、工作目录、Shell 和循环限制等信息 - `ProviderConfig`:单个 Provider 的内建定义,包括 Base URL、默认模型、实例级模型列表和 API Key 环境变量名 - `Manager`:使用 `sync.RWMutex` 保护的配置访问器与修改器 - `Loader`:对 YAML 文件和托管 `.env` 文件的文件系统封装 ## 环境变量策略 -- YAML 不保存 `api_key_env`,只保存 `selected_provider`、`current_model` 和通用运行配置。 -- `Loader.LoadEnvironment` 会尝试加载当前工作目录下的 `.env` 和 NeoCode 托管目录中的 `.env`。 -- `ProviderConfig.ResolveAPIKey` 在真正发起请求前通过 `os.Getenv` 读取密钥。 +- YAML 不保存 Provider 元数据,但允许保存顶层 `api_key_env_override`,与 `selected_provider`、`current_model` 和通用运行配置一起持久化 +- `Loader.LoadEnvironment` 会尝试加载当前工作目录下的 `.env` 和 NeoCode 托管目录中的 `.env` +- `ProviderConfig.ResolveAPIKey` 在真正发起请求前通过 `os.Getenv` 读取密钥 ## 运行时更新 -- TUI 只能通过 `ConfigManager.Update` 修改配置。 -- TUI 只负责切换当前 Provider 和当前模型,不直接修改 Provider 元数据。 -- `base_url`、`api_key_env`、`driver` 和 `models` 由代码内建定义提供,不从 YAML 读写。 -- 修改模型时,只更新 `current_model`;当前 Provider 的 `model` 仍表示默认模型,`models` 负责描述该实例可选模型列表。 +- TUI 只通过 `ConfigManager.Update` 修改配置 +- TUI 可以切换当前 Provider、当前模型,以及设置全局 `api_key_env_override`,但不直接修改 Provider 元数据 +- `base_url`、`api_key_env`、`driver` 和 `models` 由代码内建定义提供,不从 YAML 读写 +- 修改模型时,只更新 `current_model`;当前 Provider 的 `model` 仍表示默认模型,`models` 负责描述该实例可选模型列表 ## 默认值治理 -- 默认 Provider 名称、URL、默认模型、模型列表和环境变量名统一由内建 Provider 定义提供。 -- `Loader` 在加载旧配置时会丢弃 `providers` / `provider_overrides`,重新回到“YAML 只保存选择状态”的最小结构。 -- Provider 的可选模型目录属于实例配置,进入运行时 `Config` 后再提供给 TUI 和 runtime,避免 TUI 或 driver 自己维护一套零散常量。 +- 默认 Provider 名称、URL、默认模型、模型列表和环境变量名统一由内建 Provider 定义提供 +- `Loader` 在加载旧配置时会丢弃 `providers` / `provider_overrides`,重新回到“YAML 只保存选择状态和顶层 override”的最小结构 +- Provider 的可选模型目录属于实例配置,进入运行时 `Config` 后再提供给 TUI 和 runtime,避免 TUI 或 driver 自己维护一套零散常量 ## 安全约束 -- 读操作统一走 `Get`,并返回拷贝后的配置快照。 -- 写操作统一走 `Update`,修改前后都要做校验。 -- 真实密钥不能出现在日志、状态栏、聊天流或错误提示中。 +- 读操作统一走 `Get`,并返回拷贝后的配置快照 +- 写操作统一走 `Update`,修改前后都要做校验 +- 真实密钥不能出现在日志、状态栏、聊天流或错误提示中 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8f8c82d5..835b1ed9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -629,6 +629,32 @@ func TestProviderLookupAndResolveSelectedProvider(t *testing.T) { } } +func TestProviderLookupAndResolveSelectedProviderUsesOverride(t *testing.T) { + t.Setenv("CUSTOM_OPENAI_KEY", "override-key") + + manager := NewManager(NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + if err := manager.Update(context.Background(), func(cfg *Config) error { + cfg.APIKeyEnvOverride = " CUSTOM_OPENAI_KEY " + return nil + }); err != nil { + t.Fatalf("Update() error = %v", err) + } + + resolved, err := manager.ResolvedSelectedProvider() + if err != nil { + t.Fatalf("ResolvedSelectedProvider() error = %v", err) + } + if resolved.APIKeyEnv != "CUSTOM_OPENAI_KEY" { + t.Fatalf("expected resolved env %q, got %q", "CUSTOM_OPENAI_KEY", resolved.APIKeyEnv) + } + if resolved.APIKey != "override-key" { + t.Fatalf("expected resolved key %q, got %q", "override-key", resolved.APIKey) + } +} + func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { tempDir := t.TempDir() loader := NewLoader(tempDir, testDefaultConfig()) @@ -644,6 +670,7 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { cfg.CurrentModel = "gpt-5.4" cfg.Providers[0].Models = []string{"gpt-4.1", "gpt-5.4"} cfg.Providers[0].BaseURL = "https://ignored.example/v1" + cfg.APIKeyEnvOverride = "CUSTOM_OPENAI_KEY" cfg.Tools.WebFetch.MaxResponseBytes = 1024 cfg.Tools.WebFetch.SupportedContentTypes = []string{"text/html", "application/json"} if err := loader.Save(context.Background(), cfg); err != nil { @@ -664,11 +691,17 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { if strings.Contains(text, "models:") || strings.Contains(text, "base_url:") || strings.Contains(text, "api_key_env:") { t.Fatalf("expected persisted config to keep only selection state and common runtime settings, got:\n%s", text) } + if !strings.Contains(text, "api_key_env_override: CUSTOM_OPENAI_KEY") { + t.Fatalf("expected persisted config to include api_key_env_override, got:\n%s", text) + } reloaded, err := loader.Load(context.Background()) if err != nil { t.Fatalf("Load() reload error = %v", err) } + if reloaded.APIKeyEnvOverride != "CUSTOM_OPENAI_KEY" { + t.Fatalf("expected api key env override to round-trip, got %q", reloaded.APIKeyEnvOverride) + } if reloaded.CurrentModel != "gpt-5.4" { t.Fatalf("expected current model %q, got %q", "gpt-5.4", reloaded.CurrentModel) } @@ -693,6 +726,68 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { } } +func TestLoaderSaveOmitsEmptyAPIKeyEnvOverride(t *testing.T) { + tempDir := t.TempDir() + loader := NewLoader(tempDir, testDefaultConfig()) + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + cfg.APIKeyEnvOverride = "CUSTOM_OPENAI_KEY" + if err := loader.Save(context.Background(), cfg); err != nil { + t.Fatalf("Save() with override error = %v", err) + } + + cfg.APIKeyEnvOverride = " " + if err := loader.Save(context.Background(), cfg); err != nil { + t.Fatalf("Save() clearing override error = %v", err) + } + + data, err := os.ReadFile(loader.ConfigPath()) + if err != nil { + t.Fatalf("read config file: %v", err) + } + if strings.Contains(string(data), "api_key_env_override:") { + t.Fatalf("expected empty override to be omitted, got:\n%s", string(data)) + } +} + +func TestConfigValidateRejectsInvalidAPIKeyEnvOverride(t *testing.T) { + tests := []struct { + name string + override string + expectErr string + }{ + { + name: "contains spaces", + override: "CUSTOM KEY", + expectErr: "must not contain whitespace", + }, + { + name: "contains equals", + override: "CUSTOM=KEY", + expectErr: "must not contain =", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := testDefaultConfig() + cfg.APIKeyEnvOverride = tt.override + cfg.ApplyDefaultsFrom(*testDefaultConfig()) + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + }) + } +} + func TestLoaderUsesUpdatedBuiltinProviderWhenUserHasNoOverride(t *testing.T) { tempDir := t.TempDir() diff --git a/internal/config/loader.go b/internal/config/loader.go index 0c40c051..97a62184 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -24,13 +24,14 @@ type Loader struct { } type persistedConfig struct { - SelectedProvider string `yaml:"selected_provider"` - CurrentModel string `yaml:"current_model"` - Workdir string `yaml:"workdir"` - Shell string `yaml:"shell"` - MaxLoops int `yaml:"max_loops,omitempty"` - ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` - Tools ToolsConfig `yaml:"tools,omitempty"` + SelectedProvider string `yaml:"selected_provider"` + CurrentModel string `yaml:"current_model"` + APIKeyEnvOverride string `yaml:"api_key_env_override,omitempty"` + Workdir string `yaml:"workdir"` + Shell string `yaml:"shell"` + MaxLoops int `yaml:"max_loops,omitempty"` + ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Tools ToolsConfig `yaml:"tools,omitempty"` } func NewLoader(baseDir string, defaults *Config) *Loader { @@ -124,13 +125,14 @@ func (l *Loader) Save(ctx context.Context, cfg *Config) error { } file := persistedConfig{ - SelectedProvider: snapshot.SelectedProvider, - CurrentModel: snapshot.CurrentModel, - Workdir: snapshot.Workdir, - Shell: snapshot.Shell, - MaxLoops: snapshot.MaxLoops, - ToolTimeoutSec: snapshot.ToolTimeoutSec, - Tools: snapshot.Tools, + SelectedProvider: snapshot.SelectedProvider, + CurrentModel: snapshot.CurrentModel, + APIKeyEnvOverride: snapshot.APIKeyEnvOverride, + Workdir: snapshot.Workdir, + Shell: snapshot.Shell, + MaxLoops: snapshot.MaxLoops, + ToolTimeoutSec: snapshot.ToolTimeoutSec, + Tools: snapshot.Tools, } data, err := yaml.Marshal(&file) @@ -192,13 +194,14 @@ func parseCurrentConfig(data []byte, _ Config) (*Config, error) { } cfg := &Config{ - SelectedProvider: strings.TrimSpace(file.SelectedProvider), - CurrentModel: strings.TrimSpace(file.CurrentModel), - Workdir: strings.TrimSpace(file.Workdir), - Shell: strings.TrimSpace(file.Shell), - MaxLoops: file.MaxLoops, - ToolTimeoutSec: file.ToolTimeoutSec, - Tools: file.Tools, + SelectedProvider: strings.TrimSpace(file.SelectedProvider), + CurrentModel: strings.TrimSpace(file.CurrentModel), + APIKeyEnvOverride: strings.TrimSpace(file.APIKeyEnvOverride), + Workdir: strings.TrimSpace(file.Workdir), + Shell: strings.TrimSpace(file.Shell), + MaxLoops: file.MaxLoops, + ToolTimeoutSec: file.ToolTimeoutSec, + Tools: file.Tools, } return cfg, nil diff --git a/internal/config/manager.go b/internal/config/manager.go index 743a9758..29a85b5a 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -91,7 +91,8 @@ func (m *Manager) SelectedProvider() (ProviderConfig, error) { } func (m *Manager) ResolvedSelectedProvider() (ResolvedProviderConfig, error) { - provider, err := m.SelectedProvider() + cfg := m.Get() + provider, err := cfg.EffectiveSelectedProviderConfig() if err != nil { return ResolvedProviderConfig{}, err } diff --git a/internal/config/model.go b/internal/config/model.go index 6f10dccd..bc0a46fd 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -27,14 +27,15 @@ var defaultWebFetchSupportedContentTypes = []string{ } type Config struct { - Providers []ProviderConfig `yaml:"-"` - SelectedProvider string `yaml:"selected_provider"` - CurrentModel string `yaml:"current_model"` - Workdir string `yaml:"workdir"` - Shell string `yaml:"shell"` - MaxLoops int `yaml:"max_loops,omitempty"` - ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` - Tools ToolsConfig `yaml:"tools,omitempty"` + Providers []ProviderConfig `yaml:"-"` + SelectedProvider string `yaml:"selected_provider"` + CurrentModel string `yaml:"current_model"` + APIKeyEnvOverride string `yaml:"api_key_env_override,omitempty"` + Workdir string `yaml:"workdir"` + Shell string `yaml:"shell"` + MaxLoops int `yaml:"max_loops,omitempty"` + ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Tools ToolsConfig `yaml:"tools,omitempty"` } type ProviderConfig struct { @@ -125,6 +126,7 @@ func (c *Config) ApplyDefaultsFrom(defaults Config) { } c.Tools.ApplyDefaults(defaults.Tools) + c.APIKeyEnvOverride = normalizeAPIKeyEnvOverride(c.APIKeyEnvOverride) c.Workdir = normalizeWorkdir(c.Workdir) } @@ -171,6 +173,9 @@ func (c *Config) Validate() error { if !containsModelID(selected.SupportedModels(), c.CurrentModel) { return fmt.Errorf("config: current_model %q is not supported by provider %q", c.CurrentModel, selected.Name) } + if err := validateAPIKeyEnvOverride(c.APIKeyEnvOverride); err != nil { + return fmt.Errorf("config: %w", err) + } if err := c.Tools.Validate(); err != nil { return fmt.Errorf("config: tools: %w", err) } @@ -200,6 +205,14 @@ func (c *Config) ProviderByName(name string) (ProviderConfig, error) { return ProviderConfig{}, fmt.Errorf("config: provider %q not found", name) } +func (c *Config) EffectiveSelectedProviderConfig() (ProviderConfig, error) { + selected, err := c.SelectedProviderConfig() + if err != nil { + return ProviderConfig{}, err + } + return selected.WithAPIKeyEnvOverride(c.APIKeyEnvOverride) +} + func (p ProviderConfig) Validate() error { if strings.TrimSpace(p.Name) == "" { return errors.New("provider name is empty") @@ -240,6 +253,33 @@ func (p ProviderConfig) SupportedModels() []string { return []string{model} } +func (p ProviderConfig) EffectiveAPIKeyEnv(override string) (string, error) { + if err := validateAPIKeyEnvOverride(override); err != nil { + return "", err + } + + if override = normalizeAPIKeyEnvOverride(override); override != "" { + return override, nil + } + + envName := strings.TrimSpace(p.APIKeyEnv) + if envName == "" { + return "", fmt.Errorf("provider %q api_key_env is empty", p.Name) + } + return envName, nil +} + +func (p ProviderConfig) WithAPIKeyEnvOverride(override string) (ProviderConfig, error) { + envName, err := p.EffectiveAPIKeyEnv(override) + if err != nil { + return ProviderConfig{}, err + } + + next := p + next.APIKeyEnv = envName + return next, nil +} + func (p ProviderConfig) ResolveAPIKey() (string, error) { envName := strings.TrimSpace(p.APIKeyEnv) if envName == "" { @@ -302,6 +342,26 @@ func mergeProviderDefaults(provider ProviderConfig, defaults []ProviderConfig) P return provider } +func normalizeAPIKeyEnvOverride(value string) string { + return strings.TrimSpace(value) +} + +func validateAPIKeyEnvOverride(value string) error { + value = normalizeAPIKeyEnvOverride(value) + if value == "" { + return nil + } + if strings.Contains(value, "=") { + return errors.New("api_key_env_override must not contain =") + } + for _, r := range value { + if r == ' ' || r == '\t' || r == '\n' || r == '\r' { + return errors.New("api_key_env_override must not contain whitespace") + } + } + return nil +} + func matchDefaultProvider(provider ProviderConfig, defaults []ProviderConfig) (ProviderConfig, bool) { name := strings.ToLower(strings.TrimSpace(provider.Name)) if name == "" { diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 3bda125d..7504ba2d 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -3,6 +3,7 @@ package provider_test import ( "context" "errors" + "strings" "testing" "neo-code/internal/config" @@ -220,6 +221,60 @@ func TestServiceSelectProviderAndSetCurrentModel(t *testing.T) { } } +func TestServiceSetAPIKeyEnvOverridePersistsAcrossReloadAndSwitches(t *testing.T) { + defaults := builtin.DefaultConfig() + defaults.Providers = append(defaults.Providers, config.ProviderConfig{ + Name: "custom-main", + Driver: "custom", + BaseURL: "https://example.com", + Model: "custom-model", + Models: []string{"custom-model", "custom-alt"}, + APIKeyEnv: "CUSTOM_PROVIDER_KEY", + }) + manager := newTestManagerWithDefaults(t, defaults) + registry := newTestRegistry(t) + if err := registry.Register(stubDriver("custom")); err != nil { + t.Fatalf("register stub driver: %v", err) + } + + service := provider.NewService(manager, registry) + if err := service.SetAPIKeyEnvOverride(context.Background(), " GLOBAL_OVERRIDE_KEY "); err != nil { + t.Fatalf("SetAPIKeyEnvOverride() error = %v", err) + } + + cfg := manager.Get() + if cfg.APIKeyEnvOverride != "GLOBAL_OVERRIDE_KEY" { + t.Fatalf("expected override to be normalized, got %q", cfg.APIKeyEnvOverride) + } + + if _, err := service.SelectProvider(context.Background(), "custom-main"); err != nil { + t.Fatalf("SelectProvider() error = %v", err) + } + if _, err := service.SetCurrentModel(context.Background(), "custom-alt"); err != nil { + t.Fatalf("SetCurrentModel() error = %v", err) + } + + reloaded, err := manager.Reload(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + if reloaded.APIKeyEnvOverride != "GLOBAL_OVERRIDE_KEY" { + t.Fatalf("expected override to persist after reload, got %q", reloaded.APIKeyEnvOverride) + } + if reloaded.SelectedProvider != "custom-main" || reloaded.CurrentModel != "custom-alt" { + t.Fatalf("expected provider/model selection to persist, got provider=%q model=%q", reloaded.SelectedProvider, reloaded.CurrentModel) + } +} + +func TestServiceSetAPIKeyEnvOverrideRejectsInvalidInput(t *testing.T) { + service := provider.NewService(newTestManager(t), newTestRegistry(t)) + + err := service.SetAPIKeyEnvOverride(context.Background(), "CUSTOM KEY") + if err == nil || (!strings.Contains(err.Error(), "whitespace") && !strings.Contains(err.Error(), "api_key_env_override")) { + t.Fatalf("expected override validation error, got %v", err) + } +} + func TestServiceModelOperationsUseProviderConfigEvenWithoutDriver(t *testing.T) { t.Parallel() diff --git a/internal/provider/service.go b/internal/provider/service.go index d0ae3ae6..69dfb93e 100644 --- a/internal/provider/service.go +++ b/internal/provider/service.go @@ -125,6 +125,17 @@ func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (Provider return selection, nil } +func (s *Service) SetAPIKeyEnvOverride(ctx context.Context, envName string) error { + if err := s.validate(); err != nil { + return err + } + + return s.manager.Update(ctx, func(cfg *config.Config) error { + cfg.APIKeyEnvOverride = envName + return nil + }) +} + func containsModel(models []string, modelID string) bool { target := normalizeKey(modelID) for _, model := range models { diff --git a/internal/tui/app.go b/internal/tui/app.go index 37c4204b..9ebad655 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -27,6 +28,7 @@ type App struct { sessions list.Model providerPicker list.Model modelPicker list.Model + apiKeyInput textinput.Model transcript viewport.Model input textarea.Model activeMessages []provider.Message @@ -84,6 +86,12 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime spin.Spinner = spinner.Line spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorPrimary)) + apiKeyInput := textinput.New() + apiKeyInput.Prompt = "" + apiKeyInput.Placeholder = "Enter API key env name" + apiKeyInput.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) + apiKeyInput.CharLimit = 256 + h := help.New() h.ShowAll = false @@ -92,6 +100,7 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime StatusText: statusReady, CurrentProvider: cfg.SelectedProvider, CurrentModel: cfg.CurrentModel, + APIKeyEnvOverride: cfg.APIKeyEnvOverride, CurrentWorkdir: cfg.Workdir, ActiveSessionTitle: draftSessionTitle, Focus: panelInput, @@ -105,6 +114,7 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime sessions: sessionList, providerPicker: newProviderPicker(nil), modelPicker: newModelPicker(nil), + apiKeyInput: apiKeyInput, transcript: viewport.New(0, 0), input: input, focus: panelInput, diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 4aacf6a1..364f37be 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -15,15 +15,19 @@ const ( slashPrefix = "/" slashCommandProviderPick = "/provider" slashCommandModelPicker = "/model" + slashCommandAPIKeyInput = "/apikey" slashUsageProvider = "/provider" slashUsageModel = "/model" + slashUsageAPIKey = "/apikey" commandMenuTitle = "Commands" providerPickerTitle = "Select Provider" providerPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" modelPickerTitle = "Select Model" modelPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" + apiKeyInputTitle = "Set API Key Env" + apiKeyInputSubtitle = "Enter save, Esc cancel, leave empty to restore default" sidebarTitle = "Sessions" sidebarFilterHint = "Type / to search" @@ -46,6 +50,7 @@ const ( statusRunning = "Running" statusChooseProvider = "Choose a provider" statusChooseModel = "Choose a model" + statusEditAPIKeyEnv = "Set API key env" focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" @@ -76,6 +81,7 @@ type commandSuggestion struct { var builtinSlashCommands = []slashCommand{ {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, + {Usage: slashUsageAPIKey, Description: "Open the API key env input"}, } func newSelectionPicker(items []list.Item) list.Model { @@ -154,6 +160,16 @@ func (a *App) openModelPicker() { a.selectCurrentModel(a.state.CurrentModel) } +func (a *App) openAPIKeyInput() { + a.state.ActivePicker = pickerAPIKey + a.state.StatusText = statusEditAPIKeyEnv + a.apiKeyInput.SetValue(a.state.APIKeyEnvOverride) + a.apiKeyInput.Placeholder = fallback(a.state.CurrentAPIKeyEnv, "Enter API key env name") + a.focus = panelInput + a.applyFocus() + a.apiKeyInput.CursorEnd() +} + func (a *App) closePicker() { a.state.ActivePicker = pickerNone a.focus = panelInput @@ -228,3 +244,20 @@ func runModelSelection(providerSvc ProviderController, modelID string) tea.Cmd { } } } + +func runAPIKeyEnvSelection(providerSvc ProviderController, envName string) tea.Cmd { + return func() tea.Msg { + trimmed := strings.TrimSpace(envName) + if err := providerSvc.SetAPIKeyEnvOverride(context.Background(), trimmed); err != nil { + return localCommandResultMsg{err: err} + } + if trimmed == "" { + return localCommandResultMsg{ + notice: "[System] API key env restored to provider default.", + } + } + return localCommandResultMsg{ + notice: fmt.Sprintf("[System] API key env override set to %s.", trimmed), + } + } +} diff --git a/internal/tui/provider_service.go b/internal/tui/provider_service.go index c2a5ea1e..9c1d272a 100644 --- a/internal/tui/provider_service.go +++ b/internal/tui/provider_service.go @@ -11,4 +11,5 @@ type ProviderController interface { SelectProvider(ctx context.Context, providerID string) (provider.ProviderSelection, error) ListModels(ctx context.Context) ([]provider.ModelDescriptor, error) SetCurrentModel(ctx context.Context, modelID string) (provider.ProviderSelection, error) + SetAPIKeyEnvOverride(ctx context.Context, envName string) error } diff --git a/internal/tui/state.go b/internal/tui/state.go index 0639cf9c..931d1c19 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -28,6 +28,7 @@ const ( pickerNone pickerMode = iota pickerProvider pickerModel + pickerAPIKey ) type UIState struct { @@ -42,6 +43,8 @@ type UIState struct { StatusText string CurrentProvider string CurrentModel string + CurrentAPIKeyEnv string + APIKeyEnvOverride string CurrentWorkdir string ShowHelp bool ActivePicker pickerMode diff --git a/internal/tui/update.go b/internal/tui/update.go index fe6a9117..d5ee8392 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -212,6 +212,9 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te } a.openModelPicker() return a, tea.Batch(cmds...) + case slashCommandAPIKeyInput: + a.openAPIKeyInput() + return a, tea.Batch(cmds...) } if strings.HasPrefix(input, slashPrefix) { @@ -263,6 +266,10 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, nil } return a, runModelSelection(a.providerSvc, item.id) + case pickerAPIKey: + value := a.apiKeyInput.Value() + a.closePicker() + return a, runAPIKeyEnvSelection(a.providerSvc, value) } } @@ -272,6 +279,8 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.providerPicker, cmd = a.providerPicker.Update(msg) case pickerModel: a.modelPicker, cmd = a.modelPicker.Update(msg) + case pickerAPIKey: + a.apiKeyInput, cmd = a.apiKeyInput.Update(msg) } return a, cmd } @@ -359,7 +368,13 @@ func (a *App) syncActiveSessionTitle() { func (a *App) syncConfigState(cfg config.Config) { a.state.CurrentProvider = cfg.SelectedProvider a.state.CurrentModel = cfg.CurrentModel + a.state.APIKeyEnvOverride = cfg.APIKeyEnvOverride a.state.CurrentWorkdir = cfg.Workdir + if providerCfg, err := cfg.EffectiveSelectedProviderConfig(); err == nil { + a.state.CurrentAPIKeyEnv = providerCfg.APIKeyEnv + } else { + a.state.CurrentAPIKeyEnv = "" + } } func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) { @@ -511,6 +526,12 @@ func (a *App) focusPrev() { func (a *App) applyFocus() { a.state.Focus = a.focus + if a.state.ActivePicker == pickerAPIKey { + a.input.Blur() + a.apiKeyInput.Focus() + return + } + a.apiKeyInput.Blur() if a.focus == panelInput && a.state.ActivePicker == pickerNone { a.input.Focus() return @@ -535,6 +556,7 @@ func (a *App) resizeComponents() { a.transcript.Height = max(6, lay.rightHeight-menuHeight-promptHeight) a.providerPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) a.modelPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) + a.apiKeyInput.Width = max(20, clamp(lay.rightWidth-20, 24, 48)) a.updateTranscriptRect(lay) a.rebuildTranscript() } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 314d3611..070b2c4a 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -123,6 +123,25 @@ func TestAppUpdateComposerCommands(t *testing.T) { } }, }, + { + name: "apikey command opens input and does not start runtime", + input: "/apikey", + assert: func(t *testing.T, runtime *stubRuntime, manager *config.Manager, app App) { + t.Helper() + if len(runtime.runInputs) != 0 { + t.Fatalf("expected runtime not to run, got %d calls", len(runtime.runInputs)) + } + if app.state.ActivePicker != pickerAPIKey { + t.Fatalf("expected api key input to open") + } + if app.state.StatusText != statusEditAPIKeyEnv { + t.Fatalf("expected status %q, got %q", statusEditAPIKeyEnv, app.state.StatusText) + } + if app.apiKeyInput.Placeholder == "" { + t.Fatalf("expected api key input placeholder to be populated") + } + }, + }, } for _, tt := range tests { @@ -176,6 +195,26 @@ func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { } }, }, + { + name: "escape closes api key input", + setup: func(t *testing.T, app *App, runtime *stubRuntime) { + app.openAPIKeyInput() + app.apiKeyInput.SetValue("CUSTOM_OPENAI_KEY") + }, + msg: tea.KeyMsg{Type: tea.KeyEsc}, + assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { + t.Helper() + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected api key input to close") + } + if app.state.Focus != panelInput { + t.Fatalf("expected focus to return to input") + } + if app.apiKeyInput.Value() != "CUSTOM_OPENAI_KEY" { + t.Fatalf("expected api key input value to stay local on cancel, got %q", app.apiKeyInput.Value()) + } + }, + }, { name: "runtime chunk appends assistant draft", setup: func(t *testing.T, app *App, runtime *stubRuntime) { @@ -317,6 +356,8 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { app.openModelPicker() app.closePicker() + app.openAPIKeyInput() + app.closePicker() app.selectCurrentModel(provideropenai.DefaultModel) app.appendAssistantChunk("hello") app.appendAssistantChunk(" world") @@ -358,6 +399,10 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if app.renderPicker(48, 12) == "" || app.renderWaterfall(80, 20) == "" { t.Fatalf("expected model picker rendering") } + app.state.ActivePicker = pickerAPIKey + if app.renderPicker(48, 12) == "" || app.renderWaterfall(80, 20) == "" { + t.Fatalf("expected api key picker rendering") + } app.state.ActivePicker = pickerNone if app.renderCommandMenu(80) == "" { app.input.SetValue("/") @@ -511,6 +556,14 @@ func TestTUIStandaloneHelpers(t *testing.T) { if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil { t.Fatalf("expected successful localCommandResultMsg, got %+v", msg) } + msg = runAPIKeyEnvSelection(newTestProviderService(t, manager), "CUSTOM_OPENAI_KEY")() + if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil || !strings.Contains(result.notice, "CUSTOM_OPENAI_KEY") { + t.Fatalf("expected successful api key env result, got %+v", msg) + } + msg = runAPIKeyEnvSelection(newTestProviderService(t, manager), " ")() + if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil || !strings.Contains(result.notice, "restored") { + t.Fatalf("expected restore-default result, got %+v", msg) + } _ = model } @@ -895,6 +948,82 @@ func TestAppUpdateProviderPickerEnterAppliesSelection(t *testing.T) { } } +func TestAppUpdateAPIKeyPickerEnterAppliesOverride(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.openAPIKeyInput() + app.apiKeyInput.SetValue("CUSTOM_OPENAI_KEY") + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected picker to close after selection") + } + + for _, msg := range collectTeaMessages(cmd) { + model, follow := app.Update(msg) + app = model.(App) + _ = collectTeaMessages(follow) + } + + cfg := manager.Get() + if cfg.APIKeyEnvOverride != "CUSTOM_OPENAI_KEY" { + t.Fatalf("expected override to persist, got %q", cfg.APIKeyEnvOverride) + } + if app.state.CurrentAPIKeyEnv != "CUSTOM_OPENAI_KEY" { + t.Fatalf("expected current api key env to refresh, got %q", app.state.CurrentAPIKeyEnv) + } + if !strings.Contains(app.state.StatusText, "CUSTOM_OPENAI_KEY") { + t.Fatalf("expected success status to mention override, got %q", app.state.StatusText) + } +} + +func TestAppUpdateAPIKeyPickerEnterClearsOverride(t *testing.T) { + manager := newTestConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.APIKeyEnvOverride = "CUSTOM_OPENAI_KEY" + return nil + }); err != nil { + t.Fatalf("seed api key override: %v", err) + } + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.openAPIKeyInput() + app.apiKeyInput.SetValue(" ") + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected picker to close after clearing override") + } + + for _, msg := range collectTeaMessages(cmd) { + model, follow := app.Update(msg) + app = model.(App) + _ = collectTeaMessages(follow) + } + + cfg := manager.Get() + if cfg.APIKeyEnvOverride != "" { + t.Fatalf("expected override to clear, got %q", cfg.APIKeyEnvOverride) + } + if app.state.CurrentAPIKeyEnv != provideropenai.DefaultAPIKeyEnv { + t.Fatalf("expected current api key env to fall back to provider default, got %q", app.state.CurrentAPIKeyEnv) + } + if !strings.Contains(strings.ToLower(app.state.StatusText), "default") { + t.Fatalf("expected restore-default status, got %q", app.state.StatusText) + } +} + func TestRefreshPickerKeepsSizeAfterRebuild(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() diff --git a/internal/tui/view.go b/internal/tui/view.go index 32ec3273..69d78a8b 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -159,6 +159,10 @@ func (a App) renderPicker(width int, height int) string { title = providerPickerTitle subtitle = providerPickerSubtitle body = a.providerPicker.View() + } else if a.state.ActivePicker == pickerAPIKey { + title = apiKeyInputTitle + subtitle = apiKeyInputSubtitle + body = a.apiKeyInput.View() } content := lipgloss.JoinVertical( lipgloss.Left, From 93a2f3f92c3f7bcfefce1a6a73f1373a975b5617 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 30 Mar 2026 18:00:13 +0800 Subject: [PATCH 05/11] =?UTF-8?q?add:=E5=A2=9E=E5=8A=A0=E4=B8=83=E7=89=9B?= =?UTF-8?q?=E4=BA=91=E4=BE=9B=E5=BA=94=E5=95=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +++ docs/config-management-detail-design.md | 1 + internal/provider/builtin/builtin.go | 2 ++ internal/provider/builtin/builtin_test.go | 8 +++-- internal/provider/qiniuyun/qiniuyun.go | 32 ++++++++++++++++++ internal/provider/qiniuyun/qiniuyun_test.go | 37 +++++++++++++++++++++ internal/provider/registry_test.go | 8 +++-- 7 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 internal/provider/qiniuyun/qiniuyun.go create mode 100644 internal/provider/qiniuyun/qiniuyun_test.go diff --git a/README.md b/README.md index b5ab65b2..3df7871f 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ NeoCode 是一个基于 Go 和 Bubble Tea 的本地 Coding Agent MVP。 | `openai` | `gpt-4.1` | `OPENAI_API_KEY` | 默认配置 | | `gemini` | `gemini-2.5-flash` | `GEMINI_API_KEY` | 走 OpenAI-compatible 接口 | | `openll` | `gpt-4.1` | `AI_API_KEY` | 自定义 OpenAI-compatible 入口 | +| `qiniuyun` | `deepseek/deepseek-v3.2-251201` | `QINIUYUN_API_KEY` | 七牛云 OpenAI-compatible 入口 | ## 快速开始 @@ -74,6 +75,7 @@ OPENAI_API_KEY=your-api-key - `OPENAI_API_KEY` - `GEMINI_API_KEY` - `AI_API_KEY` +- `QINIUYUN_API_KEY` 你也可以在进入 TUI 后通过 `/apikey` 打开输入框,自定义当前运行时优先读取的 API Key 环境变量名。留空确认会恢复为当前 Provider 的默认环境变量名。 @@ -185,6 +187,8 @@ tools: Provider 的 `base_url`、默认模型列表和 `api_key_env` 由代码内建定义提供,不需要你在 YAML 中手动维护;如果你想全局覆盖当前运行时读取的环境变量名,可以设置顶层的 `api_key_env_override`,留空则回退到当前 Provider 的默认值。 +当前代码内建的 Provider 包括 `openai`、`gemini`、`openll` 和 `qiniuyun`。其中 `qiniuyun` 预置的模型目录为 `z-ai/glm-5`、`minimax/minimax-m2.5`、`moonshotai/kimi-k2.5` 与 `deepseek/deepseek-v3.2-251201`。 + ## 工具与安全边界 NeoCode 当前默认遵守这些约束: diff --git a/docs/config-management-detail-design.md b/docs/config-management-detail-design.md index d27d258e..8aa4fda0 100644 --- a/docs/config-management-detail-design.md +++ b/docs/config-management-detail-design.md @@ -25,6 +25,7 @@ ## 默认值治理 - 默认 Provider 名称、URL、默认模型、模型列表和环境变量名统一由内建 Provider 定义提供 +- 当前内建 Provider 包括 `openai`、`gemini`、`openll` 和 `qiniuyun`;其中 `qiniuyun` 复用 OpenAI-compatible driver,并默认读取 `QINIUYUN_API_KEY` - `Loader` 在加载旧配置时会丢弃 `providers` / `provider_overrides`,重新回到“YAML 只保存选择状态和顶层 override”的最小结构 - Provider 的可选模型目录属于实例配置,进入运行时 `Config` 后再提供给 TUI 和 runtime,避免 TUI 或 driver 自己维护一套零散常量 diff --git a/internal/provider/builtin/builtin.go b/internal/provider/builtin/builtin.go index 3d4fcb61..5ac7e214 100644 --- a/internal/provider/builtin/builtin.go +++ b/internal/provider/builtin/builtin.go @@ -8,6 +8,7 @@ import ( "neo-code/internal/provider/gemini" "neo-code/internal/provider/openai" "neo-code/internal/provider/openll" + "neo-code/internal/provider/qiniuyun" ) func DefaultConfig() *config.Config { @@ -17,6 +18,7 @@ func DefaultConfig() *config.Config { defaultProvider, gemini.BuiltinConfig(), openll.BuiltinConfig(), + qiniuyun.BuiltinConfig(), } cfg.SelectedProvider = defaultProvider.Name cfg.CurrentModel = defaultProvider.Model diff --git a/internal/provider/builtin/builtin_test.go b/internal/provider/builtin/builtin_test.go index c047c335..3df8e7f9 100644 --- a/internal/provider/builtin/builtin_test.go +++ b/internal/provider/builtin/builtin_test.go @@ -6,14 +6,15 @@ import ( "neo-code/internal/provider/gemini" "neo-code/internal/provider/openai" "neo-code/internal/provider/openll" + "neo-code/internal/provider/qiniuyun" ) func TestDefaultConfigIncludesBuiltinProviders(t *testing.T) { t.Parallel() cfg := DefaultConfig() - if len(cfg.Providers) != 3 { - t.Fatalf("expected 3 builtin providers, got %d", len(cfg.Providers)) + if len(cfg.Providers) != 4 { + t.Fatalf("expected 4 builtin providers, got %d", len(cfg.Providers)) } if cfg.Providers[0].Name != openai.Name { t.Fatalf("expected first provider %q, got %q", openai.Name, cfg.Providers[0].Name) @@ -24,6 +25,9 @@ func TestDefaultConfigIncludesBuiltinProviders(t *testing.T) { if cfg.Providers[2].Name != openll.Name { t.Fatalf("expected third provider %q, got %q", openll.Name, cfg.Providers[2].Name) } + if cfg.Providers[3].Name != qiniuyun.Name { + t.Fatalf("expected fourth provider %q, got %q", qiniuyun.Name, cfg.Providers[3].Name) + } if cfg.SelectedProvider != openai.Name { t.Fatalf("expected selected provider %q, got %q", openai.Name, cfg.SelectedProvider) } diff --git a/internal/provider/qiniuyun/qiniuyun.go b/internal/provider/qiniuyun/qiniuyun.go new file mode 100644 index 00000000..b73392b6 --- /dev/null +++ b/internal/provider/qiniuyun/qiniuyun.go @@ -0,0 +1,32 @@ +package qiniuyun + +import ( + "neo-code/internal/config" + "neo-code/internal/provider/openai" +) + +const ( + Name = "qiniuyun" + DriverName = openai.DriverName + DefaultBaseURL = "https://api.qnaigc.com/v1" + DefaultModel = "deepseek/deepseek-v3.2-251201" + DefaultAPIKeyEnv = "QINIUYUN_API_KEY" +) + +var builtinModels = []string{ + "z-ai/glm-5", + "minimax/minimax-m2.5", + "moonshotai/kimi-k2.5", + DefaultModel, +} + +func BuiltinConfig() config.ProviderConfig { + return config.ProviderConfig{ + Name: Name, + Driver: DriverName, + BaseURL: DefaultBaseURL, + Model: DefaultModel, + Models: append([]string(nil), builtinModels...), + APIKeyEnv: DefaultAPIKeyEnv, + } +} diff --git a/internal/provider/qiniuyun/qiniuyun_test.go b/internal/provider/qiniuyun/qiniuyun_test.go new file mode 100644 index 00000000..6ac7395c --- /dev/null +++ b/internal/provider/qiniuyun/qiniuyun_test.go @@ -0,0 +1,37 @@ +package qiniuyun + +import ( + "testing" + + "neo-code/internal/provider/openai" +) + +func TestBuiltinConfigUsesOpenAICompatibleDriver(t *testing.T) { + t.Parallel() + + cfg := BuiltinConfig() + if cfg.Name != Name { + t.Fatalf("expected provider name %q, got %q", Name, cfg.Name) + } + if cfg.Driver != openai.DriverName { + t.Fatalf("expected driver %q, got %q", openai.DriverName, cfg.Driver) + } + if cfg.BaseURL != DefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", DefaultBaseURL, cfg.BaseURL) + } + if cfg.Model != DefaultModel { + t.Fatalf("expected default model %q, got %q", DefaultModel, cfg.Model) + } + if cfg.APIKeyEnv != DefaultAPIKeyEnv { + t.Fatalf("expected API key env %q, got %q", DefaultAPIKeyEnv, cfg.APIKeyEnv) + } + if len(cfg.Models) != 4 { + t.Fatalf("expected 4 builtin models, got %+v", cfg.Models) + } + if cfg.Models[0] != "z-ai/glm-5" { + t.Fatalf("expected first builtin model %q, got %+v", "z-ai/glm-5", cfg.Models) + } + if cfg.Models[len(cfg.Models)-1] != DefaultModel { + t.Fatalf("expected builtin models to include default model %q, got %+v", DefaultModel, cfg.Models) + } +} diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 7504ba2d..2056dd58 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -12,6 +12,7 @@ import ( "neo-code/internal/provider/gemini" "neo-code/internal/provider/openai" "neo-code/internal/provider/openll" + "neo-code/internal/provider/qiniuyun" ) type stubProvider struct{} @@ -125,9 +126,10 @@ func TestServiceListProvidersUsesConfiguredMetadata(t *testing.T) { t.Fatalf("ListProviders() error = %v", err) } expectedModels := map[string]int{ - openai.Name: len(openai.BuiltinConfig().Models), - gemini.Name: len(gemini.BuiltinConfig().Models), - openll.Name: len(openll.BuiltinConfig().Models), + openai.Name: len(openai.BuiltinConfig().Models), + gemini.Name: len(gemini.BuiltinConfig().Models), + openll.Name: len(openll.BuiltinConfig().Models), + qiniuyun.Name: len(qiniuyun.BuiltinConfig().Models), } if len(items) != len(expectedModels) { t.Fatalf("expected only supported providers, got %d", len(items)) From 58a2e8371533d3549a82e0616447d61a685974fb Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 30 Mar 2026 14:16:59 +0800 Subject: [PATCH 06/11] =?UTF-8?q?feat(tui):=20=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E8=A1=8C=E8=BE=93=E5=85=A5=E4=B8=8E=E4=BB=A3=E7=A0=81=E5=9D=97?= =?UTF-8?q?=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/app.go | 23 +++-- internal/tui/interaction.go | 117 +++++++++++++++++++++++++ internal/tui/keymap.go | 32 +++---- internal/tui/state.go | 21 +++++ internal/tui/styles.go | 13 ++- internal/tui/transcript.go | 110 ++++++++++++++++++++++++ internal/tui/update.go | 29 ++++++- internal/tui/update_test.go | 107 ++++++++++++++++++++++- internal/tui/view.go | 165 ++++++++++++++++++++++++------------ 9 files changed, 532 insertions(+), 85 deletions(-) create mode 100644 internal/tui/interaction.go create mode 100644 internal/tui/transcript.go diff --git a/internal/tui/app.go b/internal/tui/app.go index fbfcd4bd..37c4204b 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -6,7 +6,7 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -28,8 +28,10 @@ type App struct { providerPicker list.Model modelPicker list.Model transcript viewport.Model - input textinput.Model + input textarea.Model activeMessages []provider.Message + codeBlocks []codeBlockTarget + transcriptRect rect focus panel width int height int @@ -63,13 +65,18 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime sessionList.FilterInput.Prompt = "Filter: " sessionList.FilterInput.Placeholder = "Type to search sessions" - input := textinput.New() + input := textarea.New() input.Placeholder = "Ask NeoCode to inspect, edit, or build. Type / to browse commands." - input.Prompt = "" + input.Prompt = "> " input.CharLimit = 24000 - input.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) - input.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) - input.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorSubtle)) + input.ShowLineNumbers = false + input.EndOfBufferCharacter = ' ' + input.SetHeight(1) + input.FocusedStyle.Base = lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) + input.FocusedStyle.Text = lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) + input.FocusedStyle.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color(colorSubtle)) + input.FocusedStyle.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)).Bold(true) + input.BlurredStyle = input.FocusedStyle input.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) input.Focus() @@ -130,5 +137,5 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime } func (a App) Init() tea.Cmd { - return tea.Batch(ListenForRuntimeEvent(a.runtime.Events()), textinput.Blink, a.spinner.Tick) + return tea.Batch(ListenForRuntimeEvent(a.runtime.Events()), textarea.Blink, a.spinner.Tick) } diff --git a/internal/tui/interaction.go b/internal/tui/interaction.go new file mode 100644 index 00000000..1ea5c992 --- /dev/null +++ b/internal/tui/interaction.go @@ -0,0 +1,117 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var writeClipboard = clipboard.WriteAll + +func (a *App) shouldSendInput(msg tea.KeyMsg) bool { + return key.Matches(msg, a.keys.Send) || msg.String() == "ctrl+enter" +} + +func (a *App) handleMouse(msg tea.MouseMsg) { + if !a.transcriptRect.contains(msg.X, msg.Y) { + return + } + + switch msg.Button { //nolint:exhaustive + case tea.MouseButtonWheelUp: + if msg.Action == tea.MouseActionPress { + a.focus = panelTranscript + a.applyFocus() + a.transcript.LineUp(3) + } + case tea.MouseButtonWheelDown: + if msg.Action == tea.MouseActionPress { + a.focus = panelTranscript + a.applyFocus() + a.transcript.LineDown(3) + } + case tea.MouseButtonLeft: + if msg.Action != tea.MouseActionPress { + return + } + if target := a.hitCodeBlockTarget(msg.X, msg.Y); target != nil { + a.copyCodeBlock(*target) + return + } + a.focus = panelTranscript + a.applyFocus() + } +} + +func (a *App) hitCodeBlockTarget(mouseX int, mouseY int) *codeBlockTarget { + if !a.transcriptRect.contains(mouseX, mouseY) { + return nil + } + + contentLine := a.transcript.YOffset + (mouseY - a.transcriptRect.Y) + contentX := mouseX - a.transcriptRect.X + for i := range a.codeBlocks { + target := &a.codeBlocks[i] + if target.Line != contentLine { + continue + } + if contentX >= target.X && contentX < target.X+target.Width { + return target + } + } + return nil +} + +func (a *App) copyCodeBlock(target codeBlockTarget) { + if err := writeClipboard(target.Content); err != nil { + notice := fmt.Sprintf("Copy failed: %v", err) + a.state.ExecutionError = notice + a.state.StatusText = notice + a.appendInlineMessage(roleError, notice) + a.rebuildTranscript() + return + } + + label := "code block" + if trimmed := strings.TrimSpace(target.Language); trimmed != "" && !strings.EqualFold(trimmed, "text") { + label = trimmed + " code block" + } + notice := fmt.Sprintf("Copied %s.", label) + a.state.ExecutionError = "" + a.state.StatusText = notice + a.appendInlineMessage(roleSystem, notice) + a.rebuildTranscript() +} + +func (a *App) composerRows(availableWidth int) int { + width := max(8, availableWidth-lipgloss.Width(a.input.Prompt)) + lines := wrappedLineCount(a.input.Value(), width) + if strings.TrimSpace(a.input.Value()) == "" { + lines = max(lines, 1) + } + return clamp(lines, 1, 8) +} + +func (a *App) updateTranscriptRect(lay layout) { + docX := a.styles.doc.GetPaddingLeft() + docY := a.styles.doc.GetPaddingTop() + headerHeight := lipgloss.Height(a.renderHeader(lay.contentWidth)) + rightX := docX + rightY := docY + headerHeight + if lay.stacked { + rightY += lay.sidebarHeight + } else { + rightX += lay.sidebarWidth + lay.bodyGap + } + + a.transcriptRect = rect{ + X: rightX, + Y: rightY, + Width: a.transcript.Width, + Height: a.transcript.Height, + } +} diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index 891be1a8..d17afad9 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -23,64 +23,64 @@ type keyMap struct { func newKeyMap() keyMap { return keyMap{ Send: key.NewBinding( - key.WithKeys("enter", "ctrl+s"), - key.WithHelp("Enter/Ctrl+S", "发送(输入框)"), + key.WithKeys("ctrl+enter", "ctrl+s"), + key.WithHelp("Ctrl+Enter/Ctrl+S", "send"), ), CancelAgent: key.NewBinding( key.WithKeys("ctrl+w"), - key.WithHelp("Ctrl+w", "中止"), + key.WithHelp("Ctrl+W", "cancel"), ), NewSession: key.NewBinding( key.WithKeys("ctrl+n"), - key.WithHelp("Ctrl+N", "新会话"), + key.WithHelp("Ctrl+N", "new"), ), NextPanel: key.NewBinding( key.WithKeys("tab"), - key.WithHelp("Tab", "下个面板"), + key.WithHelp("Tab", "next panel"), ), PrevPanel: key.NewBinding( key.WithKeys("shift+tab"), - key.WithHelp("Shift+Tab", "上个面板"), + key.WithHelp("Shift+Tab", "prev panel"), ), FocusInput: key.NewBinding( key.WithKeys("esc"), - key.WithHelp("Esc", "聚焦输入框"), + key.WithHelp("Esc", "focus input"), ), OpenSession: key.NewBinding( key.WithKeys("enter"), - key.WithHelp("Enter", "打开会话"), + key.WithHelp("Enter", "open session"), ), ToggleHelp: key.NewBinding( key.WithKeys("ctrl+q"), - key.WithHelp("Ctrl+Q", "帮助"), + key.WithHelp("Ctrl+Q", "help"), ), Quit: key.NewBinding( key.WithKeys("ctrl+u"), - key.WithHelp("Ctrl+U", "退出"), + key.WithHelp("Ctrl+U", "quit"), ), ScrollUp: key.NewBinding( key.WithKeys("up", "k"), - key.WithHelp("Up/k", "向上滚动"), + key.WithHelp("Up/k", "scroll up"), ), ScrollDown: key.NewBinding( key.WithKeys("down", "j"), - key.WithHelp("Down/j", "向下滚动"), + key.WithHelp("Down/j", "scroll down"), ), PageUp: key.NewBinding( key.WithKeys("pgup", "b"), - key.WithHelp("PgUp/b", "向上翻页"), + key.WithHelp("PgUp/b", "page up"), ), PageDown: key.NewBinding( key.WithKeys("pgdown", "f"), - key.WithHelp("PgDn/f", "向下翻页"), + key.WithHelp("PgDn/f", "page down"), ), Top: key.NewBinding( key.WithKeys("g", "home"), - key.WithHelp("g/Home", "跳到顶部"), + key.WithHelp("g/Home", "top"), ), Bottom: key.NewBinding( key.WithKeys("G", "end"), - key.WithHelp("G/End", "跳到底部"), + key.WithHelp("G/End", "bottom"), ), } } diff --git a/internal/tui/state.go b/internal/tui/state.go index 2bd56b35..0639cf9c 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -56,6 +56,27 @@ type activityEntry struct { IsError bool } +type rect struct { + X int + Y int + Width int + Height int +} + +func (r rect) contains(x int, y int) bool { + return x >= r.X && x < r.X+r.Width && y >= r.Y && y < r.Y+r.Height +} + +type codeBlockTarget struct { + MessageIndex int + BlockIndex int + Language string + Content string + Line int + X int + Width int +} + type sessionItem struct { Summary agentruntime.SessionSummary Active bool diff --git a/internal/tui/styles.go b/internal/tui/styles.go index a0326dcb..db50b771 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -55,6 +55,9 @@ type styles struct { inlineNotice lipgloss.Style inlineError lipgloss.Style inlineSystem lipgloss.Style + codeHeader lipgloss.Style + codeLanguage lipgloss.Style + codeCopy lipgloss.Style codeBlock lipgloss.Style codeText lipgloss.Style commandMenu lipgloss.Style @@ -176,8 +179,16 @@ func newStyles() styles { Foreground(lipgloss.Color(colorSubtle)). Background(lipgloss.Color(colorPanel)). Padding(0, 1), + codeHeader: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)). + Background(lipgloss.Color(colorPanelAlt)), + codeLanguage: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorPrimary)), + codeCopy: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorSuccess)), codeBlock: lipgloss.NewStyle(). - MarginLeft(1). Padding(0, 1). Background(lipgloss.Color(colorCode)). BorderLeft(true). diff --git a/internal/tui/transcript.go b/internal/tui/transcript.go new file mode 100644 index 00000000..0b622e36 --- /dev/null +++ b/internal/tui/transcript.go @@ -0,0 +1,110 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +type messageSegmentKind int + +const ( + segmentText messageSegmentKind = iota + segmentCode +) + +type messageSegment struct { + Kind messageSegmentKind + Content string + Language string +} + +type renderedContent struct { + View string + Height int + CodeBlocks []codeBlockTarget +} + +type renderedMessageBlock struct { + View string + Height int + CodeBlocks []codeBlockTarget +} + +func parseMessageSegments(content string) []messageSegment { + normalized := strings.ReplaceAll(content, "\r\n", "\n") + lines := strings.Split(normalized, "\n") + segments := make([]messageSegment, 0, 4) + buffer := make([]string, 0, len(lines)) + inCode := false + language := "" + + flush := func(kind messageSegmentKind, lang string) { + if len(buffer) == 0 { + return + } + segments = append(segments, messageSegment{ + Kind: kind, + Content: strings.Join(buffer, "\n"), + Language: lang, + }) + buffer = buffer[:0] + } + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") { + if inCode { + flush(segmentCode, language) + inCode = false + language = "" + } else { + flush(segmentText, "") + inCode = true + language = strings.TrimSpace(strings.TrimPrefix(trimmed, "```")) + } + continue + } + buffer = append(buffer, line) + } + + switch { + case len(buffer) == 0: + case inCode: + flush(segmentCode, language) + default: + flush(segmentText, "") + } + + if len(segments) == 0 { + return []messageSegment{{Kind: segmentText, Content: content}} + } + return segments +} + +func wrappedLineCount(text string, width int) int { + if width <= 0 { + return 1 + } + lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + total := 0 + for _, line := range lines { + runes := []rune(line) + if len(runes) == 0 { + total++ + continue + } + total += (len(runes)-1)/width + 1 + } + if total == 0 { + return 1 + } + return total +} + +func padBetween(width int, left string, right string) string { + leftWidth := lipgloss.Width(left) + rightWidth := lipgloss.Width(right) + spaceWidth := max(1, width-leftWidth-rightWidth) + return left + strings.Repeat(" ", spaceWidth) + right +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 37692d10..fe6a9117 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -99,6 +99,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } a.rebuildTranscript() return a, tea.Batch(cmds...) + case tea.MouseMsg: + if a.state.ActivePicker == pickerNone { + a.handleMouse(typed) + } + return a, tea.Batch(cmds...) case tea.KeyMsg: if key.Matches(typed, a.keys.Quit) { return a, tea.Quit @@ -177,7 +182,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (tea.Model, tea.Cmd) { - if key.Matches(typed, a.keys.Send) { + if a.shouldSendInput(typed) { input := strings.TrimSpace(a.input.Value()) if input == "" || a.state.IsAgentRunning { return a, tea.Batch(cmds...) @@ -524,17 +529,20 @@ func (a *App) resizeComponents() { menuHeight := a.commandMenuHeight(max(24, lay.rightWidth)) a.transcript.Width = max(24, lay.rightWidth) promptInnerWidth := max(8, lay.rightWidth-a.styles.inputBoxFocused.GetHorizontalFrameSize()) - a.input.Width = max(4, promptInnerWidth-lipgloss.Width("> ")) + a.input.SetWidth(promptInnerWidth) + a.input.SetHeight(a.composerRows(promptInnerWidth)) promptHeight := lipgloss.Height(a.renderPrompt(a.transcript.Width)) a.transcript.Height = max(6, lay.rightHeight-menuHeight-promptHeight) a.providerPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) a.modelPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) + a.updateTranscriptRect(lay) a.rebuildTranscript() } func (a *App) rebuildTranscript() { width := max(24, a.transcript.Width) if len(a.activeMessages) == 0 { + a.codeBlocks = nil a.transcript.SetContent(a.styles.empty.Width(width).Render(emptyConversationText)) a.transcript.GotoTop() return @@ -542,10 +550,23 @@ func (a *App) rebuildTranscript() { atBottom := a.transcript.AtBottom() blocks := make([]string, 0, len(a.activeMessages)) - for _, message := range a.activeMessages { - blocks = append(blocks, a.renderMessageBlock(message, width)) + targets := make([]codeBlockTarget, 0, 4) + lineOffset := 0 + for i, message := range a.activeMessages { + rendered := a.renderMessageBlockDetailed(message, width) + for _, target := range rendered.CodeBlocks { + target.MessageIndex = i + target.Line += lineOffset + targets = append(targets, target) + } + blocks = append(blocks, rendered.View) + lineOffset += rendered.Height + if i < len(a.activeMessages)-1 { + lineOffset += 2 + } } + a.codeBlocks = targets a.transcript.SetContent(strings.Join(blocks, "\n\n")) if atBottom || a.state.IsAgentRunning { a.transcript.GotoBottom() diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 5a62ad72..52649574 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -138,7 +138,7 @@ func TestAppUpdateComposerCommands(t *testing.T) { app.input.SetValue(tt.input) app.state.InputText = tt.input - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) app = model.(App) for _, msg := range collectTeaMessages(cmd) { @@ -687,6 +687,23 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { } }, }, + { + name: "input enter inserts newline instead of sending", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.input.SetValue("hello") + app.state.InputText = "hello" + }, + msg: tea.KeyMsg{Type: tea.KeyEnter}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if len(runtime.runInputs) != 0 { + t.Fatalf("expected enter to stay local, got %+v", runtime.runInputs) + } + if !strings.Contains(app.state.InputText, "\n") { + t.Fatalf("expected enter to insert a newline, got %q", app.state.InputText) + } + }, + }, { name: "input typing updates composer text", msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}, @@ -703,7 +720,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { app.input.SetValue("inspect repo") app.state.InputText = "inspect repo" }, - msg: tea.KeyMsg{Type: tea.KeyEnter}, + msg: tea.KeyMsg{Type: tea.KeyCtrlS}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if !app.state.IsAgentRunning { @@ -1031,6 +1048,92 @@ func TestAppRefreshErrorPaths(t *testing.T) { }) } +func TestRenderMessageContentPreservesStructureAndCodeTargets(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + rendered := app.renderMessageContentDetailed("line1\n\nline2\n```go\nfmt.Println(\"x\")\n```\nnext", 48, app.styles.messageBody) + if !strings.Contains(rendered.View, "line1") || !strings.Contains(rendered.View, "line2") || !strings.Contains(rendered.View, "next") { + t.Fatalf("expected rendered output to keep plain-text sections") + } + if !strings.Contains(rendered.View, "[ Copy ]") { + t.Fatalf("expected rendered output to include copy affordance") + } + if len(rendered.CodeBlocks) != 1 { + t.Fatalf("expected one code block target, got %d", len(rendered.CodeBlocks)) + } + if rendered.CodeBlocks[0].Language != "go" { + t.Fatalf("expected go language label, got %q", rendered.CodeBlocks[0].Language) + } + if !strings.Contains(rendered.CodeBlocks[0].Content, "fmt.Println") { + t.Fatalf("expected code block content to be preserved, got %q", rendered.CodeBlocks[0].Content) + } +} + +func TestMouseCopyCodeBlockAndWheelScroll(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + originalWriteClipboard := writeClipboard + defer func() { writeClipboard = originalWriteClipboard }() + + var copied string + writeClipboard = func(text string) error { + copied = text + return nil + } + + app.activeMessages = []provider.Message{ + {Role: roleAssistant, Content: "```go\nfmt.Println(\"copy\")\n```"}, + } + app.rebuildTranscript() + if len(app.codeBlocks) != 1 { + t.Fatalf("expected one clickable code block, got %d", len(app.codeBlocks)) + } + + target := app.codeBlocks[0] + mouseCopy := tea.MouseMsg{ + X: app.transcriptRect.X + target.X + 1, + Y: app.transcriptRect.Y + target.Line, + Action: tea.MouseActionPress, + Button: tea.MouseButtonLeft, + } + model, _ := app.Update(mouseCopy) + app = model.(App) + + if !strings.Contains(copied, "fmt.Println(\"copy\")") { + t.Fatalf("expected code block to be copied, got %q", copied) + } + if !strings.Contains(app.state.StatusText, "Copied") { + t.Fatalf("expected copy notice, got %q", app.state.StatusText) + } + + app.transcript.SetContent(strings.Repeat("line\n", 80)) + app.transcript.Height = 5 + app.transcript.GotoTop() + app.transcriptRect = rect{X: 4, Y: 3, Width: app.transcript.Width, Height: 5} + mouseScroll := tea.MouseMsg{ + X: app.transcriptRect.X + 1, + Y: app.transcriptRect.Y + 1, + Action: tea.MouseActionPress, + Button: tea.MouseButtonWheelDown, + } + model, _ = app.Update(mouseScroll) + app = model.(App) + + if app.transcript.YOffset == 0 { + t.Fatalf("expected mouse wheel to scroll transcript") + } +} + func newTestConfigManager(t *testing.T) *config.Manager { t.Helper() manager := config.NewManager(config.NewLoader(t.TempDir(), builtin.DefaultConfig())) diff --git a/internal/tui/view.go b/internal/tui/view.go index a20bc2c4..32ec3273 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -24,7 +24,16 @@ func (a App) View() string { docWidth := max(0, a.width-a.styles.doc.GetHorizontalFrameSize()) docHeight := max(0, a.height-a.styles.doc.GetVerticalFrameSize()) if docWidth < 84 || docHeight < 24 { - return strings.TrimRight(a.styles.doc.Render(lipgloss.Place(docWidth, docHeight, lipgloss.Left, lipgloss.Top, "Window too small.\nPlease resize to at least 84x24.")), "\n") + return strings.TrimRight( + a.styles.doc.Render(lipgloss.Place( + docWidth, + docHeight, + lipgloss.Left, + lipgloss.Top, + "Window too small.\nPlease resize to at least 84x24.", + )), + "\n", + ) } lay := a.computeLayout() @@ -39,7 +48,10 @@ func (a App) View() string { } parts = append(parts, helpView) content := lipgloss.JoinVertical(lipgloss.Left, parts...) - return strings.TrimRight(a.styles.doc.Render(lipgloss.Place(docWidth, docHeight, lipgloss.Left, lipgloss.Top, content)), "\n") + return strings.TrimRight( + a.styles.doc.Render(lipgloss.Place(docWidth, docHeight, lipgloss.Left, lipgloss.Top, content)), + "\n", + ) } func (a App) renderHeader(width int) string { @@ -167,19 +179,8 @@ func (a App) renderPrompt(width int) string { box = a.styles.inputBoxFocused } - // 计算边框和内边距占用的空间 boxWidth := max(8, width-2) - - prefix := a.styles.inputPrefix.Render(">") - inputContent := a.input.View() - - content := lipgloss.JoinHorizontal( - lipgloss.Left, - prefix, - lipgloss.NewStyle().Width(1).Render(" "), - a.styles.inputLine.Width(max(4, boxWidth-2-lipgloss.Width(prefix)-1)).Render(inputContent), - ) - return box.Width(boxWidth).Render(content) + return box.Width(boxWidth).Render(a.input.View()) } func (a App) renderSidebarHeader(width int) string { @@ -223,35 +224,42 @@ func (a App) renderPanel(title string, subtitle string, body string, width int, } func (a App) renderMessageBlock(message provider.Message, width int) string { + return a.renderMessageBlockDetailed(message, width).View +} + +func (a App) renderMessageBlockDetailed(message provider.Message, width int) renderedMessageBlock { switch message.Role { case roleEvent: - return a.styles.inlineNotice.Width(width).Render(" > " + wrapPlain(message.Content, max(16, width-6))) + view := a.styles.inlineNotice.Width(width).Render(" > " + wrapPlain(message.Content, max(16, width-6))) + return renderedMessageBlock{View: view, Height: lipgloss.Height(view)} case roleError: - return a.styles.inlineError.Width(width).Render(" ! " + wrapPlain(message.Content, max(16, width-6))) + view := a.styles.inlineError.Width(width).Render(" ! " + wrapPlain(message.Content, max(16, width-6))) + return renderedMessageBlock{View: view, Height: lipgloss.Height(view)} case roleSystem: - return a.styles.inlineSystem.Width(width).Render(" - " + wrapPlain(message.Content, max(16, width-6))) + view := a.styles.inlineSystem.Width(width).Render(" - " + wrapPlain(message.Content, max(16, width-6))) + return renderedMessageBlock{View: view, Height: lipgloss.Height(view)} } - maxMessageWidth := clamp(int(float64(width)*0.84), 24, width) + contentWidth := clamp(int(float64(width)*0.84), 24, width) tag := messageTagAgent tagStyle := a.styles.messageAgentTag bodyStyle := a.styles.messageBody - blockAlign := lipgloss.Left + userAligned := false switch message.Role { case roleUser: - maxMessageWidth = clamp(int(float64(width)*0.68), 24, width) + contentWidth = clamp(int(float64(width)*0.68), 24, width) tag = messageTagUser tagStyle = a.styles.messageUserTag bodyStyle = a.styles.messageUserBody - blockAlign = lipgloss.Right + userAligned = true case roleTool: tag = messageTagTool tagStyle = a.styles.messageToolTag bodyStyle = a.styles.messageToolBody } - content := strings.TrimSpace(message.Content) + content := message.Content if content == "" && len(message.ToolCalls) > 0 { names := make([]string, 0, len(message.ToolCalls)) for _, call := range message.ToolCalls { @@ -259,25 +267,40 @@ func (a App) renderMessageBlock(message provider.Message, width int) string { } content = "Tool calls: " + strings.Join(names, ", ") } - if content == "" { + if strings.TrimSpace(content) == "" { content = emptyMessageText } - contentBlock := a.renderMessageContent(content, maxMessageWidth-2, bodyStyle) - if message.Role == roleUser { - contentBlock = lipgloss.PlaceHorizontal(maxMessageWidth, lipgloss.Right, contentBlock) + contentRender := a.renderMessageContentDetailed(content, contentWidth, bodyStyle) + tagView := tagStyle.Render(tag) + if userAligned { + tagView = lipgloss.PlaceHorizontal(contentWidth, lipgloss.Right, tagView) } block := lipgloss.JoinVertical( - blockAlign, - tagStyle.Render(tag), - contentBlock, + lipgloss.Left, + tagView, + contentRender.View, ) + block = lipgloss.NewStyle().Width(contentWidth).Render(block) - if message.Role == roleUser { - return lipgloss.PlaceHorizontal(width, lipgloss.Right, block) + targets := make([]codeBlockTarget, 0, len(contentRender.CodeBlocks)) + targetXOffset := 0 + if userAligned { + targetXOffset = width - contentWidth + block = lipgloss.PlaceHorizontal(width, lipgloss.Right, block) + } + for _, target := range contentRender.CodeBlocks { + target.X += targetXOffset + target.Line++ + targets = append(targets, target) + } + + return renderedMessageBlock{ + View: block, + Height: lipgloss.Height(block), + CodeBlocks: targets, } - return block } func (a App) renderCommandMenu(width int) string { @@ -315,40 +338,74 @@ func (a App) commandMenuHeight(width int) int { func (a App) renderHelp(width int) string { a.help.ShowAll = a.state.ShowHelp helpContent := a.help.View(a.keys) - // 确保帮助视图填充整个宽度,避免边框断裂 return a.styles.footer.Width(width).Render(helpContent) } func (a App) renderMessageContent(content string, width int, bodyStyle lipgloss.Style) string { - parts := strings.Split(content, "```") - if len(parts) == 1 { - return bodyStyle.Render(wrapPlain(content, max(16, width-2))) + return a.renderMessageContentDetailed(content, width, bodyStyle).View +} + +func (a App) renderMessageContentDetailed(content string, width int, bodyStyle lipgloss.Style) renderedContent { + segments := parseMessageSegments(content) + if len(segments) == 0 { + view := bodyStyle.Width(width).Render(emptyMessageText) + return renderedContent{View: view, Height: lipgloss.Height(view)} } - blocks := make([]string, 0, len(parts)) - for i, part := range parts { - if i%2 == 0 { - trimmed := strings.Trim(part, "\n") - if trimmed == "" { - continue - } - blocks = append(blocks, bodyStyle.Render(wrapPlain(trimmed, max(16, width-2)))) - continue + blocks := make([]string, 0, len(segments)) + targets := make([]codeBlockTarget, 0, 2) + lineOffset := 0 + codeIndex := 0 + bodyInnerWidth := max(16, width-bodyStyle.GetHorizontalFrameSize()) + + for _, segment := range segments { + switch segment.Kind { + case segmentCode: + codeView, target := a.renderCodeBlock(segment, width, codeIndex) + blocks = append(blocks, codeView) + target.Line += lineOffset + targets = append(targets, target) + lineOffset += lipgloss.Height(codeView) + codeIndex++ + default: + view := bodyStyle.Width(width).Render(wrapPlain(segment.Content, bodyInnerWidth)) + blocks = append(blocks, view) + lineOffset += lipgloss.Height(view) } + } - code := strings.Trim(part, "\n") - lines := strings.Split(code, "\n") - if len(lines) > 1 && !strings.Contains(lines[0], " ") && !strings.Contains(lines[0], "\t") { - code = strings.Join(lines[1:], "\n") - } - blocks = append(blocks, a.styles.codeBlock.Width(width).Render(a.styles.codeText.Width(max(10, width-4)).Render(code))) + view := lipgloss.JoinVertical(lipgloss.Left, blocks...) + return renderedContent{ + View: view, + Height: lipgloss.Height(view), + CodeBlocks: targets, } +} - if len(blocks) == 0 { - return bodyStyle.Render(emptyMessageText) +func (a App) renderCodeBlock(segment messageSegment, width int, codeIndex int) (string, codeBlockTarget) { + language := strings.TrimSpace(segment.Language) + if language == "" { + language = "text" } - return lipgloss.JoinVertical(lipgloss.Left, blocks...) + copyLabel := "[ Copy ]" + headerLeft := a.styles.codeLanguage.Render(language) + headerRight := a.styles.codeCopy.Render(copyLabel) + headerText := padBetween(width, headerLeft, headerRight) + header := a.styles.codeHeader.Width(width).Render(headerText) + + bodyInnerWidth := max(10, width-a.styles.codeBlock.GetHorizontalFrameSize()) + code := strings.TrimSuffix(segment.Content, "\n") + body := a.styles.codeBlock.Width(width).Render(a.styles.codeText.Width(bodyInnerWidth).Render(code)) + + return lipgloss.JoinVertical(lipgloss.Left, header, body), codeBlockTarget{ + BlockIndex: codeIndex, + Language: language, + Content: segment.Content, + Line: 0, + X: max(0, width-lipgloss.Width(headerRight)), + Width: lipgloss.Width(headerRight), + } } func (a App) statusBadge(text string) string { From 727c31e61ba299c5c107a442e9ab7d29358bcd92 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 30 Mar 2026 14:53:22 +0800 Subject: [PATCH 07/11] =?UTF-8?q?docs:=E6=94=B9=E6=88=90=E6=9B=B4=E9=80=82?= =?UTF-8?q?=E5=90=88=E7=90=86=E8=A7=A3=E7=9A=84readme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 296 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 216 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 41317fa7..edf2f3a7 100644 --- a/README.md +++ b/README.md @@ -2,127 +2,263 @@ > 基于 Go + Bubble Tea 的本地 Coding Agent -NeoCode 是一个在终端中运行的 AI 编码助手,采用 ReAct(Reason-Act-Observe)循环模式,能够自主推理、调用工具并完成任务。 +NeoCode 是一个基于 Go 和 Bubble Tea 的本地 Coding Agent MVP。 -## 核心特性 +它当前聚焦一条最重要的主链路: -- **流式输出** — 实时展示模型思考过程 -- **工具系统** — 文件操作、代码执行、搜索等内置工具 -- **多 Provider 支持** — OpenAI、Gemini 等主流模型,易于扩展 -- **终端原生体验** — 基于 Bubble Tea 的现代化 TUI +`用户输入 -> Agent 推理 -> 调用工具 -> 获取结果 -> 继续推理 -> UI 展示` -## 快速开始 +如果你是第一次打开这个仓库,可以先把它理解成一个运行在终端里的本地 AI 编码助手。它会在 TUI 中接收你的问题,调用模型进行推理,在需要时使用本地工具,再把过程和结果实时展示出来。 -### 环境要求 +## 这份 README 适合谁 -- Go 1.21+ -- API Key(OpenAI 或 Google Gemini) +- 想先把项目跑起来的新同学 +- 想快速看懂仓库分层的贡献者 +- 想知道配置文件、Provider、Tools 应该放在哪一层的协作者 -### 安装与运行 +如果你想直接看详细设计,可以跳到本文末尾的“文档索引”。 -```bash -# 克隆仓库 -git clone https://github.com/yourusername/neocode.git -cd neocode +## 当前能力 -# 设置 API Key -export OPENAI_API_KEY=your_key_here +当前仓库已经围绕 MVP 主链路组织,重点在于把边界理顺,而不是一次性做很多功能。 -# 运行 -go run ./cmd/neocode -``` +- 终端 TUI 交互界面 +- Runtime 驱动的 ReAct 主循环 +- Provider 抽象与内建 Provider 配置 +- Tool Registry 和统一工具协议 +- 本地 Session 持久化 +- 流式事件回传到 TUI + +当前内建工具: + +- `filesystem_read_file` +- `filesystem_write_file` +- `filesystem_grep` +- `filesystem_glob` +- `filesystem_edit` +- `bash` +- `webfetch` + +当前内建 Provider 配置: + +| Provider | 默认模型 | API Key 环境变量 | 说明 | +| --- | --- | --- | --- | +| `openai` | `gpt-4.1` | `OPENAI_API_KEY` | 默认配置 | +| `gemini` | `gemini-2.5-flash` | `GEMINI_API_KEY` | 走 OpenAI-compatible 接口 | +| `openll` | `gpt-4.1` | `AI_API_KEY` | 自定义 OpenAI-compatible 入口 | + +## 快速开始 + +### 1. 准备环境 + +- Go `1.25.0` +- 一个可用的模型 API Key +- 可正常联网的终端环境 -### 基本使用 +### 2. 设置 API Key -在 TUI 中输入自然语言指令,例如: +NeoCode 不会把明文 API Key 写入 `config.yaml`。你可以直接设置系统环境变量,也可以放到 `.env` 文件中。 +常见方式: + +```powershell +$env:OPENAI_API_KEY="your-api-key" ``` -帮我看看当前项目的目录结构 -创建一个 HTTP 服务器,监听 8080 端口 -分析 runtime.go 的主要逻辑 + +或在项目根目录 / `~/.neocode/.env` 中写入: + +```env +OPENAI_API_KEY=your-api-key ``` -使用 slash 命令快速切换配置: +可选环境变量名取决于你使用的 Provider: -- `/provider` — 切换模型提供商 -- `/model` — 切换模型 +- `OPENAI_API_KEY` +- `GEMINI_API_KEY` +- `AI_API_KEY` -## 架构概览 +### 3. 启动应用 +```bash +go run ./cmd/neocode ``` -┌─────────────────────────────────────────┐ -│ TUI (Bubble Tea) │ -└────────────────┬────────────────────────┘ - │ Events -┌────────────────▼────────────────────────┐ -│ Runtime (ReAct Loop) │ -└────────┬───────────────────┬────────────┘ - │ │ - ┌────▼─────┐ ┌────▼──────┐ - │ Provider │ │ Tools │ - │ (LLM) │ │ Registry │ - └──────────┘ └───────────┘ + +首次启动时,程序会自动创建默认配置文件: + +`~/.neocode/config.yaml` + +### 4. 在界面里开始使用 + +进入 TUI 后,你可以直接输入需求,例如: + +- “帮我阅读当前仓库结构并总结” +- “查看 `internal/runtime` 的主循环逻辑” +- “搜索项目里和 provider 相关的实现” + +常用 Slash Command: + +- `/provider`:切换当前 Provider +- `/model`:切换当前模型 + +## 新手先理解这 4 层 + +如果你只想快速看懂项目,先抓住下面这条链路: + +`TUI -> Runtime -> Provider / Tools` + +### TUI + +`internal/tui` + +- 负责界面展示、输入处理、Slash Command 和状态切换 +- 只消费 Runtime 事件,不直接执行工具,不直接调用厂商 API + +### Runtime + +`internal/runtime` + +- 是整个 Agent 的编排中心 +- 负责维护会话、组织上下文、驱动模型调用、执行工具回灌、控制停止条件 + +### Provider + +`internal/provider` + +- 负责抹平不同模型厂商之间的协议差异 +- 对 Runtime 暴露统一的 `ChatRequest / ChatResponse / ToolCall / StreamEvent` + +### Tools + +`internal/tools` + +- 负责所有可被模型调用的本地能力 +- 包括 schema 定义、参数校验、执行、错误包装和结果收敛 + +一句话记忆: + +不要跨层直连。UI 不碰工具执行,Runtime 不写厂商协议细节,工具能力统一进入 `internal/tools`。 + +## 配置说明 + +默认配置文件路径: + +`~/.neocode/config.yaml` + +当前落盘配置是“最小状态配置”,只保存当前选择和通用运行参数,不保存完整 Provider 元数据。 + +示例: + +```yaml +selected_provider: openai +current_model: gpt-4.1 +workdir: /absolute/path/to/workspace +shell: powershell +max_loops: 8 +tool_timeout_sec: 20 +tools: + webfetch: + max_response_bytes: 262144 + supported_content_types: + - text/html + - application/xhtml+xml + - text/plain + - application/json + - application/xml + - text/xml ``` -核心模块职责: +几个关键点: + +- `selected_provider`:当前选中的 Provider +- `current_model`:当前 Provider 下使用的模型 +- `workdir`:工具默认工作目录;启动后会被规范化为绝对路径 +- `shell`:Windows 默认是 `powershell`,其他系统默认是 `bash` +- `max_loops`:Runtime 最大推理轮数 +- `tool_timeout_sec`:工具超时时间 + +说明: + +- 示例里的 `workdir` 使用绝对路径是为了贴近真实落盘结果 +- 如果你首次启动时还没有手动配置,程序会根据当前工作目录生成默认值 + +Provider 的 `base_url`、默认模型列表和 `api_key_env` 由代码内建定义提供,不需要你在 YAML 中手动维护。 + +## 工具与安全边界 + +NeoCode 当前默认遵守这些约束: + +- `filesystem` 工具限制在工作目录内 +- `bash` 工具有超时控制,避免长时间阻塞 +- `webfetch` 限制响应大小和内容类型 +- API Key 通过环境变量读取,不写入聊天记录和配置文件 -- **`internal/config`** — 配置管理、环境变量、YAML 加载 -- **`internal/provider`** — LLM 提供商抽象,抹平厂商差异 -- **`internal/runtime`** — ReAct 主循环、事件流、会话管理 -- **`internal/tools`** — 工具注册表与具体工具实现 -- **`internal/tui`** — 终端 UI、交互体验、事件桥接 -- **`internal/app`** — 应用装配与依赖注入 +如果你要新增一个可被模型调用的能力,优先放进 `internal/tools`,不要直接写到 `runtime` 或 `tui` 里。 -## 目录结构 +## 仓库结构 ```text . -├── cmd/neocode # CLI 入口 -├── docs # 架构与设计文档 -│ ├── guides # 使用指南 -│ └── *.md # 设计文档 -├── internal -│ ├── app # 应用装配 -│ ├── config # 配置管理 -│ ├── provider # Provider 抽象与实现 -│ ├── runtime # ReAct 循环与事件流 -│ ├── tools # 工具系统 -│ └── tui # 终端 UI -└── README.md +├── cmd/neocode # CLI 入口 +├── internal/app # 应用装配与依赖注入 +├── internal/config # 配置加载、保存、校验、并发安全访问 +├── internal/provider # Provider 抽象、驱动注册、厂商适配 +├── internal/runtime # ReAct 主循环、事件、Session 管理 +├── internal/tools # 工具协议、注册表、具体工具实现 +├── internal/tui # Bubble Tea 界面与交互状态机 +└── docs # 架构与细节设计文档 ``` -## 文档 +## 开发命令 -- **[配置指南](docs/guides/configuration.md)** — Provider 策略、配置文件、环境变量 -- **[扩展 Provider](docs/guides/adding-providers.md)** — 如何添加新的模型提供商 -- **[架构设计](docs/neocode-coding-agent-mvp-architecture.md)** — 整体架构与设计理念 -- **[事件流](docs/runtime-provider-event-flow.md)** — Runtime 与 Provider 的事件交互 +启动应用: -## 开发 +```bash +go run ./cmd/neocode +``` + +编译全部包: ```bash -# 格式化代码 -gofmt -w ./cmd ./internal +go build ./... +``` -# 运行测试 +运行测试: + +```bash go test ./... +``` -# 编译 -go build ./... +格式化代码: + +```bash +gofmt -w ./cmd ./internal ``` -## 当前状态 +## 贡献时建议先看 + +这个仓库最重要的协作原则是: + +- 优先保证主链路可运行 +- 优先保持模块边界清晰 +- 新能力默认沿 `TUI -> Runtime -> Provider / Tool Manager` 接入 +- 修改 `config`、`provider`、`runtime`、`tools` 时要同步评估测试 + +在开始改代码前,建议先阅读仓库根目录的 `AGENTS.md`。 -NeoCode 正处于 MVP 阶段,核心闭环已可用: +## 文档索引 -✅ 用户输入 → Agent 推理 → 工具调用 → 结果返回 → UI 展示 +- `docs/guides/configuration.md` +- `docs/guides/adding-providers.md` +- `docs/neocode-coding-agent-mvp-architecture.md` +- `docs/runtime-provider-event-flow.md` +- `docs/config-management-detail-design.md` +- `docs/provider-schema-strategy.md` +- `docs/tools-and-tui-integration.md` +- `docs/session-persistence-design.md` -正在持续迭代中,重点关注: +## 一句话总结 -- 📚 文档完善 -- 🧪 测试覆盖率 -- 🛠️ 工具能力扩展 -- 🔧 稳定性与性能 +NeoCode 当前不是一个“功能很多”的 Agent,而是一个把主链路、分层和可验证性打磨清楚的 Coding Agent MVP。对新同学来说,最好的阅读顺序是:先跑起来,再看 `README` 的分层说明,最后按需进入 `docs/` 深入细节。 ## License From 9f81cfc99bae3d79cdd78fe8ad0e4d5dfdf20669 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 30 Mar 2026 14:53:48 +0800 Subject: [PATCH 08/11] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E5=86=B2=E7=AA=81?= =?UTF-8?q?=E5=BF=AB=E6=8D=B7=E9=94=AE=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/interaction.go | 2 +- internal/tui/keymap.go | 4 ++-- internal/tui/transcript.go | 13 ++++++++++--- internal/tui/update_test.go | 15 ++++++++++++--- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/internal/tui/interaction.go b/internal/tui/interaction.go index 1ea5c992..af4d231a 100644 --- a/internal/tui/interaction.go +++ b/internal/tui/interaction.go @@ -13,7 +13,7 @@ import ( var writeClipboard = clipboard.WriteAll func (a *App) shouldSendInput(msg tea.KeyMsg) bool { - return key.Matches(msg, a.keys.Send) || msg.String() == "ctrl+enter" + return key.Matches(msg, a.keys.Send) } func (a *App) handleMouse(msg tea.MouseMsg) { diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index d17afad9..6c211bd6 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -23,8 +23,8 @@ type keyMap struct { func newKeyMap() keyMap { return keyMap{ Send: key.NewBinding( - key.WithKeys("ctrl+enter", "ctrl+s"), - key.WithHelp("Ctrl+Enter/Ctrl+S", "send"), + key.WithKeys("ctrl+j"), + key.WithHelp("Ctrl+J", "send"), ), CancelAgent: key.NewBinding( key.WithKeys("ctrl+w"), diff --git a/internal/tui/transcript.go b/internal/tui/transcript.go index 0b622e36..4c08491d 100644 --- a/internal/tui/transcript.go +++ b/internal/tui/transcript.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" ) type messageSegmentKind int @@ -86,16 +87,22 @@ func wrappedLineCount(text string, width int) int { if width <= 0 { return 1 } + lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") total := 0 for _, line := range lines { - runes := []rune(line) - if len(runes) == 0 { + if line == "" { total++ continue } - total += (len(runes)-1)/width + 1 + displayWidth := runewidth.StringWidth(line) + if displayWidth <= 0 { + total++ + continue + } + total += (displayWidth-1)/width + 1 } + if total == 0 { return 1 } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 52649574..314d3611 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -138,7 +138,7 @@ func TestAppUpdateComposerCommands(t *testing.T) { app.input.SetValue(tt.input) app.state.InputText = tt.input - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) app = model.(App) for _, msg := range collectTeaMessages(cmd) { @@ -379,7 +379,7 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if !strings.Contains(app.renderSidebar(26, 12), sidebarTitle) || !strings.Contains(app.renderSidebar(26, 12), sidebarOpenHint) { t.Fatalf("expected updated sidebar header text") } - if strings.Contains(app.renderPrompt(80), "Enter/Ctrl+S") { + if strings.Contains(app.renderPrompt(80), "Ctrl+S") || strings.Contains(app.renderPrompt(80), "Ctrl+Enter") { t.Fatalf("expected composer hint line to be removed") } if strings.TrimSpace(app.renderPrompt(80)) == "" { @@ -448,6 +448,15 @@ func TestTUIStandaloneHelpers(t *testing.T) { if preview("line1\nline2\nline3", 8, 2) == "" { t.Fatalf("expected preview output") } + if newKeyMap().Send.Help().Key != "Ctrl+J" { + t.Fatalf("expected send help to advertise Ctrl+J, got %+v", newKeyMap().Send.Help()) + } + if wrappedLineCount("你好世界", 4) != 2 { + t.Fatalf("expected CJK text to consume full-width columns") + } + if wrappedLineCount("ab你cd", 4) != 2 { + t.Fatalf("expected mixed-width text to wrap by display width") + } if clamp(10, 0, 5) != 5 || max(2, 3) != 3 { t.Fatalf("expected numeric helpers to work") } @@ -720,7 +729,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { app.input.SetValue("inspect repo") app.state.InputText = "inspect repo" }, - msg: tea.KeyMsg{Type: tea.KeyCtrlS}, + msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if !app.state.IsAgentRunning { From 481ce30e785a81af7fde995e40eb8573d794dd18 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 30 Mar 2026 16:17:29 +0800 Subject: [PATCH 09/11] =?UTF-8?q?feat:=E5=A2=9E=E5=8A=A0=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8Fapikey=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- docs/config-management-detail-design.md | 30 +++--- internal/config/config_test.go | 95 +++++++++++++++++ internal/config/loader.go | 45 +++++---- internal/config/manager.go | 3 +- internal/config/model.go | 76 ++++++++++++-- internal/provider/registry_test.go | 55 ++++++++++ internal/provider/service.go | 11 ++ internal/tui/app.go | 10 ++ internal/tui/commands.go | 33 ++++++ internal/tui/provider_service.go | 1 + internal/tui/state.go | 3 + internal/tui/update.go | 22 ++++ internal/tui/update_test.go | 129 ++++++++++++++++++++++++ internal/tui/view.go | 4 + 15 files changed, 477 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index edf2f3a7..af7c1ebf 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ OPENAI_API_KEY=your-api-key - `GEMINI_API_KEY` - `AI_API_KEY` +你也可以在进入 TUI 后通过 `/apikey` 打开输入框,自定义当前运行时优先读取的 API Key 环境变量名。留空确认会恢复为当前 Provider 的默认环境变量名。 + ### 3. 启动应用 ```bash @@ -99,6 +101,7 @@ go run ./cmd/neocode - `/provider`:切换当前 Provider - `/model`:切换当前模型 +- `/apikey`:设置全局 API Key 环境变量名覆盖 ## 新手先理解这 4 层 @@ -151,6 +154,7 @@ go run ./cmd/neocode ```yaml selected_provider: openai current_model: gpt-4.1 +api_key_env_override: CUSTOM_OPENAI_KEY workdir: /absolute/path/to/workspace shell: powershell max_loops: 8 @@ -181,7 +185,7 @@ tools: - 示例里的 `workdir` 使用绝对路径是为了贴近真实落盘结果 - 如果你首次启动时还没有手动配置,程序会根据当前工作目录生成默认值 -Provider 的 `base_url`、默认模型列表和 `api_key_env` 由代码内建定义提供,不需要你在 YAML 中手动维护。 +Provider 的 `base_url`、默认模型列表和 `api_key_env` 由代码内建定义提供,不需要你在 YAML 中手动维护;如果你想全局覆盖当前运行时读取的环境变量名,可以设置顶层的 `api_key_env_override`,留空则回退到当前 Provider 的默认值。 ## 工具与安全边界 diff --git a/docs/config-management-detail-design.md b/docs/config-management-detail-design.md index 8975eb90..d27d258e 100644 --- a/docs/config-management-detail-design.md +++ b/docs/config-management-detail-design.md @@ -3,32 +3,32 @@ `config` 模块主要负责四类事情: - 加载和保存 YAML 配置文件 - 从环境变量解析真实密钥 -- 管理 NeoCode 托管目录中的配置与 `.env` +- 管理 NeoCode 托管目录中的 `.env` - 向运行中的系统提供并发安全的配置读写能力 ## 核心类型 -- `Config`:顶层应用配置,运行时包含 Provider 列表、当前选中 Provider、当前模型、工作目录、Shell 和循环限制等信息 +- `Config`:顶层应用配置,运行时包含 Provider 列表、当前选中的 Provider、当前模型、全局 `api_key_env_override`、工作目录、Shell 和循环限制等信息 - `ProviderConfig`:单个 Provider 的内建定义,包括 Base URL、默认模型、实例级模型列表和 API Key 环境变量名 - `Manager`:使用 `sync.RWMutex` 保护的配置访问器与修改器 - `Loader`:对 YAML 文件和托管 `.env` 文件的文件系统封装 ## 环境变量策略 -- YAML 不保存 `api_key_env`,只保存 `selected_provider`、`current_model` 和通用运行配置。 -- `Loader.LoadEnvironment` 会尝试加载当前工作目录下的 `.env` 和 NeoCode 托管目录中的 `.env`。 -- `ProviderConfig.ResolveAPIKey` 在真正发起请求前通过 `os.Getenv` 读取密钥。 +- YAML 不保存 Provider 元数据,但允许保存顶层 `api_key_env_override`,与 `selected_provider`、`current_model` 和通用运行配置一起持久化 +- `Loader.LoadEnvironment` 会尝试加载当前工作目录下的 `.env` 和 NeoCode 托管目录中的 `.env` +- `ProviderConfig.ResolveAPIKey` 在真正发起请求前通过 `os.Getenv` 读取密钥 ## 运行时更新 -- TUI 只能通过 `ConfigManager.Update` 修改配置。 -- TUI 只负责切换当前 Provider 和当前模型,不直接修改 Provider 元数据。 -- `base_url`、`api_key_env`、`driver` 和 `models` 由代码内建定义提供,不从 YAML 读写。 -- 修改模型时,只更新 `current_model`;当前 Provider 的 `model` 仍表示默认模型,`models` 负责描述该实例可选模型列表。 +- TUI 只通过 `ConfigManager.Update` 修改配置 +- TUI 可以切换当前 Provider、当前模型,以及设置全局 `api_key_env_override`,但不直接修改 Provider 元数据 +- `base_url`、`api_key_env`、`driver` 和 `models` 由代码内建定义提供,不从 YAML 读写 +- 修改模型时,只更新 `current_model`;当前 Provider 的 `model` 仍表示默认模型,`models` 负责描述该实例可选模型列表 ## 默认值治理 -- 默认 Provider 名称、URL、默认模型、模型列表和环境变量名统一由内建 Provider 定义提供。 -- `Loader` 在加载旧配置时会丢弃 `providers` / `provider_overrides`,重新回到“YAML 只保存选择状态”的最小结构。 -- Provider 的可选模型目录属于实例配置,进入运行时 `Config` 后再提供给 TUI 和 runtime,避免 TUI 或 driver 自己维护一套零散常量。 +- 默认 Provider 名称、URL、默认模型、模型列表和环境变量名统一由内建 Provider 定义提供 +- `Loader` 在加载旧配置时会丢弃 `providers` / `provider_overrides`,重新回到“YAML 只保存选择状态和顶层 override”的最小结构 +- Provider 的可选模型目录属于实例配置,进入运行时 `Config` 后再提供给 TUI 和 runtime,避免 TUI 或 driver 自己维护一套零散常量 ## 安全约束 -- 读操作统一走 `Get`,并返回拷贝后的配置快照。 -- 写操作统一走 `Update`,修改前后都要做校验。 -- 真实密钥不能出现在日志、状态栏、聊天流或错误提示中。 +- 读操作统一走 `Get`,并返回拷贝后的配置快照 +- 写操作统一走 `Update`,修改前后都要做校验 +- 真实密钥不能出现在日志、状态栏、聊天流或错误提示中 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8f8c82d5..835b1ed9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -629,6 +629,32 @@ func TestProviderLookupAndResolveSelectedProvider(t *testing.T) { } } +func TestProviderLookupAndResolveSelectedProviderUsesOverride(t *testing.T) { + t.Setenv("CUSTOM_OPENAI_KEY", "override-key") + + manager := NewManager(NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + if err := manager.Update(context.Background(), func(cfg *Config) error { + cfg.APIKeyEnvOverride = " CUSTOM_OPENAI_KEY " + return nil + }); err != nil { + t.Fatalf("Update() error = %v", err) + } + + resolved, err := manager.ResolvedSelectedProvider() + if err != nil { + t.Fatalf("ResolvedSelectedProvider() error = %v", err) + } + if resolved.APIKeyEnv != "CUSTOM_OPENAI_KEY" { + t.Fatalf("expected resolved env %q, got %q", "CUSTOM_OPENAI_KEY", resolved.APIKeyEnv) + } + if resolved.APIKey != "override-key" { + t.Fatalf("expected resolved key %q, got %q", "override-key", resolved.APIKey) + } +} + func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { tempDir := t.TempDir() loader := NewLoader(tempDir, testDefaultConfig()) @@ -644,6 +670,7 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { cfg.CurrentModel = "gpt-5.4" cfg.Providers[0].Models = []string{"gpt-4.1", "gpt-5.4"} cfg.Providers[0].BaseURL = "https://ignored.example/v1" + cfg.APIKeyEnvOverride = "CUSTOM_OPENAI_KEY" cfg.Tools.WebFetch.MaxResponseBytes = 1024 cfg.Tools.WebFetch.SupportedContentTypes = []string{"text/html", "application/json"} if err := loader.Save(context.Background(), cfg); err != nil { @@ -664,11 +691,17 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { if strings.Contains(text, "models:") || strings.Contains(text, "base_url:") || strings.Contains(text, "api_key_env:") { t.Fatalf("expected persisted config to keep only selection state and common runtime settings, got:\n%s", text) } + if !strings.Contains(text, "api_key_env_override: CUSTOM_OPENAI_KEY") { + t.Fatalf("expected persisted config to include api_key_env_override, got:\n%s", text) + } reloaded, err := loader.Load(context.Background()) if err != nil { t.Fatalf("Load() reload error = %v", err) } + if reloaded.APIKeyEnvOverride != "CUSTOM_OPENAI_KEY" { + t.Fatalf("expected api key env override to round-trip, got %q", reloaded.APIKeyEnvOverride) + } if reloaded.CurrentModel != "gpt-5.4" { t.Fatalf("expected current model %q, got %q", "gpt-5.4", reloaded.CurrentModel) } @@ -693,6 +726,68 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { } } +func TestLoaderSaveOmitsEmptyAPIKeyEnvOverride(t *testing.T) { + tempDir := t.TempDir() + loader := NewLoader(tempDir, testDefaultConfig()) + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + cfg.APIKeyEnvOverride = "CUSTOM_OPENAI_KEY" + if err := loader.Save(context.Background(), cfg); err != nil { + t.Fatalf("Save() with override error = %v", err) + } + + cfg.APIKeyEnvOverride = " " + if err := loader.Save(context.Background(), cfg); err != nil { + t.Fatalf("Save() clearing override error = %v", err) + } + + data, err := os.ReadFile(loader.ConfigPath()) + if err != nil { + t.Fatalf("read config file: %v", err) + } + if strings.Contains(string(data), "api_key_env_override:") { + t.Fatalf("expected empty override to be omitted, got:\n%s", string(data)) + } +} + +func TestConfigValidateRejectsInvalidAPIKeyEnvOverride(t *testing.T) { + tests := []struct { + name string + override string + expectErr string + }{ + { + name: "contains spaces", + override: "CUSTOM KEY", + expectErr: "must not contain whitespace", + }, + { + name: "contains equals", + override: "CUSTOM=KEY", + expectErr: "must not contain =", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := testDefaultConfig() + cfg.APIKeyEnvOverride = tt.override + cfg.ApplyDefaultsFrom(*testDefaultConfig()) + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + }) + } +} + func TestLoaderUsesUpdatedBuiltinProviderWhenUserHasNoOverride(t *testing.T) { tempDir := t.TempDir() diff --git a/internal/config/loader.go b/internal/config/loader.go index 0c40c051..97a62184 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -24,13 +24,14 @@ type Loader struct { } type persistedConfig struct { - SelectedProvider string `yaml:"selected_provider"` - CurrentModel string `yaml:"current_model"` - Workdir string `yaml:"workdir"` - Shell string `yaml:"shell"` - MaxLoops int `yaml:"max_loops,omitempty"` - ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` - Tools ToolsConfig `yaml:"tools,omitempty"` + SelectedProvider string `yaml:"selected_provider"` + CurrentModel string `yaml:"current_model"` + APIKeyEnvOverride string `yaml:"api_key_env_override,omitempty"` + Workdir string `yaml:"workdir"` + Shell string `yaml:"shell"` + MaxLoops int `yaml:"max_loops,omitempty"` + ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Tools ToolsConfig `yaml:"tools,omitempty"` } func NewLoader(baseDir string, defaults *Config) *Loader { @@ -124,13 +125,14 @@ func (l *Loader) Save(ctx context.Context, cfg *Config) error { } file := persistedConfig{ - SelectedProvider: snapshot.SelectedProvider, - CurrentModel: snapshot.CurrentModel, - Workdir: snapshot.Workdir, - Shell: snapshot.Shell, - MaxLoops: snapshot.MaxLoops, - ToolTimeoutSec: snapshot.ToolTimeoutSec, - Tools: snapshot.Tools, + SelectedProvider: snapshot.SelectedProvider, + CurrentModel: snapshot.CurrentModel, + APIKeyEnvOverride: snapshot.APIKeyEnvOverride, + Workdir: snapshot.Workdir, + Shell: snapshot.Shell, + MaxLoops: snapshot.MaxLoops, + ToolTimeoutSec: snapshot.ToolTimeoutSec, + Tools: snapshot.Tools, } data, err := yaml.Marshal(&file) @@ -192,13 +194,14 @@ func parseCurrentConfig(data []byte, _ Config) (*Config, error) { } cfg := &Config{ - SelectedProvider: strings.TrimSpace(file.SelectedProvider), - CurrentModel: strings.TrimSpace(file.CurrentModel), - Workdir: strings.TrimSpace(file.Workdir), - Shell: strings.TrimSpace(file.Shell), - MaxLoops: file.MaxLoops, - ToolTimeoutSec: file.ToolTimeoutSec, - Tools: file.Tools, + SelectedProvider: strings.TrimSpace(file.SelectedProvider), + CurrentModel: strings.TrimSpace(file.CurrentModel), + APIKeyEnvOverride: strings.TrimSpace(file.APIKeyEnvOverride), + Workdir: strings.TrimSpace(file.Workdir), + Shell: strings.TrimSpace(file.Shell), + MaxLoops: file.MaxLoops, + ToolTimeoutSec: file.ToolTimeoutSec, + Tools: file.Tools, } return cfg, nil diff --git a/internal/config/manager.go b/internal/config/manager.go index 743a9758..29a85b5a 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -91,7 +91,8 @@ func (m *Manager) SelectedProvider() (ProviderConfig, error) { } func (m *Manager) ResolvedSelectedProvider() (ResolvedProviderConfig, error) { - provider, err := m.SelectedProvider() + cfg := m.Get() + provider, err := cfg.EffectiveSelectedProviderConfig() if err != nil { return ResolvedProviderConfig{}, err } diff --git a/internal/config/model.go b/internal/config/model.go index 3273697a..2c55c96c 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -27,14 +27,15 @@ var defaultWebFetchSupportedContentTypes = []string{ } type Config struct { - Providers []ProviderConfig `yaml:"-"` - SelectedProvider string `yaml:"selected_provider"` - CurrentModel string `yaml:"current_model"` - Workdir string `yaml:"workdir"` - Shell string `yaml:"shell"` - MaxLoops int `yaml:"max_loops,omitempty"` - ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` - Tools ToolsConfig `yaml:"tools,omitempty"` + Providers []ProviderConfig `yaml:"-"` + SelectedProvider string `yaml:"selected_provider"` + CurrentModel string `yaml:"current_model"` + APIKeyEnvOverride string `yaml:"api_key_env_override,omitempty"` + Workdir string `yaml:"workdir"` + Shell string `yaml:"shell"` + MaxLoops int `yaml:"max_loops,omitempty"` + ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Tools ToolsConfig `yaml:"tools,omitempty"` } type ProviderConfig struct { @@ -125,6 +126,7 @@ func (c *Config) ApplyDefaultsFrom(defaults Config) { } c.Tools.ApplyDefaults(defaults.Tools) + c.APIKeyEnvOverride = normalizeAPIKeyEnvOverride(c.APIKeyEnvOverride) c.Workdir = normalizeWorkdir(c.Workdir) } @@ -171,6 +173,9 @@ func (c *Config) Validate() error { if !containsModelID(selected.SupportedModels(), c.CurrentModel) { return fmt.Errorf("config: current_model %q is not supported by provider %q", c.CurrentModel, selected.Name) } + if err := validateAPIKeyEnvOverride(c.APIKeyEnvOverride); err != nil { + return fmt.Errorf("config: %w", err) + } if err := c.Tools.Validate(); err != nil { return fmt.Errorf("config: tools: %w", err) } @@ -200,6 +205,14 @@ func (c *Config) ProviderByName(name string) (ProviderConfig, error) { return ProviderConfig{}, fmt.Errorf("config: provider %q not found", name) } +func (c *Config) EffectiveSelectedProviderConfig() (ProviderConfig, error) { + selected, err := c.SelectedProviderConfig() + if err != nil { + return ProviderConfig{}, err + } + return selected.WithAPIKeyEnvOverride(c.APIKeyEnvOverride) +} + func (p ProviderConfig) Validate() error { if strings.TrimSpace(p.Name) == "" { return errors.New("provider name is empty") @@ -240,6 +253,33 @@ func (p ProviderConfig) SupportedModels() []string { return []string{model} } +func (p ProviderConfig) EffectiveAPIKeyEnv(override string) (string, error) { + if err := validateAPIKeyEnvOverride(override); err != nil { + return "", err + } + + if override = normalizeAPIKeyEnvOverride(override); override != "" { + return override, nil + } + + envName := strings.TrimSpace(p.APIKeyEnv) + if envName == "" { + return "", fmt.Errorf("provider %q api_key_env is empty", p.Name) + } + return envName, nil +} + +func (p ProviderConfig) WithAPIKeyEnvOverride(override string) (ProviderConfig, error) { + envName, err := p.EffectiveAPIKeyEnv(override) + if err != nil { + return ProviderConfig{}, err + } + + next := p + next.APIKeyEnv = envName + return next, nil +} + func (p ProviderConfig) ResolveAPIKey() (string, error) { envName := strings.TrimSpace(p.APIKeyEnv) if envName == "" { @@ -302,6 +342,26 @@ func mergeProviderDefaults(provider ProviderConfig, defaults []ProviderConfig) P return provider } +func normalizeAPIKeyEnvOverride(value string) string { + return strings.TrimSpace(value) +} + +func validateAPIKeyEnvOverride(value string) error { + value = normalizeAPIKeyEnvOverride(value) + if value == "" { + return nil + } + if strings.Contains(value, "=") { + return errors.New("api_key_env_override must not contain =") + } + for _, r := range value { + if r == ' ' || r == '\t' || r == '\n' || r == '\r' { + return errors.New("api_key_env_override must not contain whitespace") + } + } + return nil +} + func matchDefaultProvider(provider ProviderConfig, defaults []ProviderConfig) (ProviderConfig, bool) { name := strings.ToLower(strings.TrimSpace(provider.Name)) if name == "" { diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 6f209301..0103aa7c 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -3,6 +3,7 @@ package provider_test import ( "context" "errors" + "strings" "testing" "neo-code/internal/config" @@ -220,6 +221,60 @@ func TestServiceSelectProviderAndSetCurrentModel(t *testing.T) { } } +func TestServiceSetAPIKeyEnvOverridePersistsAcrossReloadAndSwitches(t *testing.T) { + defaults := builtin.DefaultConfig() + defaults.Providers = append(defaults.Providers, config.ProviderConfig{ + Name: "custom-main", + Driver: "custom", + BaseURL: "https://example.com", + Model: "custom-model", + Models: []string{"custom-model", "custom-alt"}, + APIKeyEnv: "CUSTOM_PROVIDER_KEY", + }) + manager := newTestManagerWithDefaults(t, defaults) + registry := newTestRegistry(t) + if err := registry.Register(stubDriver("custom")); err != nil { + t.Fatalf("register stub driver: %v", err) + } + + service := provider.NewService(manager, registry) + if err := service.SetAPIKeyEnvOverride(context.Background(), " GLOBAL_OVERRIDE_KEY "); err != nil { + t.Fatalf("SetAPIKeyEnvOverride() error = %v", err) + } + + cfg := manager.Get() + if cfg.APIKeyEnvOverride != "GLOBAL_OVERRIDE_KEY" { + t.Fatalf("expected override to be normalized, got %q", cfg.APIKeyEnvOverride) + } + + if _, err := service.SelectProvider(context.Background(), "custom-main"); err != nil { + t.Fatalf("SelectProvider() error = %v", err) + } + if _, err := service.SetCurrentModel(context.Background(), "custom-alt"); err != nil { + t.Fatalf("SetCurrentModel() error = %v", err) + } + + reloaded, err := manager.Reload(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + if reloaded.APIKeyEnvOverride != "GLOBAL_OVERRIDE_KEY" { + t.Fatalf("expected override to persist after reload, got %q", reloaded.APIKeyEnvOverride) + } + if reloaded.SelectedProvider != "custom-main" || reloaded.CurrentModel != "custom-alt" { + t.Fatalf("expected provider/model selection to persist, got provider=%q model=%q", reloaded.SelectedProvider, reloaded.CurrentModel) + } +} + +func TestServiceSetAPIKeyEnvOverrideRejectsInvalidInput(t *testing.T) { + service := provider.NewService(newTestManager(t), newTestRegistry(t)) + + err := service.SetAPIKeyEnvOverride(context.Background(), "CUSTOM KEY") + if err == nil || (!strings.Contains(err.Error(), "whitespace") && !strings.Contains(err.Error(), "api_key_env_override")) { + t.Fatalf("expected override validation error, got %v", err) + } +} + func TestServiceModelOperationsUseProviderConfigEvenWithoutDriver(t *testing.T) { t.Parallel() diff --git a/internal/provider/service.go b/internal/provider/service.go index 8c2f983c..984ea171 100644 --- a/internal/provider/service.go +++ b/internal/provider/service.go @@ -125,6 +125,17 @@ func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (Provider return selection, nil } +func (s *Service) SetAPIKeyEnvOverride(ctx context.Context, envName string) error { + if err := s.validate(); err != nil { + return err + } + + return s.manager.Update(ctx, func(cfg *config.Config) error { + cfg.APIKeyEnvOverride = envName + return nil + }) +} + func containsModel(models []string, modelID string) bool { target := normalizeKey(modelID) for _, model := range models { diff --git a/internal/tui/app.go b/internal/tui/app.go index 37c4204b..9ebad655 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -27,6 +28,7 @@ type App struct { sessions list.Model providerPicker list.Model modelPicker list.Model + apiKeyInput textinput.Model transcript viewport.Model input textarea.Model activeMessages []provider.Message @@ -84,6 +86,12 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime spin.Spinner = spinner.Line spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorPrimary)) + apiKeyInput := textinput.New() + apiKeyInput.Prompt = "" + apiKeyInput.Placeholder = "Enter API key env name" + apiKeyInput.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) + apiKeyInput.CharLimit = 256 + h := help.New() h.ShowAll = false @@ -92,6 +100,7 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime StatusText: statusReady, CurrentProvider: cfg.SelectedProvider, CurrentModel: cfg.CurrentModel, + APIKeyEnvOverride: cfg.APIKeyEnvOverride, CurrentWorkdir: cfg.Workdir, ActiveSessionTitle: draftSessionTitle, Focus: panelInput, @@ -105,6 +114,7 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime sessions: sessionList, providerPicker: newProviderPicker(nil), modelPicker: newModelPicker(nil), + apiKeyInput: apiKeyInput, transcript: viewport.New(0, 0), input: input, focus: panelInput, diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 4aacf6a1..364f37be 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -15,15 +15,19 @@ const ( slashPrefix = "/" slashCommandProviderPick = "/provider" slashCommandModelPicker = "/model" + slashCommandAPIKeyInput = "/apikey" slashUsageProvider = "/provider" slashUsageModel = "/model" + slashUsageAPIKey = "/apikey" commandMenuTitle = "Commands" providerPickerTitle = "Select Provider" providerPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" modelPickerTitle = "Select Model" modelPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" + apiKeyInputTitle = "Set API Key Env" + apiKeyInputSubtitle = "Enter save, Esc cancel, leave empty to restore default" sidebarTitle = "Sessions" sidebarFilterHint = "Type / to search" @@ -46,6 +50,7 @@ const ( statusRunning = "Running" statusChooseProvider = "Choose a provider" statusChooseModel = "Choose a model" + statusEditAPIKeyEnv = "Set API key env" focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" @@ -76,6 +81,7 @@ type commandSuggestion struct { var builtinSlashCommands = []slashCommand{ {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, + {Usage: slashUsageAPIKey, Description: "Open the API key env input"}, } func newSelectionPicker(items []list.Item) list.Model { @@ -154,6 +160,16 @@ func (a *App) openModelPicker() { a.selectCurrentModel(a.state.CurrentModel) } +func (a *App) openAPIKeyInput() { + a.state.ActivePicker = pickerAPIKey + a.state.StatusText = statusEditAPIKeyEnv + a.apiKeyInput.SetValue(a.state.APIKeyEnvOverride) + a.apiKeyInput.Placeholder = fallback(a.state.CurrentAPIKeyEnv, "Enter API key env name") + a.focus = panelInput + a.applyFocus() + a.apiKeyInput.CursorEnd() +} + func (a *App) closePicker() { a.state.ActivePicker = pickerNone a.focus = panelInput @@ -228,3 +244,20 @@ func runModelSelection(providerSvc ProviderController, modelID string) tea.Cmd { } } } + +func runAPIKeyEnvSelection(providerSvc ProviderController, envName string) tea.Cmd { + return func() tea.Msg { + trimmed := strings.TrimSpace(envName) + if err := providerSvc.SetAPIKeyEnvOverride(context.Background(), trimmed); err != nil { + return localCommandResultMsg{err: err} + } + if trimmed == "" { + return localCommandResultMsg{ + notice: "[System] API key env restored to provider default.", + } + } + return localCommandResultMsg{ + notice: fmt.Sprintf("[System] API key env override set to %s.", trimmed), + } + } +} diff --git a/internal/tui/provider_service.go b/internal/tui/provider_service.go index c2a5ea1e..9c1d272a 100644 --- a/internal/tui/provider_service.go +++ b/internal/tui/provider_service.go @@ -11,4 +11,5 @@ type ProviderController interface { SelectProvider(ctx context.Context, providerID string) (provider.ProviderSelection, error) ListModels(ctx context.Context) ([]provider.ModelDescriptor, error) SetCurrentModel(ctx context.Context, modelID string) (provider.ProviderSelection, error) + SetAPIKeyEnvOverride(ctx context.Context, envName string) error } diff --git a/internal/tui/state.go b/internal/tui/state.go index 0639cf9c..931d1c19 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -28,6 +28,7 @@ const ( pickerNone pickerMode = iota pickerProvider pickerModel + pickerAPIKey ) type UIState struct { @@ -42,6 +43,8 @@ type UIState struct { StatusText string CurrentProvider string CurrentModel string + CurrentAPIKeyEnv string + APIKeyEnvOverride string CurrentWorkdir string ShowHelp bool ActivePicker pickerMode diff --git a/internal/tui/update.go b/internal/tui/update.go index fe6a9117..d5ee8392 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -212,6 +212,9 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te } a.openModelPicker() return a, tea.Batch(cmds...) + case slashCommandAPIKeyInput: + a.openAPIKeyInput() + return a, tea.Batch(cmds...) } if strings.HasPrefix(input, slashPrefix) { @@ -263,6 +266,10 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, nil } return a, runModelSelection(a.providerSvc, item.id) + case pickerAPIKey: + value := a.apiKeyInput.Value() + a.closePicker() + return a, runAPIKeyEnvSelection(a.providerSvc, value) } } @@ -272,6 +279,8 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.providerPicker, cmd = a.providerPicker.Update(msg) case pickerModel: a.modelPicker, cmd = a.modelPicker.Update(msg) + case pickerAPIKey: + a.apiKeyInput, cmd = a.apiKeyInput.Update(msg) } return a, cmd } @@ -359,7 +368,13 @@ func (a *App) syncActiveSessionTitle() { func (a *App) syncConfigState(cfg config.Config) { a.state.CurrentProvider = cfg.SelectedProvider a.state.CurrentModel = cfg.CurrentModel + a.state.APIKeyEnvOverride = cfg.APIKeyEnvOverride a.state.CurrentWorkdir = cfg.Workdir + if providerCfg, err := cfg.EffectiveSelectedProviderConfig(); err == nil { + a.state.CurrentAPIKeyEnv = providerCfg.APIKeyEnv + } else { + a.state.CurrentAPIKeyEnv = "" + } } func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) { @@ -511,6 +526,12 @@ func (a *App) focusPrev() { func (a *App) applyFocus() { a.state.Focus = a.focus + if a.state.ActivePicker == pickerAPIKey { + a.input.Blur() + a.apiKeyInput.Focus() + return + } + a.apiKeyInput.Blur() if a.focus == panelInput && a.state.ActivePicker == pickerNone { a.input.Focus() return @@ -535,6 +556,7 @@ func (a *App) resizeComponents() { a.transcript.Height = max(6, lay.rightHeight-menuHeight-promptHeight) a.providerPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) a.modelPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) + a.apiKeyInput.Width = max(20, clamp(lay.rightWidth-20, 24, 48)) a.updateTranscriptRect(lay) a.rebuildTranscript() } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 314d3611..070b2c4a 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -123,6 +123,25 @@ func TestAppUpdateComposerCommands(t *testing.T) { } }, }, + { + name: "apikey command opens input and does not start runtime", + input: "/apikey", + assert: func(t *testing.T, runtime *stubRuntime, manager *config.Manager, app App) { + t.Helper() + if len(runtime.runInputs) != 0 { + t.Fatalf("expected runtime not to run, got %d calls", len(runtime.runInputs)) + } + if app.state.ActivePicker != pickerAPIKey { + t.Fatalf("expected api key input to open") + } + if app.state.StatusText != statusEditAPIKeyEnv { + t.Fatalf("expected status %q, got %q", statusEditAPIKeyEnv, app.state.StatusText) + } + if app.apiKeyInput.Placeholder == "" { + t.Fatalf("expected api key input placeholder to be populated") + } + }, + }, } for _, tt := range tests { @@ -176,6 +195,26 @@ func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { } }, }, + { + name: "escape closes api key input", + setup: func(t *testing.T, app *App, runtime *stubRuntime) { + app.openAPIKeyInput() + app.apiKeyInput.SetValue("CUSTOM_OPENAI_KEY") + }, + msg: tea.KeyMsg{Type: tea.KeyEsc}, + assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { + t.Helper() + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected api key input to close") + } + if app.state.Focus != panelInput { + t.Fatalf("expected focus to return to input") + } + if app.apiKeyInput.Value() != "CUSTOM_OPENAI_KEY" { + t.Fatalf("expected api key input value to stay local on cancel, got %q", app.apiKeyInput.Value()) + } + }, + }, { name: "runtime chunk appends assistant draft", setup: func(t *testing.T, app *App, runtime *stubRuntime) { @@ -317,6 +356,8 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { app.openModelPicker() app.closePicker() + app.openAPIKeyInput() + app.closePicker() app.selectCurrentModel(provideropenai.DefaultModel) app.appendAssistantChunk("hello") app.appendAssistantChunk(" world") @@ -358,6 +399,10 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if app.renderPicker(48, 12) == "" || app.renderWaterfall(80, 20) == "" { t.Fatalf("expected model picker rendering") } + app.state.ActivePicker = pickerAPIKey + if app.renderPicker(48, 12) == "" || app.renderWaterfall(80, 20) == "" { + t.Fatalf("expected api key picker rendering") + } app.state.ActivePicker = pickerNone if app.renderCommandMenu(80) == "" { app.input.SetValue("/") @@ -511,6 +556,14 @@ func TestTUIStandaloneHelpers(t *testing.T) { if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil { t.Fatalf("expected successful localCommandResultMsg, got %+v", msg) } + msg = runAPIKeyEnvSelection(newTestProviderService(t, manager), "CUSTOM_OPENAI_KEY")() + if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil || !strings.Contains(result.notice, "CUSTOM_OPENAI_KEY") { + t.Fatalf("expected successful api key env result, got %+v", msg) + } + msg = runAPIKeyEnvSelection(newTestProviderService(t, manager), " ")() + if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil || !strings.Contains(result.notice, "restored") { + t.Fatalf("expected restore-default result, got %+v", msg) + } _ = model } @@ -895,6 +948,82 @@ func TestAppUpdateProviderPickerEnterAppliesSelection(t *testing.T) { } } +func TestAppUpdateAPIKeyPickerEnterAppliesOverride(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.openAPIKeyInput() + app.apiKeyInput.SetValue("CUSTOM_OPENAI_KEY") + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected picker to close after selection") + } + + for _, msg := range collectTeaMessages(cmd) { + model, follow := app.Update(msg) + app = model.(App) + _ = collectTeaMessages(follow) + } + + cfg := manager.Get() + if cfg.APIKeyEnvOverride != "CUSTOM_OPENAI_KEY" { + t.Fatalf("expected override to persist, got %q", cfg.APIKeyEnvOverride) + } + if app.state.CurrentAPIKeyEnv != "CUSTOM_OPENAI_KEY" { + t.Fatalf("expected current api key env to refresh, got %q", app.state.CurrentAPIKeyEnv) + } + if !strings.Contains(app.state.StatusText, "CUSTOM_OPENAI_KEY") { + t.Fatalf("expected success status to mention override, got %q", app.state.StatusText) + } +} + +func TestAppUpdateAPIKeyPickerEnterClearsOverride(t *testing.T) { + manager := newTestConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.APIKeyEnvOverride = "CUSTOM_OPENAI_KEY" + return nil + }); err != nil { + t.Fatalf("seed api key override: %v", err) + } + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.openAPIKeyInput() + app.apiKeyInput.SetValue(" ") + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected picker to close after clearing override") + } + + for _, msg := range collectTeaMessages(cmd) { + model, follow := app.Update(msg) + app = model.(App) + _ = collectTeaMessages(follow) + } + + cfg := manager.Get() + if cfg.APIKeyEnvOverride != "" { + t.Fatalf("expected override to clear, got %q", cfg.APIKeyEnvOverride) + } + if app.state.CurrentAPIKeyEnv != provideropenai.DefaultAPIKeyEnv { + t.Fatalf("expected current api key env to fall back to provider default, got %q", app.state.CurrentAPIKeyEnv) + } + if !strings.Contains(strings.ToLower(app.state.StatusText), "default") { + t.Fatalf("expected restore-default status, got %q", app.state.StatusText) + } +} + func TestRefreshPickerKeepsSizeAfterRebuild(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() diff --git a/internal/tui/view.go b/internal/tui/view.go index 32ec3273..69d78a8b 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -159,6 +159,10 @@ func (a App) renderPicker(width int, height int) string { title = providerPickerTitle subtitle = providerPickerSubtitle body = a.providerPicker.View() + } else if a.state.ActivePicker == pickerAPIKey { + title = apiKeyInputTitle + subtitle = apiKeyInputSubtitle + body = a.apiKeyInput.View() } content := lipgloss.JoinVertical( lipgloss.Left, From 800f7bf0cdb945578e3b48af6e9c915100ba527e Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 30 Mar 2026 18:00:13 +0800 Subject: [PATCH 10/11] =?UTF-8?q?add:=E5=A2=9E=E5=8A=A0=E4=B8=83=E7=89=9B?= =?UTF-8?q?=E4=BA=91=E4=BE=9B=E5=BA=94=E5=95=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +++ docs/config-management-detail-design.md | 1 + internal/provider/builtin/builtin.go | 2 ++ internal/provider/builtin/builtin_test.go | 8 +++-- internal/provider/qiniuyun/qiniuyun.go | 32 ++++++++++++++++++ internal/provider/qiniuyun/qiniuyun_test.go | 37 +++++++++++++++++++++ internal/provider/registry_test.go | 8 +++-- 7 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 internal/provider/qiniuyun/qiniuyun.go create mode 100644 internal/provider/qiniuyun/qiniuyun_test.go diff --git a/README.md b/README.md index af7c1ebf..76be6880 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ NeoCode 是一个基于 Go 和 Bubble Tea 的本地 Coding Agent MVP。 | `openai` | `gpt-4.1` | `OPENAI_API_KEY` | 默认配置 | | `gemini` | `gemini-2.5-flash` | `GEMINI_API_KEY` | 走 OpenAI-compatible 接口 | | `openll` | `gpt-4.1` | `AI_API_KEY` | 自定义 OpenAI-compatible 入口 | +| `qiniuyun` | `deepseek/deepseek-v3.2-251201` | `QINIUYUN_API_KEY` | 七牛云 OpenAI-compatible 入口 | ## 快速开始 @@ -76,6 +77,7 @@ OPENAI_API_KEY=your-api-key - `OPENAI_API_KEY` - `GEMINI_API_KEY` - `AI_API_KEY` +- `QINIUYUN_API_KEY` 你也可以在进入 TUI 后通过 `/apikey` 打开输入框,自定义当前运行时优先读取的 API Key 环境变量名。留空确认会恢复为当前 Provider 的默认环境变量名。 @@ -187,6 +189,8 @@ tools: Provider 的 `base_url`、默认模型列表和 `api_key_env` 由代码内建定义提供,不需要你在 YAML 中手动维护;如果你想全局覆盖当前运行时读取的环境变量名,可以设置顶层的 `api_key_env_override`,留空则回退到当前 Provider 的默认值。 +当前代码内建的 Provider 包括 `openai`、`gemini`、`openll` 和 `qiniuyun`。其中 `qiniuyun` 预置的模型目录为 `z-ai/glm-5`、`minimax/minimax-m2.5`、`moonshotai/kimi-k2.5` 与 `deepseek/deepseek-v3.2-251201`。 + ## 工具与安全边界 NeoCode 当前默认遵守这些约束: diff --git a/docs/config-management-detail-design.md b/docs/config-management-detail-design.md index d27d258e..8aa4fda0 100644 --- a/docs/config-management-detail-design.md +++ b/docs/config-management-detail-design.md @@ -25,6 +25,7 @@ ## 默认值治理 - 默认 Provider 名称、URL、默认模型、模型列表和环境变量名统一由内建 Provider 定义提供 +- 当前内建 Provider 包括 `openai`、`gemini`、`openll` 和 `qiniuyun`;其中 `qiniuyun` 复用 OpenAI-compatible driver,并默认读取 `QINIUYUN_API_KEY` - `Loader` 在加载旧配置时会丢弃 `providers` / `provider_overrides`,重新回到“YAML 只保存选择状态和顶层 override”的最小结构 - Provider 的可选模型目录属于实例配置,进入运行时 `Config` 后再提供给 TUI 和 runtime,避免 TUI 或 driver 自己维护一套零散常量 diff --git a/internal/provider/builtin/builtin.go b/internal/provider/builtin/builtin.go index 3d4fcb61..5ac7e214 100644 --- a/internal/provider/builtin/builtin.go +++ b/internal/provider/builtin/builtin.go @@ -8,6 +8,7 @@ import ( "neo-code/internal/provider/gemini" "neo-code/internal/provider/openai" "neo-code/internal/provider/openll" + "neo-code/internal/provider/qiniuyun" ) func DefaultConfig() *config.Config { @@ -17,6 +18,7 @@ func DefaultConfig() *config.Config { defaultProvider, gemini.BuiltinConfig(), openll.BuiltinConfig(), + qiniuyun.BuiltinConfig(), } cfg.SelectedProvider = defaultProvider.Name cfg.CurrentModel = defaultProvider.Model diff --git a/internal/provider/builtin/builtin_test.go b/internal/provider/builtin/builtin_test.go index c047c335..3df8e7f9 100644 --- a/internal/provider/builtin/builtin_test.go +++ b/internal/provider/builtin/builtin_test.go @@ -6,14 +6,15 @@ import ( "neo-code/internal/provider/gemini" "neo-code/internal/provider/openai" "neo-code/internal/provider/openll" + "neo-code/internal/provider/qiniuyun" ) func TestDefaultConfigIncludesBuiltinProviders(t *testing.T) { t.Parallel() cfg := DefaultConfig() - if len(cfg.Providers) != 3 { - t.Fatalf("expected 3 builtin providers, got %d", len(cfg.Providers)) + if len(cfg.Providers) != 4 { + t.Fatalf("expected 4 builtin providers, got %d", len(cfg.Providers)) } if cfg.Providers[0].Name != openai.Name { t.Fatalf("expected first provider %q, got %q", openai.Name, cfg.Providers[0].Name) @@ -24,6 +25,9 @@ func TestDefaultConfigIncludesBuiltinProviders(t *testing.T) { if cfg.Providers[2].Name != openll.Name { t.Fatalf("expected third provider %q, got %q", openll.Name, cfg.Providers[2].Name) } + if cfg.Providers[3].Name != qiniuyun.Name { + t.Fatalf("expected fourth provider %q, got %q", qiniuyun.Name, cfg.Providers[3].Name) + } if cfg.SelectedProvider != openai.Name { t.Fatalf("expected selected provider %q, got %q", openai.Name, cfg.SelectedProvider) } diff --git a/internal/provider/qiniuyun/qiniuyun.go b/internal/provider/qiniuyun/qiniuyun.go new file mode 100644 index 00000000..b73392b6 --- /dev/null +++ b/internal/provider/qiniuyun/qiniuyun.go @@ -0,0 +1,32 @@ +package qiniuyun + +import ( + "neo-code/internal/config" + "neo-code/internal/provider/openai" +) + +const ( + Name = "qiniuyun" + DriverName = openai.DriverName + DefaultBaseURL = "https://api.qnaigc.com/v1" + DefaultModel = "deepseek/deepseek-v3.2-251201" + DefaultAPIKeyEnv = "QINIUYUN_API_KEY" +) + +var builtinModels = []string{ + "z-ai/glm-5", + "minimax/minimax-m2.5", + "moonshotai/kimi-k2.5", + DefaultModel, +} + +func BuiltinConfig() config.ProviderConfig { + return config.ProviderConfig{ + Name: Name, + Driver: DriverName, + BaseURL: DefaultBaseURL, + Model: DefaultModel, + Models: append([]string(nil), builtinModels...), + APIKeyEnv: DefaultAPIKeyEnv, + } +} diff --git a/internal/provider/qiniuyun/qiniuyun_test.go b/internal/provider/qiniuyun/qiniuyun_test.go new file mode 100644 index 00000000..6ac7395c --- /dev/null +++ b/internal/provider/qiniuyun/qiniuyun_test.go @@ -0,0 +1,37 @@ +package qiniuyun + +import ( + "testing" + + "neo-code/internal/provider/openai" +) + +func TestBuiltinConfigUsesOpenAICompatibleDriver(t *testing.T) { + t.Parallel() + + cfg := BuiltinConfig() + if cfg.Name != Name { + t.Fatalf("expected provider name %q, got %q", Name, cfg.Name) + } + if cfg.Driver != openai.DriverName { + t.Fatalf("expected driver %q, got %q", openai.DriverName, cfg.Driver) + } + if cfg.BaseURL != DefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", DefaultBaseURL, cfg.BaseURL) + } + if cfg.Model != DefaultModel { + t.Fatalf("expected default model %q, got %q", DefaultModel, cfg.Model) + } + if cfg.APIKeyEnv != DefaultAPIKeyEnv { + t.Fatalf("expected API key env %q, got %q", DefaultAPIKeyEnv, cfg.APIKeyEnv) + } + if len(cfg.Models) != 4 { + t.Fatalf("expected 4 builtin models, got %+v", cfg.Models) + } + if cfg.Models[0] != "z-ai/glm-5" { + t.Fatalf("expected first builtin model %q, got %+v", "z-ai/glm-5", cfg.Models) + } + if cfg.Models[len(cfg.Models)-1] != DefaultModel { + t.Fatalf("expected builtin models to include default model %q, got %+v", DefaultModel, cfg.Models) + } +} diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 0103aa7c..03e56780 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -12,6 +12,7 @@ import ( "neo-code/internal/provider/gemini" "neo-code/internal/provider/openai" "neo-code/internal/provider/openll" + "neo-code/internal/provider/qiniuyun" ) type stubProvider struct{} @@ -125,9 +126,10 @@ func TestServiceListProvidersUsesConfiguredMetadata(t *testing.T) { t.Fatalf("ListProviders() error = %v", err) } expectedModels := map[string]int{ - openai.Name: len(openai.BuiltinConfig().Models), - gemini.Name: len(gemini.BuiltinConfig().Models), - openll.Name: len(openll.BuiltinConfig().Models), + openai.Name: len(openai.BuiltinConfig().Models), + gemini.Name: len(gemini.BuiltinConfig().Models), + openll.Name: len(openll.BuiltinConfig().Models), + qiniuyun.Name: len(qiniuyun.BuiltinConfig().Models), } if len(items) != len(expectedModels) { t.Fatalf("expected only supported providers, got %d", len(items)) From 02190e28f679881678a5f569e4650e85430939b9 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 30 Mar 2026 19:27:38 +0800 Subject: [PATCH 11/11] =?UTF-8?q?fix:=E6=B8=85=E7=90=86=20README=20?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/README.md b/README.md index 2b1ebd2c..f796cf7c 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,5 @@ # NeoCode -<<<<<<< HEAD -> 基于 Go + Bubble Tea 的本地 Coding Agent - -NeoCode 是一个基于 Go 和 Bubble Tea 的本地 Coding Agent MVP。 - -它当前聚焦一条最重要的主链路: - -`用户输入 -> Agent 推理 -> 调用工具 -> 获取结果 -> 继续推理 -> UI 展示` - -如果你是第一次打开这个仓库,可以先把它理解成一个运行在终端里的本地 AI 编码助手。它会在 TUI 中接收你的问题,调用模型进行推理,在需要时使用本地工具,再把过程和结果实时展示出来。 - -## 这份 README 适合谁 - -- 想先把项目跑起来的新同学 -- 想快速看懂仓库分层的贡献者 -- 想知道配置文件、Provider、Tools 应该放在哪一层的协作者 - -如果你想直接看详细设计,可以跳到本文末尾的“文档索引”。 - -## 当前能力 - -======= NeoCode 是一个基于 Go 和 Bubble Tea 的本地 Coding Agent MVP。 它当前聚焦一条最重要的主链路: @@ -40,7 +18,6 @@ NeoCode 是一个基于 Go 和 Bubble Tea 的本地 Coding Agent MVP。 ## 当前能力 ->>>>>>> 93a2f3f92c3f7bcfefce1a6a73f1373a975b5617 当前仓库已经围绕 MVP 主链路组织,重点在于把边界理顺,而不是一次性做很多功能。 - 终端 TUI 交互界面 @@ -276,11 +253,8 @@ gofmt -w ./cmd ./internal ## 文档索引 -<<<<<<< HEAD - `docs/guides/configuration.md` - `docs/guides/adding-providers.md` -======= ->>>>>>> 93a2f3f92c3f7bcfefce1a6a73f1373a975b5617 - `docs/neocode-coding-agent-mvp-architecture.md` - `docs/runtime-provider-event-flow.md` - `docs/config-management-detail-design.md` @@ -291,10 +265,6 @@ gofmt -w ./cmd ./internal ## 一句话总结 NeoCode 当前不是一个“功能很多”的 Agent,而是一个把主链路、分层和可验证性打磨清楚的 Coding Agent MVP。对新同学来说,最好的阅读顺序是:先跑起来,再看 `README` 的分层说明,最后按需进入 `docs/` 深入细节。 -<<<<<<< HEAD - ## License MIT -======= ->>>>>>> 93a2f3f92c3f7bcfefce1a6a73f1373a975b5617