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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
35 changes: 35 additions & 0 deletions internal/tui/components/activity_preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package components

import (
"strings"

tuistate "neo-code/internal/tui/state"
)

// ActivityPreviewHeight 根据活动条目数量计算预览区高度。
func ActivityPreviewHeight(count int) int {
if count == 0 {
return 0
}
return 6
}

// RenderActivityLine 将活动条目渲染为单行文本,错误条目会优先标记为 ERROR。
func RenderActivityLine(entry tuistate.ActivityEntry, width int) string {
timeLabel := entry.Time.Format("15:04:05")
kind := strings.TrimSpace(entry.Kind)
if entry.IsError {
kind = "error"
}
kindLabel := strings.ToUpper(fallback(kind, "event"))

text := entry.Title
if strings.TrimSpace(entry.Detail) != "" {
text = text + ": " + entry.Detail
}

return trimMiddle(
timeLabel+" "+kindLabel+" "+strings.Join(strings.Fields(text), " "),
max(12, width),
)
}
39 changes: 39 additions & 0 deletions internal/tui/components/code_block.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package components

import (
"strings"

"github.com/charmbracelet/lipgloss"
)

// NormalizeBlockRightEdge 对多行块内容进行统一宽度补齐,避免右边界抖动。
func NormalizeBlockRightEdge(content string, maxWidth int) string {
if strings.TrimSpace(content) == "" {
return content
}

lines := strings.Split(content, "\n")
targetWidth := 0
for _, line := range lines {
targetWidth = max(targetWidth, lipgloss.Width(line))
}
targetWidth = clamp(targetWidth, 1, maxWidth)

padStyle := lipgloss.NewStyle().Width(targetWidth)
normalized := make([]string, 0, len(lines))
for _, line := range lines {
normalized = append(normalized, padStyle.Render(line))
}
return strings.Join(normalized, "\n")
}

// TrimRenderedTrailingWhitespace 清理渲染后每行末尾的空白字符。
func TrimRenderedTrailingWhitespace(content string) string {
lines := strings.Split(content, "\n")
for i := range lines {
lines[i] = strings.TrimRight(lines[i], " \t")
}
return strings.Join(lines, "\n")
}

// clampInt 将数值限制在给定区间内。
31 changes: 31 additions & 0 deletions internal/tui/components/command_menu.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package components

import (
"strings"

"github.com/charmbracelet/lipgloss"
)

// CommandMenuData 描述命令建议菜单渲染所需的基础数据与样式。
type CommandMenuData struct {
Title string
Body string
Width int
ContainerStyle lipgloss.Style
TitleStyle lipgloss.Style
}

// RenderCommandMenu 负责将命令菜单数据渲染为最终字符串。
func RenderCommandMenu(data CommandMenuData) string {
if strings.TrimSpace(data.Body) == "" {
return ""
}

return data.ContainerStyle.Width(data.Width).Render(
lipgloss.JoinVertical(
lipgloss.Left,
data.TitleStyle.Render(data.Title),
data.Body,
),
)
}
189 changes: 189 additions & 0 deletions internal/tui/components/components_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package components

import (
"strings"
"testing"
"time"

"github.com/charmbracelet/lipgloss"
tuistate "neo-code/internal/tui/state"
)

func TestActivityPreviewHeight(t *testing.T) {
if got := ActivityPreviewHeight(0); got != 0 {
t.Fatalf("expected empty activity height 0, got %d", got)
}
if got := ActivityPreviewHeight(1); got != 6 {
t.Fatalf("expected non-empty activity height 6, got %d", got)
}
}

func TestRenderActivityLine(t *testing.T) {
fixed := time.Date(2026, 4, 5, 12, 34, 56, 0, time.UTC)
line := RenderActivityLine(tuistate.ActivityEntry{
Time: fixed,
Kind: "",
Title: "single line",
Detail: "",
}, 80)
if !strings.Contains(line, "12:34:56") {
t.Fatalf("expected time label in line, got %q", line)
}
if !strings.Contains(line, "EVENT") {
t.Fatalf("expected default kind label EVENT, got %q", line)
}
if !strings.Contains(line, "single line") {
t.Fatalf("expected title text, got %q", line)
}

errorLine := RenderActivityLine(tuistate.ActivityEntry{
Time: fixed,
Kind: "tool",
Title: "failed task",
Detail: "boom",
IsError: true,
}, 80)
if !strings.Contains(errorLine, "ERROR") {
t.Fatalf("expected error kind label for IsError item, got %q", errorLine)
}
}

