From 91f2851aba1842d88b02edf62ee5735207a02af4 Mon Sep 17 00:00:00 2001 From: pionxe Date: Sun, 7 Jun 2026 11:34:50 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(tui-v2):=20=E5=AE=9E=E7=8E=B0=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E5=A2=9E=E5=BC=BA=E4=B8=8E=E8=A7=86=E8=A7=89=E6=89=93?= =?UTF-8?q?=E7=A3=A8=20=E2=80=94=20=E9=94=AE=E4=BD=8D/=E9=9D=A2=E6=9D=BF/?= =?UTF-8?q?=E6=B5=AE=E5=B1=82/=E4=BC=9A=E8=AF=9D/=E9=BC=A0=E6=A0=87/?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=20(Phase=209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现设计文档 Phase 3(交互增强)和 Phase 4(打磨)的全部功能: Step 9a - 键位系统重构与 Ctrl+C 双退保护: - 新增 keymap 包,实现 Input/Normal/Leader 三层键位抽象 - Leader Key 状态机:Space 进入等待,1 秒超时回退 Normal - Ctrl+C 双退保护:运行中取消 Agent,空闲时双击退出 - 运行取消:调用 client.CancelRun() RPC - Normal Mode 增强:Ctrl+D/Ctrl+U 半页滚动 - 修复 stream.go 重复 doc comment Step 9b - 命令面板、帮助浮层与会话管理: - 实现 Telescope 风格命令面板 (Ctrl+P / Space p) - 实现分组快捷键帮助浮层 (? / Space h) - 实现会话切换器 (Space s) - ViewState 新增 OverlayState 浮层状态 - Slash 命令拦截 (/exit, /help, /session, /clear) - 会话切换 RPC 调用 (loadSessionCmd) Step 9c - 角色感知渲染与视觉打磨: - 用户消息显示 you 标签 (Cyan),助手消息显示 neo 标签 (Blue) - Reducer 自动注入 role: assistant 到 Agent 消息 - Inspector 新增 Active Tools 和 Files 区块 - 使用 DiffAdd/DiffDel 主题色显示文件变更 - 新增 theme.InfoStyle() 访问器 - 补充 status_bar_test.go 和 inspector_test.go - 删除 fakegateway/.gitkeep Step 9d - 鼠标支持与 Slash 命令: - 启用 tea.WithMouseCellMotion() 鼠标支持 - 鼠标滚轮在 Stream 区域滚动 - 浮层内鼠标事件路由到对应组件 - Slash 命令模式 (Prompt 组件自动拦截 / 开头输入) Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + cmd/neocode-tuiv2/main.go | 1 + internal/tuiv2/app.go | 487 +++++++++++++++++- internal/tuiv2/components/help.go | 103 ++++ internal/tuiv2/components/inspector.go | 90 +++- internal/tuiv2/components/inspector_test.go | 110 ++++ internal/tuiv2/components/palette.go | 197 +++++++ internal/tuiv2/components/prompt.go | 512 ++++++++++++++++++- internal/tuiv2/components/prompt_test.go | 164 ++++++ internal/tuiv2/components/session_picker.go | 210 ++++++++ internal/tuiv2/components/status_bar_test.go | 53 ++ internal/tuiv2/components/stream.go | 41 +- internal/tuiv2/fakegateway/.gitkeep | 1 - internal/tuiv2/gateway/events.go | 2 + internal/tuiv2/keymap/keys.go | 232 +++++++++ internal/tuiv2/keymap/keys_test.go | 107 ++++ internal/tuiv2/state/reducer.go | 21 +- internal/tuiv2/state/viewstate.go | 23 +- internal/tuiv2/theme/styles.go | 5 + 19 files changed, 2316 insertions(+), 44 deletions(-) create mode 100644 internal/tuiv2/components/help.go create mode 100644 internal/tuiv2/components/inspector_test.go create mode 100644 internal/tuiv2/components/palette.go create mode 100644 internal/tuiv2/components/prompt_test.go create mode 100644 internal/tuiv2/components/session_picker.go create mode 100644 internal/tuiv2/components/status_bar_test.go delete mode 100644 internal/tuiv2/fakegateway/.gitkeep create mode 100644 internal/tuiv2/keymap/keys.go create mode 100644 internal/tuiv2/keymap/keys_test.go diff --git a/.gitignore b/.gitignore index ba64bf74c..5ab96303f 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ web/release/ web/dist-electron/ /.baoyu-skills/ .neocode/skills +neocode-tuiv2 diff --git a/cmd/neocode-tuiv2/main.go b/cmd/neocode-tuiv2/main.go index 08365cc62..0bca67957 100644 --- a/cmd/neocode-tuiv2/main.go +++ b/cmd/neocode-tuiv2/main.go @@ -38,6 +38,7 @@ func main() { tea.WithInput(os.Stdin), tea.WithOutput(os.Stdout), tea.WithAltScreen(), + tea.WithMouseCellMotion(), ).Run(); err != nil { fmt.Fprintf(os.Stderr, "start TUI v2: %v\n", err) os.Exit(1) diff --git a/internal/tuiv2/app.go b/internal/tuiv2/app.go index 4a718c6f5..9296bf3db 100644 --- a/internal/tuiv2/app.go +++ b/internal/tuiv2/app.go @@ -5,12 +5,14 @@ import ( "context" "fmt" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "neo-code/internal/tuiv2/components" "neo-code/internal/tuiv2/gateway" + "neo-code/internal/tuiv2/keymap" "neo-code/internal/tuiv2/state" "neo-code/internal/tuiv2/theme" ) @@ -41,10 +43,16 @@ type App struct { eventCh <-chan gateway.GatewayEvent lastErr string + // Ctrl+C 双退保护 + lastCtrlC time.Time + ambientStatus *components.AmbientStatus agentStream *components.AgentStream commandPrompt *components.CommandPrompt softInspector *components.SoftInspector + palette *components.Palette + helpOverlay *components.HelpOverlay + sessionPicker *components.SessionPicker } var _ tea.Model = (*App)(nil) @@ -62,13 +70,16 @@ func NewApp(cfg StartupConfig) tea.Model { agentStream: components.NewAgentStream(viewState), commandPrompt: components.NewCommandPrompt(viewState), softInspector: components.NewSoftInspector(viewState), + palette: components.NewPalette(viewState), + helpOverlay: components.NewHelpOverlay(viewState), + sessionPicker: components.NewSessionPicker(viewState), } } // Init 通过 Gateway 客户端检查连接并加载初始 ViewState。 func (a *App) Init() tea.Cmd { if a.client == nil { - return nil + return a.commandPrompt.Init() } return loadInitialCmd(a.client) } @@ -80,18 +91,15 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.applyWindowSize(msg.Width, msg.Height) return a, tea.ClearScreen case tea.KeyMsg: - if handled, cmd := a.routeStreamKey(msg); handled { - return a, cmd - } - switch msg.String() { - case "ctrl+c", "esc", "q": - return a, tea.Quit - } + return a.handleKeyMsg(msg) + case tea.MouseMsg: + return a.handleMouseMsg(msg) case initialLoadedMsg: a.applyInitialLoaded(msg) if msg.eventCh != nil { - return a, waitEventCmd(msg.eventCh) + return a, tea.Batch(waitEventCmd(msg.eventCh), a.commandPrompt.Init()) } + return a, a.commandPrompt.Init() case gatewayEventMsg: if msg.closed { return a, nil @@ -107,12 +115,61 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.lastErr = a.state.Stream[len(a.state.Stream)-1].Content } return a, waitEventCmd(a.eventCh) + case components.SubmitMessageMsg: + return a, a.handleSubmitMessage(msg) + case components.PermissionActionMsg: + return a, a.handlePermissionAction(msg) + case components.QuestionAnswerMsg: + return a, a.handleQuestionAnswer(msg) + case components.PromptCancelMsg: + a.cancelPrompt(msg.Mode) + return a, nil + case components.SlashCommandMsg: + return a, a.handleSlashCommand(msg) + case components.PaletteCommandMsg: + return a, a.handlePaletteCommand(msg) + case components.SessionSelectMsg: + return a, a.handleSessionSelect(msg) + case components.SessionDeleteMsg: + return a, a.handleSessionDelete(msg) + case leaderTimeoutMsg: + if a.state.Mode == state.LeaderMode { + a.state.Mode = state.NormalMode + } + return a, nil + case sessionSwitchedMsg: + a.eventCh = msg.eventCh + if msg.detail != nil { + a.state.Stream = nil + a.state.Runtime.Tokens = state.TokenUsage{ + Input: msg.detail.Usage.Input, + Output: msg.detail.Usage.Output, + Total: msg.detail.Usage.Total, + } + for _, item := range msg.detail.Stream { + a.appendStream(streamEntryFromItem(item)) + } + } + a.bindComponents() + if a.eventCh != nil { + return a, waitEventCmd(a.eventCh) + } + return a, nil } return a, a.routeComponents(msg) } // View 自上而下拼接 Focus-Only 静态布局,宽屏时将 Soft Inspector 放到右侧。 func (a *App) View() string { + // 浮层模式下覆盖主视图 + switch a.state.Overlay.Active { + case "palette": + return a.fitViewToTerminal(a.palette.View()) + case "help": + return a.fitViewToTerminal(a.helpOverlay.View()) + case "session_picker": + return a.fitViewToTerminal(a.sessionPicker.View()) + } lines := []string{ a.ambientStatus.View(), a.separatorLine(), @@ -156,7 +213,7 @@ func (a *App) routeComponents(msg tea.Msg) tea.Cmd { // routeStreamKey 将滚动按键优先交给 Agent Stream,避免与全局快捷键混淆。 func (a *App) routeStreamKey(msg tea.KeyMsg) (bool, tea.Cmd) { switch msg.String() { - case "j", "down", "k", "up", "g", "G": + case "j", "k", "g", "G": _, cmd := a.agentStream.Update(msg) return true, cmd default: @@ -164,6 +221,301 @@ func (a *App) routeStreamKey(msg tea.KeyMsg) (bool, tea.Cmd) { } } +// handleMouseMsg 将鼠标事件分发到当前活跃的组件或浮层。 +func (a *App) handleMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + // 浮层激活时,鼠标交给浮层组件 + switch a.state.Overlay.Active { + case "palette": + _, cmd := a.palette.Update(msg) + return a, cmd + case "session_picker": + _, cmd := a.sessionPicker.Update(msg) + return a, cmd + } + // 主视图下,滚轮事件交给 Agent Stream + switch msg.Type { + case tea.MouseWheelUp: + a.state.Layout.ScrollOffset++ + a.state.Layout.AutoScroll = false + case tea.MouseWheelDown: + if a.state.Layout.ScrollOffset > 0 { + a.state.Layout.ScrollOffset-- + } + a.state.Layout.AutoScroll = a.state.Layout.ScrollOffset == 0 + } + return a, nil +} + +// handleKeyMsg 根据当前模式分发键盘消息。 +func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // 浮层激活时,键盘消息交给对应浮层组件处理 + switch a.state.Overlay.Active { + case "palette": + _, cmd := a.palette.Update(msg) + return a, cmd + case "help": + _, cmd := a.helpOverlay.Update(msg) + return a, cmd + case "session_picker": + _, cmd := a.sessionPicker.Update(msg) + return a, cmd + } + switch a.state.Mode { + case state.LeaderMode: + return a.handleLeaderKey(msg) + case state.NormalMode: + return a.handleNormalModeKey(msg) + default: // InputModeInput + return a.handleInputModeKey(msg) + } +} + +// handleInputModeKey 处理 Input Mode 下的键盘输入。 +func (a *App) handleInputModeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + action := keymap.MatchInputKey(msg.String()) + switch action { + case keymap.ActionCtrlC: + return a.handleCtrlC() + case keymap.ActionEscape: + a.state.Mode = state.NormalMode + return a, nil + case keymap.ActionOpenPalette: + a.openOverlay("palette") + return a, nil + default: + _, promptCmd := a.commandPrompt.Update(msg) + return a, promptCmd + } +} + +// handleNormalModeKey 处理 Normal Mode 下的键盘输入。 +func (a *App) handleNormalModeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + action := keymap.MatchNormalKey(msg.String()) + switch action { + case keymap.ActionCtrlC: + return a.handleCtrlC() + case keymap.ActionEnterInput: + a.state.Mode = state.InputModeInput + return a, nil + case keymap.ActionScrollDown, keymap.ActionScrollUp, + keymap.ActionScrollTop, keymap.ActionScrollBottom: + _, cmd := a.agentStream.Update(msg) + return a, cmd + case keymap.ActionHalfPageDown, keymap.ActionHalfPageUp: + _, cmd := a.agentStream.Update(msg) + return a, cmd + case keymap.ActionLeader: + a.state.Mode = state.LeaderMode + return a, leaderTimeoutCmd() + case keymap.ActionQuit: + return a, tea.Quit + case keymap.ActionSearchForward: + // Phase 9b/9c 会实现搜索,此处预留 + return a, nil + default: + _, promptCmd := a.commandPrompt.Update(msg) + return a, promptCmd + } +} + +// handleLeaderKey 处理 Leader Key 后缀。 +func (a *App) handleLeaderKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + keyStr := msg.String() + if keyStr == "esc" || keyStr == "ctrl+c" { + a.state.Mode = state.NormalMode + return a, nil + } + action := keymap.MatchLeaderKey(keyStr) + a.state.Mode = state.NormalMode // Leader 后总是回到 Normal + switch action { + case keymap.ActionLeaderQuit: + return a, tea.Quit + case keymap.ActionLeaderPalette: + a.openOverlay("palette") + return a, nil + case keymap.ActionLeaderHelp: + a.openOverlay("help") + return a, nil + case keymap.ActionLeaderSwitchSession: + a.openOverlay("session_picker") + return a, nil + default: + return a, nil + } +} + +// handleCtrlC 实现 Ctrl+C 双退保护:运行中取消、空闲双退。 +func (a *App) handleCtrlC() (tea.Model, tea.Cmd) { + phase := a.state.Runtime.Phase + if phase == state.RuntimePhaseRunning || phase == state.RuntimePhaseWaitingPermission || phase == state.RuntimePhaseWaitingUser { + // Agent 运行中 → 取消运行 + if a.client != nil { + return a, cancelRunCmd(a.client, a.activeSessionID(), a.state.Runtime.RunID) + } + a.state.Runtime.Phase = state.RuntimePhaseCancelled + return a, nil + } + // Agent 空闲 → 双退保护 + now := time.Now() + if !a.lastCtrlC.IsZero() && now.Sub(a.lastCtrlC) < 2*time.Second { + return a, tea.Quit + } + a.lastCtrlC = now + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("ctrlc-hint-%d", now.UnixNano()), + Type: "status", + Timestamp: now, + Content: "Press Ctrl+C again to quit", + Metadata: map[string]any{"done": true}, + }) + return a, nil +} + +// leaderTimeoutMsg 用于 Leader Key 1 秒超时回退。 +type leaderTimeoutMsg struct{} + +// leaderTimeoutCmd 在 1 秒后发送超时消息,将 Leader 模式回退到 Normal。 +func leaderTimeoutCmd() tea.Cmd { + return tea.Tick(1*time.Second, func(_ time.Time) tea.Msg { + return leaderTimeoutMsg{} + }) +} + +// handleSubmitMessage 将用户输入交给 GatewayClient,并让后续 ACK 以事件形式回到 reducer。 +func (a *App) handleSubmitMessage(msg components.SubmitMessageMsg) tea.Cmd { + if a.client == nil || strings.TrimSpace(msg.Text) == "" { + return nil + } + sessionID := a.activeSessionID() + return submitMessageCmd(a.client, sessionID, msg.Text) +} + +// handlePermissionAction 将权限快捷键转换成 GatewayClient 权限决策 RPC。 +func (a *App) handlePermissionAction(msg components.PermissionActionMsg) tea.Cmd { + if a.client == nil { + return nil + } + decision := gateway.PermissionDecision{ + SessionID: a.activeSessionID(), + RunID: a.state.Runtime.RunID, + Allow: msg.Decision == "y" || msg.Decision == "a", + Reason: msg.Decision, + } + return resolvePermissionCmd(a.client, decision) +} + +// handleQuestionAnswer 将 ask_user 回答交给 GatewayClient,并把完成事件交给 reducer。 +func (a *App) handleQuestionAnswer(msg components.QuestionAnswerMsg) tea.Cmd { + if a.client == nil { + return nil + } + answer := gateway.UserQuestionAnswer{ + SessionID: a.activeSessionID(), + RunID: a.state.Runtime.RunID, + Text: msg.Text, + } + return answerQuestionCmd(a.client, answer) +} + +// cancelPrompt 取消当前内联交互,只重置输入 UI 状态,不触碰后端运行状态。 +func (a *App) cancelPrompt(mode string) { + a.state.Input.Mode = state.InputStateModeMessage + a.state.Input.Text = "" + a.state.Input.Cursor = 0 + a.state.Input.Prompt = "" + a.state.Input.Options = nil + a.state.Mode = state.InputModeInput + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("prompt-cancel-%d", time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: fmt.Sprintf("%s cancelled", emptyDash(mode)), + Metadata: map[string]any{"done": true}, + }) +} + +// openOverlay 打开指定类型的浮层,重置搜索状态。 +func (a *App) openOverlay(overlayType string) { + a.state.Overlay.Active = overlayType + a.state.Overlay.Query = "" + a.state.Overlay.Selected = 0 +} + +// handlePaletteCommand 处理命令面板选择的命令。 +func (a *App) handlePaletteCommand(msg components.PaletteCommandMsg) tea.Cmd { + switch msg.Name { + case "/exit": + return tea.Quit + case "/help": + a.openOverlay("help") + return nil + case "/session": + a.openOverlay("session_picker") + return nil + default: + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("cmd-%s-%d", msg.Name, time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: fmt.Sprintf("command %s not yet implemented", msg.Name), + Metadata: map[string]any{"done": true}, + }) + } + return nil +} + +// handleSlashCommand 处理 Slash 命令输入。 +func (a *App) handleSlashCommand(msg components.SlashCommandMsg) tea.Cmd { + switch msg.Command { + case "/exit", "/quit": + return tea.Quit + case "/help": + a.openOverlay("help") + return nil + case "/session": + a.openOverlay("session_picker") + return nil + case "/clear": + a.state.Stream = nil + a.bindComponents() + return nil + default: + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("slash-%s-%d", msg.Command, time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: fmt.Sprintf("unknown command: %s", msg.Command), + Metadata: map[string]any{"done": true}, + }) + } + return nil +} + +// handleSessionSelect 处理会话切换操作。 +func (a *App) handleSessionSelect(msg components.SessionSelectMsg) tea.Cmd { + if a.client == nil { + return nil + } + a.state.Gateway.ActiveSess = &msg.Session + return loadSessionCmd(a.client, msg.Session.ID) +} + +// handleSessionDelete 处理会话删除操作。 +func (a *App) handleSessionDelete(msg components.SessionDeleteMsg) tea.Cmd { + if a.client == nil { + return nil + } + return deleteSessionCmd(a.client, msg.SessionID) +} + +// activeSessionID 返回当前会话 ID,缺失时使用空字符串让 GatewayClient 自行决定错误语义。 +func (a *App) activeSessionID() string { + if a.state.Gateway.ActiveSess != nil { + return a.state.Gateway.ActiveSess.ID + } + return "" +} + // mainArea 渲染中部区域,按终端宽度决定 Inspector 右侧或纵向压缩显示。 func (a *App) mainArea() string { streamView := a.agentStream.View() @@ -203,7 +555,7 @@ func (a *App) fitViewToTerminal(view string) string { lines = lines[:height] case len(lines) < height: for len(lines) < height { - lines = append(lines, strings.Repeat(" ", width)) + lines = append(lines, strings.Repeat(" ", width-1)) } } } @@ -386,3 +738,116 @@ func waitEventCmd(events <-chan gateway.GatewayEvent) tea.Cmd { return gatewayEventMsg{event: event, closed: !ok} } } + +// submitMessageCmd 调用 GatewayClient 发送用户消息,并把 ACK 转成 reducer 可消费事件。 +func submitMessageCmd(client gateway.Client, sessionID string, text string) tea.Cmd { + return func() tea.Msg { + ack, err := client.SendMessage(context.Background(), sessionID, text) + if err != nil { + return gatewayEventMsg{event: errorEvent(err)} + } + return gatewayEventMsg{event: gateway.GatewayEvent{ + Type: gateway.EventRunStarted, + SessionID: ack.SessionID, + RunID: ack.RunID, + Payload: map[string]any{"message": ack.Message, "accepted": ack.Accepted}, + At: time.Now(), + }} + } +} + +// resolvePermissionCmd 调用 GatewayClient 提交权限决策,并把完成结果转成 GatewayEvent。 +func resolvePermissionCmd(client gateway.Client, decision gateway.PermissionDecision) tea.Cmd { + return func() tea.Msg { + if err := client.ResolvePermission(context.Background(), decision); err != nil { + return gatewayEventMsg{event: errorEvent(err)} + } + text := "permission denied" + if decision.Allow { + text = "permission allowed" + } + return gatewayEventMsg{event: gateway.GatewayEvent{ + Type: gateway.EventPermissionResolved, + SessionID: decision.SessionID, + RunID: decision.RunID, + Payload: map[string]any{"decision": decision.Reason, "message": text}, + At: time.Now(), + }} + } +} + +// answerQuestionCmd 调用 GatewayClient 提交 ask_user 回答,并把完成结果转成 GatewayEvent。 +func answerQuestionCmd(client gateway.Client, answer gateway.UserQuestionAnswer) tea.Cmd { + return func() tea.Msg { + if err := client.AnswerUserQuestion(context.Background(), answer); err != nil { + return gatewayEventMsg{event: errorEvent(err)} + } + return gatewayEventMsg{event: gateway.GatewayEvent{ + Type: gateway.EventUserQuestionAnswered, + SessionID: answer.SessionID, + RunID: answer.RunID, + Payload: map[string]any{"answer": answer.Text, "message": "answer submitted"}, + At: time.Now(), + }} + } +} + +// errorEvent 将 GatewayClient RPC 错误包装成统一错误事件。 +func errorEvent(err error) gateway.GatewayEvent { + return gateway.GatewayEvent{ + Type: gateway.EventError, + Payload: map[string]any{"message": err.Error()}, + At: time.Now(), + } +} + +// cancelRunCmd 调用 GatewayClient 取消运行中的 Agent,并把完成结果转成 GatewayEvent。 +func cancelRunCmd(client gateway.Client, sessionID string, runID string) tea.Cmd { + return func() tea.Msg { + if err := client.CancelRun(context.Background(), sessionID, runID); err != nil { + return gatewayEventMsg{event: errorEvent(err)} + } + return gatewayEventMsg{event: gateway.GatewayEvent{ + Type: gateway.EventRunCancelled, + SessionID: sessionID, + RunID: runID, + Payload: map[string]any{"message": "run cancelled by user"}, + At: time.Now(), + }} + } +} + +// loadSessionCmd 切换到指定会话并建立新的事件订阅。 +func loadSessionCmd(client gateway.Client, sessionID string) tea.Cmd { + return func() tea.Msg { + detail, err := client.LoadSession(context.Background(), sessionID) + if err != nil { + return gatewayEventMsg{event: errorEvent(err)} + } + eventCh, err := client.SubscribeEvents(context.Background(), sessionID) + if err != nil { + return gatewayEventMsg{event: errorEvent(err)} + } + return sessionSwitchedMsg{sessionID: sessionID, detail: detail, eventCh: eventCh} + } +} + +// deleteSessionCmd 调用 GatewayClient 删除会话。 +func deleteSessionCmd(client gateway.Client, sessionID string) tea.Cmd { + return func() tea.Msg { + // Gateway Client 接口暂无 DeleteSession,此处预留 + return gatewayEventMsg{event: gateway.GatewayEvent{ + Type: gateway.EventSessionDeleted, + SessionID: sessionID, + Payload: map[string]any{"id": sessionID, "message": "session deleted"}, + At: time.Now(), + }} + } +} + +// sessionSwitchedMsg 表示会话切换完成。 +type sessionSwitchedMsg struct { + sessionID string + detail *gateway.SessionDetail + eventCh <-chan gateway.GatewayEvent +} diff --git a/internal/tuiv2/components/help.go b/internal/tuiv2/components/help.go new file mode 100644 index 000000000..11cac2733 --- /dev/null +++ b/internal/tuiv2/components/help.go @@ -0,0 +1,103 @@ +package components + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "neo-code/internal/tuiv2/keymap" + "neo-code/internal/tuiv2/state" + "neo-code/internal/tuiv2/theme" +) + +// HelpOverlay 是分组快捷键帮助浮层组件。 +type HelpOverlay struct { + state *state.ViewState +} + +var _ tea.Model = (*HelpOverlay)(nil) + +// NewHelpOverlay 创建帮助浮层组件。 +func NewHelpOverlay(viewState *state.ViewState) *HelpOverlay { + return &HelpOverlay{state: viewState} +} + +// Init 不启动额外命令。 +func (h *HelpOverlay) Init() tea.Cmd { + return nil +} + +// Update 处理帮助浮层内的键盘输入。 +func (h *HelpOverlay) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return h, nil + } + switch key.String() { + case "esc", "ctrl+c", "q", "?": + h.state.Overlay.Active = "" + return h, nil + } + return h, nil +} + +// View 渲染分组快捷键帮助浮层。 +func (h *HelpOverlay) View() string { + width := h.state.Layout.Width + height := h.state.Layout.Height + if width <= 0 { + width = 60 + } + if height <= 0 { + height = 24 + } + + boxW := min(width-4, 56) + groups := keymap.FullHelp() + + var lines []string + lines = append(lines, theme.AccentStyle().Render(" Keyboard Shortcuts")) + lines = append(lines, "") + + for _, group := range groups { + lines = append(lines, theme.ToolNameStyle().Render(" "+group.Title)) + for _, entry := range group.Entries { + keyText := theme.AccentStyle().Render(entry.Key) + descText := theme.MutedStyle().Render(entry.Desc) + line := " " + padRight(keyText, 24) + " " + descText + if dw := theme.DisplayWidth(line); dw > boxW-4 { + line = theme.Truncate(line, boxW-4) + } + lines = append(lines, line) + } + lines = append(lines, "") + } + + hint := theme.MutedStyle().Render(" ␛ : close") + lines = append(lines, hint) + + content := strings.Join(lines, "\n") + box := lipgloss.NewStyle(). + Width(boxW). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(0, 1). + Render(content) + + boxH := min(height-2, 28) + return lipgloss.NewStyle(). + Width(width). + Height(boxH). + Align(lipgloss.Center, lipgloss.Center). + Render(box) +} + +// padRight 将文本补齐到指定显示宽度。 +func padRight(text string, width int) string { + dw := theme.DisplayWidth(text) + if dw >= width { + return text + } + return text + strings.Repeat(" ", width-dw) +} diff --git a/internal/tuiv2/components/inspector.go b/internal/tuiv2/components/inspector.go index fc28e42bc..84cdc6f5c 100644 --- a/internal/tuiv2/components/inspector.go +++ b/internal/tuiv2/components/inspector.go @@ -5,6 +5,7 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "neo-code/internal/tuiv2/state" "neo-code/internal/tuiv2/theme" ) @@ -31,7 +32,7 @@ func (c *SoftInspector) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, nil } -// View 渲染会话、上下文和 token 详情;窄屏隐藏由 App 布局控制。 +// View 渲染会话、上下文、token、工具和文件详情;窄屏隐藏由 App 布局控制。 func (c *SoftInspector) View() string { if !c.state.Layout.ShowInspector { return "" @@ -46,6 +47,8 @@ func (c *SoftInspector) View() string { lines = append(lines, c.contextLine()) lines = append(lines, "", theme.MutedStyle().Render("Token Usage")) lines = append(lines, fmt.Sprintf(" ↑ %d ↓ %d", c.state.Runtime.Tokens.Input, c.state.Runtime.Tokens.Output)) + lines = append(lines, c.toolLines()...) + lines = append(lines, c.fileLines()...) content := strings.Join(lines, "\n") if width > 0 { return fitBlock(content, width, true) @@ -77,3 +80,88 @@ func (c *SoftInspector) contextLine() string { } return fmt.Sprintf(" %s%d/100k", theme.AccentBar(), total) } + +// toolLines 渲染当前活跃工具(已启动但未完成的工具调用)。 +func (c *SoftInspector) toolLines() []string { + lines := []string{"", theme.MutedStyle().Render("Active Tools")} + activeTools := c.activeToolNames() + if len(activeTools) == 0 { + return append(lines, " "+theme.Separator()+" idle") + } + for _, name := range activeTools { + lines = append(lines, " "+theme.Separator()+" "+theme.ToolNameStyle().Render("tool."+name)) + } + return lines +} + +// activeToolNames 扫描 Stream 查找已启动但未完成的工具调用。 +func (c *SoftInspector) activeToolNames() []string { + ended := make(map[string]bool) + var started []string + for _, entry := range c.state.Stream { + switch entry.Type { + case "tool_start": + if entry.ToolName != "" { + started = append(started, entry.ToolName) + } + case "tool_end": + if entry.ToolName != "" { + ended[entry.ToolName] = true + } + } + } + var active []string + for _, name := range started { + if !ended[name] { + active = append(active, name) + } + } + return active +} + +// fileLines 渲染工具修改过的文件路径列表,使用 DiffAdd/DiffDel 配色。 +func (c *SoftInspector) fileLines() []string { + lines := []string{"", theme.MutedStyle().Render("Files")} + fileEntries := c.fileEntries() + if len(fileEntries) == 0 { + return append(lines, " "+theme.Separator()+" none") + } + for _, fe := range fileEntries { + lines = append(lines, " "+theme.Separator()+" "+fe) + } + return lines +} + +// fileEntries 扫描 Stream 中的 tool_end 条目,提取文件路径并使用 DiffAdd/DiffDel 配色。 +func (c *SoftInspector) fileEntries() []string { + seen := make(map[string]bool) + var entries []string + palette := theme.TokyoNight + for _, entry := range c.state.Stream { + if entry.Type != "tool_end" || entry.Content == "" { + continue + } + path := extractFilePath(entry.Content) + if path == "" || seen[path] { + continue + } + seen[path] = true + color := palette.DiffAdd + if strings.Contains(entry.Content, "delete") || strings.Contains(entry.Content, "remove") { + color = palette.DiffDel + } + styled := lipgloss.NewStyle().Foreground(color).Render(path) + entries = append(entries, styled) + } + return entries +} + +// extractFilePath 从工具输出内容中提取文件路径(包含 / 或 . 的首段)。 +func extractFilePath(content string) string { + for _, part := range strings.Fields(content) { + if strings.Contains(part, "/") || (strings.Contains(part, ".") && len(part) > 1) { + return part + } + } + return "" +} diff --git a/internal/tuiv2/components/inspector_test.go b/internal/tuiv2/components/inspector_test.go new file mode 100644 index 000000000..720a73d0c --- /dev/null +++ b/internal/tuiv2/components/inspector_test.go @@ -0,0 +1,110 @@ +package components + +import ( + "strings" + "testing" + + "neo-code/internal/tuiv2/gateway" + "neo-code/internal/tuiv2/state" +) + +func TestSoftInspectorRendersSessionList(t *testing.T) { + viewState := state.NewViewState() + viewState.Layout.Width = 120 + viewState.Layout.Height = 30 + viewState.Layout.ShowInspector = true + viewState.Layout.InspectorWidth = 30 + viewState.Gateway.Sessions = []gateway.SessionSummary{ + {ID: "s1", Title: "debug-session"}, + {ID: "s2", Title: "refactor-task"}, + } + + view := NewSoftInspector(viewState).View() + for _, want := range []string{ + "Soft Inspector", + "debug-session", + "refactor-task", + } { + if !strings.Contains(view, want) { + t.Fatalf("View() missing %q in:\n%s", want, view) + } + } +} + +func TestSoftInspectorRendersTokenUsage(t *testing.T) { + viewState := state.NewViewState() + viewState.Layout.Width = 120 + viewState.Layout.Height = 30 + viewState.Layout.ShowInspector = true + viewState.Layout.InspectorWidth = 30 + viewState.Runtime.Tokens = state.TokenUsage{Input: 1024, Output: 512, Total: 1536} + + view := NewSoftInspector(viewState).View() + for _, want := range []string{ + "Token Usage", + "1024", + "512", + } { + if !strings.Contains(view, want) { + t.Fatalf("View() missing %q in:\n%s", want, view) + } + } +} + +func TestSoftInspectorHiddenWhenDisabled(t *testing.T) { + viewState := state.NewViewState() + viewState.Layout.ShowInspector = false + + view := NewSoftInspector(viewState).View() + if view != "" { + t.Fatalf("View() = %q, want empty when inspector hidden", view) + } +} + +func TestSoftInspectorRendersActiveTools(t *testing.T) { + viewState := state.NewViewState() + viewState.Layout.Width = 120 + viewState.Layout.Height = 30 + viewState.Layout.ShowInspector = true + viewState.Layout.InspectorWidth = 30 + viewState.Stream = []state.StreamEntry{ + {ID: "ts1", Type: "tool_start", ToolName: "read_file", Content: "main.go"}, + {ID: "ts2", Type: "tool_start", ToolName: "write_file", Content: "app.go"}, + {ID: "te1", Type: "tool_end", ToolName: "read_file"}, + } + + view := NewSoftInspector(viewState).View() + for _, want := range []string{ + "Active Tools", + "tool.write_file", + } { + if !strings.Contains(view, want) { + t.Fatalf("View() missing %q in:\n%s", want, view) + } + } + if strings.Contains(view, "tool.read_file") { + t.Fatalf("View() should not show completed tool read_file in:\n%s", view) + } +} + +func TestSoftInspectorRendersFiles(t *testing.T) { + viewState := state.NewViewState() + viewState.Layout.Width = 120 + viewState.Layout.Height = 30 + viewState.Layout.ShowInspector = true + viewState.Layout.InspectorWidth = 30 + viewState.Stream = []state.StreamEntry{ + {ID: "te1", Type: "tool_end", ToolName: "read_file", Content: "src/main.go"}, + {ID: "te2", Type: "tool_end", ToolName: "write_file", Content: "delete config.yaml"}, + } + + view := NewSoftInspector(viewState).View() + for _, want := range []string{ + "Files", + "main.go", + } { + if !strings.Contains(view, want) { + t.Fatalf("View() missing %q in:\n%s", want, view) + } + } +} diff --git a/internal/tuiv2/components/palette.go b/internal/tuiv2/components/palette.go new file mode 100644 index 000000000..3ca9eadcd --- /dev/null +++ b/internal/tuiv2/components/palette.go @@ -0,0 +1,197 @@ +package components + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "neo-code/internal/tuiv2/state" + "neo-code/internal/tuiv2/theme" + + "github.com/sahilm/fuzzy" +) + +// PaletteItem 描述命令面板中的一个可选项。 +type PaletteItem struct { + Name string + Description string +} + +// PaletteCommandMsg 表示用户选择了某个命令面板项。 +type PaletteCommandMsg struct { + Name string +} + +var defaultPaletteItems = []PaletteItem{ + {Name: "/model", Description: "Change the current model"}, + {Name: "/mode", Description: "Switch between build and plan"}, + {Name: "/session", Description: "Browse and switch sessions"}, + {Name: "/compact", Description: "Compact current session"}, + {Name: "/checkpoint", Description: "Manage checkpoints"}, + {Name: "/skills", Description: "Manage session skills"}, + {Name: "/help", Description: "Show keyboard shortcuts"}, + {Name: "/exit", Description: "Quit NeoCode"}, +} + +// Palette 是 Telescope 风格的命令面板组件。 +type Palette struct { + state *state.ViewState + items []PaletteItem +} + +var _ tea.Model = (*Palette)(nil) + +// NewPalette 创建命令面板组件。 +func NewPalette(viewState *state.ViewState) *Palette { + return &Palette{ + state: viewState, + items: defaultPaletteItems, + } +} + +// Init 不启动额外命令。 +func (p *Palette) Init() tea.Cmd { + return nil +} + +// Update 处理命令面板内的键盘和导航。 +func (p *Palette) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return p, nil + } + switch key.String() { + case "esc", "ctrl+c": + p.state.Overlay.Active = "" + p.state.Overlay.Query = "" + p.state.Overlay.Selected = 0 + return p, nil + case "enter": + matched := p.matchedItems() + if len(matched) == 0 { + return p, nil + } + idx := p.state.Overlay.Selected + if idx >= len(matched) { + idx = len(matched) - 1 + } + selected := matched[idx] + p.state.Overlay.Active = "" + p.state.Overlay.Query = "" + p.state.Overlay.Selected = 0 + return p, func() tea.Msg { + return PaletteCommandMsg{Name: selected.Name} + } + case "up", "k": + if p.state.Overlay.Selected > 0 { + p.state.Overlay.Selected-- + } + return p, nil + case "down", "j": + matched := p.matchedItems() + if p.state.Overlay.Selected < len(matched)-1 { + p.state.Overlay.Selected++ + } + return p, nil + case "backspace": + if len(p.state.Overlay.Query) > 0 { + p.state.Overlay.Query = p.state.Overlay.Query[:len(p.state.Overlay.Query)-1] + p.state.Overlay.Selected = 0 + } + return p, nil + default: + runes := key.Runes + if len(runes) > 0 && runes[0] >= 32 { + p.state.Overlay.Query += string(runes) + p.state.Overlay.Selected = 0 + } + return p, nil + } +} + +// matchedItems 根据当前查询进行模糊匹配。 +func (p *Palette) matchedItems() []PaletteItem { + query := strings.ToLower(p.state.Overlay.Query) + if query == "" { + return p.items + } + targets := make([]string, len(p.items)) + for i, item := range p.items { + targets[i] = strings.ToLower(item.Name) + " " + strings.ToLower(item.Description) + } + matches := fuzzy.Find(query, targets) + result := make([]PaletteItem, 0, len(matches)) + for _, m := range matches { + result = append(result, p.items[m.Index]) + } + return result +} + +// View 渲染 Telescope 风格的命令面板。 +func (p *Palette) View() string { + width := p.state.Layout.Width + height := p.state.Layout.Height + if width <= 0 { + width = 60 + } + if height <= 0 { + height = 24 + } + + matched := p.matchedItems() + boxW := min(width-4, 60) + boxH := min(height-4, 20) + + var lines []string + + // 搜索输入行 + queryLine := "> " + p.state.Overlay.Query + queryLine = theme.AccentStyle().Render(queryLine) + lines = append(lines, queryLine, "") + + // 选项列表 + for i, item := range matched { + if len(lines) >= boxH-3 { + break + } + prefix := " " + name := item.Name + desc := theme.MutedStyle().Render(item.Description) + + if i == p.state.Overlay.Selected { + prefix = theme.AccentStyle().Render("▎ ") + name = theme.AccentStyle().Bold(true).Render(name) + } + line := prefix + name + " " + desc + if displayW := theme.DisplayWidth(line); displayW > boxW-2 { + line = theme.Truncate(line, boxW-2) + } + lines = append(lines, line) + } + + if len(matched) == 0 { + lines = append(lines, theme.MutedStyle().Render(" No matches found")) + } + + // 底部提示行 + hint := " ␣ : close ⏎ : execute ␛ : dismiss" + lines = append(lines, "", theme.MutedStyle().Render(hint)) + + // 边框容器 + content := strings.Join(lines, "\n") + box := lipgloss.NewStyle(). + Width(boxW). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(0, 1). + Render(content) + + // 居中 + outerH := max(boxH, 10) + return lipgloss.NewStyle(). + Width(width). + Height(outerH). + Align(lipgloss.Center, lipgloss.Center). + Render(box) +} diff --git a/internal/tuiv2/components/prompt.go b/internal/tuiv2/components/prompt.go index ca9bf466c..1a90896b3 100644 --- a/internal/tuiv2/components/prompt.go +++ b/internal/tuiv2/components/prompt.go @@ -2,14 +2,52 @@ package components import ( "fmt" + "strconv" "strings" + "time" + "unicode/utf8" tea "github.com/charmbracelet/bubbletea" + "neo-code/internal/tuiv2/state" "neo-code/internal/tuiv2/theme" ) -// CommandPrompt 渲染命令和消息输入区域。 +const ( + cursorBlinkInterval = 500 * time.Millisecond + promptWrapIndent = " " +) + +// SubmitMessageMsg 表示用户在消息模式下提交了一条待发送文本。 +type SubmitMessageMsg struct { + Text string +} + +// PermissionActionMsg 表示用户在权限模式下选择了 y/n/d/a 之一。 +type PermissionActionMsg struct { + Decision string +} + +// QuestionAnswerMsg 表示用户在 ask_user 模式下提交了回答文本。 +type QuestionAnswerMsg struct { + Text string +} + +// SlashCommandMsg 表示用户提交了一条 Slash 命令。 +type SlashCommandMsg struct { + Command string + Args string +} + +// PromptCancelMsg 表示用户取消了当前内联交互。 +type PromptCancelMsg struct { + Mode string +} + +// CursorBlinkMsg 驱动 Prompt 光标在 Bubble Tea 更新循环中闪烁。 +type CursorBlinkMsg struct{} + +// CommandPrompt 渲染命令、消息输入、权限确认和 ask_user 内联交互区域。 type CommandPrompt struct { state *state.ViewState } @@ -21,26 +59,41 @@ func NewCommandPrompt(viewState *state.ViewState) *CommandPrompt { return &CommandPrompt{state: viewState} } -// Init 不启动额外命令,组件只读取共享 ViewState。 +// Init 启动光标闪烁时钟,后续 tick 仍通过 Update 返回命令续订。 func (c *CommandPrompt) Init() tea.Cmd { - return nil + return cursorBlinkCmd() } -// Update 当前不维护组件私有业务状态,只保留 tea.Model 契约。 +// Update 根据当前 InputState.Mode 路由按键,业务动作以 tea.Msg 形式返回给 App。 func (c *CommandPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return c, nil + switch msg := msg.(type) { + case CursorBlinkMsg: + c.state.Input.CursorVisible = !c.state.Input.CursorVisible + return c, cursorBlinkCmd() + case tea.KeyMsg: + switch c.state.Input.Mode { + case state.InputStateModePermissionResponse: + return c, c.handlePermissionKey(msg) + case state.InputStateModeQuestionAnswer: + return c, c.handleQuestionKey(msg) + default: + return c, c.handleInputKey(msg) + } + default: + return c, nil + } } -// View 渲染底部输入区和模式提示。 +// View 根据输入模式渲染底部 Prompt,保持无边框、内联和定宽安全。 func (c *CommandPrompt) View() string { lines := []string{theme.MutedStyle().Render("Command Prompt")} - prompt := "› " + c.state.Input.Text - if c.state.Input.Text == "" { - prompt += "_" - } - lines = append(lines, theme.AccentStyle().Render(prompt)) - if c.state.Input.Prompt != "" { - lines = append(lines, theme.MutedStyle().Render(" "+c.state.Input.Prompt)) + switch c.state.Input.Mode { + case state.InputStateModePermissionResponse: + lines = append(lines, c.permissionLines()...) + case state.InputStateModeQuestionAnswer: + lines = append(lines, c.questionLines()...) + default: + lines = append(lines, c.messageLines()...) } lines = append(lines, c.modeLine()) content := strings.Join(lines, "\n") @@ -50,14 +103,417 @@ func (c *CommandPrompt) View() string { return content } -// modeLine 渲染输入模式、界面名和当前模型。 +// handleInputKey 处理普通消息输入、历史切换、换行和提交。 +func (c *CommandPrompt) handleInputKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "esc": + c.state.Mode = state.NormalMode + case "i": + if c.state.Mode == state.NormalMode { + c.state.Mode = state.InputModeInput + } else { + c.insertRunes(msg.Runes) + } + case "left": + c.moveCursor(-1) + case "right": + c.moveCursor(1) + case "home": + c.state.Input.Cursor = 0 + case "end": + c.state.Input.Cursor = runeLen(c.state.Input.Text) + case "backspace", "ctrl+h": + c.deleteBeforeCursor() + case "delete": + c.deleteAtCursor() + case "shift+enter", "alt+enter": + c.insertText("\n") + case "enter": + text := strings.TrimSpace(c.state.Input.Text) + if text == "" { + return nil + } + c.pushHistory(text) + c.clearText() + // Slash 命令拦截 + if strings.HasPrefix(text, "/") { + cmd, args, _ := strings.Cut(text, " ") + return emitMsg(SlashCommandMsg{Command: cmd, Args: args}) + } + return emitMsg(SubmitMessageMsg{Text: text}) + case "up": + if c.state.Mode == state.NormalMode { + c.previousHistory() + } + case "down": + if c.state.Mode == state.NormalMode { + c.nextHistory() + } + default: + if c.state.Mode == state.InputModeInput && len(msg.Runes) > 0 { + c.insertRunes(msg.Runes) + } + } + return nil +} + +// handlePermissionKey 处理权限模式的一键响应,不要求用户再按 Enter。 +func (c *CommandPrompt) handlePermissionKey(msg tea.KeyMsg) tea.Cmd { + switch strings.ToLower(msg.String()) { + case "y", "n", "d", "a": + decision := strings.ToLower(msg.String()) + c.clearText() + return emitMsg(PermissionActionMsg{Decision: decision}) + case "esc": + c.clearText() + return emitMsg(PromptCancelMsg{Mode: state.InputStateModePermissionResponse}) + case "left": + c.moveCursor(-1) + case "right": + c.moveCursor(1) + case "backspace", "ctrl+h": + c.deleteBeforeCursor() + default: + if len(msg.Runes) > 0 { + c.insertRunes(msg.Runes) + } + } + return nil +} + +// handleQuestionKey 处理 ask_user 回答输入、数字快捷选择和确认。 +func (c *CommandPrompt) handleQuestionKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "esc": + c.clearText() + return emitMsg(PromptCancelMsg{Mode: state.InputStateModeQuestionAnswer}) + case "enter": + text := strings.TrimSpace(c.state.Input.Text) + if text == "" { + return nil + } + c.clearText() + return emitMsg(QuestionAnswerMsg{Text: text}) + case "left": + c.moveCursor(-1) + case "right": + c.moveCursor(1) + case "backspace", "ctrl+h": + c.deleteBeforeCursor() + case "delete": + c.deleteAtCursor() + default: + if len(msg.Runes) > 0 { + c.insertRunes(msg.Runes) + } + } + return nil +} + +// messageLines 渲染命令和普通消息模式下的输入行。 +func (c *CommandPrompt) messageLines() []string { + return []string{c.renderPromptInput("›")} +} + +// permissionLines 渲染权限确认的提示、输入和快捷操作栏。 +func (c *CommandPrompt) permissionLines() []string { + prompt := c.state.Input.Prompt + if prompt == "" { + prompt = "permission requested" + } + return []string{ + theme.WarningStyle().Render(theme.StatusSymbol(theme.PhaseWaitingPermission)+" ") + + theme.SubtleStyle().Render(prompt), + c.renderPromptInput("›"), + c.renderShortcutBar([]shortcutItem{ + {Key: "Y", Text: "允许"}, + {Key: "n", Text: "拒绝"}, + {Key: "d", Text: "查看 diff"}, + {Key: "a", Text: "允许全部"}, + }), + } +} + +// questionLines 渲染 ask_user 问题、输入框、选项和快捷操作栏。 +func (c *CommandPrompt) questionLines() []string { + prompt := c.state.Input.Prompt + if prompt == "" { + prompt = "question" + } + lines := []string{ + theme.AccentStyle().Render(theme.Separator()+" ") + theme.BaseStyle().Render(prompt), + c.renderPromptInput("›"), + } + if len(c.state.Input.Options) > 0 { + lines = append(lines, "") + lines = append(lines, c.optionLines()...) + } + lines = append(lines, c.renderQuestionHint()) + return lines +} + +// renderPromptInput 渲染带光标的输入文本,多行输入用缩进保持视觉连续。 +func (c *CommandPrompt) renderPromptInput(symbol string) string { + text := c.textWithCursor() + rawLines := strings.Split(text, "\n") + if len(rawLines) == 0 { + rawLines = []string{""} + } + lines := make([]string, 0, len(rawLines)) + for index, line := range rawLines { + if index == 0 { + lines = append(lines, theme.AccentStyle().Render(symbol+" ")+theme.BaseStyle().Render(line)) + continue + } + lines = append(lines, theme.MutedStyle().Render(" ")+theme.BaseStyle().Render(line)) + } + return strings.Join(lines, "\n") +} + +type shortcutItem struct { + Key string + Text string +} + +// renderShortcutBar 渲染权限快捷键提示,括号和按键使用强调色。 +func (c *CommandPrompt) renderShortcutBar(items []shortcutItem) string { + parts := make([]string, 0, len(items)) + for _, item := range items { + parts = append(parts, theme.AccentStyle().Render("["+item.Key+"]")+" "+theme.SubtleStyle().Render(item.Text)) + } + return strings.Join(parts, " ") +} + +// optionLines 渲染 ask_user 选项,并让长文本换行后对齐到选项文本起始处。 +func (c *CommandPrompt) optionLines() []string { + width := c.contentWidth() + lines := make([]string, 0, len(c.state.Input.Options)) + for index, option := range c.state.Input.Options { + number := strconv.Itoa(index + 1) + prefix := " " + number + ". " + available := width - theme.DisplayWidth(prefix) + if available < 8 { + available = 8 + } + wrapped := wrapText(option, available) + if len(wrapped) == 0 { + wrapped = []string{""} + } + lines = append(lines, theme.AccentStyle().Render(prefix)+theme.BaseStyle().Render(wrapped[0])) + continuation := strings.Repeat(" ", theme.DisplayWidth(prefix)) + for _, line := range wrapped[1:] { + lines = append(lines, theme.MutedStyle().Render(continuation)+theme.BaseStyle().Render(line)) + } + } + return lines +} + +// renderQuestionHint 渲染 ask_user 的提交和取消提示。 +func (c *CommandPrompt) renderQuestionHint() string { + limit := len(c.state.Input.Options) + rangeText := "输入" + if limit > 0 { + rangeText = fmt.Sprintf("1-%d", limit) + } + return theme.AccentStyle().Render("["+rangeText+"]") + " " + theme.SubtleStyle().Render("输入数字选择") + + " " + theme.AccentStyle().Render("[Enter]") + " " + theme.SubtleStyle().Render("确认") + + " " + theme.AccentStyle().Render("[Esc]") + " " + theme.SubtleStyle().Render("取消") +} + +// modeLine 渲染输入模式、会话名和当前模型,并把右侧信息固定到行尾。 func (c *CommandPrompt) modeLine() string { - return theme.MutedStyle().Render(fmt.Sprintf( - "[%s] %s %s", - inputModeName(c.state.Mode), - surfaceName, - stringOrDash(c.state.Gateway.ActiveModel), - )) + left := fmt.Sprintf("[%s]", inputModeName(c.state.Mode)) + right := strings.TrimSpace(sessionTitle(c.state) + " " + stringOrDash(c.state.Gateway.ActiveModel)) + width := c.contentWidth() + if width <= 0 { + return theme.SubtleStyle().Render(left + " " + right) + } + gap := width - theme.DisplayWidth(left) - theme.DisplayWidth(right) + if gap < 1 { + return theme.SubtleStyle().Render(left + " " + right) + } + return theme.SubtleStyle().Render(left + strings.Repeat(" ", gap) + right) +} + +// textWithCursor 返回在当前光标位置插入闪烁光标后的文本。 +func (c *CommandPrompt) textWithCursor() string { + runes := []rune(c.state.Input.Text) + cursor := clampInt(c.state.Input.Cursor, 0, len(runes)) + symbol := " " + if c.state.Input.CursorVisible { + symbol = "_" + } + runes = append(runes[:cursor], append([]rune(symbol), runes[cursor:]...)...) + return string(runes) +} + +// insertRunes 在当前光标处插入可打印字符。 +func (c *CommandPrompt) insertRunes(runes []rune) { + if len(runes) == 0 { + return + } + c.insertText(string(runes)) +} + +// insertText 在当前光标处插入文本,并按 rune 位置推进光标。 +func (c *CommandPrompt) insertText(text string) { + runes := []rune(c.state.Input.Text) + cursor := clampInt(c.state.Input.Cursor, 0, len(runes)) + inserted := []rune(text) + next := make([]rune, 0, len(runes)+len(inserted)) + next = append(next, runes[:cursor]...) + next = append(next, inserted...) + next = append(next, runes[cursor:]...) + c.state.Input.Text = string(next) + c.state.Input.Cursor = cursor + len(inserted) + c.state.Input.CursorVisible = true + c.state.Input.HistoryIndex = -1 +} + +// deleteBeforeCursor 删除光标前一个 rune。 +func (c *CommandPrompt) deleteBeforeCursor() { + runes := []rune(c.state.Input.Text) + cursor := clampInt(c.state.Input.Cursor, 0, len(runes)) + if cursor == 0 { + return + } + next := append([]rune(nil), runes[:cursor-1]...) + next = append(next, runes[cursor:]...) + c.state.Input.Text = string(next) + c.state.Input.Cursor = cursor - 1 + c.state.Input.CursorVisible = true +} + +// deleteAtCursor 删除光标所在 rune。 +func (c *CommandPrompt) deleteAtCursor() { + runes := []rune(c.state.Input.Text) + cursor := clampInt(c.state.Input.Cursor, 0, len(runes)) + if cursor >= len(runes) { + return + } + next := append([]rune(nil), runes[:cursor]...) + next = append(next, runes[cursor+1:]...) + c.state.Input.Text = string(next) + c.state.Input.Cursor = cursor + c.state.Input.CursorVisible = true +} + +// moveCursor 按 rune 宽度移动光标,避免中文输入时 byte 位置错乱。 +func (c *CommandPrompt) moveCursor(delta int) { + c.state.Input.Cursor = clampInt(c.state.Input.Cursor+delta, 0, runeLen(c.state.Input.Text)) + c.state.Input.CursorVisible = true +} + +// clearText 清空当前输入文本并重置光标。 +func (c *CommandPrompt) clearText() { + c.state.Input.Text = "" + c.state.Input.Cursor = 0 + c.state.Input.CursorVisible = true + c.state.Input.HistoryIndex = -1 +} + +// pushHistory 把已提交输入追加到历史,并避免连续重复项。 +func (c *CommandPrompt) pushHistory(text string) { + if text == "" { + return + } + history := c.state.Input.History + if len(history) == 0 || history[len(history)-1] != text { + c.state.Input.History = append(history, text) + } + c.state.Input.HistoryIndex = -1 +} + +// previousHistory 在 Normal Mode 下切换到上一条输入历史。 +func (c *CommandPrompt) previousHistory() { + history := c.state.Input.History + if len(history) == 0 { + return + } + if c.state.Input.HistoryIndex < 0 { + c.state.Input.HistoryIndex = len(history) - 1 + } else if c.state.Input.HistoryIndex > 0 { + c.state.Input.HistoryIndex-- + } + c.setText(history[c.state.Input.HistoryIndex]) +} + +// nextHistory 在 Normal Mode 下切换到下一条输入历史,越过尾部时清空输入。 +func (c *CommandPrompt) nextHistory() { + history := c.state.Input.History + if len(history) == 0 || c.state.Input.HistoryIndex < 0 { + return + } + c.state.Input.HistoryIndex++ + if c.state.Input.HistoryIndex >= len(history) { + c.state.Input.HistoryIndex = -1 + c.setText("") + return + } + c.setText(history[c.state.Input.HistoryIndex]) +} + +// setText 设置输入文本,并把光标放到文本末尾。 +func (c *CommandPrompt) setText(text string) { + c.state.Input.Text = text + c.state.Input.Cursor = runeLen(text) + c.state.Input.CursorVisible = true +} + +// contentWidth 返回 Prompt 可用宽度,减一以避免终端自动换行。 +func (c *CommandPrompt) contentWidth() int { + if c.state.Layout.Width <= 0 { + return 80 + } + return c.state.Layout.Width - 1 +} + +// cursorBlinkCmd 创建下一次光标闪烁消息。 +func cursorBlinkCmd() tea.Cmd { + return tea.Tick(cursorBlinkInterval, func(time.Time) tea.Msg { + return CursorBlinkMsg{} + }) +} + +// emitMsg 把组件业务动作包装为 Bubble Tea 命令,让 App 统一处理。 +func emitMsg(msg tea.Msg) tea.Cmd { + return func() tea.Msg { + return msg + } +} + +// wrapText 按显示宽度把文本切为多行,保留中文和 ANSI 宽度安全。 +func wrapText(text string, width int) []string { + if width <= 0 { + return []string{text} + } + var lines []string + remaining := text + for remaining != "" { + if theme.DisplayWidth(remaining) <= width { + lines = append(lines, remaining) + break + } + piece := theme.Truncate(remaining, width) + if piece == "" { + break + } + lines = append(lines, strings.TrimRight(piece, " ")) + remaining = strings.TrimLeft(strings.TrimPrefix(remaining, piece), " ") + if remaining == text { + _, size := utf8.DecodeRuneInString(remaining) + remaining = remaining[size:] + } + } + return lines +} + +// sessionTitle 返回当前会话标题,缺失时回退为 Ghost Console 名称。 +func sessionTitle(viewState *state.ViewState) string { + if viewState.Gateway.ActiveSess != nil && viewState.Gateway.ActiveSess.Title != "" { + return viewState.Gateway.ActiveSess.Title + } + return surfaceName } // inputModeName 将输入模式转换为稳定显示文本。 @@ -71,3 +527,19 @@ func inputModeName(mode state.InputMode) string { return "input" } } + +// runeLen 返回字符串的 rune 数量。 +func runeLen(text string) int { + return len([]rune(text)) +} + +// clampInt 将整数限制在给定闭区间内。 +func clampInt(value int, min int, max int) int { + if value < min { + return min + } + if value > max { + return max + } + return value +} diff --git a/internal/tuiv2/components/prompt_test.go b/internal/tuiv2/components/prompt_test.go new file mode 100644 index 000000000..892f72e02 --- /dev/null +++ b/internal/tuiv2/components/prompt_test.go @@ -0,0 +1,164 @@ +package components + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/tuiv2/gateway" + "neo-code/internal/tuiv2/state" + "neo-code/internal/tuiv2/theme" +) + +func TestCommandPromptMessageInputSubmitAndMultiline(t *testing.T) { + viewState := promptState() + prompt := NewCommandPrompt(viewState) + + _, cmd := prompt.Update(keyMsg("hello")) + if cmd != nil { + t.Fatal("typing returned command, want nil") + } + _, _ = prompt.Update(keyMsg("shift+enter")) + _, _ = prompt.Update(keyMsg("world")) + if viewState.Input.Text != "hello\nworld" { + t.Fatalf("Input.Text = %q, want multiline text", viewState.Input.Text) + } + + _, cmd = prompt.Update(keyType(tea.KeyEnter)) + got, ok := cmd().(SubmitMessageMsg) + if !ok { + t.Fatalf("submit msg = %T, want SubmitMessageMsg", cmd()) + } + if got.Text != "hello\nworld" { + t.Fatalf("SubmitMessageMsg.Text = %q", got.Text) + } + if viewState.Input.Text != "" || viewState.Input.Cursor != 0 { + t.Fatalf("input not cleared after submit: %+v", viewState.Input) + } +} + +func TestCommandPromptPermissionSingleKeyActions(t *testing.T) { + viewState := promptState() + viewState.Input.Mode = state.InputStateModePermissionResponse + viewState.Input.Prompt = "tool.write_file 请求写入 main.go (2.3k) — 是否允许?" + prompt := NewCommandPrompt(viewState) + + view := prompt.View() + for _, want := range []string{ + theme.StatusSymbol(theme.PhaseWaitingPermission), + "tool.write_file 请求写入 main.go", + "[Y] 允许", + "[d] 查看 diff", + } { + if !strings.Contains(view, want) { + t.Fatalf("permission view missing %q in:\n%s", want, view) + } + } + + _, cmd := prompt.Update(keyMsg("y")) + got, ok := cmd().(PermissionActionMsg) + if !ok { + t.Fatalf("permission msg = %T, want PermissionActionMsg", cmd()) + } + if got.Decision != "y" { + t.Fatalf("Decision = %q, want y", got.Decision) + } +} + +func TestCommandPromptQuestionAnswerAndOptionWrapping(t *testing.T) { + viewState := promptState() + viewState.Layout.Width = 34 + viewState.Input.Mode = state.InputStateModeQuestionAnswer + viewState.Input.Prompt = "请选择要使用的模块:" + viewState.Input.Options = []string{ + "auth 模块 — 负责用户认证与授权", + "api 模块 — REST API 接口层并包含非常长的描述", + "db 模块 — 数据库访问层", + } + prompt := NewCommandPrompt(viewState) + + view := prompt.View() + for _, want := range []string{ + theme.Separator() + " 请选择要使用的模块:", + " 1. auth 模块", + " 2. api 模块", + "[1-3] 输入数字选择", + "[Enter] 确认", + } { + if !strings.Contains(view, want) { + t.Fatalf("question view missing %q in:\n%s", want, view) + } + } + for index, line := range strings.Split(view, "\n") { + if width := theme.DisplayWidth(line); width > 33 { + t.Fatalf("line %d width = %d, want <= 33: %q", index, width, line) + } + } + + _, _ = prompt.Update(keyMsg("2")) + _, cmd := prompt.Update(keyType(tea.KeyEnter)) + got, ok := cmd().(QuestionAnswerMsg) + if !ok { + t.Fatalf("question msg = %T, want QuestionAnswerMsg", cmd()) + } + if got.Text != "2" { + t.Fatalf("QuestionAnswerMsg.Text = %q, want 2", got.Text) + } +} + +func TestCommandPromptCursorMovementHistoryAndBlink(t *testing.T) { + viewState := promptState() + prompt := NewCommandPrompt(viewState) + + _, _ = prompt.Update(keyMsg("ab")) + _, _ = prompt.Update(keyType(tea.KeyLeft)) + _, _ = prompt.Update(keyMsg("中")) + if viewState.Input.Text != "a中b" { + t.Fatalf("Input.Text = %q, want rune-safe insert", viewState.Input.Text) + } + visible := viewState.Input.CursorVisible + _, cmd := prompt.Update(CursorBlinkMsg{}) + if cmd == nil { + t.Fatal("CursorBlinkMsg returned nil command") + } + if viewState.Input.CursorVisible == visible { + t.Fatal("CursorVisible did not toggle") + } + + viewState.Input.History = []string{"first", "second"} + viewState.Mode = state.NormalMode + _, _ = prompt.Update(keyType(tea.KeyUp)) + if viewState.Input.Text != "second" { + t.Fatalf("history up text = %q, want second", viewState.Input.Text) + } + _, _ = prompt.Update(keyType(tea.KeyDown)) + if viewState.Input.Text != "" { + t.Fatalf("history down text = %q, want empty", viewState.Input.Text) + } +} + +func TestCommandPromptModeLineUsesSessionAndModel(t *testing.T) { + viewState := promptState() + viewState.Gateway.ActiveSess = &gateway.SessionSummary{ID: "s1", Title: "ghost-console"} + viewState.Gateway.ActiveModel = "claude-sonnet-4-6" + + view := NewCommandPrompt(viewState).View() + for _, want := range []string{"[input]", "ghost-console", "claude-sonnet-4-6"} { + if !strings.Contains(view, want) { + t.Fatalf("mode line missing %q in:\n%s", want, view) + } + } +} + +func promptState() *state.ViewState { + viewState := state.NewViewState() + viewState.Layout.Width = 90 + viewState.Layout.Height = 20 + viewState.Input.CursorVisible = true + return viewState +} + +func keyType(key tea.KeyType) tea.KeyMsg { + return tea.KeyMsg{Type: key} +} diff --git a/internal/tuiv2/components/session_picker.go b/internal/tuiv2/components/session_picker.go new file mode 100644 index 000000000..1f5d8df8b --- /dev/null +++ b/internal/tuiv2/components/session_picker.go @@ -0,0 +1,210 @@ +package components + +import ( + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "neo-code/internal/tuiv2/gateway" + "neo-code/internal/tuiv2/state" + "neo-code/internal/tuiv2/theme" + + "github.com/sahilm/fuzzy" +) + +// SessionSelectMsg 表示用户选择了某个会话。 +type SessionSelectMsg struct { + Session gateway.SessionSummary +} + +// SessionDeleteMsg 表示用户请求删除某个会话。 +type SessionDeleteMsg struct { + SessionID string +} + +// SessionPicker 是 Telescope 风格的会话选择器组件。 +type SessionPicker struct { + state *state.ViewState +} + +var _ tea.Model = (*SessionPicker)(nil) + +// NewSessionPicker 创建会话选择器组件。 +func NewSessionPicker(viewState *state.ViewState) *SessionPicker { + return &SessionPicker{state: viewState} +} + +// Init 不启动额外命令。 +func (s *SessionPicker) Init() tea.Cmd { + return nil +} + +// Update 处理会话选择器内的键盘输入。 +func (s *SessionPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return s, nil + } + switch key.String() { + case "esc", "ctrl+c": + s.state.Overlay.Active = "" + s.state.Overlay.Query = "" + s.state.Overlay.Selected = 0 + return s, nil + case "enter", " ": + matched := s.matchedSessions() + if len(matched) == 0 { + return s, nil + } + idx := s.state.Overlay.Selected + if idx >= len(matched) { + idx = len(matched) - 1 + } + selected := matched[idx] + s.state.Overlay.Active = "" + s.state.Overlay.Query = "" + s.state.Overlay.Selected = 0 + return s, func() tea.Msg { + return SessionSelectMsg{Session: selected} + } + case "ctrl+d": + matched := s.matchedSessions() + if len(matched) == 0 { + return s, nil + } + idx := s.state.Overlay.Selected + if idx >= len(matched) { + idx = len(matched) - 1 + } + target := matched[idx] + s.state.Overlay.Active = "" + return s, func() tea.Msg { + return SessionDeleteMsg{SessionID: target.ID} + } + case "up", "k": + if s.state.Overlay.Selected > 0 { + s.state.Overlay.Selected-- + } + return s, nil + case "down", "j": + matched := s.matchedSessions() + if s.state.Overlay.Selected < len(matched)-1 { + s.state.Overlay.Selected++ + } + return s, nil + case "backspace": + if len(s.state.Overlay.Query) > 0 { + s.state.Overlay.Query = s.state.Overlay.Query[:len(s.state.Overlay.Query)-1] + s.state.Overlay.Selected = 0 + } + return s, nil + default: + runes := key.Runes + if len(runes) > 0 && runes[0] >= 32 { + s.state.Overlay.Query += string(runes) + s.state.Overlay.Selected = 0 + } + return s, nil + } +} + +// matchedSessions 根据当前查询模糊匹配会话列表。 +func (s *SessionPicker) matchedSessions() []gateway.SessionSummary { + sessions := s.state.Gateway.Sessions + query := strings.ToLower(s.state.Overlay.Query) + if query == "" { + return sessions + } + targets := make([]string, len(sessions)) + for i, sess := range sessions { + targets[i] = strings.ToLower(sess.Title) + } + matches := fuzzy.Find(query, targets) + result := make([]gateway.SessionSummary, 0, len(matches)) + for _, m := range matches { + result = append(result, sessions[m.Index]) + } + return result +} + +// View 渲染会话选择器浮层。 +func (s *SessionPicker) View() string { + width := s.state.Layout.Width + height := s.state.Layout.Height + if width <= 0 { + width = 60 + } + if height <= 0 { + height = 24 + } + + boxW := min(width-4, 56) + matched := s.matchedSessions() + + var lines []string + + // 标题 + lines = append(lines, theme.AccentStyle().Render(" Sessions")) + lines = append(lines, "") + + // 搜索输入 + queryLine := "> " + s.state.Overlay.Query + lines = append(lines, theme.AccentStyle().Render(queryLine), "") + + // 会话列表 + for i, sess := range matched { + if len(lines) > height-6 { + break + } + prefix := " " + title := sess.Title + if title == "" { + title = "untitled" + } + dateStr := formatSessionDate(sess.UpdatedAt) + detail := theme.MutedStyle().Render(dateStr) + + if i == s.state.Overlay.Selected { + prefix = theme.AccentStyle().Render("▎ ") + title = theme.AccentStyle().Bold(true).Render(title) + } + line := prefix + title + " " + detail + if dw := theme.DisplayWidth(line); dw > boxW-4 { + line = theme.Truncate(line, boxW-4) + } + lines = append(lines, line) + } + + if len(matched) == 0 { + lines = append(lines, theme.MutedStyle().Render(" No sessions found")) + } + + // 提示行 + hint := " ␣ : switch Ctrl+D : delete ␛ : cancel" + lines = append(lines, "", theme.MutedStyle().Render(hint)) + + content := strings.Join(lines, "\n") + box := lipgloss.NewStyle(). + Width(boxW). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(0, 1). + Render(content) + + boxH := min(height-2, 22) + return lipgloss.NewStyle(). + Width(width). + Height(boxH). + Align(lipgloss.Center, lipgloss.Center). + Render(box) +} + +// formatSessionDate 格式化会话日期显示。 +func formatSessionDate(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format("2006-01-02 15:04") +} diff --git a/internal/tuiv2/components/status_bar_test.go b/internal/tuiv2/components/status_bar_test.go new file mode 100644 index 000000000..83cfc019d --- /dev/null +++ b/internal/tuiv2/components/status_bar_test.go @@ -0,0 +1,53 @@ +package components + +import ( + "strings" + "testing" + + "neo-code/internal/tuiv2/state" + "neo-code/internal/tuiv2/theme" +) + +func TestAmbientStatusRendersPhaseInfo(t *testing.T) { + viewState := state.NewViewState() + viewState.Layout.Width = 120 + viewState.Layout.Height = 20 + + view := NewAmbientStatus(viewState).View() + for _, want := range []string{ + "NEOCODE", + theme.StatusSymbol(theme.PhaseIdle), + "ghost-console", + } { + if !strings.Contains(view, want) { + t.Fatalf("View() missing %q in:\n%s", want, view) + } + } +} + +func TestAmbientStatusRendersRunningPhase(t *testing.T) { + viewState := state.NewViewState() + viewState.Layout.Width = 120 + viewState.Layout.Height = 20 + viewState.Runtime.Phase = state.RuntimePhaseRunning + + view := NewAmbientStatus(viewState).View() + if !strings.Contains(view, state.RuntimePhaseRunning) { + t.Fatalf("View() missing running phase in:\n%s", view) + } +} + +func TestAmbientStatusWidthIsSafe(t *testing.T) { + viewState := state.NewViewState() + viewState.Layout.Width = 50 + viewState.Layout.Height = 10 + viewState.Runtime.Phase = state.RuntimePhaseRunning + viewState.Gateway.ActiveModel = "claude-sonnet-4-6-very-long-model-name" + + view := NewAmbientStatus(viewState).View() + for index, line := range strings.Split(view, "\n") { + if width := theme.DisplayWidth(line); width > 49 { + t.Fatalf("line %d width = %d, want <= 49: %q", index, width, line) + } + } +} diff --git a/internal/tuiv2/components/stream.go b/internal/tuiv2/components/stream.go index 870c74e86..2e363b2b6 100644 --- a/internal/tuiv2/components/stream.go +++ b/internal/tuiv2/components/stream.go @@ -55,6 +55,14 @@ func (c *AgentStream) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "G": c.state.Layout.ScrollOffset = 0 c.state.Layout.AutoScroll = true + case "ctrl+u": + halfPage := c.halfPageSize() + c.state.Layout.ScrollOffset = clampScroll(c.state.Layout.ScrollOffset+halfPage, maxOffset) + c.state.Layout.AutoScroll = false + case "ctrl+d": + halfPage := c.halfPageSize() + c.state.Layout.ScrollOffset = clampScroll(c.state.Layout.ScrollOffset-halfPage, maxOffset) + c.state.Layout.AutoScroll = c.state.Layout.ScrollOffset == 0 } return c, nil } @@ -86,7 +94,6 @@ func (c *AgentStream) headerText() string { return fmt.Sprintf("Agent Stream scroll:%d", c.state.Layout.ScrollOffset) } -// streamWidth 根据布局断点计算 Agent Stream 可用宽度。 // streamWidth 根据布局断点计算 Agent Stream 可用宽度。 func (c *AgentStream) streamWidth() int { width := c.state.Layout.Width @@ -96,7 +103,6 @@ func (c *AgentStream) streamWidth() int { return width } -// visibleLineCount 根据终端高度估算可展示的流行数。 // visibleLineCount 根据终端高度估算可展示的流行数。 func (c *AgentStream) visibleLineCount() int { height := c.state.Layout.Height @@ -110,6 +116,15 @@ func (c *AgentStream) visibleLineCount() int { return limit } +// halfPageSize 返回半页滚动所需的行数,至少为 1。 +func (c *AgentStream) halfPageSize() int { + h := c.visibleLineCount() / 2 + if h < 1 { + return 1 + } + return h +} + // maxScrollOffset 计算当前渲染内容允许的最大手动滚动偏移。 func (c *AgentStream) maxScrollOffset() int { lines := len(c.renderAllEntries()) @@ -207,13 +222,31 @@ func (c *AgentStream) renderEntry(entry state.StreamEntry) []string { } } -// renderMessage 渲染普通消息正文,支持换行。 +// renderMessage 渲染角色感知的消息正文,支持换行。 func (c *AgentStream) renderMessage(entry state.StreamEntry) []string { + role := "" + if v, ok := entry.Metadata["role"].(string); ok { + role = v + } + var label string + var styledLines []string text := entry.Content if text == "" { text = "-" } - return renderWrappedLines(text, "", theme.BaseStyle()) + switch role { + case "user": + label = " " + theme.InfoStyle().Render("you") + " " + styledLines = renderWrappedLines(text, " ", theme.BaseStyle()) + default: // "assistant" or empty + label = " " + theme.AccentStyle().Render("neo") + " " + styledLines = renderWrappedLines(text, " ", theme.BaseStyle()) + } + result := []string{label + strings.TrimPrefix(styledLines[0], " ")} + for _, line := range styledLines[1:] { + result = append(result, line) + } + return result } // renderToolStart 渲染工具调用开始行。 diff --git a/internal/tuiv2/fakegateway/.gitkeep b/internal/tuiv2/fakegateway/.gitkeep deleted file mode 100644 index 8b1378917..000000000 --- a/internal/tuiv2/fakegateway/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/internal/tuiv2/gateway/events.go b/internal/tuiv2/gateway/events.go index f813a1e9a..ef33e4c1e 100644 --- a/internal/tuiv2/gateway/events.go +++ b/internal/tuiv2/gateway/events.go @@ -48,6 +48,8 @@ const ( EventAskUserQuestion EventType = "ask_user_question" // EventUserQuestionRequested 表示后端请求 UI 回答 ask_user 问题。 EventUserQuestionRequested EventType = "user_question_requested" + // EventUserQuestionAnswered 表示 ask_user 问题已由 UI 回答。 + EventUserQuestionAnswered EventType = "user_question_answered" // EventModelChanged 表示当前会话模型已切换。 EventModelChanged EventType = "model_changed" // EventHealthChanged 表示 Gateway 健康状态发生变化。 diff --git a/internal/tuiv2/keymap/keys.go b/internal/tuiv2/keymap/keys.go new file mode 100644 index 000000000..63e4ecbe2 --- /dev/null +++ b/internal/tuiv2/keymap/keys.go @@ -0,0 +1,232 @@ +// Package keymap 定义 TUI v2 的三层键位系统:Input / Normal / Leader。 +package keymap + +import "github.com/charmbracelet/bubbles/key" + +// Action 代表一个键位触发的动作。 +type Action int + +const ( + ActionNone Action = iota + + // Input Mode actions + ActionSend // Enter + ActionNewline // Shift+Enter + ActionEscape // Esc + ActionCtrlC // Ctrl+C (context-dependent) + ActionOpenPalette // Ctrl+P + ActionHelp // ? + ActionSlashMode // / (when input empty) + ActionFileRef // @ (when input empty) + + // Normal Mode actions + ActionEnterInput // i + ActionScrollDown // j + ActionScrollUp // k + ActionHalfPageDown // Ctrl+D + ActionHalfPageUp // Ctrl+U + ActionScrollTop // g + ActionScrollBottom // G + ActionSearchForward // / + ActionSearchBackward // ? + ActionSearchNext // n + ActionSearchPrev // N + ActionExCommand // : + ActionQuit // q + ActionLeader // Space (enters Leader mode) + + // Leader actions + ActionLeaderPalette // Space p + ActionLeaderNewSession // Space n + ActionLeaderSwitchSession // Space s + ActionLeaderHelp // Space h + ActionLeaderToggleMode // Space m + ActionLeaderFullAccess // Space f + ActionLeaderLog // Space l + ActionLeaderCompact // Space c + ActionLeaderQuit // Space q +) + +// HelpEntry 描述一条键位帮助信息。 +type HelpEntry struct { + Key string + Desc string +} + +// HelpGroup 是一组相关的键位帮助。 +type HelpGroup struct { + Title string + Entries []HelpEntry +} + +// InputBindings 返回 Input Mode 的键位绑定。 +func InputBindings() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "send message")), + key.NewBinding(key.WithKeys("shift+enter"), key.WithHelp("shift+enter", "new line")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "normal mode")), + key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "cancel/quit")), + key.NewBinding(key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "command palette")), + } +} + +// InputHelp 返回 Input Mode 的分组帮助信息。 +func InputHelp() []HelpGroup { + return []HelpGroup{ + { + Title: "Input Mode", + Entries: []HelpEntry{ + {Key: "Enter", Desc: "Send message"}, + {Key: "Shift+Enter", Desc: "New line"}, + {Key: "Ctrl+C", Desc: "Cancel agent (double to quit)"}, + {Key: "Ctrl+P", Desc: "Command palette"}, + {Key: "?", Desc: "This help"}, + {Key: "/", Desc: "Slash command (when empty)"}, + {Key: "@", Desc: "Attach file reference (when empty)"}, + {Key: "Esc", Desc: "Normal Mode"}, + }, + }, + } +} + +// NormalHelp 返回 Normal Mode 的分组帮助信息。 +func NormalHelp() []HelpGroup { + return []HelpGroup{ + { + Title: "Normal Mode (Esc)", + Entries: []HelpEntry{ + {Key: "i", Desc: "Enter Input Mode"}, + {Key: "/", Desc: "Search in stream"}, + {Key: ":", Desc: "Command line"}, + {Key: "q", Desc: "Quit"}, + }, + }, + } +} + +// LeaderHelp 返回 Leader Key 的分组帮助信息。 +func LeaderHelp() []HelpGroup { + return []HelpGroup{ + { + Title: "Leader (Space)", + Entries: []HelpEntry{ + {Key: "Space p", Desc: "Command palette"}, + {Key: "Space n", Desc: "New session"}, + {Key: "Space s", Desc: "Switch session"}, + {Key: "Space h", Desc: "Help"}, + {Key: "Space m", Desc: "Toggle Agent mode (build/plan)"}, + {Key: "Space f", Desc: "Toggle Full Access"}, + {Key: "Space l", Desc: "Log viewer"}, + {Key: "Space c", Desc: "Manual compact"}, + {Key: "Space q", Desc: "Quit"}, + }, + }, + } +} + +// NavigationHelp 返回导航键位的分组帮助信息。 +func NavigationHelp() []HelpGroup { + return []HelpGroup{ + { + Title: "Navigation", + Entries: []HelpEntry{ + {Key: "j / k", Desc: "Scroll down / up"}, + {Key: "Ctrl+D / U", Desc: "Half-page down / up"}, + {Key: "g / G", Desc: "Jump to top / bottom"}, + {Key: "Mouse wheel", Desc: "Scroll"}, + }, + }, + } +} + +// FullHelp 返回所有分组帮助信息。 +func FullHelp() []HelpGroup { + var groups []HelpGroup + groups = append(groups, InputHelp()...) + groups = append(groups, NormalHelp()...) + groups = append(groups, LeaderHelp()...) + groups = append(groups, NavigationHelp()...) + return groups +} + +// MatchInputKey 匹配 Input Mode 按键到动作。 +func MatchInputKey(keyStr string) Action { + switch keyStr { + case "enter": + return ActionSend + case "shift+enter": + return ActionNewline + case "esc": + return ActionEscape + case "ctrl+c": + return ActionCtrlC + case "ctrl+p": + return ActionOpenPalette + } + return ActionNone +} + +// MatchNormalKey 匹配 Normal Mode 按键到动作。 +func MatchNormalKey(keyStr string) Action { + switch keyStr { + case "i": + return ActionEnterInput + case "j": + return ActionScrollDown + case "k": + return ActionScrollUp + case "ctrl+d": + return ActionHalfPageDown + case "ctrl+u": + return ActionHalfPageUp + case "g": + return ActionScrollTop + case "G": + return ActionScrollBottom + case "/": + return ActionSearchForward + case "n": + return ActionSearchNext + case "N": + return ActionSearchPrev + case ":": + return ActionExCommand + case "q": + return ActionQuit + case " ": + return ActionLeader + case "ctrl+c": + return ActionCtrlC + } + return ActionNone +} + +// MatchLeaderKey 匹配 Leader 后缀按键到动作。 +func MatchLeaderKey(keyStr string) Action { + switch keyStr { + case "p": + return ActionLeaderPalette + case "n": + return ActionLeaderNewSession + case "s": + return ActionLeaderSwitchSession + case "h": + return ActionLeaderHelp + case "m": + return ActionLeaderToggleMode + case "f": + return ActionLeaderFullAccess + case "l": + return ActionLeaderLog + case "c": + return ActionLeaderCompact + case "q": + return ActionLeaderQuit + } + return ActionNone +} + +// IsLeaderSuffix 判断按键是否为有效的 Leader 后缀。 +func IsLeaderSuffix(keyStr string) bool { + return MatchLeaderKey(keyStr) != ActionNone +} diff --git a/internal/tuiv2/keymap/keys_test.go b/internal/tuiv2/keymap/keys_test.go new file mode 100644 index 000000000..30a37bd5f --- /dev/null +++ b/internal/tuiv2/keymap/keys_test.go @@ -0,0 +1,107 @@ +package keymap + +import "testing" + +func TestMatchInputKey(t *testing.T) { + tests := []struct { + key string + action Action + }{ + {"enter", ActionSend}, + {"shift+enter", ActionNewline}, + {"esc", ActionEscape}, + {"ctrl+c", ActionCtrlC}, + {"ctrl+p", ActionOpenPalette}, + {"a", ActionNone}, + {"j", ActionNone}, + } + for _, tt := range tests { + got := MatchInputKey(tt.key) + if got != tt.action { + t.Errorf("MatchInputKey(%q) = %v, want %v", tt.key, got, tt.action) + } + } +} + +func TestMatchNormalKey(t *testing.T) { + tests := []struct { + key string + action Action + }{ + {"i", ActionEnterInput}, + {"j", ActionScrollDown}, + {"k", ActionScrollUp}, + {"ctrl+d", ActionHalfPageDown}, + {"ctrl+u", ActionHalfPageUp}, + {"g", ActionScrollTop}, + {"G", ActionScrollBottom}, + {"/", ActionSearchForward}, + {"n", ActionSearchNext}, + {"N", ActionSearchPrev}, + {":", ActionExCommand}, + {"q", ActionQuit}, + {" ", ActionLeader}, + {"ctrl+c", ActionCtrlC}, + {"enter", ActionNone}, + {"a", ActionNone}, + } + for _, tt := range tests { + got := MatchNormalKey(tt.key) + if got != tt.action { + t.Errorf("MatchNormalKey(%q) = %v, want %v", tt.key, got, tt.action) + } + } +} + +func TestMatchLeaderKey(t *testing.T) { + tests := []struct { + key string + action Action + }{ + {"p", ActionLeaderPalette}, + {"n", ActionLeaderNewSession}, + {"s", ActionLeaderSwitchSession}, + {"h", ActionLeaderHelp}, + {"m", ActionLeaderToggleMode}, + {"f", ActionLeaderFullAccess}, + {"l", ActionLeaderLog}, + {"c", ActionLeaderCompact}, + {"q", ActionLeaderQuit}, + {"a", ActionNone}, + {"j", ActionNone}, + } + for _, tt := range tests { + got := MatchLeaderKey(tt.key) + if got != tt.action { + t.Errorf("MatchLeaderKey(%q) = %v, want %v", tt.key, got, tt.action) + } + } +} + +func TestIsLeaderSuffix(t *testing.T) { + if !IsLeaderSuffix("p") { + t.Error("IsLeaderSuffix(\"p\") = false, want true") + } + if !IsLeaderSuffix("q") { + t.Error("IsLeaderSuffix(\"q\") = false, want true") + } + if IsLeaderSuffix("a") { + t.Error("IsLeaderSuffix(\"a\") = true, want false") + } +} + +func TestFullHelpContainsAllGroups(t *testing.T) { + groups := FullHelp() + if len(groups) < 4 { + t.Errorf("FullHelp() returned %d groups, want at least 4", len(groups)) + } + titles := make(map[string]bool) + for _, g := range groups { + titles[g.Title] = true + } + for _, want := range []string{"Input Mode", "Normal Mode (Esc)", "Leader (Space)", "Navigation"} { + if !titles[want] { + t.Errorf("FullHelp() missing group %q", want) + } + } +} diff --git a/internal/tuiv2/state/reducer.go b/internal/tuiv2/state/reducer.go index f6277b3ef..bd92d91c6 100644 --- a/internal/tuiv2/state/reducer.go +++ b/internal/tuiv2/state/reducer.go @@ -33,6 +33,14 @@ func Reduce(current *ViewState, event gateway.GatewayEvent) *ViewState { return appendStream(next, streamEntry(event, "status", payloadString(event.Payload, "message", "decision", "status"))) case gateway.EventAskUserQuestion, gateway.EventUserQuestionRequested: return reduceAskUserQuestion(next, event) + case gateway.EventUserQuestionAnswered: + next.Runtime.Phase = RuntimePhaseRunning + next.Input.Mode = InputStateModeMessage + next.Input.Text = "" + next.Input.Cursor = 0 + next.Input.Prompt = "" + next.Input.Options = nil + return appendStream(next, streamEntry(event, "status", payloadString(event.Payload, "message", "answer", "text"))) case gateway.EventPhaseChanged: next.Runtime.Phase = payloadString(event.Payload, "phase", "status") case gateway.EventRunStarted: @@ -82,6 +90,9 @@ func reduceAgentChunk(next *ViewState, event gateway.GatewayEvent) *ViewState { updated.Content += text updated.Metadata = cloneMetadata(last.Metadata) updated.Metadata["done"] = false + if _, ok := updated.Metadata["role"].(string); !ok { + updated.Metadata["role"] = "assistant" + } stream := append([]StreamEntry(nil), next.Stream[:len(next.Stream)-1]...) stream = append(stream, updated) next.Stream = stream @@ -90,6 +101,7 @@ func reduceAgentChunk(next *ViewState, event gateway.GatewayEvent) *ViewState { } entry := streamEntry(event, "message", text) entry.Metadata["done"] = false + entry.Metadata["role"] = "assistant" return appendStream(next, entry) } @@ -156,6 +168,7 @@ func cloneViewState(current *ViewState) *ViewState { } next.Stream = append([]StreamEntry(nil), current.Stream...) next.Input.Options = append([]string(nil), current.Input.Options...) + next.Input.History = append([]string(nil), current.Input.History...) return &next } @@ -167,12 +180,18 @@ func appendStream(next *ViewState, entry StreamEntry) *ViewState { // streamEntry 将 Gateway 事件转换为 StreamEntry 的通用构造。 func streamEntry(event gateway.GatewayEvent, entryType string, content string) StreamEntry { + metadata := clonePayload(event.Payload) + if entryType == "message" { + if _, ok := metadata["role"].(string); !ok { + metadata["role"] = "assistant" + } + } return StreamEntry{ ID: payloadString(event.Payload, "id", "entry_id", "message_id"), Type: entryType, Timestamp: eventTime(event.At), Content: content, - Metadata: clonePayload(event.Payload), + Metadata: metadata, } } diff --git a/internal/tuiv2/state/viewstate.go b/internal/tuiv2/state/viewstate.go index 814bc228b..edf02fd7e 100644 --- a/internal/tuiv2/state/viewstate.go +++ b/internal/tuiv2/state/viewstate.go @@ -15,6 +15,14 @@ type ViewState struct { Input InputState Layout LayoutState Mode InputMode + Overlay OverlayState +} + +// OverlayState 描述当前浮层显示状态。 +type OverlayState struct { + Active string // "", "palette", "help", "session_picker", "confirm" + Query string // 搜索文本 + Selected int // 当前选中索引 } // GatewayState 描述 Gateway 连接、会话和模型选择状态。 @@ -53,11 +61,14 @@ type StreamEntry struct { // InputState 描述输入区文本、光标和当前输入任务。 type InputState struct { - Text string - Cursor int - Mode string - Prompt string - Options []string + Text string + Cursor int + Mode string + Prompt string + Options []string + History []string + HistoryIndex int + CursorVisible bool } // LayoutState 描述终端布局尺寸和 Soft Inspector 显示状态。 @@ -112,7 +123,7 @@ const ( func NewViewState() *ViewState { return &ViewState{ Runtime: RuntimeState{Phase: RuntimePhaseIdle}, - Input: InputState{Mode: InputStateModeMessage}, + Input: InputState{Mode: InputStateModeMessage, HistoryIndex: -1, CursorVisible: true}, Layout: LayoutState{AutoScroll: true}, Mode: InputModeInput, } diff --git a/internal/tuiv2/theme/styles.go b/internal/tuiv2/theme/styles.go index 09bd03aad..bdd4210a9 100644 --- a/internal/tuiv2/theme/styles.go +++ b/internal/tuiv2/theme/styles.go @@ -57,6 +57,11 @@ func CodeBlockStyle() lipgloss.Style { return BaseStyle().Foreground(TokyoNight.CodeBlock) } +// InfoStyle 返回信息/用户消息样式(Cyan)。 +func InfoStyle() lipgloss.Style { + return BaseStyle().Foreground(TokyoNight.Info) +} + // TimestampStyle 返回时间戳样式。 func TimestampStyle() lipgloss.Style { return BaseStyle().Foreground(TokyoNight.Subtle) From d837f0f3427fa95d6eb91d66edaa793ab18a541f Mon Sep 17 00:00:00 2001 From: pionxe Date: Mon, 8 Jun 2026 11:27:26 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(tui-v2):=20=E8=A1=A5=E5=85=A8=20Phase?= =?UTF-8?q?=209=20=E4=BA=A4=E4=BA=92=E5=A2=9E=E5=BC=BA=20=E2=80=94=20Leade?= =?UTF-8?q?r=20=E9=94=AE/=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9=E5=99=A8/?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=BC=B9=E7=AA=97/=E9=BC=A0=E6=A0=87/?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自审发现 22 项设计规范差距,本次修复 P0+P1 共 16 项: P0 修复(规范强制要求): 1. Space n → 新建会话(createSessionCmd + sessionCreatedMsg) 2. Space c → 手动 compact(status 提示,待后端 RPC) 3. Space m → 切换 build/plan(RuntimeState.AgentMode) 4. Space f → 切换 Full Access(RuntimeState.FullAccess) 5. Space l → 日志查看预留(status 提示) 6. Normal Mode ? → 打开帮助浮层(原 ActionSearchBackward 重映射) 7. Ctrl+L → 日志查看预留(新增 ActionLogViewer) 8. /model 命令 + 模型选择器浮层(components/model_picker.go) 9. 危险操作确认弹窗(components/confirm.go) P1 修复(体验增强): 10. Palette 鼠标点击执行选项(MouseButtonLeft + MouseActionPress) 11. Session Picker 鼠标点击选择会话 12. Palette 鼠标滚轮滚动列表 13. Session Picker 鼠标滚轮滚动列表 14. /mode /compact /clear 命令路由补齐(palette + slash) 15. /model slash 命令路由 16. Normal Mode : Ex 命令预留位 新增文件: - components/model_picker.go — Telescope 风格模型选择器 - components/confirm.go — 危险操作确认弹窗 修改文件: - app.go — Leader handler 补全、新组件注册、消息路由 - state/viewstate.go — 新增 AgentMode/FullAccess/ConfirmState - keymap/keys.go — 新增 ActionLogViewer、NormalHelp ? 条目 - palette.go — 重构 Update 为 handleKey/handleMouse,鼠标支持 - session_picker.go — 同上重构,鼠标支持,删除走确认弹窗 Co-Authored-By: Claude Opus 4.8 --- internal/tuiv2/app.go | 298 ++++++++++++++++++-- internal/tuiv2/components/confirm.go | 90 ++++++ internal/tuiv2/components/help.go | 14 +- internal/tuiv2/components/model_picker.go | 241 ++++++++++++++++ internal/tuiv2/components/palette.go | 78 ++++- internal/tuiv2/components/session_picker.go | 82 ++++-- internal/tuiv2/fakegateway/fake_test.go | 9 +- internal/tuiv2/fakegateway/fixtures.go | 51 ++-- internal/tuiv2/fakegateway/scenarios.go | 18 +- internal/tuiv2/keymap/keys.go | 5 +- internal/tuiv2/state/viewstate.go | 19 +- 11 files changed, 823 insertions(+), 82 deletions(-) create mode 100644 internal/tuiv2/components/confirm.go create mode 100644 internal/tuiv2/components/model_picker.go diff --git a/internal/tuiv2/app.go b/internal/tuiv2/app.go index 9296bf3db..d93e1c035 100644 --- a/internal/tuiv2/app.go +++ b/internal/tuiv2/app.go @@ -46,13 +46,15 @@ type App struct { // Ctrl+C 双退保护 lastCtrlC time.Time - ambientStatus *components.AmbientStatus - agentStream *components.AgentStream - commandPrompt *components.CommandPrompt - softInspector *components.SoftInspector - palette *components.Palette - helpOverlay *components.HelpOverlay - sessionPicker *components.SessionPicker + ambientStatus *components.AmbientStatus + agentStream *components.AgentStream + commandPrompt *components.CommandPrompt + softInspector *components.SoftInspector + palette *components.Palette + helpOverlay *components.HelpOverlay + sessionPicker *components.SessionPicker + modelPicker *components.ModelPicker + confirmOverlay *components.ConfirmOverlay } var _ tea.Model = (*App)(nil) @@ -61,18 +63,20 @@ var _ tea.Model = (*App)(nil) func NewApp(cfg StartupConfig) tea.Model { viewState := state.NewViewState() return &App{ - client: cfg.Client, - state: viewState, - debug: cfg.Debug, - backend: cfg.Backend, - scenario: cfg.Scenario, - ambientStatus: components.NewAmbientStatus(viewState), - agentStream: components.NewAgentStream(viewState), - commandPrompt: components.NewCommandPrompt(viewState), - softInspector: components.NewSoftInspector(viewState), - palette: components.NewPalette(viewState), - helpOverlay: components.NewHelpOverlay(viewState), - sessionPicker: components.NewSessionPicker(viewState), + client: cfg.Client, + state: viewState, + debug: cfg.Debug, + backend: cfg.Backend, + scenario: cfg.Scenario, + ambientStatus: components.NewAmbientStatus(viewState), + agentStream: components.NewAgentStream(viewState), + commandPrompt: components.NewCommandPrompt(viewState), + softInspector: components.NewSoftInspector(viewState), + palette: components.NewPalette(viewState), + helpOverlay: components.NewHelpOverlay(viewState), + sessionPicker: components.NewSessionPicker(viewState), + modelPicker: components.NewModelPicker(viewState), + confirmOverlay: components.NewConfirmOverlay(viewState), } } @@ -132,6 +136,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.handleSessionSelect(msg) case components.SessionDeleteMsg: return a, a.handleSessionDelete(msg) + case components.ModelSelectMsg: + return a, a.handleModelSelect(msg) + case components.ConfirmYesMsg: + return a, a.handleConfirmYes(msg) + case components.ConfirmNoMsg: + a.state.Confirm = state.ConfirmState{} + return a, nil case leaderTimeoutMsg: if a.state.Mode == state.LeaderMode { a.state.Mode = state.NormalMode @@ -155,6 +166,8 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, waitEventCmd(a.eventCh) } return a, nil + case sessionCreatedMsg: + return a, a.handleSessionCreated(msg) } return a, a.routeComponents(msg) } @@ -169,6 +182,10 @@ func (a *App) View() string { return a.fitViewToTerminal(a.helpOverlay.View()) case "session_picker": return a.fitViewToTerminal(a.sessionPicker.View()) + case "model_picker": + return a.fitViewToTerminal(a.modelPicker.View()) + case "confirm": + return a.fitViewToTerminal(a.confirmOverlay.View()) } lines := []string{ a.ambientStatus.View(), @@ -231,6 +248,9 @@ func (a *App) handleMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) { case "session_picker": _, cmd := a.sessionPicker.Update(msg) return a, cmd + case "model_picker": + _, cmd := a.modelPicker.Update(msg) + return a, cmd } // 主视图下,滚轮事件交给 Agent Stream switch msg.Type { @@ -248,6 +268,15 @@ func (a *App) handleMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // handleKeyMsg 根据当前模式分发键盘消息。 func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Esc always closes any active overlay first (global escape hatch) + if a.state.Overlay.Active != "" { + if msg.String() == "esc" { + a.state.Overlay.Active = "" + a.state.Overlay.Query = "" + a.state.Overlay.Selected = 0 + return a, nil + } + } // 浮层激活时,键盘消息交给对应浮层组件处理 switch a.state.Overlay.Active { case "palette": @@ -259,6 +288,12 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "session_picker": _, cmd := a.sessionPicker.Update(msg) return a, cmd + case "model_picker": + _, cmd := a.modelPicker.Update(msg) + return a, cmd + case "confirm": + _, cmd := a.confirmOverlay.Update(msg) + return a, cmd } switch a.state.Mode { case state.LeaderMode: @@ -282,6 +317,15 @@ func (a *App) handleInputModeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case keymap.ActionOpenPalette: a.openOverlay("palette") return a, nil + case keymap.ActionLogViewer: + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("log-hint-%d", time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: "Log viewer not yet available", + Metadata: map[string]any{"done": true}, + }) + return a, nil default: _, promptCmd := a.commandPrompt.Update(msg) return a, promptCmd @@ -310,7 +354,13 @@ func (a *App) handleNormalModeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case keymap.ActionQuit: return a, tea.Quit case keymap.ActionSearchForward: - // Phase 9b/9c 会实现搜索,此处预留 + // 搜索功能后续 Phase 实现,此处预留 + return a, nil + case keymap.ActionSearchBackward: + a.openOverlay("help") + return a, nil + case keymap.ActionExCommand: + // Ex 命令行后续 Phase 实现,此处预留 return a, nil default: _, promptCmd := a.commandPrompt.Update(msg) @@ -339,6 +389,26 @@ func (a *App) handleLeaderKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case keymap.ActionLeaderSwitchSession: a.openOverlay("session_picker") return a, nil + case keymap.ActionLeaderNewSession: + if a.client != nil { + return a, createSessionCmd(a.client) + } + return a, nil + case keymap.ActionLeaderToggleMode: + return a, a.toggleAgentMode() + case keymap.ActionLeaderFullAccess: + return a, a.toggleFullAccess() + case keymap.ActionLeaderLog: + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("log-hint-%d", time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: "Log viewer not yet available", + Metadata: map[string]any{"done": true}, + }) + return a, nil + case keymap.ActionLeaderCompact: + return a, a.triggerCompact() default: return a, nil } @@ -383,7 +453,18 @@ func leaderTimeoutCmd() tea.Cmd { // handleSubmitMessage 将用户输入交给 GatewayClient,并让后续 ACK 以事件形式回到 reducer。 func (a *App) handleSubmitMessage(msg components.SubmitMessageMsg) tea.Cmd { - if a.client == nil || strings.TrimSpace(msg.Text) == "" { + if strings.TrimSpace(msg.Text) == "" { + return nil + } + // 立即将用户消息追加到 Stream 中以便渲染 + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("user-msg-%d", time.Now().UnixNano()), + Type: "message", + Timestamp: time.Now(), + Content: msg.Text, + Metadata: map[string]any{"role": "user", "done": true}, + }) + if a.client == nil { return nil } sessionID := a.activeSessionID() @@ -452,6 +533,17 @@ func (a *App) handlePaletteCommand(msg components.PaletteCommandMsg) tea.Cmd { case "/session": a.openOverlay("session_picker") return nil + case "/model": + a.openOverlay("model_picker") + return nil + case "/mode": + return a.toggleAgentMode() + case "/compact": + return a.triggerCompact() + case "/clear": + a.state.Stream = nil + a.bindComponents() + return nil default: a.appendStream(state.StreamEntry{ ID: fmt.Sprintf("cmd-%s-%d", msg.Name, time.Now().UnixNano()), @@ -475,6 +567,13 @@ func (a *App) handleSlashCommand(msg components.SlashCommandMsg) tea.Cmd { case "/session": a.openOverlay("session_picker") return nil + case "/model": + a.openOverlay("model_picker") + return nil + case "/mode": + return a.toggleAgentMode() + case "/compact": + return a.triggerCompact() case "/clear": a.state.Stream = nil a.bindComponents() @@ -500,12 +599,102 @@ func (a *App) handleSessionSelect(msg components.SessionSelectMsg) tea.Cmd { return loadSessionCmd(a.client, msg.Session.ID) } -// handleSessionDelete 处理会话删除操作。 +// handleSessionDelete 通过确认弹窗处理会话删除操作。 func (a *App) handleSessionDelete(msg components.SessionDeleteMsg) tea.Cmd { if a.client == nil { return nil } - return deleteSessionCmd(a.client, msg.SessionID) + a.openConfirm( + "Delete Session", + fmt.Sprintf("Are you sure you want to delete this session?"), + "delete_session", + map[string]any{"session_id": msg.SessionID}, + ) + return nil +} + +// handleSessionCreated 处理新会话创建完成。 +func (a *App) handleSessionCreated(msg sessionCreatedMsg) tea.Cmd { + if msg.err != nil { + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("session-err-%d", time.Now().UnixNano()), + Type: "error", + Timestamp: time.Now(), + Content: fmt.Sprintf("Failed to create session: %s", msg.err), + Metadata: map[string]any{"done": true}, + }) + return nil + } + if msg.Session != nil { + a.state.Gateway.ActiveSess = msg.Session + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("session-created-%d", time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: fmt.Sprintf("New session created: %s", msg.Session.Title), + Metadata: map[string]any{"done": true}, + }) + if a.client != nil { + return loadSessionCmd(a.client, msg.Session.ID) + } + } + return nil +} + +// handleModelSelect 处理模型切换操作。 +func (a *App) handleModelSelect(msg components.ModelSelectMsg) tea.Cmd { + if a.client != nil { + sessionID := a.activeSessionID() + if err := a.client.SetModel(context.Background(), sessionID, msg.ModelID); err != nil { + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("model-err-%d", time.Now().UnixNano()), + Type: "error", + Timestamp: time.Now(), + Content: fmt.Sprintf("Failed to switch model: %s", err), + Metadata: map[string]any{"done": true}, + }) + return nil + } + } + if a.state.Gateway.ActiveSess != nil { + a.state.Gateway.ActiveSess.Model = msg.ModelID + } + a.state.Gateway.ActiveModel = msg.ModelID + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("model-switch-%d", time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: fmt.Sprintf("Model switched to %s", msg.ModelName), + Metadata: map[string]any{"done": true}, + }) + return nil +} + +// handleConfirmYes 处理确认弹窗的确认操作。 +func (a *App) handleConfirmYes(msg components.ConfirmYesMsg) tea.Cmd { + confirm := a.state.Confirm + a.state.Confirm = state.ConfirmState{} + switch confirm.Action { + case "delete_session": + sessionID, _ := confirm.Data["session_id"].(string) + if sessionID != "" && a.client != nil { + return deleteSessionCmd(a.client, sessionID) + } + } + return nil +} + +// openConfirm 打开确认弹窗。 +func (a *App) openConfirm(title, message, action string, data map[string]any) { + a.state.Confirm = state.ConfirmState{ + Title: title, + Message: message, + Action: action, + Data: data, + } + a.state.Overlay.Active = "confirm" + a.state.Overlay.Query = "" + a.state.Overlay.Selected = 0 } // activeSessionID 返回当前会话 ID,缺失时使用空字符串让 GatewayClient 自行决定错误语义。 @@ -619,6 +808,52 @@ func (a *App) bindComponents() { a.softInspector = components.NewSoftInspector(a.state) } +// toggleAgentMode 切换 Agent 模式 (build/plan) 并追加状态提示。 +func (a *App) toggleAgentMode() tea.Cmd { + mode := "plan" + if a.state.Runtime.AgentMode == "plan" || a.state.Runtime.AgentMode == "" { + mode = "build" + } + a.state.Runtime.AgentMode = mode + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("mode-toggle-%d", time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: fmt.Sprintf("Agent mode: %s", mode), + Metadata: map[string]any{"done": true}, + }) + return nil +} + +// toggleFullAccess 切换 Full Access 模式并追加状态提示。 +func (a *App) toggleFullAccess() tea.Cmd { + a.state.Runtime.FullAccess = !a.state.Runtime.FullAccess + label := "off" + if a.state.Runtime.FullAccess { + label = "on" + } + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("access-toggle-%d", time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: fmt.Sprintf("Full access: %s", label), + Metadata: map[string]any{"done": true}, + }) + return nil +} + +// triggerCompact 触发手动 compact 并追加状态提示。 +func (a *App) triggerCompact() tea.Cmd { + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("compact-%d", time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: "Compact triggered", + Metadata: map[string]any{"done": true}, + }) + return nil +} + // debugLine 渲染调试模式下的最小运行信息。 func (a *App) debugLine() string { size := defaultTerminal @@ -851,3 +1086,20 @@ type sessionSwitchedMsg struct { detail *gateway.SessionDetail eventCh <-chan gateway.GatewayEvent } + +// sessionCreatedMsg 表示新会话创建完成。 +type sessionCreatedMsg struct { + Session *gateway.SessionSummary + err error +} + +// createSessionCmd 通过 GatewayClient 创建新会话。 +func createSessionCmd(client gateway.Client) tea.Cmd { + return func() tea.Msg { + summary, err := client.CreateSession(context.Background()) + if err != nil { + return sessionCreatedMsg{err: err} + } + return sessionCreatedMsg{Session: summary} + } +} diff --git a/internal/tuiv2/components/confirm.go b/internal/tuiv2/components/confirm.go new file mode 100644 index 000000000..f5ec803c5 --- /dev/null +++ b/internal/tuiv2/components/confirm.go @@ -0,0 +1,90 @@ +package components + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "neo-code/internal/tuiv2/state" + "neo-code/internal/tuiv2/theme" +) + +// ConfirmYesMsg 表示用户确认了危险操作。 +type ConfirmYesMsg struct{} + +// ConfirmNoMsg 表示用户取消了危险操作。 +type ConfirmNoMsg struct{} + +// ConfirmOverlay 是危险操作确认弹窗组件。 +type ConfirmOverlay struct { + state *state.ViewState +} + +var _ tea.Model = (*ConfirmOverlay)(nil) + +// NewConfirmOverlay 创建确认弹窗组件。 +func NewConfirmOverlay(viewState *state.ViewState) *ConfirmOverlay { + return &ConfirmOverlay{state: viewState} +} + +// Init 不启动额外命令。 +func (c *ConfirmOverlay) Init() tea.Cmd { + return nil +} + +// Update 处理确认弹窗的键盘输入。 +func (c *ConfirmOverlay) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return c, nil + } + switch key.String() { + case "y", "enter": + return c, func() tea.Msg { return ConfirmYesMsg{} } + case "n", "esc", "ctrl+c": + return c, func() tea.Msg { return ConfirmNoMsg{} } + } + return c, nil +} + +// View 渲染确认弹窗。 +func (c *ConfirmOverlay) View() string { + width := c.state.Layout.Width + height := c.state.Layout.Height + if width <= 0 { + width = 60 + } + if height <= 0 { + height = 24 + } + + boxW := min(width-4, 48) + + confirm := c.state.Confirm + + var lines []string + lines = append(lines, theme.WarningStyle().Bold(true).Render(" "+confirm.Title)) + lines = append(lines, "") + lines = append(lines, " "+confirm.Message) + lines = append(lines, "") + lines = append(lines, theme.MutedStyle().Render(" [Y] confirm [N] cancel")) + + content := strings.Join(lines, "\n") + box := lipgloss.NewStyle(). + Width(boxW). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("196")). + Padding(0, 1). + Render(content) + + boxH := min(height-2, 14) + if boxH < 6 { + boxH = 6 + } + return lipgloss.NewStyle(). + Width(width). + Height(boxH). + Align(lipgloss.Center, lipgloss.Center). + Render(box) +} diff --git a/internal/tuiv2/components/help.go b/internal/tuiv2/components/help.go index 11cac2733..b89a10597 100644 --- a/internal/tuiv2/components/help.go +++ b/internal/tuiv2/components/help.go @@ -74,6 +74,15 @@ func (h *HelpOverlay) View() string { lines = append(lines, "") } + // 约束内容高度到终端限制 + maxContentLines := height - 5 // border + padding overhead + if maxContentLines < 4 { + maxContentLines = 4 + } + if len(lines) > maxContentLines { + lines = lines[:maxContentLines] + } + hint := theme.MutedStyle().Render(" ␛ : close") lines = append(lines, hint) @@ -85,7 +94,10 @@ func (h *HelpOverlay) View() string { Padding(0, 1). Render(content) - boxH := min(height-2, 28) + boxH := height - 2 + if boxH < 6 { + boxH = 6 + } return lipgloss.NewStyle(). Width(width). Height(boxH). diff --git a/internal/tuiv2/components/model_picker.go b/internal/tuiv2/components/model_picker.go new file mode 100644 index 000000000..2abd10a7f --- /dev/null +++ b/internal/tuiv2/components/model_picker.go @@ -0,0 +1,241 @@ +package components + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "neo-code/internal/tuiv2/state" + "neo-code/internal/tuiv2/theme" + + "github.com/sahilm/fuzzy" +) + +// ModelSelectMsg 表示用户选择了某个模型。 +type ModelSelectMsg struct { + ModelID string + ModelName string +} + +// ModelPicker 是 Telescope 风格的模型选择器组件。 +type ModelPicker struct { + state *state.ViewState +} + +var _ tea.Model = (*ModelPicker)(nil) + +// NewModelPicker 创建模型选择器组件。 +func NewModelPicker(viewState *state.ViewState) *ModelPicker { + return &ModelPicker{state: viewState} +} + +// Init 不启动额外命令。 +func (m *ModelPicker) Init() tea.Cmd { + return nil +} + +// Update 处理模型选择器内的键盘和鼠标输入。 +func (m *ModelPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m, m.handleKey(msg) + case tea.MouseMsg: + return m, m.handleMouse(msg) + } + return m, nil +} + +func (m *ModelPicker) handleKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "esc", "ctrl+c": + m.state.Overlay.Active = "" + m.state.Overlay.Query = "" + m.state.Overlay.Selected = 0 + return nil + case "enter": + matched := m.matchedModels() + if len(matched) == 0 { + return nil + } + idx := m.state.Overlay.Selected + if idx >= len(matched) { + idx = len(matched) - 1 + } + selected := matched[idx] + m.state.Overlay.Active = "" + m.state.Overlay.Query = "" + m.state.Overlay.Selected = 0 + return func() tea.Msg { + return ModelSelectMsg{ModelID: selected.ID, ModelName: selected.Name} + } + case "up", "k": + if m.state.Overlay.Selected > 0 { + m.state.Overlay.Selected-- + } + return nil + case "down", "j": + matched := m.matchedModels() + if m.state.Overlay.Selected < len(matched)-1 { + m.state.Overlay.Selected++ + } + return nil + case "backspace": + if len(m.state.Overlay.Query) > 0 { + m.state.Overlay.Query = m.state.Overlay.Query[:len(m.state.Overlay.Query)-1] + m.state.Overlay.Selected = 0 + } + return nil + default: + runes := msg.Runes + if len(runes) > 0 && runes[0] >= 32 { + m.state.Overlay.Query += string(runes) + m.state.Overlay.Selected = 0 + } + return nil + } +} + +func (m *ModelPicker) handleMouse(msg tea.MouseMsg) tea.Cmd { + switch msg.Button { + case tea.MouseButtonWheelUp: + if m.state.Overlay.Selected > 0 { + m.state.Overlay.Selected-- + } + return nil + case tea.MouseButtonWheelDown: + matched := m.matchedModels() + if m.state.Overlay.Selected < len(matched)-1 { + m.state.Overlay.Selected++ + } + return nil + case tea.MouseButtonLeft: + if msg.Action != tea.MouseActionPress { + return nil + } + // 标题 + 空行 + query + 空行 = 4 行头部 + itemIdx := msg.Y - 4 + matched := m.matchedModels() + if itemIdx >= 0 && itemIdx < len(matched) { + selected := matched[itemIdx] + m.state.Overlay.Active = "" + m.state.Overlay.Query = "" + m.state.Overlay.Selected = 0 + return func() tea.Msg { + return ModelSelectMsg{ModelID: selected.ID, ModelName: selected.Name} + } + } + return nil + } + return nil +} + +// matchedModels 根据当前查询模糊匹配模型列表。 +func (m *ModelPicker) matchedModels() []modelEntry { + models := m.state.Gateway.Models + query := strings.ToLower(m.state.Overlay.Query) + if query == "" { + result := make([]modelEntry, len(models)) + for i, mod := range models { + result[i] = modelEntry{ID: mod.ID, Name: mod.Name, Provider: mod.Provider, Current: mod.Current} + } + return result + } + targets := make([]string, len(models)) + for i, mod := range models { + targets[i] = strings.ToLower(mod.Name) + " " + strings.ToLower(mod.ID) + " " + strings.ToLower(mod.Provider) + } + matches := fuzzy.Find(query, targets) + result := make([]modelEntry, 0, len(matches)) + for _, match := range matches { + mod := models[match.Index] + result = append(result, modelEntry{ID: mod.ID, Name: mod.Name, Provider: mod.Provider, Current: mod.Current}) + } + return result +} + +type modelEntry struct { + ID string + Name string + Provider string + Current bool +} + +// View 渲染 Telescope 风格的模型选择器浮层。 +func (m *ModelPicker) View() string { + width := m.state.Layout.Width + height := m.state.Layout.Height + if width <= 0 { + width = 60 + } + if height <= 0 { + height = 24 + } + + matched := m.matchedModels() + boxW := min(width-4, 56) + boxH := height - 4 + if boxH < 8 { + boxH = 8 + } + maxItems := boxH - 5 // title + query + hint + padding + if maxItems < 1 { + maxItems = 1 + } + + var lines []string + + // 标题 + lines = append(lines, theme.AccentStyle().Render(" Models")) + lines = append(lines, "") + + // 搜索输入 + queryLine := "> " + m.state.Overlay.Query + lines = append(lines, theme.AccentStyle().Render(queryLine), "") + + // 模型列表 + for i, mod := range matched { + if i >= maxItems { + break + } + prefix := " " + name := mod.Name + detail := theme.MutedStyle().Render(mod.Provider) + if mod.Current { + detail = theme.SuccessStyle().Render("● current") + } + + if i == m.state.Overlay.Selected { + prefix = theme.AccentStyle().Render("▎ ") + name = theme.AccentStyle().Bold(true).Render(name) + } + line := prefix + name + " " + detail + if dw := theme.DisplayWidth(line); dw > boxW-4 { + line = theme.Truncate(line, boxW-4) + } + lines = append(lines, line) + } + + if len(matched) == 0 { + lines = append(lines, theme.MutedStyle().Render(" No models found")) + } + + // 提示行 + hint := " ⏎ : select ␛ : cancel" + lines = append(lines, "", theme.MutedStyle().Render(hint)) + + content := strings.Join(lines, "\n") + box := lipgloss.NewStyle(). + Width(boxW). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(0, 1). + Render(content) + + outerH := max(boxH, 10) + return lipgloss.NewStyle(). + Width(width). + Height(outerH). + Align(lipgloss.Center, lipgloss.Center). + Render(box) +} diff --git a/internal/tuiv2/components/palette.go b/internal/tuiv2/components/palette.go index 3ca9eadcd..b73519868 100644 --- a/internal/tuiv2/components/palette.go +++ b/internal/tuiv2/components/palette.go @@ -55,22 +55,28 @@ func (p *Palette) Init() tea.Cmd { return nil } -// Update 处理命令面板内的键盘和导航。 +// Update 处理命令面板内的键盘、鼠标和导航。 func (p *Palette) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - key, ok := msg.(tea.KeyMsg) - if !ok { - return p, nil + switch msg := msg.(type) { + case tea.KeyMsg: + return p, p.handleKey(msg) + case tea.MouseMsg: + return p, p.handleMouse(msg) } - switch key.String() { + return p, nil +} + +func (p *Palette) handleKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { case "esc", "ctrl+c": p.state.Overlay.Active = "" p.state.Overlay.Query = "" p.state.Overlay.Selected = 0 - return p, nil + return nil case "enter": matched := p.matchedItems() if len(matched) == 0 { - return p, nil + return nil } idx := p.state.Overlay.Selected if idx >= len(matched) { @@ -80,33 +86,33 @@ func (p *Palette) Update(msg tea.Msg) (tea.Model, tea.Cmd) { p.state.Overlay.Active = "" p.state.Overlay.Query = "" p.state.Overlay.Selected = 0 - return p, func() tea.Msg { + return func() tea.Msg { return PaletteCommandMsg{Name: selected.Name} } case "up", "k": if p.state.Overlay.Selected > 0 { p.state.Overlay.Selected-- } - return p, nil + return nil case "down", "j": matched := p.matchedItems() if p.state.Overlay.Selected < len(matched)-1 { p.state.Overlay.Selected++ } - return p, nil + return nil case "backspace": if len(p.state.Overlay.Query) > 0 { p.state.Overlay.Query = p.state.Overlay.Query[:len(p.state.Overlay.Query)-1] p.state.Overlay.Selected = 0 } - return p, nil + return nil default: - runes := key.Runes + runes := msg.Runes if len(runes) > 0 && runes[0] >= 32 { p.state.Overlay.Query += string(runes) p.state.Overlay.Selected = 0 } - return p, nil + return nil } } @@ -128,6 +134,41 @@ func (p *Palette) matchedItems() []PaletteItem { return result } +// handleMouse 处理鼠标滚轮和点击事件。 +func (p *Palette) handleMouse(msg tea.MouseMsg) tea.Cmd { + switch msg.Button { + case tea.MouseButtonWheelUp: + if p.state.Overlay.Selected > 0 { + p.state.Overlay.Selected-- + } + return nil + case tea.MouseButtonWheelDown: + matched := p.matchedItems() + if p.state.Overlay.Selected < len(matched)-1 { + p.state.Overlay.Selected++ + } + return nil + case tea.MouseButtonLeft: + if msg.Action != tea.MouseActionPress { + return nil + } + // query + 空行 = 2 行头部 + itemIdx := msg.Y - 2 + matched := p.matchedItems() + if itemIdx >= 0 && itemIdx < len(matched) { + selected := matched[itemIdx] + p.state.Overlay.Active = "" + p.state.Overlay.Query = "" + p.state.Overlay.Selected = 0 + return func() tea.Msg { + return PaletteCommandMsg{Name: selected.Name} + } + } + return nil + } + return nil +} + // View 渲染 Telescope 风格的命令面板。 func (p *Palette) View() string { width := p.state.Layout.Width @@ -141,7 +182,14 @@ func (p *Palette) View() string { matched := p.matchedItems() boxW := min(width-4, 60) - boxH := min(height-4, 20) + boxH := height - 4 + if boxH < 8 { + boxH = 8 + } + maxItems := boxH - 5 // title + query + hint + padding + if maxItems < 1 { + maxItems = 1 + } var lines []string @@ -152,7 +200,7 @@ func (p *Palette) View() string { // 选项列表 for i, item := range matched { - if len(lines) >= boxH-3 { + if i >= maxItems { break } prefix := " " diff --git a/internal/tuiv2/components/session_picker.go b/internal/tuiv2/components/session_picker.go index 1f5d8df8b..a2334ea60 100644 --- a/internal/tuiv2/components/session_picker.go +++ b/internal/tuiv2/components/session_picker.go @@ -41,22 +41,28 @@ func (s *SessionPicker) Init() tea.Cmd { return nil } -// Update 处理会话选择器内的键盘输入。 +// Update 处理会话选择器内的键盘和鼠标输入。 func (s *SessionPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - key, ok := msg.(tea.KeyMsg) - if !ok { - return s, nil + switch msg := msg.(type) { + case tea.KeyMsg: + return s, s.handleKey(msg) + case tea.MouseMsg: + return s, s.handleMouse(msg) } - switch key.String() { + return s, nil +} + +func (s *SessionPicker) handleKey(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { case "esc", "ctrl+c": s.state.Overlay.Active = "" s.state.Overlay.Query = "" s.state.Overlay.Selected = 0 - return s, nil + return nil case "enter", " ": matched := s.matchedSessions() if len(matched) == 0 { - return s, nil + return nil } idx := s.state.Overlay.Selected if idx >= len(matched) { @@ -66,13 +72,13 @@ func (s *SessionPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.state.Overlay.Active = "" s.state.Overlay.Query = "" s.state.Overlay.Selected = 0 - return s, func() tea.Msg { + return func() tea.Msg { return SessionSelectMsg{Session: selected} } case "ctrl+d": matched := s.matchedSessions() if len(matched) == 0 { - return s, nil + return nil } idx := s.state.Overlay.Selected if idx >= len(matched) { @@ -80,33 +86,33 @@ func (s *SessionPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } target := matched[idx] s.state.Overlay.Active = "" - return s, func() tea.Msg { + return func() tea.Msg { return SessionDeleteMsg{SessionID: target.ID} } case "up", "k": if s.state.Overlay.Selected > 0 { s.state.Overlay.Selected-- } - return s, nil + return nil case "down", "j": matched := s.matchedSessions() if s.state.Overlay.Selected < len(matched)-1 { s.state.Overlay.Selected++ } - return s, nil + return nil case "backspace": if len(s.state.Overlay.Query) > 0 { s.state.Overlay.Query = s.state.Overlay.Query[:len(s.state.Overlay.Query)-1] s.state.Overlay.Selected = 0 } - return s, nil + return nil default: - runes := key.Runes + runes := msg.Runes if len(runes) > 0 && runes[0] >= 32 { s.state.Overlay.Query += string(runes) s.state.Overlay.Selected = 0 } - return s, nil + return nil } } @@ -129,6 +135,41 @@ func (s *SessionPicker) matchedSessions() []gateway.SessionSummary { return result } +// handleMouse 处理鼠标滚轮和点击事件。 +func (s *SessionPicker) handleMouse(msg tea.MouseMsg) tea.Cmd { + switch msg.Button { + case tea.MouseButtonWheelUp: + if s.state.Overlay.Selected > 0 { + s.state.Overlay.Selected-- + } + return nil + case tea.MouseButtonWheelDown: + matched := s.matchedSessions() + if s.state.Overlay.Selected < len(matched)-1 { + s.state.Overlay.Selected++ + } + return nil + case tea.MouseButtonLeft: + if msg.Action != tea.MouseActionPress { + return nil + } + // 标题 + 空行 + query + 空行 = 4 行头部 + itemIdx := msg.Y - 4 + matched := s.matchedSessions() + if itemIdx >= 0 && itemIdx < len(matched) { + selected := matched[itemIdx] + s.state.Overlay.Active = "" + s.state.Overlay.Query = "" + s.state.Overlay.Selected = 0 + return func() tea.Msg { + return SessionSelectMsg{Session: selected} + } + } + return nil + } + return nil +} + // View 渲染会话选择器浮层。 func (s *SessionPicker) View() string { width := s.state.Layout.Width @@ -141,6 +182,14 @@ func (s *SessionPicker) View() string { } boxW := min(width-4, 56) + boxH := height - 4 + if boxH < 8 { + boxH = 8 + } + maxItems := boxH - 7 // title + query + hint + separator lines + if maxItems < 1 { + maxItems = 1 + } matched := s.matchedSessions() var lines []string @@ -155,7 +204,7 @@ func (s *SessionPicker) View() string { // 会话列表 for i, sess := range matched { - if len(lines) > height-6 { + if i >= maxItems { break } prefix := " " @@ -193,7 +242,6 @@ func (s *SessionPicker) View() string { Padding(0, 1). Render(content) - boxH := min(height-2, 22) return lipgloss.NewStyle(). Width(width). Height(boxH). diff --git a/internal/tuiv2/fakegateway/fake_test.go b/internal/tuiv2/fakegateway/fake_test.go index b3d24ff3a..6603c380e 100644 --- a/internal/tuiv2/fakegateway/fake_test.go +++ b/internal/tuiv2/fakegateway/fake_test.go @@ -107,11 +107,16 @@ func TestStreamingChatEventsArriveInOrder(t *testing.T) { if got[len(got)-1] != gateway.EventRunFinished { t.Fatalf("last event = %q, want %q", got[len(got)-1], gateway.EventRunFinished) } + // Verify we have some agent chunks in the middle (event types expanded in Phase 9) + hasChunks := false for _, eventType := range got[1 : len(got)-1] { - if eventType != gateway.EventAgentChunk { - t.Fatalf("middle event = %q, want %q", eventType, gateway.EventAgentChunk) + if eventType == gateway.EventAgentChunk { + hasChunks = true } } + if !hasChunks { + t.Fatalf("streaming events must include at least one agent_chunk") + } } func TestSlowGatewayAppliesRPCDelay(t *testing.T) { diff --git a/internal/tuiv2/fakegateway/fixtures.go b/internal/tuiv2/fakegateway/fixtures.go index 0166a5079..633f54e02 100644 --- a/internal/tuiv2/fakegateway/fixtures.go +++ b/internal/tuiv2/fakegateway/fixtures.go @@ -30,8 +30,10 @@ func defaultSessionSummary() gateway.SessionSummary { // defaultSessionDetail 返回包含基础对话历史的默认会话详情。 func defaultSessionDetail() gateway.SessionDetail { return detailWithStream([]gateway.StreamItem{ - userItem("msg-user-1", "Refactor the TUI without touching v1."), - assistantItem("msg-agent-1", "Plan accepted. I will keep all data behind the Gateway client contract."), + userItem("msg-user-1", "Build a Ghost Console TUI that does not touch v1."), + assistantItem("msg-agent-1", "Plan accepted. I will use the Gateway client contract and pure-function reducers for all state."), + userItem("msg-user-2", "What components make up the Focus-Only layout?"), + assistantItem("msg-agent-2", "The layout has three tiers: Ambient Status (top), Agent Stream (center), and Command Prompt (bottom). A Soft Inspector appears on the right when the terminal is wide enough."), }) } @@ -55,26 +57,43 @@ func defaultModels() []gateway.ModelInfo { // defaultEvents 返回默认场景的完整演示事件序列。 func defaultEvents() []scheduledEvent { return []scheduledEvent{ - {after: tick, event: event(gateway.EventRunStarted, defaultSessionID, defaultRunID, payload("phase", "running"))}, - {after: tick, event: event(gateway.EventAgentChunk, defaultSessionID, defaultRunID, payload("text", "Ghost Console ready."))}, - {after: tick, event: event(gateway.EventToolStarted, defaultSessionID, defaultRunID, payload("tool", "filesystem.read"))}, - {after: tick, event: event(gateway.EventToolFinished, defaultSessionID, defaultRunID, payload("tool", "filesystem.read", "status", "ok"))}, - {after: tick, event: event(gateway.EventRunFinished, defaultSessionID, defaultRunID, payload("tokens", 384))}, + {after: tick / 2, event: event(gateway.EventRunStarted, defaultSessionID, defaultRunID, payload("phase", "running"))}, + {after: tick, event: event(gateway.EventAgentMessageStart, defaultSessionID, defaultRunID, payload("message", "msg-1", "text", "Ghost Console ready."))}, + {after: tick, event: event(gateway.EventAgentChunk, defaultSessionID, defaultRunID, payload("text", "I have loaded the Ghost Console demo."))}, + {after: tick, event: event(gateway.EventAgentChunk, defaultSessionID, defaultRunID, payload("text", " The TUI v2 architecture uses pure-function reducers and unidirectional data flow."))}, + {after: tick, event: event(gateway.EventAgentMessageEnd, defaultSessionID, defaultRunID, payload("message_id", "msg-1"))}, + {after: tick, event: event(gateway.EventToolStarted, defaultSessionID, defaultRunID, payload("tool", "filesystem.read", "input", "internal/tuiv2/state/reducer.go"))}, + {after: tick, event: event(gateway.EventToolFinished, defaultSessionID, defaultRunID, payload("tool", "filesystem.read", "output", "Reducer handles 22 event types with pure-function mapping", "status", "ok"))}, + {after: tick, event: event(gateway.EventToolStarted, defaultSessionID, defaultRunID, payload("tool", "filesystem.grep", "input", "tea.Model impl"))}, + {after: tick, event: event(gateway.EventAgentChunk, defaultSessionID, defaultRunID, payload("text", "The layout follows Focus-Only design with 3-tier responsive breakpoints."))}, + {after: tick, event: event(gateway.EventAgentMessageEnd, defaultSessionID, defaultRunID, payload("message_id", "msg-2"))}, + {after: tick, event: event(gateway.EventToolFinished, defaultSessionID, defaultRunID, payload("tool", "filesystem.grep", "output", "Found AmbientStatus, AgentStream, CommandPrompt, SoftInspector", "status", "ok"))}, + {after: tick, event: event(gateway.EventTokenUsage, defaultSessionID, defaultRunID, payload("total", 384, "input", 100, "output", 284))}, + {after: tick, event: event(gateway.EventRunFinished, defaultSessionID, defaultRunID, payload("phase", "done"))}, } } // streamingEvents 返回逐块输出的助手流式事件。 func streamingEvents() []scheduledEvent { - parts := []string{"Streaming ", "chat ", "arrives ", "chunk ", "by ", "chunk."} - events := []scheduledEvent{{after: tick, event: event(gateway.EventRunStarted, defaultSessionID, defaultRunID, payload("phase", "streaming"))}} - for _, part := range parts { - events = append(events, scheduledEvent{ - after: tick, - event: event(gateway.EventAgentChunk, defaultSessionID, defaultRunID, payload("text", part)), - }) + return []scheduledEvent{ + {after: tick, event: event(gateway.EventRunStarted, defaultSessionID, defaultRunID, payload("phase", "streaming"))}, + {after: tick, event: event(gateway.EventAgentMessageStart, defaultSessionID, defaultRunID, payload("message", "stream-msg-1", "text", "Let me explain the Ghost Console architecture."))}, + {after: tick, event: event(gateway.EventAgentChunk, defaultSessionID, defaultRunID, payload("text", "The Ghost Console"))}, + {after: tick, event: event(gateway.EventAgentChunk, defaultSessionID, defaultRunID, payload("text", " is a terminal-native "))}, + {after: tick, event: event(gateway.EventAgentChunk, defaultSessionID, defaultRunID, payload("text", "design language for NeoCode. "))}, + {after: tick, event: event(gateway.EventAgentChunk, defaultSessionID, defaultRunID, payload("text", "It emphasizes whitespace, indentation, "))}, + {after: tick, event: event(gateway.EventAgentChunk, defaultSessionID, defaultRunID, payload("text", "and semantic symbols over heavy borders."))}, + {after: tick, event: event(gateway.EventAgentMessageEnd, defaultSessionID, defaultRunID, payload("message_id", "stream-msg-1"))}, + {after: tick, event: event(gateway.EventToolStarted, defaultSessionID, defaultRunID, payload("tool", "filesystem.grep", "input", "search NeoCode patterns"))}, + {after: tick, event: event(gateway.EventToolFinished, defaultSessionID, defaultRunID, payload("tool", "filesystem.grep", "output", "found 15 matches in 8 files", "status", "ok"))}, + {after: tick, event: event(gateway.EventToolStarted, defaultSessionID, defaultRunID, payload("tool", "filesystem.read", "input", "internal/tuiv2/app.go"))}, + {after: tick, event: event(gateway.EventAgentChunk, defaultSessionID, defaultRunID, payload("text", "Looking at the app structure, "))}, + {after: tick, event: event(gateway.EventAgentChunk, defaultSessionID, defaultRunID, payload("text", "I can see the Focus-Only layout is already implemented."))}, + {after: tick, event: event(gateway.EventAgentMessageEnd, defaultSessionID, defaultRunID, payload("message_id", "stream-msg-2"))}, + {after: tick, event: event(gateway.EventToolFinished, defaultSessionID, defaultRunID, payload("tool", "filesystem.read", "output", "530 lines, layout uses 3-tier responsive design", "status", "ok"))}, + {after: tick, event: event(gateway.EventTokenUsage, defaultSessionID, defaultRunID, payload("total", 580, "input", 120, "output", 460))}, + {after: tick, event: event(gateway.EventRunFinished, defaultSessionID, defaultRunID, payload("phase", "done"))}, } - events = append(events, scheduledEvent{after: tick, event: event(gateway.EventRunFinished, defaultSessionID, defaultRunID, payload("phase", "done"))}) - return events } // toolApprovalEvents 返回工具权限等待流程的事件序列。 diff --git a/internal/tuiv2/fakegateway/scenarios.go b/internal/tuiv2/fakegateway/scenarios.go index 56db4925d..ad51e7c85 100644 --- a/internal/tuiv2/fakegateway/scenarios.go +++ b/internal/tuiv2/fakegateway/scenarios.go @@ -86,7 +86,10 @@ func scenarioByName(name string) (fakeScenario, bool) { base.details = map[string]gateway.SessionDetail{} base.events = []scheduledEvent{} case ScenarioStreamingChat: - base.details[defaultSessionID] = detailWithStream([]gateway.StreamItem{userItem("stream-user", "Explain Ghost Console.")}) + base.details[defaultSessionID] = detailWithStream([]gateway.StreamItem{ + userItem("stream-user-1", "What is the Ghost Console design?"), + assistantItem("stream-agent-1", "Let me walk you through the Ghost Console architecture step by step."), + }) base.events = streamingEvents() case ScenarioToolApproval: base.events = toolApprovalEvents() @@ -133,12 +136,21 @@ func scenarioByName(name string) (fakeScenario, bool) { // baseScenario 构造所有场景共享的默认连接、会话和模型状态。 func baseScenario(name string) fakeScenario { summary := defaultSessionSummary() + sessions := []gateway.SessionSummary{ + summary, + {ID: "session-api-debug", Title: "API Debugging Session", Mode: "input", Model: defaultModelID, UpdatedAt: fixtureTime.Add(-30 * time.Minute)}, + {ID: "session-refactor", Title: "Refactor TUI Components", Mode: "input", Model: defaultModelID, UpdatedAt: fixtureTime.Add(-1 * time.Hour)}, + {ID: "session-deploy", Title: "Deploy Preparation", Mode: "input", Model: "neo-fake-fast", UpdatedAt: fixtureTime.Add(-2 * time.Hour)}, + } + details := detailsForSessions(sessions) + // Override the first session's detail with the richer default + details[summary.ID] = defaultSessionDetail() return fakeScenario{ name: name, health: gateway.HealthResult{OK: true, Status: "ok", Backend: "fake", Message: name}, rpcDelay: tick, - sessions: []gateway.SessionSummary{summary}, - details: map[string]gateway.SessionDetail{summary.ID: defaultSessionDetail()}, + sessions: sessions, + details: details, models: defaultModels(), currentModel: defaultModelID, sendAck: gateway.RunAck{SessionID: summary.ID, RunID: defaultRunID, Accepted: true, Message: "accepted"}, diff --git a/internal/tuiv2/keymap/keys.go b/internal/tuiv2/keymap/keys.go index 63e4ecbe2..109b2a9b7 100644 --- a/internal/tuiv2/keymap/keys.go +++ b/internal/tuiv2/keymap/keys.go @@ -18,6 +18,7 @@ const ( ActionHelp // ? ActionSlashMode // / (when input empty) ActionFileRef // @ (when input empty) + ActionLogViewer // Ctrl+L // Normal Mode actions ActionEnterInput // i @@ -162,6 +163,8 @@ func MatchInputKey(keyStr string) Action { return ActionCtrlC case "ctrl+p": return ActionOpenPalette + case "ctrl+l": + return ActionLogViewer } return ActionNone } @@ -193,7 +196,7 @@ func MatchNormalKey(keyStr string) Action { return ActionExCommand case "q": return ActionQuit - case " ": + case " ", "space": return ActionLeader case "ctrl+c": return ActionCtrlC diff --git a/internal/tuiv2/state/viewstate.go b/internal/tuiv2/state/viewstate.go index edf02fd7e..19f443cac 100644 --- a/internal/tuiv2/state/viewstate.go +++ b/internal/tuiv2/state/viewstate.go @@ -16,11 +16,12 @@ type ViewState struct { Layout LayoutState Mode InputMode Overlay OverlayState + Confirm ConfirmState } // OverlayState 描述当前浮层显示状态。 type OverlayState struct { - Active string // "", "palette", "help", "session_picker", "confirm" + Active string // "", "palette", "help", "session_picker", "model_picker", "confirm" Query string // 搜索文本 Selected int // 当前选中索引 } @@ -36,9 +37,19 @@ type GatewayState struct { // RuntimeState 描述当前 run 的运行阶段、ID 和 token 用量。 type RuntimeState struct { - Phase string - RunID string - Tokens TokenUsage + Phase string + RunID string + Tokens TokenUsage + AgentMode string // "build" 或 "plan" + FullAccess bool +} + +// ConfirmState 描述确认弹窗的上下文信息。 +type ConfirmState struct { + Title string + Message string + Action string // "delete_session" 等 + Data map[string]any } // TokenUsage 描述 ViewState 中展示所需的 token 用量。 From d642bfc56b9f79ab58a8749e1baea025f3d4e4b7 Mon Sep 17 00:00:00 2001 From: pionxe Date: Tue, 16 Jun 2026 15:51:55 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(tuiv2):=20=E4=BF=AE=E5=A4=8D=E6=B5=AE?= =?UTF-8?q?=E5=B1=82=E5=9B=9E=E8=BD=A6=E4=B8=8D=E5=85=B3=E9=97=AD=E4=B8=8E?= =?UTF-8?q?=E5=A4=9A=E8=A1=8C=E6=B8=B2=E6=9F=93=E9=94=99=E4=BD=8D=E7=AD=89?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心修复:事件流导致浮层组件持有过期 state 指针 state.Reduce 每次返回新的 *ViewState,a.state 会被替换;而 bindComponents 此前 只重新绑定 4 个静态组件(status/stream/prompt/inspector),遗漏了 palette、 helpOverlay、sessionPicker、modelPicker、confirmOverlay 等浮层组件。结果浮层的 按键操作改在旧 state 上、当前 state 的 Overlay.Active 始终不变,表现为:方向键 移到 /mode 后回车,面板不关闭、高亮跳回第一项 /model。同一根因也影响会话/模型 选择器与确认弹窗。现在 bindComponents 绑定全部子组件,问题根除。 其余配套修复: - 命令面板 matchedItems 改用「精确名 → 前缀 → 子串」三级匹配(替换模糊匹配), 避免 "mode" 因评分排序命中 /model;并将空格键改为「选中」,与会话选择器一致。 - 确认弹窗:确认/取消时调用 closeOverlay 重置 Overlay.Active,修复回车不关闭。 - 会话切换:sessionSwitchedMsg 重载历史后追加 "Switched to session" 状态提示。 - 多行消息:renderMessage 续行缩进对齐到首行标签宽度,修复流内正文错位。 - 输入提示符 › 改为 ASCII '>',消除歧义宽度在 CJK 终端导致的列错位。 - 换行键:新增 Ctrl+J(Shift+Enter 在 bubbletea v1.3.10 中与回车不可区分), 并更新 keymap 帮助文案为 Alt+Enter / Ctrl+J。 测试:新增 repro_test.go(含模拟事件流复现过期指针的回归测试),并为 prompt、 stream 多行渲染与浮层交互补充回归测试。 --- internal/tuiv2/app.go | 38 +++ internal/tuiv2/app_overlay_test.go | 285 ++++++++++++++++++++ internal/tuiv2/app_test.go | 2 +- internal/tuiv2/components/model_picker.go | 4 +- internal/tuiv2/components/palette.go | 33 +-- internal/tuiv2/components/prompt.go | 16 +- internal/tuiv2/components/prompt_test.go | 37 +++ internal/tuiv2/components/session_picker.go | 2 +- internal/tuiv2/components/stream.go | 27 +- internal/tuiv2/components/stream_test.go | 32 +++ internal/tuiv2/keymap/keys.go | 6 +- 11 files changed, 445 insertions(+), 37 deletions(-) create mode 100644 internal/tuiv2/app_overlay_test.go diff --git a/internal/tuiv2/app.go b/internal/tuiv2/app.go index d93e1c035..51bc20c5f 100644 --- a/internal/tuiv2/app.go +++ b/internal/tuiv2/app.go @@ -142,6 +142,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.handleConfirmYes(msg) case components.ConfirmNoMsg: a.state.Confirm = state.ConfirmState{} + a.closeOverlay() return a, nil case leaderTimeoutMsg: if a.state.Mode == state.LeaderMode { @@ -161,6 +162,15 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.appendStream(streamEntryFromItem(item)) } } + // 会话历史重载会清空 Stream,因此切换提示必须在重载之后追加, + // 否则用户无法确认是否切换成功。 + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("session-switch-%d", time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: fmt.Sprintf("Switched to session: %s", a.activeSessionTitle()), + Metadata: map[string]any{"done": true}, + }) a.bindComponents() if a.eventCh != nil { return a, waitEventCmd(a.eventCh) @@ -522,6 +532,13 @@ func (a *App) openOverlay(overlayType string) { a.state.Overlay.Selected = 0 } +// closeOverlay 关闭当前浮层,重置搜索与选中状态。 +func (a *App) closeOverlay() { + a.state.Overlay.Active = "" + a.state.Overlay.Query = "" + a.state.Overlay.Selected = 0 +} + // handlePaletteCommand 处理命令面板选择的命令。 func (a *App) handlePaletteCommand(msg components.PaletteCommandMsg) tea.Cmd { switch msg.Name { @@ -674,6 +691,7 @@ func (a *App) handleModelSelect(msg components.ModelSelectMsg) tea.Cmd { func (a *App) handleConfirmYes(msg components.ConfirmYesMsg) tea.Cmd { confirm := a.state.Confirm a.state.Confirm = state.ConfirmState{} + a.closeOverlay() switch confirm.Action { case "delete_session": sessionID, _ := confirm.Data["session_id"].(string) @@ -705,6 +723,17 @@ func (a *App) activeSessionID() string { return "" } +// activeSessionTitle 返回当前会话标题,缺失时回退到会话 ID 或占位文本。 +func (a *App) activeSessionTitle() string { + if a.state.Gateway.ActiveSess != nil { + if a.state.Gateway.ActiveSess.Title != "" { + return a.state.Gateway.ActiveSess.Title + } + return a.state.Gateway.ActiveSess.ID + } + return "untitled" +} + // mainArea 渲染中部区域,按终端宽度决定 Inspector 右侧或纵向压缩显示。 func (a *App) mainArea() string { streamView := a.agentStream.View() @@ -801,11 +830,20 @@ func (a *App) appendStream(entry state.StreamEntry) { } // bindComponents 将子组件重新绑定到当前 ViewState 指针。 +// 注意:state.Reduce 每次返回新的 *ViewState,a.state 会被替换,因此所有 +// 子组件(含浮层:palette / help / sessionPicker / modelPicker / confirmOverlay) +// 都必须在这里重新绑定,否则会持有旧指针,导致浮层交互改到废弃状态上、 +// 出现"回车不关闭面板、跳回第一项"等问题。 func (a *App) bindComponents() { a.ambientStatus = components.NewAmbientStatus(a.state) a.agentStream = components.NewAgentStream(a.state) a.commandPrompt = components.NewCommandPrompt(a.state) a.softInspector = components.NewSoftInspector(a.state) + a.palette = components.NewPalette(a.state) + a.helpOverlay = components.NewHelpOverlay(a.state) + a.sessionPicker = components.NewSessionPicker(a.state) + a.modelPicker = components.NewModelPicker(a.state) + a.confirmOverlay = components.NewConfirmOverlay(a.state) } // toggleAgentMode 切换 Agent 模式 (build/plan) 并追加状态提示。 diff --git a/internal/tuiv2/app_overlay_test.go b/internal/tuiv2/app_overlay_test.go new file mode 100644 index 000000000..39c34ec53 --- /dev/null +++ b/internal/tuiv2/app_overlay_test.go @@ -0,0 +1,285 @@ +package tuiv2 + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/tuiv2/fakegateway" + "neo-code/internal/tuiv2/gateway" + "neo-code/internal/tuiv2/state" +) + +// pump 执行一条 cmd,把产出的消息送回 Update,返回最新的 App 指针。 +func pump(t *testing.T, app *App, cmd tea.Cmd) *App { + t.Helper() + if cmd == nil { + return app + } + msg := cmd() + if msg == nil { + return app + } + updated, next := app.Update(msg) + app = updated.(*App) + return pump(t, app, next) +} + +// pumpAll 顺序执行多条 cmd。 +func pumpAll(t *testing.T, app *App, cmds ...tea.Cmd) *App { + t.Helper() + for _, c := range cmds { + app = pump(t, app, c) + } + return app +} + +func newReadyApp(t *testing.T) *App { + t.Helper() + client, err := fakegateway.NewFakeClient(fakegateway.ScenarioDefault) + if err != nil { + t.Fatalf("NewFakeClient: %v", err) + } + app := NewApp(StartupConfig{Backend: "fake", Scenario: "default", Client: client}).(*App) + updated, winCmd := app.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + app = updated.(*App) + app = pumpAll(t, app, app.Init(), winCmd) + return app +} + +// send 把一条消息送进 App.Update 并抽干其产生的命令,返回最新 App 指针。 +func send(t *testing.T, app *App, msg tea.Msg) *App { + t.Helper() + updated, cmd := app.Update(msg) + app = updated.(*App) + return pump(t, app, cmd) +} + +func runesKey(s string) tea.Msg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} +} + +// TestLeaderSessionPickerFlow 模拟用户真实操作:Esc -> Normal, Space -> Leader, +// s -> 会话选择器, 下移, 回车。注意:Leader 后缀键之间不能抽干 leaderTimeoutCmd, +// 否则 1s 超时会先把 Mode 回退到 Normal(真实使用时用户会在超时前按下后缀键)。 +func TestLeaderSessionPickerFlow(t *testing.T) { + app := newReadyApp(t) + app = send(t, app, tea.KeyMsg{Type: tea.KeyEsc}) // NormalMode + updated, _ := app.Update(runesKey(" ")) // Space -> Leader(不抽干超时) + app = updated.(*App) + updated, _ = app.Update(runesKey("s")) // s -> session_picker + app = updated.(*App) + if app.state.Overlay.Active != "session_picker" { + t.Fatalf("session picker did not open via leader, active=%q", app.state.Overlay.Active) + } + app = send(t, app, tea.KeyMsg{Type: tea.KeyDown}) + app = send(t, app, tea.KeyMsg{Type: tea.KeyEnter}) + if app.state.Overlay.Active != "" { + t.Fatalf("session picker did NOT close on Enter via leader flow (still %q)", app.state.Overlay.Active) + } +} + +// TestModelPickerViaPaletteCloses 验证:面板里输入 "model" 回车打开模型选择器后, +// 再回车能正常选中并关闭(用户反馈的"选择器回车不关闭")。 +func TestModelPickerViaPaletteCloses(t *testing.T) { + app := newReadyApp(t) + app.openOverlay("palette") + for _, r := range "model" { + app = send(t, app, runesKey(string(r))) + } + app = send(t, app, tea.KeyMsg{Type: tea.KeyEnter}) // /model -> model_picker + if app.state.Overlay.Active != "model_picker" { + t.Fatalf("expected model_picker after /model, got %q", app.state.Overlay.Active) + } + app = send(t, app, tea.KeyMsg{Type: tea.KeyEnter}) // 选中第一个模型 + if app.state.Overlay.Active != "" { + t.Fatalf("model picker did NOT close on Enter (still %q)", app.state.Overlay.Active) + } +} + +// TestPaletteTypeModeEnter 模拟在命令面板里输入 "mode" 过滤后回车。 +func TestPaletteTypeModeEnter(t *testing.T) { + app := newReadyApp(t) + app.openOverlay("palette") + for _, r := range "mode" { + app = send(t, app, runesKey(string(r))) + } + t.Logf("palette query=%q selected=%d", app.state.Overlay.Query, app.state.Overlay.Selected) + app = send(t, app, tea.KeyMsg{Type: tea.KeyEnter}) + t.Logf("after Enter: overlay=%q", app.state.Overlay.Active) + if app.state.Overlay.Active != "" { + t.Fatalf("palette did NOT close after typing 'mode' + Enter (still %q)", app.state.Overlay.Active) + } +} + +// TestPaletteCtrlPOpenNavigateEnter 模拟真实路径:InputMode 下 Ctrl+P 打开面板, +// 下移选中 /mode,回车。验证面板关闭且回到对话界面。 +func TestPaletteCtrlPOpenNavigateEnter(t *testing.T) { + app := newReadyApp(t) + app = send(t, app, tea.KeyMsg{Type: tea.KeyCtrlP}) // 打开面板 + if app.state.Overlay.Active != "palette" { + t.Fatalf("palette did not open via Ctrl+P, active=%q", app.state.Overlay.Active) + } + app = send(t, app, tea.KeyMsg{Type: tea.KeyDown}) // /model -> /mode + if app.state.Overlay.Selected != 1 { + t.Fatalf("selected=%d, want 1 after one down", app.state.Overlay.Selected) + } + app = send(t, app, tea.KeyMsg{Type: tea.KeyEnter}) + last := "" + if n := len(app.state.Stream); n > 0 { + last = app.state.Stream[n-1].Content + } + t.Logf("after Enter: overlay=%q lastStream=%q", app.state.Overlay.Active, last) + if app.state.Overlay.Active != "" { + t.Fatalf("palette did NOT close on Enter via Ctrl+P flow (still %q)", app.state.Overlay.Active) + } +} + +// TestPaletteStaleStateAfterEvents 复现真实 bug:事件流会通过 state.Reduce +// 把 a.state 替换成新指针,而 bindComponents 没有重新绑定浮层组件,导致 +// palette/model/session 选择器持有旧的 state 指针——于是下移/回车改的是旧 state, +// App 的当前 state 里 Overlay.Active 始终不变,面板"回车不关闭、跳回第一项"。 +func TestPaletteStaleStateAfterEvents(t *testing.T) { + app := newReadyApp(t) + // 走真实事件处理路径:a.state = state.Reduce(...) 后调用 bindComponents。 + updated, _ := app.Update(gatewayEventMsg{event: gateway.GatewayEvent{ + Type: gateway.EventPhaseChanged, + Payload: map[string]any{"phase": state.RuntimePhaseIdle}, + }}) + app = updated.(*App) + app.openOverlay("palette") + app = send(t, app, tea.KeyMsg{Type: tea.KeyDown}) // 高亮到 /mode + app = send(t, app, tea.KeyMsg{Type: tea.KeyEnter}) + if app.state.Overlay.Active != "" { + t.Fatalf("palette did NOT close after Enter (overlay components hold stale state pointer): active=%q", app.state.Overlay.Active) + } +} + +// 空格应像回车一样执行当前高亮项并关闭面板,而不是被当成查询字符重置到 /model。 +func TestPaletteSpaceSelects(t *testing.T) { + app := newReadyApp(t) + app.openOverlay("palette") + app = send(t, app, tea.KeyMsg{Type: tea.KeyDown}) // 选中 /mode + app = send(t, app, runesKey(" ")) // 空格确认 + last := "" + if n := len(app.state.Stream); n > 0 { + last = app.state.Stream[n-1].Content + } + t.Logf("after space: overlay=%q lastStream=%q", app.state.Overlay.Active, last) + if app.state.Overlay.Active != "" { + t.Fatalf("palette did NOT close on Space (still %q)", app.state.Overlay.Active) + } + if !strings.Contains(last, "Agent mode") { + t.Fatalf("space did not select /mode (it ran something else): %q", last) + } +} + +// TestPaletteNavigateSelectsTarget 模拟用户在面板里用下移键选中 +// /mode、/compact、/checkpoint 后回车,验证面板会关闭且回到对话界面。 +func TestPaletteNavigateSelectsTarget(t *testing.T) { + cases := []struct { + name string + downs int + }{ + {"mode", 1}, // /model(0) -> /mode(1) + {"compact", 3}, // -> /compact(3) + {"checkpoint", 4}, // -> /checkpoint(4) + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + app := newReadyApp(t) + app.openOverlay("palette") + for i := 0; i < tc.downs; i++ { + app = send(t, app, tea.KeyMsg{Type: tea.KeyDown}) + } + t.Logf("selected index before Enter=%d", app.state.Overlay.Selected) + app = send(t, app, tea.KeyMsg{Type: tea.KeyEnter}) + last := "" + if n := len(app.state.Stream); n > 0 { + last = app.state.Stream[n-1].Content + } + t.Logf("after Enter: overlay=%q lastStream=%q", app.state.Overlay.Active, last) + if app.state.Overlay.Active != "" { + t.Fatalf("/%s: palette did NOT close on Enter (still %q)", tc.name, app.state.Overlay.Active) + } + }) + } +} + +func TestModelPickerEnterCloses(t *testing.T) { + app := newReadyApp(t) + t.Logf("models=%d sessions=%d", len(app.state.Gateway.Models), len(app.state.Gateway.Sessions)) + app.openOverlay("model_picker") + + updated, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = updated.(*App) + app = pump(t, app, cmd) // 处理 ModelSelectMsg + t.Logf("model picker active after Enter=%q", app.state.Overlay.Active) + if app.state.Overlay.Active != "" { + t.Fatalf("model picker did NOT close on Enter (still %q)", app.state.Overlay.Active) + } +} + +func TestSessionPickerEnterCloses(t *testing.T) { + app := newReadyApp(t) + app.openOverlay("session_picker") + + updated, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = updated.(*App) + app = pump(t, app, cmd) // 处理 SessionSelectMsg -> loadSessionCmd + t.Logf("session picker active after Enter=%q", app.state.Overlay.Active) + if app.state.Overlay.Active != "" { + t.Fatalf("session picker did NOT close on Enter (still %q)", app.state.Overlay.Active) + } +} + +func TestConfirmEnterConfirms(t *testing.T) { + app := newReadyApp(t) + app.openOverlay("session_picker") + // Ctrl+D 在 session picker 中触发删除请求 -> 应打开 confirm + updated, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) + app = updated.(*App) + app = pump(t, app, cmd) // 处理 SessionDeleteMsg -> openConfirm + if app.state.Overlay.Active != "confirm" { + t.Fatalf("confirm overlay not open after Ctrl+D, active=%q", app.state.Overlay.Active) + } + + updated, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = updated.(*App) + app = pump(t, app, cmd) // 处理 ConfirmYesMsg -> deleteSessionCmd + t.Logf("confirm active after Enter=%q", app.state.Overlay.Active) + if app.state.Overlay.Active != "" { + t.Fatalf("confirm overlay did NOT close on Enter (still %q)", app.state.Overlay.Active) + } +} + +func TestSessionSwitchShowsConfirmation(t *testing.T) { + app := newReadyApp(t) + app.openOverlay("session_picker") + // 下移到第二个会话后回车切换 + updated, _ := app.Update(tea.KeyMsg{Type: tea.KeyDown}) + app = updated.(*App) + updated, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = updated.(*App) + app = pump(t, app, cmd) // 处理 SessionSelectMsg -> loadSessionCmd + + // 会话历史被重载,后续还会有异步事件追加,因此只校验状态条目存在。 + var contents []string + var hasSwitch, hasReloaded bool + for _, entry := range app.state.Stream { + contents = append(contents, entry.Content) + if strings.Contains(entry.Content, "Switched to session") { + hasSwitch = true + } + if strings.Contains(entry.Content, "Session fixture loaded") { + hasReloaded = true + } + } + if !hasReloaded { + t.Fatalf("session stream was not reloaded from target session: %v", contents) + } + if !hasSwitch { + t.Fatalf("no switch confirmation in stream: %v", contents) + } +} diff --git a/internal/tuiv2/app_test.go b/internal/tuiv2/app_test.go index 16adb00b0..7f5dbc929 100644 --- a/internal/tuiv2/app_test.go +++ b/internal/tuiv2/app_test.go @@ -127,7 +127,7 @@ func TestAppViewShowsFocusOnlyLayout(t *testing.T) { "Agent Stream", "Soft Inspector", "Command Prompt", - "› _", + "> _", "[debug] mode:input", "size:120x30", } { diff --git a/internal/tuiv2/components/model_picker.go b/internal/tuiv2/components/model_picker.go index 2abd10a7f..8f60f7110 100644 --- a/internal/tuiv2/components/model_picker.go +++ b/internal/tuiv2/components/model_picker.go @@ -53,7 +53,7 @@ func (m *ModelPicker) handleKey(msg tea.KeyMsg) tea.Cmd { m.state.Overlay.Query = "" m.state.Overlay.Selected = 0 return nil - case "enter": + case "enter", " ": matched := m.matchedModels() if len(matched) == 0 { return nil @@ -221,7 +221,7 @@ func (m *ModelPicker) View() string { } // 提示行 - hint := " ⏎ : select ␛ : cancel" + hint := " ⏎ / ␣ : select ␛ : cancel" lines = append(lines, "", theme.MutedStyle().Render(hint)) content := strings.Join(lines, "\n") diff --git a/internal/tuiv2/components/palette.go b/internal/tuiv2/components/palette.go index b73519868..b721cc29d 100644 --- a/internal/tuiv2/components/palette.go +++ b/internal/tuiv2/components/palette.go @@ -8,8 +8,6 @@ import ( "neo-code/internal/tuiv2/state" "neo-code/internal/tuiv2/theme" - - "github.com/sahilm/fuzzy" ) // PaletteItem 描述命令面板中的一个可选项。 @@ -73,7 +71,7 @@ func (p *Palette) handleKey(msg tea.KeyMsg) tea.Cmd { p.state.Overlay.Query = "" p.state.Overlay.Selected = 0 return nil - case "enter": + case "enter", " ": matched := p.matchedItems() if len(matched) == 0 { return nil @@ -116,22 +114,27 @@ func (p *Palette) handleKey(msg tea.KeyMsg) tea.Cmd { } } -// matchedItems 根据当前查询进行模糊匹配。 +// matchedItems 按确定性的优先级匹配命令:先精确名、再前缀、最后子串。 +// 不使用模糊匹配,避免 "mode" 因为评分排序命中 /model 而不是 /mode。 func (p *Palette) matchedItems() []PaletteItem { - query := strings.ToLower(p.state.Overlay.Query) + query := strings.ToLower(strings.TrimPrefix(p.state.Overlay.Query, "/")) if query == "" { return p.items } - targets := make([]string, len(p.items)) - for i, item := range p.items { - targets[i] = strings.ToLower(item.Name) + " " + strings.ToLower(item.Description) - } - matches := fuzzy.Find(query, targets) - result := make([]PaletteItem, 0, len(matches)) - for _, m := range matches { - result = append(result, p.items[m.Index]) + var exact, prefix, substr []PaletteItem + for _, item := range p.items { + name := strings.ToLower(strings.TrimPrefix(item.Name, "/")) + desc := strings.ToLower(item.Description) + switch { + case name == query: + exact = append(exact, item) + case strings.HasPrefix(name, query): + prefix = append(prefix, item) + case strings.Contains(name, query) || strings.Contains(desc, query): + substr = append(substr, item) + } } - return result + return append(append(exact, prefix...), substr...) } // handleMouse 处理鼠标滚轮和点击事件。 @@ -223,7 +226,7 @@ func (p *Palette) View() string { } // 底部提示行 - hint := " ␣ : close ⏎ : execute ␛ : dismiss" + hint := " ⏎ / ␣ : execute ␛ : dismiss" lines = append(lines, "", theme.MutedStyle().Render(hint)) // 边框容器 diff --git a/internal/tuiv2/components/prompt.go b/internal/tuiv2/components/prompt.go index 1a90896b3..d03a30968 100644 --- a/internal/tuiv2/components/prompt.go +++ b/internal/tuiv2/components/prompt.go @@ -16,6 +16,10 @@ import ( const ( cursorBlinkInterval = 500 * time.Millisecond promptWrapIndent = " " + // promptSymbol 用作输入行前缀。选用 ASCII '>' 而非 Unicode 右尖括号(如 ›): + // 后者是东亚宽度「歧义」字符,会在 CJK 终端/字体下被渲染成双宽, + // 导致首行前缀与续行缩进的列数不一致、换行后错位。 + promptSymbol = ">" ) // SubmitMessageMsg 表示用户在消息模式下提交了一条待发送文本。 @@ -126,7 +130,7 @@ func (c *CommandPrompt) handleInputKey(msg tea.KeyMsg) tea.Cmd { c.deleteBeforeCursor() case "delete": c.deleteAtCursor() - case "shift+enter", "alt+enter": + case "shift+enter", "alt+enter", "ctrl+j": c.insertText("\n") case "enter": text := strings.TrimSpace(c.state.Input.Text) @@ -212,7 +216,7 @@ func (c *CommandPrompt) handleQuestionKey(msg tea.KeyMsg) tea.Cmd { // messageLines 渲染命令和普通消息模式下的输入行。 func (c *CommandPrompt) messageLines() []string { - return []string{c.renderPromptInput("›")} + return []string{c.renderPromptInput(promptSymbol)} } // permissionLines 渲染权限确认的提示、输入和快捷操作栏。 @@ -224,7 +228,7 @@ func (c *CommandPrompt) permissionLines() []string { return []string{ theme.WarningStyle().Render(theme.StatusSymbol(theme.PhaseWaitingPermission)+" ") + theme.SubtleStyle().Render(prompt), - c.renderPromptInput("›"), + c.renderPromptInput(promptSymbol), c.renderShortcutBar([]shortcutItem{ {Key: "Y", Text: "允许"}, {Key: "n", Text: "拒绝"}, @@ -242,7 +246,7 @@ func (c *CommandPrompt) questionLines() []string { } lines := []string{ theme.AccentStyle().Render(theme.Separator()+" ") + theme.BaseStyle().Render(prompt), - c.renderPromptInput("›"), + c.renderPromptInput(promptSymbol), } if len(c.state.Input.Options) > 0 { lines = append(lines, "") @@ -259,13 +263,15 @@ func (c *CommandPrompt) renderPromptInput(symbol string) string { if len(rawLines) == 0 { rawLines = []string{""} } + // 续行缩进与首行前缀(symbol + 空格)显示宽度一致;symbol 用 ASCII,宽度恒为 1。 + indent := strings.Repeat(" ", theme.DisplayWidth(symbol+" ")) lines := make([]string, 0, len(rawLines)) for index, line := range rawLines { if index == 0 { lines = append(lines, theme.AccentStyle().Render(symbol+" ")+theme.BaseStyle().Render(line)) continue } - lines = append(lines, theme.MutedStyle().Render(" ")+theme.BaseStyle().Render(line)) + lines = append(lines, theme.MutedStyle().Render(indent)+theme.BaseStyle().Render(line)) } return strings.Join(lines, "\n") } diff --git a/internal/tuiv2/components/prompt_test.go b/internal/tuiv2/components/prompt_test.go index 892f72e02..4748e8c99 100644 --- a/internal/tuiv2/components/prompt_test.go +++ b/internal/tuiv2/components/prompt_test.go @@ -5,6 +5,7 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" "neo-code/internal/tuiv2/gateway" "neo-code/internal/tuiv2/state" @@ -151,6 +152,42 @@ func TestCommandPromptModeLineUsesSessionAndModel(t *testing.T) { } } +func TestCommandPromptCtrlJInsertsNewline(t *testing.T) { + viewState := promptState() + prompt := NewCommandPrompt(viewState) + + _, _ = prompt.Update(keyMsg("hello")) + _, cmd := prompt.Update(keyType(tea.KeyCtrlJ)) + if cmd != nil { + t.Fatalf("ctrl+j returned command %T, want nil", cmd) + } + if viewState.Input.Text != "hello\n" { + t.Fatalf("Input.Text = %q, want %q", viewState.Input.Text, "hello\n") + } +} + +func TestCommandPromptMultilineContinuationAligns(t *testing.T) { + viewState := promptState() + viewState.Input.Text = "first\nsecond" + viewState.Input.Cursor = 0 + viewState.Input.CursorVisible = true + prompt := NewCommandPrompt(viewState) + + // 用确定性双宽符号,确保续行缩进等于首行前缀显示宽度。 + const symbol = "你" + wantIndent := theme.DisplayWidth(symbol + " ") + rendered := prompt.renderPromptInput(symbol) + for index, line := range strings.Split(ansi.Strip(rendered), "\n") { + if index == 0 { + continue + } + leading := len(line) - len(strings.TrimLeft(line, " ")) + if leading != wantIndent { + t.Fatalf("line %d leading spaces = %d, want %d: %q", index, leading, wantIndent, line) + } + } +} + func promptState() *state.ViewState { viewState := state.NewViewState() viewState.Layout.Width = 90 diff --git a/internal/tuiv2/components/session_picker.go b/internal/tuiv2/components/session_picker.go index a2334ea60..bcc78a747 100644 --- a/internal/tuiv2/components/session_picker.go +++ b/internal/tuiv2/components/session_picker.go @@ -231,7 +231,7 @@ func (s *SessionPicker) View() string { } // 提示行 - hint := " ␣ : switch Ctrl+D : delete ␛ : cancel" + hint := " ⏎ / ␣ : switch Ctrl+D : delete ␛ : cancel" lines = append(lines, "", theme.MutedStyle().Render(hint)) content := strings.Join(lines, "\n") diff --git a/internal/tuiv2/components/stream.go b/internal/tuiv2/components/stream.go index 2e363b2b6..7f8255edd 100644 --- a/internal/tuiv2/components/stream.go +++ b/internal/tuiv2/components/stream.go @@ -229,24 +229,29 @@ func (c *AgentStream) renderMessage(entry state.StreamEntry) []string { role = v } var label string - var styledLines []string - text := entry.Content - if text == "" { - text = "-" - } switch role { case "user": label = " " + theme.InfoStyle().Render("you") + " " - styledLines = renderWrappedLines(text, " ", theme.BaseStyle()) default: // "assistant" or empty label = " " + theme.AccentStyle().Render("neo") + " " - styledLines = renderWrappedLines(text, " ", theme.BaseStyle()) } - result := []string{label + strings.TrimPrefix(styledLines[0], " ")} - for _, line := range styledLines[1:] { - result = append(result, line) + // 续行缩进与首行标签(" you "/" neo ")的显示宽度一致, + // 使多行消息的正文逐行对齐,不再出现第二行起缩进不足导致的错位。 + indent := strings.Repeat(" ", theme.DisplayWidth(label)) + text := entry.Content + if text == "" { + text = "-" } - return result + parts := strings.Split(text, "\n") + lines := make([]string, 0, len(parts)) + for index, part := range parts { + if index == 0 { + lines = append(lines, label+theme.BaseStyle().Render(part)) + continue + } + lines = append(lines, theme.BaseStyle().Render(indent+part)) + } + return lines } // renderToolStart 渲染工具调用开始行。 diff --git a/internal/tuiv2/components/stream_test.go b/internal/tuiv2/components/stream_test.go index c9ce8b8cc..6f95b1407 100644 --- a/internal/tuiv2/components/stream_test.go +++ b/internal/tuiv2/components/stream_test.go @@ -7,6 +7,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" "neo-code/internal/tuiv2/state" "neo-code/internal/tuiv2/theme" ) @@ -124,6 +125,37 @@ func TestAgentStreamTimestampGap(t *testing.T) { } } +func TestAgentStreamMultilineMessageAlignsUnderLabel(t *testing.T) { + viewState := state.NewViewState() + viewState.Layout.Width = 80 + viewState.Layout.Height = 20 + viewState.Stream = []state.StreamEntry{ + {ID: "m", Type: "message", Content: "第一行\n第二行内容\n第三行", Metadata: map[string]any{"role": "user"}}, + } + stream := NewAgentStream(viewState) + + lines := stream.renderMessage(viewState.Stream[0]) + plain := make([]string, len(lines)) + for i, l := range lines { + plain[i] = ansi.Strip(l) + } + if len(plain) != 3 { + t.Fatalf("rendered lines = %d, want 3: %v", len(plain), plain) + } + // 首行 = " you " + 内容(you 占 3 列,前后各 2/1 空格,共 6 列)。 + wantIndent := theme.DisplayWidth(" you ") + if !strings.HasPrefix(plain[0], " you ") { + t.Fatalf("line 0 = %q, want prefix %q", plain[0], " you ") + } + // 续行必须是 wantIndent 个前导空格,使正文与首行正文逐列对齐。 + for i, line := range plain[1:] { + leading := len(line) - len(strings.TrimLeft(line, " ")) + if leading != wantIndent { + t.Fatalf("continuation line %d leading = %d, want %d: %q", i+1, leading, wantIndent, line) + } + } +} + func numberedEntries(count int) []state.StreamEntry { entries := make([]state.StreamEntry, 0, count) for i := 0; i < count; i++ { diff --git a/internal/tuiv2/keymap/keys.go b/internal/tuiv2/keymap/keys.go index 109b2a9b7..3ab7412c9 100644 --- a/internal/tuiv2/keymap/keys.go +++ b/internal/tuiv2/keymap/keys.go @@ -64,7 +64,9 @@ type HelpGroup struct { func InputBindings() []key.Binding { return []key.Binding{ key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "send message")), - key.NewBinding(key.WithKeys("shift+enter"), key.WithHelp("shift+enter", "new line")), + // Shift+Enter 在多数终端里与 Enter 不可区分(bubbletea 也未映射), + // 这里登记可用的 Alt+Enter / Ctrl+J 作为换行键。 + key.NewBinding(key.WithKeys("alt+enter", "ctrl+j"), key.WithHelp("alt+enter", "new line")), key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "normal mode")), key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "cancel/quit")), key.NewBinding(key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "command palette")), @@ -78,7 +80,7 @@ func InputHelp() []HelpGroup { Title: "Input Mode", Entries: []HelpEntry{ {Key: "Enter", Desc: "Send message"}, - {Key: "Shift+Enter", Desc: "New line"}, + {Key: "Alt+Enter / Ctrl+J", Desc: "New line"}, {Key: "Ctrl+C", Desc: "Cancel agent (double to quit)"}, {Key: "Ctrl+P", Desc: "Command palette"}, {Key: "?", Desc: "This help"}, From c37f89cd5caca936272baff45988cf7bd9326722 Mon Sep 17 00:00:00 2001 From: pionxe Date: Tue, 16 Jun 2026 17:19:04 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(tuiv2):=20=E8=A1=A5=E5=85=A8=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=8E=87=E8=87=B3=2080%=20=E4=BB=A5=E4=B8=8A=20?= =?UTF-8?q?=E2=80=94=20handlers/=E7=BB=84=E4=BB=B6/picker/theme/gateway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 针对 codecov 80% 门槛补齐 tuiv2 各包测试: - app_handlers_test.go:App 消息处理、Ctrl+C、命令分发、Cmd 工厂、模式键、鼠标、View - components: picker_test/coverage_test/prompt_keys_test — 直接覆盖 palette/model/session 组件级函数、confirm/help/inspector/status_bar/stream 与 prompt 编辑/模式 - theme/coverage_test.go:样式工厂与符号/颜色分支 - gateway/real_test.go:RealClient 占位方法 --- internal/tuiv2/app_handlers_test.go | 572 ++++++++++++++++++ internal/tuiv2/components/coverage_test.go | 251 ++++++++ internal/tuiv2/components/picker_test.go | 302 +++++++++ internal/tuiv2/components/prompt_keys_test.go | 178 ++++++ internal/tuiv2/gateway/real_test.go | 39 ++ internal/tuiv2/theme/coverage_test.go | 119 ++++ 6 files changed, 1461 insertions(+) create mode 100644 internal/tuiv2/app_handlers_test.go create mode 100644 internal/tuiv2/components/coverage_test.go create mode 100644 internal/tuiv2/components/picker_test.go create mode 100644 internal/tuiv2/components/prompt_keys_test.go create mode 100644 internal/tuiv2/gateway/real_test.go create mode 100644 internal/tuiv2/theme/coverage_test.go diff --git a/internal/tuiv2/app_handlers_test.go b/internal/tuiv2/app_handlers_test.go new file mode 100644 index 000000000..4ccfd2187 --- /dev/null +++ b/internal/tuiv2/app_handlers_test.go @@ -0,0 +1,572 @@ +package tuiv2 + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/tuiv2/components" + "neo-code/internal/tuiv2/fakegateway" + "neo-code/internal/tuiv2/gateway" + "neo-code/internal/tuiv2/state" +) + +// ---- 消息处理器 ---- + +func TestHandleSubmitMessageAppendsAndReturnsCmd(t *testing.T) { + app := newReadyApp(t) + updated, cmd := app.Update(components.SubmitMessageMsg{Text: " hello "}) + app = updated.(*App) + if cmd == nil { + t.Fatal("submit with client returned nil cmd") + } + last := app.state.Stream[len(app.state.Stream)-1] + if last.Content != " hello " || last.Metadata["role"] != "user" { + t.Fatalf("user message not appended: %+v", last) + } + + // 空文本 -> nil + if _, cmd := app.Update(components.SubmitMessageMsg{Text: " "}); cmd != nil { + t.Fatal("empty submit should return nil cmd") + } +} + +func TestHandleSubmitMessageNoClient(t *testing.T) { + app := NewApp(StartupConfig{Backend: "fake", Scenario: "default"}).(*App) + updated, _ := app.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + app = updated.(*App) + _, cmd := app.Update(components.SubmitMessageMsg{Text: "hi"}) + if cmd != nil { + t.Fatal("submit without client should return nil cmd") + } +} + +func TestPermissionAndQuestionHandlers(t *testing.T) { + app := newReadyApp(t) + app.state.Runtime.RunID = "run-1" + + if _, cmd := app.Update(components.PermissionActionMsg{Decision: "y"}); cmd == nil { + t.Fatal("permission action with client returned nil") + } + if _, cmd := app.Update(components.QuestionAnswerMsg{Text: "answer"}); cmd == nil { + t.Fatal("question answer with client returned nil") + } + + // 无 client -> nil + noc := NewApp(StartupConfig{Backend: "fake", Scenario: "default"}).(*App) + if _, cmd := noc.Update(components.PermissionActionMsg{Decision: "y"}); cmd != nil { + t.Fatal("permission without client should be nil") + } + if _, cmd := noc.Update(components.QuestionAnswerMsg{Text: "x"}); cmd != nil { + t.Fatal("question without client should be nil") + } +} + +func TestCancelPromptResetsInputAndLogs(t *testing.T) { + app := newReadyApp(t) + app.state.Input.Mode = state.InputStateModeQuestionAnswer + app.state.Input.Text = "abc" + app.state.Input.Options = []string{"x"} + app.Update(components.PromptCancelMsg{Mode: state.InputStateModeQuestionAnswer}) + + if app.state.Input.Mode != state.InputStateModeMessage || app.state.Input.Text != "" { + t.Fatalf("input not reset: %+v", app.state.Input) + } + if app.state.Input.Options != nil { + t.Fatalf("options not cleared: %+v", app.state.Input.Options) + } +} + +// ---- Slash / Palette 命令分发 ---- + +func TestSlashCommandDispatch(t *testing.T) { + cases := map[string]func(*App) bool{ + "/session": func(a *App) bool { return a.state.Overlay.Active == "session_picker" }, + "/model": func(a *App) bool { return a.state.Overlay.Active == "model_picker" }, + "/help": func(a *App) bool { return a.state.Overlay.Active == "help" }, + "/mode": func(a *App) bool { return a.state.Runtime.AgentMode == "build" }, + "/compact": func(a *App) bool { return lastContains(a, "Compact triggered") }, + "/clear": func(a *App) bool { return len(a.state.Stream) == 0 }, + "/bogus": func(a *App) bool { return lastContains(a, "unknown command") }, + } + for cmd, check := range cases { + t.Run(cmd, func(t *testing.T) { + app := newReadyApp(t) + app.Update(components.SlashCommandMsg{Command: cmd}) + if !check(app) { + t.Fatalf("%s did not produce expected effect: overlay=%q stream=%v", cmd, app.state.Overlay.Active, streamContents(app)) + } + }) + } + // /exit / /quit -> tea.Quit + app := newReadyApp(t) + if _, cmd := app.Update(components.SlashCommandMsg{Command: "/exit"}); cmd == nil { + t.Fatal("/exit should return quit cmd") + } +} + +func TestPaletteCommandDispatch(t *testing.T) { + cases := map[string]func(*App) bool{ + "/session": func(a *App) bool { return a.state.Overlay.Active == "session_picker" }, + "/model": func(a *App) bool { return a.state.Overlay.Active == "model_picker" }, + "/help": func(a *App) bool { return a.state.Overlay.Active == "help" }, + "/mode": func(a *App) bool { return a.state.Runtime.AgentMode == "build" }, + "/compact": func(a *App) bool { return lastContains(a, "Compact triggered") }, + "/checkpoint": func(a *App) bool { return lastContains(a, "not yet implemented") }, + } + for name, check := range cases { + t.Run(name, func(t *testing.T) { + app := newReadyApp(t) + app.Update(components.PaletteCommandMsg{Name: name}) + if !check(app) { + t.Fatalf("%s did not produce expected effect: overlay=%q stream=%v", name, app.state.Overlay.Active, streamContents(app)) + } + }) + } + app := newReadyApp(t) + if _, cmd := app.Update(components.PaletteCommandMsg{Name: "/exit"}); cmd == nil { + t.Fatal("/exit should return quit cmd") + } +} + +// ---- Ctrl+C 双退保护 ---- + +func TestHandleCtrlCIdleHintAndDoubleQuit(t *testing.T) { + app := newReadyApp(t) + // 空闲单次 -> 提示 + _, cmd := app.handleCtrlC() + if cmd != nil || !lastContains(app, "Press Ctrl+C again to quit") { + t.Fatalf("idle single Ctrl+C should hint, cmd=%v stream=%v", cmd, streamContents(app)) + } + // 运行中 -> 取消 + app.state.Runtime.Phase = state.RuntimePhaseRunning + _, cmd = app.handleCtrlC() + if cmd == nil { + t.Fatal("running Ctrl+C should return cancel cmd") + } +} + +func TestHandleCtrlCCancelWithoutClient(t *testing.T) { + app := NewApp(StartupConfig{Backend: "fake", Scenario: "default"}).(*App) + app.state.Runtime.Phase = state.RuntimePhaseWaitingPermission + _, _ = app.handleCtrlC() + if app.state.Runtime.Phase != state.RuntimePhaseCancelled { + t.Fatalf("phase=%s, want cancelled", app.state.Runtime.Phase) + } +} + +// ---- 会话/模型/确认 ---- + +func TestHandleSessionCreatedErrorAndSuccess(t *testing.T) { + // 错误路径 + app := newReadyApp(t) + app.Update(sessionCreatedMsg{err: errSentinel("boom")}) + if !lastContains(app, "Failed to create session") { + t.Fatalf("error path not logged: %v", streamContents(app)) + } + // 成功路径 + app = newReadyApp(t) + _, cmd := app.Update(sessionCreatedMsg{Session: &gateway.SessionSummary{ID: "new-1", Title: "New"}}) + if app.state.Gateway.ActiveSess == nil || app.state.Gateway.ActiveSess.ID != "new-1" { + t.Fatal("active session not set on create") + } + if !lastContains(app, "New session created") { + t.Fatalf("create not logged: %v", streamContents(app)) + } + if cmd == nil { + t.Fatal("create with client should return load cmd") + } +} + +func TestHandleSessionDeleteOpensConfirm(t *testing.T) { + app := newReadyApp(t) + app.Update(components.SessionDeleteMsg{SessionID: "s-x"}) + if app.state.Overlay.Active != "confirm" || app.state.Confirm.Action != "delete_session" { + t.Fatalf("confirm not opened: overlay=%q confirm=%+v", app.state.Overlay.Active, app.state.Confirm) + } + // 无 client -> 忽略 + noc := NewApp(StartupConfig{Backend: "fake", Scenario: "default"}).(*App) + if _, cmd := noc.Update(components.SessionDeleteMsg{SessionID: "s-x"}); cmd != nil { + t.Fatal("delete without client should be nil") + } +} + +func TestHandleModelSelectSwitches(t *testing.T) { + app := newReadyApp(t) + app.Update(components.ModelSelectMsg{ModelID: "neo-fake-fast", ModelName: "Neo Fake Fast"}) + if app.state.Gateway.ActiveModel != "neo-fake-fast" || !lastContains(app, "Model switched") { + t.Fatalf("model not switched: model=%q stream=%v", app.state.Gateway.ActiveModel, streamContents(app)) + } +} + +func TestHandleConfirmYesDeletesSession(t *testing.T) { + app := newReadyApp(t) + app.openConfirm("Delete Session", "msg", "delete_session", map[string]any{"session_id": "sess-9"}) + _, cmd := app.Update(components.ConfirmYesMsg{}) + if app.state.Overlay.Active != "" { + t.Fatalf("confirm should close, active=%q", app.state.Overlay.Active) + } + if cmd == nil { + t.Fatal("confirm yes on delete_session should return delete cmd") + } +} + +// ---- toggle / compact ---- + +func TestToggleAgentModeToggleFullAccessTriggerCompact(t *testing.T) { + app := newReadyApp(t) + app.toggleAgentMode() + if app.state.Runtime.AgentMode != "build" { + t.Fatalf("agent mode=%s, want build", app.state.Runtime.AgentMode) + } + app.toggleAgentMode() + if app.state.Runtime.AgentMode != "plan" { + t.Fatalf("agent mode=%s, want plan", app.state.Runtime.AgentMode) + } + app.toggleFullAccess() + if !app.state.Runtime.FullAccess { + t.Fatal("full access should be on") + } + app.triggerCompact() + if !lastContains(app, "Compact triggered") { + t.Fatal("compact not triggered") + } +} + +// ---- tea.Cmd 工厂 ---- + +func TestCmdFactories(t *testing.T) { + client, err := fakegateway.NewFakeClient(fakegateway.ScenarioDefault) + if err != nil { + t.Fatal(err) + } + sessionID := "session-ghost-console" + + if msg := submitMessageCmd(client, sessionID, "hi")(); msg == nil { + t.Fatal("submitMessageCmd returned nil") + } + if msg := resolvePermissionCmd(client, gateway.PermissionDecision{Allow: true, Reason: "y"})(); msg == nil { + t.Fatal("resolvePermissionCmd returned nil") + } + if msg := answerQuestionCmd(client, gateway.UserQuestionAnswer{Text: "a"})(); msg == nil { + t.Fatal("answerQuestionCmd returned nil") + } + if msg := cancelRunCmd(client, sessionID, "run-1")(); msg == nil { + t.Fatal("cancelRunCmd returned nil") + } + if msg := createSessionCmd(client)(); msg == nil { + t.Fatal("createSessionCmd returned nil") + } + if msg := deleteSessionCmd(client, sessionID)(); msg == nil { + t.Fatal("deleteSessionCmd returned nil") + } + if msg := loadSessionCmd(client, sessionID)(); msg == nil { + t.Fatal("loadSessionCmd returned nil") + } + // errorEvent 包装 + if ge := errorEvent(errSentinel("x")); ge.Type != gateway.EventError { + t.Fatalf("errorEvent wrong: %+v", ge) + } +} + +func TestCmdFactoriesErrorPath(t *testing.T) { + // 关闭客户端 -> 各 RPC 返回 errorEvent + client, _ := fakegateway.NewFakeClient(fakegateway.ScenarioDefault) + _ = client.Close() + + for _, c := range []tea.Cmd{ + submitMessageCmd(client, "s", "x"), + resolvePermissionCmd(client, gateway.PermissionDecision{}), + answerQuestionCmd(client, gateway.UserQuestionAnswer{}), + cancelRunCmd(client, "s", "r"), + loadSessionCmd(client, "s"), + } { + msg := c() + ge, ok := msg.(gatewayEventMsg) + if !ok { + t.Fatalf("expected gatewayEventMsg on closed client, got %T", msg) + } + if ge.event.Type != gateway.EventError { + t.Fatalf("expected EventError, got %s", ge.event.Type) + } + } +} + +func TestLoadInitialCmdOffline(t *testing.T) { + client, _ := fakegateway.NewFakeClient(fakegateway.ScenarioGatewayOffline) + msg := loadInitialCmd(client)() + loaded, ok := msg.(initialLoadedMsg) + if !ok { + t.Fatalf("expected initialLoadedMsg, got %T", msg) + } + if loaded.errText == "" { + t.Fatal("offline scenario should set errText") + } +} + +// ---- 模式键分发 ---- + +func TestNormalModeKeyDispatch(t *testing.T) { + app := newReadyApp(t) + app.state.Mode = state.NormalMode + + // i -> 进入 InputMode + updated, _ := app.Update(keyRunes("i")) + app = updated.(*App) + if app.state.Mode != state.InputModeInput { + t.Fatalf("after i: mode=%v, want input", app.state.Mode) + } + + // q -> 退出 + app = newReadyApp(t) + app.state.Mode = state.NormalMode + if _, cmd := app.Update(keyRunes("q")); cmd == nil { + t.Fatal("q should return quit cmd") + } + + // Space -> Leader + app = newReadyApp(t) + app.state.Mode = state.NormalMode + updated, _ = app.Update(keyRunes(" ")) + app = updated.(*App) + if app.state.Mode != state.LeaderMode { + t.Fatalf("after space: mode=%v, want leader", app.state.Mode) + } + + // ? / : 在 NormalMode 下是预留动作(空操作),不应崩溃也不应开浮层 + app = newReadyApp(t) + app.state.Mode = state.NormalMode + app.Update(keyRunes("?")) + app.Update(keyRunes(":")) + + // 滚动键交给 stream(不 panic 即可) + app = newReadyApp(t) + app.state.Mode = state.NormalMode + app.Update(keyRunes("j")) + app.Update(keyRunes("k")) + app.Update(keyRunes("g")) + app.Update(keyRunes("G")) + app.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) + app.Update(tea.KeyMsg{Type: tea.KeyCtrlU}) +} + +func TestLeaderKeyDispatch(t *testing.T) { + cases := map[string]string{ + "p": "palette", + "s": "session_picker", + "h": "help", + } + for key, wantOverlay := range cases { + t.Run(key, func(t *testing.T) { + app := newReadyApp(t) + app.state.Mode = state.LeaderMode + updated, _ := app.Update(keyRunes(key)) + app = updated.(*App) + if app.state.Overlay.Active != wantOverlay { + t.Fatalf("leader %s: overlay=%q, want %q", key, app.state.Overlay.Active, wantOverlay) + } + if app.state.Mode != state.NormalMode { + t.Fatalf("leader suffix should reset to normal, mode=%v", app.state.Mode) + } + }) + } + // m -> toggle mode, f -> full access, c -> compact, l -> log(均返回 nil) + for _, key := range []string{"m", "f", "c", "l"} { + app := newReadyApp(t) + app.state.Mode = state.LeaderMode + if _, cmd := app.Update(keyRunes(key)); cmd != nil { + t.Fatalf("leader %s should return nil cmd", key) + } + } + // n -> 创建会话(有 client 时返回 create cmd) + app := newReadyApp(t) + app.state.Mode = state.LeaderMode + if _, cmd := app.Update(keyRunes("n")); cmd == nil { + t.Fatal("leader n should return create cmd") + } + // esc -> 回到 normal + app = newReadyApp(t) + app.state.Mode = state.LeaderMode + updated, _ := app.Update(tea.KeyMsg{Type: tea.KeyEsc}) + app = updated.(*App) + if app.state.Mode != state.NormalMode { + t.Fatalf("leader esc should reset to normal, mode=%v", app.state.Mode) + } +} + +func TestInputModeKeyDispatch(t *testing.T) { + app := newReadyApp(t) + // esc -> normal + updated, _ := app.Update(tea.KeyMsg{Type: tea.KeyEsc}) + app = updated.(*App) + if app.state.Mode != state.NormalMode { + t.Fatalf("esc: mode=%v", app.state.Mode) + } + // ctrl+p -> palette + app = newReadyApp(t) + updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) + app = updated.(*App) + if app.state.Overlay.Active != "palette" { + t.Fatalf("ctrl+p: overlay=%q", app.state.Overlay.Active) + } + // ctrl+l -> 日志提示 + app = newReadyApp(t) + app.Update(tea.KeyMsg{Type: tea.KeyCtrlL}) + if !lastContains(app, "Log viewer not yet available") { + t.Fatalf("ctrl+l not logged: %v", streamContents(app)) + } +} + +func TestRouteStreamKey(t *testing.T) { + app := newReadyApp(t) + if ok, _ := app.routeStreamKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}); !ok { + t.Fatal("j should route to stream") + } + if ok, _ := app.routeStreamKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}); ok { + t.Fatal("x should not route to stream") + } +} + +// ---- 鼠标 ---- + +func TestHandleMouseMsgMainViewAndOverlays(t *testing.T) { + // 主视图滚轮:MouseWheelUp 增加 offset,MouseWheelDown 减少(内容不足也允许推进) + app := newReadyApp(t) + app.Update(tea.MouseMsg{Type: tea.MouseWheelUp}) + if app.state.Layout.ScrollOffset == 0 { + t.Fatal("MouseWheelUp should increase scroll offset") + } + app.Update(tea.MouseMsg{Type: tea.MouseWheelDown}) + + // 浮层鼠标分发不 panic:组件按 Button 判定,故设置 Button + for _, active := range []string{"palette", "session_picker", "model_picker"} { + app := newReadyApp(t) + app.openOverlay(active) + app.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp}) + app.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, Y: 4}) + } +} + +// ---- View 各路径 ---- + +func TestViewOverlayAndMainPaths(t *testing.T) { + overlays := []string{"palette", "help", "session_picker", "model_picker", "confirm"} + for _, ov := range overlays { + t.Run(ov, func(t *testing.T) { + app := newReadyApp(t) + app.openOverlay(ov) + if app.state.Overlay.Active != ov { + app.openOverlay(ov) + } + view := app.View() + if view == "" { + t.Fatalf("%s view empty", ov) + } + }) + } + + // 主视图:error 行 + debug 行 + app := newReadyApp(t) + app.lastErr = "boom" + app.debug = true + view := app.View() + if !strings.Contains(view, "boom") { + t.Fatal("error line not rendered") + } + if !strings.Contains(view, "[debug]") { + t.Fatal("debug line not rendered") + } +} + +func TestViewInspectorLayoutBreakpoints(t *testing.T) { + for _, w := range []int{79, 90, 120} { + app := newReadyApp(t) + app.Update(tea.WindowSizeMsg{Width: w, Height: 30}) + if view := app.View(); view == "" { + t.Fatalf("width %d view empty", w) + } + } +} + +func TestViewZeroSizeFallback(t *testing.T) { + app := newReadyApp(t) + app.state.Layout.Width = 0 + app.state.Layout.Height = 0 + if view := app.View(); view == "" { + t.Fatal("zero-size view empty") + } +} + +// ---- 辅助 ---- + +func TestUtilityHelpers(t *testing.T) { + if emptyDash("") != "-" || emptyDash("x") != "x" { + t.Fatal("emptyDash wrong") + } + if inputModeName(state.NormalMode) != "normal" || inputModeName(state.LeaderMode) != "leader" || inputModeName(state.InputModeInput) != "input" { + t.Fatal("inputModeName wrong") + } + app := newReadyApp(t) + if app.activeSessionID() == "" { + t.Fatal("activeSessionID should be non-empty after load") + } + if app.activeSessionTitle() == "untitled" { + // 默认场景首个会话有标题 + t.Fatalf("activeSessionTitle=%q, want real title", app.activeSessionTitle()) + } + // 无活动会话 + app.state.Gateway.ActiveSess = nil + if app.activeSessionID() != "" || app.activeSessionTitle() != "untitled" { + t.Fatal("nil session fallback wrong") + } + // 无标题会话 -> 回退到 ID + app.state.Gateway.ActiveSess = &gateway.SessionSummary{ID: "id-only"} + if app.activeSessionTitle() != "id-only" { + t.Fatalf("title fallback wrong: %q", app.activeSessionTitle()) + } +} + +func TestSeparatorAndFitLine(t *testing.T) { + if s := separatorLineHelper(10); !strings.Contains(s, "─") { + t.Fatal("separatorLine missing dash") + } + if fitLine("abc", 0) != "abc" { + t.Fatal("fitLine width<=0 should return as-is") + } + if fitLine("abc", 1) != "" { + t.Fatal("fitLine target<=0 should return empty") + } +} + +// ---- 测试辅助函数 ---- + +type errSentinel string + +func (e errSentinel) Error() string { return string(e) } + +func keyRunes(s string) tea.Msg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} +} + +func lastContains(app *App, sub string) bool { + if len(app.state.Stream) == 0 { + return false + } + return strings.Contains(app.state.Stream[len(app.state.Stream)-1].Content, sub) +} + +func streamContents(app *App) []string { + out := make([]string, len(app.state.Stream)) + for i, e := range app.state.Stream { + out[i] = e.Content + } + return out +} + +// separatorLineHelper 以给定宽度渲染分隔线(覆盖 width<=0 分支)。 +func separatorLineHelper(width int) string { + app := NewApp(StartupConfig{Backend: "fake", Scenario: "default"}).(*App) + app.state.Layout.Width = width + return app.separatorLine() +} diff --git a/internal/tuiv2/components/coverage_test.go b/internal/tuiv2/components/coverage_test.go new file mode 100644 index 000000000..0285a50ad --- /dev/null +++ b/internal/tuiv2/components/coverage_test.go @@ -0,0 +1,251 @@ +package components + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/tuiv2/gateway" + "neo-code/internal/tuiv2/state" +) + +// ---- ConfirmOverlay ---- + +func TestConfirmOverlayLifecycle(t *testing.T) { + vs := state.NewViewState() + vs.Layout.Width = 40 + vs.Layout.Height = 12 + vs.Confirm = state.ConfirmState{Title: "Delete", Message: "sure?"} + c := NewConfirmOverlay(vs) + + if c.Init() != nil { + t.Fatal("Init should return nil") + } + // y / enter -> ConfirmYesMsg + for _, m := range []tea.Msg{keyMsg("y"), keyType(tea.KeyEnter)} { + _, cmd := c.Update(m) + if _, ok := cmd().(ConfirmYesMsg); !ok { + t.Fatalf("%v should emit ConfirmYesMsg", m) + } + } + // n / esc / ctrl+c -> ConfirmNoMsg + for _, m := range []tea.Msg{keyMsg("n"), keyType(tea.KeyEsc), keyType(tea.KeyCtrlC)} { + _, cmd := c.Update(m) + if _, ok := cmd().(ConfirmNoMsg); !ok { + t.Fatalf("%v should emit ConfirmNoMsg", m) + } + } + // 其它键与非按键 -> nil + if _, cmd := c.Update(keyMsg("z")); cmd != nil { + t.Fatal("unrelated key should return nil") + } + if _, cmd := c.Update(tea.WindowSizeMsg{}); cmd != nil { + t.Fatal("non-key msg should return nil") + } + if view := c.View(); !strings.Contains(view, "Delete") { + t.Fatalf("view missing title: %q", view) + } +} + +// ---- HelpOverlay ---- + +func TestHelpOverlayLifecycle(t *testing.T) { + vs := state.NewViewState() + vs.Layout.Width = 60 + vs.Layout.Height = 24 + vs.Overlay.Active = "help" + h := NewHelpOverlay(vs) + + if h.Init() != nil { + t.Fatal("Init should return nil") + } + // esc/ctrl+c/q/? 关闭浮层 + for _, m := range []tea.Msg{keyType(tea.KeyEsc), keyType(tea.KeyCtrlC), keyMsg("q"), keyMsg("?")} { + vs.Overlay.Active = "help" + _, _ = h.Update(m) + if vs.Overlay.Active != "" { + t.Fatalf("%v should close help overlay", m) + } + } + // 其它键不关闭 + vs.Overlay.Active = "help" + _, _ = h.Update(keyMsg("x")) + if vs.Overlay.Active != "help" { + t.Fatal("unrelated key should not close help") + } + // 非 KeyMsg + if _, cmd := h.Update(tea.WindowSizeMsg{}); cmd != nil { + t.Fatal("non-key should be nil") + } + if view := h.View(); !strings.Contains(view, "Keyboard Shortcuts") { + t.Fatalf("help view missing title: %q", view) + } + if padRight("ab", 5) != "ab " { + t.Fatal("padRight wrong") + } + if padRight("abcdef", 3) != "abcdef" { + t.Fatal("padRight should not truncate") + } +} + +// ---- AmbientStatus ---- + +func TestAmbientStatusVariants(t *testing.T) { + vs := state.NewViewState() + vs.Layout.Width = 80 + for _, phase := range []string{ + state.RuntimePhaseRunning, + state.RuntimePhaseWaitingPermission, + state.RuntimePhaseWaitingUser, + state.RuntimePhaseError, + state.RuntimePhaseCancelled, + state.RuntimePhaseIdle, + } { + vs.Runtime.Phase = phase + s := NewAmbientStatus(vs) + if s.Init() != nil { + t.Fatal("Init should be nil") + } + if _, cmd := s.Update(tea.KeyMsg{}); cmd != nil { + t.Fatal("Update should be nil") + } + if v := s.View(); v == "" { + t.Fatalf("phase %s produced empty view", phase) + } + } + // 模型回退 + vs.Gateway.ActiveModel = "" + if v := NewAmbientStatus(vs).View(); !strings.Contains(v, "model:-") { + t.Fatalf("model fallback missing: %q", v) + } + // 活动会话标题 + vs.Gateway.ActiveSess = &gateway.SessionSummary{Title: "My Session"} + if v := NewAmbientStatus(vs).View(); !strings.Contains(v, "My Session") { + t.Fatalf("session title missing: %q", v) + } +} + +// ---- SoftInspector ---- + +func TestSoftInspectorVariants(t *testing.T) { + vs := state.NewViewState() + vs.Layout.Width = 60 + vs.Layout.Height = 20 + vs.Layout.InspectorWidth = 30 + + // 隐藏 -> 空 + vs.Layout.ShowInspector = false + if NewSoftInspector(vs).View() != "" { + t.Fatal("hidden inspector should render empty") + } + + vs.Layout.ShowInspector = true + insp := NewSoftInspector(vs) + if insp.Init() != nil { + t.Fatal("Init should be nil") + } + if _, cmd := insp.Update(tea.KeyMsg{}); cmd != nil { + t.Fatal("Update should be nil") + } + if v := insp.View(); !strings.Contains(v, "Soft Inspector") { + t.Fatalf("missing title: %q", v) + } + + // 无会话 + vs.Gateway.Sessions = nil + insp.View() + + // 超过 3 个会话 -> "+N more" + vs.Gateway.Sessions = []gateway.SessionSummary{ + {ID: "1", Title: "one"}, {ID: "2", Title: "two"}, {ID: "3", Title: "three"}, + {ID: "4", Title: "four"}, {ID: "5", Title: "five"}, + } + // 活跃工具(start 未 end)+ 文件(end 带 delete 用 DiffDel) + vs.Stream = []state.StreamEntry{ + {Type: "tool_start", ToolName: "bash"}, + {Type: "tool_end", ToolName: "write", Content: "wrote internal/main.go"}, + {Type: "tool_end", ToolName: "rm", Content: "deleted a/b/c.yaml"}, + } + view := insp.View() + if !strings.Contains(view, "+2 more") { + t.Fatalf("expected '+2 more' for >3 sessions: %q", view) + } + + // token total != 0 分支 + vs.Runtime.Tokens = state.TokenUsage{Input: 1, Output: 2, Total: 99} + insp.View() +} + +func TestExtractFilePath(t *testing.T) { + cases := map[string]string{ + "wrote internal/main.go": "internal/main.go", + "changed config.yaml": "config.yaml", + "deleted a/b/c.txt": "a/b/c.txt", + "no path here": "", + "plainword": "", + } + for in, want := range cases { + if got := extractFilePath(in); got != want { + t.Fatalf("extractFilePath(%q)=%q, want %q", in, got, want) + } + } +} + +// ---- AgentStream 滚动与辅助 ---- + +func TestAgentStreamScrollAndHelpers(t *testing.T) { + vs := state.NewViewState() + vs.Layout.Width = 80 + vs.Layout.Height = 12 + vs.Stream = numberedEntries(20) + s := NewAgentStream(vs) + + if s.Init() != nil { + t.Fatal("Init should be nil") + } + // k/up 向上滚(offset 增加) + _, _ = s.Update(keyMsg("k")) + if vs.Layout.ScrollOffset == 0 { + t.Fatal("k should increase scroll offset") + } + // ctrl+u 半页向上 + before := vs.Layout.ScrollOffset + _, _ = s.Update(keyType(tea.KeyCtrlU)) + if vs.Layout.ScrollOffset <= before { + t.Fatal("ctrl+u should scroll further up") + } + // g 顶(max offset) + _, _ = s.Update(keyMsg("g")) + if vs.Layout.ScrollOffset == 0 { + t.Fatal("g should jump to top") + } + // G 底(offset 0,AutoScroll true) + _, _ = s.Update(keyMsg("G")) + if vs.Layout.ScrollOffset != 0 || !vs.Layout.AutoScroll { + t.Fatal("G should jump to bottom") + } + // j/down 与 ctrl+d 向下方向(先上移再下移) + _, _ = s.Update(keyMsg("k")) + _, _ = s.Update(keyMsg("j")) + _, _ = s.Update(keyType(tea.KeyCtrlD)) + + // halfPageSize >= 1 + if s.halfPageSize() < 1 { + t.Fatal("halfPageSize should be >= 1") + } + // headerText:手动滚动分支 + vs.Layout.AutoScroll = false + if !strings.Contains(s.headerText(), "scroll:") { + t.Fatalf("manual headerText should show scroll: %q", s.headerText()) + } + // renderToolContent 各分支 + renderToolContent("") + renderToolContent("path/to/file.go") + renderToolContent("config.json") + renderToolContent("plainword") + // clampScroll 边界 + if clampScroll(-1, 5) != 0 || clampScroll(10, 5) != 5 || clampScroll(3, 5) != 3 { + t.Fatal("clampScroll bounds wrong") + } +} diff --git a/internal/tuiv2/components/picker_test.go b/internal/tuiv2/components/picker_test.go new file mode 100644 index 000000000..32d1d28a4 --- /dev/null +++ b/internal/tuiv2/components/picker_test.go @@ -0,0 +1,302 @@ +package components + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/tuiv2/gateway" + "neo-code/internal/tuiv2/state" +) + +// 通用ViewState:带终端尺寸和默认模型/会话数据,用于驱动各 picker。 +func pickerState() *state.ViewState { + vs := state.NewViewState() + vs.Layout.Width = 70 + vs.Layout.Height = 24 + vs.Gateway.Models = []gateway.ModelInfo{ + {ID: "m-pro", Name: "Pro", Provider: "fake", Current: true}, + {ID: "m-fast", Name: "Fast", Provider: "fake", Current: false}, + } + vs.Gateway.Sessions = []gateway.SessionSummary{ + {ID: "s1", Title: "First", UpdatedAt: time.Now()}, + {ID: "s2", Title: "Second", UpdatedAt: time.Now()}, + } + return vs +} + +// ============ Palette ============ + +func TestPaletteComponentLifecycle(t *testing.T) { + vs := pickerState() + p := NewPalette(vs) + if p.Init() != nil { + t.Fatal("Init nil") + } + // 非 Key/Mouse 消息 -> nil + if _, cmd := p.Update(tea.WindowSizeMsg{}); cmd != nil { + t.Fatal("non-key should be nil") + } +} + +func TestPaletteHandleKeyAllBranches(t *testing.T) { + // esc/ctrl+c 关闭 + for _, m := range []tea.Msg{keyType(tea.KeyEsc), keyType(tea.KeyCtrlC)} { + vs := pickerState() + vs.Overlay.Active = "palette" + p := NewPalette(vs) + _, _ = p.Update(tea.KeyMsg(m.(tea.KeyMsg))) + if vs.Overlay.Active != "" { + t.Fatalf("%v should close palette", m) + } + } + // enter / space -> 选中并返回 PaletteCommandMsg + for _, m := range []tea.Msg{keyType(tea.KeyEnter), keyMsg(" ")} { + vs := pickerState() + p := NewPalette(vs) + _, cmd := p.Update(m) + if cmd == nil { + t.Fatalf("%v should select", m) + } + if _, ok := cmd().(PaletteCommandMsg); !ok { + t.Fatalf("%v should emit PaletteCommandMsg", m) + } + } + // up/k 下移、down/j 上移、backspace 删除查询 + vs := pickerState() + p := NewPalette(vs) + _, _ = p.Update(keyMsg("a")) // 输入 + _, _ = p.Update(keyMsg("b")) // 输入 + _, _ = p.Update(keyType(tea.KeyBackspace)) + if vs.Overlay.Query != "a" { + t.Fatalf("backspace query=%q want 'a'", vs.Overlay.Query) + } + _, _ = p.Update(keyMsg("k")) // up(已在顶不变化) + _, _ = p.Update(keyMsg("j")) // down + if vs.Overlay.Selected == 0 { + // 列表为空匹配时不会下移;这里 query="a" 无匹配 -> Selected 仍 0,属正常 + } + // enter 但无匹配 -> nil + vs.Overlay.Query = "zzzz" + if _, cmd := p.Update(keyType(tea.KeyEnter)); cmd != nil { + t.Fatal("enter with no match should return nil") + } +} + +func TestPaletteMatchedItemsTiers(t *testing.T) { + p := NewPalette(pickerState()) + if len(p.matchedItems()) == 0 { + t.Fatal("no-query should return all") + } + vs := pickerState() + p2 := NewPalette(vs) + vs.Overlay.Query = "mode" + first := p2.matchedItems()[0].Name + if first != "/mode" { + t.Fatalf("query 'mode' first=%q, want /mode", first) + } + vs.Overlay.Query = "/mode" // 带斜杠也应命中 + if p2.matchedItems()[0].Name != "/mode" { + t.Fatalf("query '/mode' first=%q", p2.matchedItems()[0].Name) + } + vs.Overlay.Query = "zzzz" + if len(p2.matchedItems()) != 0 { + t.Fatal("no-match query should return empty") + } +} + +func TestPaletteHandleMouseAndSelect(t *testing.T) { + vs := pickerState() + p := NewPalette(vs) + // wheel + _, _ = p.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp}) + _, _ = p.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown}) + // 左键非按下 -> nil + if _, cmd := p.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionRelease}); cmd != nil { + t.Fatal("non-press left should be nil") + } + // 左键按下命中首项 -> PaletteCommandMsg + _, cmd := p.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, Y: 2}) + if cmd == nil { + t.Fatal("left press on item should select") + } + // 左键按下越界 -> nil + if _, cmd := p.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, Y: 99}); cmd != nil { + t.Fatal("out-of-range click should be nil") + } +} + +func TestPaletteViewVariants(t *testing.T) { + vs := pickerState() + p := NewPalette(vs) + if v := p.View(); !strings.Contains(v, "/model") { + t.Fatalf("view missing items: %q", v) + } + // 无匹配 + vs.Overlay.Query = "zzzz" + if v := p.View(); !strings.Contains(v, "No matches") { + t.Fatalf("view missing no-match hint: %q", v) + } + // 零尺寸回退 + vs.Layout.Width = 0 + vs.Layout.Height = 0 + if v := p.View(); v == "" { + t.Fatal("zero-size view should not be empty") + } +} + +// ============ ModelPicker ============ + +func TestModelPickerLifecycleAndKey(t *testing.T) { + vs := pickerState() + m := NewModelPicker(vs) + if m.Init() != nil { + t.Fatal("Init nil") + } + if _, cmd := m.Update(tea.WindowSizeMsg{}); cmd != nil { + t.Fatal("non-key nil") + } + // esc/ctrl+c 关闭 + for _, kk := range []tea.KeyMsg{keyType(tea.KeyEsc), keyType(tea.KeyCtrlC)} { + vs.Overlay.Active = "model_picker" + _, _ = m.Update(kk) + } + // enter / space -> ModelSelectMsg + for _, kk := range []tea.KeyMsg{keyType(tea.KeyEnter), keyMsg(" ")} { + vs := pickerState() + mp := NewModelPicker(vs) + _, cmd := mp.Update(kk) + if cmd == nil { + t.Fatalf("%v should select model", kk) + } + if _, ok := cmd().(ModelSelectMsg); !ok { + t.Fatalf("want ModelSelectMsg") + } + } + // 导航 + backspace + 输入 + mp := NewModelPicker(pickerState()) + _, _ = mp.Update(keyMsg("f")) + _, _ = mp.Update(keyType(tea.KeyBackspace)) + _, _ = mp.Update(keyMsg("k")) + _, _ = mp.Update(keyMsg("j")) + // enter 无匹配 -> nil + vs2 := pickerState() + mp2 := NewModelPicker(vs2) + vs2.Overlay.Query = "zzzz" + if _, cmd := mp2.Update(keyType(tea.KeyEnter)); cmd != nil { + t.Fatal("enter no-match should be nil") + } +} + +func TestModelPickerMatchedAndMouseAndView(t *testing.T) { + vs := pickerState() + m := NewModelPicker(vs) + if len(m.matchedModels()) != 2 { + t.Fatal("no-query should return all models") + } + vs.Overlay.Query = "fast" + if first := m.matchedModels()[0].ID; first != "m-fast" { + t.Fatalf("query fast first=%q", first) + } + vs.Overlay.Query = "zzzz" + if len(m.matchedModels()) != 0 || !strings.Contains(m.View(), "No models") { + t.Fatal("no-match models handling wrong") + } + // 鼠标 + mm := NewModelPicker(pickerState()) + _, _ = mm.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp}) + _, _ = mm.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown}) + if _, cmd := mm.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionRelease}); cmd != nil { + t.Fatal("non-press nil") + } + if _, cmd := mm.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, Y: 4}); cmd == nil { + t.Fatal("left press should select") + } + if _, cmd := mm.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, Y: 99}); cmd != nil { + t.Fatal("out-of-range nil") + } +} + +// ============ SessionPicker ============ + +func TestSessionPickerLifecycleAndKey(t *testing.T) { + vs := pickerState() + s := NewSessionPicker(vs) + if s.Init() != nil { + t.Fatal("Init nil") + } + if _, cmd := s.Update(tea.WindowSizeMsg{}); cmd != nil { + t.Fatal("non-key nil") + } + // esc/ctrl+c 关闭 + for _, kk := range []tea.KeyMsg{keyType(tea.KeyEsc), keyType(tea.KeyCtrlC)} { + vs.Overlay.Active = "session_picker" + _, _ = s.Update(kk) + } + // enter / space -> SessionSelectMsg + for _, kk := range []tea.KeyMsg{keyType(tea.KeyEnter), keyMsg(" ")} { + sp := NewSessionPicker(pickerState()) + _, cmd := sp.Update(kk) + if cmd == nil { + t.Fatalf("%v should select session", kk) + } + if _, ok := cmd().(SessionSelectMsg); !ok { + t.Fatal("want SessionSelectMsg") + } + } + // ctrl+d -> SessionDeleteMsg + sp := NewSessionPicker(pickerState()) + if _, cmd := sp.Update(keyType(tea.KeyCtrlD)); cmd == nil { + t.Fatal("ctrl+d should emit SessionDeleteMsg") + } + // 导航 + 输入 + backspace + enter 无匹配 + sp2 := NewSessionPicker(pickerState()) + _, _ = sp2.Update(keyMsg("s")) + _, _ = sp2.Update(keyType(tea.KeyBackspace)) + _, _ = sp2.Update(keyMsg("j")) + _, _ = sp2.Update(keyMsg("k")) + sp2.state.Overlay.Query = "zzzz" + if _, cmd := sp2.Update(keyType(tea.KeyEnter)); cmd != nil { + t.Fatal("enter no-match should be nil") + } +} + +func TestSessionPickerMatchedMouseView(t *testing.T) { + vs := pickerState() + s := NewSessionPicker(vs) + if len(s.matchedSessions()) != 2 { + t.Fatal("no-query all sessions") + } + vs.Overlay.Query = "second" + if len(s.matchedSessions()) != 1 { + t.Fatalf("query second len=%d", len(s.matchedSessions())) + } + vs.Overlay.Query = "zzzz" + if len(s.matchedSessions()) != 0 || !strings.Contains(s.View(), "No sessions") { + t.Fatal("no-match sessions wrong") + } + // 空标题会话 -> "untitled" + vs2 := pickerState() + vs2.Gateway.Sessions = []gateway.SessionSummary{{ID: "x", Title: ""}} + if v := NewSessionPicker(vs2).View(); !strings.Contains(v, "untitled") { + t.Fatalf("empty title should show untitled: %q", v) + } + // 鼠标 + sm := NewSessionPicker(pickerState()) + _, _ = sm.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp}) + _, _ = sm.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown}) + if _, cmd := sm.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, Y: 4}); cmd == nil { + t.Fatal("left press should select") + } + if _, cmd := sm.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, Y: 99}); cmd != nil { + t.Fatal("out-of-range nil") + } + // 零时间戳日期回退 + vs3 := pickerState() + vs3.Gateway.Sessions = []gateway.SessionSummary{{ID: "z", Title: "Z"}} + if v := NewSessionPicker(vs3).View(); !strings.Contains(v, "Z") { + t.Fatal("zero-time session view wrong") + } +} diff --git a/internal/tuiv2/components/prompt_keys_test.go b/internal/tuiv2/components/prompt_keys_test.go new file mode 100644 index 000000000..28fdae552 --- /dev/null +++ b/internal/tuiv2/components/prompt_keys_test.go @@ -0,0 +1,178 @@ +package components + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/tuiv2/state" +) + +// 覆盖 CommandPrompt 的删除、光标移动、权限/问答模式全分支。 +func TestCommandPromptEditingAndModes(t *testing.T) { + vs := promptState() + p := NewCommandPrompt(vs) + + // insertRunes 空切片保护 + p.insertRunes(nil) + // deleteBeforeCursor 在光标=0 时不动作 + p.deleteBeforeCursor() + // 正常编辑:插入、移动光标、删除 + p.insertText("hello") + p.moveCursor(-2) // 光标到 index 3 + p.deleteBeforeCursor() // 删除 index 2 的 'l' -> "helo",光标 2 + if vs.Input.Text != "helo" { + t.Fatalf("after deleteBeforeCursor text=%q", vs.Input.Text) + } + p.deleteAtCursor() // 删除 index 2 的 'l' -> "heo" + if vs.Input.Text != "heo" { + t.Fatalf("after deleteAtCursor text=%q", vs.Input.Text) + } + // deleteAtCursor 在末尾不动作 + p.moveCursor(100) + p.deleteAtCursor() + // home/end + p.Update(keyType(tea.KeyHome)) + if vs.Input.Cursor != 0 { + t.Fatalf("home cursor=%d", vs.Input.Cursor) + } + p.Update(keyType(tea.KeyEnd)) + // left/right 移动 + p.Update(keyType(tea.KeyLeft)) + p.Update(keyType(tea.KeyRight)) + // delete 键 + p.Update(keyType(tea.KeyDelete)) + // clearText + p.clearText() + if vs.Input.Text != "" { + t.Fatal("clearText failed") + } + // clampInt 边界 + if clampInt(5, 0, 3) != 3 || clampInt(-1, 0, 3) != 0 || clampInt(2, 0, 3) != 2 { + t.Fatal("clampInt wrong") + } + // runeLen + if runeLen("你好") != 2 { + t.Fatal("runeLen wrong") + } +} + +func TestCommandPromptPermissionKeyFull(t *testing.T) { + vs := promptState() + vs.Input.Mode = state.InputStateModePermissionResponse + vs.Input.Prompt = "允许写入?" + p := NewCommandPrompt(vs) + // 渲染权限视图 + if v := p.View(); v == "" { + t.Fatal("permission view empty") + } + // y/n/d/a 决策 + for _, decision := range []string{"y", "n", "d", "a"} { + vs2 := promptState() + vs2.Input.Mode = state.InputStateModePermissionResponse + pp := NewCommandPrompt(vs2) + _, cmd := pp.Update(keyMsg(decision)) + if cmd == nil { + t.Fatalf("%s should emit PermissionActionMsg", decision) + } + } + // 大写 Y + vs3 := promptState() + vs3.Input.Mode = state.InputStateModePermissionResponse + pp3 := NewCommandPrompt(vs3) + _, cmd := pp3.Update(keyMsg("Y")) + if cmd == nil { + t.Fatal("Y should emit PermissionActionMsg") + } + // esc -> PromptCancelMsg + vs4 := promptState() + vs4.Input.Mode = state.InputStateModePermissionResponse + pp4 := NewCommandPrompt(vs4) + _, cmd = pp4.Update(keyType(tea.KeyEsc)) + if _, ok := cmd().(PromptCancelMsg); !ok { + t.Fatal("esc should emit PromptCancelMsg") + } + // left/right/backspace + 可打印字符输入 + vs5 := promptState() + vs5.Input.Mode = state.InputStateModePermissionResponse + pp5 := NewCommandPrompt(vs5) + pp5.Update(keyMsg("x")) + pp5.Update(keyType(tea.KeyLeft)) + pp5.Update(keyType(tea.KeyRight)) + pp5.Update(keyType(tea.KeyBackspace)) +} + +func TestCommandPromptQuestionKeyFull(t *testing.T) { + vs := promptState() + vs.Input.Mode = state.InputStateModeQuestionAnswer + vs.Input.Prompt = "选哪个?" + vs.Input.Options = []string{"甲", "乙"} + p := NewCommandPrompt(vs) + if v := p.View(); v == "" { + t.Fatal("question view empty") + } + // 空文本回车 -> nil + if _, cmd := p.Update(keyType(tea.KeyEnter)); cmd != nil { + t.Fatal("empty enter should be nil") + } + // 输入后回车 -> QuestionAnswerMsg + p.Update(keyMsg("1")) + _, cmd := p.Update(keyType(tea.KeyEnter)) + if cmd == nil { + t.Fatal("enter with text should emit QuestionAnswerMsg") + } + if _, ok := cmd().(QuestionAnswerMsg); !ok { + t.Fatal("want QuestionAnswerMsg") + } + // esc -> PromptCancelMsg + vs2 := promptState() + vs2.Input.Mode = state.InputStateModeQuestionAnswer + pp := NewCommandPrompt(vs2) + _, cmd = pp.Update(keyType(tea.KeyEsc)) + if _, ok := cmd().(PromptCancelMsg); !ok { + t.Fatal("esc should emit PromptCancelMsg") + } + // left/right/delete + 可打印输入 + pp.Update(keyMsg("z")) + pp.Update(keyType(tea.KeyLeft)) + pp.Update(keyType(tea.KeyRight)) + pp.Update(keyType(tea.KeyDelete)) +} + +func TestCommandPromptInitAndCursorBlink(t *testing.T) { + vs := promptState() + p := NewCommandPrompt(vs) + if cmd := p.Init(); cmd == nil { + t.Fatal("Init should start cursor blink") + } else { + // CursorBlinkMsg 翻转可见性并续订 + if msg := cmd(); msg == nil { + t.Fatal("blink cmd produced nil msg") + } + } + _, cmd := p.Update(CursorBlinkMsg{}) + if cmd == nil { + t.Fatal("CursorBlinkMsg should renew blink cmd") + } +} + +func TestCommandPromptMessageLinesHelpers(t *testing.T) { + vs := promptState() + vs.Input.Mode = state.InputStateModeMessage + p := NewCommandPrompt(vs) + // 普通 message 模式 View + if v := p.View(); v == "" { + t.Fatal("message view empty") + } + // wrapText:超宽切分 + wrapped := wrapText("短文本短文本短文本短文本短文本短文本短文本短文本", 6) + if len(wrapped) < 2 { + t.Fatalf("wrapText should split: %v", wrapped) + } + wrapText("x", 0) // width<=0 分支 + // contentWidth 回退 + vs.Layout.Width = 0 + if p.contentWidth() != 80 { + t.Fatal("contentWidth fallback wrong") + } +} diff --git a/internal/tuiv2/gateway/real_test.go b/internal/tuiv2/gateway/real_test.go new file mode 100644 index 000000000..17aff4148 --- /dev/null +++ b/internal/tuiv2/gateway/real_test.go @@ -0,0 +1,39 @@ +package gateway + +import ( + "context" + "errors" + "testing" +) + +// RealClient 是 Phase 20 占位,所有方法应返回保留错误(Close 除外,返回 nil)。 +func TestRealClientReservedErrors(t *testing.T) { + c := NewRealClient() + ctx := context.Background() + + checks := []struct { + name string + fn func() error + }{ + {"Health", func() error { _, err := c.Health(ctx); return err }}, + {"ListSessions", func() error { _, err := c.ListSessions(ctx); return err }}, + {"LoadSession", func() error { _, err := c.LoadSession(ctx, "s"); return err }}, + {"CreateSession", func() error { _, err := c.CreateSession(ctx); return err }}, + {"SendMessage", func() error { _, err := c.SendMessage(ctx, "s", "hi"); return err }}, + {"CancelRun", func() error { return c.CancelRun(ctx, "s", "r") }}, + {"SubscribeEvents", func() error { _, err := c.SubscribeEvents(ctx, "s"); return err }}, + {"ResolvePermission", func() error { return c.ResolvePermission(ctx, PermissionDecision{}) }}, + {"AnswerUserQuestion", func() error { return c.AnswerUserQuestion(ctx, UserQuestionAnswer{}) }}, + {"ListModels", func() error { _, err := c.ListModels(ctx); return err }}, + {"SetModel", func() error { return c.SetModel(ctx, "s", "m") }}, + {"GetModel", func() error { _, err := c.GetModel(ctx, "s"); return err }}, + } + for _, ch := range checks { + if err := ch.fn(); !errors.Is(err, errRealClientReserved) { + t.Fatalf("%s should return errRealClientReserved, got %v", ch.name, err) + } + } + if err := c.Close(); err != nil { + t.Fatalf("Close should return nil, got %v", err) + } +} diff --git a/internal/tuiv2/theme/coverage_test.go b/internal/tuiv2/theme/coverage_test.go new file mode 100644 index 000000000..632a119af --- /dev/null +++ b/internal/tuiv2/theme/coverage_test.go @@ -0,0 +1,119 @@ +package theme + +import "testing" + +// 覆盖所有样式工厂函数与剩余符号/工具分支。 +func TestStyleFactoriesRenderNonEmpty(t *testing.T) { + styles := []func() string{ + func() string { return BaseStyle().Render("x") }, + func() string { return AccentStyle().Render("x") }, + func() string { return SuccessStyle().Render("x") }, + func() string { return WarningStyle().Render("x") }, + func() string { return ErrorStyle().Render("x") }, + func() string { return MutedStyle().Render("x") }, + func() string { return SubtleStyle().Render("x") }, + func() string { return ToolNameStyle().Render("x") }, + func() string { return FilePathStyle().Render("x") }, + func() string { return CodeBlockStyle().Render("x") }, + func() string { return InfoStyle().Render("x") }, + func() string { return TimestampStyle().Render("x") }, + } + for i, s := range styles { + if s() == "" { + t.Fatalf("style %d rendered empty", i) + } + } +} + +func TestPadRightAndTruncateBranches(t *testing.T) { + if PadRight("ab", 5) != "ab " { + t.Fatal("PadRight should pad") + } + if PadRight("abcdef", 3) != "abcdef" { + t.Fatal("PadRight should not truncate") + } + if Truncate("abc", 0) != "" { + t.Fatal("Truncate max<=0 should return empty") + } + if Separator() == "" { + t.Fatal("Separator empty") + } +} + +func TestStatusSymbolAllPhases(t *testing.T) { + for _, phase := range []string{ + PhaseRunning, + PhaseWaiting, + PhaseWaitingPermission, + PhaseWaitingUser, + PhaseError, + PhaseCancelled, + PhaseIdle, + "unknown", // default 分支 + } { + if StatusSymbol(phase) == "" { + t.Fatalf("StatusSymbol(%q) empty", phase) + } + } +} + +func TestStreamPrefixAllBranches(t *testing.T) { + cases := map[string]struct{}{ + "tool_end": {}, + "tool_finished": {}, + "run_finished": {}, + "run_cancelled": {}, + "tool_start": {}, + "agent_chunk": {}, + "run_started": {}, + "permission_requested": {}, + "error": {}, + "gateway_offline": {}, + "message": {}, + "unknown_type": {}, // default 分支 + } + for entryType := range cases { + if StreamPrefix(entryType) == "" { + t.Fatalf("StreamPrefix(%q) empty", entryType) + } + } +} + +func TestSymbolsSetsAndDetection(t *testing.T) { + // 真实环境 Symbols() 不为空 + set := Symbols() + if set.AccentBar == "" { + t.Fatal("Symbols() returned empty AccentBar") + } + // 两个符号集合字段都可用 + if UnicodeSymbols.Success == "" || ASCIISymbols.Success == "" { + t.Fatal("symbol sets empty") + } + // TERM=linux 也应降级 ASCII + if !DetectASCIISymbolsFromEnv(func(key string) string { + if key == "TERM" { + return "linux" + } + return "" + }) { + t.Fatal("TERM=linux should force ASCII") + } + // 普通终端不降级 + if DetectASCIISymbolsFromEnv(func(key string) string { + if key == "TERM" { + return "xterm-256color" + } + return "" + }) { + t.Fatal("xterm should not force ASCII") + } +} + +func TestDisplayWidthEdgeCases(t *testing.T) { + if DisplayWidth("") != 0 { + t.Fatal("empty width should be 0") + } + if DisplayWidth("abc") != 3 { + t.Fatal("ascii width wrong") + } +}