diff --git a/internal/tui/components/.gitkeep b/internal/tui/components/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/internal/tui/components/activity_preview.go b/internal/tui/components/activity_preview.go new file mode 100644 index 00000000..86ac1256 --- /dev/null +++ b/internal/tui/components/activity_preview.go @@ -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), + ) +} diff --git a/internal/tui/components/code_block.go b/internal/tui/components/code_block.go new file mode 100644 index 00000000..22f9adad --- /dev/null +++ b/internal/tui/components/code_block.go @@ -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 将数值限制在给定区间内。 diff --git a/internal/tui/components/command_menu.go b/internal/tui/components/command_menu.go new file mode 100644 index 00000000..06aa25ca --- /dev/null +++ b/internal/tui/components/command_menu.go @@ -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, + ), + ) +} diff --git a/internal/tui/components/components_test.go b/internal/tui/components/components_test.go new file mode 100644 index 00000000..51482565 --- /dev/null +++ b/internal/tui/components/components_test.go @@ -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) + } +} diff --git a/internal/tui/components/list_rows.go b/internal/tui/components/list_rows.go new file mode 100644 index 00000000..2cb04d5e --- /dev/null +++ b/internal/tui/components/list_rows.go @@ -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 边界的前提下截断文本。 diff --git a/internal/tui/components/status_text.go b/internal/tui/components/status_text.go new file mode 100644 index 00000000..9828aa6a --- /dev/null +++ b/internal/tui/components/status_text.go @@ -0,0 +1,24 @@ +package components + +import ( + "strings" +) + +// CompactStatusText 压缩状态文本为单行展示,支持可选长度限制。 +func CompactStatusText(text string, limit int) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + lines := strings.Split(text, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + line = strings.Join(strings.Fields(line), " ") + if limit > 0 { + return trimMiddle(line, limit) + } + return line + } + return "" +} diff --git a/internal/tui/components/view_helpers.go b/internal/tui/components/view_helpers.go new file mode 100644 index 00000000..40c1765c --- /dev/null +++ b/internal/tui/components/view_helpers.go @@ -0,0 +1,48 @@ +package components + +import "unicode/utf8" + +// fallback 在主值为空时回退到默认值。 +func fallback(primary string, fallbackValue string) string { + if primary != "" { + return primary + } + return fallbackValue +} + +// trimMiddle 在长度超限时保留首尾内容并使用省略号连接。 +func trimMiddle(text string, limit int) string { + if limit <= 0 { + return "" + } + if utf8.RuneCountInString(text) <= limit { + return text + } + runes := []rune(text) + if limit <= 3 { + return string(runes[:limit]) + } + head := (limit - 3) / 2 + tail := limit - 3 - head + return string(runes[:head]) + "..." + string(runes[len(runes)-tail:]) +} + +// trimRunes 按 rune 边界安全截断文本。 +func trimRunes(text string, limit int) string { + runes := []rune(text) + if len(runes) <= limit || limit < 4 { + return text + } + return string(runes[:limit-3]) + "..." +} + +// clamp 将数值限制在给定区间内。 +func clamp(value int, minValue int, maxValue int) int { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} diff --git a/internal/tui/docs/LAYERING.md b/internal/tui/docs/LAYERING.md new file mode 100644 index 00000000..4ff7b040 --- /dev/null +++ b/internal/tui/docs/LAYERING.md @@ -0,0 +1,94 @@ +# TUI 分层约束(Iteration 0) + +本文档用于约束 `internal/tui` 的分层职责与依赖方向,确保后续迭代按层收敛,不跨层扩散。 + +## 改造范围 + +- 本轮只处理 `internal/tui`。 +- 入口层 `cmd/neocode` 暂不处理。 + +## 分层定义 + +### L1 - Entry(暂缓) + +- 位置:`cmd/neocode/` +- 职责:参数解析、终端初始化、启动 Program。 +- 本轮状态:暂不纳入改造。 + +### L2 - Bootstrap + +- 位置:`internal/tui/bootstrap/` +- 职责:依赖注入(DI)与初始化编排。 +- 负责:工作区/配置初始化、服务装配、Offline/Mock 注入切换。 + +### L3 - App/Core + +- 位置:`internal/tui/core/` +- 职责:Bubble Tea 状态机中枢(ELM 单向数据流)。 +- 负责:消息路由、状态变更、布局调度。 + +### L4 - State + +- 位置:`internal/tui/state/` +- 职责:纯数据容器。 +- 约束:只放结构体和常量,不放方法与副作用。 + +### L5 - Component Adapter + +- 位置:`internal/tui/components/` +- 职责:原子渲染组件。 +- 输入:基础数据或 state。 +- 输出:渲染字符串。 + +### L6 - Services + +- 位置:`internal/tui/services/` +- 职责:对接 runtime/provider/本地系统能力。 +- 约束:统一返回 `tea.Cmd` 或异步产出 `tea.Msg`。 + +### L7 - Infrastructure + +- 位置:`internal/tui/infra/` +- 职责:底层 I/O 与系统能力。 +- 范围:shell 执行、文件扫描、终端 I/O、渲染器、剪贴板等。 + +## 依赖方向(允许) + +- `core` -> `state` +- `core` -> `components` +- `core` -> `services` +- `services` -> `infra` + +## 禁止项 + +- 禁止 `components` 直接访问 runtime/provider 或执行外部 I/O。 +- 禁止 `core` 直接调用底层系统能力(应经 `services`)。 +- 禁止 `state` 承载业务逻辑、网络调用或文件操作。 +- 禁止新增跨层直连(例如 `core` 直接依赖 `infra`)。 +- 禁止在本轮引入行为变更;Iteration 0 只做骨架与规则。 + +## Iteration 0 验收 + +- 目录骨架已创建:`bootstrap/core/state/components/services/infra` +- 分层约束文档已建立 +- `go test ./internal/tui/...` 通过 + +## Iteration 6 补充(Bootstrap 落地) + +- `internal/tui/bootstrap` 已提供 `Build` 装配入口,统一完成 `ConfigManager + Runtime + ProviderService` 注入。 +- 支持 `Mode`(`live/offline/mock`)与 `ServiceFactory` 扩展点,可在不修改 `core` 的情况下替换注入实现。 +- `internal/tui.New(...)` 保持兼容签名,对外作为薄封装;实际装配路径为 `New -> bootstrap.Build -> newApp`。 + +## Iteration 7 补充(Runtime Source 收敛) + +- Runtime 事件新增并接入 UI 桥接: + - `EventToolStatus` + - `EventRunContext` + - `EventUsage` +- Runtime 查询接口已落地: + - `GetRunSnapshot(runID)` + - `GetSessionContext(sessionID)` + - `GetSessionUsage(sessionID)` + - `GetRunUsage(runID)` +- `internal/tui/core/runtime_bridge.go` 统一处理 payload -> VM 映射与 Tool 状态去重合并(覆盖重复/乱序事件场景)。 +- TUI 在会话刷新时优先通过 runtime 查询回填 context/token 快照,避免由 UI 本地推导。 diff --git a/internal/tui/infra/.gitkeep b/internal/tui/infra/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/internal/tui/infra/clipboard.go b/internal/tui/infra/clipboard.go new file mode 100644 index 00000000..673901aa --- /dev/null +++ b/internal/tui/infra/clipboard.go @@ -0,0 +1,11 @@ +package infra + +import "github.com/atotto/clipboard" + +// clipboardWriteAll 指向实际剪贴板写入函数,便于在测试中替换。 +var clipboardWriteAll = clipboard.WriteAll + +// CopyText 将文本写入系统剪贴板。 +func CopyText(text string) error { + return clipboardWriteAll(text) +} diff --git a/internal/tui/infra/infra_test.go b/internal/tui/infra/infra_test.go new file mode 100644 index 00000000..1e9775ec --- /dev/null +++ b/internal/tui/infra/infra_test.go @@ -0,0 +1,338 @@ +package infra + +import ( + "context" + "encoding/binary" + "os" + "path/filepath" + goruntime "runtime" + "strings" + "testing" + "unicode/utf16" + "unicode/utf8" + + "neo-code/internal/config" +) + +func TestShellArgs(t *testing.T) { + if got := ShellArgs("bash", "pwd"); len(got) != 3 || got[0] != "bash" || got[2] != "pwd" { + t.Fatalf("unexpected bash args: %+v", got) + } + if got := ShellArgs("sh", "pwd"); len(got) != 3 || got[0] != "sh" || got[2] != "pwd" { + t.Fatalf("unexpected sh args: %+v", got) + } + if got := ShellArgs("powershell", "Get-Location"); len(got) != 4 || got[0] != "powershell" { + t.Fatalf("unexpected powershell args: %+v", got) + } + if got := ShellArgs("pwsh", "Get-Location"); len(got) != 4 || got[0] != "powershell" { + t.Fatalf("unexpected pwsh args: %+v", got) + } + if got := ShellArgs("unknown", "git status"); len(got) != 4 || got[0] != "powershell" { + t.Fatalf("expected powershell fallback, got %+v", got) + } +} + +func TestSanitizeWorkspaceOutput(t *testing.T) { + raw := []byte("\x1b[31mERROR\x1b[0m\r\nok\t\x00") + got := SanitizeWorkspaceOutput(raw) + if strings.Contains(got, "\x1b[31m") { + t.Fatalf("expected ansi removed, got %q", got) + } + if !strings.Contains(got, "ERROR") || !strings.Contains(got, "ok") { + t.Fatalf("expected content preserved, got %q", got) + } +} + +func TestDecodeWorkspaceOutputUTF16LE(t *testing.T) { + utf16Data := utf16.Encode([]rune("PowerShell 输出")) + buf := make([]byte, 2+len(utf16Data)*2) + buf[0], buf[1] = 0xFF, 0xFE + for i, word := range utf16Data { + binary.LittleEndian.PutUint16(buf[2+i*2:], word) + } + + got := DecodeWorkspaceOutput(buf) + if !strings.Contains(got, "PowerShell") { + t.Fatalf("expected decoded utf16 content, got %q", got) + } +} + +func TestDecodeWorkspaceOutputUTF16BE(t *testing.T) { + utf16Data := utf16.Encode([]rune("UTF16 BE")) + buf := make([]byte, 2+len(utf16Data)*2) + buf[0], buf[1] = 0xFE, 0xFF + for i, word := range utf16Data { + binary.BigEndian.PutUint16(buf[2+i*2:], word) + } + + got := DecodeWorkspaceOutput(buf) + if !strings.Contains(got, "UTF16 BE") { + t.Fatalf("expected decoded utf16 big-endian content, got %q", got) + } +} + +func TestDecodeWorkspaceOutputHeuristicsAndEdges(t *testing.T) { + evenWithoutBOM := []byte{0x61, 0x00, 0x62, 0x00} + got := DecodeWorkspaceOutput(evenWithoutBOM) + if !strings.Contains(got, "ab") { + t.Fatalf("expected utf16 heuristic decode result to contain ab, got %q", got) + } + + if got := DecodeWorkspaceOutput([]byte{0xE4, 0xBD, 0xA0}); utf8.ValidString(got) && strings.TrimSpace(got) == "" { + t.Fatalf("expected odd-length raw bytes to keep readable content, got %q", got) + } + + if got := decodeUTF16([]byte{0x61}, true); got != "a" { + t.Fatalf("expected short utf16 input to return raw text, got %q", got) + } + if got := decodeUTF16([]byte{0x61, 0x00, 0x62}, true); !strings.Contains(got, "a") { + t.Fatalf("expected odd-length utf16 input to decode after trimming, got %q", got) + } +} + +func TestDecodedTextScore(t *testing.T) { + printable := decodedTextScore("hello world") + replacement := decodedTextScore(string([]rune{'\uFFFD'})) + if printable <= replacement { + t.Fatalf("expected printable text score > replacement score, got printable=%d replacement=%d", printable, replacement) + } +} + +func TestCollectWorkspaceFiles(t *testing.T) { + root := t.TempDir() + mustWrite := func(rel string) { + t.Helper() + path := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", rel, err) + } + if err := os.WriteFile(path, []byte(rel), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + + mustWrite("README.md") + mustWrite("internal/tui/update.go") + mustWrite(".git/config") + mustWrite("node_modules/skip.js") + + files, err := CollectWorkspaceFiles(root, 10) + if err != nil { + t.Fatalf("CollectWorkspaceFiles() error = %v", err) + } + got := strings.Join(files, ",") + if strings.Contains(got, ".git") || strings.Contains(got, "node_modules") { + t.Fatalf("expected ignored dirs skipped, got %v", files) + } + if !strings.Contains(got, "README.md") || !strings.Contains(got, "internal/tui/update.go") { + t.Fatalf("expected workspace files included, got %v", files) + } +} + +func TestCollectWorkspaceFilesLimitAndErrors(t *testing.T) { + root := t.TempDir() + mustWrite := func(rel string) { + t.Helper() + path := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", rel, err) + } + if err := os.WriteFile(path, []byte(rel), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + mustWrite("b.txt") + mustWrite("a.txt") + + files, err := CollectWorkspaceFiles(root, 1) + if err != nil { + t.Fatalf("CollectWorkspaceFiles(limit=1) error = %v", err) + } + if len(files) != 1 { + t.Fatalf("expected exactly one file due to limit, got %v", files) + } + if files[0] != "a.txt" && files[0] != "b.txt" { + t.Fatalf("unexpected limited file list: %v", files) + } + + _, err = CollectWorkspaceFiles(filepath.Join(root, "missing"), 10) + if err == nil { + t.Fatalf("expected missing root to produce walk error") + } +} + +func TestCopyTextUsesInjectedWriter(t *testing.T) { + original := clipboardWriteAll + t.Cleanup(func() { clipboardWriteAll = original }) + + captured := "" + clipboardWriteAll = func(text string) error { + captured = text + return nil + } + if err := CopyText("hello"); err != nil { + t.Fatalf("CopyText() error = %v", err) + } + if captured != "hello" { + t.Fatalf("expected captured clipboard text, got %q", captured) + } +} + +func TestCachedMarkdownRendererBasic(t *testing.T) { + renderer := NewCachedMarkdownRenderer("dark", 4, "(empty)") + + empty, err := renderer.Render(" \n\t ", 20) + if err != nil { + t.Fatalf("Render(empty) error = %v", err) + } + if empty != "(empty)" { + t.Fatalf("expected empty placeholder, got %q", empty) + } + + out, err := renderer.Render("# Title\n\n- one", 40) + if err != nil { + t.Fatalf("Render(markdown) error = %v", err) + } + if strings.TrimSpace(out) == "" { + t.Fatalf("expected non-empty rendered markdown") + } + if renderer.RendererCount() != 1 || renderer.CacheCount() != 1 { + t.Fatalf("expected renderer and cache entries, got renderers=%d cache=%d", renderer.RendererCount(), renderer.CacheCount()) + } +} + +func TestCachedMarkdownRendererCacheEviction(t *testing.T) { + renderer := NewCachedMarkdownRenderer("dark", 1, "(empty)") + + if _, err := renderer.Render("first", 20); err != nil { + t.Fatalf("Render(first) error = %v", err) + } + if _, err := renderer.Render("second", 20); err != nil { + t.Fatalf("Render(second) error = %v", err) + } + if renderer.CacheOrderCount() != 1 || renderer.CacheCount() != 1 { + t.Fatalf("expected single cache entry after eviction, got order=%d cache=%d", renderer.CacheOrderCount(), renderer.CacheCount()) + } +} + +func TestCachedMarkdownRendererDefaultsAndSetMax(t *testing.T) { + renderer := NewCachedMarkdownRenderer("", -1, "(empty)") + if renderer.style != "dark" { + t.Fatalf("expected default style dark, got %q", renderer.style) + } + if renderer.maxCacheEntries != 0 { + t.Fatalf("expected negative max cache to normalize to 0, got %d", renderer.maxCacheEntries) + } + + renderer.SetMaxCacheEntries(2) + if _, err := renderer.Render("one", 20); err != nil { + t.Fatalf("Render(one) error = %v", err) + } + if _, err := renderer.Render("two", 20); err != nil { + t.Fatalf("Render(two) error = %v", err) + } + if _, err := renderer.Render("three", 20); err != nil { + t.Fatalf("Render(three) error = %v", err) + } + if renderer.CacheCount() != 2 { + t.Fatalf("expected cache eviction to keep 2 entries, got %d", renderer.CacheCount()) + } + + renderer.SetMaxCacheEntries(1) + if renderer.CacheCount() != 1 || renderer.CacheOrderCount() != 1 { + t.Fatalf("expected cache trim to one entry, got cache=%d order=%d", renderer.CacheCount(), renderer.CacheOrderCount()) + } + + renderer.SetMaxCacheEntries(-1) + if renderer.CacheCount() != 0 || renderer.CacheOrderCount() != 0 { + t.Fatalf("expected cache trim to zero after negative max, got cache=%d order=%d", renderer.CacheCount(), renderer.CacheOrderCount()) + } +} + +func TestCachedMarkdownRendererCacheDisabledAndWidthFloor(t *testing.T) { + renderer := NewCachedMarkdownRenderer("dark", 0, "(empty)") + if _, err := renderer.Render("same", 1); err != nil { + t.Fatalf("Render(width=1) error = %v", err) + } + if _, err := renderer.Render("same", 15); err != nil { + t.Fatalf("Render(width=15) error = %v", err) + } + if renderer.CacheCount() != 0 { + t.Fatalf("expected disabled cache to keep zero entries, got %d", renderer.CacheCount()) + } + if renderer.RendererCount() != 1 { + t.Fatalf("expected render width floor to reuse one renderer, got %d", renderer.RendererCount()) + } +} + +func TestDefaultWorkspaceCommandExecutor(t *testing.T) { + workdir := t.TempDir() + shellName, successCmd, noOutputCmd, failCmd, sleepCmd := workspaceExecutorCommands() + cfg := config.Config{ + Workdir: workdir, + Shell: shellName, + ToolTimeoutSec: 1, + } + + if _, err := DefaultWorkspaceCommandExecutor(context.Background(), cfg, "", " "); err == nil { + t.Fatalf("expected empty command to fail") + } + + output, err := DefaultWorkspaceCommandExecutor(context.Background(), cfg, "", successCmd) + if err != nil { + t.Fatalf("expected success command to pass, got error %v (output=%q)", err, output) + } + if !strings.Contains(strings.ToLower(output), "ok") { + t.Fatalf("expected success output to contain ok, got %q", output) + } + + output, err = DefaultWorkspaceCommandExecutor(context.Background(), cfg, workdir, noOutputCmd) + if err != nil { + t.Fatalf("expected no-output command to pass, got error %v (output=%q)", err, output) + } + if output != "(no output)" { + t.Fatalf("expected no-output placeholder, got %q", output) + } + + output, err = DefaultWorkspaceCommandExecutor(context.Background(), cfg, workdir, failCmd) + if err == nil { + t.Fatalf("expected failing command to return error, output=%q", output) + } + if strings.TrimSpace(output) == "" { + t.Fatalf("expected failing command to return sanitized output") + } + + output, err = DefaultWorkspaceCommandExecutor(context.Background(), cfg, workdir, sleepCmd) + if err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("expected timeout error, got err=%v output=%q", err, output) + } +} + +func TestDefaultWorkspaceCommandExecutorUsesDefaultTimeout(t *testing.T) { + workdir := t.TempDir() + shellName, successCmd, _, _, _ := workspaceExecutorCommands() + cfg := config.Config{ + Workdir: workdir, + Shell: shellName, + ToolTimeoutSec: 0, + } + + if output, err := DefaultWorkspaceCommandExecutor(context.Background(), cfg, "", successCmd); err != nil || !strings.Contains(strings.ToLower(output), "ok") { + t.Fatalf("expected default timeout path to execute command, output=%q err=%v", output, err) + } +} + +func workspaceExecutorCommands() (shell string, success string, noOutput string, fail string, sleep string) { + if goruntime.GOOS == "windows" { + return "powershell", + "Write-Output 'OK'", + "$null = 1", + "Write-Error 'failed'; exit 2", + "Start-Sleep -Seconds 2" + } + return "bash", + "printf 'OK\\n'", + "true", + "echo failed 1>&2; exit 2", + "sleep 2" +} diff --git a/internal/tui/infra/markdown_cached_renderer.go b/internal/tui/infra/markdown_cached_renderer.go new file mode 100644 index 00000000..4115b061 --- /dev/null +++ b/internal/tui/infra/markdown_cached_renderer.go @@ -0,0 +1,133 @@ +package infra + +import ( + "fmt" + "regexp" + "strings" + + "github.com/charmbracelet/glamour" +) + +var markdownANSIPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// CachedMarkdownRenderer 负责按宽度复用渲染器并缓存渲染结果。 +type CachedMarkdownRenderer struct { + style string + emptyPlaceholder string + renderers map[int]*glamour.TermRenderer + cache map[string]string + cacheOrder []string + maxCacheEntries int +} + +// NewCachedMarkdownRenderer 创建带缓存的 Markdown 渲染器。 +func NewCachedMarkdownRenderer(style string, maxCacheEntries int, emptyPlaceholder string) *CachedMarkdownRenderer { + if strings.TrimSpace(style) == "" { + style = "dark" + } + if maxCacheEntries < 0 { + maxCacheEntries = 0 + } + return &CachedMarkdownRenderer{ + style: style, + emptyPlaceholder: emptyPlaceholder, + renderers: make(map[int]*glamour.TermRenderer), + cache: make(map[string]string), + cacheOrder: make([]string, 0, maxCacheEntries), + maxCacheEntries: maxCacheEntries, + } +} + +// Render 按给定宽度渲染 Markdown,并做结果缓存与空内容兜底。 +func (r *CachedMarkdownRenderer) Render(content string, width int) (string, error) { + if strings.TrimSpace(content) == "" { + return r.emptyPlaceholder, nil + } + + renderWidth := max(16, width) + cacheKey := fmt.Sprintf("%d:%s", renderWidth, content) + if cached, ok := r.cache[cacheKey]; ok { + return cached, nil + } + + termRenderer, err := r.rendererForWidth(renderWidth) + if err != nil { + return "", err + } + + rendered, err := termRenderer.Render(content) + if err != nil { + return "", err + } + rendered = strings.TrimRight(rendered, "\n") + visible := markdownANSIPattern.ReplaceAllString(rendered, "") + if strings.TrimSpace(visible) == "" { + rendered = r.emptyPlaceholder + } + + r.cacheResult(cacheKey, rendered) + return rendered, nil +} + +// SetMaxCacheEntries 调整渲染结果缓存上限。 +func (r *CachedMarkdownRenderer) SetMaxCacheEntries(max int) { + if max < 0 { + max = 0 + } + r.maxCacheEntries = max + for len(r.cacheOrder) > max { + oldest := r.cacheOrder[0] + r.cacheOrder = r.cacheOrder[1:] + delete(r.cache, oldest) + } +} + +// RendererCount 返回按宽度缓存的渲染器数量。 +func (r *CachedMarkdownRenderer) RendererCount() int { + return len(r.renderers) +} + +// CacheCount 返回渲染结果缓存条目数量。 +func (r *CachedMarkdownRenderer) CacheCount() int { + return len(r.cache) +} + +// CacheOrderCount 返回缓存队列长度。 +func (r *CachedMarkdownRenderer) CacheOrderCount() int { + return len(r.cacheOrder) +} + +// rendererForWidth 获取或创建指定宽度的底层终端渲染器。 +func (r *CachedMarkdownRenderer) rendererForWidth(width int) (*glamour.TermRenderer, error) { + if renderer, ok := r.renderers[width]; ok { + return renderer, nil + } + + renderer, err := NewGlamourTermRenderer(r.style, width) + if err != nil { + return nil, err + } + + r.renderers[width] = renderer + return renderer, nil +} + +// cacheResult 将渲染结果写入 LRU 风格缓存。 +func (r *CachedMarkdownRenderer) cacheResult(key string, value string) { + if r.maxCacheEntries <= 0 { + return + } + if _, exists := r.cache[key]; exists { + r.cache[key] = value + return + } + if len(r.cacheOrder) >= r.maxCacheEntries { + oldest := r.cacheOrder[0] + r.cacheOrder = r.cacheOrder[1:] + delete(r.cache, oldest) + } + r.cacheOrder = append(r.cacheOrder, key) + r.cache[key] = value +} + +// maxInt 返回两个整数中的较大值。 diff --git a/internal/tui/infra/markdown_renderer.go b/internal/tui/infra/markdown_renderer.go new file mode 100644 index 00000000..f0161183 --- /dev/null +++ b/internal/tui/infra/markdown_renderer.go @@ -0,0 +1,11 @@ +package infra + +import "github.com/charmbracelet/glamour" + +// NewGlamourTermRenderer 创建指定宽度的 Glamour 终端渲染器。 +func NewGlamourTermRenderer(style string, width int) (*glamour.TermRenderer, error) { + return glamour.NewTermRenderer( + glamour.WithStandardStyle(style), + glamour.WithWordWrap(width), + ) +} diff --git a/internal/tui/infra/workspace_exec.go b/internal/tui/infra/workspace_exec.go new file mode 100644 index 00000000..d08a7635 --- /dev/null +++ b/internal/tui/infra/workspace_exec.go @@ -0,0 +1,173 @@ +package infra + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "regexp" + "strings" + "time" + "unicode" + "unicode/utf16" + + "neo-code/internal/config" +) + +var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) + +// DefaultWorkspaceCommandExecutor 在受控超时下执行工作区命令并返回清洗后的输出。 +func DefaultWorkspaceCommandExecutor(ctx context.Context, cfg config.Config, workdir string, command string) (string, error) { + command = strings.TrimSpace(command) + if command == "" { + return "", errors.New("command is empty") + } + targetWorkdir := strings.TrimSpace(workdir) + if targetWorkdir == "" { + targetWorkdir = cfg.Workdir + } + + timeoutSec := cfg.ToolTimeoutSec + if timeoutSec <= 0 { + timeoutSec = config.DefaultToolTimeoutSec + } + + runCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second) + defer cancel() + + args := ShellArgs(cfg.Shell, command) + cmd := exec.CommandContext(runCtx, args[0], args[1:]...) + cmd.Dir = targetWorkdir + output, err := cmd.CombinedOutput() + text := SanitizeWorkspaceOutput(output) + + if runCtx.Err() == context.DeadlineExceeded { + return text, fmt.Errorf("command timed out after %ds", timeoutSec) + } + if err != nil { + return text, err + } + if text == "" { + return "(no output)", nil + } + return text, nil +} + +// ShellArgs 根据 shell 类型构造可执行参数。 +func ShellArgs(shell string, command string) []string { + switch strings.ToLower(strings.TrimSpace(shell)) { + case "powershell", "pwsh": + return []string{"powershell", "-NoProfile", "-Command", PowerShellUTF8Command(command)} + case "bash": + return []string{"bash", "-lc", command} + case "sh": + return []string{"sh", "-lc", command} + default: + return []string{"powershell", "-NoProfile", "-Command", PowerShellUTF8Command(command)} + } +} + +// PowerShellUTF8Command 为 PowerShell 命令前置 UTF-8 控制台编码设置。 +func PowerShellUTF8Command(command string) string { + utf8Setup := "[Console]::InputEncoding=[System.Text.Encoding]::UTF8; [Console]::OutputEncoding=[System.Text.Encoding]::UTF8; $OutputEncoding=[System.Text.Encoding]::UTF8; chcp 65001 > $null" + return utf8Setup + "; " + command +} + +// SanitizeWorkspaceOutput 对原始命令输出做编码恢复、ANSI 去除与控制字符清洗。 +func SanitizeWorkspaceOutput(raw []byte) string { + text := DecodeWorkspaceOutput(raw) + text = strings.ToValidUTF8(text, "?") + text = ansiEscapePattern.ReplaceAllString(text, "") + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + text = strings.Map(func(r rune) rune { + switch { + case r == '\n' || r == '\t': + return r + case r < 0x20: + return -1 + default: + return r + } + }, text) + return strings.TrimSpace(text) +} + +// DecodeWorkspaceOutput 尝试在 UTF-8 / UTF-16 场景下恢复可读文本。 +func DecodeWorkspaceOutput(raw []byte) string { + if len(raw) == 0 { + return "" + } + + switch { + case bytes.HasPrefix(raw, []byte{0xFF, 0xFE}): + return decodeUTF16(raw[2:], true) + case bytes.HasPrefix(raw, []byte{0xFE, 0xFF}): + return decodeUTF16(raw[2:], false) + } + + if len(raw)%2 == 0 { + le := decodeUTF16(raw, true) + be := decodeUTF16(raw, false) + rawText := string(raw) + rawScore := decodedTextScore(rawText) + leScore := decodedTextScore(le) + beScore := decodedTextScore(be) + + bestText := rawText + bestScore := rawScore + if leScore > bestScore { + bestText = le + bestScore = leScore + } + if beScore > bestScore { + bestText = be + } + return bestText + } + + return string(raw) +} + +// decodedTextScore 基于可打印字符比例评估解码结果质量。 +func decodedTextScore(text string) int { + if text == "" { + return 0 + } + + score := 0 + for _, r := range text { + switch { + case r == '\n' || r == '\r' || r == '\t': + score += 1 + case r == unicode.ReplacementChar: + score -= 6 + case unicode.IsPrint(r): + score += 2 + default: + score -= 3 + } + } + return score +} + +// decodeUTF16 按大小端将字节流解析为 UTF-16 文本。 +func decodeUTF16(raw []byte, littleEndian bool) string { + if len(raw) < 2 { + return string(raw) + } + if len(raw)%2 != 0 { + raw = raw[:len(raw)-1] + } + + words := make([]uint16, 0, len(raw)/2) + for i := 0; i < len(raw); i += 2 { + if littleEndian { + words = append(words, uint16(raw[i])|uint16(raw[i+1])<<8) + } else { + words = append(words, uint16(raw[i])<<8|uint16(raw[i+1])) + } + } + return string(utf16.Decode(words)) +} diff --git a/internal/tui/infra/workspace_files.go b/internal/tui/infra/workspace_files.go new file mode 100644 index 00000000..a79f58a7 --- /dev/null +++ b/internal/tui/infra/workspace_files.go @@ -0,0 +1,52 @@ +package infra + +import ( + "errors" + "io/fs" + "path/filepath" + "sort" +) + +// CollectWorkspaceFiles 在工作区内收集可用于引用补全的文件路径。 +func CollectWorkspaceFiles(root string, limit int) ([]string, error) { + root, err := filepath.Abs(root) + if err != nil { + return nil, err + } + + var ( + candidates []string + limitErr = errors.New("file limit reached") + ) + + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + name := d.Name() + if d.IsDir() { + switch name { + case ".git", ".gocache", "node_modules": + return filepath.SkipDir + } + return nil + } + + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + candidates = append(candidates, filepath.ToSlash(rel)) + if limit > 0 && len(candidates) >= limit { + return limitErr + } + return nil + }) + if err != nil && !errors.Is(err, limitErr) { + return nil, err + } + + sort.Strings(candidates) + return candidates, nil +} diff --git a/internal/tui/input_features_test.go b/internal/tui/input_features_test.go index 7e587771..ade4fb7f 100644 --- a/internal/tui/input_features_test.go +++ b/internal/tui/input_features_test.go @@ -91,7 +91,7 @@ func TestWorkspaceCommandHelpers(t *testing.T) { workdir := t.TempDir() cfg := config.Config{ Workdir: workdir, - ToolTimeoutSec: 5, + ToolTimeoutSec: 15, } command := "pwd" if goruntime.GOOS == "windows" { diff --git a/internal/tui/services/.gitkeep b/internal/tui/services/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/internal/tui/services/command_service.go b/internal/tui/services/command_service.go new file mode 100644 index 00000000..181f7a80 --- /dev/null +++ b/internal/tui/services/command_service.go @@ -0,0 +1,29 @@ +package services + +import ( + "context" + + tea "github.com/charmbracelet/bubbletea" +) + +// RunLocalCommandCmd 执行本地命令并将结果映射为 UI 消息。 +func RunLocalCommandCmd( + execute func(context.Context) (string, error), + toMsg func(string, error) tea.Msg, +) tea.Cmd { + return func() tea.Msg { + notice, err := execute(context.Background()) + return toMsg(notice, err) + } +} + +// RunWorkspaceCommandCmd 执行工作区命令并将命令/输出/错误映射为 UI 消息。 +func RunWorkspaceCommandCmd( + execute func(context.Context) (string, string, error), + toMsg func(string, string, error) tea.Msg, +) tea.Cmd { + return func() tea.Msg { + command, output, err := execute(context.Background()) + return toMsg(command, output, err) + } +} diff --git a/internal/tui/services/file_service.go b/internal/tui/services/file_service.go new file mode 100644 index 00000000..530f04ca --- /dev/null +++ b/internal/tui/services/file_service.go @@ -0,0 +1,56 @@ +package services + +import ( + "path/filepath" + "strings" + + tuiinfra "neo-code/internal/tui/infra" +) + +// CollectWorkspaceFiles 收集工作区候选文件(由 infra 实际执行扫描)。 +func CollectWorkspaceFiles(root string, limit int) ([]string, error) { + return tuiinfra.CollectWorkspaceFiles(root, limit) +} + +// SuggestFileMatches 基于 query 从候选集中返回优先级排序后的建议列表。 +func SuggestFileMatches(query string, candidates []string, limit int) []string { + if len(candidates) == 0 || limit <= 0 { + return nil + } + prefixMatches := make([]string, 0, limit) + containsMatches := make([]string, 0, limit) + for _, candidate := range candidates { + lower := strings.ToLower(candidate) + switch { + case query == "" || strings.HasPrefix(lower, query): + prefixMatches = append(prefixMatches, candidate) + case strings.Contains(lower, query): + containsMatches = append(containsMatches, candidate) + } + if len(prefixMatches)+len(containsMatches) >= limit { + break + } + } + + out := append(prefixMatches, containsMatches...) + if len(out) > limit { + out = out[:limit] + } + return out +} + +// ResolveWorkspaceDirectory 将工作区路径解析为绝对路径,失败时返回空字符串。 +func ResolveWorkspaceDirectory(workdir string) string { + workdir = strings.TrimSpace(workdir) + if workdir == "" { + return "" + } + if strings.ContainsRune(workdir, '\x00') { + return "" + } + absolute, err := filepath.Abs(workdir) + if err != nil { + return "" + } + return absolute +} diff --git a/internal/tui/services/provider_service.go b/internal/tui/services/provider_service.go new file mode 100644 index 00000000..71538113 --- /dev/null +++ b/internal/tui/services/provider_service.go @@ -0,0 +1,66 @@ +package services + +import ( + "context" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/config" +) + +// ProviderSelector 定义 provider 选择命令所需最小能力。 +type ProviderSelector interface { + SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) +} + +// ModelSelector 定义 model 选择命令所需最小能力。 +type ModelSelector interface { + SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) +} + +// ModelCatalogReader 定义 model catalog 刷新所需最小能力。 +type ModelCatalogReader interface { + ListModels(ctx context.Context) ([]config.ModelDescriptor, error) +} + +// SelectProviderCmd 执行 provider 切换并将结果映射为 UI 消息。 +func SelectProviderCmd( + providerSvc ProviderSelector, + providerID string, + toMsg func(config.ProviderSelection, error) tea.Msg, +) tea.Cmd { + return func() tea.Msg { + selection, err := providerSvc.SelectProvider(context.Background(), providerID) + return toMsg(selection, err) + } +} + +// SelectModelCmd 执行 model 切换并将结果映射为 UI 消息。 +func SelectModelCmd( + providerSvc ModelSelector, + modelID string, + toMsg func(config.ProviderSelection, error) tea.Msg, +) tea.Cmd { + return func() tea.Msg { + selection, err := providerSvc.SetCurrentModel(context.Background(), modelID) + return toMsg(selection, err) + } +} + +// RefreshModelCatalogCmd 拉取指定 provider 的模型列表并返回 UI 消息。 +func RefreshModelCatalogCmd( + providerSvc ModelCatalogReader, + providerID string, + toMsg func(string, []config.ModelDescriptor, error) tea.Msg, +) tea.Cmd { + providerID = strings.TrimSpace(providerID) + if providerSvc == nil || providerID == "" { + return nil + } + + return func() tea.Msg { + models, err := providerSvc.ListModels(context.Background()) + return toMsg(providerID, models, err) + } +} diff --git a/internal/tui/services/runtime_bridge.go b/internal/tui/services/runtime_bridge.go new file mode 100644 index 00000000..95b09034 --- /dev/null +++ b/internal/tui/services/runtime_bridge.go @@ -0,0 +1,552 @@ +package services + +import ( + "fmt" + "strconv" + "strings" + "time" + + tuistate "neo-code/internal/tui/state" +) + +const ( + // RuntimeEventRunContext 是与 runtime 对接时预留的 run context 事件名。 + RuntimeEventRunContext = "run_context" + // RuntimeEventToolStatus 是与 runtime 对接时预留的 tool status 事件名。 + RuntimeEventToolStatus = "tool_status" + // RuntimeEventUsage 是与 runtime 对接时预留的 usage 事件名。 + RuntimeEventUsage = "usage" +) + +// ToolStateVM 定义 tool 状态在服务层桥接的视图模型。 +type ToolStateVM = tuistate.ToolState + +// ContextWindowVM 定义上下文窗口在服务层桥接的视图模型。 +type ContextWindowVM = tuistate.ContextWindowState + +// TokenUsageVM 定义 token 使用统计在服务层桥接的视图模型。 +type TokenUsageVM = tuistate.TokenUsageState + +// RuntimeRunContextPayload 是 TUI 对 runtime run_context 的预留载荷结构。 +type RuntimeRunContextPayload struct { + Provider string + Model string + Workdir string + Mode string +} + +// RuntimeToolStatusPayload 是 TUI 对 runtime tool_status 的预留载荷结构。 +type RuntimeToolStatusPayload struct { + ToolCallID string + ToolName string + Status string + Message string + DurationMS int64 +} + +// RuntimeUsageSnapshot 表示 token 使用统计快照。 +type RuntimeUsageSnapshot struct { + InputTokens int + OutputTokens int + TotalTokens int +} + +// RuntimeUsagePayload 是 TUI 对 runtime usage 的预留载荷结构。 +type RuntimeUsagePayload struct { + Delta RuntimeUsageSnapshot + Run RuntimeUsageSnapshot + Session RuntimeUsageSnapshot +} + +// RuntimeSessionContextSnapshot 是 runtime 查询接口 session context 的预留结构。 +type RuntimeSessionContextSnapshot struct { + SessionID string + Provider string + Model string + Workdir string + Mode string +} + +// RuntimeToolStateSnapshot 是 runtime 查询接口 tool states 的预留结构。 +type RuntimeToolStateSnapshot struct { + ToolCallID string + ToolName string + Status string + Message string + DurationMS int64 + UpdatedAt time.Time +} + +// RuntimeRunContextSnapshot 是 runtime 查询接口 run context 的预留结构。 +type RuntimeRunContextSnapshot struct { + RunID string + SessionID string + Provider string + Model string + Workdir string + Mode string +} + +// RuntimeRunSnapshot 是 runtime 查询接口 run snapshot 的预留结构。 +type RuntimeRunSnapshot struct { + RunID string + SessionID string + Context RuntimeRunContextSnapshot + ToolStates []RuntimeToolStateSnapshot + Usage RuntimeUsageSnapshot + SessionUsage RuntimeUsageSnapshot +} + +// ParseRunContextPayload 解析 runtime run_context 事件载荷。 +func ParseRunContextPayload(payload any) (RuntimeRunContextPayload, bool) { + var out RuntimeRunContextPayload + switch typed := payload.(type) { + case RuntimeRunContextPayload: + out = typed + case *RuntimeRunContextPayload: + if typed == nil { + return RuntimeRunContextPayload{}, false + } + out = *typed + case map[string]any: + out = RuntimeRunContextPayload{ + Provider: readMapString(typed, "Provider"), + Model: readMapString(typed, "Model"), + Workdir: readMapString(typed, "Workdir"), + Mode: readMapString(typed, "Mode"), + } + default: + return RuntimeRunContextPayload{}, false + } + out.Provider = strings.TrimSpace(out.Provider) + out.Model = strings.TrimSpace(out.Model) + out.Workdir = strings.TrimSpace(out.Workdir) + out.Mode = strings.TrimSpace(out.Mode) + if out.Provider == "" && out.Model == "" && out.Workdir == "" && out.Mode == "" { + return RuntimeRunContextPayload{}, false + } + return out, true +} + +// ParseToolStatusPayload 解析 runtime tool_status 事件载荷。 +func ParseToolStatusPayload(payload any) (RuntimeToolStatusPayload, bool) { + var out RuntimeToolStatusPayload + switch typed := payload.(type) { + case RuntimeToolStatusPayload: + out = typed + case *RuntimeToolStatusPayload: + if typed == nil { + return RuntimeToolStatusPayload{}, false + } + out = *typed + case map[string]any: + out = RuntimeToolStatusPayload{ + ToolCallID: readMapString(typed, "ToolCallID"), + ToolName: readMapString(typed, "ToolName"), + Status: readMapString(typed, "Status"), + Message: readMapString(typed, "Message"), + DurationMS: readMapInt64(typed, "DurationMS"), + } + default: + return RuntimeToolStatusPayload{}, false + } + out.ToolCallID = strings.TrimSpace(out.ToolCallID) + out.ToolName = strings.TrimSpace(out.ToolName) + out.Status = strings.TrimSpace(out.Status) + out.Message = strings.TrimSpace(out.Message) + if out.ToolCallID == "" && out.ToolName == "" { + return RuntimeToolStatusPayload{}, false + } + return out, true +} + +// ParseUsagePayload 解析 runtime usage 事件载荷。 +func ParseUsagePayload(payload any) (RuntimeUsagePayload, bool) { + var out RuntimeUsagePayload + switch typed := payload.(type) { + case RuntimeUsagePayload: + out = typed + case *RuntimeUsagePayload: + if typed == nil { + return RuntimeUsagePayload{}, false + } + out = *typed + case map[string]any: + out = RuntimeUsagePayload{ + Delta: readUsageFromAny(typed["Delta"]), + Run: readUsageFromAny(typed["Run"]), + Session: readUsageFromAny(typed["Session"]), + } + default: + return RuntimeUsagePayload{}, false + } + if out.Delta == (RuntimeUsageSnapshot{}) && out.Run == (RuntimeUsageSnapshot{}) && out.Session == (RuntimeUsageSnapshot{}) { + return RuntimeUsagePayload{}, false + } + return out, true +} + +// ParseSessionContextSnapshot 解析 runtime 查询接口返回的 session context。 +func ParseSessionContextSnapshot(snapshot any) (RuntimeSessionContextSnapshot, bool) { + var out RuntimeSessionContextSnapshot + switch typed := snapshot.(type) { + case RuntimeSessionContextSnapshot: + out = typed + case *RuntimeSessionContextSnapshot: + if typed == nil { + return RuntimeSessionContextSnapshot{}, false + } + out = *typed + case map[string]any: + out = RuntimeSessionContextSnapshot{ + SessionID: readMapString(typed, "SessionID"), + Provider: readMapString(typed, "Provider"), + Model: readMapString(typed, "Model"), + Workdir: readMapString(typed, "Workdir"), + Mode: readMapString(typed, "Mode"), + } + default: + return RuntimeSessionContextSnapshot{}, false + } + out.SessionID = strings.TrimSpace(out.SessionID) + out.Provider = strings.TrimSpace(out.Provider) + out.Model = strings.TrimSpace(out.Model) + out.Workdir = strings.TrimSpace(out.Workdir) + out.Mode = strings.TrimSpace(out.Mode) + if out.SessionID == "" && out.Provider == "" && out.Workdir == "" { + return RuntimeSessionContextSnapshot{}, false + } + return out, true +} + +// ParseUsageSnapshot 解析 runtime 查询接口返回的 usage 快照。 +func ParseUsageSnapshot(snapshot any) (RuntimeUsageSnapshot, bool) { + usage := readUsageFromAny(snapshot) + if usage == (RuntimeUsageSnapshot{}) { + return RuntimeUsageSnapshot{}, false + } + return usage, true +} + +// ParseRunSnapshot 解析 runtime 查询接口返回的 run snapshot。 +func ParseRunSnapshot(snapshot any) (RuntimeRunSnapshot, bool) { + var out RuntimeRunSnapshot + switch typed := snapshot.(type) { + case RuntimeRunSnapshot: + out = typed + case *RuntimeRunSnapshot: + if typed == nil { + return RuntimeRunSnapshot{}, false + } + out = *typed + case map[string]any: + out = RuntimeRunSnapshot{ + RunID: readMapString(typed, "RunID"), + SessionID: readMapString(typed, "SessionID"), + Context: parseRunContextSnapshotFromAny(typed["Context"]), + ToolStates: parseToolStatesFromAny(typed["ToolStates"]), + Usage: readUsageFromAny(typed["Usage"]), + SessionUsage: readUsageFromAny(typed["SessionUsage"]), + } + default: + return RuntimeRunSnapshot{}, false + } + out.RunID = strings.TrimSpace(out.RunID) + out.SessionID = strings.TrimSpace(out.SessionID) + if out.RunID == "" && out.SessionID == "" { + return RuntimeRunSnapshot{}, false + } + return out, true +} + +// MapRunContextPayload 将 run_context 载荷映射为桥接视图模型。 +func MapRunContextPayload(runID string, sessionID string, payload RuntimeRunContextPayload) ContextWindowVM { + return tuistate.ContextWindowState{ + RunID: strings.TrimSpace(runID), + SessionID: strings.TrimSpace(sessionID), + Provider: strings.TrimSpace(payload.Provider), + Model: strings.TrimSpace(payload.Model), + Workdir: strings.TrimSpace(payload.Workdir), + Mode: strings.TrimSpace(payload.Mode), + } +} + +// MapSessionContextSnapshot 将 session context 查询结果映射为桥接视图模型。 +func MapSessionContextSnapshot(snapshot RuntimeSessionContextSnapshot) ContextWindowVM { + return tuistate.ContextWindowState{ + SessionID: strings.TrimSpace(snapshot.SessionID), + Provider: strings.TrimSpace(snapshot.Provider), + Model: strings.TrimSpace(snapshot.Model), + Workdir: strings.TrimSpace(snapshot.Workdir), + Mode: strings.TrimSpace(snapshot.Mode), + } +} + +// MapToolStatusPayload 将 tool_status 载荷映射为桥接视图模型。 +func MapToolStatusPayload(payload RuntimeToolStatusPayload) ToolStateVM { + return tuistate.ToolState{ + ToolCallID: strings.TrimSpace(payload.ToolCallID), + ToolName: strings.TrimSpace(payload.ToolName), + Status: mapToolLifecycleStatus(payload.Status), + Message: strings.TrimSpace(payload.Message), + DurationMS: payload.DurationMS, + UpdatedAt: time.Now(), + } +} + +// MergeToolStates 按 tool_call_id + tool_name 去重合并状态,处理重复与乱序事件。 +func MergeToolStates(existing []ToolStateVM, incoming ToolStateVM, limit int) []ToolStateVM { + if limit <= 0 { + limit = 16 + } + + incomingCallID := strings.TrimSpace(incoming.ToolCallID) + incomingName := strings.TrimSpace(incoming.ToolName) + updated := append([]ToolStateVM(nil), existing...) + for i := range updated { + if strings.EqualFold(strings.TrimSpace(updated[i].ToolCallID), incomingCallID) && + strings.EqualFold(strings.TrimSpace(updated[i].ToolName), incomingName) { + updated[i] = incoming + return updated + } + } + + updated = append(updated, incoming) + if len(updated) > limit { + return append([]ToolStateVM(nil), updated[len(updated)-limit:]...) + } + return updated +} + +// MapUsagePayload 将 usage 载荷映射为桥接视图模型。 +func MapUsagePayload(payload RuntimeUsagePayload) TokenUsageVM { + return tuistate.TokenUsageState{ + RunInputTokens: payload.Run.InputTokens, + RunOutputTokens: payload.Run.OutputTokens, + RunTotalTokens: payload.Run.TotalTokens, + SessionInputTokens: payload.Session.InputTokens, + SessionOutputTokens: payload.Session.OutputTokens, + SessionTotalTokens: payload.Session.TotalTokens, + } +} + +// MapUsageSnapshot 将 usage 快照映射为 TokenUsageVM(保留当前 run 统计不变)。 +func MapUsageSnapshot(snapshot RuntimeUsageSnapshot, current TokenUsageVM) TokenUsageVM { + current.SessionInputTokens = snapshot.InputTokens + current.SessionOutputTokens = snapshot.OutputTokens + current.SessionTotalTokens = snapshot.TotalTokens + return current +} + +// MapRunSnapshot 将 run snapshot 映射为桥接视图数据。 +func MapRunSnapshot(snapshot RuntimeRunSnapshot) (ContextWindowVM, []ToolStateVM, TokenUsageVM) { + context := tuistate.ContextWindowState{ + RunID: strings.TrimSpace(snapshot.RunID), + SessionID: strings.TrimSpace(snapshot.SessionID), + Provider: strings.TrimSpace(snapshot.Context.Provider), + Model: strings.TrimSpace(snapshot.Context.Model), + Workdir: strings.TrimSpace(snapshot.Context.Workdir), + Mode: strings.TrimSpace(snapshot.Context.Mode), + } + + tools := make([]ToolStateVM, 0, len(snapshot.ToolStates)) + for _, item := range snapshot.ToolStates { + tools = append(tools, tuistate.ToolState{ + ToolCallID: strings.TrimSpace(item.ToolCallID), + ToolName: strings.TrimSpace(item.ToolName), + Status: mapToolLifecycleStatus(item.Status), + Message: strings.TrimSpace(item.Message), + DurationMS: item.DurationMS, + UpdatedAt: item.UpdatedAt, + }) + } + + usage := tuistate.TokenUsageState{ + RunInputTokens: snapshot.Usage.InputTokens, + RunOutputTokens: snapshot.Usage.OutputTokens, + RunTotalTokens: snapshot.Usage.TotalTokens, + SessionInputTokens: snapshot.SessionUsage.InputTokens, + SessionOutputTokens: snapshot.SessionUsage.OutputTokens, + SessionTotalTokens: snapshot.SessionUsage.TotalTokens, + } + + return context, tools, usage +} + +func mapToolLifecycleStatus(status string) tuistate.ToolLifecycleStatus { + switch strings.ToLower(strings.TrimSpace(status)) { + case string(tuistate.ToolLifecyclePlanned): + return tuistate.ToolLifecyclePlanned + case string(tuistate.ToolLifecycleRunning): + return tuistate.ToolLifecycleRunning + case string(tuistate.ToolLifecycleSucceeded): + return tuistate.ToolLifecycleSucceeded + case string(tuistate.ToolLifecycleFailed): + return tuistate.ToolLifecycleFailed + default: + return tuistate.ToolLifecycleRunning + } +} + +func readUsageFromAny(value any) RuntimeUsageSnapshot { + switch typed := value.(type) { + case RuntimeUsageSnapshot: + return typed + case *RuntimeUsageSnapshot: + if typed == nil { + return RuntimeUsageSnapshot{} + } + return *typed + case map[string]any: + return RuntimeUsageSnapshot{ + InputTokens: readMapInt(typed, "InputTokens"), + OutputTokens: readMapInt(typed, "OutputTokens"), + TotalTokens: readMapInt(typed, "TotalTokens"), + } + default: + return RuntimeUsageSnapshot{} + } +} + +func parseRunContextSnapshotFromAny(value any) RuntimeRunContextSnapshot { + switch typed := value.(type) { + case RuntimeRunContextSnapshot: + return typed + case *RuntimeRunContextSnapshot: + if typed == nil { + return RuntimeRunContextSnapshot{} + } + return *typed + case map[string]any: + return RuntimeRunContextSnapshot{ + RunID: readMapString(typed, "RunID"), + SessionID: readMapString(typed, "SessionID"), + Provider: readMapString(typed, "Provider"), + Model: readMapString(typed, "Model"), + Workdir: readMapString(typed, "Workdir"), + Mode: readMapString(typed, "Mode"), + } + default: + return RuntimeRunContextSnapshot{} + } +} + +func parseToolStatesFromAny(value any) []RuntimeToolStateSnapshot { + switch typed := value.(type) { + case []RuntimeToolStateSnapshot: + return append([]RuntimeToolStateSnapshot(nil), typed...) + case []any: + out := make([]RuntimeToolStateSnapshot, 0, len(typed)) + for _, item := range typed { + if parsed, ok := parseToolStateFromAny(item); ok { + out = append(out, parsed) + } + } + return out + default: + return nil + } +} + +func parseToolStateFromAny(value any) (RuntimeToolStateSnapshot, bool) { + switch typed := value.(type) { + case RuntimeToolStateSnapshot: + return typed, true + case *RuntimeToolStateSnapshot: + if typed == nil { + return RuntimeToolStateSnapshot{}, false + } + return *typed, true + case map[string]any: + return RuntimeToolStateSnapshot{ + ToolCallID: readMapString(typed, "ToolCallID"), + ToolName: readMapString(typed, "ToolName"), + Status: readMapString(typed, "Status"), + Message: readMapString(typed, "Message"), + DurationMS: readMapInt64(typed, "DurationMS"), + UpdatedAt: readMapTime(typed, "UpdatedAt"), + }, true + default: + return RuntimeToolStateSnapshot{}, false + } +} + +func readMapString(m map[string]any, key string) string { + value, ok := m[key] + if !ok || value == nil { + return "" + } + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + default: + return strings.TrimSpace(fmt.Sprintf("%v", typed)) + } +} + +func readMapInt(m map[string]any, key string) int { + value, ok := m[key] + if !ok || value == nil { + return 0 + } + switch typed := value.(type) { + case int: + return typed + case int64: + return int(typed) + case int32: + return int(typed) + case float64: + return int(typed) + case float32: + return int(typed) + case string: + number, err := strconv.Atoi(strings.TrimSpace(typed)) + if err != nil { + return 0 + } + return number + default: + return 0 + } +} + +func readMapInt64(m map[string]any, key string) int64 { + value, ok := m[key] + if !ok || value == nil { + return 0 + } + switch typed := value.(type) { + case int: + return int64(typed) + case int64: + return typed + case int32: + return int64(typed) + case float64: + return int64(typed) + case float32: + return int64(typed) + case string: + number, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + if err != nil { + return 0 + } + return number + default: + return 0 + } +} + +func readMapTime(m map[string]any, key string) time.Time { + value, ok := m[key] + if !ok || value == nil { + return time.Time{} + } + switch typed := value.(type) { + case time.Time: + return typed + default: + return time.Time{} + } +} diff --git a/internal/tui/services/runtime_bridge_test.go b/internal/tui/services/runtime_bridge_test.go new file mode 100644 index 00000000..56f3fc5e --- /dev/null +++ b/internal/tui/services/runtime_bridge_test.go @@ -0,0 +1,519 @@ +package services + +import ( + "fmt" + "testing" + "time" + + tuistate "neo-code/internal/tui/state" +) + +func TestRuntimeBridgeMappings(t *testing.T) { + context := MapRunContextPayload("run-1", "session-1", RuntimeRunContextPayload{ + Provider: "openai", + Model: "gpt-5.4", + Workdir: "/repo", + Mode: "act", + }) + if context.RunID != "run-1" || context.Provider != "openai" { + t.Fatalf("unexpected context mapping: %+v", context) + } + + tool := MapToolStatusPayload(RuntimeToolStatusPayload{ + ToolCallID: "call-1", + ToolName: "filesystem_edit", + Status: "succeeded", + Message: "ok", + DurationMS: 120, + }) + if tool.Status != tuistate.ToolLifecycleSucceeded || tool.DurationMS != 120 { + t.Fatalf("unexpected tool mapping: %+v", tool) + } + + usage := MapUsagePayload(RuntimeUsagePayload{ + Run: RuntimeUsageSnapshot{InputTokens: 10, OutputTokens: 20, TotalTokens: 30}, + Session: RuntimeUsageSnapshot{InputTokens: 40, OutputTokens: 50, TotalTokens: 90}, + }) + if usage.RunTotalTokens != 30 || usage.SessionTotalTokens != 90 { + t.Fatalf("unexpected usage mapping: %+v", usage) + } +} + +func TestRuntimeBridgeParsers(t *testing.T) { + ctx, ok := ParseRunContextPayload(map[string]any{ + "Provider": "openai", + "Model": "gpt-5.4", + "Workdir": "/repo", + "Mode": "act", + }) + if !ok || ctx.Provider != "openai" { + t.Fatalf("expected run_context payload to parse, got %+v", ctx) + } + + tool, ok := ParseToolStatusPayload(map[string]any{ + "ToolCallID": "call-1", + "ToolName": "filesystem_edit", + "Status": "running", + "DurationMS": int64(99), + }) + if !ok || tool.ToolCallID != "call-1" || tool.DurationMS != 99 { + t.Fatalf("expected tool_status payload to parse, got %+v", tool) + } + + usage, ok := ParseUsagePayload(map[string]any{ + "Run": map[string]any{ + "InputTokens": 1, + "OutputTokens": 2, + "TotalTokens": 3, + }, + "Session": map[string]any{ + "InputTokens": 10, + "OutputTokens": 20, + "TotalTokens": 30, + }, + }) + if !ok || usage.Run.TotalTokens != 3 || usage.Session.TotalTokens != 30 { + t.Fatalf("expected usage payload to parse, got %+v", usage) + } +} + +func TestMergeToolStatesHandlesDuplicateAndLimit(t *testing.T) { + now := time.Now() + states := []ToolStateVM{ + { + ToolCallID: "call-1", + ToolName: "tool-a", + Status: tuistate.ToolLifecycleRunning, + UpdatedAt: now, + }, + } + + updated := MergeToolStates(states, ToolStateVM{ + ToolCallID: "call-1", + ToolName: "tool-a", + Status: tuistate.ToolLifecycleSucceeded, + UpdatedAt: now.Add(time.Second), + }, 2) + if len(updated) != 1 || updated[0].Status != tuistate.ToolLifecycleSucceeded { + t.Fatalf("expected duplicate to be replaced, got %+v", updated) + } + + updated = MergeToolStates(updated, ToolStateVM{ + ToolCallID: "call-2", + ToolName: "tool-b", + Status: tuistate.ToolLifecycleRunning, + UpdatedAt: now.Add(2 * time.Second), + }, 1) + if len(updated) != 1 || updated[0].ToolCallID != "call-2" { + t.Fatalf("expected limit to keep newest item, got %+v", updated) + } +} + +func TestMapRunSnapshot(t *testing.T) { + now := time.Now() + context, tools, usage := MapRunSnapshot(RuntimeRunSnapshot{ + RunID: "run-2", + SessionID: "session-2", + Context: RuntimeRunContextSnapshot{ + RunID: "run-2", + SessionID: "session-2", + Provider: "openai", + Model: "gpt-5.4-mini", + Workdir: "/repo", + Mode: "act", + }, + ToolStates: []RuntimeToolStateSnapshot{ + { + ToolCallID: "call-1", + ToolName: "filesystem_read_file", + Status: "succeeded", + Message: "ok", + DurationMS: 88, + UpdatedAt: now, + }, + }, + Usage: RuntimeUsageSnapshot{InputTokens: 3, OutputTokens: 7, TotalTokens: 10}, + SessionUsage: RuntimeUsageSnapshot{InputTokens: 30, OutputTokens: 70, TotalTokens: 100}, + }) + + if context.RunID != "run-2" || len(tools) != 1 || usage.SessionTotalTokens != 100 { + t.Fatalf("unexpected run snapshot mapping: context=%+v tools=%+v usage=%+v", context, tools, usage) + } +} + +func TestRuntimeBridgeParsersTypedAndNilInputs(t *testing.T) { + ctx, ok := ParseRunContextPayload(RuntimeRunContextPayload{ + Provider: " openai ", + Model: " gpt-5.4 ", + }) + if !ok || ctx.Provider != "openai" || ctx.Model != "gpt-5.4" { + t.Fatalf("expected typed run context payload to parse, got %+v ok=%v", ctx, ok) + } + + var nilRunContext *RuntimeRunContextPayload + if _, ok := ParseRunContextPayload(nilRunContext); ok { + t.Fatalf("expected nil run context pointer to fail parsing") + } + if _, ok := ParseRunContextPayload(map[string]any{"Provider": " ", "Model": " "}); ok { + t.Fatalf("expected empty run context map to fail parsing") + } + + tool, ok := ParseToolStatusPayload(map[string]any{ + "ToolCallID": 12345, + "ToolName": " filesystem ", + "Status": " failed ", + "Message": " boom ", + "DurationMS": "88", + }) + if !ok || tool.ToolCallID != "12345" || tool.ToolName != "filesystem" || tool.DurationMS != 88 { + t.Fatalf("unexpected tool status parse result: %+v ok=%v", tool, ok) + } + + var nilToolStatus *RuntimeToolStatusPayload + if _, ok := ParseToolStatusPayload(nilToolStatus); ok { + t.Fatalf("expected nil tool status pointer to fail parsing") + } + if _, ok := ParseToolStatusPayload(map[string]any{"ToolCallID": " ", "ToolName": ""}); ok { + t.Fatalf("expected empty tool status payload to fail parsing") + } + + usage, ok := ParseUsagePayload(map[string]any{ + "Delta": map[string]any{ + "InputTokens": "1", + "OutputTokens": float64(2), + "TotalTokens": int64(3), + }, + "Run": &RuntimeUsageSnapshot{ + InputTokens: 4, + OutputTokens: 5, + TotalTokens: 9, + }, + "Session": RuntimeUsageSnapshot{ + InputTokens: 10, + OutputTokens: 20, + TotalTokens: 30, + }, + }) + if !ok || usage.Delta.TotalTokens != 3 || usage.Run.TotalTokens != 9 || usage.Session.TotalTokens != 30 { + t.Fatalf("unexpected usage payload parse result: %+v ok=%v", usage, ok) + } + + var nilUsage *RuntimeUsagePayload + if _, ok := ParseUsagePayload(nilUsage); ok { + t.Fatalf("expected nil usage pointer to fail parsing") + } + if _, ok := ParseUsagePayload(RuntimeUsagePayload{}); ok { + t.Fatalf("expected zero usage payload to fail parsing") + } +} + +func TestRuntimeBridgeSnapshotParsers(t *testing.T) { + session, ok := ParseSessionContextSnapshot(map[string]any{ + "SessionID": " session-1 ", + "Provider": " openai ", + "Model": " gpt-5.4 ", + "Workdir": " /repo ", + "Mode": " act ", + }) + if !ok || session.SessionID != "session-1" || session.Workdir != "/repo" { + t.Fatalf("unexpected session snapshot parse result: %+v ok=%v", session, ok) + } + + var nilSession *RuntimeSessionContextSnapshot + if _, ok := ParseSessionContextSnapshot(nilSession); ok { + t.Fatalf("expected nil session snapshot pointer to fail parsing") + } + if _, ok := ParseSessionContextSnapshot(RuntimeSessionContextSnapshot{}); ok { + t.Fatalf("expected empty session snapshot to fail parsing") + } + + usage, ok := ParseUsageSnapshot(map[string]any{ + "InputTokens": "11", + "OutputTokens": float64(22), + "TotalTokens": int32(33), + }) + if !ok || usage.TotalTokens != 33 { + t.Fatalf("unexpected usage snapshot parse result: %+v ok=%v", usage, ok) + } + if _, ok := ParseUsageSnapshot(RuntimeUsageSnapshot{}); ok { + t.Fatalf("expected empty usage snapshot to fail parsing") + } +} + +func TestRuntimeBridgeParseRunSnapshot(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + existing := RuntimeToolStateSnapshot{ + ToolCallID: "call-2", + ToolName: "tool-b", + Status: "failed", + } + snapshot, ok := ParseRunSnapshot(map[string]any{ + "RunID": " run-9 ", + "SessionID": " session-9 ", + "Context": map[string]any{ + "RunID": "run-9", + "SessionID": "session-9", + "Provider": " openai ", + "Model": " gpt-5.4 ", + "Workdir": " /repo ", + "Mode": " act ", + }, + "ToolStates": []any{ + map[string]any{ + "ToolCallID": "call-1", + "ToolName": "tool-a", + "Status": "succeeded", + "Message": "ok", + "DurationMS": "41", + "UpdatedAt": now, + }, + &existing, + "ignored", + (*RuntimeToolStateSnapshot)(nil), + }, + "Usage": map[string]any{ + "InputTokens": 1, + "OutputTokens": 2, + "TotalTokens": 3, + }, + "SessionUsage": RuntimeUsageSnapshot{ + InputTokens: 10, + OutputTokens: 20, + TotalTokens: 30, + }, + }) + if !ok { + t.Fatalf("expected run snapshot to parse") + } + if snapshot.RunID != "run-9" || snapshot.SessionID != "session-9" { + t.Fatalf("unexpected run/session ids: %+v", snapshot) + } + if len(snapshot.ToolStates) != 2 || snapshot.ToolStates[0].DurationMS != 41 { + t.Fatalf("unexpected parsed tool states: %+v", snapshot.ToolStates) + } + if !snapshot.ToolStates[0].UpdatedAt.Equal(now) { + t.Fatalf("expected parsed updated-at timestamp, got %v", snapshot.ToolStates[0].UpdatedAt) + } + if snapshot.Context.Provider != "openai" { + t.Fatalf("expected context provider from map, got %q", snapshot.Context.Provider) + } + + var nilSnapshot *RuntimeRunSnapshot + if _, ok := ParseRunSnapshot(nilSnapshot); ok { + t.Fatalf("expected nil run snapshot pointer to fail parsing") + } + if _, ok := ParseRunSnapshot(map[string]any{"RunID": " ", "SessionID": ""}); ok { + t.Fatalf("expected empty run snapshot ids to fail parsing") + } + if _, ok := ParseRunSnapshot(42); ok { + t.Fatalf("expected unsupported run snapshot type to fail parsing") + } +} + +func TestRuntimeBridgeMapSnapshotHelpers(t *testing.T) { + context := MapSessionContextSnapshot(RuntimeSessionContextSnapshot{ + SessionID: " session-2 ", + Provider: " openai ", + Model: " gpt-5.4-mini ", + Workdir: " /workspace ", + Mode: " plan ", + }) + if context.SessionID != "session-2" || context.Provider != "openai" || context.Workdir != "/workspace" { + t.Fatalf("unexpected mapped session context: %+v", context) + } + + current := TokenUsageVM{ + RunInputTokens: 1, + RunOutputTokens: 2, + RunTotalTokens: 3, + SessionInputTokens: 4, + SessionOutputTokens: 5, + SessionTotalTokens: 9, + } + mapped := MapUsageSnapshot(RuntimeUsageSnapshot{ + InputTokens: 100, + OutputTokens: 200, + TotalTokens: 300, + }, current) + if mapped.RunTotalTokens != 3 || mapped.SessionTotalTokens != 300 { + t.Fatalf("unexpected mapped usage snapshot: %+v", mapped) + } +} + +func TestRuntimeBridgeMergeToolStatesCaseInsensitiveAndDefaultLimit(t *testing.T) { + now := time.Now() + replaced := MergeToolStates([]ToolStateVM{ + { + ToolCallID: "Call-1", + ToolName: "Tool-A", + Status: tuistate.ToolLifecycleRunning, + UpdatedAt: now, + }, + }, ToolStateVM{ + ToolCallID: "call-1", + ToolName: "tool-a", + Status: tuistate.ToolLifecycleSucceeded, + UpdatedAt: now.Add(time.Second), + }, 0) + if len(replaced) != 1 || replaced[0].Status != tuistate.ToolLifecycleSucceeded { + t.Fatalf("expected case-insensitive duplicate replacement, got %+v", replaced) + } + + var states []ToolStateVM + for i := 0; i < 16; i++ { + states = append(states, ToolStateVM{ + ToolCallID: fmt.Sprintf("call-%d", i), + ToolName: "tool", + Status: tuistate.ToolLifecycleRunning, + }) + } + states = MergeToolStates(states, ToolStateVM{ + ToolCallID: "call-16", + ToolName: "tool", + Status: tuistate.ToolLifecycleRunning, + }, 0) + if len(states) != 16 { + t.Fatalf("expected default limit to keep 16 states, got %d", len(states)) + } + if states[0].ToolCallID != "call-1" || states[len(states)-1].ToolCallID != "call-16" { + t.Fatalf("expected oldest state to be evicted with default limit, got %+v", states) + } +} + +func TestRuntimeBridgeInternalHelpers(t *testing.T) { + if got := mapToolLifecycleStatus("planned"); got != tuistate.ToolLifecyclePlanned { + t.Fatalf("expected planned status, got %q", got) + } + if got := mapToolLifecycleStatus(" RUNNING "); got != tuistate.ToolLifecycleRunning { + t.Fatalf("expected running status, got %q", got) + } + if got := mapToolLifecycleStatus("succeeded"); got != tuistate.ToolLifecycleSucceeded { + t.Fatalf("expected succeeded status, got %q", got) + } + if got := mapToolLifecycleStatus("failed"); got != tuistate.ToolLifecycleFailed { + t.Fatalf("expected failed status, got %q", got) + } + if got := mapToolLifecycleStatus("unknown"); got != tuistate.ToolLifecycleRunning { + t.Fatalf("expected unknown status fallback to running, got %q", got) + } + + if got := parseRunContextSnapshotFromAny(RuntimeRunContextSnapshot{RunID: "r1"}); got.RunID != "r1" { + t.Fatalf("unexpected direct run context snapshot parse result: %+v", got) + } + if got := parseRunContextSnapshotFromAny((*RuntimeRunContextSnapshot)(nil)); got != (RuntimeRunContextSnapshot{}) { + t.Fatalf("expected nil run context pointer to produce zero value, got %+v", got) + } + if got := parseRunContextSnapshotFromAny(map[string]any{"RunID": "r2", "Provider": "openai"}); got.RunID != "r2" { + t.Fatalf("unexpected map run context snapshot parse result: %+v", got) + } + + original := []RuntimeToolStateSnapshot{{ToolCallID: "call-1"}} + cloned := parseToolStatesFromAny(original) + if len(cloned) != 1 || cloned[0].ToolCallID != "call-1" { + t.Fatalf("unexpected direct tool state parse result: %+v", cloned) + } + cloned[0].ToolCallID = "modified" + if original[0].ToolCallID != "call-1" { + t.Fatalf("expected parseToolStatesFromAny to return a copied slice") + } + + parsedStates := parseToolStatesFromAny([]any{ + map[string]any{"ToolCallID": "call-2", "ToolName": "tool"}, + RuntimeToolStateSnapshot{ToolCallID: "call-3"}, + (*RuntimeToolStateSnapshot)(nil), + "ignored", + }) + if len(parsedStates) != 2 { + t.Fatalf("expected two parsed tool states from mixed input, got %+v", parsedStates) + } + if got := parseToolStatesFromAny("unsupported"); got != nil { + t.Fatalf("expected unsupported tool state input to return nil, got %+v", got) + } + + if _, ok := parseToolStateFromAny((*RuntimeToolStateSnapshot)(nil)); ok { + t.Fatalf("expected nil tool state pointer to fail parsing") + } + if _, ok := parseToolStateFromAny(false); ok { + t.Fatalf("expected unsupported tool state type to fail parsing") + } +} + +func TestRuntimeBridgeReadMapHelpers(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + m := map[string]any{ + "string": " value ", + "int": 12, + "int64": int64(13), + "int32": int32(14), + "float64": float64(15.9), + "float32": float32(16.7), + "numericStr": " 17 ", + "badStr": "x", + "time": now, + "nil": nil, + } + + if got := readMapString(m, "string"); got != "value" { + t.Fatalf("unexpected readMapString result: %q", got) + } + if got := readMapString(m, "int"); got != "12" { + t.Fatalf("expected non-string value to be stringified, got %q", got) + } + if got := readMapString(m, "missing"); got != "" { + t.Fatalf("expected missing key to return empty string, got %q", got) + } + + if got := readMapInt(m, "int"); got != 12 { + t.Fatalf("unexpected readMapInt int value: %d", got) + } + if got := readMapInt(m, "int64"); got != 13 { + t.Fatalf("unexpected readMapInt int64 value: %d", got) + } + if got := readMapInt(m, "int32"); got != 14 { + t.Fatalf("unexpected readMapInt int32 value: %d", got) + } + if got := readMapInt(m, "float64"); got != 15 { + t.Fatalf("unexpected readMapInt float64 value: %d", got) + } + if got := readMapInt(m, "float32"); got != 16 { + t.Fatalf("unexpected readMapInt float32 value: %d", got) + } + if got := readMapInt(m, "numericStr"); got != 17 { + t.Fatalf("unexpected readMapInt string value: %d", got) + } + if got := readMapInt(m, "badStr"); got != 0 { + t.Fatalf("expected invalid numeric string to return 0, got %d", got) + } + if got := readMapInt(m, "nil"); got != 0 { + t.Fatalf("expected nil value to return 0, got %d", got) + } + + if got := readMapInt64(m, "int"); got != 12 { + t.Fatalf("unexpected readMapInt64 int value: %d", got) + } + if got := readMapInt64(m, "int64"); got != 13 { + t.Fatalf("unexpected readMapInt64 int64 value: %d", got) + } + if got := readMapInt64(m, "int32"); got != 14 { + t.Fatalf("unexpected readMapInt64 int32 value: %d", got) + } + if got := readMapInt64(m, "float64"); got != 15 { + t.Fatalf("unexpected readMapInt64 float64 value: %d", got) + } + if got := readMapInt64(m, "float32"); got != 16 { + t.Fatalf("unexpected readMapInt64 float32 value: %d", got) + } + if got := readMapInt64(m, "numericStr"); got != 17 { + t.Fatalf("unexpected readMapInt64 string value: %d", got) + } + if got := readMapInt64(m, "badStr"); got != 0 { + t.Fatalf("expected invalid int64 string to return 0, got %d", got) + } + + if got := readMapTime(m, "time"); !got.Equal(now) { + t.Fatalf("unexpected readMapTime value: %v", got) + } + if got := readMapTime(m, "string"); !got.IsZero() { + t.Fatalf("expected non-time value to return zero time, got %v", got) + } +} diff --git a/internal/tui/services/runtime_service.go b/internal/tui/services/runtime_service.go new file mode 100644 index 00000000..c151bad6 --- /dev/null +++ b/internal/tui/services/runtime_service.go @@ -0,0 +1,58 @@ +package services + +import ( + "context" + + tea "github.com/charmbracelet/bubbletea" + + agentruntime "neo-code/internal/runtime" +) + +// Runner 定义执行 runtime run 所需最小能力。 +type Runner interface { + Run(ctx context.Context, input agentruntime.UserInput) error +} + +// Compactor 定义执行 runtime compact 所需最小能力。 +type Compactor interface { + Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) +} + +// ListenForRuntimeEventCmd 监听 runtime 事件通道,并将结果映射为 UI 消息。 +func ListenForRuntimeEventCmd( + sub <-chan agentruntime.RuntimeEvent, + eventMsg func(agentruntime.RuntimeEvent) tea.Msg, + closedMsg func() tea.Msg, +) tea.Cmd { + return func() tea.Msg { + event, ok := <-sub + if !ok { + return closedMsg() + } + return eventMsg(event) + } +} + +// RunAgentCmd 执行 runtime run,并将执行结果回传为 UI 消息。 +func RunAgentCmd( + runtime Runner, + input agentruntime.UserInput, + doneMsg func(error) tea.Msg, +) tea.Cmd { + return func() tea.Msg { + err := runtime.Run(context.Background(), input) + return doneMsg(err) + } +} + +// RunCompactCmd 执行 runtime compact,并将结果映射为 UI 消息。 +func RunCompactCmd( + runtime Compactor, + input agentruntime.CompactInput, + doneMsg func(error) tea.Msg, +) tea.Cmd { + return func() tea.Msg { + _, err := runtime.Compact(context.Background(), input) + return doneMsg(err) + } +} diff --git a/internal/tui/services/services_test.go b/internal/tui/services/services_test.go new file mode 100644 index 00000000..cde184c9 --- /dev/null +++ b/internal/tui/services/services_test.go @@ -0,0 +1,213 @@ +package services + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/config" + agentruntime "neo-code/internal/runtime" +) + +type stubRunner struct { + lastInput agentruntime.UserInput + err error +} + +func (s *stubRunner) Run(ctx context.Context, input agentruntime.UserInput) error { + s.lastInput = input + return s.err +} + +type stubCompactor struct { + lastInput agentruntime.CompactInput + err error +} + +func (s *stubCompactor) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + s.lastInput = input + return agentruntime.CompactResult{}, s.err +} + +type stubProvider struct { + selection config.ProviderSelection + models []config.ModelDescriptor + err error +} + +func (s *stubProvider) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { + return s.selection, s.err +} + +func (s *stubProvider) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { + return s.selection, s.err +} + +func (s *stubProvider) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { + return s.models, s.err +} + +func TestListenForRuntimeEventCmd(t *testing.T) { + ch := make(chan agentruntime.RuntimeEvent, 1) + event := agentruntime.RuntimeEvent{Type: agentruntime.EventUserMessage} + ch <- event + + msg := ListenForRuntimeEventCmd( + ch, + func(e agentruntime.RuntimeEvent) tea.Msg { return e }, + func() tea.Msg { return "closed" }, + )() + got, ok := msg.(agentruntime.RuntimeEvent) + if !ok || got.Type != agentruntime.EventUserMessage { + t.Fatalf("expected runtime event msg, got %T %#v", msg, msg) + } + + close(ch) + msg = ListenForRuntimeEventCmd( + ch, + func(e agentruntime.RuntimeEvent) tea.Msg { return e }, + func() tea.Msg { return "closed" }, + )() + if gotClosed, ok := msg.(string); !ok || gotClosed != "closed" { + t.Fatalf("expected closed msg, got %T %#v", msg, msg) + } +} + +func TestRunAgentCmd(t *testing.T) { + runner := &stubRunner{err: errors.New("boom")} + input := agentruntime.UserInput{SessionID: "s1", Content: "hello", Workdir: "D:/"} + msg := RunAgentCmd(runner, input, func(err error) tea.Msg { return err })() + if runner.lastInput.SessionID != "s1" || runner.lastInput.Content != "hello" { + t.Fatalf("unexpected runner input: %+v", runner.lastInput) + } + if err, ok := msg.(error); !ok || err == nil || err.Error() != "boom" { + t.Fatalf("expected forwarded error message, got %T %#v", msg, msg) + } +} + +func TestRunCompactCmd(t *testing.T) { + compactor := &stubCompactor{err: errors.New("compact failed")} + input := agentruntime.CompactInput{SessionID: "s2"} + msg := RunCompactCmd(compactor, input, func(err error) tea.Msg { return err })() + if compactor.lastInput.SessionID != "s2" { + t.Fatalf("unexpected compact input: %+v", compactor.lastInput) + } + if err, ok := msg.(error); !ok || err == nil || err.Error() != "compact failed" { + t.Fatalf("expected forwarded compact error, got %T %#v", msg, msg) + } +} + +func TestProviderCmds(t *testing.T) { + svc := &stubProvider{ + selection: config.ProviderSelection{ProviderID: "openai", ModelID: "gpt-5.4"}, + models: []config.ModelDescriptor{{ID: "gpt-5.4", Name: "GPT-5.4"}}, + } + + msg := SelectProviderCmd(svc, "openai", func(sel config.ProviderSelection, err error) tea.Msg { return sel })() + if sel, ok := msg.(config.ProviderSelection); !ok || sel.ProviderID != "openai" { + t.Fatalf("expected provider selection msg, got %T %#v", msg, msg) + } + + msg = SelectModelCmd(svc, "gpt-5.4", func(sel config.ProviderSelection, err error) tea.Msg { return sel })() + if sel, ok := msg.(config.ProviderSelection); !ok || sel.ModelID != "gpt-5.4" { + t.Fatalf("expected model selection msg, got %T %#v", msg, msg) + } + + msg = RefreshModelCatalogCmd( + svc, + "openai", + func(providerID string, models []config.ModelDescriptor, err error) tea.Msg { + return providerID + ":" + models[0].ID + }, + )() + if got, ok := msg.(string); !ok || got != "openai:gpt-5.4" { + t.Fatalf("expected catalog refresh msg, got %T %#v", msg, msg) + } + + if cmd := RefreshModelCatalogCmd(svc, "", func(providerID string, models []config.ModelDescriptor, err error) tea.Msg { return nil }); cmd != nil { + t.Fatalf("expected nil cmd for empty provider id") + } +} + +func TestCommandCmds(t *testing.T) { + localMsg := RunLocalCommandCmd( + func(ctx context.Context) (string, error) { return "ok", nil }, + func(notice string, err error) tea.Msg { return notice }, + )() + if got, ok := localMsg.(string); !ok || got != "ok" { + t.Fatalf("expected local command notice msg, got %T %#v", localMsg, localMsg) + } + + workspaceMsg := RunWorkspaceCommandCmd( + func(ctx context.Context) (string, string, error) { return "git status", "clean", nil }, + func(command string, output string, err error) tea.Msg { return command + ":" + output }, + )() + if got, ok := workspaceMsg.(string); !ok || got != "git status:clean" { + t.Fatalf("expected workspace command msg, got %T %#v", workspaceMsg, workspaceMsg) + } +} + +func TestFileServices(t *testing.T) { + matches := SuggestFileMatches("int", []string{ + "README.md", + "internal/tui/update.go", + "docs/internal-arch.md", + }, 2) + if len(matches) != 2 { + t.Fatalf("expected 2 matches, got %d (%v)", len(matches), matches) + } + if matches[0] != "internal/tui/update.go" { + t.Fatalf("expected prefix match first, got %v", matches) + } + + root := t.TempDir() + path := filepath.Join(root, "a.txt") + if err := os.WriteFile(path, []byte("a"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + files, err := CollectWorkspaceFiles(root, 10) + if err != nil { + t.Fatalf("CollectWorkspaceFiles() error = %v", err) + } + if len(files) == 0 || files[0] != "a.txt" { + t.Fatalf("unexpected collected files: %v", files) + } + + if resolved := ResolveWorkspaceDirectory(root); resolved == "" { + t.Fatalf("expected resolved workspace directory") + } + if resolved := ResolveWorkspaceDirectory(""); resolved != "" { + t.Fatalf("expected empty resolved path for blank input, got %q", resolved) + } +} + +func TestSuggestFileMatchesBranches(t *testing.T) { + candidates := []string{ + "internal/tui/update.go", + "docs/internal-arch.md", + "README.md", + } + + if got := SuggestFileMatches("arch", candidates, 2); len(got) != 1 || got[0] != "docs/internal-arch.md" { + t.Fatalf("expected contains-match branch, got %v", got) + } + if got := SuggestFileMatches("", candidates, 2); len(got) != 2 { + t.Fatalf("expected empty query to return prefix-priority items, got %v", got) + } + if got := SuggestFileMatches("any", candidates, 0); got != nil { + t.Fatalf("expected zero limit to return nil, got %v", got) + } + if got := SuggestFileMatches("any", nil, 2); got != nil { + t.Fatalf("expected nil candidates to return nil, got %v", got) + } +} + +func TestResolveWorkspaceDirectoryInvalidPath(t *testing.T) { + if resolved := ResolveWorkspaceDirectory("\x00"); resolved != "" { + t.Fatalf("expected invalid path to resolve as empty string, got %q", resolved) + } +} diff --git a/internal/tui/state/.gitkeep b/internal/tui/state/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/internal/tui/state/chat_state.go b/internal/tui/state/chat_state.go new file mode 100644 index 00000000..18440412 --- /dev/null +++ b/internal/tui/state/chat_state.go @@ -0,0 +1,17 @@ +package state + +import "time" + +// ActivityEntry 表示 Activity 面板中的单条事件记录。 +type ActivityEntry struct { + Time time.Time + Kind string + Title string + Detail string + IsError bool +} + +// CommandMenuMeta 表示命令建议菜单的标题等元信息。 +type CommandMenuMeta struct { + Title string +} diff --git a/internal/tui/state/constants.go b/internal/tui/state/constants.go new file mode 100644 index 00000000..3205ff91 --- /dev/null +++ b/internal/tui/state/constants.go @@ -0,0 +1,15 @@ +package state + +import "time" + +// 这些常量定义了输入框与粘贴检测在 Update 流程中的基础行为阈值。 +const ( + ComposerMinHeight = 1 + ComposerMaxHeight = 5 + ComposerPromptWidth = 2 + MouseWheelStepLines = 3 + PasteBurstWindow = 120 * time.Millisecond + PasteEnterGuard = 180 * time.Millisecond + PasteSessionGuard = 5 * time.Second + PasteBurstThreshold = 12 +) diff --git a/internal/tui/state/messages.go b/internal/tui/state/messages.go new file mode 100644 index 00000000..9016281d --- /dev/null +++ b/internal/tui/state/messages.go @@ -0,0 +1,53 @@ +package state + +import ( + "neo-code/internal/config" + agentruntime "neo-code/internal/runtime" +) + +// RuntimeMsg 封装 runtime 事件流消息。 +type RuntimeMsg struct { + Event agentruntime.RuntimeEvent +} + +// RuntimeClosedMsg 表示 runtime 事件通道已关闭。 +type RuntimeClosedMsg struct{} + +// RunFinishedMsg 表示一次 Run 调用结束。 +type RunFinishedMsg struct { + Err error +} + +// ModelCatalogRefreshMsg 表示模型目录刷新结果。 +type ModelCatalogRefreshMsg struct { + ProviderID string + Models []config.ModelDescriptor + Err error +} + +// CompactFinishedMsg 表示 compact 调用结束。 +type CompactFinishedMsg struct { + Err error +} + +// LocalCommandResultMsg 表示本地命令执行结果。 +type LocalCommandResultMsg struct { + Notice string + Err error + ProviderChanged bool + ModelChanged bool +} + +// SessionWorkdirResultMsg 表示会话工作目录命令结果。 +type SessionWorkdirResultMsg struct { + Notice string + Workdir string + Err error +} + +// WorkspaceCommandResultMsg 表示工作区命令执行结果。 +type WorkspaceCommandResultMsg struct { + Command string + Output string + Err error +} diff --git a/internal/tui/state/runtime_state.go b/internal/tui/state/runtime_state.go new file mode 100644 index 00000000..7909ed11 --- /dev/null +++ b/internal/tui/state/runtime_state.go @@ -0,0 +1,43 @@ +package state + +import "time" + +// ToolLifecycleStatus 描述工具执行生命周期状态。 +type ToolLifecycleStatus string + +const ( + ToolLifecyclePlanned ToolLifecycleStatus = "planned" + ToolLifecycleRunning ToolLifecycleStatus = "running" + ToolLifecycleSucceeded ToolLifecycleStatus = "succeeded" + ToolLifecycleFailed ToolLifecycleStatus = "failed" +) + +// ToolState 记录单个工具调用在 UI 中展示的状态。 +type ToolState struct { + ToolCallID string + ToolName string + Status ToolLifecycleStatus + Message string + DurationMS int64 + UpdatedAt time.Time +} + +// ContextWindowState 描述 runtime 透出的上下文窗口信息。 +type ContextWindowState struct { + RunID string + SessionID string + Provider string + Model string + Workdir string + Mode string +} + +// TokenUsageState 描述 token 统计在 UI 的展示结构。 +type TokenUsageState struct { + RunInputTokens int + RunOutputTokens int + RunTotalTokens int + SessionInputTokens int + SessionOutputTokens int + SessionTotalTokens int +} diff --git a/internal/tui/state/state_test.go b/internal/tui/state/state_test.go new file mode 100644 index 00000000..73e34cda --- /dev/null +++ b/internal/tui/state/state_test.go @@ -0,0 +1,25 @@ +package state + +import "testing" + +func TestPanelAndPickerConstants(t *testing.T) { + if PanelSessions != 0 || PanelTranscript != 1 || PanelActivity != 2 || PanelInput != 3 { + t.Fatalf("unexpected panel constants: %d %d %d %d", PanelSessions, PanelTranscript, PanelActivity, PanelInput) + } + if PickerNone != 0 || PickerProvider != 1 || PickerModel != 2 || PickerFile != 3 { + t.Fatalf("unexpected picker constants: %d %d %d %d", PickerNone, PickerProvider, PickerModel, PickerFile) + } +} + +func TestUIStateCarriesFocusAndPicker(t *testing.T) { + s := UIState{ + Focus: PanelInput, + ActivePicker: PickerModel, + } + if s.Focus != PanelInput { + t.Fatalf("expected focus panel input, got %v", s.Focus) + } + if s.ActivePicker != PickerModel { + t.Fatalf("expected model picker, got %v", s.ActivePicker) + } +} diff --git a/internal/tui/state/ui_state.go b/internal/tui/state/ui_state.go new file mode 100644 index 00000000..ac6c4611 --- /dev/null +++ b/internal/tui/state/ui_state.go @@ -0,0 +1,47 @@ +package state + +import agentruntime "neo-code/internal/runtime" + +// Panel 定义 TUI 中可聚焦的主面板。 +type Panel int + +const ( + PanelSessions Panel = iota + PanelTranscript + PanelActivity + PanelInput +) + +// PickerMode 定义当前激活的选择器类型。 +type PickerMode int + +const ( + PickerNone PickerMode = iota + PickerProvider + PickerModel + PickerFile +) + +// UIState 保存顶层界面状态快照,仅作为数据容器使用。 +type UIState struct { + Sessions []agentruntime.SessionSummary + ActiveSessionID string + ActiveSessionTitle string + ActiveRunID string + InputText string + IsAgentRunning bool + IsCompacting bool + StreamingReply bool + CurrentTool string + ToolStates []ToolState + RunContext ContextWindowState + TokenUsage TokenUsageState + ExecutionError string + StatusText string + CurrentProvider string + CurrentModel string + CurrentWorkdir string + ShowHelp bool + ActivePicker PickerMode + Focus Panel +} diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index ed307cc0..32c932e8 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "regexp" + goruntime "runtime" "strings" "sync" "testing" @@ -2481,7 +2482,14 @@ func TestWorkspaceCommandAndFileReferenceFlow(t *testing.T) { if !strings.Contains(menu, shellMenuTitle) || !strings.Contains(menu, workspaceCommandUsage) { t.Fatalf("expected shell hint menu, got %q", menu) } - if strings.Count(menu, "\n") > 3 { + // Shell menu should stay reasonably compact (title + one item row + padding). + // Allow extra newlines on Windows where long paths with non-ASCII characters + // may cause lipgloss to wrap the description line. + maxShellMenuLines := 4 + if goruntime.GOOS == "windows" { + maxShellMenuLines = 6 + } + if strings.Count(menu, "\n") > maxShellMenuLines { t.Fatalf("expected compact shell menu, got %q", menu) } }