func TestRenderCommandMenu(t *testing.T) {
data := CommandMenuData{
Title: "Suggestions",
Body: "/help - show help",
Width: 40,
ContainerStyle: lipgloss.NewStyle(),
TitleStyle: lipgloss.NewStyle(),
}
output := RenderCommandMenu(data)
if !strings.Contains(output, "Suggestions") {
t.Fatalf("expected title in output, got %q", output)
}
if !strings.Contains(output, "/help - show help") {
t.Fatalf("expected body in output, got %q", output)
}

empty := RenderCommandMenu(CommandMenuData{
Title: "Suggestions",
Body: " ",
Width: 40,
ContainerStyle: lipgloss.NewStyle(),
TitleStyle: lipgloss.NewStyle(),
})
if empty != "" {
t.Fatalf("expected empty output for blank body, got %q", empty)
}
}

func TestCompactStatusText(t *testing.T) {
if got := CompactStatusText("\n hello world \n", 0); got != "hello world" {
t.Fatalf("expected compacted line, got %q", got)
}
if got := CompactStatusText("\n \n", 10); got != "" {
t.Fatalf("expected empty compact status text, got %q", got)
}
}

func TestCodeBlockHelpers(t *testing.T) {
if got := TrimRenderedTrailingWhitespace("a \t\nb "); got != "a\nb" {
t.Fatalf("unexpected trimmed result: %q", got)
}

normalized := NormalizeBlockRightEdge("a\nbb", 8)
lines := strings.Split(normalized, "\n")
if len(lines) != 2 {
t.Fatalf("expected two lines, got %d", len(lines))
}
if lipgloss.Width(lines[0]) != lipgloss.Width(lines[1]) {
t.Fatalf("expected equal visual widths, got %d and %d", lipgloss.Width(lines[0]), lipgloss.Width(lines[1]))
}
}

func TestRenderCommandMenuRow(t *testing.T) {
row := RenderCommandMenuRow(CommandMenuRowData{
Title: "/status",
Description: "show status",
Highlight: true,
Selected: false,
Width: 30,
UsageStyle: lipgloss.NewStyle(),
UsageMatchStyle: lipgloss.NewStyle().Bold(true),
DescriptionStyle: lipgloss.NewStyle(),
})
if !strings.Contains(row, "/status") || !strings.Contains(row, "show status") {
t.Fatalf("unexpected command menu row: %q", row)
}
}

func TestRenderSessionRow(t *testing.T) {
row := RenderSessionRow(SessionRowData{
Title: "My session title",
UpdatedAtLabel: "04-05 12:34",
Active: true,
Selected: true,
Width: 40,
RowStyle: lipgloss.NewStyle(),
RowActiveStyle: lipgloss.NewStyle(),
RowFocusStyle: lipgloss.NewStyle(),
MetaStyle: lipgloss.NewStyle(),
MetaActiveStyle: lipgloss.NewStyle(),
MetaFocusStyle: lipgloss.NewStyle(),
})
if !strings.Contains(row, ">") {
t.Fatalf("expected selected prefix in row, got %q", row)
}
if !strings.Contains(row, "04-05 12:34") {
t.Fatalf("expected updated-at label in row, got %q", row)
}
}

