Current section
Files
Jump to
Current section
Files
lib/cmdc_tui/app.ex
defmodule CmdcTui.App do
@moduledoc """
CMDC TUI 主应用模块 — ExRatatui.App behaviour 实现。
使用 ExRatatui.Server(GenServer)管理终端事件循环,
`poll_event` 在 DirtyIO scheduler 上阻塞,不影响 `handle_info` 接收 CMDC 事件,
按键到达后立刻 dispatch → render,零额外延迟。
## 布局
```
┌────────────────────────────┬───────────────┐
│ │ ToolPanel │
│ ChatPanel │ /TodoPanel │
│ (messages + textarea) │ (Tab 切换) │
│ │ │
├────────────────────────────┴───────────────┤
│ StatusBar │
└─────────────────────────────────────────────┘
```
## State 管理
集中式 state,Panel 模块为纯渲染函数,不持有独立状态。
EventBus 事件通过 `handle_info` 统一更新 state 并触发 re-render。
"""
use ExRatatui.App
alias ExRatatui.{Layout, Style}
alias ExRatatui.Layout.Rect
alias ExRatatui.Widgets.{Block, Clear, Paragraph, Textarea}
alias CmdcTui.{
Panels.ChatPanel,
Panels.ToolPanel,
Panels.TodoPanel,
Panels.StatusBar,
Dialogs.ApprovalDialog,
Dialogs.AskUserDialog,
Dialogs.CommandPalette,
Dialogs.SlashAutocomplete
}
@sidebar_width_ratio 30
@status_bar_height 1
@input_height 4
# ==========================================================================
# ExRatatui.App Callbacks
# ==========================================================================
@impl true
def mount(opts) do
textarea = ExRatatui.textarea_new()
opts = Keyword.put(opts, :textarea, textarea)
state = CmdcTui.State.new(opts)
if state.session do
CMDC.subscribe(state.session)
end
{:ok, state}
end
@impl true
def render(state, frame) do
state = tick_throbber(state)
area = %Rect{x: 0, y: 0, width: frame.width, height: frame.height}
if frame.width < 40 or frame.height < 10 do
render_too_small(area)
else
render_layout(state, area)
end
end
@impl true
def handle_event(%ExRatatui.Event.Key{code: "c", modifiers: ["ctrl"]}, state) do
{:stop, state}
end
def handle_event(%ExRatatui.Event.Key{} = key, state) do
case CmdcTui.KeyHandler.handle(key, state) do
{:stop, state} ->
{:stop, state}
{:noreply, state} ->
# 处理完第一个按键后,非阻塞消费所有积压的后续按键,
# 这样 Server 只渲染一帧,避免每键一帧的延迟
{:noreply, drain_pending_keys(state)}
end
end
def handle_event(%ExRatatui.Event.Resize{}, state) do
{:noreply, state}
end
def handle_event(_event, state) do
{:noreply, state}
end
@impl true
def handle_info({:cmdc_event, _session_id, event}, state) do
state = CmdcTui.EventRouter.route(event, state)
{:noreply, state}
end
def handle_info(_msg, state), do: {:noreply, state}
@impl true
def terminate(_reason, state) do
if state.session do
CMDC.unsubscribe(state.session)
end
:ok
end
# ==========================================================================
# Layout
# ==========================================================================
defp render_layout(state, area) do
[main_area, status_area] =
Layout.split(area, :vertical, [
{:min, 8},
{:length, @status_bar_height}
])
show_sidebar = area.width >= 80
base_widgets =
if show_sidebar do
render_with_sidebar(state, main_area, status_area)
else
render_no_sidebar(state, main_area, status_area)
end
dialog_widgets =
ApprovalDialog.render(state, area) ++
AskUserDialog.render(state, area) ++
CommandPalette.render(state, area) ++
SlashAutocomplete.render(state, area)
[{%Clear{}, area} | base_widgets ++ dialog_widgets]
end
defp render_with_sidebar(state, main_area, status_area) do
[chat_area, sidebar_area] =
Layout.split(main_area, :horizontal, [
{:percentage, 100 - @sidebar_width_ratio},
{:percentage, @sidebar_width_ratio}
])
chat_widgets = render_chat_area(state, chat_area)
sidebar_widgets = render_sidebar(state, sidebar_area)
status_widgets = StatusBar.render(state, status_area)
chat_widgets ++ sidebar_widgets ++ status_widgets
end
defp render_no_sidebar(state, main_area, status_area) do
chat_widgets = render_chat_area(state, main_area)
status_widgets = StatusBar.render(state, status_area)
chat_widgets ++ status_widgets
end
defp render_chat_area(state, area) do
[messages_area, input_area] =
Layout.split(area, :vertical, [
{:min, 3},
{:length, @input_height}
])
message_widgets = ChatPanel.render_messages(state, messages_area)
input_widgets = render_input(state, input_area)
message_widgets ++ input_widgets
end
defp render_input(state, area) do
if is_nil(state.textarea) do
placeholder = %Paragraph{
text: "输入消息... (Enter 发送, Shift+Enter 换行, / 命令)",
style: %Style{fg: :dark_gray}
}
[{placeholder, area}]
else
textarea = %Textarea{
state: state.textarea,
style: %Style{fg: :white},
cursor_style: %Style{bg: :white, fg: :black},
placeholder: "输入消息... (Enter 发送, Shift+Enter 换行, / 命令)",
placeholder_style: %Style{fg: :dark_gray},
block: %Block{
title: input_title(state),
borders: [:all],
border_type: :rounded,
border_style: input_border_style(state)
}
}
[{textarea, area}]
end
end
defp input_title(%{agent_status: :streaming}), do: "输入 (streaming...)"
defp input_title(%{agent_status: :running}), do: "输入 (thinking...)"
defp input_title(_state), do: "输入"
defp input_border_style(%{agent_status: s}) when s in [:streaming, :running],
do: %Style{fg: :yellow}
defp input_border_style(_state), do: %Style{fg: :blue}
defp render_sidebar(state, area) do
case state.sidebar_tab do
:tools -> ToolPanel.render(state, area)
:todos -> TodoPanel.render(state, area)
end
end
defp render_too_small(area) do
msg = %Paragraph{
text: "终端过小,请调整窗口大小 (需要 ≥ 40×10)",
style: %Style{fg: :red, modifiers: [:bold]}
}
[{msg, area}]
end
# ==========================================================================
# Private Helpers
# ==========================================================================
defp tick_throbber(%{agent_status: s} = state) when s in [:streaming, :running] do
%{state | throbber_step: state.throbber_step + 1}
end
defp tick_throbber(state), do: state
# 非阻塞排尽所有积压的按键事件,合并到同一帧渲染
defp drain_pending_keys(state) do
case ExRatatui.poll_event(0) do
%ExRatatui.Event.Key{code: "c", modifiers: ["ctrl"]} ->
state
%ExRatatui.Event.Key{} = key ->
case CmdcTui.KeyHandler.handle(key, state) do
{:noreply, new_state} -> drain_pending_keys(new_state)
{:stop, new_state} -> new_state
end
_ ->
state
end
end
end