From 61699324f5c591320966ce90c7730b0b67eeea3c Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 30 Mar 2026 14:16:59 +0800 Subject: [PATCH 1/2] =?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 7832659d28d9eecaa8040f4993e23cd80a840b25 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 30 Mar 2026 14:53:48 +0800 Subject: [PATCH 2/2] =?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 {