func TestViewHelperBranches(t *testing.T) {
if got := fallback("primary", "fallback"); got != "primary" {
t.Fatalf("expected primary fallback value, got %q", got)
}
if got := fallback("", "fallback"); got != "fallback" {
t.Fatalf("expected fallback value, got %q", got)
}

if got := trimMiddle("abcdef", 0); got != "" {
t.Fatalf("expected empty string for non-positive limit, got %q", got)
}
if got := trimMiddle("abcdef", 3); got != "abc" {
t.Fatalf("expected hard truncate for short limit, got %q", got)
}
if got := trimMiddle("abcdefghij", 7); got != "ab...ij" {
t.Fatalf("expected middle trim output, got %q", got)
}

if got := trimRunes("abcdef", 3); got != "abcdef" {
t.Fatalf("expected original text when limit < 4, got %q", got)
}
if got := trimRunes("abcdef", 5); got != "ab..." {
t.Fatalf("expected rune-safe ellipsis trim, got %q", got)
}

if got := clamp(-1, 0, 10); got != 0 {
t.Fatalf("expected clamp to min, got %d", got)
}
if got := clamp(20, 0, 10); got != 10 {
t.Fatalf("expected clamp to max, got %d", got)
}
if got := clamp(6, 0, 10); got != 6 {
t.Fatalf("expected clamp to keep in-range value, got %d", got)
}
}

func TestNormalizeBlockRightEdgeBlankContent(t *testing.T) {
blank := " \n\t"
if got := NormalizeBlockRightEdge(blank, 20); got != blank {
t.Fatalf("expected blank content passthrough, got %q", got)
}
}

func TestCompactStatusTextWithLimit(t *testing.T) {
text := "\n first useful line \nsecond line"
if got := CompactStatusText(text, 9); got != "fir...ine" {
t.Fatalf("expected first non-empty line compacted with ellipsis, got %q", got)
}
}
92 changes: 92 additions & 0 deletions internal/tui/components/list_rows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package components

import (
"fmt"
"strings"

"github.com/charmbracelet/lipgloss"
)

// CommandMenuRowData 描述命令建议菜单单行渲染所需数据。
type CommandMenuRowData struct {
Title string
Description string
Highlight bool
Selected bool
Width int
UsageStyle lipgloss.Style
UsageMatchStyle lipgloss.Style
DescriptionStyle lipgloss.Style
}

// RenderCommandMenuRow 渲染命令建议菜单中的单行内容。
func RenderCommandMenuRow(data CommandMenuRowData) string {
contentWidth := max(12, data.Width-2)
usageStyle := data.UsageStyle
if data.Highlight || data.Selected {
usageStyle = data.UsageMatchStyle
}

line := usageStyle.Render(data.Title)
if description := strings.TrimSpace(data.Description); description != "" {
descWidth := max(8, contentWidth-lipgloss.Width(data.Title)-2)
line = lipgloss.JoinHorizontal(
lipgloss.Top,
line,
lipgloss.NewStyle().Width(2).Render(""),
data.DescriptionStyle.Render(trimMiddle(description, descWidth)),
)
}

return lipgloss.NewStyle().Width(contentWidth).Render(line)
}

// SessionRowData 描述会话列表单行渲染所需数据。
type SessionRowData struct {
Title string
UpdatedAtLabel string
Active bool
Selected bool
Width int
RowStyle lipgloss.Style
RowActiveStyle lipgloss.Style
RowFocusStyle lipgloss.Style
MetaStyle lipgloss.Style
MetaActiveStyle lipgloss.Style
MetaFocusStyle lipgloss.Style
}

// RenderSessionRow 渲染会话列表单行内容。
func RenderSessionRow(data SessionRowData) string {
width := max(18, data.Width-2)
title := trimRunes(data.Title, max(8, width-10))

prefix := "o"
if data.Active {
prefix = "*"
}
if data.Selected {
prefix = ">"
}

style := data.RowStyle
metaStyle := data.MetaStyle
if data.Active {
style = data.RowActiveStyle
metaStyle = data.MetaActiveStyle
}
if data.Selected {
style = data.RowFocusStyle
metaStyle = data.MetaFocusStyle
}

content := lipgloss.JoinVertical(
lipgloss.Left,
fmt.Sprintf("%s %s", prefix, title),
metaStyle.Render(" "+data.UpdatedAtLabel),
)

return style.Width(width).Render(content)
}

// trimRunes 在不破坏 rune 边界的前提下截断文本。
Loading
Loading