diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..ff68061 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,133 @@ +# the name by which the project can be referenced within Serena +project_name: "superwork" + + +# list of languages for which language servers are started; choose from: +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html java json julia +# kotlin lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor powershell python +# python_jedi python_ty r rego ruby +# ruby_solargraph rust scala scss solidity +# svelte swift systemverilog terraform toml +# typescript typescript_vts vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- go + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] diff --git a/tui/internal/tui/detail.go b/tui/internal/tui/detail.go index 109e5c4..f626430 100644 --- a/tui/internal/tui/detail.go +++ b/tui/internal/tui/detail.go @@ -4,6 +4,7 @@ import ( "fmt" "path/filepath" "strings" + "time" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" @@ -117,7 +118,7 @@ var ( // detailBody builds the two stacked blocks (header + STATE JSON table) shown for // an issue, sized to the given outer width. -func detailBody(iss issue.Issue, width int) string { +func (m Model) detailBody(iss issue.Issue, width int) string { box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(detailBorderColor). @@ -144,9 +145,8 @@ func detailBody(iss issue.Issue, width int) string { } header := lipgloss.JoinVertical(lipgloss.Left, cell(title, false), - cell("", false), - cell(hint.Render("[b] 头脑风暴 [i] 实施 [v] 审查 [t] 测试 [p] PR提交 [g] PR摘要"), false), - cell(hint.Render("[f] 配置 [F] 测试配置 [o] 打开文件 [c] 关闭 [d] 删除 esc 返回"), false), + cell(hint.Render("↑↓/jk 选择 ←→/hl 列 H/L 移动 c 关闭 d 删除 p 前置 P 清前置 n 新建"), false), + cell(m.actionBarView(), false), ) headerBox := box.Render(header) @@ -178,22 +178,201 @@ func detailBody(iss issue.Issue, width int) string { return lipgloss.JoinVertical(lipgloss.Left, headerBox, tableBox) } -// detailView renders the issue detail as a centered modal (two stacked blocks) -// floating over the board via lipgloss v2 layer compositing. +// detailView renders the board above a bottom issue property panel, matching +// the VSCode extension's kanban + attributes split. func (m Model) detailView() string { - bg := m.boardView() - if m.detailIss == nil { - return bg + iss, ok := m.currentDetailIssue() + if !ok { + return m.boardView() } - width := 84 - if m.width > 0 && m.width-8 < width { - width = m.width - 8 + return m.boardWithDetailView(iss) +} + +func (m Model) boardWithDetailView(iss issue.Issue) string { + width := m.width - 2 + if m.width <= 0 { + width = 100 } if width < 40 { width = 40 } - modal := detailBody(*m.detailIss, width) - return placeOverlayCenter(bg, modal) + panel := m.bottomTabsPanel(iss, width) + panelH := lipgloss.Height(panel) + + statusLine := "" + indicator := "" + if m.hasChanges { + indicator = " " + confirmStyle.Render("● 未提交") + } + if status := m.statusBar(); status != "" { + statusLine = status + indicator + } else if indicator != "" { + statusLine = indicator + } + + reserved := panelH + 1 + if statusLine != "" { + reserved++ + } + boardH := m.height - reserved + if m.height == 0 { + boardH = 12 + } + if boardH < 5 { + boardH = 5 + } + + parts := []string{m.boardOnlyView(boardH)} + if statusLine != "" { + parts = append(parts, statusLine) + } + parts = append(parts, panel) + return lipgloss.JoinVertical(lipgloss.Left, parts...) +} + +func (m Model) bottomTabsPanel(iss issue.Issue, width int) string { + tabs := m.bottomTabsHeader(width) + content := "" + switch m.bottomTab { + case bottomTabProfile: + content = m.profileTabBody(width) + case bottomTabSessions: + content = m.sessionsTabBody(width) + case bottomTabCommits: + content = m.commitsTabBody(width) + default: + content = m.detailBody(iss, width) + } + return lipgloss.JoinVertical(lipgloss.Left, tabs, content) +} + +func (m Model) bottomTabsHeader(width int) string { + labels := []struct { + tab bottomTab + label string + }{ + {bottomTabIssue, "1 工单"}, + {bottomTabProfile, "2 Profile"}, + {bottomTabSessions, "3 会话"}, + {bottomTabCommits, "4 提交"}, + } + var parts []string + for _, item := range labels { + prefix := " " + if m.focusPane == focusPanel && m.bottomTab == item.tab { + prefix = "▸" + } + text := prefix + item.label + " " + if m.bottomTab == item.tab { + parts = append(parts, selectedCardStyle.Render(text)) + } else { + parts = append(parts, helpStyle.Render(text)) + } + } + line := strings.Join(parts, " ") + hint := helpStyle.Render(" Tab 切焦点;面板聚焦后 ↑↓/←→ 切 tab;点击区域也可切焦点") + if lipgloss.Width(line)+lipgloss.Width(hint) < width { + line += hint + } + return lipgloss.NewStyle().Width(width).Render(line) +} + +func (m Model) tabBox(title string, lines []string, width int) string { + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(detailBorderColor). + Width(width) + titleLine := lipgloss.NewStyle().Bold(true).Render(title) + body := append([]string{titleLine, ""}, lines...) + return box.Render(lipgloss.JoinVertical(lipgloss.Left, body...)) +} + +func (m Model) profileTabBody(width int) string { + lines := []string{ + helpStyle.Render("只读摘要;按 G 打开 Profile 表格编辑器"), + } + if len(m.profileGridData.Profiles) == 0 { + lines = append(lines, "未加载 Profile 数据,按 2 重新加载") + return m.tabBox("Profile", lines, width) + } + lines = append(lines, "Profiles: "+strings.Join(m.profileGridData.Profiles, ", ")) + if len(m.profileGridData.Rows) == 0 { + lines = append(lines, "暂无配置行") + return m.tabBox("Profile", lines, width) + } + maxRows := 8 + for i, row := range m.profileGridData.Rows { + if i >= maxRows { + lines = append(lines, fmt.Sprintf("... 还有 %d 行", len(m.profileGridData.Rows)-maxRows)) + break + } + var vals []string + for _, prof := range m.profileGridData.Profiles { + vals = append(vals, fmt.Sprintf("%s=%s", prof, orDash(row.Values[prof]))) + } + lines = append(lines, truncate(row.Key+" "+strings.Join(vals, " "), width-6)) + } + return m.tabBox("Profile", lines, width) +} + +func (m Model) sessionsTabBody(width int) string { + lines := []string{ + helpStyle.Render("只读摘要;按 m 打开会话管理器"), + } + if len(m.managedSessions.Sessions) == 0 { + lines = append(lines, "暂无托管会话") + return m.tabBox("会话", lines, width) + } + maxRows := 8 + for i, sess := range m.managedSessions.Sessions { + if i >= maxRows { + lines = append(lines, fmt.Sprintf("... 还有 %d 个会话", len(m.managedSessions.Sessions)-maxRows)) + break + } + ts := time.Unix(sess.CreatedAt, 0).Format("2006-01-02 15:04") + id := sess.ID + if len(id) > 12 { + id = id[:12] + } + lines = append(lines, truncate(fmt.Sprintf("%s %s %s %s", sess.Name, id, profileDisplay(sess.ProfilePath), ts), width-6)) + } + return m.tabBox("会话", lines, width) +} + +func (m Model) commitsTabBody(width int) string { + lines := []string{ + helpStyle.Render("只读摘要;按 R 打开 PR 提交确认面板"), + } + if m.prCommitsIss == nil || m.prCommitsIss.PR == "" { + lines = append(lines, "当前工单无关联 PR") + return m.tabBox("提交", lines, width) + } + if m.prCommitsErr != nil { + lines = append(lines, errorStyle.Render(m.prCommitsErr.Error())) + return m.tabBox(fmt.Sprintf("PR #%s 提交", m.prCommitsIss.PR), lines, width) + } + if !m.prCommitsLoaded { + lines = append(lines, "加载中...") + return m.tabBox(fmt.Sprintf("PR #%s 提交", m.prCommitsIss.PR), lines, width) + } + maxCommits := 5 + lines = append(lines, fmt.Sprintf("Commits (%d):", len(m.prCommits))) + for i, c := range m.prCommits { + if i >= maxCommits { + lines = append(lines, fmt.Sprintf("... 还有 %d 个 commit", len(m.prCommits)-maxCommits)) + break + } + mark := " " + if m.prConfirmedSHAs[c.SHA] { + mark = "x" + } + lines = append(lines, truncate(fmt.Sprintf("[%s] %s %s", mark, shortSHA(c.SHA), firstLine(c.Commit.Message)), width-6)) + } + lines = append(lines, fmt.Sprintf("Files: %d", len(m.prFiles))) + if allFilesConfirmed(m.prFiles, m.prConfirmed) && len(m.prFiles) > 0 { + lines = append(lines, confirmStyle.Render("所有文件已确认")) + } + return m.tabBox(fmt.Sprintf("PR #%s 提交", m.prCommitsIss.PR), lines, width) } diff --git a/tui/internal/tui/detail_test.go b/tui/internal/tui/detail_test.go index c65cf1a..fd92852 100644 --- a/tui/internal/tui/detail_test.go +++ b/tui/internal/tui/detail_test.go @@ -4,8 +4,10 @@ import ( "strings" "testing" + tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "superwork-tui/internal/issue" + "superwork-tui/internal/store" ) func TestDetailBody(t *testing.T) { @@ -31,7 +33,7 @@ func TestDetailBody(t *testing.T) { AutoReview: &autoReview, } - out := detailBody(iss, 80) + out := Model{}.detailBody(iss, 80) checks := []string{ "#42", @@ -58,10 +60,9 @@ func TestDetailBody(t *testing.T) { } } -// TestDetailModal_OverlaysBoard verifies the detail view is a centered modal -// composited over the board (true overlay), not a full-screen replacement, and -// that it never overflows the terminal height. -func TestDetailModal_OverlaysBoard(t *testing.T) { +// TestDetailView_RendersBoardAbovePropertyPanel verifies the detail view keeps +// the kanban board visible above a bottom property panel, matching VSCode. +func TestDetailView_RendersBoardAbovePropertyPanel(t *testing.T) { var issues []issue.Issue for i := 0; i < 11; i++ { issues = append(issues, issue.Issue{Number: 238 - i, Title: "支持部署时使用客户自定义域名", Column: issue.ColumnTodo}) @@ -81,18 +82,201 @@ func TestDetailModal_OverlaysBoard(t *testing.T) { out := m.detailView() if !strings.Contains(out, "#238") { - t.Errorf("modal title missing") + t.Errorf("detail title missing") } if !strings.Contains(out, "STATE JSON") { t.Errorf("STATE JSON section missing") } if !strings.Contains(out, "todo (11)") { - t.Errorf("board background (todo header) not visible behind modal — not a true overlay") + t.Errorf("board header not visible above detail panel") } if !strings.Contains(out, "╭") { - t.Errorf("modal rounded border missing") + t.Errorf("detail panel rounded border missing") + } + boardIdx := strings.Index(out, "todo (11)") + panelIdx := strings.Index(out, "STATE JSON") + if boardIdx == -1 || panelIdx == -1 || boardIdx > panelIdx { + t.Errorf("detail view should render board before property panel") } if h := lipgloss.Height(out); h > m.height { - t.Errorf("detail overlay height %d exceeds terminal height %d", h, m.height) + t.Errorf("detail split height %d exceeds terminal height %d", h, m.height) + } +} + +func TestBoardView_DefaultDetailPanelFollowsSelection(t *testing.T) { + issues := []issue.Issue{ + {Number: 1, Title: "First issue", Column: issue.ColumnTodo}, + {Number: 2, Title: "Second issue", Column: issue.ColumnTodo}, + } + m := makeLoadedModel(issues, 0, 0) + + out := m.boardView() + if !strings.Contains(out, "STATE JSON") || !strings.Contains(out, "First issue") { + t.Fatalf("default board view should show selected issue properties, got: %q", out) + } + + m2, _ := sendKey(m, "j") + if m2.state != stateLoaded { + t.Fatalf("moving selection should stay in board mode, got state %v", m2.state) + } + out = m2.boardView() + if !strings.Contains(out, "Second issue") { + t.Fatalf("detail panel should follow moved selection, got: %q", out) + } + + m3, _ := sendKey(m2, "enter") + if m3.state != stateLoaded { + t.Fatalf("enter should not steal keyboard focus, got state %v", m3.state) + } +} + +func TestBoardView_BottomTabsSwitchWithoutStealingBoardFocus(t *testing.T) { + issues := []issue.Issue{ + {Number: 1, Title: "First issue", Column: issue.ColumnTodo}, + {Number: 2, Title: "Second issue", Column: issue.ColumnTodo}, + } + m := makeLoadedModel(issues, 0, 0) + m.profileGridData = store.ProfilesData{Profiles: []string{"dev"}, Rows: []store.ProfileRow{{Key: "model", Values: map[string]string{"dev": "sonnet"}}}} + m.managedSessions = store.ManagedSessionsData{Sessions: []store.ManagedSession{{ID: "abcdef123456", Name: "daily", CreatedAt: 1}}} + + out := m.boardView() + for _, want := range []string{"1 工单", "2 Profile", "3 会话", "4 提交"} { + if !strings.Contains(out, want) { + t.Fatalf("bottom tabs missing %q: %q", want, out) + } + } + + m2, _ := sendKey(m, "2") + if m2.state != stateLoaded || m2.bottomTab != bottomTabProfile { + t.Fatalf("profile tab should stay in board mode, state=%v tab=%v", m2.state, m2.bottomTab) + } + if out := m2.boardView(); !strings.Contains(out, "Profile") || !strings.Contains(out, "model") { + t.Fatalf("profile tab body missing expected content: %q", out) + } + + m3, _ := sendKey(m2, "j") + if m3.state != stateLoaded || m3.rowIdx != 1 { + t.Fatalf("board selection should still move while tab is active, state=%v row=%d", m3.state, m3.rowIdx) + } + + m4, _ := sendKey(m3, "3") + if out := m4.boardView(); !strings.Contains(out, "会话") || !strings.Contains(out, "daily") { + t.Fatalf("sessions tab body missing expected content: %q", out) + } + + m5, _ := sendKey(m4, "tab") + if m5.focusPane != focusPanel || m5.bottomTab != bottomTabSessions { + t.Fatalf("tab should focus panel without changing tab, focus=%v tab=%v", m5.focusPane, m5.bottomTab) + } + m6, _ := sendKey(m5, "j") + if m6.focusPane != focusPanel || m6.bottomTab != bottomTabCommits { + t.Fatalf("panel-focused j on tabs should cycle to commits, focus=%v tab=%v", m6.focusPane, m6.bottomTab) + } + m7, _ := sendKey(m6, "tab") + if m7.focusPane != focusBoard { + t.Fatalf("second tab should focus board, got %v", m7.focusPane) + } +} + +func TestBoardView_PanelActionBarKeyboardAndMouse(t *testing.T) { + issues := []issue.Issue{ + {Number: 1, Title: "First issue", Column: issue.ColumnTodo}, + } + m := makeLoadedModel(issues, 0, 0) + m.height = 40 + + m2, _ := sendKey(m, "tab") + m3, _ := sendKey(m2, "j") + if m3.panelFocus != panelFocusActions { + t.Fatalf("down in focused issue panel should enter action row, got %v", m3.panelFocus) + } + m4, _ := sendKey(m3, "l") + if m4.actionIdx != 1 { + t.Fatalf("right should move action selection, got %d", m4.actionIdx) + } + m5, cmd := sendKey(m4, "enter") + if cmd != nil { + t.Fatalf("implement action without plan should not start command") + } + if !strings.Contains(m5.statusMsg, "无计划文件") { + t.Fatalf("enter should trigger selected action, status=%q", m5.statusMsg) + } + + panelY, ok := m.panelStartY() + if !ok { + t.Fatal("expected panel start") + } + clickImplement := tea.MouseClickMsg(tea.Mouse{X: 18, Y: panelY + 4, Button: tea.MouseLeft}) + next, cmd := m.Update(clickImplement) + m6 := next.(Model) + if cmd != nil { + t.Fatalf("mouse implement action without plan should not start command") + } + if m6.focusPane != focusPanel || m6.panelFocus != panelFocusActions || m6.actionIdx != 1 { + t.Fatalf("mouse click should focus action, focus=%v panel=%v action=%d", m6.focusPane, m6.panelFocus, m6.actionIdx) + } + if !strings.Contains(m6.statusMsg, "无计划文件") { + t.Fatalf("mouse click should trigger action, status=%q", m6.statusMsg) + } +} + +func TestBoardView_PanelActionBarMouseHover(t *testing.T) { + issues := []issue.Issue{ + {Number: 1, Title: "First issue", Column: issue.ColumnTodo}, + } + m := makeLoadedModel(issues, 0, 0) + m.height = 40 + + panelY, ok := m.panelStartY() + if !ok { + t.Fatal("expected panel start") + } + + hoverImplement := tea.MouseMotionMsg(tea.Mouse{X: 18, Y: panelY + 4}) + next, _ := m.Update(hoverImplement) + m2 := next.(Model) + if !m2.hoverActionOK || m2.hoverActionIdx != 1 { + t.Fatalf("hover should select implement action, ok=%v idx=%d", m2.hoverActionOK, m2.hoverActionIdx) + } + + gapBetweenActions := tea.MouseMotionMsg(tea.Mouse{X: 15, Y: panelY + 4}) + next, _ = m2.Update(gapBetweenActions) + mGap := next.(Model) + if mGap.hoverActionOK { + t.Fatalf("hover should not select gap between actions") + } + + leave := tea.MouseMotionMsg(tea.Mouse{X: 2, Y: 1}) + next, _ = mGap.Update(leave) + m3 := next.(Model) + if m3.hoverActionOK { + t.Fatalf("hover should clear after leaving action row") + } +} + +func TestBoardView_MouseClickChangesFocusedPaneAndTab(t *testing.T) { + issues := []issue.Issue{ + {Number: 1, Title: "First issue", Column: issue.ColumnTodo}, + } + m := makeLoadedModel(issues, 0, 0) + m.height = 40 + + panelY, ok := m.panelStartY() + if !ok { + t.Fatal("expected panel start") + } + + clickPanel := tea.MouseClickMsg(tea.Mouse{X: 22, Y: panelY, Button: tea.MouseLeft}) + next, _ := m.Update(clickPanel) + m2 := next.(Model) + if m2.focusPane != focusPanel || m2.bottomTab != bottomTabSessions { + t.Fatalf("clicking sessions tab should focus panel and switch tab, focus=%v tab=%v", m2.focusPane, m2.bottomTab) + } + + clickBoard := tea.MouseClickMsg(tea.Mouse{X: 2, Y: 1, Button: tea.MouseLeft}) + next, _ = m2.Update(clickBoard) + m3 := next.(Model) + if m3.focusPane != focusBoard { + t.Fatalf("clicking board should focus board, got %v", m3.focusPane) } } diff --git a/tui/internal/tui/model.go b/tui/internal/tui/model.go index c63ce9a..19f80fd 100644 --- a/tui/internal/tui/model.go +++ b/tui/internal/tui/model.go @@ -92,6 +92,31 @@ type confirmPending struct { issue issue.Issue } +// ── bottom panel tabs ──────────────────────────────────────────────────────── + +type bottomTab int + +const ( + bottomTabIssue bottomTab = iota + bottomTabProfile + bottomTabSessions + bottomTabCommits +) + +type focusPane int + +const ( + focusBoard focusPane = iota + focusPanel +) + +type panelFocus int + +const ( + panelFocusTabs panelFocus = iota + panelFocusActions +) + // ── state enum ────────────────────────────────────────────────────────────── type boardState int @@ -166,8 +191,14 @@ type Model struct { createInboxDir string // /tmp/spx-inbox/ for this create session // detail panel state - detailIss *issue.Issue - sessionMgr *SessionManager + detailIss *issue.Issue + sessionMgr *SessionManager + bottomTab bottomTab + focusPane focusPane + panelFocus panelFocus + actionIdx int + hoverActionIdx int + hoverActionOK bool // PR commits review panel state prCommitsIss *issue.Issue @@ -179,6 +210,7 @@ type Model struct { prCursor int // cursor over commit rows then file rows prConfirmed map[string]bool // file path → confirmed prConfirmedSHAs map[string]bool // commit sha → confirmed + prCommitsReturn boardState // webhook server lifecycle webhookCtx context.Context @@ -566,6 +598,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height + case tea.MouseClickMsg: + return m.handleMouseClick(msg) + + case tea.MouseMotionMsg: + return m.handleMouseMotion(msg) + case spinner.TickMsg: if m.state == stateLoading || (m.state == statePRCommits && !m.prCommitsLoaded) { var cmd tea.Cmd @@ -1092,7 +1130,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.buckets = bucketize(m.issues) m.statusMsg = fmt.Sprintf("正在启动实施 #%d…", iss.Number) return m, implementCmd(iss, m.sessionMgr) - case "p": + case "p", "R": if m.detailIss == nil { break } @@ -1103,6 +1141,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { iss := *m.detailIss m.prCommitsIss = &iss m.prBaseRef = "" + m.prCommitsReturn = stateDetail m.state = statePRCommits m.prCommitsLoaded = false m.prCommitsErr = nil @@ -1321,6 +1360,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + if m.state == stateLoaded { + if handledModel, cmd, handled := m.handleFocusKey(msg); handled { + return handledModel, cmd + } + } + + if m.state == stateLoaded && isInlineDetailKey(msg.String()) { + return m.handleInlineDetailKey(msg) + } + switch msg.String() { case "q", "ctrl+c": if m.webhookCancel != nil { @@ -1328,11 +1377,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Quit - case "enter": - if m.state == stateLoaded && len(m.buckets[m.colIdx]) > 0 { - iss := m.buckets[m.colIdx][m.rowIdx] - m.detailIss = &iss - m.state = stateDetail + case "1": + if m.state == stateLoaded { + return m.switchBottomTab(bottomTabIssue) + } + + case "2": + if m.state == stateLoaded { + return m.switchBottomTab(bottomTabProfile) + } + + case "3": + if m.state == stateLoaded { + return m.switchBottomTab(bottomTabSessions) + } + + case "4": + if m.state == stateLoaded { + return m.switchBottomTab(bottomTabCommits) } case "r": @@ -1553,6 +1615,7 @@ func prevNonEmpty(buckets [4][]issue.Issue, cur int) int { func (m Model) View() tea.View { v := tea.NewView(m.viewString()) v.AltScreen = true + v.MouseMode = tea.MouseModeAllMotion return v } @@ -1602,7 +1665,9 @@ func (m Model) viewString() string { } func (m Model) boardView() string { - colWidth := m.colWidth() + if iss, ok := m.selectedIssue(); ok { + return m.boardWithDetailView(iss) + } boxH := m.height - 2 if m.height == 0 { @@ -1611,15 +1676,9 @@ func (m Model) boardView() string { if boxH < 5 { boxH = 5 } + board := m.boardOnlyView(boxH) - cols := make([]string, 4) - for ci := range columnOrder { - cols[ci] = m.renderColumn(ci, colWidth, boxH) - } - - board := lipgloss.JoinHorizontal(lipgloss.Top, cols...) - - help := helpStyle.Render("↑↓/jk 选择 ←→/hl 列 H/L 移动 enter 详情 c 关闭 d 删除 p 前置 P 清前置 n 新建 C 提交 S 同步 E 环境锁 , 设置 r 刷新 q 退出") + help := helpStyle.Render("↑↓/jk 选择 ←→/hl 列 H/L 移动 c 关闭 d 删除 p 前置 P 清前置 n 新建 C 提交 S 同步 E 环境锁 , 设置 r 刷新 q 退出") indicator := "" if m.hasChanges { indicator = " " + confirmStyle.Render("● 未提交") @@ -1634,6 +1693,370 @@ func (m Model) boardView() string { return board + "\n" + help } +func (m Model) selectedIssue() (issue.Issue, bool) { + if m.colIdx < 0 || m.colIdx >= len(m.buckets) { + return issue.Issue{}, false + } + if m.rowIdx < 0 || m.rowIdx >= len(m.buckets[m.colIdx]) { + return issue.Issue{}, false + } + return m.buckets[m.colIdx][m.rowIdx], true +} + +func (m Model) currentDetailIssue() (issue.Issue, bool) { + if m.state == stateDetail && m.detailIss != nil { + return *m.detailIss, true + } + return m.selectedIssue() +} + +func (m Model) switchBottomTab(tab bottomTab) (Model, tea.Cmd) { + m.bottomTab = tab + switch tab { + case bottomTabProfile: + return m, loadProfileGridCmd() + case bottomTabSessions: + return m, loadManagedSessionsCmd() + case bottomTabCommits: + iss, ok := m.selectedIssue() + if !ok { + m.statusMsg = "无选中工单" + return m, nil + } + if iss.PR == "" { + m.statusMsg = "当前工单无关联 PR" + m.prCommitsIss = &iss + m.prCommitsLoaded = true + m.prCommitsErr = nil + m.prCommits = nil + m.prFiles = nil + return m, nil + } + m.prCommitsIss = &iss + m.prBaseRef = "" + m.prCommitsReturn = stateLoaded + m.prCommitsLoaded = false + m.prCommitsErr = nil + m.prCommits = nil + m.prFiles = nil + m.prCursor = 0 + m.prConfirmed = map[string]bool{} + m.prConfirmedSHAs = map[string]bool{} + return m, tea.Batch(m.spinner.Tick, loadPRCommitsCmd(iss)) + default: + return m, nil + } +} + +type panelAction struct { + key string + label string +} + +var panelActions = []panelAction{ + {"b", "头脑风暴"}, + {"i", "实施"}, + {"v", "审查"}, + {"t", "测试"}, + {"R", "PR提交"}, + {"g", "PR摘要"}, + {"o", "打开文件"}, + {"r", "刷新"}, + {"q", "退出"}, +} + +func (m Model) actionBarView() string { + var parts []string + for i, action := range panelActions { + text := fmt.Sprintf("[%s] %s", action.key, action.label) + focused := m.focusPane == focusPanel && m.panelFocus == panelFocusActions && m.actionIdx == i + hovered := m.hoverActionOK && m.hoverActionIdx == i + if focused || hovered { + parts = append(parts, selectedCardStyle.Render(text)) + } else { + parts = append(parts, helpStyle.Render(text)) + } + } + return strings.Join(parts, " ") +} + +func (m Model) handleFocusKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { + switch msg.String() { + case "tab": + if m.focusPane == focusPanel { + m.focusPane = focusBoard + } else { + m.focusPane = focusPanel + } + return m, nil, true + case "shift+tab": + if m.focusPane == focusPanel { + m.focusPane = focusBoard + } else { + m.focusPane = focusPanel + } + return m, nil, true + } + + if m.focusPane != focusPanel { + return m, nil, false + } + + if m.bottomTab == bottomTabIssue && m.panelFocus == panelFocusActions { + switch msg.String() { + case "left", "h": + if m.actionIdx > 0 { + m.actionIdx-- + } + return m, nil, true + case "right", "l": + if m.actionIdx < len(panelActions)-1 { + m.actionIdx++ + } + return m, nil, true + case "up", "k": + m.panelFocus = panelFocusTabs + return m, nil, true + case "enter": + return m.triggerPanelAction(m.actionIdx) + } + } + + switch msg.String() { + case "up", "k", "left", "h": + next := (m.bottomTab + 3) % 4 + nextM, cmd := m.switchBottomTab(next) + nextM.focusPane = focusPanel + return nextM, cmd, true + case "down", "j", "right", "l": + if m.bottomTab == bottomTabIssue && (msg.String() == "down" || msg.String() == "j") { + m.panelFocus = panelFocusActions + if m.actionIdx < 0 || m.actionIdx >= len(panelActions) { + m.actionIdx = 0 + } + return m, nil, true + } + next := (m.bottomTab + 1) % 4 + nextM, cmd := m.switchBottomTab(next) + nextM.focusPane = focusPanel + return nextM, cmd, true + } + return m, nil, false +} + +func (m Model) triggerPanelAction(idx int) (tea.Model, tea.Cmd, bool) { + if idx < 0 || idx >= len(panelActions) { + return m, nil, true + } + key := panelActions[idx].key + if key == "r" { + m.state = stateLoading + m.loadErr = nil + m.statusMsg = "" + return m, tea.Batch(m.spinner.Tick, loadCmd()), true + } + if key == "q" { + if m.webhookCancel != nil { + m.webhookCancel() + } + return m, tea.Quit, true + } + next, cmd := m.handleInlineDetailKey(tea.KeyPressMsg{Code: []rune(key)[0], Text: key}) + return next, cmd, true +} + +func (m Model) handleMouseClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) { + mouse := tea.Mouse(msg) + if mouse.Button != tea.MouseLeft { + return m, nil + } + if m.state != stateLoaded { + return m, nil + } + + panelY, ok := m.panelStartY() + if !ok { + m.focusPane = focusBoard + return m, nil + } + if mouse.Y < panelY { + m.focusPane = focusBoard + return m, nil + } + + m.focusPane = focusPanel + if mouse.Y == panelY { + m.panelFocus = panelFocusTabs + if tab, ok := tabAtX(mouse.X); ok { + nextM, cmd := m.switchBottomTab(tab) + nextM.focusPane = focusPanel + return nextM, cmd + } + } + if m.bottomTab == bottomTabIssue && mouse.Y == panelY+4 { + m.panelFocus = panelFocusActions + if idx, ok := actionAtX(mouse.X); ok { + m.actionIdx = idx + m.hoverActionIdx = idx + m.hoverActionOK = true + next, cmd, _ := m.triggerPanelAction(idx) + return next, cmd + } + } + return m, nil +} + +func (m Model) handleMouseMotion(msg tea.MouseMotionMsg) (tea.Model, tea.Cmd) { + mouse := tea.Mouse(msg) + if m.state != stateLoaded || m.bottomTab != bottomTabIssue { + m.hoverActionOK = false + return m, nil + } + panelY, ok := m.panelStartY() + if !ok || mouse.Y != panelY+4 { + m.hoverActionOK = false + return m, nil + } + idx, ok := actionAtX(mouse.X) + if !ok { + m.hoverActionOK = false + return m, nil + } + m.hoverActionIdx = idx + m.hoverActionOK = true + return m, nil +} + +func (m Model) panelStartY() (int, bool) { + iss, ok := m.selectedIssue() + if !ok { + return 0, false + } + width := m.width - 2 + if m.width <= 0 { + width = 100 + } + if width < 40 { + width = 40 + } + panel := m.bottomTabsPanel(iss, width) + panelH := lipgloss.Height(panel) + + statusLine := "" + indicator := "" + if m.hasChanges { + indicator = " " + confirmStyle.Render("● 未提交") + } + if status := m.statusBar(); status != "" { + statusLine = status + indicator + } else if indicator != "" { + statusLine = indicator + } + + reserved := panelH + 1 + if statusLine != "" { + reserved++ + } + boardH := m.height - reserved + if m.height == 0 { + boardH = 12 + } + if boardH < 5 { + boardH = 5 + } + if statusLine != "" { + return boardH + 1, true + } + return boardH, true +} + +func tabAtX(x int) (bottomTab, bool) { + // Approximate ranges for the plain header text: + // " 1 工单 " " 2 Profile " " 3 会话 " " 4 提交 ". + switch { + case x >= 0 && x < 8: + return bottomTabIssue, true + case x >= 8 && x < 20: + return bottomTabProfile, true + case x >= 20 && x < 28: + return bottomTabSessions, true + case x >= 28 && x < 36: + return bottomTabCommits, true + default: + return bottomTabIssue, false + } +} + +func actionAtX(x int) (int, bool) { + start := 2 // left border + cell's leading inset + for i, action := range panelActions { + w := lipgloss.Width(fmt.Sprintf("[%s] %s", action.key, action.label)) + end := start + w + if x >= start && x < end { + return i, true + } + start = end + 2 + } + return 0, false +} + +func isInlineDetailKey(key string) bool { + switch key { + case "b", "i", "v", "t", "R", "g", "f", "F", "o", "O", "w", "M": + return true + default: + return false + } +} + +func (m Model) handleInlineDetailKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + iss, ok := m.selectedIssue() + if !ok { + return m, nil + } + + m.detailIss = &iss + m.state = stateDetail + next, cmd := m.Update(msg) + nextModel, ok := next.(Model) + if !ok { + return next, cmd + } + + switch nextModel.state { + case stateDetail: + nextModel.state = stateLoaded + nextModel.detailIss = nil + case stateProfilePicker: + if nextModel.profilePickerPrevState == stateDetail { + nextModel.profilePickerPrevState = stateLoaded + } + case stateFilePicker: + if nextModel.filePickerPrevState == stateDetail { + nextModel.filePickerPrevState = stateLoaded + } + case statePRCommits: + if nextModel.prCommitsReturn == stateDetail { + nextModel.prCommitsReturn = stateLoaded + } + } + return nextModel, cmd +} + +func (m Model) boardOnlyView(boxH int) string { + colWidth := m.colWidth() + if boxH < 4 { + boxH = 4 + } + + cols := make([]string, 4) + for ci := range columnOrder { + cols[ci] = m.renderColumn(ci, colWidth, boxH) + } + + return lipgloss.JoinHorizontal(lipgloss.Top, cols...) +} + // statusBar returns the status line content. func (m Model) statusBar() string { if m.state == stateInput { diff --git a/tui/internal/tui/prcommits.go b/tui/internal/tui/prcommits.go index 0a9c823..4740d43 100644 --- a/tui/internal/tui/prcommits.go +++ b/tui/internal/tui/prcommits.go @@ -191,6 +191,11 @@ func (m Model) cursorOnFile() (int, bool) { func (m Model) handlePRCommitsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": + if m.prCommitsReturn != stateLoading { + m.state = m.prCommitsReturn + m.prCommitsReturn = stateLoading + return m, nil + } m.state = stateDetail return m, nil