diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index 2c25ac13..a42261cb 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -181,24 +181,24 @@ func newApp(container tuibootstrap.Container) (App, error) { return "> " }) input.FocusedStyle.Base = lipgloss.NewStyle() - input.FocusedStyle.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)).Bold(true) - 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(lightText)).Bold(true) + input.FocusedStyle.Text = lipgloss.NewStyle().Foreground(lipgloss.Color(lightText)) + input.FocusedStyle.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color(oliveGray)) input.FocusedStyle.CursorLine = lipgloss.NewStyle() input.FocusedStyle.CursorLineNumber = lipgloss.NewStyle() input.BlurredStyle.Base = lipgloss.NewStyle() - input.BlurredStyle.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)).Bold(true) - input.BlurredStyle.Text = lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) - input.BlurredStyle.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color(colorSubtle)) + input.BlurredStyle.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color(lightText)).Bold(true) + input.BlurredStyle.Text = lipgloss.NewStyle().Foreground(lipgloss.Color(lightText)) + input.BlurredStyle.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color(oliveGray)) input.BlurredStyle.CursorLine = lipgloss.NewStyle() input.BlurredStyle.CursorLineNumber = lipgloss.NewStyle() - input.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) + input.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(purpleAccent)) input.SetHeight(composerMinHeight) input.Focus() spin := spinner.New() spin.Spinner = spinner.Line - spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorPrimary)) + spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(purpleAccent)) h := help.New() h.ShowAll = false diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index 4dba1c6f..47ff7d2b 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -52,11 +52,8 @@ const ( filePickerTitle = "Browse Files" filePickerSubtitle = "Navigate folders, Enter choose file, Esc cancel" - sidebarTitle = "Sessions" - sidebarFilterHint = "Type / to search" - sidebarOpenHint = "Enter to open" - activityTitle = "Activity" - activitySubtitle = "Latest execution events" + activityTitle = "Activity" + activitySubtitle = "Latest execution events" draftSessionTitle = "Draft" emptyConversationText = "No conversation yet.\nAsk NeoCode to inspect or change code, or type /help to browse local commands." diff --git a/internal/tui/core/app/styles.go b/internal/tui/core/app/styles.go index 83c44a47..9a48c67e 100644 --- a/internal/tui/core/app/styles.go +++ b/internal/tui/core/app/styles.go @@ -7,30 +7,35 @@ import ( ) const ( - colorPrimary = "#7AA2F7" - colorUser = "#4FD6BE" - colorBorder = "#2B3342" - colorError = "#F7768E" - colorSuccess = "#73DACA" - colorText = "#DCE3F0" - colorSubtle = "#7E8AA3" - colorBg = "#090C12" - colorPanel = "#11161F" - colorPanelAlt = "#151C27" - colorCode = "#0E131B" - colorInk = "#081018" - colorWarning = "#E0AF68" + purpleBg = "#1a1625" + purpleBg2 = "#251f35" + purpleSurface = "#352d47" + + lightText = "#e8e6f0" + lightText2 = "#c8c6d8" + midGray = "#7a7890" + + purpleAccent = "#a78bfa" + purpleLight = "#c4b5fd" + coralAccent = "#f09070" + + errorRed = "#f87171" + successGreen = "#34d399" + warningYellow = "#fbbf24" + + charcoal = "#4a4560" + oliveGray = "#6b6588" + stoneGray = "#9089a8" + warmSilver = "#a9a4b8" + + borderDark = "#3d3654" + borderLight = "#4a4268" ) type styles struct { doc lipgloss.Style headerBar lipgloss.Style headerBrand lipgloss.Style - headerLabel lipgloss.Style - headerPath lipgloss.Style - headerSub lipgloss.Style - headerMeta lipgloss.Style - headerSpacer lipgloss.Style panel lipgloss.Style panelFocused lipgloss.Style panelTitle lipgloss.Style @@ -48,10 +53,8 @@ type styles struct { streamContent lipgloss.Style messageUserTag lipgloss.Style messageAgentTag lipgloss.Style - messageToolTag lipgloss.Style messageBody lipgloss.Style messageUserBody lipgloss.Style - messageToolBody lipgloss.Style inlineNotice lipgloss.Style inlineError lipgloss.Style inlineSystem lipgloss.Style @@ -77,168 +80,124 @@ type styles struct { } func newStyles() styles { + subtleText := lipgloss.AdaptiveColor{Light: oliveGray, Dark: lightText2} + headerAccent := lipgloss.AdaptiveColor{Light: coralAccent, Dark: purpleLight} + panel := lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(colorBorder)). - UnsetBackground(). + BorderForeground(lipgloss.Color(borderDark)). Padding(0, 1) return styles{ doc: lipgloss.NewStyle(). Padding(1, 2, 0, 2). - UnsetBackground(). - Foreground(lipgloss.Color(colorText)), + UnsetBackground(), headerBar: lipgloss.NewStyle(). - UnsetBackground(). - Foreground(lipgloss.Color(colorText)), + UnsetBackground(), headerBrand: lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color(colorPrimary)). + Foreground(headerAccent). UnsetBackground(). Padding(0, 1), - headerLabel: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)), - headerPath: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorText)), - headerSub: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)), - headerMeta: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorText)), - headerSpacer: lipgloss.NewStyle(). - Width(1). - UnsetBackground(), panel: panel, panelFocused: panel.Copy(). - BorderForeground(lipgloss.Color(colorPrimary)), + BorderForeground(lipgloss.Color(purpleAccent)), panelTitle: lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color(colorText)), + Foreground(lipgloss.Color(lightText)), panelSubtitle: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)), + Foreground(lipgloss.Color(oliveGray)), panelBody: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorText)). - UnsetBackground(), + Foreground(lipgloss.Color(lightText)), empty: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)). + Foreground(lipgloss.Color(midGray)). Padding(1, 0), sessionRow: lipgloss.NewStyle(). Padding(0, 1). - Foreground(lipgloss.Color(colorText)). - UnsetBackground(), + Foreground(lipgloss.Color(lightText)), sessionRowActive: lipgloss.NewStyle(). Padding(0, 1). - Foreground(lipgloss.Color(colorText)). - UnsetBackground(), + Foreground(lipgloss.Color(lightText)), sessionRowFocused: lipgloss.NewStyle(). Padding(0, 1). - Foreground(lipgloss.Color(colorText)). - UnsetBackground(). + Foreground(lipgloss.Color(lightText)). Bold(true), sessionMeta: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)), + Foreground(lipgloss.Color(oliveGray)), sessionMetaActive: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#94A0B8")), + Foreground(lipgloss.Color(lightText)), sessionMetaFocus: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#AFC3FF")), + Foreground(lipgloss.Color(purpleAccent)), streamTitle: lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color(colorText)), + Foreground(lipgloss.Color(lightText)), streamMeta: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)), + Foreground(lipgloss.Color(oliveGray)), streamContent: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorText)). - UnsetBackground(), - messageUserTag: tagStyle(colorUser), - messageAgentTag: tagStyle(colorPrimary), - messageToolTag: tagStyle(colorSuccess), + Foreground(lipgloss.Color(lightText)), + messageUserTag: tagStyle(purpleAccent), + messageAgentTag: tagStyle(purpleLight), messageBody: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorText)). - UnsetBackground(). - Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(colorBorder)). - Padding(0, 0), + Foreground(lipgloss.Color(lightText2)). + MarginLeft(2), messageUserBody: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorText)). - UnsetBackground(). - Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(colorUser)). - Padding(0, 0), - messageToolBody: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSuccess)). - UnsetBackground(). - Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(colorSuccess)). - Padding(0, 0), + Foreground(lipgloss.Color(purpleAccent)). + Bold(true). + MarginRight(8), inlineNotice: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)). - UnsetBackground(). - Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(colorBorder)). - Padding(0, 0). + Foreground(lipgloss.Color(oliveGray)). Italic(true), inlineError: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorError)). - UnsetBackground(). - Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(colorError)). - Padding(0, 0). + Foreground(lipgloss.Color(errorRed)). Bold(true), inlineSystem: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)). - UnsetBackground(). - Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(colorBorder)). - Padding(0, 0), + Foreground(lipgloss.Color(oliveGray)), codeBlock: lipgloss.NewStyle(). MarginLeft(1). - Padding(0, 0). + Padding(1, 0). UnsetBackground(). Border(lipgloss.NormalBorder()). BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(colorPrimary)), + BorderForeground(lipgloss.Color(borderLight)), codeText: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorText)), + Foreground(lipgloss.Color(warmSilver)), codeCopyButton: lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color(colorPrimary)). + Foreground(lipgloss.Color(coralAccent)). Underline(true), commandMenu: lipgloss.NewStyle(). - UnsetBackground(). - Padding(1, 1), + MarginTop(1), commandMenuTitle: lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color(colorPrimary)), + Foreground(lipgloss.Color(purpleAccent)), commandUsage: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorText)), + Foreground(lipgloss.Color(lightText)), commandUsageMatch: lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color(colorPrimary)), + Foreground(lipgloss.Color(purpleAccent)), commandDesc: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)), + Foreground(lipgloss.Color(oliveGray)), inputPrefix: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorUser)). + Foreground(lipgloss.Color(purpleAccent)). Bold(true), inputLine: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorText)), + Foreground(lipgloss.Color(lightText)), inputBox: lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(colorBorder)). - UnsetBackground(). + BorderForeground(lipgloss.Color(borderDark)). Padding(0, 1), inputBoxFocused: lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(colorPrimary)). - UnsetBackground(). + BorderForeground(lipgloss.Color(purpleAccent)). Padding(0, 1), footer: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)). - UnsetBackground(), - badgeUser: badge("#12202D", colorUser), - badgeAgent: badge("#16233A", colorPrimary), - badgeSuccess: badge("#102018", colorSuccess), - badgeWarning: badge("#241B10", colorWarning), - badgeError: badge("#26131A", colorError), - badgeMuted: badge(colorPanelAlt, colorSubtle), + Foreground(subtleText), + badgeUser: badge("", purpleAccent), + badgeAgent: badge("", coralAccent), + badgeSuccess: badge("", successGreen), + badgeWarning: badge("", warningYellow), + badgeError: badge("", errorRed), + badgeMuted: badge("", stoneGray), } } @@ -246,7 +205,6 @@ func tagStyle(fg string) lipgloss.Style { return lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color(fg)). - Background(lipgloss.Color(colorBg)). Padding(0, 1) } @@ -254,7 +212,6 @@ func badge(bg string, fg string) lipgloss.Style { return lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color(fg)). - UnsetBackground(). Padding(0, 1) } diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index c117524f..a5515968 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -1276,13 +1276,8 @@ func (a App) transcriptBounds() (int, int, int, int) { streamX := contentX streamY := bodyY - if lay.stacked { - streamY += lay.sidebarHeight - } else { - streamX += lay.sidebarWidth + lay.bodyGap - } - return streamX, streamY, lay.rightWidth, a.transcript.Height + return streamX, streamY, lay.contentWidth, a.transcript.Height } func (a App) isMouseWithinInput(msg tea.MouseMsg) bool { @@ -1302,15 +1297,10 @@ func (a App) inputBounds() (int, int, int, int) { streamX := contentX streamY := bodyY - if lay.stacked { - streamY += lay.sidebarHeight - } else { - streamX += lay.sidebarWidth + lay.bodyGap - } - inputY := streamY + a.transcript.Height + a.activityPreviewHeight() + a.commandMenuHeight(max(24, lay.rightWidth)) - inputHeight := lipgloss.Height(a.renderPrompt(max(24, lay.rightWidth))) - return streamX, inputY, lay.rightWidth, inputHeight + inputY := streamY + a.transcript.Height + a.activityPreviewHeight() + a.commandMenuHeight(lay.contentWidth) + inputHeight := lipgloss.Height(a.renderPrompt(lay.contentWidth)) + return streamX, inputY, lay.contentWidth, inputHeight } func (a App) activityBounds() (int, int, int, int) { @@ -1322,17 +1312,12 @@ func (a App) activityBounds() (int, int, int, int) { streamX := contentX streamY := bodyY - if lay.stacked { - streamY += lay.sidebarHeight - } else { - streamX += lay.sidebarWidth + lay.bodyGap - } activityHeight := a.activityPreviewHeight() if activityHeight <= 0 { - return streamX, streamY + a.transcript.Height, lay.rightWidth, 0 + return streamX, streamY + a.transcript.Height, lay.contentWidth, 0 } - return streamX, streamY + a.transcript.Height, lay.rightWidth, activityHeight + return streamX, streamY + a.transcript.Height, lay.contentWidth, activityHeight } func (a App) isMouseWithinActivity(msg tea.MouseMsg) bool { @@ -1472,16 +1457,11 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { prevActivityWidth := a.activity.Width prevActivityHeight := a.activity.Height a.help.ShowAll = a.state.ShowHelp - sidebarFrameWidth := a.styles.panelFocused.GetHorizontalFrameSize() - sidebarFrameHeight := a.styles.panelFocused.GetVerticalFrameSize() - sidebarBodyWidth := max(14, lay.sidebarWidth-sidebarFrameWidth) - sidebarBodyHeight := max(4, lay.sidebarHeight-sidebarFrameHeight-lipgloss.Height(a.renderSidebarHeader(sidebarBodyWidth))) - a.sessions.SetSize(sidebarBodyWidth, sidebarBodyHeight) - a.transcript.Width = max(24, lay.rightWidth) + a.transcript.Width = lay.contentWidth a.resizeCommandMenu() - a.input.SetWidth(a.composerInnerWidth(lay.rightWidth)) + a.input.SetWidth(a.composerInnerWidth(lay.contentWidth)) a.input.SetHeight(a.composerHeight()) - transcriptHeight, activityHeight, _, _ := a.waterfallMetrics(a.transcript.Width, lay.rightHeight) + transcriptHeight, activityHeight, _, _ := a.waterfallMetrics(a.transcript.Width, lay.contentHeight) a.transcript.Height = transcriptHeight if activityHeight > 0 { @@ -1489,25 +1469,25 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { frameHeight := panelStyle.GetVerticalFrameSize() borderWidth := 2 paddingWidth := panelStyle.GetHorizontalFrameSize() - borderWidth - panelWidth := max(1, lay.rightWidth-borderWidth) + panelWidth := max(1, lay.contentWidth-borderWidth) bodyWidth := max(10, panelWidth-paddingWidth) bodyHeight := max(1, activityHeight-frameHeight-1) a.activity.Width = bodyWidth a.activity.Height = bodyHeight } else { - a.activity.Width = max(10, lay.rightWidth-4) + a.activity.Width = max(10, lay.contentWidth-4) a.activity.Height = 0 } - a.providerPicker.SetSize(max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.rightHeight-10, 6, 10))) - a.modelPicker.SetSize(max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.rightHeight-10, 6, 10))) - helpPickerMaxHeight := max(8, lay.rightHeight-6) + a.providerPicker.SetSize(max(24, tuiutils.Clamp(lay.contentWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.contentHeight-10, 6, 10))) + a.modelPicker.SetSize(max(24, tuiutils.Clamp(lay.contentWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.contentHeight-10, 6, 10))) + helpPickerMaxHeight := max(8, lay.contentHeight-6) helpPickerDesiredHeight := (len(a.helpPicker.Items()) * 3) + 1 a.helpPicker.SetSize( - max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), + max(24, tuiutils.Clamp(lay.contentWidth-14, 28, 52)), max(6, tuiutils.Clamp(helpPickerDesiredHeight, 6, helpPickerMaxHeight)), ) - a.fileBrowser.SetHeight(max(6, tuiutils.Clamp(lay.rightHeight-8, 8, 16))) + a.fileBrowser.SetHeight(max(6, tuiutils.Clamp(lay.contentHeight-8, 8, 16))) if rebuildTranscript || prevTranscriptWidth != a.transcript.Width { a.rebuildTranscript() } else if a.transcript.AtBottom() || a.isBusy() { @@ -1519,11 +1499,11 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { } func (a App) composerBoxWidth(totalWidth int) int { - return max(8, totalWidth-2) + return totalWidth } func (a App) composerInnerWidth(totalWidth int) int { - return max(4, a.composerBoxWidth(totalWidth)-a.styles.inputBoxFocused.GetHorizontalFrameSize()) + return max(4, totalWidth-a.styles.inputBoxFocused.GetHorizontalFrameSize()) } func (a App) composerHeight() int { @@ -1558,7 +1538,13 @@ func (a *App) rebuildTranscript() { copyButtons := make([]copyCodeButtonBinding, 0, 4) nextCopyID := 1 for _, message := range a.activeMessages { + if message.Role == roleTool { + continue + } rendered, bindings := a.renderMessageBlockWithCopy(message, width, nextCopyID) + if rendered == "" { + continue + } blocks = append(blocks, rendered) copyButtons = append(copyButtons, bindings...) nextCopyID += len(bindings) diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index dfc2e776..661b8c35 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -731,21 +731,15 @@ func TestComputeLayoutStackedAndWide(t *testing.T) { app.width = 90 app.height = 40 layout := app.computeLayout() - if !layout.stacked { - t.Fatalf("expected stacked layout for narrow width") - } - if layout.rightWidth <= 0 || layout.sidebarWidth <= 0 { - t.Fatalf("expected positive layout widths, got %+v", layout) + if layout.contentWidth <= 0 { + t.Fatalf("expected positive content width, got %+v", layout) } app.width = 140 app.height = 40 layout = app.computeLayout() - if layout.stacked { - t.Fatalf("expected non-stacked layout for wide width") - } - if layout.rightWidth <= 0 || layout.sidebarWidth <= 0 { - t.Fatalf("expected positive layout widths, got %+v", layout) + if layout.contentWidth <= 0 { + t.Fatalf("expected positive content width, got %+v", layout) } } diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 7247f218..355dd223 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/lipgloss" providertypes "neo-code/internal/provider/types" @@ -14,23 +13,17 @@ import ( ) type layout struct { - stacked bool contentWidth int contentHeight int - sidebarWidth int - sidebarHeight int - rightWidth int - rightHeight int - bodyGap int } -const headerBarHeight = 1 +const headerBarHeight = 2 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") + if docWidth < 60 || docHeight < 20 { + return strings.TrimRight(a.styles.doc.Render(lipgloss.Place(docWidth, docHeight, lipgloss.Left, lipgloss.Top, "Window too small.\nPlease resize to at least 60x20.")), "\n") } lay := a.computeLayout() @@ -49,93 +42,36 @@ func (a App) View() string { } func (a App) renderHeader(width int) string { - status := tuicomponents.CompactStatusText(a.state.StatusText, max(18, width/3)) + status := compactStatusText(a.state.StatusText, max(18, width/3)) if a.state.IsAgentRunning { if a.runProgressKnown { - progressBar := a.progress - progressBar.Width = tuiutils.Clamp(width/7, 12, 26) progressLabel := tuiutils.Fallback(strings.TrimSpace(a.runProgressLabel), tuiutils.Fallback(status, statusRunning)) - status = progressBar.ViewAs(a.runProgressValue) + " " + progressLabel - } else { - status = a.spinner.View() + " " + tuiutils.Fallback(status, statusRunning) + percent := int(a.runProgressValue*100 + 0.5) + status = fmt.Sprintf("%d%% %s", percent, progressLabel) + } else if status != statusThinking { + status = tuiutils.Fallback(status, statusRunning) } } + status = tuiutils.Fallback(status, statusReady) - brand := lipgloss.JoinHorizontal( - lipgloss.Center, - a.styles.headerBrand.Render("NeoCode"), - ) - - meta := lipgloss.JoinHorizontal( - lipgloss.Top, - a.styles.badgeAgent.Render(a.state.CurrentProvider), - a.styles.badgeUser.Render(a.state.CurrentModel), - a.styles.badgeMuted.Render(a.focusLabel()), - a.statusBadge(status), - ) - - spacerWidth := lipgloss.Width(a.styles.headerSpacer.Render("")) - workdirLabel := a.styles.headerLabel.Render("Workdir") - workdirWidth := max(12, width-lipgloss.Width(brand)-lipgloss.Width(meta)-(spacerWidth*2)-lipgloss.Width(workdirLabel)-1) - workdir := lipgloss.JoinHorizontal( - lipgloss.Center, - workdirLabel, - " ", - a.styles.headerPath.Render(tuiutils.TrimMiddle(a.state.CurrentWorkdir, workdirWidth)), - ) - - header := lipgloss.JoinHorizontal( - lipgloss.Center, - brand, - a.styles.headerSpacer.Render(""), - workdir, - a.styles.headerSpacer.Render(""), - meta, - ) - return a.styles.headerBar.Width(width).Render(lipgloss.Place(width, 1, lipgloss.Left, lipgloss.Top, header)) -} - -func (a App) renderBody(lay layout) string { - sidebar := a.renderSidebar(lay.sidebarWidth, lay.sidebarHeight) - stream := a.renderWaterfall(lay.rightWidth, lay.rightHeight) - if lay.stacked { - return lipgloss.JoinVertical(lipgloss.Left, sidebar, stream) + model := tuiutils.Fallback(strings.TrimSpace(a.state.CurrentModel), "unknown-model") + headerText := fmt.Sprintf("NeoCode | %s | %s", model, status) + if lipgloss.Width(headerText) > width { + headerText = tuiutils.TrimMiddle(headerText, max(8, width)) } - return lipgloss.JoinHorizontal( - lipgloss.Top, - sidebar, - lipgloss.NewStyle().Width(lay.bodyGap).Render(""), - stream, - ) + return a.styles.headerBar.Width(width).Height(headerBarHeight).Render(headerText) } -func (a App) renderSidebar(width int, height int) string { - style := a.styles.panel - if a.focus == panelSessions { - style = a.styles.panelFocused - } - - frameHeight := style.GetVerticalFrameSize() - borderWidth := 2 - paddingWidth := style.GetHorizontalFrameSize() - borderWidth - panelWidth := max(1, width-borderWidth) - bodyWidth := max(10, panelWidth-paddingWidth) - header := a.renderSidebarHeader(bodyWidth) - bodyHeight := max(3, height-frameHeight-lipgloss.Height(header)) - a.sessions.SetSize(bodyWidth, bodyHeight) - body := a.styles.panelBody.Width(bodyWidth).Height(bodyHeight).Render(a.sessions.View()) - - panel := style.Width(panelWidth).Height(max(1, height-frameHeight)).Render(lipgloss.JoinVertical(lipgloss.Left, header, body)) - return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, panel) +func (a App) renderBody(lay layout) string { + return a.renderWaterfall(lay.contentWidth, lay.contentHeight) } // waterfallMetrics 统一计算瀑布区各组件高度,确保渲染、布局与命中区域使用同一组尺寸。 func (a App) waterfallMetrics(width int, height int) (int, int, int, int) { activityHeight := a.activityPreviewHeight() menuHeight := a.commandMenuHeight(width) - promptHeight := lipgloss.Height(a.renderPrompt(width)) - transcriptHeight := max(6, height-activityHeight-menuHeight-promptHeight) - return transcriptHeight, activityHeight, menuHeight, promptHeight + transcriptHeight := max(6, height-activityHeight-menuHeight) + return transcriptHeight, activityHeight, menuHeight, 0 } func (a App) renderWaterfall(width int, height int) string { @@ -154,6 +90,12 @@ func (a App) renderWaterfall(width int, height int) string { transcript := a.styles.streamContent.Width(width).Height(transcriptHeight).Render(a.transcript.View()) parts := []string{transcript} + if a.state.IsAgentRunning && a.state.StatusText == statusThinking { + thinkingStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(oliveGray)). + Italic(true) + parts = append(parts, thinkingStyle.Render("Thinking...")) + } if activity := a.renderActivityPreview(width); activity != "" { parts = append(parts, activity) } @@ -200,31 +142,17 @@ func (a App) renderPicker(width int, height int) string { } func (a App) renderPrompt(width int) string { + if a.pendingPermission != nil { + box := a.styles.inputBoxFocused + return box.Width(width).Margin(1, 0, 0, 0).Render(a.renderPermissionPrompt()) + } + box := a.styles.inputBox if a.focus == panelInput && a.state.ActivePicker == pickerNone { box = a.styles.inputBoxFocused } - // Account for frame and padding when sizing the composer container. - boxWidth := a.composerBoxWidth(width) - return box.Width(boxWidth).Render(a.renderPermissionPrompt()) -} - -func (a App) renderSidebarHeader(width int) string { - title := a.styles.panelTitle.Render(sidebarTitle) - filterWidth := max(6, width-lipgloss.Width(title)-1) - titleRow := lipgloss.JoinHorizontal( - lipgloss.Center, - title, - lipgloss.NewStyle().Width(1).Render(""), - a.styles.panelSubtitle.Render(tuiutils.TrimRunes(sidebarFilterHint, filterWidth)), - ) - openRow := a.styles.panelSubtitle.Render(tuiutils.TrimRunes(sidebarOpenHint, width)) - return lipgloss.JoinVertical( - lipgloss.Left, - lipgloss.Place(width, 1, lipgloss.Left, lipgloss.Top, titleRow), - lipgloss.Place(width, 1, lipgloss.Left, lipgloss.Top, openRow), - ) + return box.Width(width).Margin(1, 0, 0, 0).Render(a.input.View()) } func (a App) renderPanel(title string, subtitle string, body string, width int, height int, focused bool) string { @@ -264,7 +192,6 @@ func (a App) renderMessageBlockWithCopy(message providertypes.Message, width int tag := messageTagAgent tagStyle := a.styles.messageAgentTag bodyStyle := a.styles.messageBody - blockAlign := lipgloss.Left switch message.Role { case roleUser: @@ -272,11 +199,8 @@ func (a App) renderMessageBlockWithCopy(message providertypes.Message, width int tag = messageTagUser tagStyle = a.styles.messageUserTag bodyStyle = a.styles.messageUserBody - blockAlign = lipgloss.Right case roleTool: - tag = messageTagTool - tagStyle = a.styles.messageToolTag - bodyStyle = a.styles.messageToolBody + return "", nil } content := strings.TrimSpace(message.Content) @@ -300,7 +224,19 @@ func (a App) renderMessageBlockWithCopy(message providertypes.Message, width int } else { contentBlock, copyButtons = a.renderMessageContentWithCopy(content, maxMessageWidth-2, bodyStyle, startCopyID) } - parts := []string{tagStyle.Render(tag), contentBlock} + tagLine := tagStyle.Render(tag) + blockAlign := lipgloss.Left + if message.Role == roleUser { + blockAlign = lipgloss.Right + rightInset := bodyStyle.GetMarginRight() - tagStyle.GetPaddingRight() + if rightInset < 0 { + rightInset = 0 + } + if rightInset > 0 { + tagLine = tagStyle.Copy().MarginRight(rightInset).Render(tag) + } + } + parts := []string{tagLine, contentBlock} block := lipgloss.JoinVertical(blockAlign, parts...) if message.Role == roleUser { @@ -470,22 +406,7 @@ func (a App) computeLayout() layout { helpHeight := a.helpHeight(contentWidth) headerHeight := headerBarHeight contentHeight := max(1, a.height-a.styles.doc.GetVerticalFrameSize()-headerHeight-helpHeight) - lay := layout{contentWidth: contentWidth, contentHeight: contentHeight} - if contentWidth < 110 { - lay.stacked = true - lay.sidebarWidth = contentWidth - lay.sidebarHeight = tuiutils.Clamp(contentHeight/3, 9, 13) - lay.rightWidth = contentWidth - lay.rightHeight = max(10, contentHeight-lay.sidebarHeight) - return lay - } - - lay.bodyGap = 1 - lay.sidebarWidth = 22 - lay.sidebarHeight = contentHeight - lay.rightWidth = max(24, contentWidth-lay.sidebarWidth-lay.bodyGap) - lay.rightHeight = contentHeight - return lay + return layout{contentWidth: contentWidth, contentHeight: contentHeight} } // helpHeight 仅计算帮助区高度,避免在 layout 计算阶段触发完整渲染。 @@ -493,7 +414,3 @@ func (a App) helpHeight(width int) int { a.help.ShowAll = a.state.ShowHelp return lipgloss.Height(a.styles.footer.Width(width).Render(a.help.View(a.keys))) } - -func (a App) isFilteringSessions() bool { - return a.sessions.FilterState() != list.Unfiltered -} diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go index cb638676..edd44261 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -5,7 +5,9 @@ import ( "testing" "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/lipgloss" + providertypes "neo-code/internal/provider/types" tuistate "neo-code/internal/tui/state" ) @@ -53,7 +55,7 @@ func TestApplyComponentLayoutKeepsTranscriptHeightInSyncWithWaterfall(t *testing app.applyComponentLayout(false) lay := app.computeLayout() - wantTranscriptHeight, activityHeight, menuHeight, _ := app.waterfallMetrics(app.transcript.Width, lay.rightHeight) + wantTranscriptHeight, activityHeight, menuHeight, _ := app.waterfallMetrics(app.transcript.Width, lay.contentHeight) if app.transcript.Height != wantTranscriptHeight { t.Fatalf("expected transcript height %d, got %d", wantTranscriptHeight, app.transcript.Height) } @@ -74,3 +76,56 @@ func TestApplyComponentLayoutKeepsTranscriptHeightInSyncWithWaterfall(t *testing t.Fatalf("expected input Y %d, got %d", transcriptY+wantTranscriptHeight+activityHeight+menuHeight, inputY) } } + +func TestComputeLayoutUsesRenderedHeaderHeight(t *testing.T) { + app, _ := newTestApp(t) + app.width = 100 + app.height = 30 + + lay := app.computeLayout() + header := app.renderHeader(lay.contentWidth) + if got := lipgloss.Height(header); got != headerBarHeight { + t.Fatalf("expected header height %d, got %d", headerBarHeight, got) + } + if strings.Contains(header, "\x1b[") { + t.Fatalf("expected header to avoid ANSI escapes, got %q", header) + } +} + +func TestRenderUserMessageKeepsTagAndBodyRightAligned(t *testing.T) { + app, _ := newTestApp(t) + + block, _ := app.renderMessageBlockWithCopy(providertypes.Message{ + Role: roleUser, + Content: "hello right aligned", + }, 72, 1) + + plain := copyCodeANSIPattern.ReplaceAllString(block, "") + lines := strings.Split(plain, "\n") + + var ( + tagLine string + contentLine string + ) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.Contains(line, messageTagUser) { + tagLine = line + } + if strings.Contains(line, "hello right aligned") { + contentLine = line + } + } + if tagLine == "" || contentLine == "" { + t.Fatalf("expected user tag and content lines, got %q", plain) + } + + tagRightEdge := lipgloss.Width(strings.TrimRight(tagLine, " ")) + bodyRightEdge := lipgloss.Width(strings.TrimRight(contentLine, " ")) + if tagRightEdge != bodyRightEdge { + t.Fatalf("expected user tag and body right edges to match, got tag=%d body=%d\n%q\n%q", tagRightEdge, bodyRightEdge, tagLine, contentLine) + } +}