Packages

CMDC TUI — 基于 ExRatatui 的 AI Agent 终端交互界面

Current section

Files

Jump to
cmdc_tui lib cmdc_tui panels status_bar.ex
Raw

lib/cmdc_tui/panels/status_bar.ex

defmodule CmdcTui.Panels.StatusBar do
@moduledoc """
底部状态栏 — 三区域布局:左(状态 + Throbber)/ 中(session + model)/ 右(快捷键)。
## 宽度适应
- ≥80 列:三区域全显
- 40~79 列:隐藏快捷键区域
- <40 列:仅显示状态指示
"""
alias ExRatatui.{Layout, Style}
alias ExRatatui.Layout.Rect
alias ExRatatui.Widgets.{Paragraph, Throbber}
@doc "渲染状态栏(返回 1~3 个 widget)。"
@spec render(CmdcTui.State.t(), Rect.t()) :: [{ExRatatui.widget(), Rect.t()}]
def render(state, area) do
cond do
area.width >= 80 -> render_full(state, area)
area.width >= 40 -> render_compact(state, area)
true -> render_minimal(state, area)
end
end
# ==========================================================================
# 三区域完整布局
# ==========================================================================
defp render_full(state, area) do
[left_area, mid_area, right_area] =
Layout.split(area, :horizontal, [
{:percentage, 30},
{:percentage, 40},
{:percentage, 30}
])
left_widgets = render_left(state, left_area)
mid_widget = render_mid(state, mid_area)
right_widget = render_right(right_area)
left_widgets ++ [mid_widget, right_widget]
end
# -----
# 左侧:状态 + Throbber(active 时)
# -----
defp render_left(%{agent_status: s} = state, area) when s in [:running, :streaming] do
[throbber_area, text_area] =
Layout.split(area, :horizontal, [
{:length, 3},
{:min, 1}
])
throbber = %Throbber{
label: "",
step: state.throbber_step,
throbber_set: :braille,
style: %Style{fg: :yellow},
throbber_style: %Style{fg: :yellow, modifiers: [:bold]}
}
label_text = if s == :streaming, do: " streaming", else: " thinking…"
label = %Paragraph{
text: label_text,
style: %Style{fg: :yellow}
}
[{throbber, throbber_area}, {label, text_area}]
end
defp render_left(_state, area) do
widget = %Paragraph{
text: " ● idle",
style: %Style{fg: :dark_gray}
}
[{widget, area}]
end
# -----
# 中间:token / cost / session_id / model
# -----
defp render_mid(state, area) do
tokens = format_number(state.total_tokens)
cost = format_cost(state.cost_usd)
mid_text = " #{tokens} tok $#{cost}"
extra =
cond do
state.model_name ->
" │ #{shorten_model(state.model_name)}"
state.session_id ->
" │ #{String.slice(state.session_id, 0, 12)}"
true ->
""
end
widget = %Paragraph{
text: mid_text <> extra,
style: %Style{fg: :dark_gray}
}
{widget, area}
end
# -----
# 右侧:快捷键提示
# -----
defp render_right(area) do
widget = %Paragraph{
text: "Enter:发送 S-Enter:换行 Tab:切换 Ctrl+C:退出 ",
style: %Style{fg: :dark_gray},
alignment: :right
}
{widget, area}
end
# ==========================================================================
# 精简布局(隐藏右侧)
# ==========================================================================
defp render_compact(state, area) do
[left_area, right_area] =
Layout.split(area, :horizontal, [
{:percentage, 40},
{:percentage, 60}
])
left_widgets = render_left(state, left_area)
mid = render_mid(state, right_area)
left_widgets ++ [mid]
end
# ==========================================================================
# 最小布局
# ==========================================================================
defp render_minimal(state, area) do
text = status_text(state)
widget = %Paragraph{
text: text,
style: %Style{fg: :dark_gray, bg: :black}
}
[{widget, area}]
end
defp status_text(%{agent_status: :streaming}), do: " ◉ streaming"
defp status_text(%{agent_status: :running}), do: " ◉ thinking"
defp status_text(_), do: " ● idle"
# ==========================================================================
# 格式化辅助
# ==========================================================================
defp format_number(n) when n >= 1_000_000, do: "#{div(n, 1_000_000)}M"
defp format_number(n) when n >= 1_000, do: "#{Float.round(n / 1000.0, 1)}K"
defp format_number(n), do: "#{n}"
defp format_cost(cost) when cost < 0.0001, do: "0.0000"
defp format_cost(cost), do: :erlang.float_to_binary(cost, decimals: 4)
# anthropic:claude-sonnet-4-5 → sonnet-4-5
defp shorten_model(name) do
name
|> String.split(":")
|> List.last()
|> String.replace("claude-", "")
|> String.slice(0, 14)
end
end