diff --git a/docs/tui-v2/tui-v2-implementation-guide.md b/docs/tui-v2/tui-v2-implementation-guide.md index ae3eaa33..80f32df5 100644 --- a/docs/tui-v2/tui-v2-implementation-guide.md +++ b/docs/tui-v2/tui-v2-implementation-guide.md @@ -308,4 +308,15 @@ TUI v2 的 Gateway 通信层有两个选择: --- +### 后续 Phase 进展(tui-v2-issues 规划) + +早期 Phase 1–4 之外,TUI v2 按 `~/meno/tui-v2-issues/` 下的分阶段规划持续推进: + +- **Phase 9(交互增强)**:Leader 键、模型选择器、确认弹窗、鼠标、命令路由(已交付,见 git 历史 `feat(tui-v2): Phase 9` 系列 commit)。 +- **Phase 10(全新快捷键系统)**:三层键位(Input/Normal/Leader)补全 Input Mode 行编辑(Ctrl+A/E/K/W)、Normal Mode 整页翻页(Ctrl+F/B)与 stream 搜索(`/` + `n/N` + stale 提示)、`:` 命令行(q/debug/help/compact/mode)、Leader 动作调整(m=模型选择器、c=取消运行、r=重试、Space=切上一会话)、模式指示配色、Ctrl+D 上下文分发、Overlay typed 常量。详细键位与验收标准见规划文档 `phase-10-keybinding-system.md`。 + +> 注:Phase 10 不引入 `ModeChangedMsg`——模式切换通过同步修改 `ViewState.Mode` 完成(遵循 AGENTS.md「代码为准」原则,规划文档已同步)。 + +--- + *本指南是 TUI v2 文档集的子文档。架构总览见 [架构导航](./tui-v2-architecture-hub.md),视觉交互见 [UI/UX](./tui-v2-ui-ux-design.md),数据契约见 [数据/契约](./tui-v2-data-and-gateway.md)。* diff --git a/internal/tuiv2/app.go b/internal/tuiv2/app.go index 51bc20c5..06691214 100644 --- a/internal/tuiv2/app.go +++ b/internal/tuiv2/app.go @@ -8,7 +8,6 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "neo-code/internal/tuiv2/components" "neo-code/internal/tuiv2/gateway" @@ -46,9 +45,14 @@ type App struct { // Ctrl+C 双退保护 lastCtrlC time.Time + // Leader 重试与切换上一会话所需的私有运行时状态(不入 ViewState,非渲染状态) + lastUserText string // 最近一次发送的用户消息文本,供 Space r 重试 + prevSessionID string // 上一个会话 ID,供 Space Space 切换 + ambientStatus *components.AmbientStatus agentStream *components.AgentStream commandPrompt *components.CommandPrompt + cmdLine *components.CmdLine softInspector *components.SoftInspector palette *components.Palette helpOverlay *components.HelpOverlay @@ -71,6 +75,7 @@ func NewApp(cfg StartupConfig) tea.Model { ambientStatus: components.NewAmbientStatus(viewState), agentStream: components.NewAgentStream(viewState), commandPrompt: components.NewCommandPrompt(viewState), + cmdLine: components.NewCmdLine(viewState), softInspector: components.NewSoftInspector(viewState), palette: components.NewPalette(viewState), helpOverlay: components.NewHelpOverlay(viewState), @@ -113,6 +118,15 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(a.state.Stream) > beforeStreamLen { a.state.Layout.AutoScroll = true a.state.Layout.ScrollOffset = 0 + // stream 增长后,已有搜索结果不再包含新内容,标记 stale 提示用户。 + if len(a.state.Search.Matches) > 0 { + a.state.Search.Stale = true + } + } + // 新 run 开始或会话相关事件时清理 Normal 子状态(搜索/Ex),避免跨 run/会话残留。 + switch msg.event.Type { + case gateway.EventRunStarted: + a.clearSearchAndEx() } a.bindComponents() if a.state.Runtime.Phase == state.RuntimePhaseError && len(a.state.Stream) > 0 { @@ -128,6 +142,25 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case components.PromptCancelMsg: a.cancelPrompt(msg.Mode) return a, nil + case components.ExCommandMsg: + // : 命令行提交:执行命令并关闭 Ex overlay(命令打开新 overlay 者除外)。 + cmd := a.executeExCommand(msg.Command) + if a.state.Overlay.Active == state.OverlayEx { + a.closeOverlay() + a.state.Ex = state.ExState{} + } + return a, cmd + case components.SearchSubmitMsg: + // / 搜索提交:执行扫描并关闭搜索 overlay 回 Normal。 + cmd := a.executeSearch(msg.Query) + a.closeOverlay() + a.state.Search.Active = false + return a, cmd + case components.CmdLineCancelMsg: + // Esc/Ctrl+C 取消 Ex/Search 输入:关闭 overlay 回 Normal。 + a.closeOverlay() + a.clearSearchAndEx() + return a, nil case components.SlashCommandMsg: return a, a.handleSlashCommand(msg) case components.PaletteCommandMsg: @@ -186,15 +219,15 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (a *App) View() string { // 浮层模式下覆盖主视图 switch a.state.Overlay.Active { - case "palette": + case state.OverlayPalette: return a.fitViewToTerminal(a.palette.View()) - case "help": + case state.OverlayHelp: return a.fitViewToTerminal(a.helpOverlay.View()) - case "session_picker": + case state.OverlaySessionPicker: return a.fitViewToTerminal(a.sessionPicker.View()) - case "model_picker": + case state.OverlayModelPicker: return a.fitViewToTerminal(a.modelPicker.View()) - case "confirm": + case state.OverlayConfirm: return a.fitViewToTerminal(a.confirmOverlay.View()) } lines := []string{ @@ -204,31 +237,19 @@ func (a *App) View() string { if a.lastErr != "" { lines = append(lines, theme.ErrorStyle().Render(" "+theme.StatusSymbol(theme.PhaseError)+" "+a.lastErr)) } - lines = append(lines, a.mainArea(), a.separatorLine(), a.commandPrompt.View()) + // Ex/Search 输入 overlay:渲染 cmdline 输入行(替代普通 prompt 输入区), + // 不覆盖主视图,仍保留 ambient/stream 可见。 + promptView := a.commandPrompt.View() + if a.state.Overlay.Active == state.OverlayEx || a.state.Overlay.Active == state.OverlaySearch { + promptView = a.cmdLine.View() + "\n" + a.commandPrompt.ModeLine() + } + lines = append(lines, a.mainArea(), a.separatorLine(), promptView) if a.debug { lines = append(lines, "", theme.WarningStyle().Render(a.debugLine())) } return a.fitViewToTerminal(strings.Join(lines, "\n")) } -// applyWindowSize 更新布局尺寸,并按 Focus-Only 断点计算 Soft Inspector 状态。 -func (a *App) applyWindowSize(width int, height int) { - a.state.Layout.Width = width - a.state.Layout.Height = height - switch { - case width < inspectorHiddenWidth: - a.state.Layout.ShowInspector = false - a.state.Layout.InspectorWidth = 0 - case width < inspectorWideMin: - a.state.Layout.ShowInspector = true - a.state.Layout.InspectorWidth = width - default: - a.state.Layout.ShowInspector = true - a.state.Layout.InspectorWidth = inspectorWideWidth - } -} - -// routeComponents 将全局消息转发给各静态布局组件。 func (a *App) routeComponents(msg tea.Msg) tea.Cmd { _, statusCmd := a.ambientStatus.Update(msg) _, streamCmd := a.agentStream.Update(msg) @@ -252,13 +273,13 @@ func (a *App) routeStreamKey(msg tea.KeyMsg) (bool, tea.Cmd) { func (a *App) handleMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // 浮层激活时,鼠标交给浮层组件 switch a.state.Overlay.Active { - case "palette": + case state.OverlayPalette: _, cmd := a.palette.Update(msg) return a, cmd - case "session_picker": + case state.OverlaySessionPicker: _, cmd := a.sessionPicker.Update(msg) return a, cmd - case "model_picker": + case state.OverlayModelPicker: _, cmd := a.modelPicker.Update(msg) return a, cmd } @@ -279,31 +300,37 @@ 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 a.state.Overlay.Active != state.OverlayNone { if msg.String() == "esc" { - a.state.Overlay.Active = "" - a.state.Overlay.Query = "" - a.state.Overlay.Selected = 0 + // 关闭 overlay 并清理 Normal 子状态(搜索/Ex 输入)。 + a.closeOverlay() + a.clearSearchAndEx() return a, nil } } // 浮层激活时,键盘消息交给对应浮层组件处理 switch a.state.Overlay.Active { - case "palette": + case state.OverlayPalette: _, cmd := a.palette.Update(msg) return a, cmd - case "help": + case state.OverlayHelp: _, cmd := a.helpOverlay.Update(msg) return a, cmd - case "session_picker": + case state.OverlaySessionPicker: _, cmd := a.sessionPicker.Update(msg) return a, cmd - case "model_picker": + case state.OverlayModelPicker: _, cmd := a.modelPicker.Update(msg) return a, cmd - case "confirm": + case state.OverlayConfirm: _, cmd := a.confirmOverlay.Update(msg) return a, cmd + case state.OverlayEx, state.OverlaySearch: + // Ex/Search 输入 overlay:所有按键路由给 cmdline 组件, + // 由它处理字符/Backspace/Enter/Esc(Esc/Ctrl+C 已在上方被全局拦截 + // 关闭 overlay,此处主要处理字符输入与提交)。 + _, cmd := a.cmdLine.Update(msg) + return a, cmd } switch a.state.Mode { case state.LeaderMode: @@ -316,7 +343,20 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // handleInputModeKey 处理 Input Mode 下的键盘输入。 +// +// 分层约定(plan-v4):模式切换键在此拦截,不传给 prompt 编辑器。 +// Ctrl+D 不进 MatchInputKey,由本函数按输入框是否为空决定: +// - 输入为空 → EOF 退出程序 +// - 输入非空 → 删除光标后字符(等同 delete),委派 prompt 处理 func (a *App) handleInputModeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Ctrl+D 上下文分发 + if msg.String() == "ctrl+d" { + if strings.TrimSpace(a.state.Input.Text) == "" { + return a, tea.Quit + } + _, cmd := a.commandPrompt.Update(tea.KeyMsg{Type: tea.KeyDelete}) + return a, cmd + } action := keymap.MatchInputKey(msg.String()) switch action { case keymap.ActionCtrlC: @@ -325,7 +365,7 @@ func (a *App) handleInputModeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.state.Mode = state.NormalMode return a, nil case keymap.ActionOpenPalette: - a.openOverlay("palette") + a.openOverlay(state.OverlayPalette) return a, nil case keymap.ActionLogViewer: a.appendStream(state.StreamEntry{ @@ -342,89 +382,6 @@ func (a *App) handleInputModeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } -// 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 实现,此处预留 - 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) - 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 - 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 - } -} - -// 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 { @@ -466,6 +423,8 @@ func (a *App) handleSubmitMessage(msg components.SubmitMessageMsg) tea.Cmd { if strings.TrimSpace(msg.Text) == "" { return nil } + // 记录最近一次用户输入,供 Leader Space r 重试使用。 + a.lastUserText = msg.Text // 立即将用户消息追加到 Stream 中以便渲染 a.appendStream(state.StreamEntry{ ID: fmt.Sprintf("user-msg-%d", time.Now().UnixNano()), @@ -526,7 +485,7 @@ func (a *App) cancelPrompt(mode string) { } // openOverlay 打开指定类型的浮层,重置搜索状态。 -func (a *App) openOverlay(overlayType string) { +func (a *App) openOverlay(overlayType state.OverlayType) { a.state.Overlay.Active = overlayType a.state.Overlay.Query = "" a.state.Overlay.Selected = 0 @@ -534,24 +493,23 @@ func (a *App) openOverlay(overlayType string) { // closeOverlay 关闭当前浮层,重置搜索与选中状态。 func (a *App) closeOverlay() { - a.state.Overlay.Active = "" + a.state.Overlay.Active = state.OverlayNone 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") + a.openOverlay(state.OverlayHelp) return nil case "/session": - a.openOverlay("session_picker") + a.openOverlay(state.OverlaySessionPicker) return nil case "/model": - a.openOverlay("model_picker") + a.openOverlay(state.OverlayModelPicker) return nil case "/mode": return a.toggleAgentMode() @@ -579,13 +537,13 @@ func (a *App) handleSlashCommand(msg components.SlashCommandMsg) tea.Cmd { case "/exit", "/quit": return tea.Quit case "/help": - a.openOverlay("help") + a.openOverlay(state.OverlayHelp) return nil case "/session": - a.openOverlay("session_picker") + a.openOverlay(state.OverlaySessionPicker) return nil case "/model": - a.openOverlay("model_picker") + a.openOverlay(state.OverlayModelPicker) return nil case "/mode": return a.toggleAgentMode() @@ -608,10 +566,15 @@ func (a *App) handleSlashCommand(msg components.SlashCommandMsg) tea.Cmd { } // handleSessionSelect 处理会话切换操作。 +// +// 切换前先把当前活动会话 ID 存入 prevSessionID,供 Leader Space Space 回切。 func (a *App) handleSessionSelect(msg components.SessionSelectMsg) tea.Cmd { if a.client == nil { return nil } + if current := a.activeSessionID(); current != "" && current != msg.Session.ID { + a.prevSessionID = current + } a.state.Gateway.ActiveSess = &msg.Session return loadSessionCmd(a.client, msg.Session.ID) } @@ -710,7 +673,7 @@ func (a *App) openConfirm(title, message, action string, data map[string]any) { Action: action, Data: data, } - a.state.Overlay.Active = "confirm" + a.state.Overlay.Active = state.OverlayConfirm a.state.Overlay.Query = "" a.state.Overlay.Selected = 0 } @@ -734,97 +697,6 @@ func (a *App) activeSessionTitle() string { return "untitled" } -// mainArea 渲染中部区域,按终端宽度决定 Inspector 右侧或纵向压缩显示。 -func (a *App) mainArea() string { - streamView := a.agentStream.View() - if !a.state.Layout.ShowInspector { - return streamView - } - inspectorView := a.softInspector.View() - if a.state.Layout.Width >= inspectorWideMin { - return lipgloss.JoinHorizontal(lipgloss.Top, streamView, " ", inspectorView) - } - return lipgloss.JoinVertical(lipgloss.Left, streamView, "", a.separatorLine(), inspectorView) -} - -// separatorLine 渲染单条细线,用于区分主要区域而不使用边框。 -func (a *App) separatorLine() string { - width := a.state.Layout.Width - if width <= 0 { - width = 48 - } - return theme.SubtleStyle().Render(strings.Repeat("─", width)) -} - -// fitViewToTerminal 将视图约束到当前终端尺寸,避免 resize 后自动换行或旧行残留。 -func (a *App) fitViewToTerminal(view string) string { - width := a.state.Layout.Width - height := a.state.Layout.Height - if width <= 0 { - return view - } - lines := strings.Split(view, "\n") - for i, line := range lines { - lines[i] = fitLine(line, width) - } - if height > 0 { - switch { - case len(lines) > height: - lines = lines[:height] - case len(lines) < height: - for len(lines) < height { - lines = append(lines, strings.Repeat(" ", width-1)) - } - } - } - return strings.Join(lines, "\n") -} - -// fitLine 截断并补齐单行显示宽度,保留 ANSI 样式同时防止终端自动 wrap。 -func fitLine(line string, width int) string { - if width <= 0 { - return line - } - target := width - 1 - if target <= 0 { - return "" - } - fitted := theme.Truncate(line, target) - lineWidth := theme.DisplayWidth(fitted) - if lineWidth < target { - fitted += strings.Repeat(" ", target-lineWidth) - } - return fitted -} - -// applyInitialLoaded 将 Gateway 初始 RPC 结果写入 ViewState。 -func (a *App) applyInitialLoaded(msg initialLoadedMsg) { - a.lastErr = msg.errText - a.state.Gateway.Connected = msg.connected - a.state.Gateway.Sessions = append([]gateway.SessionSummary(nil), msg.sessions...) - a.state.Gateway.Models = append([]gateway.ModelInfo(nil), msg.models...) - a.state.Gateway.ActiveModel = msg.activeModel - a.eventCh = msg.eventCh - if msg.errText != "" { - a.state.Runtime.Phase = state.RuntimePhaseError - } - if len(msg.sessions) > 0 { - active := msg.sessions[0] - a.state.Gateway.ActiveSess = &active - } - if msg.detail != 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)) - } - } -} - -// appendStream 以追加新 entry 的方式维护不可变 StreamEntry 序列。 func (a *App) appendStream(entry state.StreamEntry) { a.state.Stream = append(a.state.Stream, entry) } @@ -838,6 +710,7 @@ func (a *App) bindComponents() { a.ambientStatus = components.NewAmbientStatus(a.state) a.agentStream = components.NewAgentStream(a.state) a.commandPrompt = components.NewCommandPrompt(a.state) + a.cmdLine = components.NewCmdLine(a.state) a.softInspector = components.NewSoftInspector(a.state) a.palette = components.NewPalette(a.state) a.helpOverlay = components.NewHelpOverlay(a.state) @@ -892,37 +765,6 @@ func (a *App) triggerCompact() tea.Cmd { return nil } -// debugLine 渲染调试模式下的最小运行信息。 -func (a *App) debugLine() string { - size := defaultTerminal - if a.state.Layout.Width > 0 || a.state.Layout.Height > 0 { - size = fmt.Sprintf("%dx%d", a.state.Layout.Width, a.state.Layout.Height) - } - return fmt.Sprintf( - "[debug] mode:%s scenario:%s events:%d size:%s", - inputModeName(a.state.Mode), - a.scenario, - len(a.state.Stream), - size, - ) -} - -// streamEntryFromItem 将会话历史 DTO 映射为不可变 StreamEntry。 -func streamEntryFromItem(item gateway.StreamItem) state.StreamEntry { - return state.StreamEntry{ - ID: item.ID, - Type: item.Kind, - Timestamp: item.CreatedAt, - Content: item.Text, - Metadata: map[string]any{ - "role": item.Role, - "status": item.Status, - "done": true, - }, - } -} - -// inputModeName 将输入模式转换为占位视图中的稳定文本。 func inputModeName(mode state.InputMode) string { switch mode { case state.NormalMode: @@ -941,203 +783,3 @@ func emptyDash(value string) string { } return value } - -type initialLoadedMsg struct { - connected bool - sessions []gateway.SessionSummary - detail *gateway.SessionDetail - models []gateway.ModelInfo - activeModel string - eventCh <-chan gateway.GatewayEvent - errText string -} - -type gatewayEventMsg struct { - event gateway.GatewayEvent - closed bool -} - -// loadInitialCmd 通过 Gateway 客户端加载初始状态,并建立首个会话的事件订阅。 -func loadInitialCmd(client gateway.Client) tea.Cmd { - return func() tea.Msg { - ctx := context.Background() - msg := initialLoadedMsg{} - if _, err := client.Health(ctx); err != nil { - msg.errText = err.Error() - return msg - } - msg.connected = true - sessions, err := client.ListSessions(ctx) - if err != nil { - msg.errText = err.Error() - return msg - } - msg.sessions = sessions - models, err := client.ListModels(ctx) - if err != nil { - msg.errText = err.Error() - return msg - } - msg.models = models - if len(sessions) == 0 { - return msg - } - activeModel, err := client.GetModel(ctx, sessions[0].ID) - if err != nil { - msg.errText = err.Error() - return msg - } - msg.activeModel = activeModel - detail, err := client.LoadSession(ctx, sessions[0].ID) - if err != nil { - msg.errText = err.Error() - return msg - } - msg.detail = detail - eventCh, err := client.SubscribeEvents(ctx, sessions[0].ID) - if err != nil { - msg.errText = err.Error() - return msg - } - msg.eventCh = eventCh - return msg - } -} - -// waitEventCmd 等待 Gateway 事件 channel 的下一条事件,保持异步事件逐条进入 Update。 -func waitEventCmd(events <-chan gateway.GatewayEvent) tea.Cmd { - return func() tea.Msg { - event, ok := <-events - 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 -} - -// 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/app_commands.go b/internal/tuiv2/app_commands.go new file mode 100644 index 00000000..da253c9e --- /dev/null +++ b/internal/tuiv2/app_commands.go @@ -0,0 +1,234 @@ +// app_commands.go 承载与 Gateway 客户端交互的 tea.Cmd 工厂与消息类型, +// 从 app.go 拆分以控制单文件行数(plan-v4 Step 8)。 +package tuiv2 + +import ( + "context" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/tuiv2/gateway" + "neo-code/internal/tuiv2/state" +) + +// streamEntryFromItem 将会话历史 DTO 映射为不可变 StreamEntry。 +func streamEntryFromItem(item gateway.StreamItem) state.StreamEntry { + return state.StreamEntry{ + ID: item.ID, + Type: item.Kind, + Timestamp: item.CreatedAt, + Content: item.Text, + Metadata: map[string]any{ + "role": item.Role, + "status": item.Status, + "done": true, + }, + } +} + +// inputModeName 将输入模式转换为占位视图中的稳定文本。 + +type initialLoadedMsg struct { + connected bool + sessions []gateway.SessionSummary + detail *gateway.SessionDetail + models []gateway.ModelInfo + activeModel string + eventCh <-chan gateway.GatewayEvent + errText string +} + +type gatewayEventMsg struct { + event gateway.GatewayEvent + closed bool +} + +// loadInitialCmd 通过 Gateway 客户端加载初始状态,并建立首个会话的事件订阅。 +func loadInitialCmd(client gateway.Client) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + msg := initialLoadedMsg{} + if _, err := client.Health(ctx); err != nil { + msg.errText = err.Error() + return msg + } + msg.connected = true + sessions, err := client.ListSessions(ctx) + if err != nil { + msg.errText = err.Error() + return msg + } + msg.sessions = sessions + models, err := client.ListModels(ctx) + if err != nil { + msg.errText = err.Error() + return msg + } + msg.models = models + if len(sessions) == 0 { + return msg + } + activeModel, err := client.GetModel(ctx, sessions[0].ID) + if err != nil { + msg.errText = err.Error() + return msg + } + msg.activeModel = activeModel + detail, err := client.LoadSession(ctx, sessions[0].ID) + if err != nil { + msg.errText = err.Error() + return msg + } + msg.detail = detail + eventCh, err := client.SubscribeEvents(ctx, sessions[0].ID) + if err != nil { + msg.errText = err.Error() + return msg + } + msg.eventCh = eventCh + return msg + } +} + +// waitEventCmd 等待 Gateway 事件 channel 的下一条事件,保持异步事件逐条进入 Update。 +func waitEventCmd(events <-chan gateway.GatewayEvent) tea.Cmd { + return func() tea.Msg { + event, ok := <-events + 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 表示会话切换完成。 + +// sessionSwitchedMsg 表示会话切换完成。 +type sessionSwitchedMsg struct { + sessionID string + detail *gateway.SessionDetail + eventCh <-chan gateway.GatewayEvent +} + +// sessionCreatedMsg 表示新会话创建完成。 + +// 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/app_handlers_test.go b/internal/tuiv2/app_handlers_test.go index 4ccfd218..7d05daaa 100644 --- a/internal/tuiv2/app_handlers_test.go +++ b/internal/tuiv2/app_handlers_test.go @@ -3,6 +3,7 @@ package tuiv2 import ( "strings" "testing" + "time" tea "github.com/charmbracelet/bubbletea" @@ -82,9 +83,9 @@ func TestCancelPromptResetsInputAndLogs(t *testing.T) { 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" }, + "/session": func(a *App) bool { return a.state.Overlay.Active == state.OverlaySessionPicker }, + "/model": func(a *App) bool { return a.state.Overlay.Active == state.OverlayModelPicker }, + "/help": func(a *App) bool { return a.state.Overlay.Active == state.OverlayHelp }, "/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 }, @@ -108,9 +109,9 @@ func TestSlashCommandDispatch(t *testing.T) { 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" }, + "/session": func(a *App) bool { return a.state.Overlay.Active == state.OverlaySessionPicker }, + "/model": func(a *App) bool { return a.state.Overlay.Active == state.OverlayModelPicker }, + "/help": func(a *App) bool { return a.state.Overlay.Active == state.OverlayHelp }, "/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") }, @@ -352,10 +353,10 @@ func TestNormalModeKeyDispatch(t *testing.T) { } func TestLeaderKeyDispatch(t *testing.T) { - cases := map[string]string{ - "p": "palette", - "s": "session_picker", - "h": "help", + cases := map[string]state.OverlayType{ + "p": state.OverlayPalette, + "s": state.OverlaySessionPicker, + "h": state.OverlayHelp, } for key, wantOverlay := range cases { t.Run(key, func(t *testing.T) { @@ -371,7 +372,8 @@ func TestLeaderKeyDispatch(t *testing.T) { } }) } - // m -> toggle mode, f -> full access, c -> compact, l -> log(均返回 nil) + // m -> model picker(openOverlay nil), f -> full access(nil), + // c -> cancel run(空闲 nil), l -> log(nil)(均返回 nil cmd) for _, key := range []string{"m", "f", "c", "l"} { app := newReadyApp(t) app.state.Mode = state.LeaderMode @@ -395,6 +397,70 @@ func TestLeaderKeyDispatch(t *testing.T) { } } +// TestLeaderNewActions 覆盖 Phase 10 新增的 Leader 动作:m/c/r/space 与边界。 +func TestLeaderNewActions(t *testing.T) { + // m -> model_picker overlay + app := newReadyApp(t) + app.state.Mode = state.LeaderMode + app.Update(keyRunes("m")) + if app.state.Overlay.Active != state.OverlayModelPicker { + t.Fatalf("leader m: overlay=%q, want model_picker", app.state.Overlay.Active) + } + + // c 运行中 -> cancel cmd;空闲 -> nil + app = newReadyApp(t) + app.state.Mode = state.LeaderMode + app.state.Runtime.Phase = state.RuntimePhaseRunning + if _, cmd := app.Update(keyRunes("c")); cmd == nil { + t.Fatal("leader c running should return cancel cmd") + } + app = newReadyApp(t) + app.state.Mode = state.LeaderMode + if _, cmd := app.Update(keyRunes("c")); cmd != nil { + t.Fatal("leader c idle should return nil") + } + + // r 无历史 -> 提示 nil;有历史 -> submit cmd + app = newReadyApp(t) + app.state.Mode = state.LeaderMode + if _, cmd := app.Update(keyRunes("r")); cmd != nil { + t.Fatal("leader r with no history should return nil") + } + if !lastContains(app, "No previous run to retry") { + t.Fatalf("leader r hint missing: %v", streamContents(app)) + } + app = newReadyApp(t) + app.state.Mode = state.LeaderMode + app.lastUserText = "hello" + if _, cmd := app.Update(keyRunes("r")); cmd == nil { + t.Fatal("leader r with history should return submit cmd") + } + + // space 无上一会话 -> 提示 nil;有上一会话 -> load cmd + app = newReadyApp(t) + app.state.Mode = state.LeaderMode + if _, cmd := app.Update(keyRunes(" ")); cmd != nil { + t.Fatal("leader space with no prev session should return nil") + } + if !lastContains(app, "No previous session to switch") { + t.Fatalf("leader space hint missing: %v", streamContents(app)) + } + app = newReadyApp(t) + app.state.Mode = state.LeaderMode + app.prevSessionID = "sess-prev" + if _, cmd := app.Update(keyRunes(" ")); cmd == nil { + t.Fatal("leader space with prev session should return load cmd") + } + + // 非后缀键 -> 静默回 Normal,不执行动作 + app = newReadyApp(t) + app.state.Mode = state.LeaderMode + app.Update(keyRunes("x")) + if app.state.Mode != state.NormalMode { + t.Fatalf("leader non-suffix should reset to normal, mode=%v", app.state.Mode) + } +} + func TestInputModeKeyDispatch(t *testing.T) { app := newReadyApp(t) // esc -> normal @@ -418,6 +484,176 @@ func TestInputModeKeyDispatch(t *testing.T) { } } +// TestCtrlDContextual 覆盖 Ctrl+D 三态分发。 +func TestCtrlDContextual(t *testing.T) { + // Input 空输入 → EOF 退出(tea.Quit) + app := newReadyApp(t) + app.state.Mode = state.InputModeInput + app.state.Input.Text = "" + _, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) + if cmd == nil { + t.Fatal("ctrl+d empty input should return quit cmd") + } + + // Input 非空 → 删字符(不退出) + app = newReadyApp(t) + app.state.Mode = state.InputModeInput + app.state.Input.Text = "abc" + app.state.Input.Cursor = 3 + _, cmd = app.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) + // 删除后文本应变少(delete 删光标后字符,光标在末尾时 no-op,移到中间验证) + app.state.Input.Cursor = 1 + app.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) + if app.state.Input.Text != "ac" { + t.Fatalf("ctrl+d non-empty should delete char after cursor, text=%q", app.state.Input.Text) + } + + // Normal → 半页下翻(路由 stream,不退出) + app = newReadyApp(t) + app.state.Mode = state.NormalMode + _, cmd = app.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) + if cmd != nil { + t.Fatal("normal ctrl+d should route to stream, nil cmd") + } +} + +// TestExCommand 覆盖 : 命令行各分支。 +func TestExCommand(t *testing.T) { + cases := map[string]func(*App) bool{ + "": func(a *App) bool { return lastContains(a, "Unknown ex command") }, + "debug": func(a *App) bool { return a.debug }, + "help": func(a *App) bool { return a.state.Overlay.Active == state.OverlayHelp }, + "compact": func(a *App) bool { return lastContains(a, "Compact triggered") }, + "mode": func(a *App) bool { return a.state.Runtime.AgentMode == "build" }, + "bogus": func(a *App) bool { return lastContains(a, "Unknown ex command") }, + } + for cmd, check := range cases { + t.Run(cmd, func(t *testing.T) { + app := newReadyApp(t) + app.Update(components.ExCommandMsg{Command: cmd}) + if !check(app) { + t.Fatalf("ex %q failed: overlay=%q stream=%v", cmd, app.state.Overlay.Active, streamContents(app)) + } + }) + } + // q/quit/exit → tea.Quit + app := newReadyApp(t) + if _, cmd := app.Update(components.ExCommandMsg{Command: "q"}); cmd == nil { + t.Fatal("ex q should return quit cmd") + } +} + +// TestExAndSearchOverlayFlow 覆盖 Normal 下 : 与 / 进入输入、提交、取消。 +func TestExAndSearchOverlayFlow(t *testing.T) { + // Normal 下 : → 打开 Ex overlay + app := newReadyApp(t) + app.state.Mode = state.NormalMode + app.Update(keyRunes(":")) + if app.state.Overlay.Active != state.OverlayEx { + t.Fatalf("normal : should open ex overlay, got %q", app.state.Overlay.Active) + } + // 字符路由给 cmdLine + app.Update(keyRunes("q")) + if app.state.Ex.Input != "q" { + t.Fatalf("ex input=%q, want q", app.state.Ex.Input) + } + // Backspace 删除 + app.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + if app.state.Ex.Input != "" { + t.Fatalf("ex backspace input=%q, want empty", app.state.Ex.Input) + } + // ExCommandMsg 提交后关闭 Ex overlay(直接驱动,模拟 runtime 投递) + app.openEx() + app.Update(components.ExCommandMsg{Command: "debug"}) + if app.state.Overlay.Active != state.OverlayNone { + t.Fatalf("ex command submit should close overlay, got %q", app.state.Overlay.Active) + } + + // Normal 下 / → 打开 Search overlay + app = newReadyApp(t) + app.state.Mode = state.NormalMode + app.Update(keyRunes("/")) + if app.state.Overlay.Active != state.OverlaySearch { + t.Fatalf("normal / should open search overlay, got %q", app.state.Overlay.Active) + } + app.Update(keyRunes("e")) + app.Update(keyRunes("r")) + app.Update(keyRunes("r")) + if app.state.Search.Query != "err" { + t.Fatalf("search query=%q, want err", app.state.Search.Query) + } + + // Esc 关闭 overlay 并清理 + app = newReadyApp(t) + app.openSearch() + app.state.Search.Query = "x" + app.Update(tea.KeyMsg{Type: tea.KeyEsc}) + if app.state.Overlay.Active != state.OverlayNone { + t.Fatal("esc should close search overlay") + } + if app.state.Search.Query != "" { + t.Fatalf("esc should clear search query, got %q", app.state.Search.Query) + } +} + +// TestSearchSubmitAndJump 覆盖搜索提交、n/N 跳转、stale 标记。 +func TestSearchSubmitAndJump(t *testing.T) { + app := newReadyApp(t) + // 注入含匹配的 stream 内容 + app.appendStream(state.StreamEntry{ID: "1", Type: "message", Content: "hello error world"}) + app.appendStream(state.StreamEntry{ID: "2", Type: "message", Content: "all good"}) + app.appendStream(state.StreamEntry{ID: "3", Type: "message", Content: "another ERROR"}) + + // 提交搜索 error + app.Update(components.SearchSubmitMsg{Query: "error"}) + if len(app.state.Search.Matches) != 2 { + t.Fatalf("matches=%v, want 2", app.state.Search.Matches) + } + if app.state.Search.Stale { + t.Fatal("fresh search should not be stale") + } + + // n → 跳到下一个 + firstIdx := app.state.Search.MatchIndex + app.state.Mode = state.NormalMode + app.Update(keyRunes("n")) + if app.state.Search.MatchIndex == firstIdx { + t.Fatal("n should advance match index") + } + // N → 回到上一个 + app.Update(keyRunes("N")) + + // 无匹配搜索 + app.Update(components.SearchSubmitMsg{Query: "zzz"}) + if app.state.Search.Matches != nil { + t.Fatalf("no match should set Matches nil, got %v", app.state.Search.Matches) + } + // n 在无匹配时 no-op 不崩溃 + app.Update(keyRunes("n")) + + // 空查询 no-op + app.Update(components.SearchSubmitMsg{Query: " "}) +} + +// TestSearchStaleOnStreamGrowth 覆盖 stream 增长后 stale 标记。 +func TestSearchStaleOnStreamGrowth(t *testing.T) { + app := newReadyApp(t) + app.appendStream(state.StreamEntry{ID: "1", Type: "message", Content: "error one"}) + app.Update(components.SearchSubmitMsg{Query: "error"}) + if app.state.Search.Stale { + t.Fatal("search should not be stale initially") + } + // 模拟 gateway 事件追加 stream + app.Update(gatewayEventMsg{event: gateway.GatewayEvent{ + Type: gateway.EventAgentMessageStart, + At: time.Now(), + Payload: map[string]any{"text": "new error"}, + }}) + if !app.state.Search.Stale { + t.Fatal("search should be stale after stream growth") + } +} + func TestRouteStreamKey(t *testing.T) { app := newReadyApp(t) if ok, _ := app.routeStreamKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}); !ok { @@ -440,7 +676,7 @@ func TestHandleMouseMsgMainViewAndOverlays(t *testing.T) { app.Update(tea.MouseMsg{Type: tea.MouseWheelDown}) // 浮层鼠标分发不 panic:组件按 Button 判定,故设置 Button - for _, active := range []string{"palette", "session_picker", "model_picker"} { + for _, active := range []state.OverlayType{state.OverlayPalette, state.OverlaySessionPicker, state.OverlayModelPicker} { app := newReadyApp(t) app.openOverlay(active) app.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp}) @@ -451,9 +687,12 @@ func TestHandleMouseMsgMainViewAndOverlays(t *testing.T) { // ---- View 各路径 ---- func TestViewOverlayAndMainPaths(t *testing.T) { - overlays := []string{"palette", "help", "session_picker", "model_picker", "confirm"} + overlays := []state.OverlayType{ + state.OverlayPalette, state.OverlayHelp, + state.OverlaySessionPicker, state.OverlayModelPicker, state.OverlayConfirm, + } for _, ov := range overlays { - t.Run(ov, func(t *testing.T) { + t.Run(string(ov), func(t *testing.T) { app := newReadyApp(t) app.openOverlay(ov) if app.state.Overlay.Active != ov { @@ -570,3 +809,130 @@ func separatorLineHelper(width int) string { app.state.Layout.Width = width return app.separatorLine() } + +// ---- 第 1 轮审查盲区补测 ---- + +// TestLeaderTimeoutCmdAndRecovery 覆盖 leaderTimeoutCmd 与超时回退分支(原 50%)。 +func TestLeaderTimeoutCmdAndRecovery(t *testing.T) { + cmd := leaderTimeoutCmd() + if cmd == nil { + t.Fatal("leaderTimeoutCmd should return non-nil cmd") + } + msg := cmd() + if msg == nil { + t.Fatal("leader timeout cmd produced nil msg") + } + app := newReadyApp(t) + app.state.Mode = state.LeaderMode + app.Update(msg) + if app.state.Mode != state.NormalMode { + t.Fatalf("leader timeout should reset to normal, mode=%v", app.state.Mode) + } + app = newReadyApp(t) + app.state.Mode = state.InputModeInput + beforeMode := app.state.Mode + app.Update(msg) + if app.state.Mode != beforeMode { + t.Fatalf("leader timeout in non-leader mode should be no-op, mode=%v", app.state.Mode) + } +} + +// TestScrollToStreamIndexBoundaries 直接覆盖 scrollToStreamIndex 边界(原 71.4%)。 +func TestScrollToStreamIndexBoundaries(t *testing.T) { + app := newReadyApp(t) + app.appendStream(state.StreamEntry{ID: "1", Type: "message", Content: "a"}) + app.appendStream(state.StreamEntry{ID: "2", Type: "message", Content: "b"}) + app.scrollToStreamIndex(0) + if app.state.Layout.AutoScroll { + t.Fatal("scrollToStreamIndex should disable AutoScroll") + } + prevOffset := app.state.Layout.ScrollOffset + app.scrollToStreamIndex(-1) + if app.state.Layout.ScrollOffset != prevOffset { + t.Fatal("scrollToStreamIndex(-1) should be no-op") + } + app.scrollToStreamIndex(100) + if app.state.Layout.ScrollOffset != prevOffset { + t.Fatal("scrollToStreamIndex(overflow) should be no-op") + } + emptyApp := newReadyApp(t) + emptyApp.state.Stream = nil + emptyApp.scrollToStreamIndex(0) +} + +// TestCancelCurrentRunNoClient 覆盖 cancelCurrentRun 在 client==nil 的分支(原 71.4%)。 +func TestCancelCurrentRunNoClient(t *testing.T) { + app := NewApp(StartupConfig{Backend: "fake", Scenario: "default"}).(*App) + app.state.Runtime.Phase = state.RuntimePhaseRunning + cmd := app.cancelCurrentRun() + if cmd != nil { + t.Fatal("cancelCurrentRun without client should return nil cmd") + } + if app.state.Runtime.Phase != state.RuntimePhaseCancelled { + t.Fatalf("phase=%s, want cancelled", app.state.Runtime.Phase) + } + app2 := NewApp(StartupConfig{Backend: "fake", Scenario: "default"}).(*App) + app2.state.Runtime.Phase = state.RuntimePhaseIdle + cmd = app2.cancelCurrentRun() + if cmd != nil { + t.Fatal("cancelCurrentRun idle without client should return nil") + } + if app2.state.Runtime.Phase != state.RuntimePhaseIdle { + t.Fatal("idle phase should be unchanged") + } +} + +// TestPromptModeLineExported 覆盖导出的 ModeLine 方法(原 0%)。 +func TestPromptModeLineExported(t *testing.T) { + vs := state.NewViewState() + vs.Layout.Width = 80 + p := components.NewCommandPrompt(vs) + for _, mode := range []state.InputMode{state.InputModeInput, state.NormalMode, state.LeaderMode} { + vs.Mode = mode + if v := p.ModeLine(); v == "" { + t.Fatalf("ModeLine() empty for mode %v", mode) + } + } +} + +// TestLoadInitialCmdErrorPaths 覆盖 loadInitialCmd 的失败分支(原 71.4%)。 +func TestLoadInitialCmdErrorPaths(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") + } + client2, _ := fakegateway.NewFakeClient(fakegateway.ScenarioDefault) + _ = client2.Close() + msg2 := loadInitialCmd(client2)() + loaded2, ok := msg2.(initialLoadedMsg) + if !ok { + t.Fatalf("expected initialLoadedMsg on closed client, got %T", msg2) + } + if loaded2.errText == "" { + t.Fatal("closed client should set errText") + } +} + +// TestExecuteSearchEmptyStream 覆盖空 stream 下的搜索(边界)。 +func TestExecuteSearchEmptyStream(t *testing.T) { + app := newReadyApp(t) + app.state.Stream = nil + app.executeSearch("anything") + if app.state.Search.Matches != nil { + t.Fatal("empty stream search should yield nil matches") + } +} + +// TestExecuteExCommandEmpty 覆盖空 ex 命令的提示分支。 +func TestExecuteExCommandEmpty(t *testing.T) { + app := newReadyApp(t) + app.executeExCommand("") + if !lastContains(app, "Unknown ex command") { + t.Fatalf("empty ex command should hint unknown, stream=%v", streamContents(app)) + } +} diff --git a/internal/tuiv2/app_leader.go b/internal/tuiv2/app_leader.go new file mode 100644 index 00000000..6cb28fd2 --- /dev/null +++ b/internal/tuiv2/app_leader.go @@ -0,0 +1,126 @@ +// app_leader.go 承载 Leader Mode 的按键路由与 Leader 动作辅助函数, +// 从 app.go 拆分以控制单文件行数(plan-v4 Step 8)。 +package tuiv2 + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/tuiv2/components" + "neo-code/internal/tuiv2/keymap" + "neo-code/internal/tuiv2/state" +) + +// handleLeaderKey 处理 Leader Key 后缀。 +// +// 行为约定(plan-v4):Leader 是独占捕获,非后缀键或超时(1s)时立即静默回到 +// Normal(不泄漏给 Normal handler)。后缀键执行动作后回到 Normal,除非打开了 +// 需要保持的面板(palette/session_picker/help/model_picker)。 +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) + // 非后缀键:静默回到 Normal,不执行任何动作。 + if action == keymap.ActionNone { + a.state.Mode = state.NormalMode + return a, nil + } + a.state.Mode = state.NormalMode // Leader 后总是回到 Normal + switch action { + case keymap.ActionLeaderQuit: + return a, tea.Quit + case keymap.ActionLeaderPalette: + a.openOverlay(state.OverlayPalette) + return a, nil + case keymap.ActionLeaderHelp: + a.openOverlay(state.OverlayHelp) + return a, nil + case keymap.ActionLeaderSwitchSession: + a.openOverlay(state.OverlaySessionPicker) + return a, nil + case keymap.ActionLeaderModelPicker: + a.openOverlay(state.OverlayModelPicker) + return a, nil + case keymap.ActionLeaderNewSession: + if a.client != nil { + return a, createSessionCmd(a.client) + } + return a, nil + 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.ActionLeaderCancelRun: + return a, a.cancelCurrentRun() + case keymap.ActionLeaderRetry: + return a, a.retryLastRun() + case keymap.ActionLeaderLastSession: + return a, a.switchToLastSession() + default: + return a, nil + } +} + +// cancelCurrentRun 取消当前运行中的 Agent;无运行中任务时静默 no-op。 +func (a *App) cancelCurrentRun() tea.Cmd { + phase := a.state.Runtime.Phase + if phase != state.RuntimePhaseRunning && + phase != state.RuntimePhaseWaitingPermission && + phase != state.RuntimePhaseWaitingUser { + // 空闲态:静默 no-op,避免打扰用户。 + return nil + } + if a.client != nil { + return cancelRunCmd(a.client, a.activeSessionID(), a.state.Runtime.RunID) + } + a.state.Runtime.Phase = state.RuntimePhaseCancelled + return nil +} + +// retryLastRun 重试最近一次用户输入;无历史输入时提示。 +func (a *App) retryLastRun() tea.Cmd { + if strings.TrimSpace(a.lastUserText) == "" { + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("retry-hint-%d", time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: "No previous run to retry", + Metadata: map[string]any{"done": true}, + }) + return nil + } + return a.handleSubmitMessage(components.SubmitMessageMsg{Text: a.lastUserText}) +} + +// switchToLastSession 切换到上一个会话;无上一会话时提示。 +func (a *App) switchToLastSession() tea.Cmd { + if a.prevSessionID == "" { + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("last-sess-hint-%d", time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: "No previous session to switch", + Metadata: map[string]any{"done": true}, + }) + return nil + } + if a.client == nil { + return nil + } + return loadSessionCmd(a.client, a.prevSessionID) +} + +// handleCtrlC 实现 Ctrl+C 双退保护:运行中取消、空闲双退。 diff --git a/internal/tuiv2/app_normal.go b/internal/tuiv2/app_normal.go new file mode 100644 index 00000000..a4980ca2 --- /dev/null +++ b/internal/tuiv2/app_normal.go @@ -0,0 +1,182 @@ +// app_normal.go 承载 Normal Mode 的按键路由与命令行/搜索辅助函数, +// 从 app.go 拆分以控制单文件行数(plan-v4 Step 8)。 +package tuiv2 + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/tuiv2/components" + "neo-code/internal/tuiv2/keymap" + "neo-code/internal/tuiv2/state" +) + +// handleNormalModeKey 处理 Normal Mode 下的键盘输入。 +// +// 分层约定(plan-v4):模式切换键(i/Enter→Input、Space→Leader、:→Ex、 +// /→Search)优先拦截;n/N 在搜索 Matches 非空时跳转;Ctrl+D 半页下翻; +// 其余导航键交给 stream。 +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.enterInputFromNormal() + 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.ActionFullPageDown, keymap.ActionFullPageUp: + _, 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: + // / 进入搜索输入 overlay + a.openSearch() + return a, nil + case keymap.ActionSearchNext: + a.jumpSearchMatch(1) + return a, nil + case keymap.ActionSearchPrev: + a.jumpSearchMatch(-1) + return a, nil + case keymap.ActionExCommand: + // : 进入 Ex 命令行输入 overlay + a.openEx() + return a, nil + default: + _, promptCmd := a.commandPrompt.Update(msg) + return a, promptCmd + } +} + +// enterInputFromNormal 从 Normal 进入 Input Mode,并清除 Normal 专属子状态(搜索)。 +func (a *App) enterInputFromNormal() { + a.state.Mode = state.InputModeInput + a.clearSearchAndEx() +} + +// openEx 打开 : 命令行输入 overlay。 +func (a *App) openEx() { + a.state.Ex.Active = true + a.state.Ex.Input = "" + a.openOverlay(state.OverlayEx) +} + +// openSearch 打开 / 搜索输入 overlay。 +func (a *App) openSearch() { + a.state.Search.Active = true + a.state.Search.Query = "" + a.openOverlay(state.OverlaySearch) +} + +// clearSearchAndEx 清除搜索与 Ex 输入状态(切出 Normal 或事件触发时调用)。 +func (a *App) clearSearchAndEx() { + a.state.Search = state.SearchState{} + a.state.Ex = state.ExState{} +} + +// executeExCommand 解释并执行 : 命令(已去除前缀 ":"),返回副作用 cmd。 +// +// 支持命令:q/quit/exit=退出、debug=切调试、help=开帮助、compact=触发压缩、 +// mode=切换 Agent 模式。空或未知命令给出提示。 +func (a *App) executeExCommand(command string) tea.Cmd { + switch command { + case "q", "quit", "exit": + return tea.Quit + case "debug": + a.debug = !a.debug + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("debug-toggle-%d", time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: fmt.Sprintf("Debug: %v", a.debug), + Metadata: map[string]any{"done": true}, + }) + return nil + case "help": + a.openOverlay(state.OverlayHelp) + return nil + case "compact": + return a.triggerCompact() + case "mode": + return a.toggleAgentMode() + default: + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("ex-unknown-%d", time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: fmt.Sprintf("Unknown ex command: %s", emptyDash(command)), + Metadata: map[string]any{"done": true}, + }) + return nil + } +} + +// executeSearch 执行全量扫描并记录匹配索引到 Search.Matches,滚动到首个匹配。 +// +// 空 query 为 no-op(关闭搜索 overlay);无匹配给出提示。 +func (a *App) executeSearch(query string) tea.Cmd { + if strings.TrimSpace(query) == "" { + return nil + } + matches := components.RunSearch(a.state.Stream, query) + a.state.Search.Matches = matches + a.state.Search.MatchIndex = 0 + a.state.Search.Stale = false + if len(matches) == 0 { + a.appendStream(state.StreamEntry{ + ID: fmt.Sprintf("search-empty-%d", time.Now().UnixNano()), + Type: "status", + Timestamp: time.Now(), + Content: fmt.Sprintf("No matches: %s", query), + Metadata: map[string]any{"done": true}, + }) + return nil + } + a.scrollToStreamIndex(matches[0]) + return nil +} + +// jumpSearchMatch 在搜索匹配间循环跳转(direction=1 下一个,-1 上一个)。 +// +// 无匹配时静默 no-op;到末尾/首位循环折返。 +func (a *App) jumpSearchMatch(direction int) { + matches := a.state.Search.Matches + if len(matches) == 0 { + return + } + a.state.Search.MatchIndex = (a.state.Search.MatchIndex + direction + len(matches)) % len(matches) + a.scrollToStreamIndex(matches[a.state.Search.MatchIndex]) +} + +// scrollToStreamIndex 滚动 stream 使指定全局 entry 索引尽量可见。 +// +// 由于 state.Stream 是 append-only 且全量在内存,这里基于目标索引估算 +// 滚动偏移(粗略:将目标定位到视口中部),足够满足跳转可见需求。 +func (a *App) scrollToStreamIndex(targetIndex int) { + if targetIndex < 0 || targetIndex >= len(a.state.Stream) { + return + } + // 粗略估计:stream 行数约为 entry 数的倍数,这里直接用 entry 索引作为 + // 偏移参考,关闭自动滚动并尝试把目标带到视口。精确视口定位由 stream + // 渲染时的 visibleLines 兜底(超出范围会被 clamp)。 + a.state.Layout.AutoScroll = false + // 反向估算:偏移越大表示越靠顶部。目标越靠后(索引大)越接近底部,偏移越小。 + // targetIndex 已被上方 guard 限制在 [0, len),故 estimated 恒 > 0,无需再判负。 + a.state.Layout.ScrollOffset = len(a.state.Stream) - targetIndex +} + +// handlePaletteCommand 处理命令面板选择的命令。 diff --git a/internal/tuiv2/app_overlay_test.go b/internal/tuiv2/app_overlay_test.go index 39c34ec5..57ee196e 100644 --- a/internal/tuiv2/app_overlay_test.go +++ b/internal/tuiv2/app_overlay_test.go @@ -84,7 +84,7 @@ func TestLeaderSessionPickerFlow(t *testing.T) { // 再回车能正常选中并关闭(用户反馈的"选择器回车不关闭")。 func TestModelPickerViaPaletteCloses(t *testing.T) { app := newReadyApp(t) - app.openOverlay("palette") + app.openOverlay(state.OverlayPalette) for _, r := range "model" { app = send(t, app, runesKey(string(r))) } @@ -101,7 +101,7 @@ func TestModelPickerViaPaletteCloses(t *testing.T) { // TestPaletteTypeModeEnter 模拟在命令面板里输入 "mode" 过滤后回车。 func TestPaletteTypeModeEnter(t *testing.T) { app := newReadyApp(t) - app.openOverlay("palette") + app.openOverlay(state.OverlayPalette) for _, r := range "mode" { app = send(t, app, runesKey(string(r))) } @@ -148,7 +148,7 @@ func TestPaletteStaleStateAfterEvents(t *testing.T) { Payload: map[string]any{"phase": state.RuntimePhaseIdle}, }}) app = updated.(*App) - app.openOverlay("palette") + app.openOverlay(state.OverlayPalette) app = send(t, app, tea.KeyMsg{Type: tea.KeyDown}) // 高亮到 /mode app = send(t, app, tea.KeyMsg{Type: tea.KeyEnter}) if app.state.Overlay.Active != "" { @@ -159,7 +159,7 @@ func TestPaletteStaleStateAfterEvents(t *testing.T) { // 空格应像回车一样执行当前高亮项并关闭面板,而不是被当成查询字符重置到 /model。 func TestPaletteSpaceSelects(t *testing.T) { app := newReadyApp(t) - app.openOverlay("palette") + app.openOverlay(state.OverlayPalette) app = send(t, app, tea.KeyMsg{Type: tea.KeyDown}) // 选中 /mode app = send(t, app, runesKey(" ")) // 空格确认 last := "" @@ -189,7 +189,7 @@ func TestPaletteNavigateSelectsTarget(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { app := newReadyApp(t) - app.openOverlay("palette") + app.openOverlay(state.OverlayPalette) for i := 0; i < tc.downs; i++ { app = send(t, app, tea.KeyMsg{Type: tea.KeyDown}) } @@ -210,7 +210,7 @@ func TestPaletteNavigateSelectsTarget(t *testing.T) { 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") + app.openOverlay(state.OverlayModelPicker) updated, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) app = updated.(*App) @@ -223,7 +223,7 @@ func TestModelPickerEnterCloses(t *testing.T) { func TestSessionPickerEnterCloses(t *testing.T) { app := newReadyApp(t) - app.openOverlay("session_picker") + app.openOverlay(state.OverlaySessionPicker) updated, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) app = updated.(*App) @@ -236,7 +236,7 @@ func TestSessionPickerEnterCloses(t *testing.T) { func TestConfirmEnterConfirms(t *testing.T) { app := newReadyApp(t) - app.openOverlay("session_picker") + app.openOverlay(state.OverlaySessionPicker) // Ctrl+D 在 session picker 中触发删除请求 -> 应打开 confirm updated, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) app = updated.(*App) @@ -256,7 +256,7 @@ func TestConfirmEnterConfirms(t *testing.T) { func TestSessionSwitchShowsConfirmation(t *testing.T) { app := newReadyApp(t) - app.openOverlay("session_picker") + app.openOverlay(state.OverlaySessionPicker) // 下移到第二个会话后回车切换 updated, _ := app.Update(tea.KeyMsg{Type: tea.KeyDown}) app = updated.(*App) diff --git a/internal/tuiv2/app_view.go b/internal/tuiv2/app_view.go new file mode 100644 index 00000000..fd6bf3ac --- /dev/null +++ b/internal/tuiv2/app_view.go @@ -0,0 +1,139 @@ +// app_view.go 承载视图布局与尺寸适配辅助,从 app.go 拆分(plan-v4 Step 8)。 +package tuiv2 + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + "neo-code/internal/tuiv2/gateway" + "neo-code/internal/tuiv2/state" + "neo-code/internal/tuiv2/theme" +) + +// applyWindowSize 更新布局尺寸,并按 Focus-Only 断点计算 Soft Inspector 状态。 +func (a *App) applyWindowSize(width int, height int) { + a.state.Layout.Width = width + a.state.Layout.Height = height + switch { + case width < inspectorHiddenWidth: + a.state.Layout.ShowInspector = false + a.state.Layout.InspectorWidth = 0 + case width < inspectorWideMin: + a.state.Layout.ShowInspector = true + a.state.Layout.InspectorWidth = width + default: + a.state.Layout.ShowInspector = true + a.state.Layout.InspectorWidth = inspectorWideWidth + } +} + +// routeComponents 将全局消息转发给各静态布局组件。 + +// mainArea 渲染中部区域,按终端宽度决定 Inspector 右侧或纵向压缩显示。 +func (a *App) mainArea() string { + streamView := a.agentStream.View() + if !a.state.Layout.ShowInspector { + return streamView + } + inspectorView := a.softInspector.View() + if a.state.Layout.Width >= inspectorWideMin { + return lipgloss.JoinHorizontal(lipgloss.Top, streamView, " ", inspectorView) + } + return lipgloss.JoinVertical(lipgloss.Left, streamView, "", a.separatorLine(), inspectorView) +} + +// separatorLine 渲染单条细线,用于区分主要区域而不使用边框。 +func (a *App) separatorLine() string { + width := a.state.Layout.Width + if width <= 0 { + width = 48 + } + return theme.SubtleStyle().Render(strings.Repeat("─", width)) +} + +// fitViewToTerminal 将视图约束到当前终端尺寸,避免 resize 后自动换行或旧行残留。 +func (a *App) fitViewToTerminal(view string) string { + width := a.state.Layout.Width + height := a.state.Layout.Height + if width <= 0 { + return view + } + lines := strings.Split(view, "\n") + for i, line := range lines { + lines[i] = fitLine(line, width) + } + if height > 0 { + switch { + case len(lines) > height: + lines = lines[:height] + case len(lines) < height: + for len(lines) < height { + lines = append(lines, strings.Repeat(" ", width-1)) + } + } + } + return strings.Join(lines, "\n") +} + +// fitLine 截断并补齐单行显示宽度,保留 ANSI 样式同时防止终端自动 wrap。 +func fitLine(line string, width int) string { + if width <= 0 { + return line + } + target := width - 1 + if target <= 0 { + return "" + } + fitted := theme.Truncate(line, target) + lineWidth := theme.DisplayWidth(fitted) + if lineWidth < target { + fitted += strings.Repeat(" ", target-lineWidth) + } + return fitted +} + +// applyInitialLoaded 将 Gateway 初始 RPC 结果写入 ViewState。 +func (a *App) applyInitialLoaded(msg initialLoadedMsg) { + a.lastErr = msg.errText + a.state.Gateway.Connected = msg.connected + a.state.Gateway.Sessions = append([]gateway.SessionSummary(nil), msg.sessions...) + a.state.Gateway.Models = append([]gateway.ModelInfo(nil), msg.models...) + a.state.Gateway.ActiveModel = msg.activeModel + a.eventCh = msg.eventCh + if msg.errText != "" { + a.state.Runtime.Phase = state.RuntimePhaseError + } + if len(msg.sessions) > 0 { + active := msg.sessions[0] + a.state.Gateway.ActiveSess = &active + } + if msg.detail != 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)) + } + } +} + +// appendStream 以追加新 entry 的方式维护不可变 StreamEntry 序列。 + +// debugLine 渲染调试模式下的最小运行信息。 +func (a *App) debugLine() string { + size := defaultTerminal + if a.state.Layout.Width > 0 || a.state.Layout.Height > 0 { + size = fmt.Sprintf("%dx%d", a.state.Layout.Width, a.state.Layout.Height) + } + return fmt.Sprintf( + "[debug] mode:%s scenario:%s events:%d size:%s", + inputModeName(a.state.Mode), + a.scenario, + len(a.state.Stream), + size, + ) +} diff --git a/internal/tuiv2/components/cmdline.go b/internal/tuiv2/components/cmdline.go new file mode 100644 index 00000000..0d154a7c --- /dev/null +++ b/internal/tuiv2/components/cmdline.go @@ -0,0 +1,159 @@ +package components + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/tuiv2/state" + "neo-code/internal/tuiv2/theme" +) + +// ExCommandMsg 表示用户在 : 命令行提交了一条命令(已去除前缀 ":")。 +// 由 app 层解释具体命令(q/debug/help/compact/mode 等)并执行副作用。 +type ExCommandMsg struct { + Command string +} + +// SearchSubmitMsg 表示用户在 / 搜索行提交了搜索词,由 app 层执行全量扫描。 +type SearchSubmitMsg struct { + Query string +} + +// CmdLineCancelMsg 表示用户取消了 Ex/Search 输入(Esc),由 app 层关闭 overlay。 +type CmdLineCancelMsg struct{} + +// CmdLine 渲染并处理 Normal Mode 下的 : 命令行与 / 搜索输入。 +// +// 组件本身无副作用:命令解释与搜索扫描由 app 层根据返回的 tea.Msg 完成, +// 保持组件职责单一(输入收集 + 渲染)。 +type CmdLine struct { + state *state.ViewState +} + +var _ tea.Model = (*CmdLine)(nil) + +// NewCmdLine 创建命令行/搜索输入组件。 +func NewCmdLine(viewState *state.ViewState) *CmdLine { + return &CmdLine{state: viewState} +} + +// Init 不启动额外命令。 +func (c *CmdLine) Init() tea.Cmd { + return nil +} + +// Update 处理 Ex/Search 输入的所有按键(app 在 overlay 激活时路由给它)。 +// +// 行为约定: +// - 可打印字符追加到对应输入(Ex.Input 或 Search.Query) +// - Backspace 删除末尾字符 +// - Enter 提交:Ex 返回 ExCommandMsg,Search 返回 SearchSubmitMsg +// - Esc/Ctrl+C 取消,返回 CmdLineCancelMsg +// - 其余键(j/k 等导航)忽略,Ex/Search 输入时不滚动 +func (c *CmdLine) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return c, nil + } + switch key.String() { + case "esc", "ctrl+c": + return c, emitMsg(CmdLineCancelMsg{}) + case "enter": + return c, c.handleSubmit() + case "backspace", "ctrl+h": + c.handleBackspace() + return c, nil + default: + // 仅接受可打印字符(rune >= 32),忽略功能键与导航键。 + if len(key.Runes) > 0 && key.Runes[0] >= 32 { + c.handleRune(key.Runes[0]) + } + return c, nil + } +} + +// handleSubmit 根据当前 overlay 类型提交命令或搜索。 +func (c *CmdLine) handleSubmit() tea.Cmd { + switch c.state.Overlay.Active { + case state.OverlayEx: + cmd := strings.TrimSpace(c.state.Ex.Input) + c.state.Ex.Input = "" + return emitMsg(ExCommandMsg{Command: cmd}) + case state.OverlaySearch: + query := c.state.Search.Query + c.state.Search.Query = "" + return emitMsg(SearchSubmitMsg{Query: query}) + } + return nil +} + +// handleBackspace 删除当前输入末尾字符。 +func (c *CmdLine) handleBackspace() { + switch c.state.Overlay.Active { + case state.OverlayEx: + if len(c.state.Ex.Input) > 0 { + c.state.Ex.Input = c.state.Ex.Input[:len(c.state.Ex.Input)-1] + } + case state.OverlaySearch: + if len(c.state.Search.Query) > 0 { + c.state.Search.Query = c.state.Search.Query[:len(c.state.Search.Query)-1] + } + } +} + +// handleRune 将可打印字符追加到当前输入。 +func (c *CmdLine) handleRune(r rune) { + switch c.state.Overlay.Active { + case state.OverlayEx: + c.state.Ex.Input += string(r) + case state.OverlaySearch: + c.state.Search.Query += string(r) + } +} + +// View 渲染底部单行输入(: 前缀或 / 前缀),并在搜索结果过时时追加 stale 提示。 +func (c *CmdLine) View() string { + var prefix, text, hint string + switch c.state.Overlay.Active { + case state.OverlayEx: + prefix = ":" + text = c.state.Ex.Input + case state.OverlaySearch: + prefix = "/" + text = c.state.Search.Query + if c.state.Search.Stale { + hint = "\n" + theme.MutedStyle().Render(" results may be stale — press / to refresh") + } + default: + return "" + } + cursor := "_" + line := theme.AccentStyle().Render(prefix+" ") + theme.BaseStyle().Render(text+cursor) + content := line + hint + width := c.state.Layout.Width + if width > 0 { + return fitBlock(content, width, true) + } + return content +} + +// RunSearch 全量扫描 Stream,返回匹配 entry 的全局索引(append-only 保证索引稳定)。 +// +// 匹配规则:子串包含、忽略大小写。空 query 返回 nil(调用方按 no-op 处理)。 +func RunSearch(stream []state.StreamEntry, query string) []int { + if strings.TrimSpace(query) == "" { + return nil + } + needle := strings.ToLower(query) + matches := make([]int, 0) + for i, entry := range stream { + if strings.Contains(strings.ToLower(entry.Content), needle) { + matches = append(matches, i) + } + } + if len(matches) == 0 { + return nil + } + return matches +} diff --git a/internal/tuiv2/components/cmdline_test.go b/internal/tuiv2/components/cmdline_test.go new file mode 100644 index 00000000..bf2e7439 --- /dev/null +++ b/internal/tuiv2/components/cmdline_test.go @@ -0,0 +1,226 @@ +package components + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/tuiv2/state" +) + +// promptViewState 构造一个带尺寸的 ViewState,供 cmdline 测试使用。 +func cmdlineViewState() *state.ViewState { + vs := state.NewViewState() + vs.Layout.Width = 80 + vs.Layout.Height = 24 + return vs +} + +func TestCmdLineExInputAndSubmit(t *testing.T) { + vs := cmdlineViewState() + vs.Overlay.Active = state.OverlayEx + c := NewCmdLine(vs) + + // 输入字符 + c.Update(keyMsgRunes('q')) + c.Update(keyMsgRunes('u')) + c.Update(keyMsgRunes('i')) + c.Update(keyMsgRunes('t')) + if vs.Ex.Input != "quit" { + t.Fatalf("ex input=%q, want quit", vs.Ex.Input) + } + + // Backspace 删除末尾 + c.Update(keyType(tea.KeyBackspace)) + if vs.Ex.Input != "qui" { + t.Fatalf("after backspace input=%q, want qui", vs.Ex.Input) + } + + // 清空后重新输入 quit 并提交 + vs.Ex.Input = "" + c.Update(keyMsgRunes('q')) + c.Update(keyMsgRunes('u')) + c.Update(keyMsgRunes('i')) + c.Update(keyMsgRunes('t')) + _, cmd := c.Update(keyType(tea.KeyEnter)) + if cmd == nil { + t.Fatal("ex enter should emit ExCommandMsg") + } + msg := cmd() + exMsg, ok := msg.(ExCommandMsg) + if !ok { + t.Fatalf("want ExCommandMsg, got %T", msg) + } + if exMsg.Command != "quit" { + t.Fatalf("command=%q, want quit", exMsg.Command) + } + if vs.Ex.Input != "" { + t.Fatalf("ex input should be cleared after submit, got %q", vs.Ex.Input) + } +} + +func TestCmdLineExEscCancels(t *testing.T) { + vs := cmdlineViewState() + vs.Overlay.Active = state.OverlayEx + vs.Ex.Input = "debug" + c := NewCmdLine(vs) + _, cmd := c.Update(keyType(tea.KeyEsc)) + if cmd == nil { + t.Fatal("esc should emit CmdLineCancelMsg") + } + if _, ok := cmd().(CmdLineCancelMsg); !ok { + t.Fatal("want CmdLineCancelMsg") + } +} + +func TestCmdLineSearchInputAndSubmit(t *testing.T) { + vs := cmdlineViewState() + vs.Overlay.Active = state.OverlaySearch + c := NewCmdLine(vs) + + c.Update(keyMsgRunes('e')) + c.Update(keyMsgRunes('r')) + c.Update(keyMsgRunes('r')) + if vs.Search.Query != "err" { + t.Fatalf("search query=%q, want err", vs.Search.Query) + } + + _, cmd := c.Update(keyType(tea.KeyEnter)) + if cmd == nil { + t.Fatal("search enter should emit SearchSubmitMsg") + } + msg := cmd() + sMsg, ok := msg.(SearchSubmitMsg) + if !ok { + t.Fatalf("want SearchSubmitMsg, got %T", msg) + } + if sMsg.Query != "err" { + t.Fatalf("query=%q, want err", sMsg.Query) + } +} + +func TestCmdLineViewRendersPrefixAndStale(t *testing.T) { + // Ex 视图 + vs := cmdlineViewState() + vs.Overlay.Active = state.OverlayEx + vs.Ex.Input = "q" + c := NewCmdLine(vs) + if v := c.View(); v == "" { + t.Fatal("ex view empty") + } + + // Search 视图 + stale 提示 + vs2 := cmdlineViewState() + vs2.Overlay.Active = state.OverlaySearch + vs2.Search.Query = "err" + vs2.Search.Stale = true + c2 := NewCmdLine(vs2) + v := c2.View() + if v == "" { + t.Fatal("search view empty") + } + // stale 提示应出现在输出中 + if !containsStr(v, "stale") { + t.Fatalf("stale hint missing in view: %q", v) + } + + // 无 overlay 时 View 返回空 + vs3 := cmdlineViewState() + c3 := NewCmdLine(vs3) + if c3.View() != "" { + t.Fatal("view should be empty when no overlay active") + } +} + +func TestRunSearchMatching(t *testing.T) { + stream := []state.StreamEntry{ + {ID: "1", Content: "hello world"}, + {ID: "2", Content: "ERROR: something"}, + {ID: "3", Content: "all good"}, + {ID: "4", Content: "another error here"}, + } + // 大小写不敏感匹配 "error" + matches := RunSearch(stream, "error") + if len(matches) != 2 { + t.Fatalf("matches=%v, want 2 hits", matches) + } + if matches[0] != 1 || matches[1] != 3 { + t.Fatalf("matches indices=%v, want [1 3]", matches) + } + // 空查询返回 nil + if RunSearch(stream, " ") != nil { + t.Fatal("empty query should return nil") + } + // 无匹配返回 nil + if RunSearch(stream, "zzz") != nil { + t.Fatal("no match should return nil") + } +} + +// keyMsgRunes 构造一个携带 rune 的 KeyMsg。 +func keyMsgRunes(r rune) tea.KeyMsg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} +} + +// containsStr 简单子串包含判断。 +func containsStr(s, sub string) bool { + return len(s) >= len(sub) && (indexOf(s, sub) >= 0) +} + +// TestCmdLineInit 覆盖 CmdLine.Init(返回 nil,无启动命令)。 +func TestCmdLineInit(t *testing.T) { + c := NewCmdLine(cmdlineViewState()) + if cmd := c.Init(); cmd != nil { + t.Fatalf("CmdLine.Init should return nil, got %v", cmd) + } +} + +// TestCmdLineSearchBackspace 覆盖 Search overlay 下的 Backspace(原 60% 盲区)。 +func TestCmdLineSearchBackspace(t *testing.T) { + vs := cmdlineViewState() + vs.Overlay.Active = state.OverlaySearch + vs.Search.Query = "hello" + c := NewCmdLine(vs) + + c.Update(keyType(tea.KeyBackspace)) + if vs.Search.Query != "hell" { + t.Fatalf("search backspace query=%q, want hell", vs.Search.Query) + } + // 连续 Backspace 到空,再按应 no-op 不崩溃 + c.Update(keyType(tea.KeyBackspace)) + c.Update(keyType(tea.KeyBackspace)) + c.Update(keyType(tea.KeyBackspace)) + c.Update(keyType(tea.KeyBackspace)) + c.Update(keyType(tea.KeyBackspace)) + if vs.Search.Query != "" { + t.Fatalf("search query after many backspace=%q, want empty", vs.Search.Query) + } + + // 非 Ex/Search overlay 下 Backspace 不应改动任何输入 + vs2 := cmdlineViewState() + vs2.Overlay.Active = state.OverlayPalette + c2 := NewCmdLine(vs2) + c2.Update(keyType(tea.KeyBackspace)) // no-op +} + +// TestCmdLineNonPrintableKeysIgnored 覆盖导航键等被忽略的分支。 +func TestCmdLineNonPrintableKeysIgnored(t *testing.T) { + vs := cmdlineViewState() + vs.Overlay.Active = state.OverlayEx + c := NewCmdLine(vs) + // left/right/up/down 等功能键应被忽略,不修改输入 + c.Update(keyType(tea.KeyLeft)) + c.Update(keyType(tea.KeyUp)) + if vs.Ex.Input != "" { + t.Fatalf("non-printable should be ignored, input=%q", vs.Ex.Input) + } +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/internal/tuiv2/components/coverage_test.go b/internal/tuiv2/components/coverage_test.go index 0285a50a..85ae4caf 100644 --- a/internal/tuiv2/components/coverage_test.go +++ b/internal/tuiv2/components/coverage_test.go @@ -54,7 +54,7 @@ func TestHelpOverlayLifecycle(t *testing.T) { vs := state.NewViewState() vs.Layout.Width = 60 vs.Layout.Height = 24 - vs.Overlay.Active = "help" + vs.Overlay.Active = state.OverlayHelp h := NewHelpOverlay(vs) if h.Init() != nil { @@ -62,14 +62,14 @@ func TestHelpOverlayLifecycle(t *testing.T) { } // esc/ctrl+c/q/? 关闭浮层 for _, m := range []tea.Msg{keyType(tea.KeyEsc), keyType(tea.KeyCtrlC), keyMsg("q"), keyMsg("?")} { - vs.Overlay.Active = "help" + vs.Overlay.Active = state.OverlayHelp _, _ = h.Update(m) if vs.Overlay.Active != "" { t.Fatalf("%v should close help overlay", m) } } // 其它键不关闭 - vs.Overlay.Active = "help" + vs.Overlay.Active = state.OverlayHelp _, _ = h.Update(keyMsg("x")) if vs.Overlay.Active != "help" { t.Fatal("unrelated key should not close help") diff --git a/internal/tuiv2/components/help.go b/internal/tuiv2/components/help.go index b89a1059..5898a1f8 100644 --- a/internal/tuiv2/components/help.go +++ b/internal/tuiv2/components/help.go @@ -36,7 +36,7 @@ func (h *HelpOverlay) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch key.String() { case "esc", "ctrl+c", "q", "?": - h.state.Overlay.Active = "" + h.state.Overlay.Active = state.OverlayNone return h, nil } return h, nil diff --git a/internal/tuiv2/components/model_picker.go b/internal/tuiv2/components/model_picker.go index 8f60f711..4d1ebb0e 100644 --- a/internal/tuiv2/components/model_picker.go +++ b/internal/tuiv2/components/model_picker.go @@ -49,7 +49,7 @@ func (m *ModelPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *ModelPicker) handleKey(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "esc", "ctrl+c": - m.state.Overlay.Active = "" + m.state.Overlay.Active = state.OverlayNone m.state.Overlay.Query = "" m.state.Overlay.Selected = 0 return nil @@ -63,7 +63,7 @@ func (m *ModelPicker) handleKey(msg tea.KeyMsg) tea.Cmd { idx = len(matched) - 1 } selected := matched[idx] - m.state.Overlay.Active = "" + m.state.Overlay.Active = state.OverlayNone m.state.Overlay.Query = "" m.state.Overlay.Selected = 0 return func() tea.Msg { @@ -118,7 +118,7 @@ func (m *ModelPicker) handleMouse(msg tea.MouseMsg) tea.Cmd { matched := m.matchedModels() if itemIdx >= 0 && itemIdx < len(matched) { selected := matched[itemIdx] - m.state.Overlay.Active = "" + m.state.Overlay.Active = state.OverlayNone m.state.Overlay.Query = "" m.state.Overlay.Selected = 0 return func() tea.Msg { diff --git a/internal/tuiv2/components/palette.go b/internal/tuiv2/components/palette.go index b721cc29..b71fc1e0 100644 --- a/internal/tuiv2/components/palette.go +++ b/internal/tuiv2/components/palette.go @@ -67,7 +67,7 @@ func (p *Palette) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (p *Palette) handleKey(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "esc", "ctrl+c": - p.state.Overlay.Active = "" + p.state.Overlay.Active = state.OverlayNone p.state.Overlay.Query = "" p.state.Overlay.Selected = 0 return nil @@ -81,7 +81,7 @@ func (p *Palette) handleKey(msg tea.KeyMsg) tea.Cmd { idx = len(matched) - 1 } selected := matched[idx] - p.state.Overlay.Active = "" + p.state.Overlay.Active = state.OverlayNone p.state.Overlay.Query = "" p.state.Overlay.Selected = 0 return func() tea.Msg { @@ -160,7 +160,7 @@ func (p *Palette) handleMouse(msg tea.MouseMsg) tea.Cmd { matched := p.matchedItems() if itemIdx >= 0 && itemIdx < len(matched) { selected := matched[itemIdx] - p.state.Overlay.Active = "" + p.state.Overlay.Active = state.OverlayNone p.state.Overlay.Query = "" p.state.Overlay.Selected = 0 return func() tea.Msg { diff --git a/internal/tuiv2/components/picker_test.go b/internal/tuiv2/components/picker_test.go index 32d1d28a..4345646f 100644 --- a/internal/tuiv2/components/picker_test.go +++ b/internal/tuiv2/components/picker_test.go @@ -45,7 +45,7 @@ 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" + vs.Overlay.Active = state.OverlayPalette p := NewPalette(vs) _, _ = p.Update(tea.KeyMsg(m.(tea.KeyMsg))) if vs.Overlay.Active != "" { @@ -160,7 +160,7 @@ func TestModelPickerLifecycleAndKey(t *testing.T) { } // esc/ctrl+c 关闭 for _, kk := range []tea.KeyMsg{keyType(tea.KeyEsc), keyType(tea.KeyCtrlC)} { - vs.Overlay.Active = "model_picker" + vs.Overlay.Active = state.OverlayModelPicker _, _ = m.Update(kk) } // enter / space -> ModelSelectMsg @@ -232,7 +232,7 @@ func TestSessionPickerLifecycleAndKey(t *testing.T) { } // esc/ctrl+c 关闭 for _, kk := range []tea.KeyMsg{keyType(tea.KeyEsc), keyType(tea.KeyCtrlC)} { - vs.Overlay.Active = "session_picker" + vs.Overlay.Active = state.OverlaySessionPicker _, _ = s.Update(kk) } // enter / space -> SessionSelectMsg diff --git a/internal/tuiv2/components/prompt.go b/internal/tuiv2/components/prompt.go index d03a3096..5f8ace82 100644 --- a/internal/tuiv2/components/prompt.go +++ b/internal/tuiv2/components/prompt.go @@ -8,6 +8,7 @@ import ( "unicode/utf8" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "neo-code/internal/tuiv2/state" "neo-code/internal/tuiv2/theme" @@ -108,16 +109,26 @@ func (c *CommandPrompt) View() string { } // handleInputKey 处理普通消息输入、历史切换、换行和提交。 +// +// 分层约定(plan-v4):模式切换键(Esc 进 Normal、i 在 Normal 下进 Input)已 +// 由 app 层 handleInputModeKey/handleNormalModeKey 拦截,不会到达本函数。 +// 本函数只负责编辑操作(字符插入、删除、光标移动、行编辑、发送、历史切换)。 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 "ctrl+a": + c.state.Input.Cursor = 0 + c.state.Input.CursorVisible = true + return nil + case "ctrl+e": + c.state.Input.Cursor = runeLen(c.state.Input.Text) + c.state.Input.CursorVisible = true + return nil + case "ctrl+k": + c.killToEnd() + return nil + case "ctrl+w": + c.deleteWordBack() + return nil case "left": c.moveCursor(-1) case "right": @@ -161,6 +172,49 @@ func (c *CommandPrompt) handleInputKey(msg tea.KeyMsg) tea.Cmd { return nil } +// killToEnd 删除从光标到行尾的所有字符(Ctrl+K,Readline 语义)。 +func (c *CommandPrompt) killToEnd() { + runes := []rune(c.state.Input.Text) + cursor := clampInt(c.state.Input.Cursor, 0, len(runes)) + if cursor >= len(runes) { + return + } + c.state.Input.Text = string(runes[:cursor]) + c.state.Input.CursorVisible = true + c.state.Input.HistoryIndex = -1 +} + +// deleteWordBack 删除光标前一个词(Ctrl+W,按空格与标点分词)。 +func (c *CommandPrompt) deleteWordBack() { + runes := []rune(c.state.Input.Text) + cursor := clampInt(c.state.Input.Cursor, 0, len(runes)) + if cursor == 0 { + return + } + // 跳过光标前的连续空白 + end := cursor + for end > 0 && isWordBoundary(runes[end-1]) { + end-- + } + // 跳过一个词的非边界字符 + for end > 0 && !isWordBoundary(runes[end-1]) { + end-- + } + c.state.Input.Text = string(append(append([]rune(nil), runes[:end]...), runes[cursor:]...)) + c.state.Input.Cursor = end + c.state.Input.CursorVisible = true + c.state.Input.HistoryIndex = -1 +} + +// isWordBoundary 判断 rune 是否为分词边界(空白与常见标点)。 +func isWordBoundary(r rune) bool { + switch r { + case ' ', '\t', '\n', '.', ',', ';', ':', '/', '\\', '(', ')', '[', ']', '{', '}', '"', '\'', '=', '-', '_': + return true + } + return false +} + // handlePermissionKey 处理权限模式的一键响应,不要求用户再按 Enter。 func (c *CommandPrompt) handlePermissionKey(msg tea.KeyMsg) tea.Cmd { switch strings.ToLower(msg.String()) { @@ -327,18 +381,46 @@ func (c *CommandPrompt) renderQuestionHint() string { } // modeLine 渲染输入模式、会话名和当前模型,并把右侧信息固定到行尾。 +// +// 左侧模式指示按当前模式着色:input=BaseStyle(FG)、normal=SubtleStyle、 +// leader=AccentStyle 加粗(不加闪烁,加粗已足够区分)。右侧会话与模型信息 +// 始终用 SubtleStyle。 func (c *CommandPrompt) modeLine() string { - left := fmt.Sprintf("[%s]", inputModeName(c.state.Mode)) - right := strings.TrimSpace(sessionTitle(c.state) + " " + stringOrDash(c.state.Gateway.ActiveModel)) + return c.renderModeLine() +} + +// ModeLine 导出 modeLine 渲染,供 App 在 Ex/Search overlay 时复用状态行。 +func (c *CommandPrompt) ModeLine() string { + return c.renderModeLine() +} + +// renderModeLine 是 modeLine 的实际实现,供 modeLine 与 ModeLine 共用。 +func (c *CommandPrompt) renderModeLine() string { + leftText := fmt.Sprintf("[%s]", inputModeName(c.state.Mode)) + rightText := strings.TrimSpace(sessionTitle(c.state) + " " + stringOrDash(c.state.Gateway.ActiveModel)) + leftStyled := modeIndicatorStyle(c.state.Mode).Render(leftText) + rightStyled := theme.SubtleStyle().Render(rightText) width := c.contentWidth() if width <= 0 { - return theme.SubtleStyle().Render(left + " " + right) + return leftStyled + " " + rightStyled } - gap := width - theme.DisplayWidth(left) - theme.DisplayWidth(right) + gap := width - theme.DisplayWidth(leftText) - theme.DisplayWidth(rightText) if gap < 1 { - return theme.SubtleStyle().Render(left + " " + right) + return leftStyled + " " + rightStyled + } + return leftStyled + strings.Repeat(" ", gap) + rightStyled +} + +// modeIndicatorStyle 根据输入模式返回模式指示器的样式。 +func modeIndicatorStyle(mode state.InputMode) lipgloss.Style { + switch mode { + case state.NormalMode: + return theme.SubtleStyle() + case state.LeaderMode: + return theme.AccentStyle().Bold(true) + default: // InputModeInput + return theme.BaseStyle() } - return theme.SubtleStyle().Render(left + strings.Repeat(" ", gap) + right) } // textWithCursor 返回在当前光标位置插入闪烁光标后的文本。 diff --git a/internal/tuiv2/components/prompt_keys_test.go b/internal/tuiv2/components/prompt_keys_test.go index 28fdae55..a3660a3f 100644 --- a/internal/tuiv2/components/prompt_keys_test.go +++ b/internal/tuiv2/components/prompt_keys_test.go @@ -176,3 +176,80 @@ func TestCommandPromptMessageLinesHelpers(t *testing.T) { t.Fatal("contentWidth fallback wrong") } } + +// TestCommandPromptCtrlEditing 覆盖 Ctrl+A/E/K/W 行编辑能力。 +func TestCommandPromptCtrlEditing(t *testing.T) { + vs := promptState() + vs.Input.Mode = state.InputStateModeMessage + vs.Mode = state.InputModeInput + p := NewCommandPrompt(vs) + + // 输入 "hello world",光标在末尾(11) + p.insertText("hello world") + + // Ctrl+A → 光标到行首 + p.Update(keyMsg("ctrl+a")) + if vs.Input.Cursor != 0 { + t.Fatalf("ctrl+a cursor=%d, want 0", vs.Input.Cursor) + } + + // Ctrl+E → 光标到行尾 + p.Update(keyMsg("ctrl+e")) + if vs.Input.Cursor != runeLen("hello world") { + t.Fatalf("ctrl+e cursor=%d, want %d", vs.Input.Cursor, runeLen("hello world")) + } + + // Ctrl+K 在行尾不删除;移到中间再删到行尾 + p.Update(keyMsg("ctrl+a")) + p.moveCursor(6) // 光标到 "hello " 之后(6),即 "world" 之前 + p.Update(keyMsg("ctrl+k")) + if vs.Input.Text != "hello " { + t.Fatalf("ctrl+k text=%q, want \"hello \"", vs.Input.Text) + } + + // 重新输入 "foo bar baz" 测 Ctrl+W 删词 + vs.Input.Text = "" + vs.Input.Cursor = 0 + p.insertText("foo bar baz") + p.Update(keyMsg("ctrl+w")) // 删 "baz" + if vs.Input.Text != "foo bar " { + t.Fatalf("ctrl+w text=%q, want \"foo bar \"", vs.Input.Text) + } + p.Update(keyMsg("ctrl+w")) // 删 "bar"(先跳过尾部空格) + if vs.Input.Text != "foo " { + t.Fatalf("ctrl+w text=%q, want \"foo \"", vs.Input.Text) + } + + // isWordBoundary 边界字符 + if !isWordBoundary(' ') || isWordBoundary('a') { + t.Fatal("isWordBoundary wrong") + } +} + +// TestModeLineIndicatorColors 验证模式指示器按模式着色。 +func TestModeLineIndicatorColors(t *testing.T) { + // input → BaseStyle + vs := promptState() + vs.Mode = state.InputModeInput + p := NewCommandPrompt(vs) + if v := p.modeLine(); v == "" { + t.Fatal("input modeLine empty") + } + // normal → SubtleStyle + vs.Mode = state.NormalMode + if v := p.modeLine(); v == "" { + t.Fatal("normal modeLine empty") + } + // leader → AccentStyle 加粗 + vs.Mode = state.LeaderMode + if v := p.modeLine(); v == "" { + t.Fatal("leader modeLine empty") + } + // modeIndicatorStyle 返回正确类型(非空 Style,通过是否可 Render 验证) + for _, m := range []state.InputMode{state.InputModeInput, state.NormalMode, state.LeaderMode} { + s := modeIndicatorStyle(m) + if s.Render("x") == "" { + t.Fatalf("modeIndicatorStyle(%v) render empty", m) + } + } +} diff --git a/internal/tuiv2/components/session_picker.go b/internal/tuiv2/components/session_picker.go index bcc78a74..db4aeabd 100644 --- a/internal/tuiv2/components/session_picker.go +++ b/internal/tuiv2/components/session_picker.go @@ -55,7 +55,7 @@ func (s *SessionPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (s *SessionPicker) handleKey(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "esc", "ctrl+c": - s.state.Overlay.Active = "" + s.state.Overlay.Active = state.OverlayNone s.state.Overlay.Query = "" s.state.Overlay.Selected = 0 return nil @@ -69,7 +69,7 @@ func (s *SessionPicker) handleKey(msg tea.KeyMsg) tea.Cmd { idx = len(matched) - 1 } selected := matched[idx] - s.state.Overlay.Active = "" + s.state.Overlay.Active = state.OverlayNone s.state.Overlay.Query = "" s.state.Overlay.Selected = 0 return func() tea.Msg { @@ -85,7 +85,7 @@ func (s *SessionPicker) handleKey(msg tea.KeyMsg) tea.Cmd { idx = len(matched) - 1 } target := matched[idx] - s.state.Overlay.Active = "" + s.state.Overlay.Active = state.OverlayNone return func() tea.Msg { return SessionDeleteMsg{SessionID: target.ID} } @@ -158,7 +158,7 @@ func (s *SessionPicker) handleMouse(msg tea.MouseMsg) tea.Cmd { matched := s.matchedSessions() if itemIdx >= 0 && itemIdx < len(matched) { selected := matched[itemIdx] - s.state.Overlay.Active = "" + s.state.Overlay.Active = state.OverlayNone s.state.Overlay.Query = "" s.state.Overlay.Selected = 0 return func() tea.Msg { diff --git a/internal/tuiv2/components/stream.go b/internal/tuiv2/components/stream.go index 7f8255ed..6bed2092 100644 --- a/internal/tuiv2/components/stream.go +++ b/internal/tuiv2/components/stream.go @@ -63,6 +63,16 @@ func (c *AgentStream) Update(msg tea.Msg) (tea.Model, tea.Cmd) { halfPage := c.halfPageSize() c.state.Layout.ScrollOffset = clampScroll(c.state.Layout.ScrollOffset-halfPage, maxOffset) c.state.Layout.AutoScroll = c.state.Layout.ScrollOffset == 0 + case "ctrl+b": + // 整页上翻,步长为可见行数(vim Ctrl+B 语义)。 + fullPage := c.visibleLineCount() + c.state.Layout.ScrollOffset = clampScroll(c.state.Layout.ScrollOffset+fullPage, maxOffset) + c.state.Layout.AutoScroll = false + case "ctrl+f": + // 整页下翻,步长为可见行数(vim Ctrl+F 语义)。 + fullPage := c.visibleLineCount() + c.state.Layout.ScrollOffset = clampScroll(c.state.Layout.ScrollOffset-fullPage, maxOffset) + c.state.Layout.AutoScroll = c.state.Layout.ScrollOffset == 0 } return c, nil } diff --git a/internal/tuiv2/components/stream_test.go b/internal/tuiv2/components/stream_test.go index 6f95b140..5011da48 100644 --- a/internal/tuiv2/components/stream_test.go +++ b/internal/tuiv2/components/stream_test.go @@ -80,6 +80,39 @@ func TestAgentStreamManualAndAutoScroll(t *testing.T) { } } +// TestAgentStreamFullPageScroll 覆盖 Ctrl+F/Ctrl+B 整页翻页。 +func TestAgentStreamFullPageScroll(t *testing.T) { + viewState := state.NewViewState() + viewState.Layout.Width = 80 + viewState.Layout.Height = 12 + viewState.Stream = numberedEntries(40) + stream := NewAgentStream(viewState) + + // Ctrl+B 在底部(自动滚动, offset=0)上翻一页 → offset 增加 + _, _ = stream.Update(keyMsg("ctrl+b")) + if viewState.Layout.AutoScroll { + t.Fatal("ctrl+b should disable AutoScroll") + } + offsetAfterB := viewState.Layout.ScrollOffset + if offsetAfterB == 0 { + t.Fatal("ctrl+b should increase ScrollOffset") + } + + // Ctrl+F 下翻一页 → offset 减少 + _, _ = stream.Update(keyMsg("ctrl+f")) + if viewState.Layout.ScrollOffset >= offsetAfterB { + t.Fatalf("ctrl+f should decrease offset: before=%d after=%d", offsetAfterB, viewState.Layout.ScrollOffset) + } + + // 空流下 ctrl+f/b 不崩溃 + emptyVS := state.NewViewState() + emptyVS.Layout.Width = 80 + emptyVS.Layout.Height = 10 + emptyStream := NewAgentStream(emptyVS) + _, _ = emptyStream.Update(keyMsg("ctrl+f")) + _, _ = emptyStream.Update(keyMsg("ctrl+b")) +} + func TestAgentStreamWidthIsSafe(t *testing.T) { viewState := state.NewViewState() viewState.Layout.Width = 40 diff --git a/internal/tuiv2/keymap/keys.go b/internal/tuiv2/keymap/keys.go index 3ab7412c..416bd261 100644 --- a/internal/tuiv2/keymap/keys.go +++ b/internal/tuiv2/keymap/keys.go @@ -21,30 +21,33 @@ const ( ActionLogViewer // Ctrl+L // 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) + ActionEnterInput // i + ActionScrollDown // j + ActionScrollUp // k + ActionHalfPageDown // Ctrl+D + ActionHalfPageUp // Ctrl+U + ActionFullPageDown // Ctrl+F 整页下翻 + ActionFullPageUp // Ctrl+B 整页上翻 + ActionScrollTop // g + ActionScrollBottom // G + ActionSearchForward // / + 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 + ActionLeaderModelPicker // Space m 模型选择器 ActionLeaderFullAccess // Space f ActionLeaderLog // Space l - ActionLeaderCompact // Space c + ActionLeaderCancelRun // Space c 取消当前运行 + ActionLeaderRetry // Space r 重试上次运行 + ActionLeaderLastSession // Space Space 切换上一会话 ActionLeaderQuit // Space q ) @@ -64,8 +67,9 @@ type HelpGroup struct { func InputBindings() []key.Binding { return []key.Binding{ key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "send message")), - // Shift+Enter 在多数终端里与 Enter 不可区分(bubbletea 也未映射), - // 这里登记可用的 Alt+Enter / Ctrl+J 作为换行键。 + // Shift+Enter 仅在支持 kitty keyboard protocol 的终端(Kitty/WezTerm/Alacritty) + // 可与 Enter 区分;多数终端(GNOME Terminal、tmux、screen、VS Code)不可区分, + // 这里登记可用的 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")), @@ -80,7 +84,11 @@ func InputHelp() []HelpGroup { Title: "Input Mode", Entries: []HelpEntry{ {Key: "Enter", Desc: "Send message"}, - {Key: "Alt+Enter / Ctrl+J", Desc: "New line"}, + {Key: "Alt+Enter / Ctrl+J", Desc: "New line (Shift+Enter 仅 kitty 协议终端可用)"}, + {Key: "Ctrl+A / Ctrl+E", Desc: "行首 / 行尾"}, + {Key: "Ctrl+K", Desc: "删除到行尾"}, + {Key: "Ctrl+W", Desc: "删除前一个词"}, + {Key: "Ctrl+D", Desc: "空输入时 EOF 退出;非空时删除光标后字符"}, {Key: "Ctrl+C", Desc: "Cancel agent (double to quit)"}, {Key: "Ctrl+P", Desc: "Command palette"}, {Key: "?", Desc: "This help"}, @@ -100,7 +108,7 @@ func NormalHelp() []HelpGroup { Entries: []HelpEntry{ {Key: "i", Desc: "Enter Input Mode"}, {Key: "/", Desc: "Search in stream"}, - {Key: ":", Desc: "Command line"}, + {Key: ":", Desc: "Command line (:q / :debug / :compact / :mode)"}, {Key: "q", Desc: "Quit"}, }, }, @@ -116,12 +124,15 @@ func LeaderHelp() []HelpGroup { {Key: "Space p", Desc: "Command palette"}, {Key: "Space n", Desc: "New session"}, {Key: "Space s", Desc: "Switch session"}, + {Key: "Space m", Desc: "Model picker"}, {Key: "Space h", Desc: "Help"}, - {Key: "Space m", Desc: "Toggle Agent mode (build/plan)"}, + {Key: "Space r", Desc: "Retry last run"}, + {Key: "Space c", Desc: "Cancel current run"}, + {Key: "Space Space", Desc: "Switch to last session"}, {Key: "Space f", Desc: "Toggle Full Access"}, {Key: "Space l", Desc: "Log viewer"}, - {Key: "Space c", Desc: "Manual compact"}, {Key: "Space q", Desc: "Quit"}, + {Key: "---", Desc: "已移除:compact → :compact,toggle-mode → :mode"}, }, }, } @@ -135,7 +146,9 @@ func NavigationHelp() []HelpGroup { Entries: []HelpEntry{ {Key: "j / k", Desc: "Scroll down / up"}, {Key: "Ctrl+D / U", Desc: "Half-page down / up"}, + {Key: "Ctrl+F / B", Desc: "Full-page down / up"}, {Key: "g / G", Desc: "Jump to top / bottom"}, + {Key: "n / N", Desc: "Search next / previous (循环)"}, {Key: "Mouse wheel", Desc: "Scroll"}, }, }, @@ -153,6 +166,10 @@ func FullHelp() []HelpGroup { } // MatchInputKey 匹配 Input Mode 按键到动作。 +// +// 注意:ctrl+d 不在此函数映射。Input Mode 下 Ctrl+D 的行为依赖输入框是否为空 +// (空 → EOF 退出程序;非空 → 删除光标后字符),由 app.handleInputModeKey 层 +// 按上下文决定。这里不映射可避免 keymap 层对上下文语义做错误假设。 func MatchInputKey(keyStr string) Action { switch keyStr { case "enter": @@ -184,6 +201,10 @@ func MatchNormalKey(keyStr string) Action { return ActionHalfPageDown case "ctrl+u": return ActionHalfPageUp + case "ctrl+f": + return ActionFullPageDown + case "ctrl+b": + return ActionFullPageUp case "g": return ActionScrollTop case "G": @@ -218,13 +239,17 @@ func MatchLeaderKey(keyStr string) Action { case "h": return ActionLeaderHelp case "m": - return ActionLeaderToggleMode + return ActionLeaderModelPicker case "f": return ActionLeaderFullAccess case "l": return ActionLeaderLog case "c": - return ActionLeaderCompact + return ActionLeaderCancelRun + case "r": + return ActionLeaderRetry + case " ", "space": + return ActionLeaderLastSession case "q": return ActionLeaderQuit } diff --git a/internal/tuiv2/keymap/keys_test.go b/internal/tuiv2/keymap/keys_test.go index 30a37bd5..c3cd338b 100644 --- a/internal/tuiv2/keymap/keys_test.go +++ b/internal/tuiv2/keymap/keys_test.go @@ -12,6 +12,9 @@ func TestMatchInputKey(t *testing.T) { {"esc", ActionEscape}, {"ctrl+c", ActionCtrlC}, {"ctrl+p", ActionOpenPalette}, + {"ctrl+l", ActionLogViewer}, + // ctrl+d 不在 MatchInputKey 映射(由 app 层按输入框空否决定)。 + {"ctrl+d", ActionNone}, {"a", ActionNone}, {"j", ActionNone}, } @@ -33,6 +36,8 @@ func TestMatchNormalKey(t *testing.T) { {"k", ActionScrollUp}, {"ctrl+d", ActionHalfPageDown}, {"ctrl+u", ActionHalfPageUp}, + {"ctrl+f", ActionFullPageDown}, + {"ctrl+b", ActionFullPageUp}, {"g", ActionScrollTop}, {"G", ActionScrollBottom}, {"/", ActionSearchForward}, @@ -62,10 +67,13 @@ func TestMatchLeaderKey(t *testing.T) { {"n", ActionLeaderNewSession}, {"s", ActionLeaderSwitchSession}, {"h", ActionLeaderHelp}, - {"m", ActionLeaderToggleMode}, + {"m", ActionLeaderModelPicker}, {"f", ActionLeaderFullAccess}, {"l", ActionLeaderLog}, - {"c", ActionLeaderCompact}, + {"c", ActionLeaderCancelRun}, + {"r", ActionLeaderRetry}, + {" ", ActionLeaderLastSession}, + {"space", ActionLeaderLastSession}, {"q", ActionLeaderQuit}, {"a", ActionNone}, {"j", ActionNone}, @@ -85,6 +93,12 @@ func TestIsLeaderSuffix(t *testing.T) { if !IsLeaderSuffix("q") { t.Error("IsLeaderSuffix(\"q\") = false, want true") } + if !IsLeaderSuffix("r") { + t.Error("IsLeaderSuffix(\"r\") = false, want true") + } + if !IsLeaderSuffix(" ") { + t.Error("IsLeaderSuffix(\" \") = false, want true") + } if IsLeaderSuffix("a") { t.Error("IsLeaderSuffix(\"a\") = true, want false") } @@ -105,3 +119,15 @@ func TestFullHelpContainsAllGroups(t *testing.T) { } } } + +// TestHelpEntriesConsistent 校验帮助文案不出现与规划冲突的描述(如 g g 双键)。 +func TestHelpEntriesConsistent(t *testing.T) { + for _, group := range FullHelp() { + for _, entry := range group.Entries { + // 不应出现 "g g" 双键描述(已改为 g 单键)。 + if entry.Key == "g g" { + t.Errorf("help entry still references \"g g\": %+v", entry) + } + } + } +} diff --git a/internal/tuiv2/state/reducer.go b/internal/tuiv2/state/reducer.go index bd92d91c..33333c8d 100644 --- a/internal/tuiv2/state/reducer.go +++ b/internal/tuiv2/state/reducer.go @@ -169,6 +169,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...) + next.Search.Matches = append([]int(nil), current.Search.Matches...) return &next } diff --git a/internal/tuiv2/state/viewstate.go b/internal/tuiv2/state/viewstate.go index 19f443ca..51af6241 100644 --- a/internal/tuiv2/state/viewstate.go +++ b/internal/tuiv2/state/viewstate.go @@ -17,13 +17,56 @@ type ViewState struct { Mode InputMode Overlay OverlayState Confirm ConfirmState + Search SearchState + Ex ExState } +// OverlayType 描述当前激活的浮层类型,所有引用必须使用常量,禁止散落字符串字面量。 +type OverlayType string + +const ( + // OverlayNone 表示无浮层激活。 + OverlayNone OverlayType = "" + // OverlayPalette 命令面板。 + OverlayPalette OverlayType = "palette" + // OverlayHelp 快捷键帮助。 + OverlayHelp OverlayType = "help" + // OverlaySessionPicker 会话选择器。 + OverlaySessionPicker OverlayType = "session_picker" + // OverlayModelPicker 模型选择器。 + OverlayModelPicker OverlayType = "model_picker" + // OverlayConfirm 危险操作确认弹窗。 + OverlayConfirm OverlayType = "confirm" + // OverlayEx Ex 命令行输入(: 前缀)。 + OverlayEx OverlayType = "ex" + // OverlaySearch 搜索输入(/ 前缀)。 + OverlaySearch OverlayType = "search" +) + // OverlayState 描述当前浮层显示状态。 type OverlayState struct { - Active string // "", "palette", "help", "session_picker", "model_picker", "confirm" - Query string // 搜索文本 - Selected int // 当前选中索引 + Active OverlayType // 使用 OverlayXxx 常量,禁止字面量 + Query string // 搜索文本 + Selected int // 当前选中索引 +} + +// SearchState 描述 Normal Mode 下的 stream 搜索状态。 +// +// Matches 存放命中的 StreamEntry 全局索引;由于 state.Stream 是 append-only +// (只在末尾追加),这些索引一旦计算即永久有效,不会因后续追加而错乱。 +// Stale 在 stream 增长后置位,提示用户当前 Matches 不含新增内容,需重新搜索。 +type SearchState struct { + Active bool + Query string + Matches []int // state.Stream 的全局索引(append-only 保证稳定) + MatchIndex int + Stale bool +} + +// ExState 描述 Normal Mode 下 : 命令行的输入状态。 +type ExState struct { + Active bool + Input string } // GatewayState 描述 Gateway 连接、会话和模型选择状态。